@seanhogg/builderforce-memory 2026.6.31 → 2026.6.35
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 +3 -1
- package/dist/cognition/EvermindCognition.d.ts +46 -7
- package/dist/cognition/EvermindCognition.d.ts.map +1 -1
- package/dist/cognition/EvermindCognition.js +95 -25
- package/dist/cognition/EvermindCognition.js.map +1 -1
- package/dist/cognition/canonicalize.d.ts +37 -0
- package/dist/cognition/canonicalize.d.ts.map +1 -0
- package/dist/cognition/canonicalize.js +58 -0
- package/dist/cognition/canonicalize.js.map +1 -0
- package/dist/cognition/index.d.ts +4 -0
- package/dist/cognition/index.d.ts.map +1 -1
- package/dist/cognition/index.js +2 -0
- package/dist/cognition/index.js.map +1 -1
- package/dist/cognition/sanitize.d.ts +58 -0
- package/dist/cognition/sanitize.d.ts.map +1 -0
- package/dist/cognition/sanitize.js +103 -0
- package/dist/cognition/sanitize.js.map +1 -0
- package/dist/distillation/DistillationEngine.d.ts +32 -1
- package/dist/distillation/DistillationEngine.d.ts.map +1 -1
- package/dist/distillation/DistillationEngine.js +53 -5
- package/dist/distillation/DistillationEngine.js.map +1 -1
- package/dist/distillation/index.d.ts +1 -1
- package/dist/distillation/index.d.ts.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/memory/MemoryStore.d.ts +53 -0
- package/dist/memory/MemoryStore.d.ts.map +1 -1
- package/dist/memory/MemoryStore.js +175 -22
- package/dist/memory/MemoryStore.js.map +1 -1
- package/dist/publish/huggingface.d.ts +77 -0
- package/dist/publish/huggingface.d.ts.map +1 -0
- package/dist/publish/huggingface.js +88 -0
- package/dist/publish/huggingface.js.map +1 -0
- package/dist/publish/index.d.ts +7 -0
- package/dist/publish/index.d.ts.map +1 -0
- package/dist/publish/index.js +6 -0
- package/dist/publish/index.js.map +1 -0
- package/dist/retrieval/HybridRetriever.d.ts +5 -0
- package/dist/retrieval/HybridRetriever.d.ts.map +1 -1
- package/dist/retrieval/HybridRetriever.js +9 -7
- package/dist/retrieval/HybridRetriever.js.map +1 -1
- package/dist/retrieval/hnsw.d.ts +71 -0
- package/dist/retrieval/hnsw.d.ts.map +1 -0
- package/dist/retrieval/hnsw.js +201 -0
- package/dist/retrieval/hnsw.js.map +1 -0
- package/dist/retrieval/index.d.ts +2 -0
- package/dist/retrieval/index.d.ts.map +1 -1
- package/dist/retrieval/index.js +1 -0
- package/dist/retrieval/index.js.map +1 -1
- package/dist/session/session.d.ts +9 -0
- package/dist/session/session.d.ts.map +1 -1
- package/dist/session/session.js +12 -2
- package/dist/session/session.js.map +1 -1
- package/dist/workflow/steps.d.ts.map +1 -1
- package/dist/workflow/steps.js +73 -1
- package/dist/workflow/steps.js.map +1 -1
- package/dist/workflow/templates.d.ts.map +1 -1
- package/dist/workflow/templates.js +3 -1
- package/dist/workflow/templates.js.map +1 -1
- package/package.json +7 -7
- package/src/cognition/EvermindCognition.ts +123 -27
- package/src/cognition/canonicalize.ts +61 -0
- package/src/cognition/index.ts +12 -0
- package/src/cognition/sanitize.ts +126 -0
- package/src/distillation/DistillationEngine.ts +72 -5
- package/src/distillation/index.ts +1 -1
- package/src/index.ts +18 -1
- package/src/memory/MemoryStore.ts +197 -18
- package/src/publish/huggingface.ts +139 -0
- package/src/publish/index.ts +7 -0
- package/src/retrieval/HybridRetriever.ts +14 -7
- package/src/retrieval/hnsw.ts +242 -0
- package/src/retrieval/index.ts +3 -0
- package/src/session/session.ts +23 -1
- package/src/workflow/steps.ts +93 -0
- package/src/workflow/templates.ts +3 -1
|
@@ -53,6 +53,22 @@ export interface DistillOptions {
|
|
|
53
53
|
qualityGate? : QualityGate;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Catastrophic-forgetting guard (EVM-5). Online WSLA adapts a narrow set of
|
|
58
|
+
* weights toward the newest exemplar, which can erode previously-learned
|
|
59
|
+
* knowledge. A rehearsal (experience-replay) buffer mitigates this: each adapt
|
|
60
|
+
* also trains on a sample of past exemplars, so old knowledge is continually
|
|
61
|
+
* reinforced instead of overwritten.
|
|
62
|
+
*/
|
|
63
|
+
export interface RehearsalOptions {
|
|
64
|
+
/** Ring-buffer capacity of past exemplars. 0 disables rehearsal. Default 0. */
|
|
65
|
+
bufferSize?: number;
|
|
66
|
+
/** How many past exemplars to mix into each adapt pass. Default 2. */
|
|
67
|
+
sampleK?: number;
|
|
68
|
+
/** Deterministic sampling seed. Default 1. */
|
|
69
|
+
seed?: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
56
72
|
export interface DistillResult {
|
|
57
73
|
/** The input prompt that was distilled. */
|
|
58
74
|
input : string;
|
|
@@ -64,6 +80,8 @@ export interface DistillResult {
|
|
|
64
80
|
skipped? : boolean;
|
|
65
81
|
/** Reason adaptation was skipped, if applicable. */
|
|
66
82
|
skipReason? : string;
|
|
83
|
+
/** Number of past exemplars rehearsed alongside this one (EVM-5). */
|
|
84
|
+
rehearsed? : number;
|
|
67
85
|
}
|
|
68
86
|
|
|
69
87
|
export interface DistillBatchResult {
|
|
@@ -94,14 +112,56 @@ export class DistillationEngine {
|
|
|
94
112
|
private readonly _bridge : TransformerBridge;
|
|
95
113
|
private readonly _log : DistillationLog[] = [];
|
|
96
114
|
|
|
115
|
+
// ── Rehearsal buffer (EVM-5 catastrophic-forgetting guard) ─────────────────
|
|
116
|
+
private readonly _rehearsalSize : number;
|
|
117
|
+
private readonly _rehearsalK : number;
|
|
118
|
+
private readonly _rehearsal : Array<{ input: string; teacherOutput: string }> = [];
|
|
119
|
+
private _rehearsalState : number;
|
|
120
|
+
|
|
97
121
|
/**
|
|
98
122
|
* @param runtime The SSMRuntime whose SSM will be trained as the student.
|
|
99
123
|
* @param bridge The transformer bridge acting as teacher.
|
|
100
124
|
* A bridge must be provided — distillation requires one.
|
|
125
|
+
* @param rehearsal Optional experience-replay config (EVM-5). When
|
|
126
|
+
* `bufferSize > 0`, each adapt also trains on a sample of past
|
|
127
|
+
* exemplars to guard against catastrophic forgetting.
|
|
101
128
|
*/
|
|
102
|
-
constructor(runtime: SSMRuntime, bridge: TransformerBridge) {
|
|
129
|
+
constructor(runtime: SSMRuntime, bridge: TransformerBridge, rehearsal: RehearsalOptions = {}) {
|
|
103
130
|
this._runtime = runtime;
|
|
104
131
|
this._bridge = bridge;
|
|
132
|
+
this._rehearsalSize = Math.max(0, rehearsal.bufferSize ?? 0);
|
|
133
|
+
this._rehearsalK = Math.max(0, rehearsal.sampleK ?? 2);
|
|
134
|
+
this._rehearsalState = (rehearsal.seed ?? 1) >>> 0 || 1;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Current number of exemplars held in the rehearsal buffer (EVM-5). */
|
|
138
|
+
getRehearsalBufferSize(): number {
|
|
139
|
+
return this._rehearsal.length;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Deterministic [0,1) draw for reproducible rehearsal sampling. */
|
|
143
|
+
private _rand(): number {
|
|
144
|
+
this._rehearsalState = (Math.imul(1664525, this._rehearsalState) + 1013904223) >>> 0;
|
|
145
|
+
return this._rehearsalState / 0x1_0000_0000;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Sample up to `_rehearsalK` distinct past exemplars (reservoir-free, by index). */
|
|
149
|
+
private _sampleRehearsal(): Array<{ input: string; teacherOutput: string }> {
|
|
150
|
+
if (this._rehearsalSize === 0 || this._rehearsal.length === 0 || this._rehearsalK === 0) return [];
|
|
151
|
+
const k = Math.min(this._rehearsalK, this._rehearsal.length);
|
|
152
|
+
const idxs = this._rehearsal.map((_, i) => i);
|
|
153
|
+
// Partial Fisher–Yates to pick k distinct indices deterministically.
|
|
154
|
+
for (let i = 0; i < k; i++) {
|
|
155
|
+
const j = i + Math.floor(this._rand() * (idxs.length - i));
|
|
156
|
+
const tmp = idxs[i]!; idxs[i] = idxs[j]!; idxs[j] = tmp;
|
|
157
|
+
}
|
|
158
|
+
return idxs.slice(0, k).map((i) => this._rehearsal[i]!);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private _pushRehearsal(input: string, teacherOutput: string): void {
|
|
162
|
+
if (this._rehearsalSize === 0) return;
|
|
163
|
+
this._rehearsal.push({ input, teacherOutput });
|
|
164
|
+
if (this._rehearsal.length > this._rehearsalSize) this._rehearsal.shift();
|
|
105
165
|
}
|
|
106
166
|
|
|
107
167
|
/**
|
|
@@ -182,9 +242,13 @@ export class DistillationEngine {
|
|
|
182
242
|
}
|
|
183
243
|
}
|
|
184
244
|
|
|
185
|
-
// Train the SSM on the teacher's output
|
|
186
|
-
//
|
|
187
|
-
|
|
245
|
+
// Train the SSM on the teacher's output, prepending the input so the model
|
|
246
|
+
// learns the (prompt → response) mapping. EVM-5: interleave a sample of
|
|
247
|
+
// past exemplars (experience replay) so this adapt reinforces prior
|
|
248
|
+
// knowledge instead of overwriting it (catastrophic-forgetting guard).
|
|
249
|
+
const rehearsed = this._sampleRehearsal();
|
|
250
|
+
const pairs = [...rehearsed, { input, teacherOutput }].map((p) => `${p.input}\n${p.teacherOutput}`);
|
|
251
|
+
const trainingText = pairs.join('\n\n');
|
|
188
252
|
|
|
189
253
|
let adaptResult: AdaptResult;
|
|
190
254
|
try {
|
|
@@ -197,6 +261,9 @@ export class DistillationEngine {
|
|
|
197
261
|
);
|
|
198
262
|
}
|
|
199
263
|
|
|
264
|
+
// Record the new exemplar for future rehearsal.
|
|
265
|
+
this._pushRehearsal(input, teacherOutput);
|
|
266
|
+
|
|
200
267
|
this._appendLog({
|
|
201
268
|
input,
|
|
202
269
|
teacherOutputLength: teacherOutput.length,
|
|
@@ -205,7 +272,7 @@ export class DistillationEngine {
|
|
|
205
272
|
epochs : adaptResult.epochCount,
|
|
206
273
|
});
|
|
207
274
|
|
|
208
|
-
return { input, teacherOutput, adaptResult, skipped: false };
|
|
275
|
+
return { input, teacherOutput, adaptResult, skipped: false, rehearsed: rehearsed.length };
|
|
209
276
|
}
|
|
210
277
|
|
|
211
278
|
/**
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { DistillationEngine } from './DistillationEngine.js';
|
|
2
|
-
export type { DistillOptions, DistillResult, DistillBatchResult, DistillationLog, QualityGate } from './DistillationEngine.js';
|
|
2
|
+
export type { DistillOptions, DistillResult, DistillBatchResult, DistillationLog, QualityGate, RehearsalOptions } from './DistillationEngine.js';
|
package/src/index.ts
CHANGED
|
@@ -169,7 +169,16 @@ export type {
|
|
|
169
169
|
} from './memory/MemoryStore.js';
|
|
170
170
|
|
|
171
171
|
// ── Cognition (Evermind — Write-Through Cognition) ────────────────────────────
|
|
172
|
-
export {
|
|
172
|
+
export {
|
|
173
|
+
EvermindCognition,
|
|
174
|
+
workspacePresenceGatherer,
|
|
175
|
+
canonicalizeSubjectKey,
|
|
176
|
+
normalizeSubjectKey,
|
|
177
|
+
buildAliasTable,
|
|
178
|
+
sanitizeRecalledFact,
|
|
179
|
+
buildRecallContext,
|
|
180
|
+
trustScore,
|
|
181
|
+
} from './cognition/index.js';
|
|
173
182
|
export type {
|
|
174
183
|
EvermindCognitionOptions,
|
|
175
184
|
WorkspacePresenceRule,
|
|
@@ -180,6 +189,9 @@ export type {
|
|
|
180
189
|
EvidenceGatherer,
|
|
181
190
|
EvidenceResult,
|
|
182
191
|
Verdict,
|
|
192
|
+
AliasTable,
|
|
193
|
+
RecalledFact,
|
|
194
|
+
FactProvenance,
|
|
183
195
|
} from './cognition/index.js';
|
|
184
196
|
|
|
185
197
|
// ── Diagnostics (execution-output runner) ──────────────────────────────────────
|
|
@@ -218,6 +230,10 @@ export type {
|
|
|
218
230
|
RunWorkflowOptions,
|
|
219
231
|
} from './workflow/index.js';
|
|
220
232
|
|
|
233
|
+
// ── Publish (ship an engine model export to HF / local folder) ──────────────────
|
|
234
|
+
export { publishToHuggingFace, writeExportToDir } from './publish/index.js';
|
|
235
|
+
export type { HuggingFaceTarget, PublishOutcome, HubClient, FsLike } from './publish/index.js';
|
|
236
|
+
|
|
221
237
|
// ── Distillation ──────────────────────────────────────────────────────────────
|
|
222
238
|
export { DistillationEngine } from './distillation/DistillationEngine.js';
|
|
223
239
|
export type {
|
|
@@ -226,6 +242,7 @@ export type {
|
|
|
226
242
|
DistillBatchResult,
|
|
227
243
|
DistillationLog,
|
|
228
244
|
QualityGate,
|
|
245
|
+
RehearsalOptions,
|
|
229
246
|
} from './distillation/DistillationEngine.js';
|
|
230
247
|
|
|
231
248
|
// ── Agent ─────────────────────────────────────────────────────────────────────
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { SSMError } from '../errors/SSMError.js';
|
|
12
|
-
import { tokenize, jaccardSimilarity
|
|
13
|
-
import { hybridRetrieve, type RetrievalCandidate, type HybridRetrieveOptions } from '../retrieval/index.js';
|
|
12
|
+
import { tokenize, jaccardSimilarity } from '../similarity/index.js';
|
|
13
|
+
import { hybridRetrieve, denseSearch, HnswIndex, type RetrievalCandidate, type HybridRetrieveOptions } from '../retrieval/index.js';
|
|
14
14
|
|
|
15
15
|
export type FactType = 'text' | 'json' | 'number' | 'boolean';
|
|
16
16
|
|
|
@@ -61,6 +61,23 @@ export interface MemoryStoreOptions {
|
|
|
61
61
|
* Entries with an expired TTL are filtered from recallAll() and related methods.
|
|
62
62
|
*/
|
|
63
63
|
defaultTtlMs? : number;
|
|
64
|
+
/**
|
|
65
|
+
* Fact count at/above which semantic recall switches from an exact O(N)
|
|
66
|
+
* cosine scan to an O(log N) HNSW ANN index. Default 256 — small stores stay
|
|
67
|
+
* exact (simpler + faster), large stores stay fast. (EVM-1)
|
|
68
|
+
*/
|
|
69
|
+
annThreshold? : number;
|
|
70
|
+
/**
|
|
71
|
+
* Hard cap on stored facts. When set, each write evicts the lowest-value
|
|
72
|
+
* entries (expired first, then lowest importance, then oldest) so the store
|
|
73
|
+
* stays bounded. Unset = unbounded (legacy). (EVM-7)
|
|
74
|
+
*/
|
|
75
|
+
maxEntries? : number;
|
|
76
|
+
/**
|
|
77
|
+
* Max content→embedding vectors held in the LRU recall cache. Default 2000.
|
|
78
|
+
* (EVM-8 — bounded LRU instead of clear-on-overflow.)
|
|
79
|
+
*/
|
|
80
|
+
embedCacheMax?: number;
|
|
64
81
|
}
|
|
65
82
|
|
|
66
83
|
const FACTS_STORE = 'facts';
|
|
@@ -84,7 +101,7 @@ interface SaveLoadRuntime {
|
|
|
84
101
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
85
102
|
type SSMRuntimeRef = any;
|
|
86
103
|
|
|
87
|
-
/** Max
|
|
104
|
+
/** Max content→embedding vectors retained (LRU-evicted past this). (EVM-8) */
|
|
88
105
|
const EMBED_CACHE_MAX = 2000;
|
|
89
106
|
|
|
90
107
|
export class MemoryStore {
|
|
@@ -92,15 +109,36 @@ export class MemoryStore {
|
|
|
92
109
|
private readonly _weightsKey : string;
|
|
93
110
|
private readonly _idb : IDBFactory | undefined;
|
|
94
111
|
private readonly _defaultTtl : number | undefined;
|
|
112
|
+
private readonly _annThreshold : number;
|
|
113
|
+
private readonly _maxEntries : number | undefined;
|
|
114
|
+
private readonly _embedCacheMax : number;
|
|
95
115
|
private _db: IDBDatabase | null = null;
|
|
116
|
+
private _sweepTimer: ReturnType<typeof setInterval> | null = null;
|
|
96
117
|
/** Content → L2-normalised embedding cache, used by recallSimilar. */
|
|
97
118
|
private readonly _embedCache = new Map<string, Float32Array>();
|
|
119
|
+
/** Runtime whose embeddings populate `_embedCache`; a change invalidates it. */
|
|
120
|
+
private _embedRuntime: unknown = null;
|
|
121
|
+
|
|
122
|
+
// ── Persistent dense index (EVM-1b) ────────────────────────────────────────
|
|
123
|
+
// Fact embeddings keyed by `${key}@${seq}` so an EDIT (seq bump) is a new key
|
|
124
|
+
// and an unchanged fact is never re-embedded. The HNSW index is derived from
|
|
125
|
+
// these vectors and reused across recalls until the fact set changes — so
|
|
126
|
+
// steady-state recall is O(1) query-embed + O(log N) search, not O(N) embed.
|
|
127
|
+
private readonly _denseVectors = new Map<string, Float32Array>();
|
|
128
|
+
private _denseIndex: HnswIndex | null = null;
|
|
129
|
+
/** Signature of the fact set the current index was built from. */
|
|
130
|
+
private _denseSig = '';
|
|
131
|
+
/** Identity of the runtime whose embeddings populated the vectors. */
|
|
132
|
+
private _denseRuntime: unknown = null;
|
|
98
133
|
|
|
99
134
|
constructor(opts: MemoryStoreOptions = {}) {
|
|
100
135
|
this._dbName = opts.dbName ?? 'ssmjs';
|
|
101
136
|
this._weightsKey = opts.weightsKey ?? 'ssmjs-weights';
|
|
102
137
|
this._idb = opts.idbFactory;
|
|
103
138
|
this._defaultTtl = opts.defaultTtlMs;
|
|
139
|
+
this._annThreshold = opts.annThreshold ?? 256;
|
|
140
|
+
this._maxEntries = opts.maxEntries;
|
|
141
|
+
this._embedCacheMax = opts.embedCacheMax ?? EMBED_CACHE_MAX;
|
|
104
142
|
}
|
|
105
143
|
|
|
106
144
|
// ── Internal DB open ──────────────────────────────────────────────────────
|
|
@@ -167,7 +205,65 @@ export class MemoryStore {
|
|
|
167
205
|
};
|
|
168
206
|
|
|
169
207
|
const tx = db.transaction(FACTS_STORE, 'readwrite');
|
|
170
|
-
|
|
208
|
+
await requestToPromise(tx.objectStore(FACTS_STORE).put(entry), `Failed to store fact "${key}"`, () => undefined);
|
|
209
|
+
// EVM-7: keep the store bounded — evict lowest-value entries past the cap.
|
|
210
|
+
if (this._maxEntries != null) await this._enforceCap();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Evicts the lowest-value entries until at most `maxEntries` remain. Eviction
|
|
215
|
+
* priority (removed first): expired → lowest importance → oldest. No-op when
|
|
216
|
+
* no cap is set or the store is under it. (EVM-7)
|
|
217
|
+
*/
|
|
218
|
+
private async _enforceCap(): Promise<void> {
|
|
219
|
+
if (this._maxEntries == null) return;
|
|
220
|
+
const db = await this._open();
|
|
221
|
+
const tx = db.transaction(FACTS_STORE, 'readonly');
|
|
222
|
+
const all = await requestToPromise(
|
|
223
|
+
tx.objectStore(FACTS_STORE).getAll() as IDBRequest<MemoryEntry[]>,
|
|
224
|
+
'Failed to scan facts for cap',
|
|
225
|
+
(r) => r,
|
|
226
|
+
);
|
|
227
|
+
if (all.length <= this._maxEntries) return;
|
|
228
|
+
|
|
229
|
+
// Ascending sort = worst first: expired (0) before live (1), then lower
|
|
230
|
+
// importance, then older timestamp, then lower write sequence.
|
|
231
|
+
const rank = (e: MemoryEntry): [number, number, number, number] =>
|
|
232
|
+
[this._isExpired(e) ? 0 : 1, e.importance ?? 0.5, e.timestamp, e.seq ?? 0];
|
|
233
|
+
const sorted = [...all].sort((a, b) => {
|
|
234
|
+
const ra = rank(a), rb = rank(b);
|
|
235
|
+
for (let i = 0; i < ra.length; i++) {
|
|
236
|
+
if (ra[i] !== rb[i]) return ra[i]! - rb[i]!;
|
|
237
|
+
}
|
|
238
|
+
return 0;
|
|
239
|
+
});
|
|
240
|
+
const victims = sorted.slice(0, all.length - this._maxEntries);
|
|
241
|
+
await Promise.all(victims.map((e) => this.forget(e.key)));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Starts a background timer that periodically hard-deletes expired entries —
|
|
246
|
+
* TTL is otherwise only evaluated lazily on read, so an idle store would keep
|
|
247
|
+
* dead entries forever. Idempotent; pair with {@link stopTtlSweeper}. (EVM-7)
|
|
248
|
+
*/
|
|
249
|
+
startTtlSweeper(intervalMs: number): void {
|
|
250
|
+
if (this._sweepTimer) return;
|
|
251
|
+
const timer = setInterval(() => { void this.purgeExpired(); }, intervalMs);
|
|
252
|
+
// Don't keep a Node process alive just for the sweeper.
|
|
253
|
+
const maybeUnref = timer as unknown as { unref?: () => void };
|
|
254
|
+
/* istanbul ignore else -- unref exists in Node; absent in the browser */
|
|
255
|
+
if (typeof maybeUnref.unref === 'function') {
|
|
256
|
+
maybeUnref.unref();
|
|
257
|
+
}
|
|
258
|
+
this._sweepTimer = timer;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Stops the background TTL sweeper started by {@link startTtlSweeper}. */
|
|
262
|
+
stopTtlSweeper(): void {
|
|
263
|
+
if (this._sweepTimer) {
|
|
264
|
+
clearInterval(this._sweepTimer);
|
|
265
|
+
this._sweepTimer = null;
|
|
266
|
+
}
|
|
171
267
|
}
|
|
172
268
|
|
|
173
269
|
/**
|
|
@@ -239,16 +335,16 @@ export class MemoryStore {
|
|
|
239
335
|
if (runtime != null && typeof runtime.embed === 'function') {
|
|
240
336
|
const queryVec = await this._embedWithCache(runtime, query);
|
|
241
337
|
if (queryVec) {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
return
|
|
338
|
+
// EVM-1b: sync the persistent vector store + index (embeds only the
|
|
339
|
+
// delta), then query it — no per-call re-embed of every fact.
|
|
340
|
+
const synced = await this._syncDense(runtime, all);
|
|
341
|
+
if (synced) {
|
|
342
|
+
const hits = synced.index
|
|
343
|
+
// EVM-1: above the threshold, query the persistent HNSW (O(log N)).
|
|
344
|
+
? synced.index.search(queryVec, topK)
|
|
345
|
+
// Below it, an exact scan over the (already-embedded) vectors.
|
|
346
|
+
: denseSearch(queryVec, synced.items, topK, this._annThreshold);
|
|
347
|
+
return hits.map(h => synced.byKey.get(h.id)).filter((e): e is MemoryEntry => !!e);
|
|
252
348
|
}
|
|
253
349
|
}
|
|
254
350
|
// Any failure falls through to the Jaccard path below.
|
|
@@ -296,7 +392,7 @@ export class MemoryStore {
|
|
|
296
392
|
candidates.push({ id: entry.key, text: entry.content, vector });
|
|
297
393
|
}
|
|
298
394
|
|
|
299
|
-
const hits = hybridRetrieve({ text: query, vector: queryVec }, candidates, { topK, ...opts });
|
|
395
|
+
const hits = hybridRetrieve({ text: query, vector: queryVec }, candidates, { topK, annThreshold: this._annThreshold, ...opts });
|
|
300
396
|
const byKey = new Map(all.map(e => [e.key, e]));
|
|
301
397
|
return hits.map(h => byKey.get(h.id)).filter((e): e is MemoryEntry => !!e);
|
|
302
398
|
}
|
|
@@ -307,13 +403,29 @@ export class MemoryStore {
|
|
|
307
403
|
* unavailable so callers can fall back to lexical similarity.
|
|
308
404
|
*/
|
|
309
405
|
private async _embedWithCache(runtime: SSMRuntimeRef, text: string): Promise<Float32Array | null> {
|
|
406
|
+
// Embeddings are model-specific: if the runtime instance changed, the cached
|
|
407
|
+
// vectors are from a different model and must be dropped. (EVM-1b)
|
|
408
|
+
if (runtime !== this._embedRuntime) {
|
|
409
|
+
this._embedCache.clear();
|
|
410
|
+
this._embedRuntime = runtime;
|
|
411
|
+
}
|
|
310
412
|
const cached = this._embedCache.get(text);
|
|
311
|
-
if (cached)
|
|
413
|
+
if (cached) {
|
|
414
|
+
// LRU touch: re-insert so this entry becomes most-recently-used. (EVM-8)
|
|
415
|
+
this._embedCache.delete(text);
|
|
416
|
+
this._embedCache.set(text, cached);
|
|
417
|
+
return cached;
|
|
418
|
+
}
|
|
312
419
|
try {
|
|
313
420
|
const vec = await runtime.embed(text);
|
|
314
421
|
if (vec instanceof Float32Array && vec.length > 0) {
|
|
315
|
-
//
|
|
316
|
-
|
|
422
|
+
// EVM-8: bound cache growth by evicting the LEAST-recently-used entry
|
|
423
|
+
// (Map keeps insertion order; the first key is the LRU), instead of
|
|
424
|
+
// dumping the whole cache on every overflow.
|
|
425
|
+
if (this._embedCache.size >= this._embedCacheMax) {
|
|
426
|
+
const lru = this._embedCache.keys().next().value;
|
|
427
|
+
if (lru !== undefined) this._embedCache.delete(lru);
|
|
428
|
+
}
|
|
317
429
|
this._embedCache.set(text, vec);
|
|
318
430
|
return vec;
|
|
319
431
|
}
|
|
@@ -323,6 +435,68 @@ export class MemoryStore {
|
|
|
323
435
|
return null;
|
|
324
436
|
}
|
|
325
437
|
|
|
438
|
+
/**
|
|
439
|
+
* Synchronise the persistent dense vector store + HNSW index to `all` (EVM-1b).
|
|
440
|
+
* Embeds ONLY new/changed facts (keyed by `key@seq`), prunes removed ones, and
|
|
441
|
+
* rebuilds the index only when the fact set actually changed — so a read-heavy
|
|
442
|
+
* workload reuses both the embeddings and the index across recalls. Returns
|
|
443
|
+
* `null` (caller falls back to lexical) if any embedding is unavailable, or
|
|
444
|
+
* resets everything when the runtime identity changes (embeddings are
|
|
445
|
+
* model-specific).
|
|
446
|
+
*/
|
|
447
|
+
private async _syncDense(
|
|
448
|
+
runtime: SSMRuntimeRef,
|
|
449
|
+
all: MemoryEntry[],
|
|
450
|
+
): Promise<{ byKey: Map<string, MemoryEntry>; items: Array<{ id: string; vector: Float32Array }>; index: HnswIndex | null } | null> {
|
|
451
|
+
if (runtime !== this._denseRuntime) {
|
|
452
|
+
this._denseVectors.clear();
|
|
453
|
+
this._denseIndex = null;
|
|
454
|
+
this._denseSig = '';
|
|
455
|
+
this._denseRuntime = runtime;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const vkeyOf = (e: MemoryEntry): string => `${e.key}@${e.seq ?? 0}`;
|
|
459
|
+
const currentVkeys = new Set<string>();
|
|
460
|
+
let sig = `${all.length}`;
|
|
461
|
+
for (const e of all) { const k = vkeyOf(e); currentVkeys.add(k); sig += '|' + k; }
|
|
462
|
+
|
|
463
|
+
const byKey = new Map<string, MemoryEntry>();
|
|
464
|
+
const items: Array<{ id: string; vector: Float32Array }> = [];
|
|
465
|
+
for (const entry of all) {
|
|
466
|
+
const vk = vkeyOf(entry);
|
|
467
|
+
let vec = this._denseVectors.get(vk);
|
|
468
|
+
if (!vec) {
|
|
469
|
+
const v = await this._embedWithCache(runtime, entry.content);
|
|
470
|
+
if (!v) return null; // embedding failed → lexical fallback
|
|
471
|
+
vec = v;
|
|
472
|
+
this._denseVectors.set(vk, vec);
|
|
473
|
+
}
|
|
474
|
+
items.push({ id: entry.key, vector: vec });
|
|
475
|
+
byKey.set(entry.key, entry);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Drop vectors for facts that are gone or were edited (stale vkeys).
|
|
479
|
+
if (this._denseVectors.size > currentVkeys.size) {
|
|
480
|
+
for (const k of this._denseVectors.keys()) {
|
|
481
|
+
if (!currentVkeys.has(k)) this._denseVectors.delete(k);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Maintain the ANN index only above the threshold; (re)build it only when
|
|
486
|
+
// the fact set changed (no embedding involved — purely in-memory).
|
|
487
|
+
if (all.length >= this._annThreshold) {
|
|
488
|
+
if (sig !== this._denseSig || this._denseIndex === null) {
|
|
489
|
+
const index = new HnswIndex();
|
|
490
|
+
for (const it of items) index.add(it.id, it.vector);
|
|
491
|
+
this._denseIndex = index;
|
|
492
|
+
}
|
|
493
|
+
} else {
|
|
494
|
+
this._denseIndex = null;
|
|
495
|
+
}
|
|
496
|
+
this._denseSig = sig;
|
|
497
|
+
return { byKey, items, index: this._denseIndex };
|
|
498
|
+
}
|
|
499
|
+
|
|
326
500
|
/**
|
|
327
501
|
* Hard-deletes all entries whose TTL has expired.
|
|
328
502
|
* @returns The number of entries deleted.
|
|
@@ -356,6 +530,11 @@ export class MemoryStore {
|
|
|
356
530
|
/** Deletes all facts. Does not affect saved weights. */
|
|
357
531
|
async clear(): Promise<void> {
|
|
358
532
|
const db = await this._open();
|
|
533
|
+
// Drop the persistent dense index/vectors too (EVM-1b) — they describe a
|
|
534
|
+
// fact set that no longer exists.
|
|
535
|
+
this._denseVectors.clear();
|
|
536
|
+
this._denseIndex = null;
|
|
537
|
+
this._denseSig = '';
|
|
359
538
|
|
|
360
539
|
const tx = db.transaction(FACTS_STORE, 'readwrite');
|
|
361
540
|
return requestToPromise(tx.objectStore(FACTS_STORE).clear(), 'Failed to clear facts', () => undefined);
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* publish/huggingface.ts — push an exported Evermind model to the Hugging Face Hub.
|
|
3
|
+
*
|
|
4
|
+
* The runtime stays zero-dependency: the actual Hub I/O is delegated to an
|
|
5
|
+
* injectable {@link HubClient}. In production it is dynamically imported from the
|
|
6
|
+
* optional `@huggingface/hub` package (which handles LFS for the binary weight
|
|
7
|
+
* files correctly); tests inject a fake. There is also a credential-free path —
|
|
8
|
+
* {@link writeExportToDir} — that writes the repo to a local folder you push with
|
|
9
|
+
* `git` / `huggingface-cli upload`.
|
|
10
|
+
*
|
|
11
|
+
* The export itself (turning a trained model into the file set) lives in the
|
|
12
|
+
* engine's `export/` module; this module is only the *transport*.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ExportResult, ExportFile } from "@seanhogg/builderforce-memory-engine";
|
|
16
|
+
|
|
17
|
+
/** Where/how to publish. `repoId` is "owner/name" (e.g. "builderforce/Evermind"). */
|
|
18
|
+
export interface HuggingFaceTarget {
|
|
19
|
+
repoId: string;
|
|
20
|
+
/** A write-scoped HF access token (hf_…). */
|
|
21
|
+
token: string;
|
|
22
|
+
/** Create the repo private. Default false (public). */
|
|
23
|
+
private?: boolean;
|
|
24
|
+
/** Commit title. Default "Publish <repoId>". */
|
|
25
|
+
commitTitle?: string;
|
|
26
|
+
/** Target branch. Default "main". */
|
|
27
|
+
branch?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PublishOutcome {
|
|
31
|
+
repoId: string;
|
|
32
|
+
url: string;
|
|
33
|
+
files: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The slice of `@huggingface/hub` we use — injectable for testing. */
|
|
37
|
+
export interface HubClient {
|
|
38
|
+
createRepo(args: {
|
|
39
|
+
repo: { type: "model"; name: string };
|
|
40
|
+
accessToken: string;
|
|
41
|
+
private?: boolean;
|
|
42
|
+
}): Promise<unknown>;
|
|
43
|
+
uploadFiles(args: {
|
|
44
|
+
repo: { type: "model"; name: string };
|
|
45
|
+
accessToken: string;
|
|
46
|
+
branch?: string;
|
|
47
|
+
commitTitle?: string;
|
|
48
|
+
files: { path: string; content: Blob }[];
|
|
49
|
+
}): Promise<unknown>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Turn an emitted file into a Blob (binary or text) for upload. */
|
|
53
|
+
function toBlob(file: ExportFile): Blob {
|
|
54
|
+
const part = (typeof file.data === "string" ? file.data : file.data) as unknown as BlobPart;
|
|
55
|
+
return new Blob([part], { type: file.contentType });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function loadHub(): Promise<HubClient> {
|
|
59
|
+
try {
|
|
60
|
+
// Optional peer dep — only required when actually uploading. A variable
|
|
61
|
+
// specifier keeps it out of the static module graph (no build-time dep).
|
|
62
|
+
const spec = "@huggingface/hub";
|
|
63
|
+
const mod = (await import(spec)) as unknown as HubClient;
|
|
64
|
+
if (typeof mod.createRepo !== "function" || typeof mod.uploadFiles !== "function") {
|
|
65
|
+
throw new Error("module shape unexpected");
|
|
66
|
+
}
|
|
67
|
+
return mod;
|
|
68
|
+
} catch {
|
|
69
|
+
throw new Error(
|
|
70
|
+
"publishToHuggingFace needs the optional '@huggingface/hub' package " +
|
|
71
|
+
"(npm i @huggingface/hub) — or use writeExportToDir() + `huggingface-cli upload`.",
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Publish an export to the Hub: create the repo (idempotent) then upload every
|
|
78
|
+
* file. Pass `deps.hub` to inject a client; otherwise `@huggingface/hub` is
|
|
79
|
+
* loaded dynamically. Requires a write-scoped token (the credential-gated step).
|
|
80
|
+
*/
|
|
81
|
+
export async function publishToHuggingFace(
|
|
82
|
+
result: ExportResult,
|
|
83
|
+
target: HuggingFaceTarget,
|
|
84
|
+
deps: { hub?: HubClient } = {},
|
|
85
|
+
): Promise<PublishOutcome> {
|
|
86
|
+
if (!target.repoId.includes("/")) {
|
|
87
|
+
throw new Error(`publishToHuggingFace: repoId must be "owner/name" (got "${target.repoId}")`);
|
|
88
|
+
}
|
|
89
|
+
if (!target.token) throw new Error("publishToHuggingFace: a write-scoped HF token is required");
|
|
90
|
+
|
|
91
|
+
const hub = deps.hub ?? (await loadHub());
|
|
92
|
+
const repo = { type: "model" as const, name: target.repoId };
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
await hub.createRepo({ repo, accessToken: target.token, private: target.private ?? false });
|
|
96
|
+
} catch (e) {
|
|
97
|
+
// Re-creating an existing repo is fine; only rethrow unexpected failures.
|
|
98
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
99
|
+
if (!/exist|409|conflict/i.test(msg)) throw e;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const files = result.files.map((f) => ({ path: f.path, content: toBlob(f) }));
|
|
103
|
+
await hub.uploadFiles({
|
|
104
|
+
repo,
|
|
105
|
+
accessToken: target.token,
|
|
106
|
+
branch: target.branch ?? "main",
|
|
107
|
+
commitTitle: target.commitTitle ?? `Publish ${target.repoId}`,
|
|
108
|
+
files,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
return { repoId: target.repoId, url: `https://huggingface.co/${target.repoId}`, files: files.map((f) => f.path) };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A minimal fs surface (Node `fs/promises`) — injectable for testing. */
|
|
115
|
+
export interface FsLike {
|
|
116
|
+
mkdir(path: string, opts: { recursive: boolean }): Promise<unknown>;
|
|
117
|
+
writeFile(path: string, data: Uint8Array | string): Promise<void>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Credential-free publish: write the export to a local directory (a ready-to-push
|
|
122
|
+
* HF repo folder). Returns the relative paths written. Inject `fs` in tests;
|
|
123
|
+
* otherwise Node's `fs/promises` is used.
|
|
124
|
+
*/
|
|
125
|
+
export async function writeExportToDir(result: ExportResult, dir: string, fs?: FsLike): Promise<string[]> {
|
|
126
|
+
const spec = "node:fs/promises";
|
|
127
|
+
const realFs = fs ?? ((await import(spec)) as unknown as FsLike);
|
|
128
|
+
const sep = dir.endsWith("/") || dir.endsWith("\\") ? "" : "/";
|
|
129
|
+
await realFs.mkdir(dir, { recursive: true });
|
|
130
|
+
const written: string[] = [];
|
|
131
|
+
for (const f of result.files) {
|
|
132
|
+
const full = `${dir}${sep}${f.path}`;
|
|
133
|
+
const slash = Math.max(full.lastIndexOf("/"), full.lastIndexOf("\\"));
|
|
134
|
+
if (slash > dir.length) await realFs.mkdir(full.slice(0, slash), { recursive: true });
|
|
135
|
+
await realFs.writeFile(full, f.data);
|
|
136
|
+
written.push(f.path);
|
|
137
|
+
}
|
|
138
|
+
return written;
|
|
139
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* publish/ — transport for published Evermind exports (the engine builds the
|
|
3
|
+
* files; this ships them). Hugging Face Hub upload + local-folder writer.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export { publishToHuggingFace, writeExportToDir } from "./huggingface.js";
|
|
7
|
+
export type { HuggingFaceTarget, PublishOutcome, HubClient, FsLike } from "./huggingface.js";
|
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
* candidate vectors → BM25-only; no overlap → dense-only.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { cosineSimilarity } from '../similarity/index.js';
|
|
18
17
|
import { bm25Search, type Bm25Options } from './bm25.js';
|
|
19
18
|
import { reciprocalRankFusion, maximalMarginalRelevance, type MmrCandidate } from './fusion.js';
|
|
19
|
+
import { denseSearch } from './hnsw.js';
|
|
20
20
|
|
|
21
21
|
export interface RetrievalCandidate {
|
|
22
22
|
id: string;
|
|
@@ -46,6 +46,11 @@ export interface HybridRetrieveOptions {
|
|
|
46
46
|
mmrLambda?: number;
|
|
47
47
|
/** BM25 tuning. */
|
|
48
48
|
bm25?: Bm25Options;
|
|
49
|
+
/**
|
|
50
|
+
* Candidate count at/above which the dense pass uses an HNSW ANN index
|
|
51
|
+
* instead of an exact cosine scan (O(log N) vs O(N)). Default 256.
|
|
52
|
+
*/
|
|
53
|
+
annThreshold?: number;
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
export interface HybridHit {
|
|
@@ -70,14 +75,16 @@ export function hybridRetrieve(
|
|
|
70
75
|
|
|
71
76
|
const byId = new Map(candidates.map(c => [c.id, c]));
|
|
72
77
|
|
|
73
|
-
// ── Dense ranking (cosine)
|
|
78
|
+
// ── Dense ranking (cosine, ANN-accelerated above the threshold) ────────────
|
|
74
79
|
let denseIds: string[] = [];
|
|
75
80
|
if (query.vector) {
|
|
76
|
-
|
|
77
|
-
.filter(c => c.vector && c.vector.length > 0)
|
|
78
|
-
.map(c => ({ id: c.id,
|
|
79
|
-
|
|
80
|
-
|
|
81
|
+
const vectored = candidates
|
|
82
|
+
.filter((c): c is RetrievalCandidate & { vector: Float32Array } => !!c.vector && c.vector.length > 0)
|
|
83
|
+
.map(c => ({ id: c.id, vector: c.vector }));
|
|
84
|
+
// Rank the full vectored set (k = all) so fusion sees the complete dense
|
|
85
|
+
// ordering; denseSearch uses an exact scan under the threshold and HNSW
|
|
86
|
+
// above it, so behaviour is identical for small stores.
|
|
87
|
+
denseIds = denseSearch(query.vector, vectored, vectored.length, opts.annThreshold ?? 256).map(h => h.id);
|
|
81
88
|
}
|
|
82
89
|
|
|
83
90
|
// ── Sparse ranking (BM25) ─────────────────────────────────────────────────
|