@kinqs/brainrouter-mcp-server 0.3.5 → 0.3.6

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 (60) hide show
  1. package/.env.example +121 -71
  2. package/dist/__tests__/cognitive-extractor.test.js +112 -0
  3. package/dist/__tests__/crypto.test.js +8 -1
  4. package/dist/__tests__/working-memory.test.js +67 -0
  5. package/dist/index.js +0 -0
  6. package/dist/memory/engine.js +21 -1
  7. package/dist/memory/pipeline/cognitive-extractor.js +19 -1
  8. package/dist/memory/recall.d.ts +3 -1
  9. package/dist/memory/recall.js +48 -3
  10. package/dist/memory/store/relevance-judge.d.ts +51 -0
  11. package/dist/memory/store/relevance-judge.js +196 -0
  12. package/dist/memory/working/canvas.js +11 -0
  13. package/package.json +2 -2
  14. package/dist/memory/config.d.ts +0 -2
  15. package/dist/memory/config.js +0 -3
  16. package/dist/memory/pipeline/l1-contradiction.d.ts +0 -7
  17. package/dist/memory/pipeline/l1-contradiction.js +0 -66
  18. package/dist/memory/pipeline/l1-dedup.d.ts +0 -23
  19. package/dist/memory/pipeline/l1-dedup.js +0 -39
  20. package/dist/memory/pipeline/l1-extractor.d.ts +0 -21
  21. package/dist/memory/pipeline/l1-extractor.js +0 -180
  22. package/dist/memory/pipeline/l2-direction-shift.d.ts +0 -10
  23. package/dist/memory/pipeline/l2-direction-shift.js +0 -27
  24. package/dist/memory/pipeline/l2-scene.d.ts +0 -15
  25. package/dist/memory/pipeline/l2-scene.js +0 -140
  26. package/dist/memory/pipeline/l3-distiller.d.ts +0 -15
  27. package/dist/memory/pipeline/l3-distiller.js +0 -40
  28. package/dist/memory/pipeline/task-queue.d.ts +0 -54
  29. package/dist/memory/pipeline/task-queue.js +0 -117
  30. package/dist/memory/prompts/graph-extraction-batch.d.ts +0 -14
  31. package/dist/memory/prompts/graph-extraction-batch.js +0 -54
  32. package/dist/memory/prompts/l1-contradiction-batch.d.ts +0 -16
  33. package/dist/memory/prompts/l1-contradiction-batch.js +0 -47
  34. package/dist/memory/prompts/l1-contradiction.d.ts +0 -1
  35. package/dist/memory/prompts/l1-contradiction.js +0 -25
  36. package/dist/memory/prompts/l1-extraction.d.ts +0 -10
  37. package/dist/memory/prompts/l1-extraction.js +0 -114
  38. package/dist/memory/prompts/l2-direction-shift.d.ts +0 -5
  39. package/dist/memory/prompts/l2-direction-shift.js +0 -32
  40. package/dist/memory/prompts/l2-scene-cluster.d.ts +0 -2
  41. package/dist/memory/prompts/l2-scene-cluster.js +0 -33
  42. package/dist/memory/prompts/l2-scene.d.ts +0 -7
  43. package/dist/memory/prompts/l2-scene.js +0 -40
  44. package/dist/memory/prompts/l3-persona.d.ts +0 -6
  45. package/dist/memory/prompts/l3-persona.js +0 -60
  46. package/dist/memory/store/types.d.ts +0 -101
  47. package/dist/memory/store/types.js +0 -1
  48. package/dist/memory/types.d.ts +0 -207
  49. package/dist/memory/types.js +0 -7
  50. package/dist/memory/validation.d.ts +0 -441
  51. package/dist/memory/validation.js +0 -129
  52. package/dist/tools/agent_memory_tools.d.ts +0 -485
  53. package/dist/tools/agent_memory_tools.js +0 -793
  54. package/dist/tools/get_doc.d.ts +0 -21
  55. package/dist/tools/get_doc.js +0 -24
  56. package/dist/tools/list_docs.d.ts +0 -15
  57. package/dist/tools/list_docs.js +0 -16
  58. package/dist/tools/update_doc.d.ts +0 -24
  59. package/dist/tools/update_doc.js +0 -35
  60. /package/dist/__tests__/{agent_mode.test.d.ts → cognitive-extractor.test.d.ts} +0 -0
