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 ? 'Disable' : 'Enable'} Remove ` : `${s.enabled ? 'enabled' : 'disabled'} `}
`).join('') : 'No scheduled tasks. Add one with `qodex schedule add`. ';
const schedulePanel = `Scheduled tasks task cron ${live ? 'actions' : 'state'} ${scheduleRows}
`;
+
+ // Model switcher (live: a dropdown of known models; read-only: just the current one).
+ const modelCtl = live && d.models.length
+ ? `${d.models.map(m => `${esc(m)} `).join('')} `
+ : `${esc(d.model)} `;
+ const modelPanel = ``;
+
+ // 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 ? ` Promote Reject ` : ''} `).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 Provider Base URL Models API key ${providerRows}
Recent sessions id title model turns tokens cost when ${sessionRows}
-
+
Memory · learned facts ${live ? `
Remember
` : ''}
Episodic memory · past tasks
+ ${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