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
|
@@ -1,29 +1,62 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Layer 1: Semantic Memory
|
|
2
|
+
* Layer 1: Semantic Memory — v2 core store.
|
|
3
3
|
*
|
|
4
|
-
* Persistent, shared knowledge base anchored to
|
|
5
|
-
* Backed by LanceDB
|
|
4
|
+
* Persistent, shared knowledge base anchored to namespaces (design doc
|
|
5
|
+
* docs/design/semantic-memory-v2.md §3.1–§3.5). Backed by LanceDB.
|
|
6
6
|
*
|
|
7
|
-
* Two tables:
|
|
8
|
-
* -
|
|
9
|
-
* -
|
|
7
|
+
* Two active tables (names tracked in ~/.campfire/memory/meta.json):
|
|
8
|
+
* - fragments_v2[_<dim>]: episodic/semantic MemoryFragments with vectors
|
|
9
|
+
* - consolidated_v2[_<dim>]: distilled ConsolidatedKnowledge per (namespace, tag)
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* v2 adds:
|
|
12
|
+
* - namespaces (global / repo:<hash> / session:<id> / agent:<backend>) with
|
|
13
|
+
* pushed-down where() filtering (§3.1, fixes §1.7 starvation)
|
|
14
|
+
* - lazy decay + capped reinforcement (§3.2) — computed at read time
|
|
15
|
+
* - composite scored retrieval simNorm^1.5 × w(t) × confidence (§3.3)
|
|
16
|
+
* - embeddingStatus lifecycle ("ok"/"pending"/"none") — no zero vectors in
|
|
17
|
+
* ANN results (§1.6) + lazy re-embed queue at ≤ 2 req/s
|
|
18
|
+
* - consolidation primitives: idempotent upsert by (namespace, tag) with
|
|
19
|
+
* supersession tombstones and source marking (§1.2, §3.4 Stage 3)
|
|
20
|
+
* - v1 → v2 migration + dimension-change handling (§3.5, memory-migration.ts)
|
|
15
21
|
*/
|
|
16
22
|
|
|
17
|
-
import { mkdirSync
|
|
23
|
+
import { mkdirSync } from "node:fs";
|
|
18
24
|
import { join } from "node:path";
|
|
19
25
|
import { homedir } from "node:os";
|
|
20
26
|
import { randomUUID } from "node:crypto";
|
|
21
|
-
import type { BackendType } from "./session-types.js";
|
|
22
|
-
import { embed, getEmbeddingDim } from "./embedding.js";
|
|
27
|
+
import type { BackendType, MemoryEnrichmentItem } from "./session-types.js";
|
|
28
|
+
import { embed, getEmbeddingDim, getEmbeddingProviderName } from "./embedding.js";
|
|
29
|
+
import { getMemorySettings, type MemoryDecayPolicy } from "./settings-manager.js";
|
|
30
|
+
import {
|
|
31
|
+
ensureSchemaV2,
|
|
32
|
+
hashRepoRoot,
|
|
33
|
+
repoNamespace,
|
|
34
|
+
sessionNamespace,
|
|
35
|
+
agentNamespace,
|
|
36
|
+
namespaceClass,
|
|
37
|
+
isNamespaceString,
|
|
38
|
+
toNumberArray,
|
|
39
|
+
type MemoryMeta,
|
|
40
|
+
type NamespaceClass,
|
|
41
|
+
} from "./memory-migration.js";
|
|
42
|
+
|
|
43
|
+
// Re-export the namespace model so consumers only need this module.
|
|
44
|
+
export {
|
|
45
|
+
hashRepoRoot,
|
|
46
|
+
repoNamespace,
|
|
47
|
+
sessionNamespace,
|
|
48
|
+
agentNamespace,
|
|
49
|
+
namespaceClass,
|
|
50
|
+
isNamespaceString,
|
|
51
|
+
type NamespaceClass,
|
|
52
|
+
};
|
|
23
53
|
|
|
24
54
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
25
55
|
|
|
26
56
|
export type MemoryType = "observation" | "hypothesis" | "decision" | "pattern";
|
|
57
|
+
export type EmbeddingStatus = "ok" | "pending" | "none";
|
|
58
|
+
export type SynthesisMethod = "llm" | "concat";
|
|
59
|
+
export type KnowledgeType = "pattern" | "decision" | "convention" | "failure" | "fact";
|
|
27
60
|
|
|
28
61
|
export interface GitContext {
|
|
29
62
|
commitHash?: string;
|
|
@@ -46,8 +79,19 @@ export interface MemoryFragment {
|
|
|
46
79
|
tags: string[];
|
|
47
80
|
consolidatedInto?: string;
|
|
48
81
|
isConsolidated: boolean;
|
|
49
|
-
// Stored in LanceDB as a Float32 vector column —
|
|
82
|
+
// Stored in LanceDB as a Float32 vector column — populated on read only
|
|
83
|
+
// when embeddingStatus === "ok"
|
|
50
84
|
embedding?: number[];
|
|
85
|
+
// ── v2 lifecycle fields (§3.1/§3.2) — always populated by the store;
|
|
86
|
+
// optional so pre-v2 constructors/mocks keep compiling.
|
|
87
|
+
namespace?: string;
|
|
88
|
+
repoRootHash?: string;
|
|
89
|
+
lastReinforcedAt?: number;
|
|
90
|
+
accessCount?: number;
|
|
91
|
+
pinned?: boolean;
|
|
92
|
+
/** Per-fragment half-life override in hours; null = use namespace policy. */
|
|
93
|
+
halfLifeHours?: number | null;
|
|
94
|
+
embeddingStatus?: EmbeddingStatus;
|
|
51
95
|
}
|
|
52
96
|
|
|
53
97
|
export interface ConsolidatedKnowledge {
|
|
@@ -58,6 +102,12 @@ export interface ConsolidatedKnowledge {
|
|
|
58
102
|
lastUpdated: number;
|
|
59
103
|
confidence: number;
|
|
60
104
|
repoRoot: string;
|
|
105
|
+
// ── v2 fields
|
|
106
|
+
namespace?: string;
|
|
107
|
+
type?: KnowledgeType;
|
|
108
|
+
synthesisMethod?: SynthesisMethod;
|
|
109
|
+
/** Tombstone: id of the knowledge row that superseded this one ("" / undefined = active). */
|
|
110
|
+
supersededBy?: string;
|
|
61
111
|
}
|
|
62
112
|
|
|
63
113
|
export interface MemoryQueryOptions {
|
|
@@ -66,84 +116,144 @@ export interface MemoryQueryOptions {
|
|
|
66
116
|
tags?: string[];
|
|
67
117
|
type?: MemoryType;
|
|
68
118
|
sessionId?: string;
|
|
119
|
+
/** v2: restrict to a single namespace (pushed down into LanceDB where()). */
|
|
120
|
+
namespace?: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ─── Tunables (§3.2/§3.3) ────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
/** Similarity sharpening exponent — decay/confidence break ties, not override matches. */
|
|
126
|
+
export const SIM_EXPONENT = 1.5;
|
|
127
|
+
/** Fragments with pairwise cosine above this are near-duplicates (keep higher score). */
|
|
128
|
+
export const NEAR_DUP_COSINE = 0.97;
|
|
129
|
+
/** Decayed-weight floor below which consolidated, unpinned fragments are evicted. */
|
|
130
|
+
export const EVICTION_WEIGHT_THRESHOLD = 0.05;
|
|
131
|
+
/** Hard cap of fragments per namespace (lowest-w eviction backstop). */
|
|
132
|
+
export const NAMESPACE_HARD_CAP = 5000;
|
|
133
|
+
/** Reinforcement cap: half-life extension multiplier applies at most this many times. */
|
|
134
|
+
export const REINFORCE_ACCESS_CAP = 8;
|
|
135
|
+
/** Enrichment budget: max chars of raw fragment text in the block. */
|
|
136
|
+
export const FRAGMENT_BUDGET_CHARS = 1200;
|
|
137
|
+
/** Enrichment budget: max chars of the whole injected block. */
|
|
138
|
+
export const TOTAL_BUDGET_CHARS = 2000;
|
|
139
|
+
/** Max fragment lines in the enrichment block (§3.6.2). */
|
|
140
|
+
export const MAX_ENRICHMENT_FRAGMENTS = 5;
|
|
141
|
+
|
|
142
|
+
const REINFORCE_DEBOUNCE_MS = 500;
|
|
143
|
+
const SWEEP_INTERVAL_MS = 60 * 60 * 1000; // hourly (§3.2)
|
|
144
|
+
const REEMBED_INTERVAL_MS = 1000; // batch of 2 per second = ≤ 2 req/s (§3.5)
|
|
145
|
+
|
|
146
|
+
// ─── Decay + reinforcement (§3.2) ────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
export type DecayPolicy = MemoryDecayPolicy;
|
|
149
|
+
|
|
150
|
+
/** Minimal shape needed to compute a decayed weight (table-driven-testable). */
|
|
151
|
+
export interface DecayableLike {
|
|
152
|
+
pinned?: boolean;
|
|
153
|
+
lastReinforcedAt?: number;
|
|
154
|
+
timestamp?: number;
|
|
155
|
+
accessCount?: number;
|
|
156
|
+
halfLifeHours?: number | null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Lazy decay weight, computed at read time and never stored:
|
|
161
|
+
*
|
|
162
|
+
* halfLife_eff = halfLife_base × reinforceMultiplier ^ min(accessCount, 8)
|
|
163
|
+
* w(t) = pinned ? 1.0 : 0.5 ^ ((now − lastReinforcedAt) / halfLife_eff)
|
|
164
|
+
*
|
|
165
|
+
* Pinned rows and null half-lives never decay (w = 1).
|
|
166
|
+
*/
|
|
167
|
+
export function computeDecayedWeight(fragment: DecayableLike, now: number, policy: DecayPolicy): number {
|
|
168
|
+
if (fragment.pinned) return 1;
|
|
169
|
+
const override = fragment.halfLifeHours;
|
|
170
|
+
const baseHalfLife = typeof override === "number" && override > 0 ? override : policy.halfLifeHours;
|
|
171
|
+
if (baseHalfLife === null || baseHalfLife <= 0) return 1;
|
|
172
|
+
const multiplier = policy.reinforceMultiplier > 0 ? policy.reinforceMultiplier : 1;
|
|
173
|
+
const capped = Math.min(Math.max(fragment.accessCount ?? 0, 0), REINFORCE_ACCESS_CAP);
|
|
174
|
+
const effectiveHalfLife = baseHalfLife * Math.pow(multiplier, capped);
|
|
175
|
+
const anchor = fragment.lastReinforcedAt ?? fragment.timestamp ?? now;
|
|
176
|
+
const ageHours = Math.max(0, now - anchor) / 3_600_000;
|
|
177
|
+
return Math.pow(0.5, ageHours / effectiveHalfLife);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Decay policy for a namespace, from settings (defaults per §3.1). */
|
|
181
|
+
export function policyForNamespace(namespace: string): DecayPolicy {
|
|
182
|
+
const cls: NamespaceClass = namespaceClass(namespace);
|
|
183
|
+
return getMemorySettings().decay[cls];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ─── SQL helpers (pushed-down filters, §1.7 fix) ─────────────────────────────
|
|
187
|
+
|
|
188
|
+
/** Quote a string literal for a LanceDB (DataFusion) where() predicate. */
|
|
189
|
+
export function sqlQuote(value: string): string {
|
|
190
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Where-clause for a namespace query. `embeddedOnly` additionally excludes
|
|
195
|
+
* rows without a usable embedding from ANN search (§1.6 / §3.3).
|
|
196
|
+
*/
|
|
197
|
+
export function buildNamespaceWhere(namespace: string, embeddedOnly = true): string {
|
|
198
|
+
const base = `namespace = ${sqlQuote(namespace)}`;
|
|
199
|
+
return embeddedOnly ? `${base} AND embeddingStatus = 'ok'` : base;
|
|
69
200
|
}
|
|
70
201
|
|
|
71
202
|
// ─── LanceDB integration ─────────────────────────────────────────────────────
|
|
72
203
|
|
|
73
|
-
// Lazily import to avoid startup cost when memory is not used
|
|
74
204
|
type LanceDB = typeof import("@lancedb/lancedb");
|
|
75
|
-
type
|
|
205
|
+
type LanceConnection = Awaited<ReturnType<LanceDB["connect"]>>;
|
|
206
|
+
type LanceTable = Awaited<ReturnType<LanceConnection["openTable"]>>;
|
|
207
|
+
|
|
208
|
+
interface StoreState {
|
|
209
|
+
db: LanceConnection;
|
|
210
|
+
fragments: LanceTable;
|
|
211
|
+
consolidated: LanceTable;
|
|
212
|
+
meta: MemoryMeta;
|
|
213
|
+
}
|
|
76
214
|
|
|
77
|
-
let
|
|
78
|
-
let
|
|
79
|
-
let _consolidatedTable: LanceTable | null = null;
|
|
80
|
-
let _memoryDir = join(homedir(), ".campfire", "memory", "lancedb");
|
|
215
|
+
let _memoryRoot = join(homedir(), ".campfire", "memory");
|
|
216
|
+
let _initPromise: Promise<StoreState> | null = null;
|
|
81
217
|
|
|
82
|
-
async function
|
|
83
|
-
if (_db) return _db;
|
|
218
|
+
async function initState(): Promise<StoreState> {
|
|
84
219
|
const lancedb = await import("@lancedb/lancedb");
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
220
|
+
const dbDir = join(_memoryRoot, "lancedb");
|
|
221
|
+
mkdirSync(dbDir, { recursive: true });
|
|
222
|
+
const db = await lancedb.connect(dbDir);
|
|
223
|
+
const { meta, fragments, consolidated } = await ensureSchemaV2({
|
|
224
|
+
db,
|
|
225
|
+
memoryRoot: _memoryRoot,
|
|
226
|
+
provider: getEmbeddingProviderName(),
|
|
227
|
+
providerDim: getEmbeddingDim(),
|
|
228
|
+
});
|
|
229
|
+
return { db, fragments, consolidated, meta };
|
|
88
230
|
}
|
|
89
231
|
|
|
90
|
-
async function
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const seed = [{
|
|
100
|
-
id: "__seed__",
|
|
101
|
-
sessionId: "",
|
|
102
|
-
agentId: "",
|
|
103
|
-
backendType: "claude" as BackendType,
|
|
104
|
-
timestamp: 0,
|
|
105
|
-
type: "observation" as MemoryType,
|
|
106
|
-
content: "",
|
|
107
|
-
gitContextJson: "{}",
|
|
108
|
-
referencesJson: "[]",
|
|
109
|
-
tagsJson: "[]",
|
|
110
|
-
confidence: 0,
|
|
111
|
-
consolidatedInto: "",
|
|
112
|
-
isConsolidated: false,
|
|
113
|
-
vector: Array(dim).fill(0) as number[],
|
|
114
|
-
}];
|
|
115
|
-
_fragmentsTable = await db.createTable("fragments", seed);
|
|
116
|
-
await _fragmentsTable.delete('id = "__seed__"');
|
|
232
|
+
async function getState(): Promise<StoreState> {
|
|
233
|
+
let state = await (_initPromise ??= initState());
|
|
234
|
+
// Live provider/dimension change (settings edited at runtime): re-run the
|
|
235
|
+
// reconciliation path so the active tables always match the provider (§3.5.2).
|
|
236
|
+
const provider = getEmbeddingProviderName();
|
|
237
|
+
const dim = getEmbeddingDim();
|
|
238
|
+
if (provider !== state.meta.embeddingProvider || (dim !== null && dim !== state.meta.dim)) {
|
|
239
|
+
_initPromise = initState();
|
|
240
|
+
state = await _initPromise;
|
|
117
241
|
}
|
|
118
|
-
|
|
242
|
+
ensureMaintenanceTimers();
|
|
243
|
+
return state;
|
|
119
244
|
}
|
|
120
245
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if (tableNames.includes("consolidated")) {
|
|
126
|
-
_consolidatedTable = await db.openTable("consolidated");
|
|
127
|
-
} else {
|
|
128
|
-
const seed = [{
|
|
129
|
-
id: "__seed__",
|
|
130
|
-
tag: "",
|
|
131
|
-
summary: "",
|
|
132
|
-
sourceFragmentsJson: "[]",
|
|
133
|
-
lastUpdated: 0,
|
|
134
|
-
confidence: 0,
|
|
135
|
-
repoRoot: "",
|
|
136
|
-
}];
|
|
137
|
-
_consolidatedTable = await db.createTable("consolidated", seed);
|
|
138
|
-
await _consolidatedTable.delete('id = "__seed__"');
|
|
139
|
-
}
|
|
140
|
-
return _consolidatedTable;
|
|
246
|
+
// ─── Row <-> domain object mapping ───────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
function rowStr(v: unknown): string {
|
|
249
|
+
return typeof v === "string" ? v : "";
|
|
141
250
|
}
|
|
142
251
|
|
|
143
|
-
|
|
252
|
+
function rowNum(v: unknown): number {
|
|
253
|
+
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
254
|
+
}
|
|
144
255
|
|
|
145
|
-
function fragmentToRow(fragment: MemoryFragment,
|
|
146
|
-
const dim = getEmbeddingDim();
|
|
256
|
+
function fragmentToRow(fragment: MemoryFragment, vector: number[], status: EmbeddingStatus) {
|
|
147
257
|
return {
|
|
148
258
|
id: fragment.id,
|
|
149
259
|
sessionId: fragment.sessionId,
|
|
@@ -158,41 +268,84 @@ function fragmentToRow(fragment: MemoryFragment, embedding: number[] | null) {
|
|
|
158
268
|
confidence: fragment.confidence,
|
|
159
269
|
consolidatedInto: fragment.consolidatedInto ?? "",
|
|
160
270
|
isConsolidated: fragment.isConsolidated,
|
|
161
|
-
|
|
271
|
+
namespace: fragment.namespace ?? "",
|
|
272
|
+
repoRoot: fragment.gitContext.repoRoot ?? "",
|
|
273
|
+
repoRootHash: fragment.repoRootHash ?? "",
|
|
274
|
+
lastReinforcedAt: fragment.lastReinforcedAt ?? fragment.timestamp,
|
|
275
|
+
accessCount: fragment.accessCount ?? 0,
|
|
276
|
+
pinned: fragment.pinned ?? false,
|
|
277
|
+
// 0 = no override (null in the domain model)
|
|
278
|
+
halfLifeHours: fragment.halfLifeHours ?? 0,
|
|
279
|
+
embeddingStatus: status,
|
|
280
|
+
vector,
|
|
162
281
|
};
|
|
163
282
|
}
|
|
164
283
|
|
|
165
284
|
function rowToFragment(row: Record<string, unknown>): MemoryFragment {
|
|
285
|
+
const status = (rowStr(row.embeddingStatus) || "none") as EmbeddingStatus;
|
|
286
|
+
const halfLife = rowNum(row.halfLifeHours);
|
|
166
287
|
return {
|
|
167
|
-
id: row.id
|
|
168
|
-
sessionId: row.sessionId
|
|
169
|
-
agentId: row.agentId
|
|
170
|
-
backendType: row.backendType as BackendType,
|
|
171
|
-
timestamp: row.timestamp
|
|
172
|
-
type: row.type as MemoryType,
|
|
173
|
-
content: row.content
|
|
174
|
-
gitContext: JSON.parse(row.gitContextJson
|
|
175
|
-
references: JSON.parse(row.referencesJson
|
|
176
|
-
tags: JSON.parse(row.tagsJson
|
|
177
|
-
confidence: row.confidence
|
|
178
|
-
consolidatedInto: (row.consolidatedInto
|
|
179
|
-
isConsolidated: row.isConsolidated
|
|
288
|
+
id: rowStr(row.id),
|
|
289
|
+
sessionId: rowStr(row.sessionId),
|
|
290
|
+
agentId: rowStr(row.agentId),
|
|
291
|
+
backendType: (rowStr(row.backendType) || "claude") as BackendType,
|
|
292
|
+
timestamp: rowNum(row.timestamp),
|
|
293
|
+
type: (rowStr(row.type) || "observation") as MemoryType,
|
|
294
|
+
content: rowStr(row.content),
|
|
295
|
+
gitContext: JSON.parse(rowStr(row.gitContextJson) || "{}") as GitContext,
|
|
296
|
+
references: JSON.parse(rowStr(row.referencesJson) || "[]") as string[],
|
|
297
|
+
tags: JSON.parse(rowStr(row.tagsJson) || "[]") as string[],
|
|
298
|
+
confidence: rowNum(row.confidence),
|
|
299
|
+
consolidatedInto: rowStr(row.consolidatedInto) || undefined,
|
|
300
|
+
isConsolidated: row.isConsolidated === true,
|
|
301
|
+
embedding: status === "ok" ? toNumberArray(row.vector) : undefined,
|
|
302
|
+
namespace: rowStr(row.namespace),
|
|
303
|
+
repoRootHash: rowStr(row.repoRootHash),
|
|
304
|
+
lastReinforcedAt: rowNum(row.lastReinforcedAt) || rowNum(row.timestamp),
|
|
305
|
+
accessCount: rowNum(row.accessCount),
|
|
306
|
+
pinned: row.pinned === true,
|
|
307
|
+
halfLifeHours: halfLife > 0 ? halfLife : null,
|
|
308
|
+
embeddingStatus: status,
|
|
180
309
|
};
|
|
181
310
|
}
|
|
182
311
|
|
|
183
312
|
function rowToConsolidated(row: Record<string, unknown>): ConsolidatedKnowledge {
|
|
184
313
|
return {
|
|
185
|
-
id: row.id
|
|
186
|
-
tag: row.tag
|
|
187
|
-
summary: row.summary
|
|
188
|
-
sourceFragments: JSON.parse(row.sourceFragmentsJson
|
|
189
|
-
lastUpdated: row.lastUpdated
|
|
190
|
-
confidence: row.confidence
|
|
191
|
-
repoRoot: row.repoRoot
|
|
314
|
+
id: rowStr(row.id),
|
|
315
|
+
tag: rowStr(row.tag),
|
|
316
|
+
summary: rowStr(row.summary),
|
|
317
|
+
sourceFragments: JSON.parse(rowStr(row.sourceFragmentsJson) || "[]") as string[],
|
|
318
|
+
lastUpdated: rowNum(row.lastUpdated),
|
|
319
|
+
confidence: rowNum(row.confidence),
|
|
320
|
+
repoRoot: rowStr(row.repoRoot),
|
|
321
|
+
namespace: rowStr(row.namespace),
|
|
322
|
+
type: (rowStr(row.knowledgeType) || undefined) as KnowledgeType | undefined,
|
|
323
|
+
synthesisMethod: (rowStr(row.synthesisMethod) || undefined) as SynthesisMethod | undefined,
|
|
324
|
+
supersededBy: rowStr(row.supersededBy) || undefined,
|
|
192
325
|
};
|
|
193
326
|
}
|
|
194
327
|
|
|
195
|
-
// ───
|
|
328
|
+
// ─── Vector math ─────────────────────────────────────────────────────────────
|
|
329
|
+
|
|
330
|
+
function cosineSimilarity(a: number[], b: number[]): number {
|
|
331
|
+
if (a.length === 0 || a.length !== b.length) return 0;
|
|
332
|
+
let dot = 0;
|
|
333
|
+
let magA = 0;
|
|
334
|
+
let magB = 0;
|
|
335
|
+
for (let i = 0; i < a.length; i++) {
|
|
336
|
+
dot += a[i] * b[i];
|
|
337
|
+
magA += a[i] * a[i];
|
|
338
|
+
magB += b[i] * b[i];
|
|
339
|
+
}
|
|
340
|
+
if (magA === 0 || magB === 0) return 0;
|
|
341
|
+
return dot / (Math.sqrt(magA) * Math.sqrt(magB));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function zeros(dim: number): number[] {
|
|
345
|
+
return Array(dim).fill(0) as number[];
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ─── storeFragment ───────────────────────────────────────────────────────────
|
|
196
349
|
|
|
197
350
|
export interface StoreFragmentOptions {
|
|
198
351
|
sessionId: string;
|
|
@@ -204,20 +357,32 @@ export interface StoreFragmentOptions {
|
|
|
204
357
|
tags?: string[];
|
|
205
358
|
confidence?: number;
|
|
206
359
|
references?: string[];
|
|
360
|
+
/** v2: explicit target namespace. Default: repo:<hash> when a repoRoot is present, else session:<id>. */
|
|
361
|
+
namespace?: string;
|
|
362
|
+
pinned?: boolean;
|
|
363
|
+
/** Per-fragment half-life override in hours (null/omitted = namespace policy). */
|
|
364
|
+
halfLifeHours?: number | null;
|
|
207
365
|
}
|
|
208
366
|
|
|
209
367
|
/**
|
|
210
368
|
* Store a new memory fragment. Generates an embedding at write time.
|
|
211
|
-
*
|
|
212
|
-
*
|
|
369
|
+
*
|
|
370
|
+
* §1.6 fix: no zero-vector pollution — when the provider is "none" the row is
|
|
371
|
+
* stored with embeddingStatus "none"; when an embed call fails (or the dim
|
|
372
|
+
* mismatches mid-migration) it is stored "pending" and picked up by the lazy
|
|
373
|
+
* re-embed queue. Both states are excluded from ANN by where() (§3.3).
|
|
213
374
|
*/
|
|
214
375
|
export async function storeFragment(opts: StoreFragmentOptions): Promise<MemoryFragment> {
|
|
376
|
+
const repoRoot = opts.gitContext.repoRoot ?? "";
|
|
377
|
+
const namespace =
|
|
378
|
+
opts.namespace ?? (repoRoot ? repoNamespace(repoRoot) : sessionNamespace(opts.sessionId));
|
|
379
|
+
const now = Date.now();
|
|
215
380
|
const fragment: MemoryFragment = {
|
|
216
381
|
id: randomUUID(),
|
|
217
382
|
sessionId: opts.sessionId,
|
|
218
383
|
agentId: opts.agentId,
|
|
219
384
|
backendType: opts.backendType,
|
|
220
|
-
timestamp:
|
|
385
|
+
timestamp: now,
|
|
221
386
|
type: opts.type,
|
|
222
387
|
content: opts.content,
|
|
223
388
|
gitContext: opts.gitContext,
|
|
@@ -225,90 +390,701 @@ export async function storeFragment(opts: StoreFragmentOptions): Promise<MemoryF
|
|
|
225
390
|
confidence: opts.confidence ?? 0.7,
|
|
226
391
|
tags: opts.tags ?? [],
|
|
227
392
|
isConsolidated: false,
|
|
393
|
+
namespace,
|
|
394
|
+
repoRootHash: repoRoot ? hashRepoRoot(repoRoot) : "",
|
|
395
|
+
lastReinforcedAt: now,
|
|
396
|
+
accessCount: 0,
|
|
397
|
+
pinned: opts.pinned ?? false,
|
|
398
|
+
halfLifeHours: opts.halfLifeHours ?? null,
|
|
228
399
|
};
|
|
229
400
|
|
|
230
401
|
const embedding = await embed(opts.content);
|
|
231
|
-
const
|
|
232
|
-
|
|
402
|
+
const state = await getState();
|
|
403
|
+
const dim = state.meta.dim;
|
|
233
404
|
|
|
405
|
+
let status: EmbeddingStatus;
|
|
406
|
+
let vector: number[];
|
|
407
|
+
if (embedding && embedding.length === dim) {
|
|
408
|
+
status = "ok";
|
|
409
|
+
vector = embedding;
|
|
410
|
+
} else if (getEmbeddingDim() === null) {
|
|
411
|
+
status = "none";
|
|
412
|
+
vector = zeros(dim);
|
|
413
|
+
} else {
|
|
414
|
+
status = "pending";
|
|
415
|
+
vector = zeros(dim);
|
|
416
|
+
}
|
|
417
|
+
fragment.embeddingStatus = status;
|
|
418
|
+
if (status === "ok") fragment.embedding = embedding as number[];
|
|
419
|
+
|
|
420
|
+
await state.fragments.add([fragmentToRow(fragment, vector, status)]);
|
|
234
421
|
return fragment;
|
|
235
422
|
}
|
|
236
423
|
|
|
424
|
+
// ─── Scored retrieval (§3.3) ─────────────────────────────────────────────────
|
|
425
|
+
|
|
426
|
+
export interface RecallPlanEntry {
|
|
427
|
+
namespace: string;
|
|
428
|
+
depth: number;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export interface ScoredFragment {
|
|
432
|
+
fragment: MemoryFragment;
|
|
433
|
+
/** Composite score: simNorm^1.5 × w(t) × confidence. */
|
|
434
|
+
score: number;
|
|
435
|
+
/** Decayed weight w(t) at recall time. */
|
|
436
|
+
weight: number;
|
|
437
|
+
/** Normalized similarity (1 when retrieval had no query vector). */
|
|
438
|
+
simNorm: number;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function scoreRow(
|
|
442
|
+
row: Record<string, unknown>,
|
|
443
|
+
queryVector: number[] | null,
|
|
444
|
+
policy: DecayPolicy,
|
|
445
|
+
now: number,
|
|
446
|
+
): ScoredFragment {
|
|
447
|
+
const fragment = rowToFragment(row);
|
|
448
|
+
const weight = computeDecayedWeight(fragment, now, policy);
|
|
449
|
+
let simNorm = 1;
|
|
450
|
+
if (queryVector) {
|
|
451
|
+
simNorm = Math.max(0, cosineSimilarity(queryVector, toNumberArray(row.vector)));
|
|
452
|
+
}
|
|
453
|
+
const score = Math.pow(simNorm, SIM_EXPONENT) * weight * fragment.confidence;
|
|
454
|
+
return { fragment, score, weight, simNorm };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Near-duplicate dedupe: cosine > 0.97 (or identical content) keeps the higher score. */
|
|
458
|
+
function dedupeNearDuplicates(scored: ScoredFragment[]): ScoredFragment[] {
|
|
459
|
+
const kept: ScoredFragment[] = [];
|
|
460
|
+
const sorted = [...scored].sort((a, b) => b.score - a.score);
|
|
461
|
+
for (const candidate of sorted) {
|
|
462
|
+
const isDup = kept.some((existing) => {
|
|
463
|
+
if (existing.fragment.content === candidate.fragment.content) return true;
|
|
464
|
+
const a = existing.fragment.embedding;
|
|
465
|
+
const b = candidate.fragment.embedding;
|
|
466
|
+
if (!a || !b) return false;
|
|
467
|
+
return cosineSimilarity(a, b) > NEAR_DUP_COSINE;
|
|
468
|
+
});
|
|
469
|
+
if (!isDup) kept.push(candidate);
|
|
470
|
+
}
|
|
471
|
+
return kept;
|
|
472
|
+
}
|
|
473
|
+
|
|
237
474
|
/**
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
475
|
+
* Per-namespace scored search (§3.3). For each namespace: pushed-down
|
|
476
|
+
* where("namespace = ... AND embeddingStatus = 'ok'"), limit depth×4, composite
|
|
477
|
+
* scoring in TS, top-depth per namespace, merged + near-dup deduped.
|
|
478
|
+
*
|
|
479
|
+
* No-embedding fallback: metadata scan per namespace ranked by w(t)×confidence.
|
|
480
|
+
*/
|
|
481
|
+
export async function queryScoredFragments(
|
|
482
|
+
queryText: string,
|
|
483
|
+
plan: RecallPlanEntry[],
|
|
484
|
+
now: number = Date.now(),
|
|
485
|
+
): Promise<ScoredFragment[]> {
|
|
486
|
+
const state = await getState();
|
|
487
|
+
const queryVector = await embed(queryText);
|
|
488
|
+
const useVector = !!queryVector && queryVector.length === state.meta.dim;
|
|
489
|
+
const merged: ScoredFragment[] = [];
|
|
490
|
+
|
|
491
|
+
for (const entry of plan) {
|
|
492
|
+
if (entry.depth <= 0) continue;
|
|
493
|
+
const policy = policyForNamespace(entry.namespace);
|
|
494
|
+
let rows: Record<string, unknown>[];
|
|
495
|
+
if (useVector) {
|
|
496
|
+
rows = (await state.fragments
|
|
497
|
+
.search(queryVector as number[])
|
|
498
|
+
.where(buildNamespaceWhere(entry.namespace, true))
|
|
499
|
+
.limit(entry.depth * 4)
|
|
500
|
+
.toArray()) as unknown as Record<string, unknown>[];
|
|
501
|
+
} else {
|
|
502
|
+
rows = (await state.fragments
|
|
503
|
+
.query()
|
|
504
|
+
.where(buildNamespaceWhere(entry.namespace, false))
|
|
505
|
+
.limit(1000)
|
|
506
|
+
.toArray()) as unknown as Record<string, unknown>[];
|
|
507
|
+
}
|
|
508
|
+
const scored = rows
|
|
509
|
+
.map((row) => scoreRow(row, useVector ? (queryVector as number[]) : null, policy, now))
|
|
510
|
+
.sort((a, b) => b.score - a.score)
|
|
511
|
+
.slice(0, entry.depth);
|
|
512
|
+
merged.push(...scored);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return dedupeNearDuplicates(merged).sort((a, b) => b.score - a.score);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Semantic similarity search over stored fragments (legacy-compatible API).
|
|
520
|
+
*
|
|
521
|
+
* v2: metadata filters (repoRoot/sessionId/type/namespace) are pushed down
|
|
522
|
+
* into LanceDB where() instead of post-hoc JS filtering (§1.7 fix), and
|
|
523
|
+
* results are ranked by the composite score simNorm^1.5 × w(t) × confidence.
|
|
524
|
+
* Without a provider, falls back to a metadata scan ranked by w(t)×confidence.
|
|
241
525
|
*/
|
|
242
526
|
export async function queryFragments(
|
|
243
527
|
query: string,
|
|
244
528
|
options: MemoryQueryOptions = {},
|
|
245
529
|
): Promise<MemoryFragment[]> {
|
|
246
|
-
const { limit = 10, repoRoot, tags, type, sessionId } = options;
|
|
247
|
-
const
|
|
530
|
+
const { limit = 10, repoRoot, tags, type, sessionId, namespace } = options;
|
|
531
|
+
const state = await getState();
|
|
532
|
+
const now = Date.now();
|
|
248
533
|
const queryVector = await embed(query);
|
|
534
|
+
const useVector = !!queryVector && queryVector.length === state.meta.dim;
|
|
249
535
|
|
|
250
|
-
|
|
536
|
+
const conds: string[] = [];
|
|
537
|
+
if (namespace) conds.push(`namespace = ${sqlQuote(namespace)}`);
|
|
538
|
+
if (repoRoot) conds.push(`repoRootHash = ${sqlQuote(hashRepoRoot(repoRoot))}`);
|
|
539
|
+
if (sessionId) conds.push(`sessionId = ${sqlQuote(sessionId)}`);
|
|
540
|
+
if (type) conds.push(`type = ${sqlQuote(type)}`);
|
|
251
541
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
542
|
+
let rows: Record<string, unknown>[];
|
|
543
|
+
if (useVector) {
|
|
544
|
+
conds.push("embeddingStatus = 'ok'");
|
|
545
|
+
rows = (await state.fragments
|
|
546
|
+
.search(queryVector as number[])
|
|
547
|
+
.where(conds.join(" AND "))
|
|
548
|
+
.limit(limit * 4)
|
|
549
|
+
.toArray()) as unknown as Record<string, unknown>[];
|
|
257
550
|
} else {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
rows =
|
|
551
|
+
let q = state.fragments.query();
|
|
552
|
+
if (conds.length > 0) q = q.where(conds.join(" AND "));
|
|
553
|
+
rows = (await q.limit(2000).toArray()) as unknown as Record<string, unknown>[];
|
|
261
554
|
}
|
|
262
555
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
if (sessionId && f.sessionId !== sessionId) return false;
|
|
267
|
-
if (type && f.type !== type) return false;
|
|
268
|
-
if (tags && tags.length > 0) {
|
|
269
|
-
const fragTags = new Set(f.tags);
|
|
270
|
-
if (!tags.some((t) => fragTags.has(t))) return false;
|
|
271
|
-
}
|
|
272
|
-
return true;
|
|
556
|
+
let scored = rows.map((row) => {
|
|
557
|
+
const ns = rowStr(row.namespace);
|
|
558
|
+
return scoreRow(row, useVector ? (queryVector as number[]) : null, policyForNamespace(ns), now);
|
|
273
559
|
});
|
|
274
560
|
|
|
275
|
-
//
|
|
276
|
-
if (
|
|
277
|
-
|
|
561
|
+
// Tags live in a JSON column — post-filter only this one.
|
|
562
|
+
if (tags && tags.length > 0) {
|
|
563
|
+
scored = scored.filter((s) => {
|
|
564
|
+
const fragTags = new Set(s.fragment.tags);
|
|
565
|
+
return tags.some((t) => fragTags.has(t));
|
|
566
|
+
});
|
|
278
567
|
}
|
|
279
568
|
|
|
280
|
-
return
|
|
569
|
+
return scored
|
|
570
|
+
.sort((a, b) => b.score - a.score)
|
|
571
|
+
.slice(0, limit)
|
|
572
|
+
.map((s) => s.fragment);
|
|
281
573
|
}
|
|
282
574
|
|
|
283
575
|
/**
|
|
284
576
|
* Retrieve all fragments for a session (used during consolidation).
|
|
577
|
+
* v2: sessionId filter pushed down into where().
|
|
285
578
|
*/
|
|
286
579
|
export async function getSessionFragments(sessionId: string): Promise<MemoryFragment[]> {
|
|
287
|
-
const
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
.
|
|
291
|
-
.
|
|
580
|
+
const state = await getState();
|
|
581
|
+
const rows = (await state.fragments
|
|
582
|
+
.query()
|
|
583
|
+
.where(`sessionId = ${sqlQuote(sessionId)}`)
|
|
584
|
+
.limit(10000)
|
|
585
|
+
.toArray()) as unknown as Record<string, unknown>[];
|
|
586
|
+
return rows.map(rowToFragment);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// ─── Reinforcement (§3.2) ────────────────────────────────────────────────────
|
|
590
|
+
|
|
591
|
+
const _pendingReinforce = {
|
|
592
|
+
fragments: new Map<string, number>(),
|
|
593
|
+
knowledge: new Map<string, number>(),
|
|
594
|
+
};
|
|
595
|
+
let _reinforceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
596
|
+
|
|
597
|
+
function scheduleReinforceFlush(): void {
|
|
598
|
+
if (_reinforceTimer) return;
|
|
599
|
+
_reinforceTimer = setTimeout(() => {
|
|
600
|
+
flushReinforcements().catch((err) => console.warn("[memory] reinforcement flush failed:", err));
|
|
601
|
+
}, REINFORCE_DEBOUNCE_MS);
|
|
602
|
+
_reinforceTimer.unref?.();
|
|
292
603
|
}
|
|
293
604
|
|
|
294
605
|
/**
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
* the
|
|
298
|
-
*
|
|
299
|
-
|
|
606
|
+
* Reinforce fragments that were *actually used* (included in an enrichment
|
|
607
|
+
* block or explicitly returned by memory_query): accessCount += 1 and
|
|
608
|
+
* lastReinforcedAt = now. Writes are debounced/batched; the reinforcement cap
|
|
609
|
+
* (×multiplier at most 8 times) is applied at read time in computeDecayedWeight.
|
|
610
|
+
*/
|
|
611
|
+
export function reinforceFragments(ids: string[]): void {
|
|
612
|
+
for (const id of ids) {
|
|
613
|
+
_pendingReinforce.fragments.set(id, (_pendingReinforce.fragments.get(id) ?? 0) + 1);
|
|
614
|
+
}
|
|
615
|
+
if (ids.length > 0) scheduleReinforceFlush();
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** Same as reinforceFragments but for consolidated knowledge rows. */
|
|
619
|
+
export function reinforceKnowledge(ids: string[]): void {
|
|
620
|
+
for (const id of ids) {
|
|
621
|
+
_pendingReinforce.knowledge.set(id, (_pendingReinforce.knowledge.get(id) ?? 0) + 1);
|
|
622
|
+
}
|
|
623
|
+
if (ids.length > 0) scheduleReinforceFlush();
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
async function applyReinforcements(
|
|
627
|
+
table: LanceTable,
|
|
628
|
+
pending: Map<string, number>,
|
|
629
|
+
now: number,
|
|
630
|
+
): Promise<void> {
|
|
631
|
+
if (pending.size === 0) return;
|
|
632
|
+
const ids = [...pending.keys()];
|
|
633
|
+
const rows = (await table
|
|
634
|
+
.query()
|
|
635
|
+
.where(`id IN (${ids.map(sqlQuote).join(", ")})`)
|
|
636
|
+
.limit(ids.length)
|
|
637
|
+
.toArray()) as unknown as Record<string, unknown>[];
|
|
638
|
+
for (const row of rows) {
|
|
639
|
+
const id = rowStr(row.id);
|
|
640
|
+
const inc = pending.get(id) ?? 0;
|
|
641
|
+
if (inc <= 0) continue;
|
|
642
|
+
await table.update({
|
|
643
|
+
where: `id = ${sqlQuote(id)}`,
|
|
644
|
+
values: { accessCount: rowNum(row.accessCount) + inc, lastReinforcedAt: now },
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** Force the debounced reinforcement batch to write now (tests / shutdown). */
|
|
650
|
+
export async function flushReinforcements(): Promise<void> {
|
|
651
|
+
if (_reinforceTimer) {
|
|
652
|
+
clearTimeout(_reinforceTimer);
|
|
653
|
+
_reinforceTimer = null;
|
|
654
|
+
}
|
|
655
|
+
const fragmentBatch = new Map(_pendingReinforce.fragments);
|
|
656
|
+
const knowledgeBatch = new Map(_pendingReinforce.knowledge);
|
|
657
|
+
_pendingReinforce.fragments.clear();
|
|
658
|
+
_pendingReinforce.knowledge.clear();
|
|
659
|
+
if (fragmentBatch.size === 0 && knowledgeBatch.size === 0) return;
|
|
660
|
+
const state = await getState();
|
|
661
|
+
const now = Date.now();
|
|
662
|
+
await applyReinforcements(state.fragments, fragmentBatch, now);
|
|
663
|
+
await applyReinforcements(state.consolidated, knowledgeBatch, now);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/** Pin/unpin a fragment. Pinned rows never decay and are never evicted. */
|
|
667
|
+
export async function setFragmentPinned(id: string, pinned: boolean): Promise<boolean> {
|
|
668
|
+
const state = await getState();
|
|
669
|
+
const exists = (await state.fragments.countRows(`id = ${sqlQuote(id)}`)) > 0;
|
|
670
|
+
if (!exists) return false;
|
|
671
|
+
await state.fragments.update({ where: `id = ${sqlQuote(id)}`, values: { pinned } });
|
|
672
|
+
return true;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// ─── Eviction sweep (§3.2) ───────────────────────────────────────────────────
|
|
676
|
+
|
|
677
|
+
export interface EvictionSweepOptions {
|
|
678
|
+
now?: number;
|
|
679
|
+
perNamespaceCap?: number;
|
|
680
|
+
weightThreshold?: number;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
export interface EvictionSweepResult {
|
|
684
|
+
/** Consolidated, unpinned fragments deleted because w(t) < threshold. */
|
|
685
|
+
decayedDeleted: number;
|
|
686
|
+
/** Fragments evicted by the per-namespace hard cap (lowest-w first). */
|
|
687
|
+
capEvicted: number;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
async function deleteFragmentIds(table: LanceTable, ids: string[]): Promise<void> {
|
|
691
|
+
const CHUNK = 200;
|
|
692
|
+
for (let i = 0; i < ids.length; i += CHUNK) {
|
|
693
|
+
const chunk = ids.slice(i, i + CHUNK);
|
|
694
|
+
await table.delete(`id IN (${chunk.map(sqlQuote).join(", ")})`);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Eviction sweep (runs hourly):
|
|
700
|
+
* - w(t) < threshold AND unpinned AND isConsolidated → delete (essence lives
|
|
701
|
+
* in the consolidated table).
|
|
702
|
+
* - Un-consolidated low-w fragments are left alone — consolidation drops
|
|
703
|
+
* them, never a silent delete.
|
|
704
|
+
* - Per-namespace hard cap (default 5000) with lowest-w eviction as backstop.
|
|
705
|
+
* - Pinned rows are never evicted.
|
|
706
|
+
*/
|
|
707
|
+
export async function runEvictionSweep(opts: EvictionSweepOptions = {}): Promise<EvictionSweepResult> {
|
|
708
|
+
const now = opts.now ?? Date.now();
|
|
709
|
+
const cap = opts.perNamespaceCap ?? NAMESPACE_HARD_CAP;
|
|
710
|
+
const threshold = opts.weightThreshold ?? EVICTION_WEIGHT_THRESHOLD;
|
|
711
|
+
const state = await getState();
|
|
712
|
+
|
|
713
|
+
const rows = (await state.fragments.query().limit(200000).toArray()) as unknown as Record<
|
|
714
|
+
string,
|
|
715
|
+
unknown
|
|
716
|
+
>[];
|
|
717
|
+
|
|
718
|
+
const byNamespace = new Map<string, Array<{ id: string; weight: number; pinned: boolean; consolidated: boolean }>>();
|
|
719
|
+
for (const row of rows) {
|
|
720
|
+
const ns = rowStr(row.namespace) || "global";
|
|
721
|
+
const fragment = rowToFragment(row);
|
|
722
|
+
const weight = computeDecayedWeight(fragment, now, policyForNamespace(ns));
|
|
723
|
+
const list = byNamespace.get(ns) ?? [];
|
|
724
|
+
list.push({
|
|
725
|
+
id: fragment.id,
|
|
726
|
+
weight,
|
|
727
|
+
pinned: fragment.pinned === true,
|
|
728
|
+
consolidated: fragment.isConsolidated,
|
|
729
|
+
});
|
|
730
|
+
byNamespace.set(ns, list);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const decayedIds: string[] = [];
|
|
734
|
+
const capIds: string[] = [];
|
|
735
|
+
for (const [, list] of byNamespace) {
|
|
736
|
+
const remaining: typeof list = [];
|
|
737
|
+
for (const item of list) {
|
|
738
|
+
if (!item.pinned && item.consolidated && item.weight < threshold) {
|
|
739
|
+
decayedIds.push(item.id);
|
|
740
|
+
} else {
|
|
741
|
+
remaining.push(item);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
if (remaining.length > cap) {
|
|
745
|
+
const evictable = remaining.filter((i) => !i.pinned).sort((a, b) => a.weight - b.weight);
|
|
746
|
+
const overflow = remaining.length - cap;
|
|
747
|
+
for (const item of evictable.slice(0, overflow)) capIds.push(item.id);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
if (decayedIds.length > 0) await deleteFragmentIds(state.fragments, decayedIds);
|
|
752
|
+
if (capIds.length > 0) await deleteFragmentIds(state.fragments, capIds);
|
|
753
|
+
return { decayedDeleted: decayedIds.length, capEvicted: capIds.length };
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// ─── Lazy re-embed queue (§3.5) ──────────────────────────────────────────────
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Re-embed up to `maxItems` rows with embeddingStatus = "pending" (fragments
|
|
760
|
+
* first, then consolidated knowledge). Called at ≤ 2 req/s by the maintenance
|
|
761
|
+
* timer whenever a real provider is configured; also directly callable.
|
|
762
|
+
* Returns the number of rows re-embedded.
|
|
763
|
+
*/
|
|
764
|
+
export async function processReembedBatch(maxItems = 2): Promise<number> {
|
|
765
|
+
if (getEmbeddingDim() === null) return 0; // no provider — nothing to do
|
|
766
|
+
const state = await getState();
|
|
767
|
+
let processed = 0;
|
|
768
|
+
|
|
769
|
+
for (const table of [state.fragments, state.consolidated]) {
|
|
770
|
+
if (processed >= maxItems) break;
|
|
771
|
+
const rows = (await table
|
|
772
|
+
.query()
|
|
773
|
+
.where("embeddingStatus = 'pending'")
|
|
774
|
+
.limit(maxItems - processed)
|
|
775
|
+
.toArray()) as unknown as Record<string, unknown>[];
|
|
776
|
+
for (const row of rows) {
|
|
777
|
+
const id = rowStr(row.id);
|
|
778
|
+
const text = rowStr(row.content) || rowStr(row.summary);
|
|
779
|
+
if (!text) {
|
|
780
|
+
// Nothing to embed — flip to "none" so we don't retry forever.
|
|
781
|
+
await table.update({ where: `id = ${sqlQuote(id)}`, values: { embeddingStatus: "none" } });
|
|
782
|
+
continue;
|
|
783
|
+
}
|
|
784
|
+
const vector = await embed(text);
|
|
785
|
+
if (!vector || vector.length !== state.meta.dim) return processed; // provider failing — retry next tick
|
|
786
|
+
await table.update({
|
|
787
|
+
where: `id = ${sqlQuote(id)}`,
|
|
788
|
+
values: { vector, embeddingStatus: "ok" },
|
|
789
|
+
});
|
|
790
|
+
processed++;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
return processed;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// ─── Maintenance timers ──────────────────────────────────────────────────────
|
|
797
|
+
|
|
798
|
+
let _sweepTimer: ReturnType<typeof setInterval> | null = null;
|
|
799
|
+
let _reembedTimer: ReturnType<typeof setInterval> | null = null;
|
|
800
|
+
|
|
801
|
+
function ensureMaintenanceTimers(): void {
|
|
802
|
+
// Tests drive sweeps/re-embeds explicitly — background timers would make
|
|
803
|
+
// row states flip mid-assertion.
|
|
804
|
+
if (process.env.VITEST || process.env.NODE_ENV === "test") return;
|
|
805
|
+
if (!_sweepTimer) {
|
|
806
|
+
_sweepTimer = setInterval(() => {
|
|
807
|
+
runEvictionSweep().catch((err) => console.warn("[memory] eviction sweep failed:", err));
|
|
808
|
+
}, SWEEP_INTERVAL_MS);
|
|
809
|
+
_sweepTimer.unref?.();
|
|
810
|
+
}
|
|
811
|
+
if (!_reembedTimer) {
|
|
812
|
+
_reembedTimer = setInterval(() => {
|
|
813
|
+
processReembedBatch(2).catch((err) => console.warn("[memory] re-embed batch failed:", err));
|
|
814
|
+
}, REEMBED_INTERVAL_MS);
|
|
815
|
+
_reembedTimer.unref?.();
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/** Stop background maintenance timers (shutdown / tests). */
|
|
820
|
+
export function stopMemoryMaintenance(): void {
|
|
821
|
+
if (_sweepTimer) {
|
|
822
|
+
clearInterval(_sweepTimer);
|
|
823
|
+
_sweepTimer = null;
|
|
824
|
+
}
|
|
825
|
+
if (_reembedTimer) {
|
|
826
|
+
clearInterval(_reembedTimer);
|
|
827
|
+
_reembedTimer = null;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// ─── Consolidated knowledge retrieval ────────────────────────────────────────
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Active (non-superseded) consolidated knowledge for a namespace, optionally
|
|
835
|
+
* filtered by tag, ranked by confidence then recency.
|
|
836
|
+
*/
|
|
837
|
+
export async function getKnowledgeByNamespace(
|
|
838
|
+
namespace: string,
|
|
839
|
+
tag?: string,
|
|
840
|
+
): Promise<ConsolidatedKnowledge[]> {
|
|
841
|
+
const state = await getState();
|
|
842
|
+
const conds = [`namespace = ${sqlQuote(namespace)}`, "supersededBy = ''"];
|
|
843
|
+
if (tag) conds.push(`tag = ${sqlQuote(tag)}`);
|
|
844
|
+
const rows = (await state.consolidated
|
|
845
|
+
.query()
|
|
846
|
+
.where(conds.join(" AND "))
|
|
847
|
+
.limit(1000)
|
|
848
|
+
.toArray()) as unknown as Record<string, unknown>[];
|
|
849
|
+
return rows
|
|
850
|
+
.map(rowToConsolidated)
|
|
851
|
+
.sort((a, b) => b.confidence - a.confidence || b.lastUpdated - a.lastUpdated);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* Retrieve consolidated knowledge for a repo, optionally filtered by tag
|
|
856
|
+
* (legacy-compatible API).
|
|
300
857
|
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
858
|
+
* v2 behavior change (§1.8 fix): repoRoot === "" now means the `global`
|
|
859
|
+
* namespace instead of matching literally-empty repoRoot rows, so existing
|
|
860
|
+
* callers that pass "" get cross-repo knowledge rather than nothing.
|
|
303
861
|
*/
|
|
304
|
-
export async function
|
|
862
|
+
export async function getConsolidatedKnowledge(
|
|
863
|
+
repoRoot: string,
|
|
864
|
+
tag?: string,
|
|
865
|
+
): Promise<ConsolidatedKnowledge[]> {
|
|
866
|
+
const namespace = repoRoot ? repoNamespace(repoRoot) : "global";
|
|
867
|
+
return getKnowledgeByNamespace(namespace, tag);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// ─── Consolidation support APIs (§3.4 Stage 1/3) ─────────────────────────────
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* Un-consolidated fragments for a namespace (e.g. "session:abc", "repo:<hash>")
|
|
874
|
+
* or a bare session id, newest first. Embeddings are included (when status is
|
|
875
|
+
* "ok") so Stage-1 JUDGE can cluster/dedupe without re-embedding.
|
|
876
|
+
*/
|
|
877
|
+
export async function getUnconsolidatedFragments(
|
|
878
|
+
namespaceOrSessionId: string,
|
|
879
|
+
limit = 200,
|
|
880
|
+
): Promise<MemoryFragment[]> {
|
|
881
|
+
const state = await getState();
|
|
882
|
+
const scopeCond = isNamespaceString(namespaceOrSessionId)
|
|
883
|
+
? `namespace = ${sqlQuote(namespaceOrSessionId)}`
|
|
884
|
+
: `sessionId = ${sqlQuote(namespaceOrSessionId)}`;
|
|
885
|
+
const rows = (await state.fragments
|
|
886
|
+
.query()
|
|
887
|
+
.where(`${scopeCond} AND isConsolidated = false`)
|
|
888
|
+
.limit(10000)
|
|
889
|
+
.toArray()) as unknown as Record<string, unknown>[];
|
|
890
|
+
return rows
|
|
891
|
+
.map(rowToFragment)
|
|
892
|
+
.sort((a, b) => b.timestamp - a.timestamp)
|
|
893
|
+
.slice(0, limit);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* Mark source fragments as consolidated into a knowledge row (§1.2 fix —
|
|
898
|
+
* makes isConsolidated/consolidatedInto live state).
|
|
899
|
+
*/
|
|
900
|
+
export async function markFragmentsConsolidated(ids: string[], knowledgeId: string): Promise<void> {
|
|
901
|
+
if (ids.length === 0) return;
|
|
902
|
+
const state = await getState();
|
|
903
|
+
const CHUNK = 200;
|
|
904
|
+
for (let i = 0; i < ids.length; i += CHUNK) {
|
|
905
|
+
const chunk = ids.slice(i, i + CHUNK);
|
|
906
|
+
await state.fragments.update({
|
|
907
|
+
where: `id IN (${chunk.map(sqlQuote).join(", ")})`,
|
|
908
|
+
values: { isConsolidated: true, consolidatedInto: knowledgeId },
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
export interface DistilledKnowledgeItem {
|
|
914
|
+
tag: string;
|
|
915
|
+
type?: KnowledgeType;
|
|
916
|
+
summary: string;
|
|
917
|
+
confidence: number;
|
|
918
|
+
sourceFragmentIds: string[];
|
|
919
|
+
/** Existing knowledge ids this item replaces (tombstoned with supersededBy). */
|
|
920
|
+
supersedes?: string[];
|
|
921
|
+
/** Namespace class from the distillation output ("repo" | "global" | "agent") or a full namespace string. */
|
|
922
|
+
namespace: string;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
export interface DistillationContext {
|
|
926
|
+
sessionId: string;
|
|
927
|
+
repoRoot: string;
|
|
928
|
+
backendType: string;
|
|
929
|
+
/** Defaults to "llm"; the concat fallback passes "concat". */
|
|
930
|
+
synthesisMethod?: SynthesisMethod;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function resolveKnowledgeNamespace(nsField: string, ctx: DistillationContext): string {
|
|
934
|
+
if (isNamespaceString(nsField)) return nsField;
|
|
935
|
+
if (nsField === "repo") return ctx.repoRoot ? repoNamespace(ctx.repoRoot) : "global";
|
|
936
|
+
if (nsField === "agent") return agentNamespace(ctx.backendType);
|
|
937
|
+
if (nsField === "session") return sessionNamespace(ctx.sessionId);
|
|
938
|
+
return "global";
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* Stage-3 CONSOLIDATE (§3.4): upsert distilled knowledge by (namespace, tag).
|
|
943
|
+
* - Any active row with the same (namespace, tag), plus any rows named in
|
|
944
|
+
* `supersedes`, is tombstoned with supersededBy = <new id> (audit trail).
|
|
945
|
+
* - The new summary is embedded (status "pending" if that fails/no provider).
|
|
946
|
+
* - All sourceFragmentIds are marked consolidated into the new row.
|
|
947
|
+
* Idempotent: re-running with the same (namespace, tag) replaces, never duplicates.
|
|
948
|
+
*/
|
|
949
|
+
export async function upsertKnowledgeFromDistillation(
|
|
950
|
+
items: DistilledKnowledgeItem[],
|
|
951
|
+
ctx: DistillationContext,
|
|
952
|
+
): Promise<ConsolidatedKnowledge[]> {
|
|
953
|
+
const state = await getState();
|
|
954
|
+
const dim = state.meta.dim;
|
|
955
|
+
const results: ConsolidatedKnowledge[] = [];
|
|
956
|
+
const synthesisMethod: SynthesisMethod = ctx.synthesisMethod ?? "llm";
|
|
957
|
+
|
|
958
|
+
for (const item of items) {
|
|
959
|
+
const namespace = resolveKnowledgeNamespace(item.namespace, ctx);
|
|
960
|
+
const cls = namespaceClass(namespace);
|
|
961
|
+
const repoRoot = cls === "repo" ? ctx.repoRoot : "";
|
|
962
|
+
const newId = randomUUID();
|
|
963
|
+
const now = Date.now();
|
|
964
|
+
|
|
965
|
+
// Tombstone: same (namespace, tag) active rows + explicit supersedes list.
|
|
966
|
+
await state.consolidated.update({
|
|
967
|
+
where: `namespace = ${sqlQuote(namespace)} AND tag = ${sqlQuote(item.tag)} AND supersededBy = ''`,
|
|
968
|
+
values: { supersededBy: newId },
|
|
969
|
+
});
|
|
970
|
+
const supersedes = (item.supersedes ?? []).filter(Boolean);
|
|
971
|
+
if (supersedes.length > 0) {
|
|
972
|
+
await state.consolidated.update({
|
|
973
|
+
where: `id IN (${supersedes.map(sqlQuote).join(", ")})`,
|
|
974
|
+
values: { supersededBy: newId },
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
const embedding = await embed(item.summary);
|
|
979
|
+
let status: EmbeddingStatus;
|
|
980
|
+
let vector: number[];
|
|
981
|
+
if (embedding && embedding.length === dim) {
|
|
982
|
+
status = "ok";
|
|
983
|
+
vector = embedding;
|
|
984
|
+
} else if (getEmbeddingDim() === null) {
|
|
985
|
+
status = "none";
|
|
986
|
+
vector = zeros(dim);
|
|
987
|
+
} else {
|
|
988
|
+
status = "pending";
|
|
989
|
+
vector = zeros(dim);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
await state.consolidated.add([
|
|
993
|
+
{
|
|
994
|
+
id: newId,
|
|
995
|
+
tag: item.tag,
|
|
996
|
+
summary: item.summary,
|
|
997
|
+
sourceFragmentsJson: JSON.stringify(item.sourceFragmentIds),
|
|
998
|
+
lastUpdated: now,
|
|
999
|
+
confidence: item.confidence,
|
|
1000
|
+
repoRoot,
|
|
1001
|
+
namespace,
|
|
1002
|
+
repoRootHash: repoRoot ? hashRepoRoot(repoRoot) : "",
|
|
1003
|
+
knowledgeType: item.type ?? "",
|
|
1004
|
+
synthesisMethod,
|
|
1005
|
+
supersededBy: "",
|
|
1006
|
+
accessCount: 0,
|
|
1007
|
+
lastReinforcedAt: now,
|
|
1008
|
+
embeddingStatus: status,
|
|
1009
|
+
vector,
|
|
1010
|
+
},
|
|
1011
|
+
]);
|
|
1012
|
+
|
|
1013
|
+
await markFragmentsConsolidated(item.sourceFragmentIds, newId);
|
|
1014
|
+
|
|
1015
|
+
results.push({
|
|
1016
|
+
id: newId,
|
|
1017
|
+
tag: item.tag,
|
|
1018
|
+
summary: item.summary,
|
|
1019
|
+
sourceFragments: item.sourceFragmentIds,
|
|
1020
|
+
lastUpdated: now,
|
|
1021
|
+
confidence: item.confidence,
|
|
1022
|
+
repoRoot,
|
|
1023
|
+
namespace,
|
|
1024
|
+
type: item.type,
|
|
1025
|
+
synthesisMethod,
|
|
1026
|
+
supersededBy: undefined,
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
return results;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/**
|
|
1034
|
+
* Active knowledge in a namespace whose embedding is within `threshold`
|
|
1035
|
+
* cosine similarity of a centroid (§3.4 Stage 2 — `existingKnowledge` input).
|
|
1036
|
+
* Accepts a precomputed centroid vector or texts to embed-and-average.
|
|
1037
|
+
* Returns [] when no provider/vectors are available.
|
|
1038
|
+
*/
|
|
1039
|
+
export async function findRelatedKnowledge(
|
|
1040
|
+
namespace: string,
|
|
1041
|
+
centroidVectorOrTexts: number[] | string[],
|
|
1042
|
+
threshold = 0.8,
|
|
1043
|
+
): Promise<ConsolidatedKnowledge[]> {
|
|
1044
|
+
const state = await getState();
|
|
1045
|
+
if (centroidVectorOrTexts.length === 0) return [];
|
|
1046
|
+
|
|
1047
|
+
let centroid: number[] | null = null;
|
|
1048
|
+
if (typeof centroidVectorOrTexts[0] === "number") {
|
|
1049
|
+
centroid = centroidVectorOrTexts as number[];
|
|
1050
|
+
} else {
|
|
1051
|
+
const vectors: number[][] = [];
|
|
1052
|
+
for (const text of centroidVectorOrTexts as string[]) {
|
|
1053
|
+
const v = await embed(text);
|
|
1054
|
+
if (v && v.length === state.meta.dim) vectors.push(v);
|
|
1055
|
+
}
|
|
1056
|
+
if (vectors.length > 0) {
|
|
1057
|
+
centroid = zeros(state.meta.dim);
|
|
1058
|
+
for (const v of vectors) for (let i = 0; i < v.length; i++) centroid[i] += v[i];
|
|
1059
|
+
for (let i = 0; i < centroid.length; i++) centroid[i] /= vectors.length;
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
if (!centroid || centroid.length !== state.meta.dim) return [];
|
|
1063
|
+
|
|
1064
|
+
const rows = (await state.consolidated
|
|
1065
|
+
.search(centroid)
|
|
1066
|
+
.where(`namespace = ${sqlQuote(namespace)} AND supersededBy = '' AND embeddingStatus = 'ok'`)
|
|
1067
|
+
.limit(50)
|
|
1068
|
+
.toArray()) as unknown as Record<string, unknown>[];
|
|
1069
|
+
return rows
|
|
1070
|
+
.filter((row) => cosineSimilarity(centroid as number[], toNumberArray(row.vector)) >= threshold)
|
|
1071
|
+
.map(rowToConsolidated);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/**
|
|
1075
|
+
* No-LLM consolidation fallback: today's concatenation synthesis, grouped by
|
|
1076
|
+
* tag, upserted by (namespace, tag) with synthesisMethod = "concat" and all
|
|
1077
|
+
* sources marked consolidated. Used when no OpenRouter key is configured or
|
|
1078
|
+
* when distillation output fails validation (§3.4).
|
|
1079
|
+
*/
|
|
1080
|
+
export async function concatFallbackConsolidate(
|
|
305
1081
|
sessionId: string,
|
|
306
1082
|
repoRoot: string,
|
|
1083
|
+
backendType = "claude",
|
|
307
1084
|
): Promise<ConsolidatedKnowledge[]> {
|
|
308
|
-
const fragments = await
|
|
1085
|
+
const fragments = await getUnconsolidatedFragments(sessionId, 10000);
|
|
309
1086
|
if (fragments.length === 0) return [];
|
|
310
1087
|
|
|
311
|
-
// Group by tag
|
|
312
1088
|
const byTag = new Map<string, MemoryFragment[]>();
|
|
313
1089
|
for (const f of fragments) {
|
|
314
1090
|
for (const tag of f.tags.length > 0 ? f.tags : ["general"]) {
|
|
@@ -317,57 +1093,234 @@ export async function consolidateSession(
|
|
|
317
1093
|
}
|
|
318
1094
|
}
|
|
319
1095
|
|
|
320
|
-
const
|
|
321
|
-
const results: ConsolidatedKnowledge[] = [];
|
|
322
|
-
|
|
1096
|
+
const items: DistilledKnowledgeItem[] = [];
|
|
323
1097
|
for (const [tag, tagFragments] of byTag) {
|
|
324
|
-
const avgConfidence =
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
id: randomUUID(),
|
|
1098
|
+
const avgConfidence =
|
|
1099
|
+
tagFragments.reduce((s, f) => s + f.confidence, 0) / tagFragments.length;
|
|
1100
|
+
items.push({
|
|
328
1101
|
tag,
|
|
329
|
-
summary,
|
|
330
|
-
sourceFragments: tagFragments.map((f) => f.id),
|
|
331
|
-
lastUpdated: Date.now(),
|
|
1102
|
+
summary: synthesize(tag, tagFragments),
|
|
332
1103
|
confidence: avgConfidence,
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
await table.add([{
|
|
337
|
-
id: knowledge.id,
|
|
338
|
-
tag: knowledge.tag,
|
|
339
|
-
summary: knowledge.summary,
|
|
340
|
-
sourceFragmentsJson: JSON.stringify(knowledge.sourceFragments),
|
|
341
|
-
lastUpdated: knowledge.lastUpdated,
|
|
342
|
-
confidence: knowledge.confidence,
|
|
343
|
-
repoRoot: knowledge.repoRoot,
|
|
344
|
-
}]);
|
|
345
|
-
|
|
346
|
-
results.push(knowledge);
|
|
1104
|
+
sourceFragmentIds: tagFragments.map((f) => f.id),
|
|
1105
|
+
namespace: repoRoot ? "repo" : "global",
|
|
1106
|
+
});
|
|
347
1107
|
}
|
|
348
1108
|
|
|
349
|
-
return
|
|
1109
|
+
return upsertKnowledgeFromDistillation(items, {
|
|
1110
|
+
sessionId,
|
|
1111
|
+
repoRoot,
|
|
1112
|
+
backendType,
|
|
1113
|
+
synthesisMethod: "concat",
|
|
1114
|
+
});
|
|
350
1115
|
}
|
|
351
1116
|
|
|
352
1117
|
/**
|
|
353
|
-
*
|
|
1118
|
+
* Consolidate episodic fragments from a session into semantic knowledge
|
|
1119
|
+
* (legacy-compatible API — routes to the v2 concat fallback).
|
|
1120
|
+
*
|
|
1121
|
+
* v2 (§1.2 fix): idempotent — upserts by (namespace, tag), tombstones the
|
|
1122
|
+
* replaced rows, and marks source fragments isConsolidated/consolidatedInto.
|
|
1123
|
+
* Running twice no longer duplicates rows (the second run sees no
|
|
1124
|
+
* un-consolidated fragments and returns []).
|
|
354
1125
|
*/
|
|
355
|
-
export async function
|
|
1126
|
+
export async function consolidateSession(
|
|
1127
|
+
sessionId: string,
|
|
356
1128
|
repoRoot: string,
|
|
357
|
-
tag?: string,
|
|
358
1129
|
): Promise<ConsolidatedKnowledge[]> {
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
1130
|
+
return concatFallbackConsolidate(sessionId, repoRoot);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// ─── Enrichment entry point (§3.3 + §3.6.2) ──────────────────────────────────
|
|
1134
|
+
|
|
1135
|
+
export interface EnrichmentQueryOptions {
|
|
1136
|
+
sessionId: string;
|
|
1137
|
+
repoRoot: string;
|
|
1138
|
+
backendType: string;
|
|
1139
|
+
queryText: string;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
export interface EnrichmentResult {
|
|
1143
|
+
items: MemoryEnrichmentItem[];
|
|
1144
|
+
/** Injectable block per §3.6.2, or null when nothing was recalled. */
|
|
1145
|
+
block: string | null;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
const ENRICHMENT_HEADER = "--- Campfire memory (auto-recalled; may be stale) ---";
|
|
1149
|
+
const ENRICHMENT_FOOTER = "--- end memory ---";
|
|
1150
|
+
const LINE_CLIP_CHARS = 300;
|
|
1151
|
+
|
|
1152
|
+
function clip(text: string, max: number): string {
|
|
1153
|
+
const oneLine = text.replace(/\s+/g, " ").trim();
|
|
1154
|
+
return oneLine.length <= max ? oneLine : `${oneLine.slice(0, max - 1)}…`;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* Query memory for prompt enrichment. Namespace recall order per §3.6.2:
|
|
1159
|
+
* fragments query [repo:<hash>, agent:<backend>, global] — NOT session:<id>,
|
|
1160
|
+
* since same-session context is already in the agent's own conversation.
|
|
1161
|
+
* Consolidated knowledge (same namespaces) is ranked above fragments.
|
|
1162
|
+
*
|
|
1163
|
+
* Budgets: ≤ ~1200 chars of fragment text, ≤ ~2000 chars total, max 5
|
|
1164
|
+
* fragment lines. Every included row is reinforced (§3.2). Returns the exact
|
|
1165
|
+
* injectable block plus the item list for the UI `memory_enriched` chip.
|
|
1166
|
+
*/
|
|
1167
|
+
export async function queryForEnrichment(opts: EnrichmentQueryOptions): Promise<EnrichmentResult> {
|
|
1168
|
+
const depths = getMemorySettings().recallDepth;
|
|
1169
|
+
const now = Date.now();
|
|
1170
|
+
const nsRepo = opts.repoRoot ? repoNamespace(opts.repoRoot) : null;
|
|
1171
|
+
const nsAgent = agentNamespace(opts.backendType);
|
|
1172
|
+
|
|
1173
|
+
// Consolidated knowledge, priority order: repo → agent → global.
|
|
1174
|
+
const knowledgeNamespaces = [nsRepo, nsAgent, "global"].filter((n): n is string => !!n);
|
|
1175
|
+
const knowledgeCandidates: ConsolidatedKnowledge[] = [];
|
|
1176
|
+
for (const ns of knowledgeNamespaces) {
|
|
1177
|
+
knowledgeCandidates.push(...(await getKnowledgeByNamespace(ns)));
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// Fragments: repo/agent/global with per-namespace recall depths (§3.1).
|
|
1181
|
+
const plan: RecallPlanEntry[] = [];
|
|
1182
|
+
if (nsRepo) plan.push({ namespace: nsRepo, depth: depths.repo });
|
|
1183
|
+
plan.push({ namespace: nsAgent, depth: depths.agent });
|
|
1184
|
+
plan.push({ namespace: "global", depth: depths.global });
|
|
1185
|
+
const scoredFragments = await queryScoredFragments(opts.queryText, plan, now);
|
|
1186
|
+
|
|
1187
|
+
// Assemble under budgets. Header/footer + section labels count toward total.
|
|
1188
|
+
const items: MemoryEnrichmentItem[] = [];
|
|
1189
|
+
const knowledgeLines: string[] = [];
|
|
1190
|
+
const fragmentLines: string[] = [];
|
|
1191
|
+
const baseOverhead =
|
|
1192
|
+
ENRICHMENT_HEADER.length + ENRICHMENT_FOOTER.length + "Knowledge:".length + "Notes:".length + 4;
|
|
1193
|
+
let totalChars = baseOverhead;
|
|
1194
|
+
|
|
1195
|
+
for (const k of knowledgeCandidates) {
|
|
1196
|
+
const line = `- [${k.tag}] ${clip(k.summary, LINE_CLIP_CHARS)}`;
|
|
1197
|
+
if (totalChars + line.length + 1 > TOTAL_BUDGET_CHARS) break;
|
|
1198
|
+
knowledgeLines.push(line);
|
|
1199
|
+
totalChars += line.length + 1;
|
|
1200
|
+
items.push({
|
|
1201
|
+
id: k.id,
|
|
1202
|
+
kind: "knowledge",
|
|
1203
|
+
namespace: k.namespace ?? "",
|
|
1204
|
+
summary: k.summary,
|
|
1205
|
+
tag: k.tag,
|
|
1206
|
+
weight: 1, // consolidated knowledge does not decay (§3.2)
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
let fragmentChars = 0;
|
|
1211
|
+
for (const s of scoredFragments) {
|
|
1212
|
+
if (fragmentLines.length >= MAX_ENRICHMENT_FRAGMENTS) break;
|
|
1213
|
+
if (s.score <= 0) continue;
|
|
1214
|
+
const line = `- [${s.fragment.type}] ${clip(s.fragment.content, LINE_CLIP_CHARS)}`;
|
|
1215
|
+
if (fragmentChars + line.length + 1 > FRAGMENT_BUDGET_CHARS) break;
|
|
1216
|
+
if (totalChars + line.length + 1 > TOTAL_BUDGET_CHARS) break;
|
|
1217
|
+
fragmentLines.push(line);
|
|
1218
|
+
fragmentChars += line.length + 1;
|
|
1219
|
+
totalChars += line.length + 1;
|
|
1220
|
+
items.push({
|
|
1221
|
+
id: s.fragment.id,
|
|
1222
|
+
kind: "fragment",
|
|
1223
|
+
namespace: s.fragment.namespace ?? "",
|
|
1224
|
+
summary: clip(s.fragment.content, LINE_CLIP_CHARS),
|
|
1225
|
+
tag: s.fragment.tags[0],
|
|
1226
|
+
weight: s.weight,
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
if (items.length === 0) return { items: [], block: null };
|
|
1231
|
+
|
|
1232
|
+
const parts: string[] = [ENRICHMENT_HEADER];
|
|
1233
|
+
if (knowledgeLines.length > 0) parts.push("Knowledge:", ...knowledgeLines);
|
|
1234
|
+
if (fragmentLines.length > 0) parts.push("Notes:", ...fragmentLines);
|
|
1235
|
+
parts.push(ENRICHMENT_FOOTER);
|
|
1236
|
+
|
|
1237
|
+
// Reinforce exactly what was included (§3.2: inclusion reinforces, matching doesn't).
|
|
1238
|
+
reinforceFragments(items.filter((i) => i.kind === "fragment").map((i) => i.id));
|
|
1239
|
+
reinforceKnowledge(items.filter((i) => i.kind === "knowledge").map((i) => i.id));
|
|
1240
|
+
|
|
1241
|
+
return { items, block: parts.join("\n") };
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// ─── Namespace overview (UI) ─────────────────────────────────────────────────
|
|
1245
|
+
|
|
1246
|
+
export interface NamespaceOverviewEntry {
|
|
1247
|
+
namespace: string;
|
|
1248
|
+
count: number;
|
|
1249
|
+
avgWeight: number;
|
|
1250
|
+
pinnedCount: number;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
/**
|
|
1254
|
+
* Per-namespace fragment stats for the memory panel: count, average decayed
|
|
1255
|
+
* weight, and pinned count, over [session:<id>, repo:<hash>, agent:<backend>, global].
|
|
1256
|
+
*/
|
|
1257
|
+
/**
|
|
1258
|
+
* Open the LanceDB connection and the tables a subsequent enrichment query
|
|
1259
|
+
* will touch, so the first real `queryForEnrichment` for a session lands
|
|
1260
|
+
* within the 250 ms budget instead of paying the cold connect+open cost
|
|
1261
|
+
* (~230 ms on a fresh process) and getting skipped. Best-effort and
|
|
1262
|
+
* reinforcement-free — it must never throw into session startup, and it must
|
|
1263
|
+
* not touch access counts (it is not a real recall).
|
|
1264
|
+
*/
|
|
1265
|
+
export async function warmMemory(opts: { repoRoot: string; backendType: string }): Promise<void> {
|
|
1266
|
+
try {
|
|
1267
|
+
const ns = opts.repoRoot ? repoNamespace(opts.repoRoot) : "global";
|
|
1268
|
+
// getNamespaceOverview opens the fragments table + connection;
|
|
1269
|
+
// getKnowledgeByNamespace opens the consolidated table. Neither reinforces.
|
|
1270
|
+
await Promise.all([
|
|
1271
|
+
getNamespaceOverview({ sessionId: "warm", repoRoot: opts.repoRoot, backendType: opts.backendType }),
|
|
1272
|
+
getKnowledgeByNamespace(ns),
|
|
1273
|
+
]);
|
|
1274
|
+
} catch {
|
|
1275
|
+
// Warming is an optimization; a cold first query still works, just slower.
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
export async function getNamespaceOverview(opts: {
|
|
1280
|
+
sessionId: string;
|
|
1281
|
+
repoRoot: string;
|
|
1282
|
+
backendType: string;
|
|
1283
|
+
}): Promise<NamespaceOverviewEntry[]> {
|
|
1284
|
+
const state = await getState();
|
|
1285
|
+
const now = Date.now();
|
|
1286
|
+
const namespaces = [
|
|
1287
|
+
sessionNamespace(opts.sessionId),
|
|
1288
|
+
...(opts.repoRoot ? [repoNamespace(opts.repoRoot)] : []),
|
|
1289
|
+
agentNamespace(opts.backendType),
|
|
1290
|
+
"global",
|
|
1291
|
+
];
|
|
1292
|
+
|
|
1293
|
+
const overview: NamespaceOverviewEntry[] = [];
|
|
1294
|
+
for (const namespace of namespaces) {
|
|
1295
|
+
const rows = (await state.fragments
|
|
1296
|
+
.query()
|
|
1297
|
+
.where(buildNamespaceWhere(namespace, false))
|
|
1298
|
+
.limit(100000)
|
|
1299
|
+
.toArray()) as unknown as Record<string, unknown>[];
|
|
1300
|
+
const policy = policyForNamespace(namespace);
|
|
1301
|
+
let weightSum = 0;
|
|
1302
|
+
let pinnedCount = 0;
|
|
1303
|
+
for (const row of rows) {
|
|
1304
|
+
const fragment = rowToFragment(row);
|
|
1305
|
+
weightSum += computeDecayedWeight(fragment, now, policy);
|
|
1306
|
+
if (fragment.pinned) pinnedCount++;
|
|
1307
|
+
}
|
|
1308
|
+
overview.push({
|
|
1309
|
+
namespace,
|
|
1310
|
+
count: rows.length,
|
|
1311
|
+
avgWeight: rows.length > 0 ? weightSum / rows.length : 0,
|
|
1312
|
+
pinnedCount,
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1315
|
+
return overview;
|
|
364
1316
|
}
|
|
365
1317
|
|
|
366
|
-
// ─── Internal helpers
|
|
1318
|
+
// ─── Internal helpers ────────────────────────────────────────────────────────
|
|
367
1319
|
|
|
368
1320
|
/**
|
|
369
|
-
*
|
|
370
|
-
*
|
|
1321
|
+
* Concatenation synthesis — the no-LLM fallback (§3.4). Kept from v1; the
|
|
1322
|
+
* LLM distillation pipeline (memory-consolidation.ts) replaces this as the
|
|
1323
|
+
* primary path.
|
|
371
1324
|
*/
|
|
372
1325
|
function synthesize(tag: string, fragments: MemoryFragment[]): string {
|
|
373
1326
|
const sorted = [...fragments].sort((a, b) => b.confidence - a.confidence);
|
|
@@ -380,10 +1333,19 @@ function synthesize(tag: string, fragments: MemoryFragment[]): string {
|
|
|
380
1333
|
|
|
381
1334
|
// ─── Test helpers ─────────────────────────────────────────────────────────────
|
|
382
1335
|
|
|
383
|
-
/**
|
|
1336
|
+
/**
|
|
1337
|
+
* Override the memory root dir for tests — must be called before any DB access.
|
|
1338
|
+
* v2 note: the argument is the memory ROOT (meta.json lives here; LanceDB in
|
|
1339
|
+
* <dir>/lancedb), where v1 treated it as the LanceDB dir itself.
|
|
1340
|
+
*/
|
|
384
1341
|
export function _resetForTest(dir: string): void {
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
1342
|
+
_initPromise = null;
|
|
1343
|
+
_memoryRoot = dir;
|
|
1344
|
+
stopMemoryMaintenance();
|
|
1345
|
+
if (_reinforceTimer) {
|
|
1346
|
+
clearTimeout(_reinforceTimer);
|
|
1347
|
+
_reinforceTimer = null;
|
|
1348
|
+
}
|
|
1349
|
+
_pendingReinforce.fragments.clear();
|
|
1350
|
+
_pendingReinforce.knowledge.clear();
|
|
389
1351
|
}
|