@@ -0,0 +1,51 @@
1
+ import type { RelevanceJudgeServiceConfig, RelevanceVerdict } from "@kinqs/brainrouter-types";
2
+ export interface JudgeCandidate {
3
+ /** Stable id used for logging — typically the memory's record_id. */
4
+ id: string;
5
+ /** Memory content the judge will read. */
6
+ content: string;
7
+ }
8
+ export interface JudgeResult {
9
+ /** Verdicts in the order returned by the judge. */
10
+ verdicts: RelevanceVerdict[];
11
+ /** Indices the judge approved as relevant. */
12
+ approvedIndices: number[];
13
+ }
14
+ /**
15
+ * LLM-as-judge stage that approves or rejects retrieved memories based on
16
+ * actual semantic relevance to the user query — sits between the reranker and
17
+ * context formatting, dropping candidates that share keywords but aren't
18
+ * genuinely about the query subject.
19
+ *
20
+ * Failure mode is "skip the gate": if the call errors out, callers fall back
21
+ * to the unfiltered reranker output. We never want a flaky judge call to
22
+ * crash a recall.
23
+ */
24
+ export declare class RelevanceJudgeService {
25
+ private readonly enabled;
26
+ private readonly endpoint;
27
+ private readonly apiKey;
28
+ private readonly model;
29
+ private readonly maxCandidates;
30
+ private readonly timeoutMs;
31
+ private readonly ready;
32
+ constructor(config: RelevanceJudgeServiceConfig);
33
+ isReady(): boolean;
34
+ getMaxCandidates(): number;
35
+ /**
36
+ * Grade a batch of candidates against the query. Returns verdicts and the
37
+ * subset of indices approved as relevant. Throws on transport/parsing
38
+ * failure — callers are expected to fall back to pre-judge results.
39
+ */
40
+ judge(params: {
41
+ query: string;
42
+ candidates: JudgeCandidate[];
43
+ }): Promise<JudgeResult>;
44
+ /**
45
+ * Defensive JSON parse — strips code fences, picks the first valid JSON
46
+ * object/array, and tolerates either {"verdicts":[…]} or a bare array.
47
+ * Returns one verdict per candidate; missing entries default to "rejected"
48
+ * so a malformed response can't silently approve everything.
49
+ */
50
+ private parseVerdicts;
51
+ }
@@ -0,0 +1,196 @@
1
+ import { fetchWithExternalRetry } from "../retry.js";
2
+ import { acquireLLMSlot } from "../llm-semaphore.js";
3
+ /**
4
+ * LLM-as-judge stage that approves or rejects retrieved memories based on
5
+ * actual semantic relevance to the user query — sits between the reranker and
6
+ * context formatting, dropping candidates that share keywords but aren't
7
+ * genuinely about the query subject.
8
+ *
9
+ * Failure mode is "skip the gate": if the call errors out, callers fall back
10
+ * to the unfiltered reranker output. We never want a flaky judge call to
11
+ * crash a recall.
12
+ */
13
+ export class RelevanceJudgeService {
14
+ enabled;
15
+ endpoint;
16
+ apiKey;
17
+ model;
18
+ maxCandidates;
19
+ timeoutMs;
20
+ ready;
21
+ constructor(config) {
22
+ this.enabled = config.enabled ?? false;
23
+ this.endpoint = config.endpoint ?? "https://api.openai.com/v1/chat/completions";
24
+ this.apiKey = config.apiKey ?? "";
25
+ this.model = config.model ?? "gpt-4o-mini";
26
+ this.maxCandidates = Math.max(1, config.maxCandidates ?? 10);
27
+ this.timeoutMs = Math.max(1000, config.timeoutMs ?? 15_000);
28
+ this.ready = this.enabled && !!this.apiKey;
29
+ if (this.enabled && !this.apiKey) {
30
+ console.error("[BrainRouter] Relevance judge enabled but no API key set. Stage 4 judging will be skipped.");
31
+ }
32
+ }
33
+ isReady() {
34
+ return this.ready;
35
+ }
36
+ getMaxCandidates() {
37
+ return this.maxCandidates;
38
+ }
39
+ /**
40
+ * Grade a batch of candidates against the query. Returns verdicts and the
41
+ * subset of indices approved as relevant. Throws on transport/parsing
42
+ * failure — callers are expected to fall back to pre-judge results.
43
+ */
44
+ async judge(params) {
45
+ if (!this.ready) {
46
+ throw new Error("RelevanceJudgeService is not ready (disabled or missing API key)");
47
+ }
48
+ if (params.candidates.length === 0) {
49
+ return { verdicts: [], approvedIndices: [] };
50
+ }
51
+ const candidates = params.candidates.slice(0, this.maxCandidates);
52
+ const safeQuery = params.query.length > 800 ? params.query.slice(0, 800) + "…" : params.query;
53
+ const candidateBlock = candidates
54
+ .map((c, i) => {
55
+ const text = c.content.length > 600 ? c.content.slice(0, 600) + "…" : c.content;
56
+ return `[${i}] ${text.replace(/\s+/g, " ").trim()}`;
57
+ })
58
+ .join("\n");
59
+ const systemPrompt = [
60
+ "You are a strict relevance judge for a memory retrieval system.",
61
+ "For each candidate memory, decide whether it is actually relevant to the user's query.",
62
+ "A memory is RELEVANT only if it provides information that directly helps answer, contextualize, or inform the query.",
63
+ "It is NOT relevant if it merely shares keywords, is about a different subject, or is generic background.",
64
+ "When in doubt, reject — false positives pollute the agent's context window.",
65
+ "Respond with strict JSON only, no prose.",
66
+ ].join(" ");
67
+ const userPrompt = [
68
+ `Query: ${safeQuery}`,
69
+ "",
70
+ "Candidates:",
71
+ candidateBlock,
72
+ "",
73
+ "Respond with exactly this JSON shape:",
74
+ `{"verdicts":[{"index":0,"relevant":true,"reason":"…"}, …]}`,
75
+ "Include one verdict per candidate. Keep each reason under 120 chars.",
76
+ ].join("\n");
77
+ const doFetch = () => fetchWithExternalRetry(this.endpoint, {
78
+ method: "POST",
79
+ headers: {
80
+ "Content-Type": "application/json",
81
+ "Authorization": `Bearer ${this.apiKey}`,
82
+ },
83
+ // Deliberately omitting `response_format` — OpenAI accepts
84
+ // `{type:"json_object"}`, but LM Studio / llama.cpp-style backends
85
+ // reject anything except `json_schema` or `text` with a 400, and
86
+ // Ollama / vLLM each have their own quirks. The system prompt is
87
+ // explicit about strict-JSON output and the parser below strips
88
+ // code fences + tolerates surrounding prose, so dropping the hint
89
+ // is cheaper than per-provider branching.
90
+ body: JSON.stringify({
91
+ model: this.model,
92
+ messages: [
93
+ { role: "system", content: systemPrompt },
94
+ { role: "user", content: userPrompt },
95
+ ],
96
+ temperature: 0,
97
+ }),
98
+ signal: AbortSignal.timeout(this.timeoutMs),
99
+ }, {
100
+ label: "Relevance Judge API",
101
+ });
102
+ const release = await acquireLLMSlot();
103
+ let raw;
104
+ try {
105
+ let res = await doFetch();
106
+ // LM Studio quirk: idle models auto-unload and the first call after
107
+ // unload returns 400 with "Model is unloaded" / "No models loaded".
108
+ // The backend then loads the model in the background, so a retry
109
+ // ~1.5s later usually succeeds. Mirrors ModelLLMRunner in engine.ts.
110
+ if (res.status === 400) {
111
+ const errorBody = await res.text();
112
+ if (/model\s+(is\s+)?unloaded|model\s+not\s+loaded|no\s+models?\s+loaded/i.test(errorBody)) {
113
+ await new Promise((resolve) => setTimeout(resolve, 1500));
114
+ res = await doFetch();
115
+ if (!res.ok) {
116
+ const retryBody = await res.text().catch(() => "(no body)");
117
+ throw new Error(`Relevance Judge API failed after LM Studio reload retry: HTTP ${res.status} ${res.statusText} - ${retryBody}`);
118
+ }
119
+ }
120
+ else {
121
+ throw new Error(`Relevance Judge API failed: HTTP ${res.status} ${res.statusText} - ${errorBody}`);
122
+ }
123
+ }
124
+ else if (!res.ok) {
125
+ const err = await res.text().catch(() => "(no body)");
126
+ throw new Error(`Relevance Judge API failed: HTTP ${res.status} ${res.statusText} - ${err}`);
127
+ }
128
+ const data = await res.json();
129
+ if (data?.error) {
130
+ const errMsg = typeof data.error === "string" ? data.error : (data.error.message ?? JSON.stringify(data.error).slice(0, 400));
131
+ throw new Error(`Relevance Judge endpoint returned an error envelope: ${errMsg}`);
132
+ }
133
+ const choice = data?.choices?.[0];
134
+ const content = choice?.message?.content ?? choice?.delta?.content;
135
+ if (typeof content !== "string") {
136
+ throw new Error(`Relevance Judge returned no usable content. Response: ${JSON.stringify(data).slice(0, 400)}`);
137
+ }
138
+ raw = content;
139
+ }
140
+ finally {
141
+ release();
142
+ }
143
+ const parsed = this.parseVerdicts(raw, candidates.length);
144
+ const approvedIndices = [];
145
+ for (const v of parsed) {
146
+ if (v.relevant && v.index >= 0 && v.index < candidates.length) {
147
+ approvedIndices.push(v.index);
148
+ }
149
+ }
150
+ return { verdicts: parsed, approvedIndices };
151
+ }
152
+ /**
153
+ * Defensive JSON parse — strips code fences, picks the first valid JSON
154
+ * object/array, and tolerates either {"verdicts":[…]} or a bare array.
155
+ * Returns one verdict per candidate; missing entries default to "rejected"
156
+ * so a malformed response can't silently approve everything.
157
+ */
158
+ parseVerdicts(raw, candidateCount) {
159
+ let text = raw.trim();
160
+ text = text.replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/i, "").trim();
161
+ let parsed;
162
+ try {
163
+ parsed = JSON.parse(text);
164
+ }
165
+ catch {
166
+ const objMatch = text.match(/\{[\s\S]*\}/);
167
+ const arrMatch = text.match(/\[[\s\S]*\]/);
168
+ const candidate = objMatch?.[0] ?? arrMatch?.[0];
169
+ if (!candidate) {
170
+ throw new Error(`Relevance Judge produced non-JSON output: ${text.slice(0, 200)}`);
171
+ }
172
+ parsed = JSON.parse(candidate);
173
+ }
174
+ const list = Array.isArray(parsed)
175
+ ? parsed
176
+ : Array.isArray(parsed?.verdicts) ? parsed.verdicts : [];
177
+ const byIndex = new Map();
178
+ for (const item of list) {
179
+ if (!item || typeof item !== "object")
180
+ continue;
181
+ const index = Number(item.index);
182
+ if (!Number.isFinite(index))
183
+ continue;
184
+ byIndex.set(index, {
185
+ index,
186
+ relevant: Boolean(item.relevant),
187
+ reason: typeof item.reason === "string" ? item.reason.slice(0, 200) : "",
188
+ });
189
+ }
190
+ const out = [];
191
+ for (let i = 0; i < candidateCount; i++) {
192
+ out.push(byIndex.get(i) ?? { index: i, relevant: false, reason: "no verdict returned" });
193
+ }
194
+ return out;
195
+ }
196
+ }
@@ -24,6 +24,17 @@ export function buildAnnotatedCanvas(steps, activeNodeId) {
24
24
  for (let index = 1; index < steps.length; index += 1) {
25
25
  lines.push(` ${steps[index - 1].nodeId} --> ${steps[index].nodeId}`);
26
26
  }
27
+ // Reasoning steps ("Why: …" decisions emitted via memory_working_offload
28
+ // with kind:"reasoning") get a dashed border so the audit trail is
29
+ // visually separable from tool_output and compressed_summary nodes when
30
+ // a human (or the dashboard) inspects canvas.mmd. Emitted before the
31
+ // active-node fill so the active highlight overrides the dashed style
32
+ // when the same node happens to be both.
33
+ for (const step of steps) {
34
+ if (step.kind === "reasoning") {
35
+ lines.push(` style ${step.nodeId} stroke-dasharray:4 4,stroke:#9f7aea,stroke-width:2px`);
36
+ }
37
+ }
27
38
  if (activeNodeId && steps.some((step) => step.nodeId === activeNodeId)) {
28
39
  lines.push(` style ${activeNodeId} fill:#2b6cb0,stroke:#3182ce,stroke-width:2px,color:#fff`);
29
40
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kinqs/brainrouter-mcp-server",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
4
4
  "description": "BrainRouter MCP server — the cognitive memory engine. Exposes recall, capture, focus scenes, persona, contradictions, skills, and graph queries as MCP tools for any MCP-speaking agent.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -45,7 +45,7 @@
45
45
  "gray-matter": "^4.0.3",
46
46
  "sqlite-vec": "^0.1.9",
47
47
  "zod": "^3.22.4",
48
- "@kinqs/brainrouter-types": "^0.3.5"
48
+ "@kinqs/brainrouter-types": "^0.3.6"
49
49
  },
50
50
  "engines": {
51
51
  "node": ">=22.0.0"
@@ -1,2 +0,0 @@
1
- export type BrainRouterLlmMode = "server" | "agent";
2
- export declare function getBrainRouterLlmMode(): BrainRouterLlmMode;
@@ -1,3 +0,0 @@
1
- export function getBrainRouterLlmMode() {
2
- return process.env.BRAINROUTER_LLM_MODE === "agent" ? "agent" : "server";
3
- }
@@ -1,7 +0,0 @@
1
- import type { IMemoryStore } from "@brainrouter/types";
2
- import type { LLMRunner, L1Record } from "@brainrouter/types";
3
- export declare function detectContradictions(params: {
4
- newRecord: L1Record;
5
- store: IMemoryStore;
6
- llmRunner: LLMRunner;
7
- }): Promise<void>;
@@ -1,66 +0,0 @@
1
- import { L1_CONTRADICTION_PROMPT } from "../prompts/l1-contradiction.js";
2
- import crypto from "node:crypto";
3
- export async function detectContradictions(params) {
4
- const { newRecord, store, llmRunner } = params;
5
- // 1. Search for potentially related memories
6
- // We use keyword search on the content of the new record to find similar existing ones
7
- const candidates = store.searchL1Fts(newRecord.userId, newRecord.content, 5);
8
- const evaluations = [];
9
- const _parsedContradictionTimeout = parseInt(process.env.BRAINROUTER_CONTRADICTION_TIMEOUT_MS || "", 10);
10
- const contradictionTimeoutMs = isNaN(_parsedContradictionTimeout) ? 60000 : _parsedContradictionTimeout;
11
- for (const candidate of candidates) {
12
- // Don't compare with self
13
- if (candidate.record_id === newRecord.id)
14
- continue;
15
- // Only compare if they are of the same type or both are episodic/persona
16
- // (instructions don't usually contradict episodic facts)
17
- const prompt = L1_CONTRADICTION_PROMPT
18
- .replace("{{newContent}}", newRecord.content)
19
- .replace("{{existingContent}}", candidate.content);
20
- try {
21
- const response = await llmRunner.run({
22
- prompt,
23
- taskId: `contradiction-check-${newRecord.id}-${candidate.record_id}`,
24
- timeoutMs: contradictionTimeoutMs
25
- });
26
- // Simple JSON extraction (flexible for local models)
27
- const jsonMatch = response.match(/\{[\s\S]*\}/);
28
- if (!jsonMatch)
29
- continue;
30
- const data = JSON.parse(jsonMatch[0]);
31
- if (data.isContradiction && data.confidence > 0.7) {
32
- evaluations.push({
33
- candidate,
34
- isContradiction: true,
35
- confidence: data.confidence,
36
- kind: data.kind || "genuine_conflict",
37
- reason: data.reason
38
- });
39
- }
40
- }
41
- catch (e) {
42
- console.error(`[BrainRouter] Contradiction check failed for ${newRecord.id} vs ${candidate.record_id}:`, e.message);
43
- }
44
- }
45
- // If ANY evaluation is a temporal_update, then the entire batch of contradictions represents a temporal transition!
46
- const hasTemporalUpdate = evaluations.some(ev => ev.kind === "temporal_update");
47
- for (const ev of evaluations) {
48
- if (hasTemporalUpdate) {
49
- // Treat all conflicting old records as superseded by the new record
50
- console.error(`[BrainRouter] TEMPORAL UPDATE DETECTED (transition): Superseding memory ${ev.candidate.record_id} with new memory ${newRecord.id}`);
51
- store.invalidateL1Record(newRecord.userId, ev.candidate.record_id, newRecord.id);
52
- }
53
- else {
54
- // Genuine conflict
55
- console.error(`[BrainRouter] CONTRADICTION DETECTED: ${newRecord.id} vs ${ev.candidate.record_id}`);
56
- store.upsertContradiction({
57
- id: `conflict_${crypto.randomBytes(4).toString("hex")}`,
58
- userId: newRecord.userId,
59
- recordIdA: ev.candidate.record_id,
60
- recordIdB: newRecord.id,
61
- reason: ev.reason,
62
- confidence: ev.confidence
63
- });
64
- }
65
- }
66
- }
@@ -1,23 +0,0 @@
1
- import type { L1Record } from "@brainrouter/types";
2
- import type { IMemoryStore } from "@brainrouter/types";
3
- /**
4
- * Result of the deduplication process
5
- */
6
- export interface DedupResult {
7
- /** Memories that are unique and should be stored */
8
- uniqueRecords: L1Record[];
9
- /** Memories that were identified as exact duplicates and dropped */
10
- droppedCount: number;
11
- }
12
- /**
13
- * Proactively deduplicate extracted memories against the existing memory store
14
- * before they are stored.
15
- *
16
- * Uses exact/near-exact string matching to prevent identical noisy facts
17
- * from accumulating in the L1 store.
18
- */
19
- export declare function deduplicateMemories(params: {
20
- records: L1Record[];
21
- store: IMemoryStore;
22
- userId: string;
23
- }): Promise<DedupResult>;
@@ -1,39 +0,0 @@
1
- /**
2
- * Proactively deduplicate extracted memories against the existing memory store
3
- * before they are stored.
4
- *
5
- * Uses exact/near-exact string matching to prevent identical noisy facts
6
- * from accumulating in the L1 store.
7
- */
8
- export async function deduplicateMemories(params) {
9
- const { records, store, userId } = params;
10
- if (records.length === 0) {
11
- return { uniqueRecords: [], droppedCount: 0 };
12
- }
13
- const uniqueRecords = [];
14
- let droppedCount = 0;
15
- for (const newRecord of records) {
16
- // 1. Keyword search to find potentially identical memories
17
- // We only need top 3 to see if there is an exact match
18
- const candidates = store.searchL1Fts(userId, newRecord.content, 3);
19
- let isDuplicate = false;
20
- for (const candidate of candidates) {
21
- // Direct string comparison (case-insensitive, trimmed)
22
- if (candidate.content.trim().toLowerCase() === newRecord.content.trim().toLowerCase()) {
23
- isDuplicate = true;
24
- break;
25
- }
26
- }
27
- if (isDuplicate) {
28
- console.log(`[BrainRouter] Dropped exact duplicate memory: "${newRecord.content}"`);
29
- droppedCount++;
30
- }
31
- else {
32
- uniqueRecords.push(newRecord);
33
- }
34
- }
35
- return {
36
- uniqueRecords,
37
- droppedCount
38
- };
39
- }
@@ -1,21 +0,0 @@
1
- import type { L0Record, L1Record, LLMRunner } from "@brainrouter/types";
2
- export interface L1ExtractionResult {
3
- success: boolean;
4
- extractedCount: number;
5
- records: L1Record[];
6
- sceneNames: string[];
7
- errorMessage?: string;
8
- }
9
- export declare function extractL1Memories(params: {
10
- messages: L0Record[];
11
- userId: string;
12
- sessionKey: string;
13
- sessionId: string;
14
- llmRunner: LLMRunner;
15
- maxMessagesPerExtraction?: number;
16
- maxBackgroundMessages?: number;
17
- previousSceneName?: string;
18
- existingSceneNames?: string[];
19
- activeSkill?: string;
20
- skillHints?: string;
21
- }): Promise<L1ExtractionResult>;
@@ -1,180 +0,0 @@
1
- import { EXTRACT_MEMORIES_SYSTEM_PROMPT, formatExtractionPrompt } from "../prompts/l1-extraction.js";
2
- import { getMemoryTypeConfig } from "../memory-type-config.js";
3
- import crypto from "node:crypto";
4
- const ALLOWED_MEMORY_TYPES = new Set([
5
- "persona", "episodic", "instruction", "skill_context", "tool_preference",
6
- "codebase_fact", "api_contract", "data_model", "dependency_constraint",
7
- "environment_constraint", "architecture_decision", "implementation_decision",
8
- "design_constraint", "security_policy", "performance_baseline", "bug_finding",
9
- "debug_trace", "fix_summary", "verification_result", "failed_attempt",
10
- "regression_risk", "task_state", "handover_note", "blocked_reason",
11
- "review_comment", "release_note", "source_evidence", "artifact_reference",
12
- "file_history", "command_knowledge",
13
- ]);
14
- const ALLOWED_SOURCE_KINDS = new Set([
15
- "", "user_instruction", "source_file", "command_output", "test_result",
16
- "model_inference", "prior_memory",
17
- ]);
18
- const ALLOWED_VERIFICATION_STATUSES = new Set([
19
- "", "verified", "unverified", "stale",
20
- ]);
21
- // Ensure the message has actual words to extract from, not just symbols or single letters.
22
- function shouldExtractL1(text) {
23
- if (!text)
24
- return false;
25
- const clean = text.trim();
26
- if (clean.length < 3)
27
- return false;
28
- // If it's pure symbols/numbers, ignore
29
- if (/^[^a-zA-Z\u4e00-\u9fa5]+$/.test(clean))
30
- return false;
31
- return true;
32
- }
33
- export async function extractL1Memories(params) {
34
- const { messages, userId, sessionKey, sessionId, llmRunner, maxMessagesPerExtraction = 10, maxBackgroundMessages = 5, previousSceneName, existingSceneNames, activeSkill, skillHints } = params;
35
- if (messages.length === 0) {
36
- return { success: true, extractedCount: 0, records: [], sceneNames: [] };
37
- }
38
- const qualifiedMessages = messages.filter((m) => shouldExtractL1(m.messageText));
39
- if (qualifiedMessages.length === 0) {
40
- return { success: true, extractedCount: 0, records: [], sceneNames: [] };
41
- }
42
- const newMessages = qualifiedMessages.slice(-maxMessagesPerExtraction);
43
- const bgEndIdx = qualifiedMessages.length - newMessages.length;
44
- const backgroundMessages = bgEndIdx > 0
45
- ? qualifiedMessages.slice(Math.max(0, bgEndIdx - maxBackgroundMessages), bgEndIdx)
46
- : [];
47
- const userPrompt = formatExtractionPrompt({
48
- newMessages,
49
- backgroundMessages,
50
- previousSceneName,
51
- existingSceneNames,
52
- activeSkill,
53
- skillHints
54
- });
55
- let rawResult;
56
- try {
57
- rawResult = await llmRunner.run({
58
- prompt: userPrompt,
59
- systemPrompt: EXTRACT_MEMORIES_SYSTEM_PROMPT,
60
- taskId: "l1-extraction",
61
- timeoutMs: 120_000
62
- });
63
- }
64
- catch (err) {
65
- const errorMessage = err instanceof Error ? err.message : String(err);
66
- console.error("[BrainRouter] LLM extraction failed:", err);
67
- return { success: false, extractedCount: 0, records: [], sceneNames: [], errorMessage };
68
- }
69
- const parsedScenes = parseExtractionResult(rawResult);
70
- const records = [];
71
- const sceneNames = [];
72
- const nowStr = new Date().toISOString();
73
- for (const scene of parsedScenes) {
74
- sceneNames.push(scene.scene_name);
75
- for (const mem of scene.memories) {
76
- const config = getMemoryTypeConfig(mem.type);
77
- records.push({
78
- id: `l1_${sessionKey}_${Date.now()}_${crypto.randomBytes(4).toString("hex")}`,
79
- userId,
80
- sessionKey,
81
- sessionId,
82
- content: mem.content,
83
- type: mem.type,
84
- priority: mem.priority,
85
- sceneName: scene.scene_name,
86
- skillTag: mem.skill_tag || activeSkill || "",
87
- halfLifeDays: config.halfLifeDays,
88
- supersededBy: null,
89
- timestampStr: "", // Phase 1: Not strictly tracking time from LLM, just raw extraction
90
- timestampStart: "",
91
- timestampEnd: "",
92
- createdTime: nowStr,
93
- updatedTime: nowStr,
94
- metadata: mem.metadata,
95
- confidence: mem.confidence ?? config.defaultConfidence,
96
- status: "active",
97
- sourceKind: mem.sourceKind,
98
- verificationStatus: mem.verificationStatus,
99
- repoPaths: mem.repoPaths,
100
- filePaths: mem.filePaths,
101
- commands: mem.commands,
102
- // ACE fields — zero on creation, updated by citation tracking
103
- citationCount: 0,
104
- lastCitedAt: null,
105
- neverCitedCount: 0,
106
- archived: false,
107
- });
108
- }
109
- }
110
- return {
111
- success: true,
112
- extractedCount: records.length,
113
- records,
114
- sceneNames
115
- };
116
- }
117
- function parseExtractionResult(raw) {
118
- try {
119
- let cleaned = raw.trim();
120
- if (cleaned.startsWith("\`\`\`")) {
121
- cleaned = cleaned.replace(/^\`\`\`(?:json)?\s*\n?/, "").replace(/\n?\`\`\`\s*$/, "");
122
- }
123
- const match = cleaned.match(/\[[\s\S]*\]/);
124
- if (!match)
125
- return [];
126
- const parsed = JSON.parse(match[0]);
127
- if (!Array.isArray(parsed))
128
- return [];
129
- const scenes = [];
130
- for (const item of parsed) {
131
- if (!item || typeof item !== "object")
132
- continue;
133
- const s = item;
134
- const memories = Array.isArray(s.memories) ? s.memories.map((m) => ({
135
- content: String(m.content || ""),
136
- type: parseMemoryType(m.type),
137
- priority: clampNumber(m.priority, 0, 100, 50),
138
- skill_tag: m.skill_tag ? String(m.skill_tag) : undefined,
139
- confidence: typeof m.confidence === "number" ? clampNumber(m.confidence, 0, 1, 0.65) : undefined,
140
- sourceKind: parseSourceKind(m.sourceKind ?? m.source_kind),
141
- verificationStatus: parseVerificationStatus(m.verificationStatus ?? m.verification_status),
142
- repoPaths: parseStringArray(m.repoPaths ?? m.repo_paths),
143
- filePaths: parseStringArray(m.filePaths ?? m.file_paths),
144
- commands: parseStringArray(m.commands),
145
- metadata: m.metadata && typeof m.metadata === "object" ? m.metadata : {}
146
- })).filter((m) => m.content.length > 0) : [];
147
- scenes.push({
148
- scene_name: String(s.scene_name || "Unknown Scene"),
149
- memories
150
- });
151
- }
152
- return scenes;
153
- }
154
- catch (err) {
155
- console.error("[BrainRouter] Failed to parse extraction result", err);
156
- return [];
157
- }
158
- }
159
- function parseMemoryType(value) {
160
- const candidate = String(value || "");
161
- return ALLOWED_MEMORY_TYPES.has(candidate) ? candidate : "episodic";
162
- }
163
- function parseSourceKind(value) {
164
- const candidate = String(value || "");
165
- return ALLOWED_SOURCE_KINDS.has(candidate) ? candidate : "model_inference";
166
- }
167
- function parseVerificationStatus(value) {
168
- const candidate = String(value || "");
169
- return ALLOWED_VERIFICATION_STATUSES.has(candidate) ? candidate : "unverified";
170
- }
171
- function parseStringArray(value) {
172
- if (!Array.isArray(value))
173
- return [];
174
- return [...new Set(value.map((item) => String(item).trim()).filter(Boolean))];
175
- }
176
- function clampNumber(value, min, max, fallback) {
177
- return typeof value === "number" && Number.isFinite(value)
178
- ? Math.min(max, Math.max(min, value))
179
- : fallback;
180
- }
@@ -1,10 +0,0 @@
1
- import type { L1Record, L2SceneRecord, LLMRunner } from "@brainrouter/types";
2
- export declare function detectDirectionShift(params: {
3
- activeScene: L2SceneRecord;
4
- newL1Records: L1Record[];
5
- llmRunner: LLMRunner;
6
- }): Promise<{
7
- shift: boolean;
8
- confidence: number;
9
- reason: string;
10
- }>;
@@ -1,27 +0,0 @@
1
- import { L2_DIRECTION_SHIFT_SYSTEM_PROMPT, formatL2DirectionShiftPrompt } from "../prompts/l2-direction-shift.js";
2
- export async function detectDirectionShift(params) {
3
- const { activeScene, newL1Records, llmRunner } = params;
4
- try {
5
- const prompt = formatL2DirectionShiftPrompt(activeScene.sceneName, activeScene.summaryMd, newL1Records.map(r => ({ content: r.content, type: r.type })));
6
- const response = await llmRunner.run({
7
- prompt,
8
- systemPrompt: L2_DIRECTION_SHIFT_SYSTEM_PROMPT,
9
- taskId: "l2-direction-shift",
10
- timeoutMs: 30_000,
11
- });
12
- const jsonMatch = response.match(/\{[\s\S]*\}/);
13
- if (!jsonMatch) {
14
- throw new Error("No JSON object found in LLM response");
15
- }
16
- const parsed = JSON.parse(jsonMatch[0]);
17
- return {
18
- shift: Boolean(parsed.shift),
19
- confidence: Number(parsed.confidence) || 0,
20
- reason: String(parsed.reason) || "",
21
- };
22
- }
23
- catch (err) {
24
- console.error(`[BrainRouter] L2 direction shift detection failed:`, err.message);
25
- return { shift: false, confidence: 0, reason: "Error" };
26
- }
27
- }