@kinqs/brainrouter-cli 0.3.4

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.
Files changed (87) hide show
  1. package/.env.example +109 -0
  2. package/README.md +185 -0
  3. package/dist/agent/agent.d.ts +765 -0
  4. package/dist/agent/agent.js +1977 -0
  5. package/dist/cli/cliPrompt.d.ts +15 -0
  6. package/dist/cli/cliPrompt.js +62 -0
  7. package/dist/cli/commands/_context.d.ts +53 -0
  8. package/dist/cli/commands/_context.js +14 -0
  9. package/dist/cli/commands/_helpers.d.ts +45 -0
  10. package/dist/cli/commands/_helpers.js +140 -0
  11. package/dist/cli/commands/guard.d.ts +6 -0
  12. package/dist/cli/commands/guard.js +292 -0
  13. package/dist/cli/commands/memory.d.ts +12 -0
  14. package/dist/cli/commands/memory.js +263 -0
  15. package/dist/cli/commands/obs.d.ts +6 -0
  16. package/dist/cli/commands/obs.js +208 -0
  17. package/dist/cli/commands/orchestration.d.ts +6 -0
  18. package/dist/cli/commands/orchestration.js +218 -0
  19. package/dist/cli/commands/session.d.ts +6 -0
  20. package/dist/cli/commands/session.js +191 -0
  21. package/dist/cli/commands/ui.d.ts +6 -0
  22. package/dist/cli/commands/ui.js +477 -0
  23. package/dist/cli/commands/workflow.d.ts +6 -0
  24. package/dist/cli/commands/workflow.js +691 -0
  25. package/dist/cli/repl.d.ts +12 -0
  26. package/dist/cli/repl.js +894 -0
  27. package/dist/config/config.d.ts +22 -0
  28. package/dist/config/config.js +105 -0
  29. package/dist/config/workspace.d.ts +7 -0
  30. package/dist/config/workspace.js +62 -0
  31. package/dist/index.d.ts +2 -0
  32. package/dist/index.js +610 -0
  33. package/dist/memory/briefing.d.ts +46 -0
  34. package/dist/memory/briefing.js +152 -0
  35. package/dist/memory/consolidation.d.ts +60 -0
  36. package/dist/memory/consolidation.js +208 -0
  37. package/dist/memory/formatters.d.ts +38 -0
  38. package/dist/memory/formatters.js +102 -0
  39. package/dist/memory/mentions.d.ts +10 -0
  40. package/dist/memory/mentions.js +72 -0
  41. package/dist/orchestration/orchestrator.d.ts +36 -0
  42. package/dist/orchestration/orchestrator.js +71 -0
  43. package/dist/orchestration/roles.d.ts +11 -0
  44. package/dist/orchestration/roles.js +117 -0
  45. package/dist/orchestration/tools.d.ts +244 -0
  46. package/dist/orchestration/tools.js +528 -0
  47. package/dist/prompt/breadthHint.d.ts +48 -0
  48. package/dist/prompt/breadthHint.js +93 -0
  49. package/dist/prompt/compactor.d.ts +31 -0
  50. package/dist/prompt/compactor.js +112 -0
  51. package/dist/prompt/initAgentMd.d.ts +13 -0
  52. package/dist/prompt/initAgentMd.js +194 -0
  53. package/dist/prompt/skillRunner.d.ts +34 -0
  54. package/dist/prompt/skillRunner.js +146 -0
  55. package/dist/prompt/systemPrompt.d.ts +10 -0
  56. package/dist/prompt/systemPrompt.js +171 -0
  57. package/dist/runtime/clipboard.d.ts +17 -0
  58. package/dist/runtime/clipboard.js +52 -0
  59. package/dist/runtime/llmSemaphore.d.ts +30 -0
  60. package/dist/runtime/llmSemaphore.js +67 -0
  61. package/dist/runtime/loopRunner.d.ts +25 -0
  62. package/dist/runtime/loopRunner.js +79 -0
  63. package/dist/runtime/mcpClient.d.ts +156 -0
  64. package/dist/runtime/mcpClient.js +234 -0
  65. package/dist/runtime/mcpUtils.d.ts +36 -0
  66. package/dist/runtime/mcpUtils.js +64 -0
  67. package/dist/runtime/sandbox.d.ts +48 -0
  68. package/dist/runtime/sandbox.js +156 -0
  69. package/dist/runtime/tracing.d.ts +25 -0
  70. package/dist/runtime/tracing.js +91 -0
  71. package/dist/state/cliState.d.ts +59 -0
  72. package/dist/state/cliState.js +311 -0
  73. package/dist/state/goalStore.d.ts +174 -0
  74. package/dist/state/goalStore.js +410 -0
  75. package/dist/state/hookifyStore.d.ts +80 -0
  76. package/dist/state/hookifyStore.js +237 -0
  77. package/dist/state/hooksStore.d.ts +42 -0
  78. package/dist/state/hooksStore.js +71 -0
  79. package/dist/state/preferencesStore.d.ts +41 -0
  80. package/dist/state/preferencesStore.js +25 -0
  81. package/dist/state/sessionStore.d.ts +42 -0
  82. package/dist/state/sessionStore.js +193 -0
  83. package/dist/state/taskStore.d.ts +23 -0
  84. package/dist/state/taskStore.js +80 -0
  85. package/dist/state/workflowArtifacts.d.ts +33 -0
  86. package/dist/state/workflowArtifacts.js +139 -0
  87. package/package.json +71 -0
