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
34 changes: 32 additions & 2 deletions src/cli/dashboard-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,40 @@ export async function dispatchAction(name: string, params: any, cwd: string): Pr
return { ok: true, message: `Set ${params.key} = ${v.coerced}. Takes effect on the next run.` };
}
case 'model.set': {
// role: main (default) | subagent | vision | all — "all" points every role at one model,
// which is all you need when that model has vision (no separate vision model required).
const model = String(params?.model ?? '').trim();
const role = String(params?.role ?? 'main').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.` };
if (!['main', 'subagent', 'vision', 'all'].includes(role)) return { ok: false, message: `Unknown role "${role}".` };
const { inferProvider } = await import('../llm/role-resolver.js');
const { looksVisionCapable } = await import('../setup/model-detector.js');
const provider = inferProvider(model);
const seesImages = looksVisionCapable(model);
if (role === 'main' || role === 'all') await writeConfigKnob('defaults.model', model);
if (role === 'subagent' || role === 'all') { await writeConfigKnob('roles.subagent.model', model); await writeConfigKnob('roles.subagent.provider', provider); }
if (role === 'vision' || role === 'all') { await writeConfigKnob('roles.vision.model', model); await writeConfigKnob('roles.vision.provider', provider); }
const visionNote = role === 'vision' && !seesImages
? ' ⚠️ this model does not look vision-capable — screenshots may fail.'
: seesImages && role !== 'subagent' ? ' 👁 has vision — no separate vision model needed.' : '';
const label = role === 'all' ? 'ALL roles (main + subagent + vision)' : role === 'main' ? 'Default model' : `${role} model`;
return { ok: true, message: `${label} → ${model}.${visionNote} Takes effect on the next run.` };
}
case 'recall.query': {
const q = String(params?.query ?? '').trim();
if (!q) return { ok: false, message: 'Type what to recall.' };
const { rankApproaches } = await import('../context/approach-recall.js');
const { renderApproachDiffs } = await import('../context/approach-diff.js');
const { getSessionStore } = await import('../session/store.js');
const store = getSessionStore();
const worklog = (() => { try { return store.getWorklog(cwd, 100).map((w: any) => ({ kind: 'worklog' as const, text: w.entry, when: String(w.created_at ?? '').slice(0, 10), at: w.created_at, detail: w.kind })); } catch { return []; } })();
const episodes = await (async () => {
try { const { readEpisodes } = await import('../context/episodic-memory.js'); return (await readEpisodes(cwd)).map((e: any) => ({ kind: 'episode' as const, text: `${e.prompt} ${e.summary}`, when: String(e.ts ?? '').slice(0, 10), at: e.ts, files: e.filesChanged, detail: e.summary })); }
catch { return []; }
})();
const facts = (() => { try { return store.getFactsForCwd(cwd, 200).map((f: string) => ({ kind: 'fact' as const, text: f, when: '', detail: 'fact' })); } catch { return []; } })();
const matches = rankApproaches(q, [...episodes, ...worklog, ...facts], { topK: 4, nowMs: Date.now(), diversity: 0.35 });
return { ok: true, message: renderApproachDiffs(q, matches) };
}
case 'memory.add': {
const fact = String(params?.fact ?? '').trim();
Expand Down
48 changes: 42 additions & 6 deletions src/cli/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export interface DashboardData {
maintainProjection?: { cleanupsPerMonth: number; minutesPerMonth: number };
maintainForecast?: import('./maintain-stats.js').MaintainForecast;
extractMetrics?: import('../tools/web/extract-metrics.js').ExtractCounts;
roleModels?: { subagent?: string; vision?: string; mainHasVision: boolean };
totals: { sessions: number; tokens: number; cost: number; facts: number; episodes: number; skills: number };
}

Expand Down Expand Up @@ -120,6 +121,12 @@ export function buildDashboardHtml(d: DashboardData, opts: { token?: string } =
const healthBadges = d.health.map(h => `<span class="badge ${h.ok ? 'ok' : 'warn'}">${h.ok ? '✓' : '!'} ${esc(h.label)}: ${esc(h.detail)}</span>`).join('');
const healthPanel = `<div class="panel"><div class="ctl" style="border:0;padding:0 0 12px"><h2 style="margin:0">Health</h2>${live ? `<button onclick="if(confirm('Pull + rebuild QodeX now?'))act('app.update',{})">⟳ Update QodeX</button>` : ''}</div><div class="badges">${healthBadges}</div></div>`;
const logsPanel = d.logs.length ? `<div class="panel"><h2>Recent log</h2><pre class="logs">${d.logs.map(esc).join('\n')}</pre></div>` : '';
// Recall explorer — ask "how did we do X before?" right here: best match + how other attempts
// differed (the same visual diff recall_approach returns), rendered in-place without a reload.
const recallPanel = live ? `<div class="panel"><h2>Recall — how did we do it before?</h2>
<div class="ctl" style="border:0"><input id="recall-q" placeholder="e.g. how did we add auth?" style="flex:1" onkeydown="if(event.key==='Enter')recallRun()"><button onclick="recallRun()">Recall</button></div>
<pre id="recall-out" class="logs" style="display:none"></pre>
</div>` : '';
// Web-extract quality: how often an oversized page was trimmed SEMANTICALLY (query-aware) vs a
// positional head+tail window. A high semantic rate = agents pass a query and get the relevant part.
const em = d.extractMetrics;
Expand All @@ -138,11 +145,18 @@ export function buildDashboardHtml(d: DashboardData, opts: { token?: string } =
um.preferences.length ? `<ul>${um.preferences.map(p => `<li>${esc(p)}</li>`).join('')}</ul>` : '<p class="dim">No stated preferences yet — tell me with “Remember” above.</p>'
}${um.recentThemes.length ? `<p class="dim">Recent focus: ${um.recentThemes.map(esc).join(' · ')}</p>` : ''}${um.favoriteAreas.length ? `<p class="dim">Works mostly in: ${um.favoriteAreas.map(esc).join(', ')}</p>` : ''}</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>`;
// Model switcher — per ROLE (main / subagent / vision) plus a one-model-for-everything setter.
// When the main model has vision, say so: a separate vision model is optional, not required.
const rm = d.roleModels;
const modelSelect = (role: string, current: string | undefined, extraFirst?: string) => live && d.models.length
? `<select onchange="act('model.set',{model:this.value,role:'${role}'})">${extraFirst ? `<option value="" selected disabled>${esc(extraFirst)}</option>` : ''}${d.models.map(m => `<option${!extraFirst && m === current ? ' selected' : ''}>${esc(m)}</option>`).join('')}</select>`
: `<span class="mono">${esc(current ?? '—')}</span>`;
const modelPanel = `<div class="panel"><h2>Models — per role</h2>
<div class="ctl"><span>Main (default)${rm?.mainHasVision ? ' <b class="ok">👁 has vision</b>' : ''}</span>${modelSelect('main', d.model)}</div>
<div class="ctl"><span>Sub-agent</span>${modelSelect('subagent', rm?.subagent ?? d.model)}</div>
<div class="ctl"><span>Vision${rm?.mainHasVision ? ' <span class="dim">(optional — main already sees images)</span>' : ''}</span>${modelSelect('vision', rm?.vision ?? (rm?.mainHasVision ? d.model : undefined))}</div>
${live && d.models.length ? `<div class="ctl" style="border:0"><span>One model for everything</span>${modelSelect('all', undefined, 'pick a model…')}</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>';
Expand Down Expand Up @@ -236,6 +250,7 @@ export function buildDashboardHtml(d: DashboardData, opts: { token?: string } =
${umPanel}
<div class="panel"><h2>Skills</h2><ul>${skillList}</ul></div>
${candidatePanel}
${recallPanel}
${extractPanel}
${logsPanel}
<p class="dim" style="text-align:center">Generated by <b>qodex dashboard</b> — all data is local, under ~/.qodex/.</p>
Expand All @@ -256,6 +271,17 @@ ${live ? `
if(j.ok) setTimeout(()=>location.reload(), 700);
}catch(e){ toast(String(e), false); }
}
async function recallRun(){
const q = document.getElementById('recall-q').value.trim();
if(!q) return;
const out = document.getElementById('recall-out');
out.style.display = 'block'; out.textContent = 'Searching your history…';
try{
const r = await fetch('/api/action?k='+encodeURIComponent(TOKEN),{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({action:'recall.query',params:{query:q}})});
const j = await r.json();
out.textContent = j.message || (j.ok ? '(no result)' : 'Failed');
}catch(e){ out.textContent = String(e); }
}
` : ''}
</script></body></html>`;
}
Expand All @@ -270,6 +296,16 @@ export async function gatherDashboardData(cwd: string): Promise<DashboardData> {
const project = (() => { try { return store.getProject(cwd)?.name || path.basename(cwd); } catch { return path.basename(cwd); } })();
const defModel = config?.defaults?.model ?? '(unset)';
const defProvider = config?.defaults?.provider;
const roleModels = await (async () => {
try {
const { looksVisionCapable } = await import('../setup/model-detector.js');
return {
subagent: config?.roles?.subagent?.model as string | undefined,
vision: config?.roles?.vision?.model as string | undefined,
mainHasVision: typeof defModel === 'string' && looksVisionCapable(defModel),
};
} catch { return { subagent: undefined, vision: undefined, mainHasVision: false }; }
})();

// Providers: built-ins present in config + user customs.
const providers: DashboardData['providers'] = [];
Expand Down Expand Up @@ -406,7 +442,7 @@ export async function gatherDashboardData(cwd: string): Promise<DashboardData> {
providers, sessions, facts, episodes, skills, controls, schedules, models, candidates, runs, bot, health, logs, userModel,
maintainStats, maintainWeekly: maintain?.weekly, maintainNext: maintain?.next ?? undefined,
maintainTrend: maintain?.trend, maintainProjection: maintain?.projection, maintainForecast: maintain?.forecast,
extractMetrics,
extractMetrics, roleModels,
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
55 changes: 51 additions & 4 deletions src/cli/maintain-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,42 +7,89 @@
* All PURE (string/array in, string/array out) so the round-trip + merge are unit-tested.
*/
import type { MaintainRun } from './maintain-stats.js';
import { buildAuditChain, chainHead, signChainHead, verifyChainSignature, keyIdFor, type AuditableRun } from './maintain-audit.js';

export const MAINTAIN_HISTORY_VERSION = 1;

export interface HistoryAudit {
algo: 'sha256-chain' | 'sha256-chain+hmac-sha256';
head: string;
keyId?: string;
signature?: string;
}

export interface MaintainHistoryFile {
kind: 'qodex-maintain-history';
version: number;
exportedAt: string;
count: number;
runs: MaintainRun[];
/** Tamper-evidence for the snapshot itself: the audit-chain head over `runs` (+ optional HMAC). */
audit?: HistoryAudit;
}

const toAuditable = (r: MaintainRun): AuditableRun =>
({ at: r.at ?? '', scope: r.scope, status: r.status, filesChanged: r.filesChanged });

/** The audit-chain head over a run list (order-independent — the chain sorts chronologically). PURE. */
export function historyHead(runs: MaintainRun[]): string {
return chainHead(buildAuditChain(runs.map(toAuditable)));
}

/** Serialize maintain runs into a portable snapshot. PURE (pass `nowIso`). */
export function serializeMaintainHistory(runs: MaintainRun[], nowIso: string): string {
/**
* Serialize maintain runs into a portable snapshot. PURE (pass `nowIso`). With `opts.key`, the
* snapshot carries an `audit` block: the hash-chain head over the runs plus an HMAC signature — so
* the receiver can prove the history wasn't altered in transit and WHO exported it. Without a key
* it still carries the unsigned head (integrity check, no authenticity).
*/
export function serializeMaintainHistory(runs: MaintainRun[], nowIso: string, opts: { key?: string } = {}): string {
const clean = runs.map(normalizeRun);
const head = historyHead(clean);
const audit: HistoryAudit = opts.key
? { algo: 'sha256-chain+hmac-sha256', head, keyId: keyIdFor(opts.key), signature: signChainHead(head, opts.key) }
: { algo: 'sha256-chain', head };
const file: MaintainHistoryFile = {
kind: 'qodex-maintain-history',
version: MAINTAIN_HISTORY_VERSION,
exportedAt: nowIso,
count: clean.length,
runs: clean,
audit,
};
return JSON.stringify(file, null, 2);
}

export interface HistoryAuditVerdict {
present: boolean;
headMatches?: boolean;
signaturePresent?: boolean;
signatureValid?: boolean; // undefined when unsigned or no key supplied
ok: boolean; // everything checkable passed (an unsigned-but-intact snapshot is ok)
}

/** Verify a snapshot's audit block against its own runs. PURE. */
export function verifyHistoryAudit(runs: MaintainRun[], audit: HistoryAudit | undefined, key?: string): HistoryAuditVerdict {
if (!audit) return { present: false, ok: true }; // legacy snapshot — nothing to check
const headMatches = historyHead(runs.map(normalizeRun)) === audit.head;
const signaturePresent = !!audit.signature;
let signatureValid: boolean | undefined;
if (signaturePresent && key) signatureValid = verifyChainSignature(audit.head, audit.signature!, key);
return { present: true, headMatches, signaturePresent, signatureValid, ok: headMatches && (!signaturePresent || signatureValid !== false) };
}

/**
* Parse a history snapshot back into runs. Tolerant of extra fields and either the wrapped file
* shape or a bare runs array; throws only when the input is not usable JSON or has no runs field.
* PURE.
*/
export function deserializeMaintainHistory(text: string): { runs: MaintainRun[]; exportedAt?: string; version?: number } {
export function deserializeMaintainHistory(text: string): { runs: MaintainRun[]; exportedAt?: string; version?: number; audit?: HistoryAudit } {
let obj: any;
try { obj = JSON.parse(text); } catch { throw new Error('not valid JSON'); }
const rawRuns = Array.isArray(obj) ? obj : obj?.runs;
if (!Array.isArray(rawRuns)) throw new Error('no maintain runs found in the file');
const runs = rawRuns.filter(isRunLike).map(normalizeRun);
return { runs, exportedAt: typeof obj?.exportedAt === 'string' ? obj.exportedAt : undefined, version: typeof obj?.version === 'number' ? obj.version : undefined };
const audit = obj?.audit && typeof obj.audit === 'object' && typeof obj.audit.head === 'string' ? obj.audit as HistoryAudit : undefined;
return { runs, exportedAt: typeof obj?.exportedAt === 'string' ? obj.exportedAt : undefined, version: typeof obj?.version === 'number' ? obj.version : undefined, audit };
}

/** A stable identity for a run, so re-importing the same snapshot doesn't double-count. */
Expand Down
29 changes: 24 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -782,16 +782,22 @@ async function gatherMaintainRuns(): Promise<import('./cli/maintain-stats.js').M

program
.command('maintain-export')
.description('Export the maintain history to a portable JSON snapshot (archive, share, or move machines)')
.description('Export the maintain history to a portable JSON snapshot; --sign adds an HMAC-signed audit head')
.option('-o, --out <file>', 'write to this file instead of stdout')
.action(async (opts: { out?: string }) => {
.option('--sign', 'sign the snapshot with HMAC-SHA256 using the QODEX_AUDIT_KEY env var (never stored)')
.action(async (opts: { out?: string; sign?: boolean }) => {
const { serializeMaintainHistory } = await import('./cli/maintain-history.js');
let key: string | undefined;
if (opts.sign) {
key = process.env.QODEX_AUDIT_KEY;
if (!key) { console.error('\n✗ --sign needs a key: set QODEX_AUDIT_KEY in your environment (it is never stored).\n'); process.exit(1); }
}
const runs = await gatherMaintainRuns();
const json = serializeMaintainHistory(runs, new Date().toISOString());
const json = serializeMaintainHistory(runs, new Date().toISOString(), { key });
if (opts.out) {
const { promises: fs } = await import('fs');
await fs.writeFile(opts.out, json);
console.log(`\n📦 Exported ${runs.length} maintain run(s) → ${opts.out}\n`);
console.log(`\n📦 Exported ${runs.length} maintain run(s) → ${opts.out}${key ? ' 🔏 signed' : ''}\n`);
} else {
console.log(json);
}
Expand All @@ -804,12 +810,19 @@ program
.option('--merge', 'merge the snapshot with local history and report the combined analytics')
.action(async (file: string, opts: { merge?: boolean }) => {
const { promises: fs } = await import('fs');
const { deserializeMaintainHistory, mergeRuns } = await import('./cli/maintain-history.js');
const { deserializeMaintainHistory, mergeRuns, verifyHistoryAudit } = await import('./cli/maintain-history.js');
const { buildMaintainStats, forecastTrend } = await import('./cli/maintain-stats.js');
let parsed;
try { parsed = deserializeMaintainHistory(await fs.readFile(file, 'utf-8')); }
catch (e: any) { console.error(`\n✗ Could not read snapshot: ${e?.message ?? e}\n`); process.exit(1); }
let runs = parsed!.runs;
// Tamper check BEFORE merging: verify the snapshot's audit head (and signature, if a key is set).
const audit = verifyHistoryAudit(runs, parsed!.audit, process.env.QODEX_AUDIT_KEY);
if (audit.present && !audit.ok) {
const why = audit.headMatches === false ? 'runs do not match the audit head (snapshot was altered)' : 'signature is INVALID (wrong key or forged)';
console.error(`\n❌ Snapshot failed its audit check: ${why}. Refusing to report on it.\n`);
process.exit(1);
}
let label = `snapshot (${runs.length} run(s)${parsed!.exportedAt ? `, exported ${parsed!.exportedAt.slice(0, 10)}` : ''})`;
if (opts.merge) {
const local = await gatherMaintainRuns();
Expand All @@ -822,6 +835,12 @@ program
const fc = forecastTrend(runs, now);
const arrow = fc.direction === 'rising' ? 'rising ↑' : fc.direction === 'falling' ? 'cooling ↓' : 'steady →';
console.log(`\n📥 Maintain history — ${label}\n`);
if (audit.present) {
const sig = !audit.signaturePresent ? 'unsigned'
: audit.signatureValid === undefined ? 'signed (set QODEX_AUDIT_KEY to verify)'
: audit.signatureValid ? '🔏 signature valid (authentic)' : 'signature INVALID';
console.log(` Audit: ✓ integrity intact · ${sig}`);
}
if (stats.totalRuns === 0) { console.log(' No runs in the snapshot.\n'); process.exit(0); }
console.log(` Totals: ${stats.opened} cleanup PR(s) · ${stats.blocked} safely blocked · ${stats.filesCleaned} files cleaned · ~${stats.estMinutesSaved} min saved`);
console.log(` Forecast: ${arrow} · avg ~${fc.weeklyAvg}/wk · next week ≈ ${fc.nextWeek}`);
Expand Down
Loading