@polylogicai/polycode 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,166 @@
1
+ // lib/compiler.mjs
2
+ // Builds a bounded context window for the agent loop from the session log.
3
+ // Two-phase: an optional high-quality model selects which log rows are most
4
+ // relevant to the current turn, and a deterministic assembler composes them
5
+ // into the final prompt. The prompt is then scrubbed for secrets before any
6
+ // network call.
7
+ //
8
+ // polycode is inspired by Claude Code's public architecture at docs.claude.com.
9
+ // polycode is not affiliated with Anthropic. No Claude Code code or system
10
+ // prompts are copied. This file consumes the Anthropic Claude SDK as a paid
11
+ // API client if an ANTHROPIC_API_KEY is available; otherwise it falls back to
12
+ // a pure-Node selection path.
13
+
14
+ import Anthropic from '@anthropic-ai/sdk';
15
+ import { loadAnthropicKeys } from './inference-router.mjs';
16
+
17
+ const COMPILER_MODEL = 'claude-haiku-4-5-20251001';
18
+ const COMPILER_MAX_TOKENS = 600;
19
+ const SUMMARY_ROW_LIMIT = 400;
20
+
21
+ const COMPILER_SYSTEM_PROMPT = `You are a context selection helper. You receive a summary of a user's append-only session log (each row has an index, a type, and a short preview) and the user's current message. Your job is to return a JSON list of the row indices most relevant to the current message. You never write prose.
22
+
23
+ Selection rules:
24
+ - Prefer rows that reference entities, decisions, file paths, or numbers in the current message.
25
+ - Prefer rows with VERIFIED outcome over REFUTED or PENDING.
26
+ - Include the active intent row if it clarifies what the user is working on.
27
+ - Cap your selection at 8 rows. Fewer is better.
28
+ - If the current message asks about a specific prior turn or file, pull the rows that mention it directly.
29
+ - Output JSON only, no prose, no backticks, no explanation. Schema: {"relevant_rows": [integer, ...]}.`;
30
+
31
+ function buildCanonSummary(canon) {
32
+ const rows = [];
33
+ const all = canon.tail(SUMMARY_ROW_LIMIT);
34
+ for (const r of all) {
35
+ if (r.type === 'session_start' || r.type === 'session_end' || r.type === 'compile_result' || r.type === 'fidelity_check') continue;
36
+ const preview = r.type === 'witnessed_commitment'
37
+ ? `${r.payload?.verdict || '?'} ${String(r.payload?.claim || '').slice(0, 140)}`
38
+ : r.type === 'user_turn'
39
+ ? String(r.payload?.message || '').slice(0, 140)
40
+ : r.type === 'user_intent'
41
+ ? `intent: ${String(r.payload?.text || '').slice(0, 140)}`
42
+ : JSON.stringify(r.payload).slice(0, 140);
43
+ rows.push(`#${r.row_index} ${r.type}: ${preview}`);
44
+ }
45
+ return rows.join('\n');
46
+ }
47
+
48
+ async function selectRelevantRows(canon, userMessage) {
49
+ const keys = loadAnthropicKeys();
50
+ if (keys.length === 0) return { selected: null, usage: null, provider: null };
51
+
52
+ const summary = buildCanonSummary(canon);
53
+ const userContent = `Canon summary (${canon.size()} total rows; last ${Math.min(SUMMARY_ROW_LIMIT, canon.size())} shown):\n${summary}\n\nCurrent user message:\n${userMessage}\n\nOutput JSON only.`;
54
+
55
+ let lastError = null;
56
+ for (const { role, key } of keys) {
57
+ try {
58
+ const client = new Anthropic({ apiKey: key });
59
+ const response = await client.messages.create({
60
+ model: COMPILER_MODEL,
61
+ max_tokens: COMPILER_MAX_TOKENS,
62
+ system: COMPILER_SYSTEM_PROMPT,
63
+ messages: [{ role: 'user', content: userContent }],
64
+ });
65
+ const text = response.content?.[0]?.text || '';
66
+ const match = text.match(/\{[\s\S]*?\}/);
67
+ if (match) {
68
+ try {
69
+ const parsed = JSON.parse(match[0]);
70
+ if (Array.isArray(parsed.relevant_rows)) {
71
+ return { selected: parsed.relevant_rows, usage: response.usage, provider: `anthropic-haiku-4.5:${role}` };
72
+ }
73
+ } catch {
74
+ // fall through to next key
75
+ }
76
+ }
77
+ } catch (err) {
78
+ lastError = err;
79
+ }
80
+ }
81
+
82
+ return { selected: null, usage: null, provider: null, error: lastError?.message || 'no anthropic key responded' };
83
+ }
84
+
85
+ export async function compilePacket(canon, userMessage, cwd) {
86
+ const selection = await selectRelevantRows(canon, userMessage);
87
+ const selectedIndices = new Set(selection.selected || []);
88
+
89
+ // Pure-Node Phase B: assemble the packet deterministically.
90
+ const intent = canon.activeIntent();
91
+ const rowsToInclude = [];
92
+
93
+ if (intent) rowsToInclude.push(intent);
94
+
95
+ const all = canon.tail(SUMMARY_ROW_LIMIT);
96
+ for (const r of all) {
97
+ if (selectedIndices.has(r.row_index)) rowsToInclude.push(r);
98
+ }
99
+
100
+ if (rowsToInclude.length <= (intent ? 1 : 0)) {
101
+ // Fallback: no LLM selection (key unavailable or parse failure). Use the last
102
+ // few canon rows by intent scope, same shape as the v1.0.0 pure-Node query.
103
+ const fallback = intent && intent.payload?.intent_id
104
+ ? canon.queryByIntent(intent.payload.intent_id, 10)
105
+ : canon.tail(6);
106
+ for (const r of fallback) {
107
+ if (r.type !== 'session_start' && r.type !== 'session_end') rowsToInclude.push(r);
108
+ }
109
+ }
110
+
111
+ const seen = new Set();
112
+ const uniqueRows = [];
113
+ for (const r of rowsToInclude) {
114
+ if (!seen.has(r.row_index)) {
115
+ seen.add(r.row_index);
116
+ uniqueRows.push(r);
117
+ }
118
+ }
119
+ uniqueRows.sort((a, b) => a.row_index - b.row_index);
120
+
121
+ const lines = [];
122
+ lines.push(`[polycode compile-packet · compiler: ${selection.provider || 'pure-node-fallback'}]`);
123
+ lines.push(`working_directory: ${cwd || process.cwd()}`);
124
+ lines.push(`canon_size: ${canon.size()} rows`);
125
+ lines.push(`canon_last_hash: ${canon.lastHash().slice(0, 16)}...`);
126
+
127
+ if (intent) {
128
+ lines.push('');
129
+ lines.push(`ACTIVE INTENT (${intent.payload.intent_id}):`);
130
+ lines.push(` ${String(intent.payload.text || '').slice(0, 500)}`);
131
+ }
132
+
133
+ const nonIntentRows = uniqueRows.filter((r) => r.type !== 'user_intent');
134
+ if (nonIntentRows.length > 0) {
135
+ lines.push('');
136
+ lines.push(`SELECTED CANON ROWS (${nonIntentRows.length}, compiler-selected by relevance):`);
137
+ for (const r of nonIntentRows) {
138
+ const p = r.payload || {};
139
+ if (r.type === 'witnessed_commitment') {
140
+ const verdict = p.verdict || 'PENDING';
141
+ const claim = String(p.claim || '').slice(0, 280);
142
+ const tool = p.tool_name ? ` [tool=${p.tool_name}]` : '';
143
+ lines.push(` #${r.row_index} [${verdict}]${tool} ${claim}`);
144
+ if (p.tool_result_snippet) {
145
+ lines.push(` result: ${String(p.tool_result_snippet).slice(0, 160).replace(/\n/g, ' ')}`);
146
+ }
147
+ } else if (r.type === 'user_turn') {
148
+ lines.push(` #${r.row_index} user: ${String(p.message || '').slice(0, 280)}`);
149
+ }
150
+ }
151
+ }
152
+
153
+ lines.push('');
154
+ lines.push('CURRENT USER MESSAGE:');
155
+ lines.push(userMessage);
156
+
157
+ const prompt = lines.join('\n');
158
+ return {
159
+ prompt,
160
+ estimatedTokens: Math.ceil(prompt.length / 4),
161
+ selectedRows: selection.selected || [],
162
+ compilerProvider: selection.provider || 'pure-node-fallback',
163
+ compilerUsage: selection.usage || null,
164
+ fallback: !selection.selected,
165
+ };
166
+ }
@@ -0,0 +1,79 @@
1
+ // lib/context-builder.mjs
2
+ // The key invention: build a MINIMAL prompt from structural canon queries.
3
+ // The model never sees the full history. It sees only:
4
+ // - Active intent (one row)
5
+ // - Last N user turns within active intent
6
+ // - Last N witnessed commitments within active intent
7
+ // - Current user message
8
+ // The size of the resulting prompt is O(1) in the size of the canon, not O(N).
9
+ // That is the architectural proof that agents do not need context windows that
10
+ // grow with their history. State lives in the canon. The window stays bounded.
11
+ //
12
+ // This is what lets polycode operate indefinitely on a free 128k model while
13
+ // remaining coherent across thousands of prior commitments.
14
+
15
+ const DEFAULT_N_TURNS = 4;
16
+ const DEFAULT_N_COMMITMENTS = 5;
17
+ const MAX_TURN_SNIPPET_BYTES = 600;
18
+ const MAX_COMMITMENT_SNIPPET_BYTES = 300;
19
+
20
+ function truncate(str, n) {
21
+ const s = String(str || '');
22
+ if (s.length <= n) return s;
23
+ return s.slice(0, n) + '...';
24
+ }
25
+
26
+ export function buildContext(canon, userMessage, cwd) {
27
+ const intent = canon.activeIntent();
28
+ const intentId = intent?.payload?.intent_id || null;
29
+
30
+ const recentIntentRows = intentId ? canon.queryByIntent(intentId, 40) : [];
31
+ const recentTurns = recentIntentRows.filter((r) => r.type === 'user_turn').slice(-DEFAULT_N_TURNS);
32
+ const recentCommits = recentIntentRows.filter((r) => r.type === 'witnessed_commitment').slice(-DEFAULT_N_COMMITMENTS);
33
+
34
+ const lines = [];
35
+ lines.push(`[polycode structural canon query, not full history]`);
36
+ lines.push(`working_directory: ${cwd || process.cwd()}`);
37
+ lines.push(`canon_size: ${canon.size()} rows`);
38
+ lines.push(`canon_last_hash: ${canon.lastHash().slice(0, 16)}...`);
39
+
40
+ if (intent) {
41
+ lines.push('');
42
+ lines.push(`ACTIVE INTENT (${intent.payload.intent_id}):`);
43
+ lines.push(` ${truncate(intent.payload.text, 400)}`);
44
+ }
45
+
46
+ if (recentTurns.length > 0) {
47
+ lines.push('');
48
+ lines.push(`RECENT USER TURNS within active intent (last ${recentTurns.length}):`);
49
+ for (const r of recentTurns) {
50
+ lines.push(` - ${truncate(r.payload?.message, MAX_TURN_SNIPPET_BYTES)}`);
51
+ }
52
+ }
53
+
54
+ if (recentCommits.length > 0) {
55
+ lines.push('');
56
+ lines.push(`RECENT WITNESSED COMMITMENTS within active intent (last ${recentCommits.length}):`);
57
+ for (const r of recentCommits) {
58
+ const p = r.payload || {};
59
+ const claim = truncate(p.claim, MAX_COMMITMENT_SNIPPET_BYTES);
60
+ const verdict = p.verdict || 'PENDING';
61
+ lines.push(` [${verdict}] ${claim}`);
62
+ }
63
+ }
64
+
65
+ lines.push('');
66
+ lines.push('CURRENT USER MESSAGE:');
67
+ lines.push(userMessage);
68
+
69
+ const prompt = lines.join('\n');
70
+ const estimatedTokens = Math.ceil(prompt.length / 4);
71
+
72
+ return {
73
+ prompt,
74
+ estimatedTokens,
75
+ intentId,
76
+ nTurns: recentTurns.length,
77
+ nCommits: recentCommits.length,
78
+ };
79
+ }
package/lib/hooks.mjs ADDED
@@ -0,0 +1,118 @@
1
+ // lib/hooks.mjs
2
+ // polycode hook executor. Matches the public Claude Code hook contract
3
+ // published at docs.claude.com so hook scripts written for Claude Code run
4
+ // unchanged in polycode. This is a compatibility affordance, not a code
5
+ // dependency on Claude Code.
6
+ //
7
+ // Hook events (from the public spec):
8
+ // - SessionStart (matchers: startup, resume, clear, compact)
9
+ // - UserPromptSubmit (before the prompt is processed)
10
+ // - PreToolUse (before tool execution; can block or modify)
11
+ // - PostToolUse (after tool succeeds)
12
+ // - Stop (turn complete)
13
+ // - PreCompact (before context compaction)
14
+ // - SessionEnd (session terminates)
15
+ // - SubagentStop (subagent finished)
16
+ // - Notification (alerts, permission prompts, idle prompts)
17
+ //
18
+ // Hook scripts live under POLYCODE_HOOK_DIR (default ~/.polycode/hooks/)
19
+ // keyed by event name. They receive the event JSON on stdin and can
20
+ // return JSON on stdout to block or modify the event.
21
+ //
22
+ // Example ~/.polycode/hooks/PreToolUse.sh:
23
+ // #!/bin/bash
24
+ // read -r event
25
+ // tool=$(echo "$event" | jq -r .tool_name)
26
+ // if [ "$tool" = "bash" ]; then
27
+ // # Block any bash call with "rm -rf"
28
+ // cmd=$(echo "$event" | jq -r .tool_input.command)
29
+ // if echo "$cmd" | grep -q "rm -rf"; then
30
+ // echo '{"action":"block","reason":"rm -rf is not allowed"}'
31
+ // exit 0
32
+ // fi
33
+ // fi
34
+ // echo '{"action":"allow"}'
35
+
36
+ import { existsSync, statSync } from 'node:fs';
37
+ import { join } from 'node:path';
38
+ import { spawn } from 'node:child_process';
39
+
40
+ const HOOK_EVENTS = new Set([
41
+ 'SessionStart',
42
+ 'UserPromptSubmit',
43
+ 'PreToolUse',
44
+ 'PostToolUse',
45
+ 'Stop',
46
+ 'PreCompact',
47
+ 'SessionEnd',
48
+ 'SubagentStop',
49
+ 'Notification',
50
+ ]);
51
+
52
+ const HOOK_TIMEOUT_MS = 10_000;
53
+
54
+ export async function fireHook(eventName, payload, hookDir) {
55
+ if (!HOOK_EVENTS.has(eventName)) {
56
+ return { action: 'allow', reason: 'unknown event' };
57
+ }
58
+ if (!hookDir || !existsSync(hookDir)) {
59
+ return { action: 'allow', reason: 'no hook dir' };
60
+ }
61
+
62
+ const candidates = [
63
+ join(hookDir, eventName),
64
+ join(hookDir, `${eventName}.sh`),
65
+ join(hookDir, `${eventName}.mjs`),
66
+ join(hookDir, `${eventName}.js`),
67
+ ];
68
+
69
+ const hookPath = candidates.find((p) => existsSync(p) && statSync(p).isFile());
70
+ if (!hookPath) return { action: 'allow', reason: 'no hook script' };
71
+
72
+ const isShell = hookPath.endsWith('.sh') || !hookPath.includes('.');
73
+ const isNode = hookPath.endsWith('.mjs') || hookPath.endsWith('.js');
74
+
75
+ return new Promise((resolveHook) => {
76
+ let child;
77
+ try {
78
+ child = isNode
79
+ ? spawn('node', [hookPath], { stdio: ['pipe', 'pipe', 'pipe'] })
80
+ : spawn('sh', [hookPath], { stdio: ['pipe', 'pipe', 'pipe'] });
81
+ } catch (err) {
82
+ resolveHook({ action: 'allow', reason: `spawn failed: ${err.message}` });
83
+ return;
84
+ }
85
+
86
+ let stdoutBuf = '';
87
+ let stderrBuf = '';
88
+ const timer = setTimeout(() => {
89
+ child.kill('SIGKILL');
90
+ }, HOOK_TIMEOUT_MS);
91
+
92
+ child.stdout.on('data', (b) => { stdoutBuf += b.toString(); });
93
+ child.stderr.on('data', (b) => { stderrBuf += b.toString(); });
94
+
95
+ child.on('close', (code) => {
96
+ clearTimeout(timer);
97
+ if (code !== 0) {
98
+ resolveHook({ action: 'allow', reason: `hook exit ${code}: ${stderrBuf.slice(0, 200)}` });
99
+ return;
100
+ }
101
+ const trimmed = stdoutBuf.trim();
102
+ if (!trimmed) {
103
+ resolveHook({ action: 'allow' });
104
+ return;
105
+ }
106
+ try {
107
+ resolveHook(JSON.parse(trimmed));
108
+ } catch {
109
+ resolveHook({ action: 'allow', reason: 'hook output not JSON' });
110
+ }
111
+ });
112
+
113
+ child.stdin.write(JSON.stringify(payload));
114
+ child.stdin.end();
115
+ });
116
+ }
117
+
118
+ export { HOOK_EVENTS };
@@ -0,0 +1,67 @@
1
+ // lib/inference-router.mjs
2
+ // Provider key loader. Reads GROQ_API_KEY and optional ANTHROPIC_API_KEY from
3
+ // environment variables or from ~/.polycode/secrets.env (chmod 600 recommended).
4
+ // Keys are never logged, echoed, or written to any session log row.
5
+ //
6
+ // polycode is inspired by Claude Code's public architecture at docs.claude.com.
7
+ // polycode is not affiliated with Anthropic. No Claude Code code or system
8
+ // prompts are copied. Anthropic and Groq are consumed as paid API providers
9
+ // under their public terms of service.
10
+
11
+ import { readFileSync, existsSync } from 'node:fs';
12
+ import { homedir } from 'node:os';
13
+ import { join } from 'node:path';
14
+
15
+ let ANTHROPIC_KEYS_CACHE = null;
16
+
17
+ function maskKey(k) {
18
+ if (!k || k.length < 20) return '[redacted]';
19
+ return k.slice(0, 10) + '...' + k.slice(-4);
20
+ }
21
+
22
+ export function loadAnthropicKeys() {
23
+ if (ANTHROPIC_KEYS_CACHE) return ANTHROPIC_KEYS_CACHE;
24
+
25
+ const seen = new Set();
26
+ const keys = [];
27
+
28
+ const secretsPath = join(homedir(), '.polycode', 'secrets.env');
29
+ if (existsSync(secretsPath)) {
30
+ try {
31
+ const content = readFileSync(secretsPath, 'utf8');
32
+ const primary = content.match(/^ANTHROPIC_API_KEY=(.+)$/m);
33
+ const backup = content.match(/^ANTHROPIC_API_KEY_BACKUP=(.+)$/m);
34
+ if (primary) {
35
+ const k = primary[1].trim().replace(/^['"]|['"]$/g, '');
36
+ if (!seen.has(k)) { seen.add(k); keys.push({ role: 'primary', key: k }); }
37
+ }
38
+ if (backup) {
39
+ const k = backup[1].trim().replace(/^['"]|['"]$/g, '');
40
+ if (!seen.has(k)) { seen.add(k); keys.push({ role: 'backup', key: k }); }
41
+ }
42
+ } catch {
43
+ // Ignore parse errors and fall through to environment.
44
+ }
45
+ }
46
+
47
+ if (process.env.ANTHROPIC_API_KEY && !seen.has(process.env.ANTHROPIC_API_KEY)) {
48
+ seen.add(process.env.ANTHROPIC_API_KEY);
49
+ keys.push({ role: 'env-primary', key: process.env.ANTHROPIC_API_KEY });
50
+ }
51
+ if (process.env.ANTHROPIC_API_KEY_BACKUP && !seen.has(process.env.ANTHROPIC_API_KEY_BACKUP)) {
52
+ seen.add(process.env.ANTHROPIC_API_KEY_BACKUP);
53
+ keys.push({ role: 'env-backup', key: process.env.ANTHROPIC_API_KEY_BACKUP });
54
+ }
55
+
56
+ ANTHROPIC_KEYS_CACHE = keys;
57
+ return keys;
58
+ }
59
+
60
+ export function anthropicAvailable() {
61
+ return loadAnthropicKeys().length > 0;
62
+ }
63
+
64
+ export function reportKeyStatus() {
65
+ const keys = loadAnthropicKeys();
66
+ return keys.map((k) => ({ role: k.role, masked: maskKey(k.key) }));
67
+ }
package/lib/intent.mjs ADDED
@@ -0,0 +1,31 @@
1
+ // lib/intent.mjs
2
+ // The Intent plane. Every turn operates within an ACTIVE INTENT, which is a
3
+ // canon row of type 'user_intent' that references the user's goal for the
4
+ // current stretch of turns. The context builder uses the active intent_id to
5
+ // structurally scope canon queries. This is how polycode stays coherent
6
+ // without holding full history in the model's context window.
7
+
8
+ export function ensureActiveIntent(canon, userMessage) {
9
+ const active = canon.activeIntent();
10
+ if (active) return active;
11
+ const intentId = `intent_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
12
+ return canon.append('user_intent', {
13
+ intent_id: intentId,
14
+ text: userMessage,
15
+ created_at: new Date().toISOString(),
16
+ });
17
+ }
18
+
19
+ export function rotateIntent(canon, newIntentText) {
20
+ const intentId = `intent_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
21
+ return canon.append('user_intent', {
22
+ intent_id: intentId,
23
+ text: newIntentText,
24
+ created_at: new Date().toISOString(),
25
+ });
26
+ }
27
+
28
+ export function currentIntentId(canon) {
29
+ const active = canon.activeIntent();
30
+ return active?.payload?.intent_id || null;
31
+ }
@@ -0,0 +1,91 @@
1
+ // lib/repl-ui.mjs
2
+ // Streaming terminal UI with ANSI colors. Zero external deps.
3
+ // The renderer subscribes to events emitted by AgenticLoop.runTurn() and
4
+ // prints each phase with a colored prefix. The verdict pill is rendered
5
+ // when a commitment is recorded.
6
+
7
+ export const C = {
8
+ reset: '\x1b[0m',
9
+ dim: '\x1b[2m',
10
+ bold: '\x1b[1m',
11
+ amber: '\x1b[38;5;214m',
12
+ gold: '\x1b[38;5;220m',
13
+ lightAmber: '\x1b[38;5;221m',
14
+ coolBlue: '\x1b[38;5;74m',
15
+ coral: '\x1b[38;5;209m',
16
+ darkMaroon: '\x1b[38;5;88m',
17
+ navy: '\x1b[38;5;24m',
18
+ gray: '\x1b[38;5;244m',
19
+ green: '\x1b[32m',
20
+ red: '\x1b[31m',
21
+ cyan: '\x1b[36m',
22
+ };
23
+
24
+ function verdictColor(v) {
25
+ return {
26
+ VERIFIED: C.amber,
27
+ SURVIVED: C.lightAmber,
28
+ PENDING: C.coolBlue,
29
+ CHALLENGED: C.coral,
30
+ REFUTED: C.darkMaroon,
31
+ }[v] || C.gray;
32
+ }
33
+
34
+ export function verdictPill(v) {
35
+ return `${verdictColor(v)}[${v}]${C.reset}`;
36
+ }
37
+
38
+ function phasePrefix(name) {
39
+ const map = {
40
+ intent: `${C.amber}. intent${C.reset}`,
41
+ ground: `${C.lightAmber}. ground${C.reset}`,
42
+ ground_complete: `${C.lightAmber}. ground${C.reset}`,
43
+ scrub_blocked: `${C.red}. scrub${C.reset}`,
44
+ dispatch: `${C.coolBlue}. dispatch${C.reset}`,
45
+ act: `${C.navy}. act${C.reset}`,
46
+ record: `${C.gold}. record${C.reset}`,
47
+ fallback: `${C.gray}. fallback${C.reset}`,
48
+ error: `${C.red}. error${C.reset}`,
49
+ };
50
+ return map[name] || `${C.gray}. ${name}${C.reset}`;
51
+ }
52
+
53
+ export function createRenderer(stdout) {
54
+ function line(s) { stdout.write(s + '\n'); }
55
+
56
+ function onEvent(ev) {
57
+ if (ev.phase === 'intent') {
58
+ line(`${phasePrefix('intent')} ${C.dim}active intent resolved${C.reset}`);
59
+ } else if (ev.phase === 'ground_complete') {
60
+ const compiler = ev.compilerProvider || 'pure-node';
61
+ const rows = ev.selectedRows || 0;
62
+ const fallback = ev.fallback ? ' (fallback)' : '';
63
+ line(`${phasePrefix('ground')} ${C.dim}compile-packet: ~${ev.estimatedTokens} tokens . ${compiler}${fallback} . ${rows} rows selected${C.reset}`);
64
+ } else if (ev.phase === 'scrub_blocked') {
65
+ line(`${phasePrefix('scrub_blocked')} ${C.red}secret bleed blocked: ${(ev.findings || []).map((f) => f.pattern).join(', ')}${C.reset}`);
66
+ } else if (ev.phase === 'dispatch') {
67
+ line(`${phasePrefix('dispatch')} ${C.dim}iteration ${ev.iteration}${C.reset}`);
68
+ } else if (ev.phase === 'fallback') {
69
+ line(`${phasePrefix('fallback')} ${C.dim}${ev.to}: ${String(ev.reason || '').slice(0, 80)}${C.reset}`);
70
+ } else if (ev.phase === 'act' && ev.kind === 'message') {
71
+ const content = String(ev.content || '').trim();
72
+ if (content) line(`${C.cyan}${content}${C.reset}`);
73
+ } else if (ev.phase === 'act' && ev.kind === 'tool_call') {
74
+ const preview = JSON.stringify(ev.args || {}).slice(0, 140);
75
+ line(`${phasePrefix('act')} ${C.bold}${ev.name}${C.reset}(${C.dim}${preview}${C.reset})`);
76
+ } else if (ev.phase === 'act' && ev.kind === 'tool_result') {
77
+ const preview = String(ev.result || '').slice(0, 200).replace(/\n/g, ' ');
78
+ line(`${phasePrefix('act')} ${C.dim}-> ${preview}${C.reset}`);
79
+ } else if (ev.phase === 'record') {
80
+ const c = ev.commitment;
81
+ const pill = verdictPill(c.verdict);
82
+ const hash = c.row?.row_hash?.slice(0, 8) || '????????';
83
+ const delta = c.trustDelta >= 0 ? `+${c.trustDelta}` : `${c.trustDelta}`;
84
+ line(`${phasePrefix('record')} ${pill} ${C.dim}block #${hash} . delta_N=${delta}${C.reset}`);
85
+ } else if (ev.phase === 'error') {
86
+ line(`${phasePrefix('error')} ${C.red}${ev.message}${C.reset}`);
87
+ }
88
+ }
89
+
90
+ return { onEvent, C, verdictPill };
91
+ }
@@ -0,0 +1,83 @@
1
+ // lib/slash-commands.mjs
2
+ // Slash command dispatcher for the interactive REPL. Each command is a pure
3
+ // function over (canon, args, state, stdout). The dispatcher returns either
4
+ // {continue: true} or {exit: true}.
5
+
6
+ import { C } from './repl-ui.mjs';
7
+ import { rotateIntent } from './intent.mjs';
8
+
9
+ export const SLASH_HELP = `
10
+ ${C.bold}polycode commands${C.reset}
11
+ /help Show this help
12
+ /log Show session log path, row count, and last hash
13
+ /replay <n> Print the last N session log rows
14
+ /verify Verify session log SHA-256 chain integrity
15
+ /intent Show the active intent
16
+ /intent <text> Set a new active intent
17
+ /clear Clear the screen (session log is untouched)
18
+ /exit, /quit Leave polycode
19
+ `;
20
+
21
+ export async function dispatchSlash(line, { canon, state, stdout }) {
22
+ const [cmd, ...rest] = line.slice(1).split(/\s+/);
23
+ const args = rest.join(' ');
24
+
25
+ switch (cmd) {
26
+ case 'help':
27
+ stdout.write(SLASH_HELP + '\n');
28
+ return { continue: true };
29
+
30
+ case 'log':
31
+ case 'history':
32
+ case 'canon': {
33
+ stdout.write(`${C.amber}session log${C.reset}: ${canon.size()} rows, last ${canon.lastHash().slice(0, 16)}...\n`);
34
+ stdout.write(`${C.dim}path: ${canon.path}${C.reset}\n`);
35
+ return { continue: true };
36
+ }
37
+
38
+ case 'intent': {
39
+ if (!args) {
40
+ const cur = canon.activeIntent();
41
+ if (cur) stdout.write(`${C.amber}active intent${C.reset}: ${cur.payload.text}\n`);
42
+ else stdout.write(`${C.dim}no active intent${C.reset}\n`);
43
+ return { continue: true };
44
+ }
45
+ const row = rotateIntent(canon, args);
46
+ stdout.write(`${C.amber}intent set${C.reset}: ${row.payload.intent_id}\n`);
47
+ return { continue: true };
48
+ }
49
+
50
+ case 'replay': {
51
+ const n = Number(args) || 5;
52
+ const rows = canon.tail(n);
53
+ for (const r of rows) {
54
+ const kind = r.type.padEnd(22);
55
+ const snippet = JSON.stringify(r.payload).slice(0, 140);
56
+ stdout.write(`${C.dim}#${r.row_index} ${kind} ${snippet}${C.reset}\n`);
57
+ }
58
+ return { continue: true };
59
+ }
60
+
61
+ case 'verify': {
62
+ const r = canon.verify();
63
+ if (r.valid) {
64
+ stdout.write(`${C.amber}session log verified${C.reset}: ${r.rows} rows, last ${r.last_hash.slice(0, 16)}...\n`);
65
+ } else {
66
+ stdout.write(`${C.red}session log broken${C.reset}: ${r.reason}\n`);
67
+ }
68
+ return { continue: true };
69
+ }
70
+
71
+ case 'clear':
72
+ stdout.write('\x1b[2J\x1b[H');
73
+ return { continue: true };
74
+
75
+ case 'exit':
76
+ case 'quit':
77
+ return { exit: true };
78
+
79
+ default:
80
+ stdout.write(`${C.red}unknown command${C.reset}: /${cmd}. Type /help for commands.\n`);
81
+ return { continue: true };
82
+ }
83
+ }