@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.
- package/.env.example +121 -71
- package/dist/__tests__/cognitive-extractor.test.js +112 -0
- package/dist/__tests__/crypto.test.js +8 -1
- package/dist/__tests__/working-memory.test.js +67 -0
- package/dist/index.js +0 -0
- package/dist/memory/engine.js +21 -1
- package/dist/memory/pipeline/cognitive-extractor.js +19 -1
- package/dist/memory/recall.d.ts +3 -1
- package/dist/memory/recall.js +48 -3
- package/dist/memory/store/relevance-judge.d.ts +51 -0
- package/dist/memory/store/relevance-judge.js +196 -0
- package/dist/memory/working/canvas.js +11 -0
- package/package.json +2 -2
- package/dist/memory/config.d.ts +0 -2
- package/dist/memory/config.js +0 -3
- package/dist/memory/pipeline/l1-contradiction.d.ts +0 -7
- package/dist/memory/pipeline/l1-contradiction.js +0 -66
- package/dist/memory/pipeline/l1-dedup.d.ts +0 -23
- package/dist/memory/pipeline/l1-dedup.js +0 -39
- package/dist/memory/pipeline/l1-extractor.d.ts +0 -21
- package/dist/memory/pipeline/l1-extractor.js +0 -180
- package/dist/memory/pipeline/l2-direction-shift.d.ts +0 -10
- package/dist/memory/pipeline/l2-direction-shift.js +0 -27
- package/dist/memory/pipeline/l2-scene.d.ts +0 -15
- package/dist/memory/pipeline/l2-scene.js +0 -140
- package/dist/memory/pipeline/l3-distiller.d.ts +0 -15
- package/dist/memory/pipeline/l3-distiller.js +0 -40
- package/dist/memory/pipeline/task-queue.d.ts +0 -54
- package/dist/memory/pipeline/task-queue.js +0 -117
- package/dist/memory/prompts/graph-extraction-batch.d.ts +0 -14
- package/dist/memory/prompts/graph-extraction-batch.js +0 -54
- package/dist/memory/prompts/l1-contradiction-batch.d.ts +0 -16
- package/dist/memory/prompts/l1-contradiction-batch.js +0 -47
- package/dist/memory/prompts/l1-contradiction.d.ts +0 -1
- package/dist/memory/prompts/l1-contradiction.js +0 -25
- package/dist/memory/prompts/l1-extraction.d.ts +0 -10
- package/dist/memory/prompts/l1-extraction.js +0 -114
- package/dist/memory/prompts/l2-direction-shift.d.ts +0 -5
- package/dist/memory/prompts/l2-direction-shift.js +0 -32
- package/dist/memory/prompts/l2-scene-cluster.d.ts +0 -2
- package/dist/memory/prompts/l2-scene-cluster.js +0 -33
- package/dist/memory/prompts/l2-scene.d.ts +0 -7
- package/dist/memory/prompts/l2-scene.js +0 -40
- package/dist/memory/prompts/l3-persona.d.ts +0 -6
- package/dist/memory/prompts/l3-persona.js +0 -60
- package/dist/memory/store/types.d.ts +0 -101
- package/dist/memory/store/types.js +0 -1
- package/dist/memory/types.d.ts +0 -207
- package/dist/memory/types.js +0 -7
- package/dist/memory/validation.d.ts +0 -441
- package/dist/memory/validation.js +0 -129
- package/dist/tools/agent_memory_tools.d.ts +0 -485
- package/dist/tools/agent_memory_tools.js +0 -793
- package/dist/tools/get_doc.d.ts +0 -21
- package/dist/tools/get_doc.js +0 -24
- package/dist/tools/list_docs.d.ts +0 -15
- package/dist/tools/list_docs.js +0 -16
- package/dist/tools/update_doc.d.ts +0 -24
- package/dist/tools/update_doc.js +0 -35
- /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.
|
|
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.
|
|
48
|
+
"@kinqs/brainrouter-types": "^0.3.6"
|
|
49
49
|
},
|
|
50
50
|
"engines": {
|
|
51
51
|
"node": ">=22.0.0"
|
package/dist/memory/config.d.ts
DELETED
package/dist/memory/config.js
DELETED
|
@@ -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
|
-
}
|