the-campfire 0.2.1 → 0.3.1
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/README.md +28 -4
- package/dist/assets/{MermaidDiagram-D_MwUXi3.js → MermaidDiagram-Cas1Ngze.js} +2 -2
- package/dist/assets/{_basePickBy-DsMRBmhy.js → _basePickBy-D1h3RRT9.js} +1 -1
- package/dist/assets/{_baseUniq-CZ0BR5Mx.js → _baseUniq-Z9p6uaM9.js} +1 -1
- package/dist/assets/{arc-D6Y0ooDg.js → arc-DJLNkmre.js} +1 -1
- package/dist/assets/{architectureDiagram-VXUJARFQ-3T5ZoEn1.js → architectureDiagram-VXUJARFQ-CBHHYg8R.js} +1 -1
- package/dist/assets/{blockDiagram-VD42YOAC-CvOCnB8f.js → blockDiagram-VD42YOAC-DMtvAHPO.js} +1 -1
- package/dist/assets/{c4Diagram-YG6GDRKO-DPXJvInm.js → c4Diagram-YG6GDRKO-G9Zj2LWB.js} +1 -1
- package/dist/assets/channel-CcCVj0rn.js +1 -0
- package/dist/assets/{chunk-4BX2VUAB-DPHGuPB4.js → chunk-4BX2VUAB-s3svQOtk.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-BbI83gIK.js → chunk-55IACEB6-C6fhtS_L.js} +1 -1
- package/dist/assets/{chunk-B4BG7PRW-DGvVY1Y_.js → chunk-B4BG7PRW-CpePj3eQ.js} +1 -1
- package/dist/assets/{chunk-DI55MBZ5-2YN0FhPB.js → chunk-DI55MBZ5-Dq9AChBD.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-Ctqs0bWj.js → chunk-FMBD7UC4-DSOMddvX.js} +1 -1
- package/dist/assets/{chunk-QN33PNHL-CfvfTLi4.js → chunk-QN33PNHL-ZrdWE4nY.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-C-dG1NiD.js → chunk-QZHKN3VN-BqLE4kwR.js} +1 -1
- package/dist/assets/{chunk-TZMSLE5B-Bh07YiOi.js → chunk-TZMSLE5B-DY7GliuX.js} +1 -1
- package/dist/assets/classDiagram-2ON5EDUG-8I6xWkSV.js +1 -0
- package/dist/assets/classDiagram-v2-WZHVMYZB-8I6xWkSV.js +1 -0
- package/dist/assets/clone-zAvPgvOP.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-CTC2n8Hd.js → cose-bilkent-S5V4N54A-DmRYvz2l.js} +1 -1
- package/dist/assets/{dagre-6UL2VRFP-CAgGZKYt.js → dagre-6UL2VRFP-D4IfaYgb.js} +1 -1
- package/dist/assets/{diagram-PSM6KHXK-DymwxXvC.js → diagram-PSM6KHXK-ClfgeCpw.js} +1 -1
- package/dist/assets/{diagram-QEK2KX5R-CAEL5_ej.js → diagram-QEK2KX5R-BaBfarh-.js} +1 -1
- package/dist/assets/{diagram-S2PKOQOG-B6onWI1I.js → diagram-S2PKOQOG-DT1eYmOM.js} +1 -1
- package/dist/assets/{erDiagram-Q2GNP2WA-DP-dbxgu.js → erDiagram-Q2GNP2WA-C5ZVoHhn.js} +1 -1
- package/dist/assets/{flowDiagram-NV44I4VS-OMmA9Rvb.js → flowDiagram-NV44I4VS-C9MYAakn.js} +1 -1
- package/dist/assets/{ganttDiagram-JELNMOA3-B2oAHD8b.js → ganttDiagram-JELNMOA3-HHt6rJj9.js} +1 -1
- package/dist/assets/{gitGraphDiagram-V2S2FVAM-CAZO9ISt.js → gitGraphDiagram-V2S2FVAM-CfA3qwoT.js} +1 -1
- package/dist/assets/{graph-CeeDmBlM.js → graph-BLJloKEi.js} +1 -1
- package/dist/assets/index-CvOAfVHK.css +32 -0
- package/dist/assets/{index-CAGJQXdJ.js → index-CxNtY8EJ.js} +82 -82
- package/dist/assets/{infoDiagram-HS3SLOUP-m0CP23H-.js → infoDiagram-HS3SLOUP-ASXl-hwl.js} +1 -1
- package/dist/assets/{journeyDiagram-XKPGCS4Q-C9WvzT-H.js → journeyDiagram-XKPGCS4Q-B-wDjjzW.js} +1 -1
- package/dist/assets/{kanban-definition-3W4ZIXB7-KIEeugzr.js → kanban-definition-3W4ZIXB7-BNWLVKm2.js} +1 -1
- package/dist/assets/{layout-P-20nhx7.js → layout-cpBTqNBI.js} +1 -1
- package/dist/assets/{linear-CMM5aRIM.js → linear-DAonGknK.js} +1 -1
- package/dist/assets/{mermaid.core-Z9YQUdi5.js → mermaid.core-BdetWGOO.js} +4 -4
- package/dist/assets/{mindmap-definition-VGOIOE7T-B-NyLDuB.js → mindmap-definition-VGOIOE7T-CJOEqJKm.js} +1 -1
- package/dist/assets/{pieDiagram-ADFJNKIX-Dp8zgF7X.js → pieDiagram-ADFJNKIX-Dnc1ekZC.js} +1 -1
- package/dist/assets/{quadrantDiagram-AYHSOK5B-Cs07PJ50.js → quadrantDiagram-AYHSOK5B-Dr56Xspi.js} +1 -1
- package/dist/assets/{requirementDiagram-UZGBJVZJ-CWMm228C.js → requirementDiagram-UZGBJVZJ-z-G-ThCz.js} +1 -1
- package/dist/assets/{sankeyDiagram-TZEHDZUN-Cf7zGgTA.js → sankeyDiagram-TZEHDZUN-D_4p6Me1.js} +1 -1
- package/dist/assets/{sequenceDiagram-WL72ISMW-TzZvjAhX.js → sequenceDiagram-WL72ISMW-DD1K4OoS.js} +1 -1
- package/dist/assets/{stateDiagram-FKZM4ZOC-CH45r52Y.js → stateDiagram-FKZM4ZOC-ENIvCriI.js} +1 -1
- package/dist/assets/stateDiagram-v2-4FDKWEC3-BgJoZY9E.js +1 -0
- package/dist/assets/{timeline-definition-IT6M3QCI-CLaDCX_H.js → timeline-definition-IT6M3QCI-V8I1MBPc.js} +1 -1
- package/dist/assets/{treemap-GDKQZRPO-BNNAKE7E.js → treemap-GDKQZRPO-1lnlvkR3.js} +1 -1
- package/dist/assets/{xychartDiagram-PRI3JC2R-BG_0_QvU.js → xychartDiagram-PRI3JC2R-CrSrN3UV.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/server/collective-intelligence.ts +134 -43
- package/server/embedding.ts +12 -3
- package/server/memory-consolidation.ts +703 -0
- package/server/memory-migration.ts +468 -0
- package/server/routes/ci-routes.ts +68 -10
- package/server/routes/settings-routes.ts +10 -2
- package/server/semantic-memory.ts +1149 -187
- package/server/session-types.ts +15 -0
- package/server/settings-manager.ts +122 -3
- package/server/ws-bridge.ts +209 -26
- package/dist/assets/channel-D6cof2B8.js +0 -1
- package/dist/assets/classDiagram-2ON5EDUG-BG_dxrC_.js +0 -1
- package/dist/assets/classDiagram-v2-WZHVMYZB-BG_dxrC_.js +0 -1
- package/dist/assets/clone-vuZ4vhTG.js +0 -1
- package/dist/assets/index-Byjg9zgI.css +0 -32
- package/dist/assets/stateDiagram-v2-4FDKWEC3-DaPUshfK.js +0 -1
|
@@ -0,0 +1,703 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory consolidation pipeline — JUDGE → DISTILL → CONSOLIDATE.
|
|
3
|
+
*
|
|
4
|
+
* Implements docs/design/semantic-memory-v2.md §3.4 on top of the v2 store
|
|
5
|
+
* (semantic-memory.ts). The exported signatures are the contract with the
|
|
6
|
+
* call sites (ws-bridge / collective-intelligence) — do not change them.
|
|
7
|
+
*
|
|
8
|
+
* Flow per trigger (turn boundary / idle / session end / manual — all funnel
|
|
9
|
+
* into consolidate() with a per-session in-flight guard):
|
|
10
|
+
*
|
|
11
|
+
* 1. Candidates: the session's un-consolidated fragments (all namespaces the
|
|
12
|
+
* session wrote to — the store filters by sessionId).
|
|
13
|
+
* 2. JUDGE (local, cheap): drop fragments with w(t) × confidence < 0.15;
|
|
14
|
+
* drop near-duplicates within the batch (cosine > 0.97 when embeddings
|
|
15
|
+
* exist, content equality otherwise — higher judged score wins); group
|
|
16
|
+
* survivors by greedy embedding clustering at the 0.80 threshold
|
|
17
|
+
* (fragments without embeddings fall back into a single shared cluster).
|
|
18
|
+
* 3. DISTILL (OpenRouter chat call, temperature 0): the fixed §3.4 prompt
|
|
19
|
+
* contract — exact system prompt, JSON user message with the cluster and
|
|
20
|
+
* `existingKnowledge` (active rows within 0.80 of the cluster centroid).
|
|
21
|
+
* Output is strictly validated against the §3.4 schema; on parse or
|
|
22
|
+
* validation failure the call is retried ONCE with the validator error
|
|
23
|
+
* appended to the conversation; a second failure degrades those fragments
|
|
24
|
+
* to the concat fallback. Budgets: ≤ 40 fragments per call and ≤ 4
|
|
25
|
+
* distillation calls per trigger (each call may retry once; clusters
|
|
26
|
+
* beyond the call budget stay un-consolidated for the next trigger).
|
|
27
|
+
* 4. CONSOLIDATE: upsertKnowledgeFromDistillation() with synthesisMethod
|
|
28
|
+
* "llm" (handles upsert-by-(namespace, tag), supersession tombstones,
|
|
29
|
+
* embedding, and source marking), then markFragmentsConsolidated() for
|
|
30
|
+
* discardedFragmentIds so judged-away noise never re-triggers.
|
|
31
|
+
*
|
|
32
|
+
* Degraded mode: no OpenRouter API key → straight to concatFallbackConsolidate
|
|
33
|
+
* (synthesisMethod "concat"); consolidation is never blocked on configuration.
|
|
34
|
+
* Fire-and-forget posture: consolidate() never throws to callers — internal
|
|
35
|
+
* errors are logged and reported with best-effort accounting.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { DEFAULT_OPENROUTER_MODEL, getSettings } from "./settings-manager.js";
|
|
39
|
+
import {
|
|
40
|
+
NEAR_DUP_COSINE,
|
|
41
|
+
computeDecayedWeight,
|
|
42
|
+
concatFallbackConsolidate,
|
|
43
|
+
findRelatedKnowledge,
|
|
44
|
+
getUnconsolidatedFragments,
|
|
45
|
+
markFragmentsConsolidated,
|
|
46
|
+
policyForNamespace,
|
|
47
|
+
repoNamespace,
|
|
48
|
+
upsertKnowledgeFromDistillation,
|
|
49
|
+
type ConsolidatedKnowledge,
|
|
50
|
+
type DistilledKnowledgeItem,
|
|
51
|
+
type KnowledgeType,
|
|
52
|
+
type MemoryFragment,
|
|
53
|
+
} from "./semantic-memory.js";
|
|
54
|
+
|
|
55
|
+
export type ConsolidationReason = "turn_boundary" | "idle" | "session_end" | "manual";
|
|
56
|
+
|
|
57
|
+
export interface ConsolidationContext {
|
|
58
|
+
sessionId: string;
|
|
59
|
+
repoRoot: string;
|
|
60
|
+
backendType: string;
|
|
61
|
+
reason: ConsolidationReason;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ConsolidationResult {
|
|
65
|
+
status: "ran" | "skipped" | "in_flight";
|
|
66
|
+
synthesisMethod: "llm" | "concat" | "none";
|
|
67
|
+
knowledgeUpserted: number;
|
|
68
|
+
fragmentsConsolidated: number;
|
|
69
|
+
reason: ConsolidationReason;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── Tunables (§3.4) ─────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
/** Turn-boundary trigger: consolidate when ≥ this many un-consolidated fragments exist. */
|
|
75
|
+
export const TURN_CONSOLIDATION_THRESHOLD = 8;
|
|
76
|
+
/** JUDGE floor: fragments with w(t) × confidence below this are dropped. */
|
|
77
|
+
export const JUDGE_MIN_WEIGHTED_CONFIDENCE = 0.15;
|
|
78
|
+
/** Greedy clustering threshold — also the `existingKnowledge` centroid radius. */
|
|
79
|
+
export const CLUSTER_SIM_THRESHOLD = 0.8;
|
|
80
|
+
/** Budget: max fragments per distillation call. */
|
|
81
|
+
export const MAX_FRAGMENTS_PER_CALL = 40;
|
|
82
|
+
/** Budget: max distillation calls per trigger (each may retry once on invalid output). */
|
|
83
|
+
export const MAX_CALLS_PER_TRIGGER = 4;
|
|
84
|
+
/** Idle trigger: a session inactive longer than this is consolidated. */
|
|
85
|
+
export const IDLE_TRIGGER_MS = 30 * 60 * 1000;
|
|
86
|
+
|
|
87
|
+
const IDLE_CHECK_INTERVAL_MS = 60 * 1000;
|
|
88
|
+
const DISTILL_TIMEOUT_MS = 30_000;
|
|
89
|
+
const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions";
|
|
90
|
+
/** consolidatedInto sentinel for fragments the distiller discarded (no knowledge row). */
|
|
91
|
+
const DISCARDED_KNOWLEDGE_ID = "discarded";
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* EXACT system prompt from the §3.4 prompt contract. Do not edit without
|
|
95
|
+
* updating docs/design/semantic-memory-v2.md — the doc is the source of truth.
|
|
96
|
+
*/
|
|
97
|
+
const DISTILL_SYSTEM_PROMPT =
|
|
98
|
+
"You distill working notes from an AI coding session into durable knowledge for future sessions in this repository. " +
|
|
99
|
+
"Output ONLY valid JSON matching the schema. Merge duplicates. Resolve contradictions by preferring later, " +
|
|
100
|
+
"higher-confidence notes and say what superseded what. Discard chit-chat, transient state (branch names, " +
|
|
101
|
+
"in-progress todo status), and anything true only for this one session. Each summary must be a standalone " +
|
|
102
|
+
"statement useful with zero session context, ≤ 60 words.";
|
|
103
|
+
|
|
104
|
+
/** Compact schema restatement appended to retry messages so the model can self-correct. */
|
|
105
|
+
const SCHEMA_HINT =
|
|
106
|
+
'{"knowledge":[{"tag":"kebab-case-topic","type":"pattern|decision|convention|failure|fact",' +
|
|
107
|
+
'"summary":"standalone statement","confidence":0.0,"sourceFragmentIds":["uuid"],' +
|
|
108
|
+
'"supersedes":["existing-knowledge-uuid"],"namespace":"repo|global|agent"}],"discardedFragmentIds":["uuid"]}';
|
|
109
|
+
|
|
110
|
+
const KNOWLEDGE_TYPES = new Set<string>(["pattern", "decision", "convention", "failure", "fact"]);
|
|
111
|
+
const OUTPUT_NAMESPACES = new Set<string>(["repo", "global", "agent"]);
|
|
112
|
+
const KEBAB_TAG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
113
|
+
|
|
114
|
+
// ─── Small vector helpers (local — the store does not export these) ─────────
|
|
115
|
+
|
|
116
|
+
function cosineSimilarity(a: number[], b: number[]): number {
|
|
117
|
+
if (a.length === 0 || a.length !== b.length) return 0;
|
|
118
|
+
let dot = 0;
|
|
119
|
+
let magA = 0;
|
|
120
|
+
let magB = 0;
|
|
121
|
+
for (let i = 0; i < a.length; i++) {
|
|
122
|
+
dot += a[i] * b[i];
|
|
123
|
+
magA += a[i] * a[i];
|
|
124
|
+
magB += b[i] * b[i];
|
|
125
|
+
}
|
|
126
|
+
if (magA === 0 || magB === 0) return 0;
|
|
127
|
+
return dot / (Math.sqrt(magA) * Math.sqrt(magB));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function meanVector(vectors: number[][]): number[] {
|
|
131
|
+
const dim = vectors[0].length;
|
|
132
|
+
const out = Array(dim).fill(0) as number[];
|
|
133
|
+
for (const v of vectors) for (let i = 0; i < dim; i++) out[i] += v[i];
|
|
134
|
+
for (let i = 0; i < dim; i++) out[i] /= vectors.length;
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ─── Stage 1 — JUDGE (§3.4) ──────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
interface JudgedFragment {
|
|
141
|
+
fragment: MemoryFragment;
|
|
142
|
+
/** w(t) × confidence at judge time. */
|
|
143
|
+
judgedScore: number;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Cheap local filter: drop low-signal fragments (w(t) × confidence < 0.15)
|
|
148
|
+
* and near-duplicates within the batch (cosine > 0.97 when both embeddings
|
|
149
|
+
* exist; content-equality fallback otherwise). Higher judged score wins.
|
|
150
|
+
* Dropped fragments are simply excluded — decay/eviction handles them later.
|
|
151
|
+
*/
|
|
152
|
+
function judgeFragments(candidates: MemoryFragment[], now: number): MemoryFragment[] {
|
|
153
|
+
const weighted: JudgedFragment[] = [];
|
|
154
|
+
for (const fragment of candidates) {
|
|
155
|
+
const policy = policyForNamespace(fragment.namespace ?? "");
|
|
156
|
+
const weight = computeDecayedWeight(fragment, now, policy);
|
|
157
|
+
const judgedScore = weight * fragment.confidence;
|
|
158
|
+
if (judgedScore >= JUDGE_MIN_WEIGHTED_CONFIDENCE) weighted.push({ fragment, judgedScore });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const kept: JudgedFragment[] = [];
|
|
162
|
+
for (const candidate of [...weighted].sort((a, b) => b.judgedScore - a.judgedScore)) {
|
|
163
|
+
const isDup = kept.some((existing) => {
|
|
164
|
+
if (existing.fragment.content === candidate.fragment.content) return true;
|
|
165
|
+
const a = existing.fragment.embedding;
|
|
166
|
+
const b = candidate.fragment.embedding;
|
|
167
|
+
if (!a || !b) return false;
|
|
168
|
+
return cosineSimilarity(a, b) > NEAR_DUP_COSINE;
|
|
169
|
+
});
|
|
170
|
+
if (!isDup) kept.push(candidate);
|
|
171
|
+
}
|
|
172
|
+
return kept.map((k) => k.fragment);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Greedy embedding clustering at the 0.80 threshold: each fragment joins the
|
|
177
|
+
* first cluster whose running-mean centroid is within the threshold, else it
|
|
178
|
+
* seeds a new cluster. Fragments without embeddings share a single fallback
|
|
179
|
+
* cluster (when nothing has embeddings that is the batch — §3.4 Stage 1).
|
|
180
|
+
*/
|
|
181
|
+
function clusterFragments(fragments: MemoryFragment[]): MemoryFragment[][] {
|
|
182
|
+
const clusters: Array<{ members: MemoryFragment[]; centroid: number[] }> = [];
|
|
183
|
+
const noEmbedding: MemoryFragment[] = [];
|
|
184
|
+
|
|
185
|
+
for (const fragment of fragments) {
|
|
186
|
+
const vec = fragment.embedding;
|
|
187
|
+
if (!vec || vec.length === 0) {
|
|
188
|
+
noEmbedding.push(fragment);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
let placed = false;
|
|
192
|
+
for (const cluster of clusters) {
|
|
193
|
+
if (cosineSimilarity(vec, cluster.centroid) >= CLUSTER_SIM_THRESHOLD) {
|
|
194
|
+
cluster.members.push(fragment);
|
|
195
|
+
const n = cluster.members.length;
|
|
196
|
+
for (let i = 0; i < cluster.centroid.length; i++) {
|
|
197
|
+
cluster.centroid[i] = (cluster.centroid[i] * (n - 1) + vec[i]) / n;
|
|
198
|
+
}
|
|
199
|
+
placed = true;
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (!placed) clusters.push({ members: [fragment], centroid: [...vec] });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const out = clusters.map((c) => c.members);
|
|
207
|
+
if (noEmbedding.length > 0) out.push(noEmbedding);
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ─── Stage 2 — DISTILL: strict output validation (§3.4 schema) ───────────────
|
|
212
|
+
|
|
213
|
+
interface DistillationOutput {
|
|
214
|
+
knowledge: DistilledKnowledgeItem[];
|
|
215
|
+
discardedFragmentIds: string[];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
type ValidationResult = { ok: true; value: DistillationOutput } | { ok: false; error: string };
|
|
219
|
+
|
|
220
|
+
/** Strip a single fenced code block (``` / ```json) around the payload, if present. */
|
|
221
|
+
function stripCodeFences(raw: string): string {
|
|
222
|
+
const trimmed = raw.trim();
|
|
223
|
+
const match = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/);
|
|
224
|
+
return match ? match[1] : trimmed;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Hand-rolled strict validator for the §3.4 output schema. All fragment ids
|
|
229
|
+
* must come from the input cluster and all `supersedes` ids from the
|
|
230
|
+
* `existingKnowledge` we offered — the model may not touch rows it wasn't shown.
|
|
231
|
+
*/
|
|
232
|
+
export function validateDistillationOutput(
|
|
233
|
+
raw: string,
|
|
234
|
+
clusterIds: ReadonlySet<string>,
|
|
235
|
+
existingKnowledgeIds: ReadonlySet<string>,
|
|
236
|
+
): ValidationResult {
|
|
237
|
+
let parsed: unknown;
|
|
238
|
+
try {
|
|
239
|
+
parsed = JSON.parse(stripCodeFences(raw));
|
|
240
|
+
} catch (err) {
|
|
241
|
+
return {
|
|
242
|
+
ok: false,
|
|
243
|
+
error: `output is not valid JSON (${err instanceof Error ? err.message : String(err)})`,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
247
|
+
return { ok: false, error: "top-level output must be a JSON object" };
|
|
248
|
+
}
|
|
249
|
+
const obj = parsed as Record<string, unknown>;
|
|
250
|
+
if (!Array.isArray(obj.knowledge)) {
|
|
251
|
+
return { ok: false, error: '"knowledge" must be an array' };
|
|
252
|
+
}
|
|
253
|
+
if (!Array.isArray(obj.discardedFragmentIds)) {
|
|
254
|
+
return { ok: false, error: '"discardedFragmentIds" must be an array' };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const discarded: string[] = [];
|
|
258
|
+
for (const id of obj.discardedFragmentIds as unknown[]) {
|
|
259
|
+
if (typeof id !== "string") {
|
|
260
|
+
return { ok: false, error: '"discardedFragmentIds" entries must be strings' };
|
|
261
|
+
}
|
|
262
|
+
if (!clusterIds.has(id)) {
|
|
263
|
+
return {
|
|
264
|
+
ok: false,
|
|
265
|
+
error: `discardedFragmentIds contains "${id}" which is not a fragment id from the input cluster`,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
discarded.push(id);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const items: DistilledKnowledgeItem[] = [];
|
|
272
|
+
const rawItems = obj.knowledge as unknown[];
|
|
273
|
+
for (let i = 0; i < rawItems.length; i++) {
|
|
274
|
+
const where = `knowledge[${i}]`;
|
|
275
|
+
const rawItem = rawItems[i];
|
|
276
|
+
if (typeof rawItem !== "object" || rawItem === null || Array.isArray(rawItem)) {
|
|
277
|
+
return { ok: false, error: `${where} must be an object` };
|
|
278
|
+
}
|
|
279
|
+
const item = rawItem as Record<string, unknown>;
|
|
280
|
+
if (typeof item.tag !== "string" || !KEBAB_TAG_RE.test(item.tag)) {
|
|
281
|
+
return { ok: false, error: `${where}.tag must be a kebab-case string (e.g. "auth-tokens")` };
|
|
282
|
+
}
|
|
283
|
+
if (typeof item.type !== "string" || !KNOWLEDGE_TYPES.has(item.type)) {
|
|
284
|
+
return { ok: false, error: `${where}.type must be one of pattern|decision|convention|failure|fact` };
|
|
285
|
+
}
|
|
286
|
+
if (typeof item.summary !== "string" || item.summary.trim() === "") {
|
|
287
|
+
return { ok: false, error: `${where}.summary must be a non-empty string` };
|
|
288
|
+
}
|
|
289
|
+
if (
|
|
290
|
+
typeof item.confidence !== "number" ||
|
|
291
|
+
!Number.isFinite(item.confidence) ||
|
|
292
|
+
item.confidence < 0 ||
|
|
293
|
+
item.confidence > 1
|
|
294
|
+
) {
|
|
295
|
+
return { ok: false, error: `${where}.confidence must be a number between 0 and 1` };
|
|
296
|
+
}
|
|
297
|
+
if (!Array.isArray(item.sourceFragmentIds)) {
|
|
298
|
+
return { ok: false, error: `${where}.sourceFragmentIds must be an array of fragment ids` };
|
|
299
|
+
}
|
|
300
|
+
const sourceIds: string[] = [];
|
|
301
|
+
for (const id of item.sourceFragmentIds as unknown[]) {
|
|
302
|
+
if (typeof id !== "string") {
|
|
303
|
+
return { ok: false, error: `${where}.sourceFragmentIds entries must be strings` };
|
|
304
|
+
}
|
|
305
|
+
if (!clusterIds.has(id)) {
|
|
306
|
+
return {
|
|
307
|
+
ok: false,
|
|
308
|
+
error: `${where}.sourceFragmentIds contains "${id}" which is not a fragment id from the input cluster`,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
sourceIds.push(id);
|
|
312
|
+
}
|
|
313
|
+
let supersedes: string[] | undefined;
|
|
314
|
+
if (item.supersedes !== undefined) {
|
|
315
|
+
if (!Array.isArray(item.supersedes)) {
|
|
316
|
+
return { ok: false, error: `${where}.supersedes must be an array of existing knowledge ids` };
|
|
317
|
+
}
|
|
318
|
+
supersedes = [];
|
|
319
|
+
for (const id of item.supersedes as unknown[]) {
|
|
320
|
+
if (typeof id !== "string" || !existingKnowledgeIds.has(id)) {
|
|
321
|
+
return {
|
|
322
|
+
ok: false,
|
|
323
|
+
error: `${where}.supersedes contains "${String(id)}" which is not an id from existingKnowledge`,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
supersedes.push(id);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (typeof item.namespace !== "string" || !OUTPUT_NAMESPACES.has(item.namespace)) {
|
|
330
|
+
return { ok: false, error: `${where}.namespace must be one of repo|global|agent` };
|
|
331
|
+
}
|
|
332
|
+
items.push({
|
|
333
|
+
tag: item.tag,
|
|
334
|
+
type: item.type as KnowledgeType,
|
|
335
|
+
summary: item.summary,
|
|
336
|
+
confidence: item.confidence,
|
|
337
|
+
sourceFragmentIds: sourceIds,
|
|
338
|
+
supersedes,
|
|
339
|
+
namespace: item.namespace,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return { ok: true, value: { knowledge: items, discardedFragmentIds: discarded } };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ─── Stage 2 — DISTILL: OpenRouter call (same pattern as auto-namer.ts) ──────
|
|
347
|
+
|
|
348
|
+
interface ChatMessage {
|
|
349
|
+
role: "system" | "user" | "assistant";
|
|
350
|
+
content: string;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function extractTextContent(content: unknown): string | null {
|
|
354
|
+
if (typeof content === "string") return content;
|
|
355
|
+
if (Array.isArray(content)) {
|
|
356
|
+
const text = content
|
|
357
|
+
.map((item) => {
|
|
358
|
+
if (typeof item === "string") return item;
|
|
359
|
+
if (item && typeof item === "object") {
|
|
360
|
+
const maybe = item as { text?: unknown };
|
|
361
|
+
return typeof maybe.text === "string" ? maybe.text : "";
|
|
362
|
+
}
|
|
363
|
+
return "";
|
|
364
|
+
})
|
|
365
|
+
.join("\n")
|
|
366
|
+
.trim();
|
|
367
|
+
return text || null;
|
|
368
|
+
}
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** One OpenRouter chat completion at temperature 0. Returns the raw text or null on transport failure. */
|
|
373
|
+
async function callOpenRouter(
|
|
374
|
+
messages: ChatMessage[],
|
|
375
|
+
apiKey: string,
|
|
376
|
+
model: string,
|
|
377
|
+
): Promise<string | null> {
|
|
378
|
+
const controller = new AbortController();
|
|
379
|
+
const timer = setTimeout(() => controller.abort(), DISTILL_TIMEOUT_MS);
|
|
380
|
+
try {
|
|
381
|
+
const res = await fetch(OPENROUTER_URL, {
|
|
382
|
+
method: "POST",
|
|
383
|
+
headers: {
|
|
384
|
+
"Content-Type": "application/json",
|
|
385
|
+
Authorization: `Bearer ${apiKey}`,
|
|
386
|
+
},
|
|
387
|
+
body: JSON.stringify({ model, messages, temperature: 0 }),
|
|
388
|
+
signal: controller.signal,
|
|
389
|
+
});
|
|
390
|
+
if (!res.ok) {
|
|
391
|
+
console.warn(`[memory-consolidation] OpenRouter request failed: ${res.status} ${res.statusText}`);
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
const data = (await res.json()) as {
|
|
395
|
+
choices?: Array<{ message?: { content?: unknown } }>;
|
|
396
|
+
};
|
|
397
|
+
return extractTextContent(data.choices?.[0]?.message?.content);
|
|
398
|
+
} catch (err) {
|
|
399
|
+
console.warn("[memory-consolidation] OpenRouter request failed:", err);
|
|
400
|
+
return null;
|
|
401
|
+
} finally {
|
|
402
|
+
clearTimeout(timer);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
interface DistillOutcome {
|
|
407
|
+
ok: boolean;
|
|
408
|
+
items: DistilledKnowledgeItem[];
|
|
409
|
+
discardedFragmentIds: string[];
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Distill one ≤40-fragment chunk: build the §3.4 user message (cluster +
|
|
414
|
+
* existingKnowledge within 0.80 of the centroid), call OpenRouter, validate
|
|
415
|
+
* strictly; on failure retry ONCE with the validator error appended to the
|
|
416
|
+
* conversation. Returns ok=false after the second failure (→ concat fallback).
|
|
417
|
+
*/
|
|
418
|
+
async function distillChunk(
|
|
419
|
+
chunk: MemoryFragment[],
|
|
420
|
+
ctx: ConsolidationContext,
|
|
421
|
+
apiKey: string,
|
|
422
|
+
model: string,
|
|
423
|
+
now: number,
|
|
424
|
+
): Promise<DistillOutcome> {
|
|
425
|
+
const knowledgeNamespace = ctx.repoRoot ? repoNamespace(ctx.repoRoot) : "global";
|
|
426
|
+
const vectors = chunk
|
|
427
|
+
.map((f) => f.embedding)
|
|
428
|
+
.filter((v): v is number[] => Array.isArray(v) && v.length > 0);
|
|
429
|
+
|
|
430
|
+
let related: ConsolidatedKnowledge[] = [];
|
|
431
|
+
try {
|
|
432
|
+
related =
|
|
433
|
+
vectors.length > 0
|
|
434
|
+
? await findRelatedKnowledge(knowledgeNamespace, meanVector(vectors), CLUSTER_SIM_THRESHOLD)
|
|
435
|
+
: await findRelatedKnowledge(
|
|
436
|
+
knowledgeNamespace,
|
|
437
|
+
chunk.map((f) => f.content),
|
|
438
|
+
CLUSTER_SIM_THRESHOLD,
|
|
439
|
+
);
|
|
440
|
+
} catch (err) {
|
|
441
|
+
console.warn("[memory-consolidation] findRelatedKnowledge failed:", err);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// User message — the exact JSON shape from the §3.4 prompt contract.
|
|
445
|
+
const userPayload = {
|
|
446
|
+
repoRoot: ctx.repoRoot,
|
|
447
|
+
cluster: chunk.map((f) => ({
|
|
448
|
+
id: f.id,
|
|
449
|
+
type: f.type,
|
|
450
|
+
content: f.content,
|
|
451
|
+
confidence: f.confidence,
|
|
452
|
+
ageHours: Math.round(Math.max(0, now - f.timestamp) / 360_000) / 10,
|
|
453
|
+
files: f.gitContext.files ?? [],
|
|
454
|
+
})),
|
|
455
|
+
existingKnowledge: related.map((k) => ({
|
|
456
|
+
id: k.id,
|
|
457
|
+
tag: k.tag,
|
|
458
|
+
summary: k.summary,
|
|
459
|
+
confidence: k.confidence,
|
|
460
|
+
})),
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
const messages: ChatMessage[] = [
|
|
464
|
+
{ role: "system", content: DISTILL_SYSTEM_PROMPT },
|
|
465
|
+
{ role: "user", content: JSON.stringify(userPayload) },
|
|
466
|
+
];
|
|
467
|
+
const clusterIds = new Set(chunk.map((f) => f.id));
|
|
468
|
+
const existingIds = new Set(related.map((k) => k.id));
|
|
469
|
+
|
|
470
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
471
|
+
const raw = await callOpenRouter(messages, apiKey, model);
|
|
472
|
+
if (raw === null) {
|
|
473
|
+
// Transport failure — nothing to append; one blind retry, then give up.
|
|
474
|
+
if (attempt === 0) continue;
|
|
475
|
+
return { ok: false, items: [], discardedFragmentIds: [] };
|
|
476
|
+
}
|
|
477
|
+
const validated = validateDistillationOutput(raw, clusterIds, existingIds);
|
|
478
|
+
if (validated.ok) {
|
|
479
|
+
return {
|
|
480
|
+
ok: true,
|
|
481
|
+
items: validated.value.knowledge,
|
|
482
|
+
discardedFragmentIds: validated.value.discardedFragmentIds,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
if (attempt === 0) {
|
|
486
|
+
messages.push({ role: "assistant", content: raw });
|
|
487
|
+
messages.push({
|
|
488
|
+
role: "user",
|
|
489
|
+
content: `Your output failed validation: ${validated.error}\nRespond with ONLY valid JSON matching this schema: ${SCHEMA_HINT}`,
|
|
490
|
+
});
|
|
491
|
+
} else {
|
|
492
|
+
console.warn(`[memory-consolidation] distillation output invalid after retry: ${validated.error}`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return { ok: false, items: [], discardedFragmentIds: [] };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ─── consolidate() — the single entry point for all triggers ─────────────────
|
|
499
|
+
|
|
500
|
+
const _inFlight = new Set<string>();
|
|
501
|
+
|
|
502
|
+
function emptyResult(
|
|
503
|
+
status: ConsolidationResult["status"],
|
|
504
|
+
reason: ConsolidationReason,
|
|
505
|
+
): ConsolidationResult {
|
|
506
|
+
return { status, synthesisMethod: "none", knowledgeUpserted: 0, fragmentsConsolidated: 0, reason };
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async function runConcatFallback(
|
|
510
|
+
ctx: ConsolidationContext,
|
|
511
|
+
knowledgeUpserted: number,
|
|
512
|
+
consolidatedIds: Set<string>,
|
|
513
|
+
): Promise<number> {
|
|
514
|
+
const rows = await concatFallbackConsolidate(ctx.sessionId, ctx.repoRoot, ctx.backendType);
|
|
515
|
+
for (const row of rows) for (const id of row.sourceFragments) consolidatedIds.add(id);
|
|
516
|
+
return knowledgeUpserted + rows.length;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
async function runConsolidation(ctx: ConsolidationContext): Promise<ConsolidationResult> {
|
|
520
|
+
const candidates = await getUnconsolidatedFragments(ctx.sessionId);
|
|
521
|
+
if (candidates.length === 0) return emptyResult("skipped", ctx.reason);
|
|
522
|
+
|
|
523
|
+
const settings = getSettings();
|
|
524
|
+
const apiKey = settings.openrouterApiKey.trim();
|
|
525
|
+
const consolidatedIds = new Set<string>();
|
|
526
|
+
|
|
527
|
+
// No API key → degrade to concat, never block (§3.4).
|
|
528
|
+
if (!apiKey) {
|
|
529
|
+
const knowledgeUpserted = await runConcatFallback(ctx, 0, consolidatedIds);
|
|
530
|
+
return {
|
|
531
|
+
status: "ran",
|
|
532
|
+
synthesisMethod: "concat",
|
|
533
|
+
knowledgeUpserted,
|
|
534
|
+
fragmentsConsolidated: consolidatedIds.size,
|
|
535
|
+
reason: ctx.reason,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
const model = settings.openrouterModel?.trim() || DEFAULT_OPENROUTER_MODEL;
|
|
539
|
+
|
|
540
|
+
// Stage 1 — JUDGE.
|
|
541
|
+
const now = Date.now();
|
|
542
|
+
const survivors = judgeFragments(candidates, now);
|
|
543
|
+
if (survivors.length === 0) return { ...emptyResult("ran", ctx.reason) };
|
|
544
|
+
const clusters = clusterFragments(survivors);
|
|
545
|
+
|
|
546
|
+
// Budgets: split oversized clusters into ≤40-fragment chunks, cap at 4 calls.
|
|
547
|
+
const chunks: MemoryFragment[][] = [];
|
|
548
|
+
for (const cluster of clusters) {
|
|
549
|
+
for (let i = 0; i < cluster.length; i += MAX_FRAGMENTS_PER_CALL) {
|
|
550
|
+
chunks.push(cluster.slice(i, i + MAX_FRAGMENTS_PER_CALL));
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
const budgeted = chunks.slice(0, MAX_CALLS_PER_TRIGGER);
|
|
554
|
+
|
|
555
|
+
// Stage 2 + 3 — DISTILL each chunk, then CONSOLIDATE its validated output.
|
|
556
|
+
let knowledgeUpserted = 0;
|
|
557
|
+
let anyLlmSuccess = false;
|
|
558
|
+
let anyFailure = false;
|
|
559
|
+
for (const chunk of budgeted) {
|
|
560
|
+
const outcome = await distillChunk(chunk, ctx, apiKey, model, now);
|
|
561
|
+
if (!outcome.ok) {
|
|
562
|
+
anyFailure = true;
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
anyLlmSuccess = true;
|
|
566
|
+
if (outcome.items.length > 0) {
|
|
567
|
+
const rows = await upsertKnowledgeFromDistillation(outcome.items, {
|
|
568
|
+
sessionId: ctx.sessionId,
|
|
569
|
+
repoRoot: ctx.repoRoot,
|
|
570
|
+
backendType: ctx.backendType,
|
|
571
|
+
synthesisMethod: "llm",
|
|
572
|
+
});
|
|
573
|
+
knowledgeUpserted += rows.length;
|
|
574
|
+
for (const item of outcome.items) for (const id of item.sourceFragmentIds) consolidatedIds.add(id);
|
|
575
|
+
}
|
|
576
|
+
if (outcome.discardedFragmentIds.length > 0) {
|
|
577
|
+
await markFragmentsConsolidated(outcome.discardedFragmentIds, DISCARDED_KNOWLEDGE_ID);
|
|
578
|
+
for (const id of outcome.discardedFragmentIds) consolidatedIds.add(id);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Chunks that failed twice degrade to the concat fallback (it consolidates
|
|
583
|
+
// whatever is still un-consolidated for the session — best-effort, §3.4).
|
|
584
|
+
if (anyFailure) {
|
|
585
|
+
try {
|
|
586
|
+
knowledgeUpserted = await runConcatFallback(ctx, knowledgeUpserted, consolidatedIds);
|
|
587
|
+
} catch (err) {
|
|
588
|
+
console.warn("[memory-consolidation] concat fallback failed:", err);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const synthesisMethod: ConsolidationResult["synthesisMethod"] = anyLlmSuccess
|
|
593
|
+
? "llm"
|
|
594
|
+
: anyFailure
|
|
595
|
+
? "concat"
|
|
596
|
+
: "none";
|
|
597
|
+
return {
|
|
598
|
+
status: "ran",
|
|
599
|
+
synthesisMethod,
|
|
600
|
+
knowledgeUpserted,
|
|
601
|
+
fragmentsConsolidated: consolidatedIds.size,
|
|
602
|
+
reason: ctx.reason,
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Run consolidation for a session. Idempotent per in-flight guard: concurrent
|
|
608
|
+
* calls for the same session return { status: "in_flight" }.
|
|
609
|
+
*/
|
|
610
|
+
export async function consolidate(ctx: ConsolidationContext): Promise<ConsolidationResult> {
|
|
611
|
+
if (_inFlight.has(ctx.sessionId)) return emptyResult("in_flight", ctx.reason);
|
|
612
|
+
_inFlight.add(ctx.sessionId);
|
|
613
|
+
try {
|
|
614
|
+
return await runConsolidation(ctx);
|
|
615
|
+
} catch (err) {
|
|
616
|
+
// Fire-and-forget posture: never throw to callers.
|
|
617
|
+
console.warn("[memory-consolidation] consolidate failed:", err);
|
|
618
|
+
return emptyResult("ran", ctx.reason);
|
|
619
|
+
} finally {
|
|
620
|
+
_inFlight.delete(ctx.sessionId);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** True when the session has ≥ threshold un-consolidated fragments (turn-boundary trigger). */
|
|
625
|
+
export async function shouldConsolidateOnTurn(sessionId: string): Promise<boolean> {
|
|
626
|
+
try {
|
|
627
|
+
const fragments = await getUnconsolidatedFragments(sessionId, TURN_CONSOLIDATION_THRESHOLD);
|
|
628
|
+
return fragments.length >= TURN_CONSOLIDATION_THRESHOLD;
|
|
629
|
+
} catch (err) {
|
|
630
|
+
console.warn("[memory-consolidation] shouldConsolidateOnTurn failed:", err);
|
|
631
|
+
return false;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// ─── Idle trigger (§3.4 trigger 2) ───────────────────────────────────────────
|
|
636
|
+
|
|
637
|
+
interface IdleEntry {
|
|
638
|
+
ctx: Omit<ConsolidationContext, "reason">;
|
|
639
|
+
lastActivityAt: number;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const _idleSessions = new Map<string, IdleEntry>();
|
|
643
|
+
let _idleTimer: ReturnType<typeof setInterval> | null = null;
|
|
644
|
+
|
|
645
|
+
function ensureIdleWatcher(): void {
|
|
646
|
+
// Tests drive the idle check explicitly via _checkIdleSessions — a live
|
|
647
|
+
// interval would fire consolidations mid-assertion (same gating as the
|
|
648
|
+
// store's maintenance timers in semantic-memory.ts).
|
|
649
|
+
if (process.env.VITEST || process.env.NODE_ENV === "test") return;
|
|
650
|
+
if (_idleTimer) return;
|
|
651
|
+
_idleTimer = setInterval(() => {
|
|
652
|
+
_checkIdleSessions().catch((err) =>
|
|
653
|
+
console.warn("[memory-consolidation] idle check failed:", err),
|
|
654
|
+
);
|
|
655
|
+
}, IDLE_CHECK_INTERVAL_MS);
|
|
656
|
+
_idleTimer.unref?.();
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Record session activity for the idle trigger. The module owns the idle
|
|
661
|
+
* timer internally; callers just report activity with enough context to
|
|
662
|
+
* consolidate later.
|
|
663
|
+
*/
|
|
664
|
+
export function noteSessionActivity(ctx: Omit<ConsolidationContext, "reason">): void {
|
|
665
|
+
_idleSessions.set(ctx.sessionId, { ctx, lastActivityAt: Date.now() });
|
|
666
|
+
ensureIdleWatcher();
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Idle sweep: sessions inactive > 30 min with un-consolidated fragments are
|
|
671
|
+
* consolidated with reason "idle". One-shot per idle period — the entry is
|
|
672
|
+
* removed on firing and re-registered by the next noteSessionActivity call.
|
|
673
|
+
* Exported as a test hook (the interval is disabled under vitest) and invoked
|
|
674
|
+
* by the internal unref()'d interval in production.
|
|
675
|
+
*/
|
|
676
|
+
export async function _checkIdleSessions(now: number = Date.now()): Promise<void> {
|
|
677
|
+
for (const [sessionId, entry] of [..._idleSessions]) {
|
|
678
|
+
if (now - entry.lastActivityAt <= IDLE_TRIGGER_MS) continue;
|
|
679
|
+
_idleSessions.delete(sessionId);
|
|
680
|
+
try {
|
|
681
|
+
const pending = await getUnconsolidatedFragments(sessionId, 1);
|
|
682
|
+
if (pending.length === 0) continue;
|
|
683
|
+
await consolidate({ ...entry.ctx, reason: "idle" });
|
|
684
|
+
} catch (err) {
|
|
685
|
+
console.warn("[memory-consolidation] idle consolidation failed:", err);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** Stop all idle timers (tests / shutdown). */
|
|
691
|
+
export function stopIdleWatcher(): void {
|
|
692
|
+
if (_idleTimer) {
|
|
693
|
+
clearInterval(_idleTimer);
|
|
694
|
+
_idleTimer = null;
|
|
695
|
+
}
|
|
696
|
+
_idleSessions.clear();
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** Reset module state between tests (in-flight guard + idle tracking). */
|
|
700
|
+
export function _resetForTest(): void {
|
|
701
|
+
_inFlight.clear();
|
|
702
|
+
stopIdleWatcher();
|
|
703
|
+
}
|