import { ContextGuardError } from '@j1nn0/agent-context-guard'; import type { ContextItemKind } from '@j1nn0/agent-context-guard'; import type { ExtensionAPI, ExtensionCommandContext, } from '@earendil-works/pi-coding-agent'; import { findSupersessionCandidates, type DiscoveryAnchor, type DiscoveryAnchorCategory, } from './discovery-candidates.js'; import { discoveryStatus, retireDiscovery, supersedeDiscovery, type DiscoveryLifecycleFailure, type DiscoveryLifecycleResult, } from './discovery-lifecycle.js'; import type { DiscoveryMode, ExtractionMode, LastVerification, RecoveryMode, RuntimeState, } from './types.js'; const USAGE = 'Usage: /context-guard add [--critical] | list | remove | clear [--yes] | status | recovery [off|critical] | extraction [off|automatic] | discovery [off|automatic] | discovery retire | discovery supersede | discovery candidates'; const KINDS: readonly ContextItemKind[] = [ 'goal', 'constraint', 'requirement', 'decision', 'fact', ]; interface CommandController { readonly getState: () => RuntimeState; readonly setRecoveryMode: (mode: RecoveryMode) => void; readonly setExtractionMode: (mode: ExtractionMode) => void; readonly setDiscoveryMode: (mode: DiscoveryMode) => void; readonly clearPendingSnapshot: () => void; readonly persist: () => void; readonly getLastVerification: () => LastVerification | undefined; readonly getLastUnverifiableCompactionId: () => string | undefined; } interface AddArguments { readonly id: string; readonly kind: ContextItemKind; readonly content: string; readonly critical: boolean; } function isContextItemKind(value: string): value is ContextItemKind { return KINDS.includes(value as ContextItemKind); } function notify( ctx: ExtensionCommandContext, message: string, type: 'info' | 'warning' | 'error' = 'info', ): void { ctx.ui.notify(message, type); } function parseAddArguments(value: string): AddArguments | undefined { const match = /^(\S+)\s+(\S+)(?:\s+([\s\S]*))?$/.exec(value); if (match === null) { return undefined; } const [, id, kindValue, capturedContent] = match; if (id === undefined || kindValue === undefined || !isContextItemKind(kindValue)) { return undefined; } let content = capturedContent ?? ''; let critical = false; while (content === '--critical' || /^--critical[ \t]/.test(content)) { critical = true; content = content.slice('--critical'.length); content = content.replace(/^[ \t]+/, ''); } content = content.replace(/ $/, ''); if (content.length === 0) { return undefined; } return { id, kind: kindValue, content, critical }; } function parseSingleArgument(value: string): string | undefined { const trimmed = value.trim(); return /^\S+$/.test(trimmed) ? trimmed : undefined; } function verificationSummary( last: LastVerification | undefined, lastUnverifiableCompactionId: string | undefined, ): string { if (last === undefined) { return lastUnverifiableCompactionId === undefined ? 'Last verification: none.' : `Last verification: compaction '${lastUnverifiableCompactionId}' was unverifiable.`; } const { report } = last; const criticalIds = report.criticalFailures.length === 0 ? 'none' : report.criticalFailures.join(', '); return [ `Last verification: ${report.preserved.length} preserved, ${report.changed.length} changed, ${report.lost.length} lost, ${report.unknown.length} unknown`, `critical failure ids: ${criticalIds}.`, ].join('; '); } function listItems( ctx: ExtensionCommandContext, state: RuntimeState, ): void { const items = state.guard.list(); if (items.length === 0) { notify(ctx, 'Context Guard: no protected items.'); return; } const lines = items.map((item) => { const critical = item.critical ? ', critical' : ''; const provenance = state.discoveryItemIds.has(item.id) ? `discovery ${discoveryStatus(state, item.id)}` : state.autoItemIds.has(item.id) ? 'auto' : 'manual'; const references = state.discoveryItemIds.has(item.id) ? state.discoveryProvenance.get(item.id) ?? [] : []; const referenceSummary = references.length === 0 ? '' : ` (${references.length} evidence reference${references.length === 1 ? '' : 's'}: ${Array.from(new Set(references.map((reference) => reference.toolName))).join(', ')})`; return `${item.id} [${provenance}] [${item.kind}${critical}]: ${item.content}${referenceSummary}`; }); notify(ctx, lines.join('\n')); } function extractionSummary(state: RuntimeState): string { if (state.lastExtraction === undefined) { return 'Last extraction: none.'; } if (state.lastExtraction.status === 'failed') { return `Last extraction: failed (${state.lastExtraction.failureKind}).`; } return `Last extraction: added ${state.lastExtraction.added}, retired ${state.lastExtraction.retired}.`; } function discoverySummary(state: RuntimeState): string { if (state.lastDiscovery === undefined) { return 'Last discovery: none.'; } if (state.lastDiscovery.status === 'failed') { return `Last discovery: failed (${state.lastDiscovery.failureKind}).`; } return `Last discovery: success (added ${state.lastDiscovery.added}).`; } function showStatus( ctx: ExtensionCommandContext, controller: CommandController, ): void { const state = controller.getState(); const items = state.guard.list(); const criticalCount = items.filter((item) => item.critical).length; const discoveryCount = items.filter((item) => state.discoveryItemIds.has(item.id), ).length; const automaticCount = items.filter( (item) => state.autoItemIds.has(item.id) && !state.discoveryItemIds.has(item.id), ).length; const manualCount = items.length - automaticCount - discoveryCount; const discoveryLabel = discoveryCount === 1 ? 'discovery' : 'discoveries'; const discoveryItems = items.filter((item) => state.discoveryItemIds.has(item.id), ); const countByStatus = (status: string): number => discoveryItems.filter((item) => discoveryStatus(state, item.id) === status) .length; const status = [ `Context Guard: ${items.length} items, ${criticalCount} critical, ${manualCount} manual, ${automaticCount} automatic, ${discoveryCount} ${discoveryLabel} (${countByStatus('active')} active, ${countByStatus('superseded')} superseded, ${countByStatus('retired')} retired), recovery ${state.recovery}, extraction ${state.extraction}, discovery ${state.discovery}, degraded ${state.degraded ? 'yes' : 'no'}.`, extractionSummary(state), discoverySummary(state), verificationSummary( controller.getLastVerification(), controller.getLastUnverifiableCompactionId(), ), ].join(' '); notify(ctx, status); } function addItem( ctx: ExtensionCommandContext, controller: CommandController, value: string, ): void { const parsed = parseAddArguments(value); if (parsed === undefined) { notify(ctx, USAGE, 'warning'); return; } try { controller.getState().guard.add(parsed); } catch (error: unknown) { if ( error instanceof ContextGuardError && error.code === 'duplicate_item_id' ) { notify(ctx, `Context Guard: item id '${parsed.id}' already exists.`, 'warning'); return; } notify(ctx, 'Context Guard: invalid protected item.', 'error'); return; } controller.clearPendingSnapshot(); controller.persist(); notify(ctx, `Context Guard: added '${parsed.id}'.`); } function removeItem( ctx: ExtensionCommandContext, controller: CommandController, value: string, ): void { const id = parseSingleArgument(value); if (id === undefined) { notify(ctx, 'Usage: /context-guard remove ', 'warning'); return; } const state = controller.getState(); if (!state.guard.remove(id)) { notify(ctx, `Context Guard: item '${id}' was not found.`, 'warning'); return; } state.autoItemIds.delete(id); state.discoveryItemIds.delete(id); state.discoveryProvenance.delete(id); state.discoveryLifecycle.delete(id); controller.clearPendingSnapshot(); controller.persist(); notify(ctx, `Context Guard: removed '${id}'.`); } function clearItems( ctx: ExtensionCommandContext, controller: CommandController, value: string, ): void { const state = controller.getState(); const count = state.guard.size(); if (value.trim() !== '--yes') { notify( ctx, `Context Guard: ${count} protected item${count === 1 ? '' : 's'} would be deleted. Run /context-guard clear --yes to confirm.`, 'warning', ); return; } controller.clearPendingSnapshot(); state.guard.clear(); state.autoItemIds.clear(); state.discoveryItemIds.clear(); state.discoveryProvenance.clear(); state.discoveryLifecycle.clear(); controller.persist(); notify(ctx, `Context Guard: cleared ${count} protected item${count === 1 ? '' : 's'}.`); } function changeRecovery( ctx: ExtensionCommandContext, controller: CommandController, value: string, ): void { const mode = value.trim(); if (mode !== 'off' && mode !== 'critical') { notify(ctx, 'Usage: /context-guard recovery [off|critical]', 'warning'); return; } const state = controller.getState(); if (state.recovery === mode) { notify(ctx, `Context Guard: recovery is already '${mode}'.`); return; } controller.clearPendingSnapshot(); controller.setRecoveryMode(mode); controller.persist(); notify(ctx, `Context Guard: recovery set to '${mode}'.`); } function changeExtraction( ctx: ExtensionCommandContext, controller: CommandController, value: string, ): void { const mode = value.trim(); if (mode !== 'off' && mode !== 'automatic') { notify(ctx, 'Usage: /context-guard extraction [off|automatic]', 'warning'); return; } const state = controller.getState(); if (state.extraction === mode) { notify(ctx, `Context Guard: extraction is already '${mode}'.`); return; } controller.clearPendingSnapshot(); controller.setExtractionMode(mode); controller.persist(); notify(ctx, `Context Guard: extraction set to '${mode}'.`); } function changeDiscovery( ctx: ExtensionCommandContext, controller: CommandController, value: string, ): void { const mode = value.trim(); if (mode !== 'off' && mode !== 'automatic') { notify(ctx, 'Usage: /context-guard discovery [off|automatic]', 'warning'); return; } const state = controller.getState(); if (state.discovery === mode) { notify(ctx, `Context Guard: discovery is already '${mode}'.`); return; } controller.clearPendingSnapshot(); controller.setDiscoveryMode(mode); controller.persist(); notify(ctx, `Context Guard: discovery set to '${mode}'.`); } const LIFECYCLE_FAILURE_MESSAGES: Readonly< Record > = { 'unknown-item': "Context Guard: item '%s' was not found.", 'not-a-discovery': "Context Guard: item '%s' is not a discovery; only discoveries have a lifecycle.", 'same-item': 'Context Guard: a discovery cannot supersede itself.', 'already-retired': "Context Guard: discovery '%s' is already retired.", 'already-superseded': "Context Guard: discovery '%s' is already superseded.", 'target-not-active': "Context Guard: discovery '%s' is not active and cannot supersede another.", }; function notifyLifecycleFailure( ctx: ExtensionCommandContext, result: Extract, ): void { notify( ctx, LIFECYCLE_FAILURE_MESSAGES[result.failure].replace('%s', result.subject), 'warning', ); } function retireDiscoveryItem( ctx: ExtensionCommandContext, controller: CommandController, value: string, ): void { const id = parseSingleArgument(value); if (id === undefined) { notify(ctx, 'Usage: /context-guard discovery retire ', 'warning'); return; } const state = controller.getState(); const result = retireDiscovery(state, id); if (!result.ok) { notifyLifecycleFailure(ctx, result); return; } controller.clearPendingSnapshot(); controller.persist(); notify(ctx, `Context Guard: retired discovery '${id}'.`); } function supersedeDiscoveryItem( ctx: ExtensionCommandContext, controller: CommandController, value: string, ): void { const match = /^(\S+)\s+(\S+)$/.exec(value.trim()); const id = match?.[1]; const supersededBy = match?.[2]; if (id === undefined || supersededBy === undefined) { notify( ctx, 'Usage: /context-guard discovery supersede ', 'warning', ); return; } const state = controller.getState(); const result = supersedeDiscovery(state, id, supersededBy); if (!result.ok) { notifyLifecycleFailure(ctx, result); return; } controller.clearPendingSnapshot(); controller.persist(); notify(ctx, `Context Guard: discovery '${id}' superseded by '${supersededBy}'.`); } const CANDIDATE_CATEGORIES: readonly DiscoveryAnchorCategory[] = [ 'path', 'opaque-id', 'versioned-subject', ]; /** At most this many groups are printed, so one command cannot flood the UI. */ const MAX_CANDIDATE_GROUPS = 10; function formatAnchor(anchor: DiscoveryAnchor): string { return `${anchor.category} ${anchor.value}`; } /** * Suggests which active discoveries mention the same structured token. This is * a suggestion, never a decision: a shared anchor is not shared meaning, so the * command changes nothing and the user records any replacement themselves. */ function showCandidates( ctx: ExtensionCommandContext, controller: CommandController, ): void { const state = controller.getState(); const items = state.guard .list() .filter( (item) => state.discoveryItemIds.has(item.id) && discoveryStatus(state, item.id) === 'active', ); const contentById = new Map(items.map((item) => [item.id, item.content])); const groups = findSupersessionCandidates( items.map((item) => ({ id: item.id, content: item.content })), CANDIDATE_CATEGORIES, ); if (groups.length === 0) { notify(ctx, 'Context Guard: no discovery supersession candidates.'); return; } const shown = groups.slice(0, MAX_CANDIDATE_GROUPS); const lines = [ `Context Guard: ${groups.length} discovery supersession candidate group${groups.length === 1 ? '' : 's'}.`, ]; for (const group of shown) { lines.push( '', `Shared anchors: ${group.anchors.map(formatAnchor).join(', ')}`, ...group.itemIds.map( (id) => ` ${id}: ${contentById.get(id) ?? ''}`, ), ); } if (groups.length > shown.length) { lines.push( '', `Showing ${shown.length} of ${groups.length} candidate groups.`, ); } lines.push( '', 'Nothing was changed. Run /context-guard discovery supersede to record a replacement.', ); notify(ctx, lines.join('\n')); } async function handleCommand( args: string, ctx: ExtensionCommandContext, controller: CommandController, ): Promise { const trimmed = args.trimStart(); const match = /^(\S+)(?:\s+([\s\S]*))?$/.exec(trimmed); if (match === null || match[1] === undefined) { notify(ctx, USAGE, 'warning'); return; } const subcommand = match[1]; const value = match[2] ?? ''; switch (subcommand) { case 'add': addItem(ctx, controller, value); return; case 'list': if (value.trim().length !== 0) { notify(ctx, 'Usage: /context-guard list', 'warning'); return; } listItems(ctx, controller.getState()); return; case 'remove': removeItem(ctx, controller, value); return; case 'clear': clearItems(ctx, controller, value); return; case 'status': if (value.trim().length !== 0) { notify(ctx, 'Usage: /context-guard status', 'warning'); return; } showStatus(ctx, controller); return; case 'recovery': changeRecovery(ctx, controller, value); return; case 'extraction': if (value.trim().length === 0) { notify(ctx, `Context Guard: extraction is '${controller.getState().extraction}'.`); return; } changeExtraction(ctx, controller, value); return; case 'discovery': { if (value.trim().length === 0) { notify(ctx, `Context Guard: discovery is '${controller.getState().discovery}'.`); return; } const operation = /^(\S+)(?:\s+([\s\S]*))?$/.exec(value.trimStart()); if (operation?.[1] === 'retire') { retireDiscoveryItem(ctx, controller, operation[2] ?? ''); return; } if (operation?.[1] === 'supersede') { supersedeDiscoveryItem(ctx, controller, operation[2] ?? ''); return; } if (operation?.[1] === 'candidates') { if ((operation[2] ?? '').trim().length !== 0) { notify(ctx, 'Usage: /context-guard discovery candidates', 'warning'); return; } showCandidates(ctx, controller); return; } changeDiscovery(ctx, controller, value); return; } default: notify(ctx, USAGE, 'warning'); } } export function registerContextGuardCommand( pi: ExtensionAPI, controller: CommandController, ): void { pi.registerCommand('context-guard', { description: 'Protect context items across Pi compaction.', handler: async (args, ctx): Promise => { await handleCommand(args, ctx, controller); }, }); }