@heixui/dsh-session-suite 0.0.0-stage → 0.3.0

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.
@@ -0,0 +1,1323 @@
1
+ /**
2
+ * Long-term memory engine.
3
+ *
4
+ * Two isolated stores, per the product spec:
5
+ *
6
+ * global — cross-workspace,永久, shared by every session
7
+ * workspace — private to one working directory
8
+ *
9
+ * Session snapshots are deliberately *not* here: they belong to the rewind
10
+ * feature, and they never participate in scoring, decay, or clustering.
11
+ *
12
+ * A memory entry carries the metadata the four-factor model needs (creation and
13
+ * last-access time, access count, affect, and a duplicate signal), and the
14
+ * lifecycle is:
15
+ *
16
+ * active — full text in the recall pool
17
+ * compressed — a cluster summary stands in for it; the original is archived
18
+ * archived — cold storage, recallable on demand but never auto-injected
19
+ *
20
+ * Nothing is ever hard-deleted by the engine: decay moves entries between those
21
+ * three states, and only an explicit delete removes a record.
22
+ */
23
+
24
+ import { readFileSync } from 'node:fs'
25
+ import fs from 'node:fs/promises'
26
+ import path from 'node:path'
27
+ import { ensureDir, readJson, sha256Text, shortId, writeJson } from './util.js'
28
+ import { applySupersession, isCatchAllKey, normalizeFactKey } from './factkeys.js'
29
+ import { isLoaded, loadWordList, wordTokens } from './wordlist.js'
30
+ import {
31
+ bm25,
32
+ rankIds,
33
+ reciprocalRankFusion,
34
+ tokens,
35
+ unigrams,
36
+ weightedCoverage,
37
+ } from './retrieval.js'
38
+
39
+ export const SCOPES = ['global', 'workspace']
40
+ export const STATES = ['active', 'compressed', 'archived']
41
+
42
+ /** Scoring weights; each factor is normalized to 0..1 before weighting. */
43
+ export const DEFAULT_WEIGHTS = { frequency: 0.35, recency: 0.3, affect: 0.15, uniqueness: 0.2 }
44
+
45
+ /** Lifecycle thresholds on the 0..1 composite score. */
46
+ export const DEFAULT_THRESHOLDS = { compress: 0.45, archive: 0.25 }
47
+
48
+ const DEFAULTS = {
49
+ /** Half-life of the recency factor, in days. */
50
+ decayDays: 60,
51
+ /** Two entries above this similarity are considered near-duplicates. */
52
+ duplicateSimilarity: 0.82,
53
+ /** Two entries above this similarity may join one cluster. */
54
+ clusterSimilarity: 0.5,
55
+ /** A cluster needs at least this many members to be compressed. */
56
+ clusterMinSize: 2,
57
+ /** A tag group needs at least this many entries to become a rule. */
58
+ ruleMinSize: 3,
59
+ /** How many memories one recall may return. */
60
+ recallLimit: 6,
61
+ /**
62
+ * Longest a single memory may be, in characters.
63
+ *
64
+ * A memory is a fact to recall, not a document to read. Past a few hundred
65
+ * characters it stops being useful as a memory: it is expensive to inject,
66
+ * its shingles swamp any query, and a human scanning the list cannot take it
67
+ * in. The observed failure was a 3250-character entry, which is a report, not
68
+ * a memory. Longer input is refused rather than silently truncated — a
69
+ * truncated fact reads as a complete one and would be wrong.
70
+ */
71
+ maxMemoryChars: 300,
72
+ /** BM25 term-frequency saturation. Lower saturates sooner. */
73
+ bm25K1: 1.5,
74
+ /** BM25 length-normalisation strength, 0 (none) to 1 (full). */
75
+ bm25B: 0.75,
76
+ /** RRF smoothing constant; 60 is the customary default. */
77
+ rrfK: 60,
78
+ }
79
+
80
+ /**
81
+ * The CJK word list, loaded once per process.
82
+ *
83
+ * Loading costs about a millisecond of I/O, 23 ms of parsing, and ~14 MB of
84
+ * heap, so re-reading it inside every recall would burn that on every turn for
85
+ * no gain. Failure is cached as an empty list as well: a missing data file must
86
+ * degrade silently to the two lexical routes that already existed, never break
87
+ * recall. `isLoaded()` then tells the caller whether the third route is usable.
88
+ *
89
+ * @returns {any} the shared word list
90
+ */
91
+ /**
92
+ * Accept a fact key only if it names one subject-and-aspect pair.
93
+ *
94
+ * The key answers "is this the same fact, restated?" so a key that is too broad
95
+ * destroys memories: `她.身高` is a fact, `身高` alone is a category, and
96
+ * `信息.内容` could absorb half the library. A rejected key means the entry
97
+ * simply opts out of supersession, which is always safe — the dangerous
98
+ * failure is a wrong key, not a missing one.
99
+ *
100
+ * @param {unknown} raw caller-supplied key
101
+ * @returns {string | null} canonical key, or null to opt out
102
+ */
103
+ function storedFactKey(raw) {
104
+ const canonical = normalizeFactKey(raw)
105
+ if (!canonical || isCatchAllKey(canonical)) return null
106
+ return canonical
107
+ }
108
+
109
+ let wordListCache
110
+ function cjkWordList() {
111
+ if (wordListCache === undefined) {
112
+ try {
113
+ const text = readFileSync(new URL('./data/jieba-dict.txt', import.meta.url), 'utf8')
114
+ wordListCache = loadWordList(text)
115
+ } catch {
116
+ wordListCache = loadWordList('')
117
+ }
118
+ }
119
+ return wordListCache
120
+ }
121
+
122
+ /**
123
+ * Lexical similarity for Chinese and English alike: character bigrams plus
124
+ * lowercase word tokens, scored with the overlap coefficient. It is not an
125
+ * embedding, and it is documented as such, but it is cheap, offline, and works
126
+ * well for the short sentences memories actually hold.
127
+ *
128
+ * @param {string} text input
129
+ * @returns {Set<string>} shingles
130
+ */
131
+ export function shingles(text) {
132
+ const normalized = String(text ?? '').toLowerCase().replace(/[\s\p{P}\p{S}]+/gu, ' ')
133
+ const out = new Set()
134
+ for (const word of normalized.split(' ')) {
135
+ if (!word) continue
136
+ if (/^[\x00-\x7f]+$/.test(word)) {
137
+ out.add(word)
138
+ continue
139
+ }
140
+ const chars = [...word]
141
+ if (chars.length === 1) out.add(chars[0])
142
+ for (let index = 0; index + 1 < chars.length; index += 1) out.add(`${chars[index]}${chars[index + 1]}`)
143
+ }
144
+ return out
145
+ }
146
+
147
+ /**
148
+ * @param {Set<string>} a first set
149
+ * @param {Set<string>} b second set
150
+ * @returns {number} Jaccard similarity 0..1
151
+ */
152
+ export function similarity(a, b) {
153
+ if (a.size === 0 || b.size === 0) return 0
154
+ let shared = 0
155
+ for (const item of a) if (b.has(item)) shared += 1
156
+ // Jaccard rather than the overlap coefficient: short memories that differ in
157
+ // one meaningful word must not read as near-identical.
158
+ return shared / (a.size + b.size - shared)
159
+ }
160
+
161
+ /**
162
+ * How much of the query a memory accounts for, ignoring its own length.
163
+ *
164
+ * This is deliberately *not* Jaccard. Retrieval and deduplication want opposite
165
+ * things: a query is short and a memory is long, so Jaccard caps out near
166
+ * query.length / memory.length — a perfect match against a 90-shingle memory
167
+ * with a 10-shingle query scores 0.11, indistinguishable from noise. That is
168
+ * fine for duplicate detection (which compares two memories of similar length)
169
+ * and useless for ranking, where a long relevant memory must still be able to
170
+ * win. Coverage answers "did this memory contain what was asked", which is the
171
+ * question recall is actually posing.
172
+ *
173
+ * @param {Set<string>} query query shingles
174
+ * @param {Set<string>} doc document shingles
175
+ * @returns {number} coverage 0..1
176
+ */
177
+ export function coverage(query, doc) {
178
+ if (query.size === 0 || doc.size === 0) return 0
179
+ let shared = 0
180
+ for (const item of query) if (doc.has(item)) shared += 1
181
+ return shared / query.size
182
+ }
183
+
184
+ /** @param {number} score 0..1 @param {any} thresholds thresholds @returns {string} state */
185
+ export function stateForScore(score, thresholds = DEFAULT_THRESHOLDS) {
186
+ if (score < thresholds.archive) return 'archived'
187
+ if (score < thresholds.compress) return 'compressed'
188
+ return 'active'
189
+ }
190
+
191
+ /**
192
+ * How many memories the evidence actually justifies injecting.
193
+ *
194
+ * A fixed budget forces a choice between two bad outcomes: spend the full
195
+ * allowance on a question that matched almost nothing, or cap it low and then
196
+ * pad weak evidence up to the cap anyway. The top score already says how strong
197
+ * the best match is, so it can also say how many matches are worth believing.
198
+ *
199
+ * The thresholds are the ones the recall probes actually produced: a strong
200
+ * match clears 0.6, a usable one 0.5, a marginal one 0.4. Below that the honest
201
+ * answer is one entry, not six.
202
+ *
203
+ * Returning `MAX_SAFE_INTEGER` means "the caller's own budget is the limit" —
204
+ * `select()` takes the minimum, so the configured `recallLimit` still governs.
205
+ *
206
+ * @param {number} topScore best fused rank in this recall
207
+ * @returns {number} an upper bound on how many entries to inject
208
+ */
209
+ export function injectionTier(topScore) {
210
+ // `!(x >= 0.4)` rather than `x < 0.4` so NaN, undefined, and null all take the
211
+ // conservative branch instead of slipping through as "strong".
212
+ if (!(topScore >= 0.4)) return 1
213
+ if (topScore >= 0.6) return Number.MAX_SAFE_INTEGER
214
+ if (topScore >= 0.5) return 4
215
+ return 3
216
+ }
217
+
218
+ /** @param {any} item ranked entry @returns {any} the trace-sized shape of it */
219
+ function summariseForTrace(item) {
220
+ return {
221
+ id: item.id,
222
+ scope: item.scope,
223
+ text: item.text,
224
+ rank: Number((item.rank ?? 0).toFixed(3)),
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Whether an entry passes the list filters.
230
+ *
231
+ * Both filters are optional and combine as AND, which is what a user means by
232
+ * "my error lessons from this week". Tag matching is exact and case-insensitive
233
+ * rather than substring: a substring match would make `dsh` also select
234
+ * `dsh-foo`, and silently widening a filter is how a short list becomes a long
235
+ * one again.
236
+ *
237
+ * @param {any} entry memory entry
238
+ * @param {{ tag?: string, sinceDays?: number, now?: number }} input filter
239
+ * @returns {boolean} whether it passes
240
+ */
241
+ function matchesFilter(entry, input) {
242
+ const tag = typeof input?.tag === 'string' ? input.tag.trim().toLowerCase() : ''
243
+ if (tag) {
244
+ const tags = Array.isArray(entry.tags) ? entry.tags : []
245
+ if (!tags.some(value => String(value).trim().toLowerCase() === tag)) return false
246
+ }
247
+ const sinceDays = Number(input?.sinceDays)
248
+ if (Number.isFinite(sinceDays) && sinceDays > 0) {
249
+ const now = typeof input.now === 'number' ? input.now : Date.now()
250
+ const cutoff = now - sinceDays * 86400000
251
+ // "Recent" means the entry was written or last used inside the window:
252
+ // an old fact that keeps being recalled is still current.
253
+ const when = Math.max(Number(entry.createdAt ?? 0), Number(entry.lastAccessAt ?? 0))
254
+ if (when < cutoff) return false
255
+ }
256
+ return true
257
+ }
258
+
259
+ /**
260
+ * Four-factor importance.
261
+ *
262
+ * @param {any} entry memory entry
263
+ * @param {{ maxAccess: number, uniqueness: number, now?: number, weights?: any, decayDays?: number }} context scoring context
264
+ * @returns {{ score: number, factors: any }} score and its parts
265
+ */
266
+ export function scoreEntry(entry, context) {
267
+ const weights = { ...DEFAULT_WEIGHTS, ...(context.weights ?? {}) }
268
+ const now = context.now ?? Date.now()
269
+ const decayDays = context.decayDays ?? DEFAULTS.decayDays
270
+ const frequency = Math.log1p(entry.accessCount ?? 0) / Math.log1p(Math.max(1, context.maxAccess))
271
+ const days = Math.max(0, (now - (entry.lastAccessAt ?? entry.createdAt)) / 86400000)
272
+ // A freeze pins the recency clock: a frozen memory never decays away.
273
+ const recency = entry.frozen ? 1 : Math.exp(-days / decayDays)
274
+ const affect = clamp01(entry.affect ?? 0.4)
275
+ const uniqueness = clamp01(context.uniqueness ?? 1)
276
+ const score =
277
+ weights.frequency * clamp01(frequency) + weights.recency * recency + weights.affect * affect + weights.uniqueness * uniqueness
278
+ return {
279
+ score: Number(score.toFixed(4)),
280
+ factors: {
281
+ frequency: Number(clamp01(frequency).toFixed(3)),
282
+ recency: Number(recency.toFixed(3)),
283
+ affect: Number(affect.toFixed(3)),
284
+ uniqueness: Number(uniqueness.toFixed(3)),
285
+ },
286
+ }
287
+ }
288
+
289
+ /** @param {number} value input @returns {number} clamped 0..1 */
290
+ function clamp01(value) {
291
+ const number = Number(value)
292
+ if (!Number.isFinite(number)) return 0
293
+ return Math.min(1, Math.max(0, number))
294
+ }
295
+
296
+ /**
297
+ * Memory library: entries, scoring, decay, clustering, and recall.
298
+ */
299
+ export class MemoryEngine {
300
+ /**
301
+ * @param {object} options engine options
302
+ * @param {string} options.root suite data directory
303
+ * @param {(message: string, error?: unknown) => void} [options.log] diagnostics
304
+ * @param {any} [options.config] plugin config
305
+ */
306
+ constructor({ root, log = () => {}, config = {} }) {
307
+ this.root = path.join(root, 'memory')
308
+ this.log = log
309
+ this.config = config
310
+ }
311
+
312
+ #file(scope, key) {
313
+ return path.join(this.root, scope === 'global' ? 'global.json' : path.join('workspaces', `${key}.json`))
314
+ }
315
+
316
+ #coldFile(scope, key) {
317
+ return path.join(this.root, 'cold', scope === 'global' ? 'global.json' : `${key}.json`)
318
+ }
319
+
320
+ /** @param {string} cwd working directory @returns {string} workspace key */
321
+ static workspaceKey(cwd) {
322
+ const normalized = String(cwd ?? '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
323
+ return sha256Text(normalized).slice(0, 16)
324
+ }
325
+
326
+ /** @param {string} scope scope @param {string} key workspace key @returns {Promise<any>} store */
327
+ async #readStore(scope, key) {
328
+ const data = await readJson(this.#file(scope, key))
329
+ if (!data || !Array.isArray(data.entries)) return { version: 2, scope, workspaceKey: key, entries: [] }
330
+ return data
331
+ }
332
+
333
+ async #writeStore(scope, key, store) {
334
+ await ensureDir(path.dirname(this.#file(scope, key)))
335
+ await writeJson(this.#file(scope, key), store)
336
+ // Remember the human-readable path behind each workspace key. The hash is
337
+ // one-way, so without this the settings page can only show a truncated key
338
+ // and cannot offer a picker for a workspace that has memories.
339
+ if (scope === 'workspace' && typeof store.cwd === 'string' && store.cwd) {
340
+ await this.#recordWorkspace(key, store.cwd)
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Record one workspace key to path mapping in the shared registry.
346
+ *
347
+ * @param {string} key workspace hash
348
+ * @param {string} cwd absolute workspace path
349
+ */
350
+ async #recordWorkspace(key, cwd) {
351
+ const file = path.join(this.root, 'workspaces.json')
352
+ const data = (await readJson(file)) ?? { version: 1, workspaces: {} }
353
+ if (!data.workspaces || typeof data.workspaces !== 'object') data.workspaces = {}
354
+ if (data.workspaces[key]?.cwd === cwd) return
355
+ data.workspaces[key] = { cwd, updatedAt: Date.now() }
356
+ await ensureDir(this.root)
357
+ await writeJson(file, data)
358
+ }
359
+
360
+ /**
361
+ * Every workspace that has stored memories, newest first.
362
+ *
363
+ * @returns {Promise<{ key: string, cwd: string, entries: number }[]>} known workspaces
364
+ */
365
+ async workspaces() {
366
+ const data = await readJson(path.join(this.root, 'workspaces.json'))
367
+ const known = data?.workspaces && typeof data.workspaces === 'object' ? data.workspaces : {}
368
+ const out = []
369
+ for (const [key, value] of Object.entries(known)) {
370
+ const cwd = typeof value?.cwd === 'string' ? value.cwd : undefined
371
+ if (!cwd) continue
372
+ const store = await this.#readStore('workspace', key)
373
+ out.push({ key, cwd, entries: store.entries.length, updatedAt: value?.updatedAt ?? 0 })
374
+ }
375
+ return out.sort((a, b) => b.updatedAt - a.updatedAt)
376
+ }
377
+
378
+ async #readCold(scope, key) {
379
+ const data = await readJson(this.#coldFile(scope, key))
380
+ if (!data || !Array.isArray(data.entries)) return { version: 2, scope, entries: [] }
381
+ return data
382
+ }
383
+
384
+ async #writeCold(scope, key, cold) {
385
+ await ensureDir(path.dirname(this.#coldFile(scope, key)))
386
+ await writeJson(this.#coldFile(scope, key), cold)
387
+ }
388
+
389
+ /** @returns {Promise<any>} engine settings */
390
+ async settings() {
391
+ const data = await readJson(path.join(this.root, 'settings.json'))
392
+ return {
393
+ implicit: data?.implicit !== false,
394
+ weights: { ...DEFAULT_WEIGHTS, ...(data?.weights ?? {}) },
395
+ thresholds: { ...DEFAULT_THRESHOLDS, ...(data?.thresholds ?? {}) },
396
+ decayDays: typeof data?.decayDays === 'number' ? data.decayDays : DEFAULTS.decayDays,
397
+ ...data,
398
+ }
399
+ }
400
+
401
+ /** @param {any} patch settings patch @returns {Promise<any>} saved settings */
402
+ async updateSettings(patch) {
403
+ const current = await this.settings()
404
+ const next = {
405
+ ...current,
406
+ ...patch,
407
+ weights: { ...current.weights, ...(patch.weights ?? {}) },
408
+ thresholds: { ...current.thresholds, ...(patch.thresholds ?? {}) },
409
+ }
410
+ await ensureDir(this.root)
411
+ await writeJson(path.join(this.root, 'settings.json'), next)
412
+ return next
413
+ }
414
+
415
+ /**
416
+ * Store one memory.
417
+ *
418
+ * @param {object} input memory input
419
+ * @param {'global' | 'workspace'} input.scope target store
420
+ * @param {string} [input.cwd] working directory (required for workspace scope)
421
+ * @param {string} input.text the memory itself
422
+ * @param {'fact' | 'rule'} [input.kind] entry kind
423
+ * @param {string[]} [input.tags] tags
424
+ * @param {string} [input.source] how it got here: 'explicit' | 'implicit' | 'user' | 'cluster' | 'rule'
425
+ * @param {number} [input.affect] 0..1 emotional/priority intensity
426
+ * @param {boolean} [input.frozen] pin it
427
+ * @param {string} [input.sessionId] originating session
428
+ * @returns {Promise<{ entry: any, duplicateOf?: any }>} stored entry
429
+ */
430
+ async remember(input) {
431
+ const scope = input.scope === 'global' ? 'global' : 'workspace'
432
+ const text = String(input.text ?? '').trim()
433
+ if (!text) throw new Error('memory text is empty')
434
+ // A fact, not a document. Refusing is deliberate: truncating would store a
435
+ // sentence that reads as complete while having lost its end, and a memory
436
+ // that is subtly wrong is worse than one that was never written.
437
+ const maxChars = this.config.maxMemoryChars ?? DEFAULTS.maxMemoryChars
438
+ if (maxChars > 0 && text.length > maxChars) {
439
+ throw new Error(
440
+ `memory text is ${text.length} characters; the limit is ${maxChars}. ` +
441
+ 'Split it into separate facts instead of storing one long block.',
442
+ )
443
+ }
444
+ if (scope === 'workspace' && !input.cwd) throw new Error('workspace memory needs a working directory')
445
+ const key = scope === 'workspace' ? MemoryEngine.workspaceKey(input.cwd) : 'global'
446
+ const store = await this.#readStore(scope, key)
447
+ // Keep the readable path on the store so the registry can name this workspace.
448
+ if (scope === 'workspace' && typeof input.cwd === 'string' && input.cwd) store.cwd = input.cwd
449
+
450
+ const factKey = storedFactKey(input.factKey)
451
+ const incoming = shingles(text)
452
+ let best
453
+ let bestScore = 0
454
+ for (const entry of store.entries) {
455
+ // Identical text is a duplicate by definition, and the shingle comparison
456
+ // alone cannot see that: a body made only of punctuation shingles to the
457
+ // empty set, so two identical punctuation-only records scored 0 against
458
+ // each other. That is how 154 writes of the same placeholder became 27
459
+ // separate records instead of reinforcing one.
460
+ if (entry.text === text) {
461
+ best = entry
462
+ bestScore = 1
463
+ break
464
+ }
465
+ const score = similarity(incoming, shingles(entry.text))
466
+ if (score > bestScore) {
467
+ bestScore = score
468
+ best = entry
469
+ }
470
+ }
471
+ const duplicateSimilarity = this.config.duplicateSimilarity ?? DEFAULTS.duplicateSimilarity
472
+ if (best && bestScore >= duplicateSimilarity) {
473
+ // Reinforce instead of duplicating: the existing memory gains a hit, and
474
+ // its decay clock restarts because it was clearly used.
475
+ best.accessCount = (best.accessCount ?? 0) + 1
476
+ best.lastAccessAt = Date.now()
477
+ best.affect = Math.max(best.affect ?? 0.4, clamp01(input.affect ?? 0.4))
478
+ // Fold in any tags the caller supplied. A memory stored before a domain
479
+ // taxonomy existed — or stored by a path that did not pass tags — would
480
+ // otherwise stay unfiled forever, because reinforcement never rewrites
481
+ // the record.
482
+ if (Array.isArray(input.tags)) {
483
+ const merged = new Set(best.tags ?? [])
484
+ for (const tag of input.tags) {
485
+ if (typeof tag === 'string' && tag.trim()) merged.add(tag.trim())
486
+ }
487
+ best.tags = [...merged]
488
+ }
489
+ // Restating a fact that had been superseded revives that record rather
490
+ // than inserting a second one: the id is derived from the text, so a fresh
491
+ // insert would collide with the stored record. Reviving also keeps the
492
+ // original creation history instead of pretending the fact is brand new.
493
+ if (best.supersededBy) {
494
+ delete best.supersededBy
495
+ delete best.supersededAt
496
+ best.createdAt = Date.now()
497
+ }
498
+ // An entry stored before the caller supplied keys can still be filed.
499
+ if (!best.factKey && factKey) best.factKey = factKey
500
+ await this.#writeStore(scope, key, store)
501
+ return { entry: best, duplicateOf: best }
502
+ }
503
+
504
+ const entry = {
505
+ id: `mem_${shortId(text)}`,
506
+ scope,
507
+ workspaceKey: key,
508
+ text,
509
+ kind: input.kind === 'rule' ? 'rule' : 'fact',
510
+ tags: Array.isArray(input.tags) ? input.tags.filter(tag => typeof tag === 'string') : [],
511
+ affect: clamp01(input.affect ?? 0.4),
512
+ frozen: input.frozen === true,
513
+ state: 'active',
514
+ accessCount: 1,
515
+ createdAt: Date.now(),
516
+ lastAccessAt: Date.now(),
517
+ sessionId: input.sessionId ?? null,
518
+ source: input.source ?? 'user',
519
+ digest: sha256Text(text).slice(0, 16),
520
+ // The subject-and-aspect this entry speaks about, when the caller gave
521
+ // one. Same key means same fact, so only the newest survives recall.
522
+ factKey,
523
+ clusterId: null,
524
+ summary: null,
525
+ }
526
+ store.entries.push(entry)
527
+ // Supersede older entries that talk about the same fact. Done in the same
528
+ // pass as the write so the store is never briefly inconsistent — and only
529
+ // when the new entry carries a key, so an unkeyed entry cannot retire
530
+ // anything.
531
+ if (factKey) {
532
+ store.entries = applySupersession(store.entries, { now: entry.createdAt })
533
+ }
534
+ await this.#writeStore(scope, key, store)
535
+ return { entry }
536
+ }
537
+
538
+ /**
539
+ * @param {object} input query input
540
+ * @param {string} [input.cwd] working directory
541
+ * @param {string} [input.query] the current question, used to rank recall
542
+ * @param {number} [input.limit] how many entries to return
543
+ * @param {boolean} [input.includeArchived] also look in cold storage
544
+ * @param {boolean} [input.touch] count this as an access (default true)
545
+ * @returns {Promise<any[]>} ranked memories, workspace entries ahead of global ones
546
+ */
547
+ async recall(input = {}) {
548
+ const settings = await this.settings()
549
+ const limit = typeof input.limit === 'number' ? input.limit : DEFAULTS.recallLimit
550
+ const query = shingles(input.query ?? '')
551
+ const results = []
552
+
553
+ const collect = async scope => {
554
+ const key = scope === 'workspace' ? MemoryEngine.workspaceKey(input.cwd) : 'global'
555
+ const store = await this.#readStore(scope, key)
556
+ // Superseded entries are history, not candidates: a fact whose value moved
557
+ // on must not be recalled alongside its replacement, and leaving them in
558
+ // would also dilute the uniqueness sample below.
559
+ const live = store.entries.filter(entry => entry.state !== 'archived' && !entry.supersededBy)
560
+ const maxAccess = Math.max(1, ...live.map(entry => entry.accessCount ?? 0))
561
+ for (const entry of live) {
562
+ const own = shingles(entry.text)
563
+ let uniqueness = 1
564
+ for (const other of live) {
565
+ if (other.id === entry.id) continue
566
+ uniqueness = Math.min(uniqueness, 1 - similarity(own, shingles(other.text)))
567
+ }
568
+ const { score, factors } = scoreEntry(entry, {
569
+ maxAccess,
570
+ uniqueness,
571
+ weights: settings.weights,
572
+ decayDays: settings.decayDays,
573
+ })
574
+ const relevance = query.size === 0 ? 0 : similarity(query, own)
575
+ results.push({
576
+ ...entry,
577
+ scope,
578
+ score,
579
+ factors,
580
+ relevance: Number(relevance.toFixed(3)),
581
+ // Whether this entry shares anything at all with the question. The
582
+ // rerank pass below recomputes it from the lexical routes; without a
583
+ // question every entry trivially "matches".
584
+ matched: true,
585
+ // Workspace memories outrank global ones on a tie, per the spec.
586
+ rank: relevance * 0.6 + score * 0.35 + (scope === 'workspace' ? 0.05 : 0),
587
+ })
588
+ }
589
+ if (input.includeArchived) {
590
+ const cold = await this.#readCold(scope, key)
591
+ for (const entry of cold.entries) {
592
+ // Cold storage is the history view, so superseded entries are kept
593
+ // here — that is where a user goes to see what a fact used to say.
594
+ results.push({ ...entry, scope, archived: true, score: 0, factors: {}, relevance: 0, rank: 0 })
595
+ }
596
+ }
597
+ }
598
+
599
+ await collect('workspace')
600
+ await collect('global')
601
+
602
+ // Rank by two lexical routes fused by rank, not by one coverage score.
603
+ //
604
+ // History: this used Jaccard, which is symmetric and therefore punishes a
605
+ // memory for being longer than the query; it was replaced by query-normalised
606
+ // coverage, which fixed the long-memory penalty but left a single signal with
607
+ // one blind spot. A single route cannot outvote itself, so a paraphrase that
608
+ // shares no bigram with the query scored 0 and the memory became unreachable
609
+ // no matter how relevant it was.
610
+ //
611
+ // The two routes now fail differently on purpose: bigrams are precise and
612
+ // blind to partial word overlap, unigrams are loose and noisy. RRF fuses them
613
+ // by rank because their scores are on incomparable scales.
614
+ //
615
+ // RRF has one failure mode that matters here: it only sees ranks, so a
616
+ // document that matched *nothing* still occupies a rank and collects a share
617
+ // of the fusion. With a short candidate list that is catastrophic — an
618
+ // entirely unrelated memory ranked second of two scored 0.97 while the only
619
+ // real match scored 0.93. Scores of zero are therefore dropped before fusion,
620
+ // so only documents that actually matched compete for rank.
621
+ if (query.size > 0) {
622
+ const live = results.filter(item => !item.archived)
623
+ const documents = live.map(item => tokens(item.text))
624
+ const looseDocuments = live.map(item => unigrams(item.text))
625
+ const ids = live.map(item => item.id)
626
+
627
+ const preciseScores = bm25(tokens(input.query ?? ''), documents, {
628
+ k1: this.config.bm25K1 ?? DEFAULTS.bm25K1,
629
+ b: this.config.bm25B ?? DEFAULTS.bm25B,
630
+ })
631
+ const looseScores = live.map((item, index) =>
632
+ weightedCoverage(unigrams(input.query ?? ''), looseDocuments[index], looseDocuments),
633
+ )
634
+
635
+ const preciseRanking = rankIds(
636
+ ids.filter((id, index) => preciseScores[index] > 0),
637
+ new Map(ids.map((id, index) => [id, preciseScores[index]])),
638
+ )
639
+ const looseRanking = rankIds(
640
+ ids.filter((id, index) => looseScores[index] > 0),
641
+ new Map(ids.map((id, index) => [id, looseScores[index]])),
642
+ )
643
+
644
+ // Third route: real word segmentation against a general-purpose Chinese
645
+ // word list. Character bigrams cannot tell 「车」 inside 「车站」 from 「车」
646
+ // standing alone, so they match across word boundaries; a word list fixes
647
+ // exactly that. It is weaker on anything outside the list — personal
648
+ // names, code identifiers, new coinages — which is why it joins the fusion
649
+ // instead of replacing a route. A third opinion only helps when it is
650
+ // wrong in different places.
651
+ const wordList = cjkWordList()
652
+ // `wordRoute: false` runs the two lexical routes alone, which is exactly
653
+ // what the ablation in tools/ablate-word-route.mjs compares against.
654
+ const wordsReady = this.config.wordRoute !== false && isLoaded(wordList)
655
+ const wordScores = wordsReady
656
+ ? bm25(
657
+ wordTokens(input.query ?? '', wordList),
658
+ live.map(item => wordTokens(item.text, wordList)),
659
+ {
660
+ k1: this.config.bm25K1 ?? DEFAULTS.bm25K1,
661
+ b: this.config.bm25B ?? DEFAULTS.bm25B,
662
+ },
663
+ )
664
+ : []
665
+ const wordRanking = wordsReady
666
+ ? rankIds(
667
+ ids.filter((id, index) => wordScores[index] > 0),
668
+ new Map(ids.map((id, index) => [id, wordScores[index]])),
669
+ )
670
+ : []
671
+
672
+ const fused = reciprocalRankFusion(
673
+ [preciseRanking, looseRanking, wordRanking],
674
+ this.config.rrfK ?? DEFAULTS.rrfK,
675
+ )
676
+
677
+ for (let index = 0; index < live.length; index += 1) {
678
+ const item = live[index]
679
+ const bm25Score = preciseScores[index]
680
+ const wordScore = wordScores[index] ?? 0
681
+ item.bm25 = Number(bm25Score.toFixed(3))
682
+ item.coverage = Number(looseScores[index].toFixed(3))
683
+ item.words = Number(wordScore.toFixed(3))
684
+ item.matched = bm25Score > 0 || looseScores[index] > 0 || wordScore > 0
685
+ item.rank =
686
+ (fused.get(item.id) ?? 0) * 0.75 + item.score * 0.2 + (item.scope === 'workspace' ? 0.05 : 0)
687
+ }
688
+ }
689
+
690
+ // With no query every rank is 0, so this only orders the importance-only
691
+ // case; with a query the coverage ranks are already in place.
692
+ results.sort((a, b) => b.rank - a.rank)
693
+ const top = results.slice(0, limit)
694
+ if (input.touch !== false) await this.#touch(top, input.cwd)
695
+ return top
696
+ }
697
+
698
+ /**
699
+ * Decide what actually gets injected into the prompt.
700
+ *
701
+ * `recall()` answers "rank everything", which is what the settings page, the
702
+ * probes, and the export need. Injection needs a different answer: how much of
703
+ * that ranking is worth spending context on. Always filling `recallLimit`
704
+ * slots is how a weak question drags six unrelated memories into the prompt —
705
+ * the count then claims six things are relevant when the evidence says
706
+ * otherwise. It also costs tokens on every single turn.
707
+ *
708
+ * So the injected count is tiered by the top score: strong evidence gets the
709
+ * full budget, weaker evidence gets progressively fewer slots, and a question
710
+ * that barely matches anything gets exactly one. `omitted` then reports, for
711
+ * every candidate left out, which rule left it out.
712
+ *
713
+ * That second half matters as much as the first. A memory system that silently
714
+ * decides not to recall something is indistinguishable from a broken one — the
715
+ * user cannot tell "it forgot" from "it judged this irrelevant". Naming the
716
+ * reason is the difference between a black box and a debuggable feature.
717
+ *
718
+ * @param {object} input same as recall, plus `tiered` (default true)
719
+ * @returns {Promise<{ items: any[], omitted: any[], effectiveLimit: number, topScore: number }>}
720
+ */
721
+ async select(input = {}) {
722
+ const limit = typeof input.limit === 'number' ? input.limit : DEFAULTS.recallLimit
723
+ const tiered = input.tiered !== false
724
+ const ranked = await this.recall({ ...input, limit: Number.MAX_SAFE_INTEGER, touch: false })
725
+ const asked = String(input.query ?? '').trim().length > 0
726
+
727
+ const eligible = []
728
+ const omitted = []
729
+ for (const item of ranked) {
730
+ // With a question, only entries that share something with it are
731
+ // candidates. Without one, everything is a candidate on importance alone.
732
+ if (asked && item.matched !== true) omitted.push({ ...summariseForTrace(item), reason: 'no-match' })
733
+ else eligible.push(item)
734
+ }
735
+
736
+ const topScore = eligible.length > 0 ? Number(eligible[0].rank ?? 0) : 0
737
+ // Tiering only applies when a question was asked. With no question there is
738
+ // no match to be weak or strong — the ranking is importance alone, which
739
+ // never reaches the match thresholds — so gating on it would silently
740
+ // collapse the budget to a single entry for no reason.
741
+ const effectiveLimit = tiered && asked ? Math.min(limit, injectionTier(topScore)) : limit
742
+ const items = eligible.slice(0, effectiveLimit)
743
+ for (const item of eligible.slice(effectiveLimit)) {
744
+ omitted.push({ ...summariseForTrace(item), reason: 'capacity' })
745
+ }
746
+
747
+ const selection = {
748
+ items,
749
+ omitted,
750
+ effectiveLimit,
751
+ topScore: Number(topScore.toFixed(3)),
752
+ askedAt: Date.now(),
753
+ query: input.query ?? '',
754
+ }
755
+ // Kept so the settings page can show "what did the last recall pick, and
756
+ // what did it drop" without having to replay the query itself.
757
+ this.lastSelection = selection
758
+ if (input.touch !== false) await this.#touch(items, input.cwd)
759
+ return selection
760
+ }
761
+
762
+ /**
763
+ * Count one access per injected entry and refresh its decay clock.
764
+ *
765
+ * @param {any[]} items entries that were actually used
766
+ * @param {string} [cwd] workspace they were recalled for
767
+ */
768
+ async #touch(items, cwd) {
769
+ for (const item of items) {
770
+ if (item.archived) continue
771
+ const key = item.scope === 'workspace' ? MemoryEngine.workspaceKey(cwd) : 'global'
772
+ const store = await this.#readStore(item.scope, key)
773
+ const stored = store.entries.find(entry => entry.id === item.id)
774
+ if (!stored) continue
775
+ stored.accessCount = (stored.accessCount ?? 0) + 1
776
+ stored.lastAccessAt = Date.now()
777
+ await this.#writeStore(item.scope, key, store)
778
+ }
779
+ }
780
+
781
+ /**
782
+ * Score, decay, cluster, and abstract every store this engine owns.
783
+ *
784
+ * Every registered workspace is maintained, not just the one named here.
785
+ * Memory is stored per workspace, so maintaining only the most recent one left
786
+ * every other workspace permanently unmaintained — its entries were never
787
+ * scored, decayed, clustered, or summarised. The named `cwd` is still accepted
788
+ * (and included) so a caller that just wrote to a brand-new workspace maintains
789
+ * it before it has been registered.
790
+ *
791
+ * @param {string} [cwd] workspace to maintain in addition to the registered ones
792
+ * @returns {Promise<any>} what changed
793
+ */
794
+ async maintain(cwd) {
795
+ const settings = await this.settings()
796
+ const clusterSimilarity =
797
+ this.config.clusterSimilarity ?? settings.clusterSimilarity ?? DEFAULTS.clusterSimilarity
798
+ const clusterMinSize = this.config.clusterMinSize ?? settings.clusterMinSize ?? DEFAULTS.clusterMinSize
799
+ const ruleMinSize = this.config.ruleMinSize ?? settings.ruleMinSize ?? DEFAULTS.ruleMinSize
800
+ const report = { scored: 0, archived: 0, compressed: 0, clusters: 0, rules: 0, promoted: 0, workspaces: 0 }
801
+ // The same key list `needsMaintenance` checks, so the fingerprint this pass
802
+ // writes is the fingerprint the next check compares against.
803
+ const keys = await this.#maintenanceKeys(cwd)
804
+
805
+ for (const [scope, key] of keys) {
806
+ const store = await this.#readStore(scope, key)
807
+ if (store.entries.length === 0) continue
808
+ if (scope === 'workspace') report.workspaces += 1
809
+ const maxAccess = Math.max(1, ...store.entries.map(entry => entry.accessCount ?? 0))
810
+
811
+ // 1. Score every entry and apply the two thresholds.
812
+ for (const entry of store.entries) {
813
+ if (entry.frozen) {
814
+ entry.score = 1
815
+ continue
816
+ }
817
+ const own = shingles(entry.text)
818
+ let uniqueness = 1
819
+ for (const other of store.entries) {
820
+ if (other.id === entry.id) continue
821
+ uniqueness = Math.min(uniqueness, 1 - similarity(own, shingles(other.text)))
822
+ }
823
+ const { score, factors } = scoreEntry(entry, {
824
+ maxAccess,
825
+ uniqueness,
826
+ weights: settings.weights,
827
+ decayDays: settings.decayDays,
828
+ })
829
+ entry.score = score
830
+ entry.factors = factors
831
+ entry.updatedAt = Date.now()
832
+ report.scored += 1
833
+ const target = stateForScore(score, settings.thresholds)
834
+ if (entry.state === 'archived') continue
835
+ if (target === 'archived' && entry.state !== 'archived') {
836
+ await this.#archive(scope, key, store, entry)
837
+ report.archived += 1
838
+ } else if (target === 'compressed') {
839
+ entry.state = 'compressed'
840
+ } else {
841
+ entry.state = 'active'
842
+ }
843
+ }
844
+ await this.#writeStore(scope, key, store)
845
+
846
+ // 2. Cluster near-duplicates and compress them into one summary.
847
+ //
848
+ // A summary is itself an entry, so without the `summary` guard every pass
849
+ // re-clustered the summaries produced by the previous pass. Six passes
850
+ // produced six summaries of one sentence under six different cluster ids,
851
+ // because a summary carried `summaryId` but not `clusterId` and so passed
852
+ // the `!entry.clusterId` test that was meant to exclude it.
853
+ //
854
+ // Orphaned summaries (no live member left) are dropped first. Otherwise a
855
+ // summary outlives the members it summarised and keeps feeding itself
856
+ // back into the next pass.
857
+ // Archived members move to the cold store, so they must be counted too:
858
+ // a summary whose members all aged out is still a legitimate summary.
859
+ const cold = await this.#readCold(scope, key)
860
+ const liveMembers = new Set(
861
+ [...store.entries, ...cold.entries]
862
+ .filter(entry => entry.clusterId && !entry.summary)
863
+ .map(entry => entry.clusterId),
864
+ )
865
+ const orphans = store.entries.filter(entry => entry.summary && !liveMembers.has(entry.clusterId))
866
+ if (orphans.length > 0) {
867
+ store.entries = store.entries.filter(entry => !orphans.includes(entry))
868
+ report.orphans = (report.orphans ?? 0) + orphans.length
869
+ }
870
+ const clusterable = store.entries.filter(
871
+ entry => entry.state !== 'archived' && !entry.frozen && !entry.clusterId && !entry.summary,
872
+ )
873
+ const groups = []
874
+ for (const entry of clusterable) {
875
+ const own = shingles(entry.text)
876
+ const group = groups.find(candidate => candidate.some(member => similarity(own, shingles(member.text)) >= clusterSimilarity))
877
+ if (group) group.push(entry)
878
+ else groups.push([entry])
879
+ }
880
+ for (const group of groups) {
881
+ if (group.length < clusterMinSize) continue
882
+ const clusterId = `cluster_${shortId(group.map(entry => entry.id).join(':'))}`
883
+ const summary = group.map(entry => entry.text).join(';')
884
+ const summaryText = summary.length > 400 ? `${summary.slice(0, 400)}…` : summary
885
+ // The same text under a new cluster id is a duplicate, not a finding.
886
+ // Content decides identity here; the member ids only decide the cluster.
887
+ const existing = store.entries.find(entry => entry.summary && entry.text === summaryText)
888
+ if (existing) {
889
+ for (const entry of group) {
890
+ entry.clusterId = existing.clusterId
891
+ entry.summaryId = existing.id
892
+ await this.#archive(scope, key, store, entry)
893
+ }
894
+ report.compressed += group.length
895
+ continue
896
+ }
897
+ const summaryEntry = {
898
+ id: `sum_${shortId(clusterId)}`,
899
+ scope,
900
+ workspaceKey: key,
901
+ text: summaryText,
902
+ kind: 'fact',
903
+ tags: [...new Set(group.flatMap(entry => entry.tags ?? []))],
904
+ affect: Math.max(...group.map(entry => entry.affect ?? 0.4)),
905
+ frozen: false,
906
+ state: 'active',
907
+ accessCount: group.reduce((sum, entry) => sum + (entry.accessCount ?? 0), 0),
908
+ createdAt: Date.now(),
909
+ lastAccessAt: Date.now(),
910
+ source: 'cluster',
911
+ digest: sha256Text(summary).slice(0, 16),
912
+ clusterId,
913
+ summary: true,
914
+ }
915
+ for (const entry of group) {
916
+ entry.clusterId = clusterId
917
+ entry.summaryId = summaryEntry.id
918
+ await this.#archive(scope, key, store, entry)
919
+ }
920
+ store.entries.push(summaryEntry)
921
+ report.clusters += 1
922
+ report.compressed += group.length
923
+ }
924
+ await this.#writeStore(scope, key, store)
925
+
926
+ // 3. Abstract repeated tags into a rule-type memory.
927
+ const byTag = new Map()
928
+ for (const entry of store.entries) {
929
+ if (entry.state === 'archived' || entry.kind === 'rule' || entry.summary) continue
930
+ for (const tag of entry.tags ?? []) {
931
+ if (!byTag.has(tag)) byTag.set(tag, [])
932
+ byTag.get(tag).push(entry)
933
+ }
934
+ }
935
+ for (const [tag, group] of byTag) {
936
+ if (group.length < ruleMinSize) continue
937
+ // The identity of a rule is the set of memories it summarizes, not the
938
+ // group size at the moment it was built. A size-derived id made the same
939
+ // rule reappear under a fresh id every time the group grew or shrank,
940
+ // which filled the live store and the cold archive with duplicates.
941
+ const sourceIds = group
942
+ .map(entry => entry.id)
943
+ .sort()
944
+ .join(',')
945
+ const ruleId = `rule_${shortId(tag + sourceIds)}`
946
+ // The cold archive counts as "already exists". A rule that was clustered
947
+ // into a summary and then archived is gone from the live store, so a
948
+ // live-only check rebuilt it on the next pass; the rebuilt rule was then
949
+ // clustered into a fresh summary, and the pass after that rebuilt it
950
+ // again. That loop is what put one sentence in the library seven times.
951
+ const already = [...store.entries, ...cold.entries].some(
952
+ entry => entry.kind === 'rule' && (entry.id === ruleId || entry.ruleTag === tag),
953
+ )
954
+ if (already) continue
955
+ // Cap what a rule may absorb. Joining every member of a tag group turned
956
+ // a rule into a document — the library ended up with entries over three
957
+ // thousand characters, which are unusable as memories and expensive to
958
+ // inject. A rule states the recurring conclusion, so the newest few
959
+ // members are enough to state it; the members themselves stay in the
960
+ // library and remain individually recallable.
961
+ const maxRuleChars = this.config.maxMemoryChars ?? DEFAULTS.maxMemoryChars
962
+ const members = []
963
+ let length = `规则(${tag}):`.length
964
+ for (const entry of [...group].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0))) {
965
+ const addition = (members.length === 0 ? '' : ';') + entry.text
966
+ if (maxRuleChars > 0 && members.length > 0 && length + addition.length > maxRuleChars) break
967
+ members.push(entry)
968
+ length += addition.length
969
+ }
970
+ const rule = {
971
+ id: ruleId,
972
+ scope,
973
+ workspaceKey: key,
974
+ text: `规则(${tag}):${members.map(entry => entry.text).join(';')}`,
975
+ kind: 'rule',
976
+ tags: [tag],
977
+ affect: Math.max(...members.map(entry => entry.affect ?? 0.4)),
978
+ frozen: false,
979
+ state: 'active',
980
+ accessCount: members.length,
981
+ createdAt: Date.now(),
982
+ lastAccessAt: Date.now(),
983
+ source: 'rule',
984
+ ruleTag: tag,
985
+ // The digest must cover what the rule actually says, not just the tag:
986
+ // two rules from the same tag over different members are different
987
+ // rules, and a tag-only digest made them indistinguishable.
988
+ digest: sha256Text(`规则(${tag}):${members.map(entry => entry.text).join(';')}`).slice(0, 16),
989
+ clusterId: null,
990
+ summary: null,
991
+ }
992
+ store.entries.push(rule)
993
+ report.rules += 1
994
+ }
995
+ await this.#writeStore(scope, key, store)
996
+ }
997
+ // Record that a pass actually ran, and what it produced. Without this the
998
+ // settings page cannot distinguish "ran and found nothing to do" from
999
+ // "never ran at all", which is exactly how a silent timer hides a bug.
1000
+ //
1001
+ // The dirty signal is a fingerprint of the stored content, not a timestamp.
1002
+ // A pass writes entries (it scores them and may add rules), so any
1003
+ // timestamp it produces is newer than the pass that produced it and would
1004
+ // make every subsequent pass look necessary forever.
1005
+ const status = await this.status()
1006
+ await this.#writeStatus({
1007
+ ...status,
1008
+ lastMaintainedAt: Date.now(),
1009
+ lastMaintainedCwd: cwd ?? null,
1010
+ lastReport: report,
1011
+ lastFingerprint: await this.#fingerprint(keys),
1012
+ })
1013
+ return report
1014
+ }
1015
+
1016
+ /**
1017
+ * A stable fingerprint of what the given stores contain.
1018
+ *
1019
+ * Only identity and text participate, so rescoring or clustering the same
1020
+ * entries does not look like new input.
1021
+ *
1022
+ * @param {Array<[string, string]>} keys stores to inspect
1023
+ * @returns {Promise<string>} digest, or '' when nothing is stored
1024
+ */
1025
+ async #fingerprint(keys) {
1026
+ const parts = []
1027
+ for (const [scope, key] of keys) {
1028
+ const store = await this.#readStore(scope, key)
1029
+ const live = store.entries.filter(entry => entry.state !== 'archived')
1030
+ if (live.length === 0) continue
1031
+ const rows = live
1032
+ .map(entry => `${entry.id}:${entry.text}`)
1033
+ .sort()
1034
+ .join('\u0000')
1035
+ parts.push(`${scope}/${key}/${sha256Text(rows).slice(0, 16)}`)
1036
+ }
1037
+ return parts.join('|')
1038
+ }
1039
+
1040
+ /** @returns {Promise<any>} last maintenance bookkeeping */
1041
+ async status() {
1042
+ const data = await readJson(path.join(this.root, 'maintenance.json'))
1043
+ return {
1044
+ lastMaintainedAt: typeof data?.lastMaintainedAt === 'number' ? data.lastMaintainedAt : 0,
1045
+ lastMaintainedCwd: typeof data?.lastMaintainedCwd === 'string' ? data.lastMaintainedCwd : null,
1046
+ lastReport: data?.lastReport ?? null,
1047
+ lastFingerprint: typeof data?.lastFingerprint === 'string' ? data.lastFingerprint : '',
1048
+ }
1049
+ }
1050
+
1051
+ /** @param {any} next status patch @returns {Promise<any>} saved status */
1052
+ async #writeStatus(next) {
1053
+ await ensureDir(this.root)
1054
+ await writeJson(path.join(this.root, 'maintenance.json'), next)
1055
+ return next
1056
+ }
1057
+
1058
+ /**
1059
+ * Whether a maintenance pass is worth running.
1060
+ *
1061
+ * An idle Host should not wake up on a timer to rescore unchanged memory, so
1062
+ * the caller checks this first. A pass is due when something has changed since
1063
+ * the previous one, or when nothing has ever run.
1064
+ *
1065
+ * Every registered workspace is considered, not just the one named here.
1066
+ * Checking only the most recent workspace was a real defect: memory is stored
1067
+ * per workspace, so a workspace the user had not touched lately never entered
1068
+ * the fingerprint and therefore never got scored, decayed, clustered, or
1069
+ * summarised. A conversation in that workspace could run for a long time and
1070
+ * its memories would stay completely unmaintained — they existed, but nothing
1071
+ * ever compressed them or let them fade.
1072
+ *
1073
+ * @param {string} [cwd] workspace to consider (kept for callers that know one)
1074
+ * @returns {Promise<boolean>} true when a pass is due
1075
+ */
1076
+ async needsMaintenance(cwd) {
1077
+ const keys = await this.#maintenanceKeys(cwd)
1078
+ const fingerprint = await this.#fingerprint(keys)
1079
+ // Nothing stored at all means there is nothing to maintain.
1080
+ if (fingerprint === '') return false
1081
+ const status = await this.status()
1082
+ return fingerprint !== status.lastFingerprint
1083
+ }
1084
+
1085
+ /**
1086
+ * The stores a maintenance pass covers: the global store plus every workspace.
1087
+ *
1088
+ * Shared by `needsMaintenance` and `maintain` on purpose. When the two
1089
+ * computed their own key lists they could disagree — the pass would write a
1090
+ * fingerprint for one set of keys while the check computed another, so the
1091
+ * library stayed permanently "dirty" and every tick re-ran a full pass.
1092
+ *
1093
+ * @param {string} [cwd] extra workspace to include
1094
+ * @returns {Promise<Array<[string, string]>>} scope/key pairs
1095
+ */
1096
+ async #maintenanceKeys(cwd) {
1097
+ const keys = [['global', 'global']]
1098
+ const seen = new Set()
1099
+ for (const workspaceCwd of [...(await this.#registeredCwds()), ...(cwd ? [cwd] : [])]) {
1100
+ const key = MemoryEngine.workspaceKey(workspaceCwd)
1101
+ if (seen.has(key)) continue
1102
+ seen.add(key)
1103
+ keys.push(['workspace', key])
1104
+ }
1105
+ return keys
1106
+ }
1107
+
1108
+ /**
1109
+ * Every workspace directory this engine has ever written to.
1110
+ *
1111
+ * The registry exists because a workspace key is a one-way hash of the path —
1112
+ * the key alone cannot be turned back into a directory. Without this, a
1113
+ * maintenance pass can only ever see the workspace the caller happens to
1114
+ * name.
1115
+ *
1116
+ * @returns {Promise<string[]>} readable cwd paths
1117
+ */
1118
+ async #registeredCwds() {
1119
+ const data = await readJson(path.join(this.root, 'workspaces.json')).catch(() => undefined)
1120
+ const workspaces = data?.workspaces
1121
+ if (!workspaces || typeof workspaces !== 'object') return []
1122
+ const out = []
1123
+ for (const record of Object.values(workspaces)) {
1124
+ if (typeof record?.cwd === 'string' && record.cwd) out.push(record.cwd)
1125
+ }
1126
+ return out
1127
+ }
1128
+
1129
+ async #archive(scope, key, store, entry) {
1130
+ const cold = await this.#readCold(scope, key)
1131
+ const archived = { ...entry, state: 'archived', archivedAt: Date.now() }
1132
+ cold.entries = [...cold.entries.filter(item => item.id !== entry.id), archived]
1133
+ await this.#writeCold(scope, key, cold)
1134
+ store.entries = store.entries.filter(item => item.id !== entry.id)
1135
+ }
1136
+
1137
+ /**
1138
+ * @param {object} input query input
1139
+ * @param {string} [input.cwd] workspace
1140
+ * @param {'global' | 'workspace'} [input.scope] limit to one store
1141
+ * @param {string} [input.tag] only entries carrying this tag
1142
+ * @param {number} [input.sinceDays] only entries created or touched within N days
1143
+ * @returns {Promise<any[]>} every entry with its score
1144
+ */
1145
+ async list(input = {}) {
1146
+ const settings = await this.settings()
1147
+ const out = []
1148
+ const scopes = input.scope ? [input.scope] : SCOPES
1149
+ for (const scope of scopes) {
1150
+ const key = scope === 'workspace' ? MemoryEngine.workspaceKey(input.cwd) : 'global'
1151
+ const store = await this.#readStore(scope, key)
1152
+ const cold = await this.#readCold(scope, key)
1153
+ const maxAccess = Math.max(1, ...store.entries.map(entry => entry.accessCount ?? 0))
1154
+ for (const entry of store.entries) {
1155
+ const own = shingles(entry.text)
1156
+ let uniqueness = 1
1157
+ for (const other of store.entries) {
1158
+ if (other.id === entry.id) continue
1159
+ uniqueness = Math.min(uniqueness, 1 - similarity(own, shingles(other.text)))
1160
+ }
1161
+ const { score, factors } = scoreEntry(entry, {
1162
+ maxAccess,
1163
+ uniqueness,
1164
+ weights: settings.weights,
1165
+ decayDays: settings.decayDays,
1166
+ })
1167
+ out.push({ ...entry, scope, score, factors })
1168
+ }
1169
+ for (const entry of cold.entries) out.push({ ...entry, scope, score: 0, factors: {}, state: 'archived', archived: true })
1170
+ }
1171
+ // Filtering happens here rather than in the caller so the settings page and
1172
+ // the export both see the same set for the same query. A library that grows
1173
+ // past a screenful is unusable without it — the observed complaint was
1174
+ // exactly that: too many entries to be worth reading.
1175
+ const filtered = out.filter(entry => matchesFilter(entry, input))
1176
+ return filtered.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
1177
+ }
1178
+
1179
+ /**
1180
+ * @param {object} input patch input
1181
+ * @param {string} [input.cwd] workspace
1182
+ * @param {'global' | 'workspace'} [input.scope] store
1183
+ * @param {string} input.id entry id
1184
+ * @param {{ frozen?: boolean, touch?: boolean, text?: string }} [input.patch] changes
1185
+ * @returns {Promise<any | undefined>} updated entry
1186
+ */
1187
+ async update(input) {
1188
+ const scope = input.scope === 'global' ? 'global' : 'workspace'
1189
+ const key = scope === 'workspace' ? MemoryEngine.workspaceKey(input.cwd) : 'global'
1190
+ const store = await this.#readStore(scope, key)
1191
+ const entry = store.entries.find(item => item.id === input.id)
1192
+ if (!entry) {
1193
+ const cold = await this.#readCold(scope, key)
1194
+ const archived = cold.entries.find(item => item.id === input.id)
1195
+ if (!archived) return undefined
1196
+ if (input.patch?.frozen) {
1197
+ // Freezing an archived memory restores it to the live pool.
1198
+ const restored = { ...archived, state: 'active', frozen: true, lastAccessAt: Date.now() }
1199
+ cold.entries = cold.entries.filter(item => item.id !== input.id)
1200
+ await this.#writeCold(scope, key, cold)
1201
+ store.entries.push(restored)
1202
+ await this.#writeStore(scope, key, store)
1203
+ return restored
1204
+ }
1205
+ return archived
1206
+ }
1207
+ if (typeof input.patch?.frozen === 'boolean') entry.frozen = input.patch.frozen
1208
+ if (input.patch?.touch) entry.lastAccessAt = Date.now()
1209
+ if (typeof input.patch?.text === 'string' && input.patch.text.trim()) entry.text = input.patch.text.trim()
1210
+ await this.#writeStore(scope, key, store)
1211
+ return entry
1212
+ }
1213
+
1214
+ /**
1215
+ * @param {object} input delete input
1216
+ * @param {string} [input.cwd] workspace
1217
+ * @param {'global' | 'workspace'} [input.scope] store
1218
+ * @param {string} [input.id] entry id; omitted clears the cold archive
1219
+ * @returns {Promise<{ removed: number }>} outcome
1220
+ */
1221
+ async remove(input) {
1222
+ let removed = 0
1223
+ const scopes = input.scope ? [input.scope] : SCOPES
1224
+ for (const scope of scopes) {
1225
+ const key = scope === 'workspace' ? MemoryEngine.workspaceKey(input.cwd) : 'global'
1226
+ if (input.id) {
1227
+ const store = await this.#readStore(scope, key)
1228
+ const before = store.entries.length
1229
+ store.entries = store.entries.filter(item => item.id !== input.id)
1230
+ await this.#writeStore(scope, key, store)
1231
+ const cold = await this.#readCold(scope, key)
1232
+ const coldBefore = cold.entries.length
1233
+ cold.entries = cold.entries.filter(item => item.id !== input.id)
1234
+ await this.#writeCold(scope, key, cold)
1235
+ removed += before - store.entries.length + coldBefore - cold.entries.length
1236
+ continue
1237
+ }
1238
+ const cold = await this.#readCold(scope, key)
1239
+ removed += cold.entries.length
1240
+ await this.#writeCold(scope, key, { version: 2, scope, entries: [] })
1241
+ }
1242
+ return { removed }
1243
+ }
1244
+
1245
+ /**
1246
+ * @param {object} [input] export input
1247
+ * @param {string} [input.cwd] workspace
1248
+ * @returns {Promise<any>} everything, for a portable backup
1249
+ */
1250
+ async export(input = {}) {
1251
+ const out = { version: 2, exportedAt: Date.now(), global: {}, workspace: {} }
1252
+ const read = async (scope, key) => ({
1253
+ live: await this.#readStore(scope, key),
1254
+ cold: await this.#readCold(scope, key),
1255
+ })
1256
+ out.global = await read('global', 'global')
1257
+ if (input.cwd) out.workspace = await read('workspace', MemoryEngine.workspaceKey(input.cwd))
1258
+ return out
1259
+ }
1260
+
1261
+ /**
1262
+ * Pending implicit suggestions: extracted but not yet confirmed by the user.
1263
+ *
1264
+ * @param {string} [cwd] workspace filter
1265
+ * @returns {Promise<any[]>} suggestions
1266
+ */
1267
+ async pending(cwd) {
1268
+ const data = await readJson(path.join(this.root, 'pending.json'))
1269
+ const items = Array.isArray(data?.items) ? data.items : []
1270
+ return items.filter(item => !cwd || item.cwd === cwd || item.scope === 'global')
1271
+ }
1272
+
1273
+ /**
1274
+ * @param {any} suggestion suggestion to queue
1275
+ * @returns {Promise<any>} stored suggestion
1276
+ */
1277
+ async suggest(suggestion) {
1278
+ const file = path.join(this.root, 'pending.json')
1279
+ const data = (await readJson(file)) ?? { version: 1, items: [] }
1280
+ const items = Array.isArray(data.items) ? data.items : []
1281
+ const digest = sha256Text(String(suggestion.text ?? '')).slice(0, 16)
1282
+ if (items.some(item => item.digest === digest)) return undefined
1283
+ const item = {
1284
+ id: `sug_${shortId(digest)}`,
1285
+ digest,
1286
+ text: String(suggestion.text ?? '').trim(),
1287
+ scope: suggestion.scope === 'global' ? 'global' : 'workspace',
1288
+ cwd: suggestion.cwd ?? null,
1289
+ tags: suggestion.tags ?? [],
1290
+ affect: clamp01(suggestion.affect ?? 0.5),
1291
+ reason: suggestion.reason ?? 'implicit',
1292
+ sessionId: suggestion.sessionId ?? null,
1293
+ createdAt: Date.now(),
1294
+ }
1295
+ if (!item.text) return undefined
1296
+ items.push(item)
1297
+ await ensureDir(this.root)
1298
+ await writeJson(file, { version: 1, items })
1299
+ return item
1300
+ }
1301
+
1302
+ /** @param {string} id suggestion id @param {boolean} accept keep or drop */
1303
+ async settle(id, accept) {
1304
+ const file = path.join(this.root, 'pending.json')
1305
+ const data = (await readJson(file)) ?? { version: 1, items: [] }
1306
+ const item = (data.items ?? []).find(entry => entry.id === id)
1307
+ data.items = (data.items ?? []).filter(entry => entry.id !== id)
1308
+ await writeJson(file, data)
1309
+ if (!item || !accept) return { stored: false }
1310
+ const { entry } = await this.remember({
1311
+ scope: item.scope,
1312
+ cwd: item.cwd,
1313
+ text: item.text,
1314
+ tags: item.tags,
1315
+ affect: item.affect,
1316
+ source: 'implicit',
1317
+ sessionId: item.sessionId,
1318
+ })
1319
+ return { stored: true, entry }
1320
+ }
1321
+ }
1322
+
1323
+ export { DEFAULTS as MEMORY_DEFAULTS }