Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/cli/dashboard-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.' };
Expand Down
29 changes: 27 additions & 2 deletions src/cli/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}

Expand Down Expand Up @@ -58,6 +60,16 @@ export function buildDashboardHtml(d: DashboardData, opts: { token?: string } =
<td class="r">${live ? `<button onclick="act('schedule.setEnabled',{id:'${esc(s.id)}',enabled:${!s.enabled}})">${s.enabled ? 'Disable' : 'Enable'}</button> <button class="danger" onclick="if(confirm('Remove ${esc(s.name)}?'))act('schedule.remove',{id:'${esc(s.id)}'})">Remove</button>` : `<span class="dim">${s.enabled ? 'enabled' : 'disabled'}</span>`}</td>
</tr>`).join('') : '<tr><td colspan="3" class="dim">No scheduled tasks. Add one with `qodex schedule add`.</td></tr>';
const schedulePanel = `<div class="panel"><h2>Scheduled tasks</h2><table><thead><tr><th>task</th><th>cron</th><th class="r">${live ? 'actions' : 'state'}</th></tr></thead><tbody>${scheduleRows}</tbody></table></div>`;

// Model switcher (live: a dropdown of known models; read-only: just the current one).
const modelCtl = live && d.models.length
? `<select onchange="act('model.set',{model:this.value})">${d.models.map(m => `<option${m === d.model ? ' selected' : ''}>${esc(m)}</option>`).join('')}</select>`
: `<span class="mono">${esc(d.model)}</span>`;
const modelPanel = `<div class="panel"><div class="ctl"><span>Default model</span>${modelCtl}</div></div>`;

// Quarantined skill candidates — promote (independent-judge-passed) or reject from here.
const candRows = d.candidates.length ? d.candidates.map(c => `<li><b>${esc(c.name)}</b>${c.confidence != null ? ` <span class="dim">conf ${c.confidence}</span>` : ''} — <span class="dim">${esc(c.description)}</span>${live ? ` <button onclick="act('skill.promote',{name:'${esc(c.name)}'})">Promote</button> <button class="danger" onclick="act('skill.reject',{name:'${esc(c.name)}'})">Reject</button>` : ''}</li>`).join('') : '<li class="dim">No candidates in quarantine.</li>';
const candidatePanel = `<div class="panel"><h2>Skill candidates (quarantine)</h2><ul>${candRows}</ul></div>`;
const providerRows = d.providers.map(p => `<tr>
<td>${p.isDefault ? '⭐ ' : ''}<b>${esc(p.name)}</b></td>
<td class="mono dim">${esc(p.baseUrl || '—')}</td>
Expand Down Expand Up @@ -116,15 +128,17 @@ export function buildDashboardHtml(d: DashboardData, opts: { token?: string } =
</div>
<div class="panel"><h2>Tokens per recent session</h2><canvas id="chart" height="90"></canvas></div>
${live ? '' : '<p class="dim" style="margin:-6px 0 14px">Read-only snapshot. Run <b>qodex dashboard</b> (server mode) for live controls.</p>'}
${modelPanel}
<div class="two">${controlPanel}</div>
${schedulePanel}
<div class="panel"><h2>Providers &amp; models</h2><table><thead><tr><th>Provider</th><th>Base URL</th><th>Models</th><th>API key</th></tr></thead><tbody>${providerRows}</tbody></table></div>
<div class="panel"><h2>Recent sessions</h2><table><thead><tr><th>id</th><th>title</th><th>model</th><th class="r">turns</th><th class="r">tokens</th><th class="r">cost</th><th>when</th></tr></thead><tbody>${sessionRows}</tbody></table></div>
<div class="two">
<div class="panel"><h2>Memory · learned facts</h2><ul>${factList}</ul></div>
<div class="panel"><h2>Memory · learned facts</h2><ul>${factList}</ul>${live ? `<div class="ctl" style="border:0;padding-top:10px"><input id="newfact" placeholder="Remember a fact about this project…" style="flex:1;margin-right:8px;background:#1b2233;border:1px solid var(--line);border-radius:8px;padding:6px 10px;color:var(--ink)"><button onclick="const i=document.getElementById('newfact');if(i.value.trim())act('memory.add',{fact:i.value.trim()})">Remember</button></div>` : ''}</div>
<div class="panel"><h2>Episodic memory · past tasks</h2><ul>${epList}</ul></div>
</div>
<div class="panel"><h2>Skills</h2><ul>${skillList}</ul></div>
${candidatePanel}
<p class="dim" style="text-align:center">Generated by <b>qodex dashboard</b> — all data is local, under ~/.qodex/.</p>
</div>
<div id="toast"></div>
Expand Down Expand Up @@ -208,10 +222,21 @@ export async function gatherDashboardData(cwd: string): Promise<DashboardData> {
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<string>();
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,
Expand Down
6 changes: 6 additions & 0 deletions test/dashboard-control.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
8 changes: 7 additions & 1 deletion test/dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
};

Expand All @@ -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');
Expand All @@ -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
Expand Down