@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.
- package/.env.example +109 -0
- package/README.md +185 -0
- package/dist/agent/agent.d.ts +765 -0
- package/dist/agent/agent.js +1977 -0
- package/dist/cli/cliPrompt.d.ts +15 -0
- package/dist/cli/cliPrompt.js +62 -0
- package/dist/cli/commands/_context.d.ts +53 -0
- package/dist/cli/commands/_context.js +14 -0
- package/dist/cli/commands/_helpers.d.ts +45 -0
- package/dist/cli/commands/_helpers.js +140 -0
- package/dist/cli/commands/guard.d.ts +6 -0
- package/dist/cli/commands/guard.js +292 -0
- package/dist/cli/commands/memory.d.ts +12 -0
- package/dist/cli/commands/memory.js +263 -0
- package/dist/cli/commands/obs.d.ts +6 -0
- package/dist/cli/commands/obs.js +208 -0
- package/dist/cli/commands/orchestration.d.ts +6 -0
- package/dist/cli/commands/orchestration.js +218 -0
- package/dist/cli/commands/session.d.ts +6 -0
- package/dist/cli/commands/session.js +191 -0
- package/dist/cli/commands/ui.d.ts +6 -0
- package/dist/cli/commands/ui.js +477 -0
- package/dist/cli/commands/workflow.d.ts +6 -0
- package/dist/cli/commands/workflow.js +691 -0
- package/dist/cli/repl.d.ts +12 -0
- package/dist/cli/repl.js +894 -0
- package/dist/config/config.d.ts +22 -0
- package/dist/config/config.js +105 -0
- package/dist/config/workspace.d.ts +7 -0
- package/dist/config/workspace.js +62 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +610 -0
- package/dist/memory/briefing.d.ts +46 -0
- package/dist/memory/briefing.js +152 -0
- package/dist/memory/consolidation.d.ts +60 -0
- package/dist/memory/consolidation.js +208 -0
- package/dist/memory/formatters.d.ts +38 -0
- package/dist/memory/formatters.js +102 -0
- package/dist/memory/mentions.d.ts +10 -0
- package/dist/memory/mentions.js +72 -0
- package/dist/orchestration/orchestrator.d.ts +36 -0
- package/dist/orchestration/orchestrator.js +71 -0
- package/dist/orchestration/roles.d.ts +11 -0
- package/dist/orchestration/roles.js +117 -0
- package/dist/orchestration/tools.d.ts +244 -0
- package/dist/orchestration/tools.js +528 -0
- package/dist/prompt/breadthHint.d.ts +48 -0
- package/dist/prompt/breadthHint.js +93 -0
- package/dist/prompt/compactor.d.ts +31 -0
- package/dist/prompt/compactor.js +112 -0
- package/dist/prompt/initAgentMd.d.ts +13 -0
- package/dist/prompt/initAgentMd.js +194 -0
- package/dist/prompt/skillRunner.d.ts +34 -0
- package/dist/prompt/skillRunner.js +146 -0
- package/dist/prompt/systemPrompt.d.ts +10 -0
- package/dist/prompt/systemPrompt.js +171 -0
- package/dist/runtime/clipboard.d.ts +17 -0
- package/dist/runtime/clipboard.js +52 -0
- package/dist/runtime/llmSemaphore.d.ts +30 -0
- package/dist/runtime/llmSemaphore.js +67 -0
- package/dist/runtime/loopRunner.d.ts +25 -0
- package/dist/runtime/loopRunner.js +79 -0
- package/dist/runtime/mcpClient.d.ts +156 -0
- package/dist/runtime/mcpClient.js +234 -0
- package/dist/runtime/mcpUtils.d.ts +36 -0
- package/dist/runtime/mcpUtils.js +64 -0
- package/dist/runtime/sandbox.d.ts +48 -0
- package/dist/runtime/sandbox.js +156 -0
- package/dist/runtime/tracing.d.ts +25 -0
- package/dist/runtime/tracing.js +91 -0
- package/dist/state/cliState.d.ts +59 -0
- package/dist/state/cliState.js +311 -0
- package/dist/state/goalStore.d.ts +174 -0
- package/dist/state/goalStore.js +410 -0
- package/dist/state/hookifyStore.d.ts +80 -0
- package/dist/state/hookifyStore.js +237 -0
- package/dist/state/hooksStore.d.ts +42 -0
- package/dist/state/hooksStore.js +71 -0
- package/dist/state/preferencesStore.d.ts +41 -0
- package/dist/state/preferencesStore.js +25 -0
- package/dist/state/sessionStore.d.ts +42 -0
- package/dist/state/sessionStore.js +193 -0
- package/dist/state/taskStore.d.ts +23 -0
- package/dist/state/taskStore.js +80 -0
- package/dist/state/workflowArtifacts.d.ts +33 -0
- package/dist/state/workflowArtifacts.js +139 -0
- 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;
|