@@ -0,0 +1,152 @@
1
+ import { redactText } from '../state/sessionStore.js';
2
+ import { callMcpTool } from '../runtime/mcpUtils.js';
3
+ /**
4
+ * Run pre-turn memory queries in parallel and assemble a compact briefing block.
5
+ * This is the System-1 entry point: every turn pays a small fixed cost to ask
6
+ * the BrainRouter brain "what do I already know that matters here?" so the LLM
7
+ * does not redo work the agent has done before in this workspace.
8
+ */
9
+ export async function buildMemoryBriefing(inputs) {
10
+ const { mcpClient, mcpTools, sessionKey, workspaceRoot, query, activeSkill } = inputs;
11
+ const maxChars = inputs.maxCharsPerSource ?? 4000;
12
+ const toolNames = new Set(mcpTools.map((t) => t.name));
13
+ const tasks = [];
14
+ if (toolNames.has('memory_recall')) {
15
+ tasks.push(callSafe('memory_recall', { sessionKey, query, activeSkill }, mcpClient, maxChars, extractRecords));
16
+ }
17
+ if (toolNames.has('memory_working_context')) {
18
+ tasks.push(callSafe('memory_working_context', { sessionKey, workspacePath: workspaceRoot }, mcpClient, maxChars));
19
+ }
20
+ if (toolNames.has('memory_task_state')) {
21
+ tasks.push(callSafe('memory_task_state', { query }, mcpClient, maxChars));
22
+ }
23
+ const results = await Promise.all(tasks);
24
+ const sections = [];
25
+ const sourcesQueried = [];
26
+ const recalledRecords = [];
27
+ for (const r of results) {
28
+ if (!r.text)
29
+ continue;
30
+ sourcesQueried.push(r.source);
31
+ if (r.records && r.records.length > 0) {
32
+ // Render structured cards instead of dumping the raw JSON. The previous
33
+ // form emitted ~2-4KB of `recallExplanation`/`sparkedNodes`/etc. per
34
+ // turn — high signal-to-noise loss AND a 4000-char hard slice that
35
+ // routinely chopped the payload mid-string. Cards are ~120 chars each.
36
+ const cards = r.records.slice(0, 8).map((rec) => {
37
+ const idTag = `[${rec.recordId}]`;
38
+ const typeTag = rec.type ? ` (${rec.type})` : '';
39
+ const content = (rec.content ?? '').replace(/\s+/g, ' ').trim();
40
+ const preview = content.length > 240 ? content.slice(0, 239) + '…' : content;
41
+ return `- ${idTag}${typeTag} ${preview}`;
42
+ });
43
+ sections.push(`### ${prettyLabel(r.source)}\n${cards.join('\n')}`);
44
+ recalledRecords.push(...r.records);
45
+ }
46
+ else {
47
+ // No structured records to render. Treat the JSON dump as opaque and
48
+ // only include it when it carries actual signal (skip the
49
+ // `keyword-empty` / zero-hits responses that the MCP returns when
50
+ // recall genuinely had nothing to surface).
51
+ const trimmed = r.text.trim();
52
+ if (!trimmed ||
53
+ /"recallStrategy"\s*:\s*"(keyword|hybrid)-empty"/.test(trimmed) ||
54
+ /^[\s\S]{0,40}"ftsHits"\s*:\s*0\s*,\s*"vecHits"\s*:\s*0/.test(trimmed)) {
55
+ continue;
56
+ }
57
+ sections.push(`### ${prettyLabel(r.source)}\n${redactText(trimmed.slice(0, 1500))}`);
58
+ }
59
+ }
60
+ if (sections.length === 0) {
61
+ return { block: '', recalledRecordIds: [], recalledRecords: [], sourcesQueried };
62
+ }
63
+ const block = [
64
+ '## BrainRouter Memory Briefing',
65
+ `Session: ${sessionKey}`,
66
+ `Workspace: ${workspaceRoot}`,
67
+ '',
68
+ 'The following context was recalled before this turn. Cite the IDs of records you actually used in your reasoning.',
69
+ '',
70
+ ...sections,
71
+ ].join('\n');
72
+ const recalledRecordIds = dedupe(recalledRecords.map((r) => r.recordId));
73
+ return { block, recalledRecordIds, recalledRecords, sourcesQueried };
74
+ }
75
+ /**
76
+ * Heuristic for which recalled records actually informed the assistant's
77
+ * final answer. We mark a record as "cited" when:
78
+ * - its recordId literally appears in the answer text, OR
79
+ * - a distinctive snippet (≥ 24 chars of non-trivial content) from its
80
+ * content appears verbatim in the answer.
81
+ * Conservative on purpose — false positives hurt memory quality more than
82
+ * false negatives, since uncited records get demoted next time around.
83
+ */
84
+ export function selectCitedRecordIds(records, finalAnswer) {
85
+ if (!finalAnswer || records.length === 0)
86
+ return [];
87
+ const haystack = finalAnswer.toLowerCase();
88
+ const cited = [];
89
+ for (const record of records) {
90
+ if (!record.recordId)
91
+ continue;
92
+ if (haystack.includes(record.recordId.toLowerCase())) {
93
+ cited.push(record.recordId);
94
+ continue;
95
+ }
96
+ const snippet = extractDistinctiveSnippet(record.content);
97
+ if (snippet && haystack.includes(snippet.toLowerCase())) {
98
+ cited.push(record.recordId);
99
+ }
100
+ }
101
+ return dedupe(cited);
102
+ }
103
+ function extractDistinctiveSnippet(content) {
104
+ if (!content)
105
+ return undefined;
106
+ const trimmed = content.trim();
107
+ if (trimmed.length < 24)
108
+ return undefined;
109
+ // Use the longest line that looks like substantive content; skip headings / bullets.
110
+ const lines = trimmed.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
111
+ const ranked = lines
112
+ .filter((l) => !/^[#>\-*]/.test(l) && l.length >= 24)
113
+ .sort((a, b) => b.length - a.length);
114
+ const candidate = ranked[0] ?? trimmed;
115
+ return candidate.slice(0, Math.min(60, candidate.length));
116
+ }
117
+ async function callSafe(toolName, args, mcpClient, maxChars, extractRecordsFn) {
118
+ const res = await callMcpTool(mcpClient, toolName, args);
119
+ if (res.isError || !res.text.trim())
120
+ return { source: toolName, text: null };
121
+ const records = extractRecordsFn && res.parsed ? extractRecordsFn(res.parsed) : undefined;
122
+ return { source: toolName, text: res.text.slice(0, maxChars), records };
123
+ }
124
+ function extractRecords(parsed) {
125
+ if (!parsed)
126
+ return [];
127
+ const records = parsed.recalledCognitiveMemories ??
128
+ parsed.recalledCognitiveRecords ??
129
+ parsed.records ??
130
+ [];
131
+ if (!Array.isArray(records))
132
+ return [];
133
+ return records
134
+ .filter((r) => r && (typeof r.recordId === 'string' || typeof r.recordId === 'number'))
135
+ .map((r) => ({
136
+ recordId: String(r.recordId),
137
+ content: typeof r.content === 'string' ? r.content : undefined,
138
+ type: typeof r.type === 'string' ? r.type : undefined,
139
+ priority: typeof r.priority === 'number' ? r.priority : undefined,
140
+ }));
141
+ }
142
+ function prettyLabel(toolName) {
143
+ switch (toolName) {
144
+ case 'memory_recall': return 'Recalled cognitive memories';
145
+ case 'memory_working_context': return 'Working memory canvas';
146
+ case 'memory_task_state': return 'Open task / handover state';
147
+ default: return toolName;
148
+ }
149
+ }
150
+ function dedupe(items) {
151
+ return Array.from(new Set(items));
152
+ }
@@ -0,0 +1,60 @@
1
+ import type { McpClientWrapper } from '../runtime/mcpClient.js';
2
+ /**
3
+ * Filesystem memory consolidation — the human-readable companion to the
4
+ * cognitive memory database.
5
+ *
6
+ * Brainrouter's MCP already stores recall records in the cognitive memory DB.
7
+ * This module writes filesystem artifacts so users get a human-readable view
8
+ * of what was learned across sessions:
9
+ *
10
+ * ~/.brainrouter/workspaces/<encoded>/memories/
11
+ * MEMORY.md - one-line index of all consolidated entries
12
+ * raw_memories.md - merged "raw" memories in stable order
13
+ * user.md - profile facts about the user
14
+ * feedback.md - "do this / don't do this" guidance
15
+ * project.md - in-flight project context
16
+ * reference.md - pointers to external systems
17
+ * rollout_summaries/ - one .md per recent session summary
18
+ *
19
+ * The CLI populates these from `memory_search`/`memory_recall` results at the
20
+ * end of a session or when the user runs `/memories consolidate`. This file is
21
+ * pure — it never calls the LLM; the records were already extracted by the
22
+ * agent loop and stored via memory_capture_turn.
23
+ */
24
+ export declare function memoriesDir(workspaceRoot: string): string;
25
+ export declare function ensureMemoriesDir(workspaceRoot: string): string;
26
+ export type MemoryType = 'user' | 'feedback' | 'project' | 'reference';
27
+ export interface MemoryRecord {
28
+ recordId: string;
29
+ type: MemoryType | string;
30
+ content: string;
31
+ scene?: string;
32
+ capturedAt?: string;
33
+ }
34
+ interface ConsolidationResult {
35
+ totalRecords: number;
36
+ perType: Record<string, number>;
37
+ files: string[];
38
+ }
39
+ /**
40
+ * Pull every memory record we can see from MCP and write the per-type
41
+ * markdown files. Records without a known type land in `raw_memories.md` so
42
+ * nothing is lost.
43
+ */
44
+ export declare function consolidateMemories(mcpClient: McpClientWrapper, workspaceRoot: string, options?: {
45
+ sessionKey?: string;
46
+ query?: string;
47
+ }): Promise<ConsolidationResult>;
48
+ /**
49
+ * Write a per-session rollout summary file. Used by /handover or auto-capture
50
+ * at session end. Each summary lives alongside the others so users can scan
51
+ * across sessions without trawling transcripts.
52
+ */
53
+ export declare function writeRolloutSummary(workspaceRoot: string, sessionKey: string, summary: {
54
+ firstPrompt?: string;
55
+ lastPrompt?: string;
56
+ turnCount: number;
57
+ totalTokens: number;
58
+ body: string;
59
+ }): string;
60
+ export {};
@@ -0,0 +1,208 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { callMcpTool } from '../runtime/mcpUtils.js';
4
+ import { getWorkspaceStateRoot } from '../state/cliState.js';
5
+ /**
6
+ * Filesystem memory consolidation — the human-readable companion to the
7
+ * cognitive memory database.
8
+ *
9
+ * Brainrouter's MCP already stores recall records in the cognitive memory DB.
10
+ * This module writes filesystem artifacts so users get a human-readable view
11
+ * of what was learned across sessions:
12
+ *
13
+ * ~/.brainrouter/workspaces/<encoded>/memories/
14
+ * MEMORY.md - one-line index of all consolidated entries
15
+ * raw_memories.md - merged "raw" memories in stable order
16
+ * user.md - profile facts about the user
17
+ * feedback.md - "do this / don't do this" guidance
18
+ * project.md - in-flight project context
19
+ * reference.md - pointers to external systems
20
+ * rollout_summaries/ - one .md per recent session summary
21
+ *
22
+ * The CLI populates these from `memory_search`/`memory_recall` results at the
23
+ * end of a session or when the user runs `/memories consolidate`. This file is
24
+ * pure — it never calls the LLM; the records were already extracted by the
25
+ * agent loop and stored via memory_capture_turn.
26
+ */
27
+ export function memoriesDir(workspaceRoot) {
28
+ return path.join(getWorkspaceStateRoot(workspaceRoot), 'memories');
29
+ }
30
+ export function ensureMemoriesDir(workspaceRoot) {
31
+ const dir = memoriesDir(workspaceRoot);
32
+ if (!fs.existsSync(dir))
33
+ fs.mkdirSync(dir, { recursive: true });
34
+ const summaries = path.join(dir, 'rollout_summaries');
35
+ if (!fs.existsSync(summaries))
36
+ fs.mkdirSync(summaries, { recursive: true });
37
+ return dir;
38
+ }
39
+ /**
40
+ * Pull every memory record we can see from MCP and write the per-type
41
+ * markdown files. Records without a known type land in `raw_memories.md` so
42
+ * nothing is lost.
43
+ */
44
+ export async function consolidateMemories(mcpClient, workspaceRoot, options = {}) {
45
+ const dir = ensureMemoriesDir(workspaceRoot);
46
+ const query = options.query ?? '*';
47
+ const recall = await callMcpTool(mcpClient, 'memory_search', { query, sessionKey: options.sessionKey });
48
+ if (recall.isError) {
49
+ throw new Error(`memory_search failed: ${recall.text || '(no message)'}`);
50
+ }
51
+ const records = extractRecordsFromMcp(recall.parsed);
52
+ const buckets = {
53
+ user: [], feedback: [], project: [], reference: [], raw: [],
54
+ };
55
+ for (const rec of records) {
56
+ const t = String(rec.type ?? '').toLowerCase();
57
+ if (t === 'user' || t === 'feedback' || t === 'project' || t === 'reference') {
58
+ buckets[t].push(rec);
59
+ }
60
+ else {
61
+ buckets.raw.push(rec);
62
+ }
63
+ }
64
+ const filesWritten = [];
65
+ for (const t of ['user', 'feedback', 'project', 'reference', 'raw']) {
66
+ const file = path.join(dir, t === 'raw' ? 'raw_memories.md' : `${t}.md`);
67
+ fs.writeFileSync(file, renderMemoryFile(t, buckets[t]), 'utf8');
68
+ filesWritten.push(file);
69
+ }
70
+ const index = renderIndex(records, buckets);
71
+ const indexFile = path.join(dir, 'MEMORY.md');
72
+ fs.writeFileSync(indexFile, index, 'utf8');
73
+ filesWritten.push(indexFile);
74
+ return {
75
+ totalRecords: records.length,
76
+ perType: {
77
+ user: buckets.user.length,
78
+ feedback: buckets.feedback.length,
79
+ project: buckets.project.length,
80
+ reference: buckets.reference.length,
81
+ raw: buckets.raw.length,
82
+ },
83
+ files: filesWritten.map((p) => path.relative(workspaceRoot, p)),
84
+ };
85
+ }
86
+ function extractRecordsFromMcp(parsed) {
87
+ if (!parsed)
88
+ return [];
89
+ const candidates = [
90
+ parsed?.records,
91
+ parsed?.results,
92
+ parsed?.items,
93
+ Array.isArray(parsed) ? parsed : undefined,
94
+ ].filter((x) => Array.isArray(x));
95
+ for (const list of candidates) {
96
+ const out = [];
97
+ for (const r of list) {
98
+ if (!r)
99
+ continue;
100
+ const recordId = String(r.recordId ?? r.id ?? r.record_id ?? '');
101
+ if (!recordId)
102
+ continue;
103
+ out.push({
104
+ recordId,
105
+ type: String(r.type ?? r.memoryType ?? 'raw'),
106
+ content: String(r.content ?? r.text ?? r.body ?? r.summary ?? ''),
107
+ scene: r.scene ?? r.focusScene,
108
+ capturedAt: r.capturedAt ?? r.createdAt ?? r.timestamp,
109
+ });
110
+ }
111
+ if (out.length > 0)
112
+ return out;
113
+ }
114
+ return [];
115
+ }
116
+ function renderMemoryFile(type, records) {
117
+ const heading = type === 'raw' ? 'Raw memories' : `${capitalize(type)} memory`;
118
+ const intro = type === 'raw'
119
+ ? 'Memories whose type the agent did not classify into user/feedback/project/reference.'
120
+ : descriptionFor(type);
121
+ const body = records.length === 0
122
+ ? '_(empty — no records of this type yet)_'
123
+ : records.sort(stableSort).map(renderRecord).join('\n\n');
124
+ return [`# ${heading}`, '', intro, '', body, ''].join('\n');
125
+ }
126
+ function descriptionFor(type) {
127
+ switch (type) {
128
+ case 'user': return 'Profile facts about the user — role, expertise, goals.';
129
+ case 'feedback': return 'Validated guidance from the user about how to approach work (do/avoid).';
130
+ case 'project': return 'In-flight project context: deadlines, stakeholders, motivation.';
131
+ case 'reference': return 'Pointers to external systems (Linear, Grafana, GitHub) where authoritative info lives.';
132
+ default: return '';
133
+ }
134
+ }
135
+ function renderRecord(rec) {
136
+ const lines = [];
137
+ lines.push(`## ${rec.recordId}`);
138
+ if (rec.scene)
139
+ lines.push(`*Scene: ${rec.scene}*`);
140
+ if (rec.capturedAt)
141
+ lines.push(`*Captured: ${rec.capturedAt}*`);
142
+ lines.push('');
143
+ lines.push(rec.content.trim());
144
+ return lines.join('\n');
145
+ }
146
+ function renderIndex(records, buckets) {
147
+ const lines = [];
148
+ lines.push('# Memory index');
149
+ lines.push('');
150
+ lines.push(`_${records.length} consolidated memory records across ${Object.keys(buckets).length} files._`);
151
+ lines.push('');
152
+ for (const t of ['user', 'feedback', 'project', 'reference', 'raw']) {
153
+ const list = buckets[t];
154
+ if (list.length === 0)
155
+ continue;
156
+ const file = t === 'raw' ? 'raw_memories.md' : `${t}.md`;
157
+ lines.push(`## ${capitalize(t)} (${list.length})`);
158
+ lines.push(`File: [${file}](${file})`);
159
+ lines.push('');
160
+ for (const r of list.slice(0, 12).sort(stableSort)) {
161
+ const oneLine = r.content.split('\n')[0].slice(0, 140);
162
+ lines.push(`- \`${r.recordId}\` — ${oneLine}`);
163
+ }
164
+ if (list.length > 12)
165
+ lines.push(`- _…and ${list.length - 12} more in ${file}_`);
166
+ lines.push('');
167
+ }
168
+ return lines.join('\n');
169
+ }
170
+ function stableSort(a, b) {
171
+ return a.recordId.localeCompare(b.recordId);
172
+ }
173
+ function capitalize(s) {
174
+ if (!s)
175
+ return s;
176
+ return s[0].toUpperCase() + s.slice(1);
177
+ }
178
+ /**
179
+ * Write a per-session rollout summary file. Used by /handover or auto-capture
180
+ * at session end. Each summary lives alongside the others so users can scan
181
+ * across sessions without trawling transcripts.
182
+ */
183
+ export function writeRolloutSummary(workspaceRoot, sessionKey, summary) {
184
+ ensureMemoriesDir(workspaceRoot);
185
+ const safe = sessionKey.replace(/[^A-Za-z0-9._-]+/g, '-');
186
+ const file = path.join(memoriesDir(workspaceRoot), 'rollout_summaries', `${safe}.md`);
187
+ const lines = [];
188
+ lines.push(`# Session: ${sessionKey}`);
189
+ lines.push('');
190
+ lines.push(`- Turns: ${summary.turnCount}`);
191
+ lines.push(`- Total tokens: ${summary.totalTokens}`);
192
+ if (summary.firstPrompt)
193
+ lines.push(`- First prompt: ${truncate(summary.firstPrompt, 200)}`);
194
+ if (summary.lastPrompt)
195
+ lines.push(`- Last prompt: ${truncate(summary.lastPrompt, 200)}`);
196
+ lines.push('');
197
+ lines.push('## Summary');
198
+ lines.push('');
199
+ lines.push(summary.body.trim());
200
+ lines.push('');
201
+ fs.writeFileSync(file, lines.join('\n'), 'utf8');
202
+ return path.relative(workspaceRoot, file);
203
+ }
204
+ function truncate(s, n) {
205
+ if (s.length <= n)
206
+ return s;
207
+ return `${s.slice(0, n)}…`;
208
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Compact renderers for BrainRouter memory tool results.
3
+ *
4
+ * The raw `memory_recall` / `memory_search` payload can be 70k+ chars of
5
+ * mixed JSON + Mermaid graph context + persona blocks. Dumping it to stdout
6
+ * gave the user a "JSON sea" and gave the LLM 70k tokens of noise to
7
+ * hallucinate from. These helpers parse the result and render a small,
8
+ * card-style list keyed by recordId so the human reader (and any downstream
9
+ * LLM citation) only sees the facts that are actually present.
10
+ */
11
+ export interface FlatMemory {
12
+ recordId: string;
13
+ type: string;
14
+ content: string;
15
+ sceneName?: string;
16
+ skillTag?: string;
17
+ priority?: number;
18
+ confidence?: number;
19
+ }
20
+ /**
21
+ * Extract the flat memory list from whatever shape memory_recall /
22
+ * memory_search returned. The MCP wraps payloads in `<relevant-memories>`
23
+ * XML inside `prependContext`, and ALSO returns parsed arrays at the top
24
+ * level depending on the variant. We tolerate both.
25
+ */
26
+ export declare function extractMemories(parsed: any): FlatMemory[];
27
+ /**
28
+ * Render a compact ANSI-colored block: heading + N cards, each with the
29
+ * recordId, type tag, scene tag, and a one-line content preview. Returns
30
+ * the formatted string (no console.log here so callers can choose target).
31
+ */
32
+ export declare function renderMemoryCards(memories: FlatMemory[], heading: string, limit?: number): string;
33
+ /**
34
+ * Bound a raw payload to a maximum size so the agent's context doesn't get
35
+ * blown out by a 70k-char working_context dump. Used by the briefing / slash
36
+ * command rendering paths.
37
+ */
38
+ export declare function clampPayload(text: string, maxChars?: number): string;
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Compact renderers for BrainRouter memory tool results.
3
+ *
4
+ * The raw `memory_recall` / `memory_search` payload can be 70k+ chars of
5
+ * mixed JSON + Mermaid graph context + persona blocks. Dumping it to stdout
6
+ * gave the user a "JSON sea" and gave the LLM 70k tokens of noise to
7
+ * hallucinate from. These helpers parse the result and render a small,
8
+ * card-style list keyed by recordId so the human reader (and any downstream
9
+ * LLM citation) only sees the facts that are actually present.
10
+ */
11
+ import chalk from 'chalk';
12
+ /**
13
+ * Extract the flat memory list from whatever shape memory_recall /
14
+ * memory_search returned. The MCP wraps payloads in `<relevant-memories>`
15
+ * XML inside `prependContext`, and ALSO returns parsed arrays at the top
16
+ * level depending on the variant. We tolerate both.
17
+ */
18
+ export function extractMemories(parsed) {
19
+ const out = [];
20
+ const push = (m) => {
21
+ if (!m || typeof m !== 'object')
22
+ return;
23
+ const recordId = (m.recordId ?? m.record_id ?? m.id ?? '').toString();
24
+ if (!recordId)
25
+ return;
26
+ out.push({
27
+ recordId,
28
+ type: (m.type ?? 'memory').toString(),
29
+ content: (m.content ?? m.text ?? '').toString().replace(/\s+/g, ' ').trim(),
30
+ sceneName: m.sceneName ?? m.scene_name,
31
+ skillTag: m.skillTag ?? m.skill_tag,
32
+ priority: typeof m.priority === 'number' ? m.priority : undefined,
33
+ confidence: typeof m.confidence === 'number' ? m.confidence : undefined,
34
+ });
35
+ };
36
+ // Direct arrays first. The canonical MCP key is `recalledCognitiveMemories`;
37
+ // the others are tolerated for older payloads / shimmed responses.
38
+ for (const key of [
39
+ 'recalledCognitiveMemories',
40
+ 'recalledCognitiveRecords',
41
+ 'records',
42
+ 'memories',
43
+ 'recalledMemories',
44
+ ]) {
45
+ if (Array.isArray(parsed?.[key]))
46
+ parsed[key].forEach(push);
47
+ }
48
+ // Parse the XML-style prependContext if present and we still have nothing.
49
+ if (out.length === 0 && typeof parsed?.prependContext === 'string') {
50
+ const text = parsed.prependContext;
51
+ // Match ` - [type|scene] content (skill: xxx)` lines. The MCP doesn't
52
+ // include the recordId in this text-only block, so we synthesize one for
53
+ // display purposes only — recall callers that need real ids should hit
54
+ // the JSON path above.
55
+ const re = /-\s+\[([^\]|]+)\|([^\]]+)\]\s+([^\n]+)/g;
56
+ let match;
57
+ let i = 0;
58
+ while ((match = re.exec(text)) !== null) {
59
+ out.push({
60
+ recordId: `inline-${i++}`,
61
+ type: match[1].trim(),
62
+ content: match[3].replace(/\(skill:.*$/, '').trim(),
63
+ sceneName: match[2].trim(),
64
+ });
65
+ }
66
+ }
67
+ return out;
68
+ }
69
+ /**
70
+ * Render a compact ANSI-colored block: heading + N cards, each with the
71
+ * recordId, type tag, scene tag, and a one-line content preview. Returns
72
+ * the formatted string (no console.log here so callers can choose target).
73
+ */
74
+ export function renderMemoryCards(memories, heading, limit = 10) {
75
+ if (memories.length === 0) {
76
+ return `${chalk.bold(heading)}\n ${chalk.yellow('(no records returned)')}\n`;
77
+ }
78
+ const lines = [chalk.bold(heading)];
79
+ for (const m of memories.slice(0, limit)) {
80
+ const id = chalk.gray(m.recordId.length > 40 ? m.recordId.slice(0, 37) + '…' : m.recordId);
81
+ const type = chalk.cyan(`[${m.type}]`);
82
+ const scene = m.sceneName ? chalk.gray(` · ${m.sceneName}`) : '';
83
+ const preview = m.content.length > 200 ? m.content.slice(0, 197) + '…' : m.content;
84
+ lines.push(` ${type}${scene}`);
85
+ lines.push(` ${id}`);
86
+ lines.push(` ${preview}`);
87
+ }
88
+ if (memories.length > limit) {
89
+ lines.push(chalk.gray(` …and ${memories.length - limit} more (use /memory <query> to filter further)`));
90
+ }
91
+ return lines.join('\n') + '\n';
92
+ }
93
+ /**
94
+ * Bound a raw payload to a maximum size so the agent's context doesn't get
95
+ * blown out by a 70k-char working_context dump. Used by the briefing / slash
96
+ * command rendering paths.
97
+ */
98
+ export function clampPayload(text, maxChars = 6000) {
99
+ if (text.length <= maxChars)
100
+ return text;
101
+ return text.slice(0, maxChars) + `\n…[${text.length - maxChars} chars truncated]`;
102
+ }
@@ -0,0 +1,10 @@
1
+ export interface MentionExpansion {
2
+ expanded: string;
3
+ mentions: Array<{
4
+ token: string;
5
+ resolvedPath: string;
6
+ bytes: number;
7
+ truncated: boolean;
8
+ }>;
9
+ }
10
+ export declare function expandMentions(prompt: string, workspaceRoot: string, maxBytes?: number): MentionExpansion;
@@ -0,0 +1,72 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { isPathInside } from '../state/cliState.js';
4
+ /**
5
+ * Expand `@path/to/file` mentions in a user prompt by appending the referenced
6
+ * file contents as a fenced block.
7
+ *
8
+ * Rules:
9
+ * - Token shape: `@` followed by a workspace-relative path. The path can
10
+ * contain letters, digits, `._-/~`, but stops at whitespace or punctuation
11
+ * that wouldn't appear in a filename.
12
+ * - File must exist and resolve INSIDE the workspace (no `..` escapes).
13
+ * - Each file is appended only once even if mentioned multiple times.
14
+ * - Mention text in the prompt is left intact so the human-written sentence
15
+ * still makes sense; the contents are added below as a context block.
16
+ * - Files larger than `maxBytes` are truncated with a marker.
17
+ *
18
+ * Returns `{ expanded, mentions }`. `mentions` is the resolved set of files
19
+ * actually attached, useful for status display.
20
+ */
21
+ const MAX_BYTES_DEFAULT = 24_000;
22
+ const MENTION_RE = /(^|\s)@([\w./~-][\w./~-]*[\w/])/g;
23
+ export function expandMentions(prompt, workspaceRoot, maxBytes = MAX_BYTES_DEFAULT) {
24
+ // Resolve each mention to {resolvedPath, body (possibly truncated), truncated}
25
+ // in a single pass. Re-reading inside the rendering loop with a different
26
+ // limit would silently undo the truncation we just computed.
27
+ const mentioned = new Map();
28
+ let match;
29
+ MENTION_RE.lastIndex = 0;
30
+ while ((match = MENTION_RE.exec(prompt)) !== null) {
31
+ const token = match[2];
32
+ if (!token || mentioned.has(token))
33
+ continue;
34
+ let resolved;
35
+ try {
36
+ resolved = path.resolve(workspaceRoot, token);
37
+ }
38
+ catch {
39
+ continue;
40
+ }
41
+ if (!isPathInside(workspaceRoot, resolved))
42
+ continue;
43
+ if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile())
44
+ continue;
45
+ let content = fs.readFileSync(resolved, 'utf8');
46
+ let truncated = false;
47
+ if (content.length > maxBytes) {
48
+ content = content.slice(0, maxBytes) + `\n…[truncated at ${maxBytes} chars]`;
49
+ truncated = true;
50
+ }
51
+ mentioned.set(token, { resolvedPath: resolved, body: content, truncated });
52
+ }
53
+ if (mentioned.size === 0)
54
+ return { expanded: prompt, mentions: [] };
55
+ const blocks = ['', '---', 'Attached files (from @-mentions):', ''];
56
+ for (const [token, info] of mentioned.entries()) {
57
+ const rel = path.relative(workspaceRoot, info.resolvedPath);
58
+ const ext = path.extname(rel).replace(/^\./, '');
59
+ blocks.push(`### ${rel} (referenced via @${token}${info.truncated ? ', truncated' : ''})`);
60
+ blocks.push('```' + (ext || ''));
61
+ blocks.push(info.body);
62
+ blocks.push('```');
63
+ blocks.push('');
64
+ }
65
+ const mentions = Array.from(mentioned.entries()).map(([token, info]) => ({
66
+ token,
67
+ resolvedPath: info.resolvedPath,
68
+ bytes: info.body.length,
69
+ truncated: info.truncated,
70
+ }));
71
+ return { expanded: prompt + blocks.join('\n'), mentions };
72
+ }
@@ -0,0 +1,36 @@
1
+ import { type AccessMode } from './roles.js';
2
+ export type ChildStatus = 'pending' | 'running' | 'completed' | 'failed' | 'stale' | 'closed';
3
+ export interface ChildSessionRecord {
4
+ id: string;
5
+ label?: string;
6
+ role: string;
7
+ access: AccessMode;
8
+ parentSessionKey: string;
9
+ prompt: string;
10
+ status: ChildStatus;
11
+ startedAt: string;
12
+ updatedAt: string;
13
+ completedAt?: string;
14
+ pid: number;
15
+ finalOutput?: string;
16
+ error?: string;
17
+ /** LLM usage attributable to this child (filled when the child completes). */
18
+ usage?: {
19
+ promptTokens: number;
20
+ completionTokens: number;
21
+ calls: number;
22
+ turns: number;
23
+ };
24
+ }
25
+ export declare function listSessions(workspaceRoot: string): ChildSessionRecord[];
26
+ export declare function getSession(workspaceRoot: string, id: string): ChildSessionRecord | undefined;
27
+ export declare function createSession(workspaceRoot: string, input: {
28
+ role: string;
29
+ prompt: string;
30
+ parentSessionKey: string;
31
+ access?: AccessMode;
32
+ label?: string;
33
+ }): ChildSessionRecord;
34
+ export declare function updateSession(workspaceRoot: string, id: string, patch: Partial<ChildSessionRecord>): ChildSessionRecord;
35
+ export declare function reconcileStale(workspaceRoot: string): number;
36
+ export declare function formatSessionSummary(s: ChildSessionRecord): string;