diff --git a/src/cli/dashboard-control.ts b/src/cli/dashboard-control.ts index 36970b1..4a7b0b3 100644 --- a/src/cli/dashboard-control.ts +++ b/src/cli/dashboard-control.ts @@ -84,6 +84,35 @@ export async function dispatchAction(name: string, params: any, cwd: string): Pr await writeConfigKnob(String(params.key), v.coerced); return { ok: true, message: `Set ${params.key} = ${v.coerced}. Takes effect on the next run.` }; } + case 'model.set': { + const model = String(params?.model ?? '').trim(); + if (!model) return { ok: false, message: 'Pick a model.' }; + await writeConfigKnob('defaults.model', model); + return { ok: true, message: `Default model → ${model}. Takes effect on the next run.` }; + } + case 'memory.add': { + const fact = String(params?.fact ?? '').trim(); + if (!fact) return { ok: false, message: 'Nothing to remember.' }; + const scope = params?.scope === 'user' ? 'user' : 'project'; + const { getSessionStore } = await import('../session/store.js'); + getSessionStore().addFact('dashboard', cwd, fact, scope); + try { const { exportMemory } = await import('../context/memory-mirror.js'); await exportMemory(cwd); } catch { /* mirror best-effort */ } + return { ok: true, message: `Remembered (${scope}): ${fact.slice(0, 60)}` }; + } + case 'skill.promote': { + const name = String(params?.name ?? '').trim(); + if (!name) return { ok: false, message: 'No candidate named.' }; + const { promoteCandidate } = await import('../skills/learning/candidate-store.js'); + const r = await promoteCandidate(name, cwd); + return r.promoted ? { ok: true, message: `Promoted "${name}".` } : { ok: false, message: r.reason ?? 'Promotion blocked.' }; + } + case 'skill.reject': { + const name = String(params?.name ?? '').trim(); + if (!name) return { ok: false, message: 'No candidate named.' }; + const { archiveCandidate } = await import('../skills/learning/candidate-store.js'); + const ok = await archiveCandidate(name); + return ok ? { ok: true, message: `Rejected "${name}".` } : { ok: false, message: 'No such candidate.' }; + } case 'memory.forget': { const sub = String(params?.substring ?? '').trim(); if (!sub) return { ok: false, message: 'Provide a substring to forget.' }; diff --git a/src/cli/dashboard.ts b/src/cli/dashboard.ts index 3fbace6..b087e5a 100644 --- a/src/cli/dashboard.ts +++ b/src/cli/dashboard.ts @@ -19,6 +19,8 @@ export interface DashboardData { skills: { name: string; description: string }[]; controls: { path: string; label: string; group: string; type: 'bool' | 'enum'; values?: string[]; current: string }[]; schedules: { id: string; name: string; cron: string; enabled: boolean; recipe?: string }[]; + models: string[]; + candidates: { name: string; description: string; confidence?: number }[]; totals: { sessions: number; tokens: number; cost: number; facts: number; episodes: number; skills: number }; } @@ -58,6 +60,16 @@ export function buildDashboardHtml(d: DashboardData, opts: { token?: string } = ${live ? ` ` : `${s.enabled ? 'enabled' : 'disabled'}`} `).join('') : 'No scheduled tasks. Add one with `qodex schedule add`.'; const schedulePanel = `

Scheduled tasks

${scheduleRows}
taskcron${live ? 'actions' : 'state'}
`; + + // Model switcher (live: a dropdown of known models; read-only: just the current one). + const modelCtl = live && d.models.length + ? `` + : `${esc(d.model)}`; + const modelPanel = `
Default model${modelCtl}
`; + + // Quarantined skill candidates — promote (independent-judge-passed) or reject from here. + const candRows = d.candidates.length ? d.candidates.map(c => `
  • ${esc(c.name)}${c.confidence != null ? ` conf ${c.confidence}` : ''} — ${esc(c.description)}${live ? ` ` : ''}
  • `).join('') : '
  • No candidates in quarantine.
  • '; + const candidatePanel = `

    Skill candidates (quarantine)

    `; const providerRows = d.providers.map(p => ` ${p.isDefault ? '⭐ ' : ''}${esc(p.name)} ${esc(p.baseUrl || '—')} @@ -116,15 +128,17 @@ export function buildDashboardHtml(d: DashboardData, opts: { token?: string } =

    Tokens per recent session

    ${live ? '' : '

    Read-only snapshot. Run qodex dashboard (server mode) for live controls.

    '} + ${modelPanel}
    ${controlPanel}
    ${schedulePanel}

    Providers & models

    ${providerRows}
    ProviderBase URLModelsAPI key

    Recent sessions

    ${sessionRows}
    idtitlemodelturnstokenscostwhen
    -

    Memory · learned facts

    +

    Memory · learned facts

    ${live ? `
    ` : ''}

    Episodic memory · past tasks

    Skills

    + ${candidatePanel}

    Generated by qodex dashboard — all data is local, under ~/.qodex/.

    @@ -208,10 +222,21 @@ export async function gatherDashboardData(cwd: string): Promise { try { const { getScheduleStore } = await import('../schedule/store.js'); return getScheduleStore().list().map(s => ({ id: s.id, name: s.name, cron: s.cron, enabled: !!s.enabled, recipe: s.recipe })); } catch { return []; } })(); + // Known models (for the model switcher) = every model the configured providers expose + the current default. + const models = (() => { + const set = new Set(); + if (defModel && defModel !== '(unset)') set.add(defModel); + for (const p of providers) for (const m of p.models) set.add(m); + return [...set]; + })(); + const candidates = await (async () => { + try { const { listCandidates } = await import('../skills/learning/candidate-store.js'); return (await listCandidates()).map(c => ({ name: c.name, description: (c.description ?? '').slice(0, 90), confidence: c.confidence })); } + catch { return []; } + })(); return { project, model: defModel, generatedAt: new Date().toISOString().slice(0, 16).replace('T', ' '), - providers, sessions, facts, episodes, skills, controls, schedules, + providers, sessions, facts, episodes, skills, controls, schedules, models, candidates, totals: { sessions: sessions.length, tokens: sessions.reduce((a, s) => a + s.tokens, 0), cost: sessions.reduce((a, s) => a + s.cost, 0), facts: facts.length, episodes: episodes.length, skills: skills.length, diff --git a/test/dashboard-control.test.ts b/test/dashboard-control.test.ts index 929f47d..aef0b63 100644 --- a/test/dashboard-control.test.ts +++ b/test/dashboard-control.test.ts @@ -50,6 +50,12 @@ describe('dispatchAction — unknown + validation rejection (no disk writes)', ( it('memory.forget needs a substring', async () => { expect((await dispatchAction('memory.forget', { substring: '' }, '/tmp')).ok).toBe(false); }); + it('the second-batch actions validate their input before doing anything', async () => { + expect((await dispatchAction('model.set', { model: '' }, '/tmp')).ok).toBe(false); + expect((await dispatchAction('memory.add', { fact: ' ' }, '/tmp')).ok).toBe(false); + expect((await dispatchAction('skill.promote', { name: '' }, '/tmp')).ok).toBe(false); + expect((await dispatchAction('skill.reject', { name: '' }, '/tmp')).ok).toBe(false); + }); }); describe('handleRequest — token auth + routing', () => { diff --git a/test/dashboard.test.ts b/test/dashboard.test.ts index 6fa36bb..5726659 100644 --- a/test/dashboard.test.ts +++ b/test/dashboard.test.ts @@ -14,6 +14,8 @@ const data: DashboardData = { { path: 'memory.mode', label: 'Memory injection', group: 'Memory', type: 'enum', values: ['full', 'lightweight', 'auto'], current: 'auto' }, ], schedules: [{ id: 'sched1234', name: 'nightly-deps', cron: '@daily', enabled: true, recipe: 'verified-pr' }], + models: ['qwen3-coder', 'anthropic/claude-3.5'], + candidates: [{ name: 'add-pagination', description: 'cursor pagination playbook', confidence: 82 }], totals: { sessions: 1, tokens: 42000, cost: 0.12, facts: 1, episodes: 1, skills: 1 }, }; @@ -36,7 +38,7 @@ describe('qodex dashboard (pure render)', () => { }); it('handles an empty/fresh install gracefully', () => { - const empty: DashboardData = { project: 'x', model: 'm', generatedAt: 't', providers: [], sessions: [], facts: [], episodes: [], skills: [], controls: [], schedules: [], totals: { sessions: 0, tokens: 0, cost: 0, facts: 0, episodes: 0, skills: 0 } }; + const empty: DashboardData = { project: 'x', model: 'm', generatedAt: 't', providers: [], sessions: [], facts: [], episodes: [], skills: [], controls: [], schedules: [], models: [], candidates: [], totals: { sessions: 0, tokens: 0, cost: 0, facts: 0, episodes: 0, skills: 0 } }; const html = buildDashboardHtml(empty); expect(html).toContain('No sessions yet'); expect(html).toContain('Nothing learned yet'); @@ -52,6 +54,10 @@ describe('qodex dashboard (pure render)', () => { expect(live).toContain('tok123'); expect(live).toContain("act('schedule.setEnabled'"); // schedule controls present expect(live).toContain("act('config.set'"); // config toggles present + expect(live).toContain("act('model.set'"); // model switcher + expect(live).toContain("act('memory.add'"); // remember input + expect(live).toContain("act('skill.promote'"); // candidate promote + expect(live).toContain('add-pagination'); // candidate listed }); // Exercises the REAL gather chain (config + store + skills, read-only) for an empty cwd — proves it