dsh-memory-ledger 0.0.0-stage → 1.0.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.
package/store.js ADDED
@@ -0,0 +1,895 @@
1
+ /**
2
+ * File-backed ledger for the `dsh-memory-ledger` DSH bundle.
3
+ *
4
+ * Two kinds of record live in one JSON file per scope:
5
+ *
6
+ * - `fact` — a stable user or project setting ("I am an author", "pen name is X").
7
+ * - `lesson` — a verified problem -> solution pair worth reusing instead of
8
+ * re-burning tokens on the same investigation.
9
+ *
10
+ * It also keeps mechanically captured *incidents*: signatures of failed tool
11
+ * calls. Repeats of one signature are counted without any model involvement,
12
+ * so a recurring failure becomes visible in the prompt even before the model
13
+ * decides to write a lesson about it.
14
+ *
15
+ * Retrieval is a classic vector-space model: BM25 term scoring plus a
16
+ * TF-IDF sparse vector cosine over the same terms. Terms are ASCII words and
17
+ * CJK character bigrams, so Chinese works without a tokenizer. This is not a
18
+ * neural embedding — see `docs/DETAIL.md`.
19
+ *
20
+ * Everything here is plain data in, plain data out, so `test/store.test.mjs`
21
+ * can exercise it without a Harness.
22
+ */
23
+
24
+ import { createHash, randomUUID } from 'node:crypto'
25
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
26
+ import { dirname } from 'node:path'
27
+
28
+ /** Schema version of the on-disk file. */
29
+ export const FILE_VERSION = 2
30
+
31
+ /** Retrieval modes accepted by {@link MemoryStore.search}. */
32
+ export const SEARCH_MODES = ['hybrid', 'keyword', 'vector']
33
+
34
+ /** Maximum characters of one stored title, body and message kept in the file. */
35
+ const MAX_TITLE = 200
36
+ const MAX_BODY = 4000
37
+ const MAX_MESSAGE = 300
38
+ const MAX_REASON = 400
39
+
40
+ const KINDS = new Set(['fact', 'lesson'])
41
+
42
+ function emptyState() {
43
+ return { version: FILE_VERSION, entries: [], incidents: [] }
44
+ }
45
+
46
+ function nowIso() {
47
+ return new Date().toISOString()
48
+ }
49
+
50
+ function clampText(value, limit) {
51
+ const text = typeof value === 'string' ? value.trim() : ''
52
+ if (text.length <= limit) return text
53
+ return `${text.slice(0, limit - 1)}…`
54
+ }
55
+
56
+ function truncate(value, limit) {
57
+ const text = String(value ?? '')
58
+ if (text.length <= limit) return text
59
+ return `${text.slice(0, Math.max(0, limit - 1))}…`
60
+ }
61
+
62
+ /**
63
+ * Split text into retrieval terms: ASCII words of two or more characters and
64
+ * CJK character bigrams (single characters when a run is one character long).
65
+ * @param {unknown} value Any text.
66
+ * @returns {string[]} Terms in order of appearance.
67
+ */
68
+ export function tokenize(value) {
69
+ const text = String(value ?? '').toLowerCase()
70
+ const terms = []
71
+ for (const match of text.matchAll(/[a-z0-9][a-z0-9_.\-+]{1,}/gu)) terms.push(match[0])
72
+ for (const match of text.matchAll(/[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]+/gu)) {
73
+ const run = match[0]
74
+ if (run.length === 1) {
75
+ terms.push(run)
76
+ continue
77
+ }
78
+ for (let index = 0; index < run.length - 1; index += 1) terms.push(run.slice(index, index + 2))
79
+ }
80
+ return terms
81
+ }
82
+
83
+ /**
84
+ * Parse a free-form tag list ("filesystem, powershell") into normalized tags.
85
+ * @param {unknown} value Comma, newline, or whitespace separated tags.
86
+ * @returns {string[]} Unique lowercase tags.
87
+ */
88
+ export function parseTags(value) {
89
+ if (Array.isArray(value)) return [...new Set(value.flatMap(item => parseTags(item)))]
90
+ if (typeof value !== 'string') return []
91
+ return [...new Set(
92
+ value
93
+ .split(/[,,;;\s]+/u)
94
+ .map(tag => tag.trim().toLowerCase())
95
+ .filter(tag => tag.length > 0 && tag.length <= 40),
96
+ )]
97
+ }
98
+
99
+ /**
100
+ * Collapse an error message into a stable signature so that the same failure
101
+ * reported with different ids, numbers, paths, or quoting counts as one
102
+ * incident.
103
+ * @param {string} tool Tool name that failed.
104
+ * @param {string} message Error text as the model saw it.
105
+ * @returns {{ signature: string, normalized: string }}
106
+ */
107
+ export function signatureOf(tool, message) {
108
+ const normalized = String(message ?? '')
109
+ .toLowerCase()
110
+ .replace(/[a-f0-9]{8,}/gu, '<hex>')
111
+ .replace(/\b\d+(?:[.:]\d+)*\b/gu, '<n>')
112
+ .replace(/(?:[a-z]:)?[\\/][^\s'"`,;]+/giu, '<path>')
113
+ .replace(/["'`][^"'`]{0,120}["'`]/gu, '<q>')
114
+ .replace(/\s+/gu, ' ')
115
+ .trim()
116
+ .slice(0, 160)
117
+ const signature = createHash('sha1')
118
+ .update(`${String(tool ?? '')}\u0000${normalized}`)
119
+ .digest('hex')
120
+ .slice(0, 10)
121
+ return { signature, normalized }
122
+ }
123
+
124
+ /** Sort entries the way the prompt wants them: pinned, then proven, then fresh. */
125
+ function compareEntries(a, b) {
126
+ if (Boolean(a.pinned) !== Boolean(b.pinned)) return a.pinned ? -1 : 1
127
+ const occurrences = (b.occurrences ?? 0) - (a.occurrences ?? 0)
128
+ if (occurrences !== 0) return occurrences
129
+ return String(b.updatedAt ?? '').localeCompare(String(a.updatedAt ?? ''))
130
+ }
131
+
132
+ function scoreSubstring(entry, terms) {
133
+ const title = String(entry.title ?? '').toLowerCase()
134
+ const key = String(entry.key ?? '').toLowerCase()
135
+ const body = String(entry.body ?? '').toLowerCase()
136
+ const tags = (entry.tags ?? []).join(' ').toLowerCase()
137
+ let score = 0
138
+ for (const term of terms) {
139
+ let hit = 0
140
+ if (title.includes(term)) hit += 4
141
+ if (key.includes(term)) hit += 4
142
+ if (tags.includes(term)) hit += 3
143
+ if (body.includes(term)) hit += 1
144
+ if (hit === 0) return 0
145
+ score += hit
146
+ }
147
+ return score
148
+ }
149
+
150
+ /**
151
+ * Normalize the provenance block of one entry: which project it was recorded
152
+ * from, in which session, and when.
153
+ * @param {unknown} raw
154
+ * @returns {{ cwd: string, label: string, sessionId: string, at: string } | null}
155
+ */
156
+ function normalizeOrigin(raw) {
157
+ if (!raw || typeof raw !== 'object') return null
158
+ const source = /** @type {Record<string, unknown>} */ (raw)
159
+ const cwd = typeof source.cwd === 'string' ? source.cwd.trim() : ''
160
+ const label = typeof source.label === 'string' ? source.label.trim() : ''
161
+ if (cwd === '' && label === '') return null
162
+ return {
163
+ cwd,
164
+ label: label !== '' ? label : cwd,
165
+ sessionId: typeof source.sessionId === 'string' ? source.sessionId : '',
166
+ at: typeof source.at === 'string' ? source.at : nowIso(),
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Normalize one stored entry, tolerating files written by an older version or
172
+ * edited by hand.
173
+ * @param {Record<string, unknown>} raw Entry as read from disk.
174
+ * @returns {Record<string, unknown> | null} Normalized entry, or null when unusable.
175
+ */
176
+ function normalizeEntry(raw) {
177
+ if (!raw || typeof raw !== 'object') return null
178
+ const kind = KINDS.has(String(raw.kind)) ? String(raw.kind) : 'fact'
179
+ const title = clampText(raw.title, MAX_TITLE)
180
+ if (title === '') return null
181
+ return {
182
+ id: typeof raw.id === 'string' && raw.id !== '' ? raw.id : `m_${randomUUID().slice(0, 8)}`,
183
+ kind,
184
+ title,
185
+ body: clampText(raw.body, MAX_BODY),
186
+ key: typeof raw.key === 'string' ? raw.key.trim().toLowerCase() : '',
187
+ tags: parseTags(raw.tags),
188
+ pinned: raw.pinned === true,
189
+ archived: raw.archived === true,
190
+ occurrences: Number.isFinite(raw.occurrences) ? Math.max(0, Math.trunc(raw.occurrences)) : 0,
191
+ reason: clampText(raw.reason, MAX_REASON),
192
+ origin: normalizeOrigin(raw.origin),
193
+ createdAt: typeof raw.createdAt === 'string' ? raw.createdAt : nowIso(),
194
+ updatedAt: typeof raw.updatedAt === 'string' ? raw.updatedAt : nowIso(),
195
+ }
196
+ }
197
+
198
+ function normalizeIncident(raw) {
199
+ if (!raw || typeof raw !== 'object') return null
200
+ if (typeof raw.signature !== 'string' || raw.signature === '') return null
201
+ return {
202
+ signature: raw.signature,
203
+ tool: typeof raw.tool === 'string' ? raw.tool : 'unknown',
204
+ normalized: typeof raw.normalized === 'string' ? raw.normalized : '',
205
+ message: clampText(raw.message, MAX_MESSAGE),
206
+ count: Number.isFinite(raw.count) ? Math.max(1, Math.trunc(raw.count)) : 1,
207
+ handled: raw.handled === true,
208
+ firstSeenAt: typeof raw.firstSeenAt === 'string' ? raw.firstSeenAt : nowIso(),
209
+ lastSeenAt: typeof raw.lastSeenAt === 'string' ? raw.lastSeenAt : nowIso(),
210
+ }
211
+ }
212
+
213
+ /** Term statistics over one corpus. */
214
+ function buildCorpus(documents) {
215
+ const df = new Map()
216
+ const perDocument = new Map()
217
+ let totalLength = 0
218
+ for (const document of documents) {
219
+ const tf = new Map()
220
+ for (const term of document.terms) tf.set(term, (tf.get(term) ?? 0) + 1)
221
+ perDocument.set(document.id, { tf, length: document.terms.length })
222
+ for (const term of tf.keys()) df.set(term, (df.get(term) ?? 0) + 1)
223
+ totalLength += document.terms.length
224
+ }
225
+ return {
226
+ df,
227
+ perDocument,
228
+ size: documents.length,
229
+ averageLength: documents.length === 0 ? 0 : totalLength / documents.length,
230
+ }
231
+ }
232
+
233
+ /** BM25 score of one document against query terms. */
234
+ function bm25(queryTerms, corpus, id) {
235
+ const document = corpus.perDocument.get(id)
236
+ if (document === undefined) return 0
237
+ const k1 = 1.2
238
+ const b = 0.75
239
+ let score = 0
240
+ for (const term of queryTerms) {
241
+ const documentFrequency = corpus.df.get(term) ?? 0
242
+ if (documentFrequency === 0) continue
243
+ const frequency = document.tf.get(term) ?? 0
244
+ if (frequency === 0) continue
245
+ const idf = Math.log(1 + (corpus.size - documentFrequency + 0.5) / (documentFrequency + 0.5))
246
+ const denominator = frequency + k1 * (1 - b + b * (document.length / (corpus.averageLength || 1)))
247
+ score += idf * ((frequency * (k1 + 1)) / denominator)
248
+ }
249
+ return score
250
+ }
251
+
252
+ /** Unit TF-IDF vector of one document. */
253
+ function unitVector(tf, df, size) {
254
+ const weights = new Map()
255
+ let squared = 0
256
+ for (const [term, frequency] of tf) {
257
+ const idf = Math.log(1 + size / (1 + (df.get(term) ?? 0)))
258
+ const weight = (1 + Math.log(frequency)) * idf
259
+ if (weight <= 0) continue
260
+ weights.set(term, weight)
261
+ squared += weight * weight
262
+ }
263
+ const norm = Math.sqrt(squared)
264
+ if (norm === 0) return weights
265
+ for (const [term, weight] of weights) weights.set(term, weight / norm)
266
+ return weights
267
+ }
268
+
269
+ /** Cosine similarity of two sparse unit vectors. */
270
+ function cosine(left, right) {
271
+ let score = 0
272
+ for (const [term, weight] of left) {
273
+ const other = right.get(term)
274
+ if (other !== undefined) score += weight * other
275
+ }
276
+ return score
277
+ }
278
+
279
+ /**
280
+ * Terms that carry no retrieval signal. Chinese bigram tokenization turns
281
+ * function words into terms ("一个", "怎么", "这个"), and a small ledger has
282
+ * too few documents for IDF to discount them, so they are removed explicitly.
283
+ */
284
+ export const STOP_TERMS = new Set([
285
+ '怎么', '么在', '什么', '这个', '那个', '一个', '一下', '一些', '我们', '你们',
286
+ '他们', '她们', '它们', '可以', '就是', '不是', '还是', '因为', '所以', '但是',
287
+ '不过', '如果', '那么', '这样', '那样', '时候', '现在', '需要', '应该', '已经',
288
+ '没有', '是否', '如何', '为什', '么样', '怎样', '这些', '那些', '以及', '并且',
289
+ '或者', '而且', '然后', '其实', '而是', '只是', '仍然', '仍然', '非常', '比较',
290
+ 'the', 'and', 'for', 'with', 'that', 'this', 'from', 'into', 'are', 'was',
291
+ 'were', 'not', 'but', 'you', 'your', 'its', 'has', 'have', 'had', 'can',
292
+ 'will', 'would', 'should', 'could', 'about', 'then', 'than', 'them', 'they',
293
+ 'there', 'here', 'when', 'what', 'which', 'who', 'how', 'why', 'all', 'any',
294
+ 'each', 'few', 'more', 'most', 'other', 'some', 'such', 'only', 'own',
295
+ 'same', 'too', 'very', 'via', 'per', 'out', 'off',
296
+ ])
297
+
298
+ /**
299
+ * Terms of one text with the no-signal ones removed.
300
+ * @param {unknown} value Any text.
301
+ * @returns {string[]}
302
+ */
303
+ export function contentTerms(value) {
304
+ return tokenize(value).filter(term => !STOP_TERMS.has(term))
305
+ }
306
+
307
+ /**
308
+ * Field-weighted terms of one entry: a title or key hit means far more than a
309
+ * body hit, so those fields are repeated before the corpus statistics are
310
+ * built. This is what keeps a paraphrase from matching a long body on one
311
+ * incidental bigram.
312
+ * @param {Record<string, unknown>} entry
313
+ * @returns {string[]}
314
+ */
315
+ function documentTerms(entry) {
316
+ const title = contentTerms(`${entry.title}`)
317
+ const key = contentTerms(`${entry.key ?? ''}`)
318
+ const tags = contentTerms((Array.isArray(entry.tags) ? entry.tags : []).join(' '))
319
+ const body = contentTerms(`${entry.body ?? ''}`)
320
+ return [...title, ...title, ...title, ...key, ...key, ...key, ...tags, ...tags, ...body]
321
+ }
322
+
323
+ /**
324
+ * Whether one entry passes a query's structural filters.
325
+ * @param {Record<string, unknown>} entry
326
+ * @param {object} [query]
327
+ * @returns {boolean}
328
+ */
329
+ export function entryMatches(entry, query = {}) {
330
+ if (query.includeArchived !== true && entry.archived === true) return false
331
+ if ((query.kind === 'fact' || query.kind === 'lesson') && entry.kind !== query.kind) return false
332
+ const requiredTags = parseTags(query.tags)
333
+ if (requiredTags.length > 0 && !requiredTags.every(tag => entry.tags.includes(tag))) return false
334
+ return true
335
+ }
336
+
337
+ /**
338
+ * Rank entries against a query in one shared corpus.
339
+ *
340
+ * `mode` selects the ranking: `keyword` requires every query word to appear
341
+ * and scores with BM25 plus exact substring hits, `vector` is TF-IDF cosine
342
+ * only, `hybrid` mixes both. An empty query keeps the stable
343
+ * pinned/proven/recent order.
344
+ *
345
+ * Takes the whole candidate set, so callers that combine several ledgers get
346
+ * comparable scores instead of one scale per file.
347
+ *
348
+ * @param {Array<Record<string, unknown>>} entries Candidates already filtered.
349
+ * @param {object} [query]
350
+ * @param {string} [query.text]
351
+ * @param {number} [query.limit]
352
+ * @param {'hybrid'|'keyword'|'vector'} [query.mode]
353
+ * @returns {Array<Record<string, unknown>>} Entries with `_score` and `_vector`.
354
+ */
355
+ export function rankEntries(entries, query = {}) {
356
+ const limit = Math.min(Math.max(Math.trunc(query.limit ?? 10) || 10, 1), 50)
357
+ const mode = SEARCH_MODES.includes(String(query.mode)) ? String(query.mode) : 'hybrid'
358
+ const rawQuery = String(query.text ?? '').trim()
359
+
360
+ if (rawQuery === '') {
361
+ return [...entries]
362
+ .sort(compareEntries)
363
+ .slice(0, limit)
364
+ .map(entry => ({ ...entry, _score: 0, _vector: 0 }))
365
+ }
366
+
367
+ const filteredQuery = contentTerms(rawQuery)
368
+ const queryTerms = filteredQuery.length > 0 ? filteredQuery : tokenize(rawQuery)
369
+ const words = rawQuery.toLowerCase().split(/\s+/u).filter(Boolean)
370
+ const documents = entries.map(entry => ({ id: entry.id, terms: documentTerms(entry) }))
371
+ const corpus = buildCorpus(documents)
372
+ const queryVector = unitVector(
373
+ queryTerms.reduce((tf, term) => {
374
+ tf.set(term, (tf.get(term) ?? 0) + 1)
375
+ return tf
376
+ }, new Map()),
377
+ corpus.df,
378
+ corpus.size,
379
+ )
380
+
381
+ let bestLexical = 0
382
+ const scored = entries.map(entry => {
383
+ const haystack = `${entry.title} ${entry.key} ${entry.tags.join(' ')} ${entry.body}`.toLowerCase()
384
+ const matchesEveryWord = words.length === 0 || words.every(word => haystack.includes(word))
385
+ const lexical = mode === 'vector'
386
+ ? 0
387
+ : (mode === 'keyword' && !matchesEveryWord)
388
+ ? 0
389
+ : bm25(queryTerms, corpus, entry.id) + scoreSubstring(entry, words)
390
+ const document = corpus.perDocument.get(entry.id)
391
+ const vector = mode === 'keyword'
392
+ ? 0
393
+ : document === undefined
394
+ ? 0
395
+ : cosine(queryVector, unitVector(document.tf, corpus.df, corpus.size))
396
+ if (lexical > bestLexical) bestLexical = lexical
397
+ return { entry, lexical, vector }
398
+ })
399
+
400
+ return scored
401
+ .filter(item => item.lexical > 0 || item.vector > 0.01)
402
+ .map(item => {
403
+ const normalizedLexical = bestLexical === 0 ? 0 : item.lexical / bestLexical
404
+ const score = mode === 'vector'
405
+ ? item.vector
406
+ : mode === 'keyword'
407
+ ? normalizedLexical
408
+ : 0.6 * item.vector + 0.4 * normalizedLexical
409
+ return {
410
+ ...item.entry,
411
+ _score: Number(score.toFixed(4)),
412
+ _vector: Number(item.vector.toFixed(4)),
413
+ }
414
+ })
415
+ .sort((a, b) => b._score - a._score || compareEntries(a, b))
416
+ .slice(0, limit)
417
+ }
418
+
419
+ /**
420
+ * One memory ledger owned by one file.
421
+ *
422
+ * The file is re-read when its modification time changes, so an edit made by
423
+ * hand (or by a second Harness process) shows up without a restart.
424
+ */
425
+ export class MemoryStore {
426
+ /**
427
+ * @param {object} options
428
+ * @param {string} options.file Absolute path of the JSON file.
429
+ * @param {string} [options.scope] Scope label this file represents.
430
+ * @param {string} [options.label] Human label (a project's directory path).
431
+ * @param {{ warn?: (message: string) => void }} [options.logger]
432
+ */
433
+ constructor({ file, scope = 'user', slug = '', label = '', logger = null }) {
434
+ this.file = file
435
+ this.scope = scope
436
+ this.slug = slug
437
+ this.label = label
438
+ this.logger = logger
439
+ this.state = emptyState()
440
+ this.mtime = -1
441
+ this.load()
442
+ }
443
+
444
+ /** Read the file, keeping a corrupt copy instead of silently dropping it. */
445
+ load() {
446
+ try {
447
+ if (!existsSync(this.file)) {
448
+ this.state = emptyState()
449
+ this.mtime = -1
450
+ return
451
+ }
452
+ const text = readFileSync(this.file, 'utf8')
453
+ const parsed = JSON.parse(text)
454
+ if (typeof parsed?.slug === 'string' && parsed.slug !== '') this.slug = parsed.slug
455
+ if (typeof parsed?.label === 'string' && parsed.label !== '') this.label = parsed.label
456
+ const entries = Array.isArray(parsed?.entries)
457
+ ? parsed.entries.map(normalizeEntry).filter(Boolean)
458
+ : []
459
+ const incidents = Array.isArray(parsed?.incidents)
460
+ ? parsed.incidents.map(normalizeIncident).filter(Boolean)
461
+ : []
462
+ this.state = { version: FILE_VERSION, entries, incidents }
463
+ this.mtime = statSync(this.file).mtimeMs
464
+ } catch (error) {
465
+ const backup = `${this.file}.corrupt-${Date.now()}.json`
466
+ try {
467
+ if (existsSync(this.file)) renameSync(this.file, backup)
468
+ this.logger?.warn?.(`[dsh-memory-ledger] unreadable ledger moved to ${backup}: ${error instanceof Error ? error.message : String(error)}`)
469
+ } catch {
470
+ /* keep going with an empty ledger */
471
+ }
472
+ this.state = emptyState()
473
+ this.mtime = -1
474
+ }
475
+ }
476
+
477
+ /** Re-read the file when another writer changed it. */
478
+ ensureFresh() {
479
+ try {
480
+ if (!existsSync(this.file)) return
481
+ if (statSync(this.file).mtimeMs !== this.mtime) this.load()
482
+ } catch {
483
+ /* a transient stat failure keeps the in-memory copy */
484
+ }
485
+ }
486
+
487
+ /** Write the ledger atomically (temp file + rename). */
488
+ save() {
489
+ mkdirSync(dirname(this.file), { recursive: true })
490
+ const temporary = `${this.file}.${process.pid}.tmp`
491
+ const payload = {
492
+ version: FILE_VERSION,
493
+ ...(this.slug === '' ? {} : { slug: this.slug }),
494
+ ...(this.label === '' ? {} : { label: this.label }),
495
+ entries: this.state.entries,
496
+ incidents: this.state.incidents,
497
+ }
498
+ writeFileSync(temporary, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
499
+ renameSync(temporary, this.file)
500
+ try {
501
+ this.mtime = statSync(this.file).mtimeMs
502
+ } catch {
503
+ this.mtime = -1
504
+ }
505
+ }
506
+
507
+ /** @returns {string} Absolute ledger path. */
508
+ get path() {
509
+ return this.file
510
+ }
511
+
512
+ /** @returns {Array<Record<string, unknown>>} Live entries, normalized. */
513
+ entries() {
514
+ this.ensureFresh()
515
+ return this.state.entries
516
+ }
517
+
518
+ /** @returns {Array<Record<string, unknown>>} Live incidents, normalized. */
519
+ incidents() {
520
+ this.ensureFresh()
521
+ return this.state.incidents
522
+ }
523
+
524
+ /**
525
+ * List this ledger's entries, optionally ranked by a query.
526
+ *
527
+ * `mode` selects the ranking: `keyword` is BM25 plus exact substring
528
+ * matching, `vector` is TF-IDF cosine only, `hybrid` mixes both. An empty
529
+ * query keeps the stable pinned/proven/recent order.
530
+ *
531
+ * @param {object} [query]
532
+ * @param {string} [query.text] Free-form query.
533
+ * @param {'fact'|'lesson'} [query.kind]
534
+ * @param {string[]|string} [query.tags] Tags that must all be present.
535
+ * @param {boolean} [query.includeArchived]
536
+ * @param {number} [query.limit]
537
+ * @param {string} [query.mode] One of {@link SEARCH_MODES}.
538
+ * @returns {Array<Record<string, unknown>>} Matching entries with `_score` and `_vector`.
539
+ */
540
+ list(query = {}) {
541
+ this.ensureFresh()
542
+ return rankEntries(this.state.entries.filter(entry => entryMatches(entry, query)), query)
543
+ }
544
+
545
+ /**
546
+ * Read one entry by id.
547
+ * @param {string} id
548
+ * @returns {Record<string, unknown> | undefined}
549
+ */
550
+ get(id) {
551
+ this.ensureFresh()
552
+ return this.state.entries.find(entry => entry.id === id)
553
+ }
554
+
555
+ /**
556
+ * Create or update one entry.
557
+ *
558
+ * The target is the entry with `id`, otherwise the entry with the same
559
+ * `kind` and `key` (which is how a changed setting replaces its old value
560
+ * instead of piling up a second copy).
561
+ *
562
+ * @param {object} input
563
+ * @returns {{ entry: Record<string, unknown>, updated: boolean }}
564
+ */
565
+ upsert(input) {
566
+ this.ensureFresh()
567
+ const kind = KINDS.has(String(input.kind)) ? String(input.kind) : null
568
+ if (kind === null) throw new Error(`kind must be "fact" or "lesson", received "${String(input.kind)}"`)
569
+ const title = clampText(input.title, MAX_TITLE)
570
+ if (title === '') throw new Error('title must be a non-empty single line')
571
+ const key = typeof input.key === 'string' ? input.key.trim().toLowerCase() : ''
572
+
573
+ let target
574
+ if (typeof input.id === 'string' && input.id !== '') {
575
+ target = this.state.entries.find(entry => entry.id === input.id)
576
+ if (target === undefined) throw new Error(`no memory entry with id "${input.id}"`)
577
+ } else if (key !== '') {
578
+ target = this.state.entries.find(entry => entry.kind === kind && entry.key === key)
579
+ }
580
+
581
+ const updated = target !== undefined
582
+ if (updated) {
583
+ target.kind = kind
584
+ target.title = title
585
+ if (input.body !== undefined) target.body = clampText(input.body, MAX_BODY)
586
+ if (input.key !== undefined) target.key = key
587
+ if (input.tags !== undefined) target.tags = parseTags(input.tags)
588
+ if (input.pinned !== undefined) target.pinned = input.pinned === true
589
+ if (input.archived !== undefined) target.archived = input.archived === true
590
+ if (input.reason !== undefined) target.reason = clampText(input.reason, MAX_REASON)
591
+ // Provenance is where the entry was FIRST recorded; a later edit from
592
+ // another project does not rewrite that history.
593
+ if (input.origin !== undefined && target.origin == null) target.origin = normalizeOrigin(input.origin)
594
+ target.updatedAt = nowIso()
595
+ } else {
596
+ target = {
597
+ id: `m_${randomUUID().slice(0, 8)}`,
598
+ kind,
599
+ title,
600
+ body: clampText(input.body, MAX_BODY),
601
+ key,
602
+ tags: parseTags(input.tags),
603
+ pinned: input.pinned === true,
604
+ archived: false,
605
+ occurrences: 0,
606
+ reason: clampText(input.reason, MAX_REASON),
607
+ origin: normalizeOrigin(input.origin),
608
+ createdAt: nowIso(),
609
+ updatedAt: nowIso(),
610
+ }
611
+ this.state.entries.unshift(target)
612
+ }
613
+
614
+ if (target.kind === 'lesson') this.markIncidentsHandled(target)
615
+ this.save()
616
+ return { entry: target, updated }
617
+ }
618
+
619
+ /**
620
+ * Apply a partial edit to one entry, keeping its id and its scope.
621
+ *
622
+ * Only the fields present in `fields` change; everything else survives, so
623
+ * a rename does not silently drop a body or a tag list.
624
+ *
625
+ * @param {string} id Entry id.
626
+ * @param {{ kind?: string, title?: string, body?: string, key?: string,
627
+ * tags?: string|string[], pinned?: boolean, archived?: boolean,
628
+ * reason?: string }} [fields]
629
+ * @returns {Record<string, unknown>} The updated entry.
630
+ */
631
+ patch(id, fields = {}) {
632
+ this.ensureFresh()
633
+ const entry = this.state.entries.find(item => item.id === id)
634
+ if (entry === undefined) throw new Error(`no memory entry with id "${id}"`)
635
+ if (fields.kind !== undefined) {
636
+ if (!KINDS.has(String(fields.kind))) throw new Error(`kind must be "fact" or "lesson", received "${String(fields.kind)}"`)
637
+ entry.kind = String(fields.kind)
638
+ }
639
+ if (fields.title !== undefined) {
640
+ const title = clampText(fields.title, MAX_TITLE)
641
+ if (title === '') throw new Error('title must be a non-empty single line')
642
+ entry.title = title
643
+ }
644
+ if (fields.body !== undefined) entry.body = clampText(fields.body, MAX_BODY)
645
+ if (fields.key !== undefined) entry.key = typeof fields.key === 'string' ? fields.key.trim().toLowerCase() : ''
646
+ if (fields.tags !== undefined) entry.tags = parseTags(fields.tags)
647
+ if (fields.pinned !== undefined) entry.pinned = fields.pinned === true
648
+ if (fields.archived !== undefined) entry.archived = fields.archived === true
649
+ if (fields.reason !== undefined) entry.reason = clampText(fields.reason, MAX_REASON)
650
+ entry.updatedAt = nowIso()
651
+ this.save()
652
+ return entry
653
+ }
654
+
655
+ /**
656
+ * Archive (default) or delete one entry.
657
+ * @param {string} id
658
+ * @param {boolean} [hard] Delete instead of archiving.
659
+ * @returns {{ id: string, hard: boolean, title: string }}
660
+ */
661
+ forget(id, hard = false) {
662
+ this.ensureFresh()
663
+ const index = this.state.entries.findIndex(entry => entry.id === id)
664
+ if (index === -1) throw new Error(`no memory entry with id "${id}"`)
665
+ const entry = this.state.entries[index]
666
+ if (hard === true) {
667
+ this.state.entries.splice(index, 1)
668
+ } else {
669
+ entry.archived = true
670
+ entry.updatedAt = nowIso()
671
+ }
672
+ this.save()
673
+ return { id, hard: hard === true, title: entry.title }
674
+ }
675
+
676
+ /**
677
+ * Count one failed tool call under its signature.
678
+ * @param {string} tool Failing tool name.
679
+ * @param {string} message Error text as the model saw it.
680
+ * @returns {{ signature: string, count: number, created: boolean, message: string }}
681
+ */
682
+ recordIncident(tool, message) {
683
+ this.ensureFresh()
684
+ const { signature, normalized } = signatureOf(tool, message)
685
+ const existing = this.state.incidents.find(incident => incident.signature === signature)
686
+ if (existing !== undefined) {
687
+ existing.count += 1
688
+ existing.lastSeenAt = nowIso()
689
+ if (normalized !== '') existing.normalized = normalized
690
+ return { signature, count: existing.count, created: false, message: existing.message }
691
+ }
692
+ const incident = {
693
+ signature,
694
+ tool: typeof tool === 'string' && tool !== '' ? tool : 'unknown',
695
+ normalized,
696
+ message: clampText(message, MAX_MESSAGE),
697
+ count: 1,
698
+ handled: false,
699
+ firstSeenAt: nowIso(),
700
+ lastSeenAt: nowIso(),
701
+ }
702
+ this.state.incidents.unshift(incident)
703
+ return { signature, count: 1, created: true, message: incident.message }
704
+ }
705
+
706
+ /**
707
+ * Treat incidents a saved lesson plausibly explains as handled.
708
+ * @param {Record<string, unknown>} lesson The saved lesson.
709
+ */
710
+ markIncidentsHandled(lesson) {
711
+ const haystack = `${lesson.title} ${lesson.body} ${(lesson.tags ?? []).join(' ')}`.toLowerCase()
712
+ for (const incident of this.state.incidents) {
713
+ if (incident.handled) continue
714
+ const tool = String(incident.tool).toLowerCase()
715
+ if (tool !== '' && haystack.includes(tool)) incident.handled = true
716
+ }
717
+ }
718
+
719
+ /**
720
+ * Drop the oldest low-signal incidents beyond `max`, and forget incidents
721
+ * older than `maxAgeDays`.
722
+ * @param {{ max?: number, maxAgeDays?: number }} [options]
723
+ */
724
+ pruneIncidents(options = {}) {
725
+ const max = Math.max(5, Math.trunc(options.max ?? 40) || 40)
726
+ const maxAgeMs = Math.max(1, Math.trunc(options.maxAgeDays ?? 60) || 60) * 86_400_000
727
+ const cutoff = Date.now() - maxAgeMs
728
+ this.state.incidents = this.state.incidents
729
+ .filter(incident => {
730
+ const seen = Date.parse(incident.lastSeenAt)
731
+ return Number.isNaN(seen) || seen >= cutoff
732
+ })
733
+ .sort((a, b) => b.count - a.count || String(b.lastSeenAt).localeCompare(String(a.lastSeenAt)))
734
+ .slice(0, max)
735
+ this.save()
736
+ }
737
+
738
+ /** @returns {{ facts: number, lessons: number, incidents: number, pending: number }} */
739
+ stats() {
740
+ this.ensureFresh()
741
+ const facts = this.state.entries.filter(entry => entry.kind === 'fact' && !entry.archived).length
742
+ const lessons = this.state.entries.filter(entry => entry.kind === 'lesson' && !entry.archived).length
743
+ const pending = this.state.incidents.filter(incident => !incident.handled).length
744
+ return { facts, lessons, incidents: this.state.incidents.length, pending }
745
+ }
746
+
747
+ /**
748
+ * Render the prompt section for the current ledger.
749
+ * @param {object} [options]
750
+ * @param {number} [options.maxChars] Hard character budget for the section.
751
+ * @param {number} [options.maxLessons] Lessons to surface at most.
752
+ * @param {number} [options.incidentThreshold] Repeat count that makes an incident worth surfacing.
753
+ * @param {boolean} [options.includeUsageHint]
754
+ * @param {boolean} [options.markProject] Tag entries contributed by a project ledger.
755
+ * @returns {string} Section text, or '' when the ledger is empty.
756
+ */
757
+ renderPromptSection(options = {}) {
758
+ this.ensureFresh()
759
+ return renderPromptSection(this.state, { ...options, scope: this.scope })
760
+ }
761
+ }
762
+
763
+ /**
764
+ * Render the hard-constraint section from ledger state. Pure: no file access.
765
+ *
766
+ * A fact becomes a constraint when it is pinned or when its key is listed in
767
+ * `constraintKeys`. Constraints are phrased as requirements and deliberately
768
+ * carry no body text, so the same fact does not pay for its words twice.
769
+ *
770
+ * @param {{ entries: Array<Record<string, unknown>> }} state
771
+ * @param {object} [options]
772
+ * @param {string|string[]} [options.constraintKeys] Keys that make a fact a constraint.
773
+ * @param {number} [options.maxChars] Hard character budget.
774
+ * @param {boolean} [options.markProject] Tag entries contributed by a project ledger.
775
+ * @returns {string}
776
+ */
777
+ export function renderConstraints(state, options = {}) {
778
+ const maxChars = Math.max(80, Math.trunc(options.maxChars ?? 300) || 300)
779
+ const keys = new Set(parseTags(options.constraintKeys))
780
+ const markProject = options.markProject !== false
781
+ const entries = Array.isArray(state?.entries) ? state.entries : []
782
+ const facts = entries
783
+ .filter(entry => entry.kind === 'fact' && !entry.archived)
784
+ .filter(entry => entry.pinned === true || (entry.key !== '' && keys.has(entry.key)))
785
+ .sort(compareEntries)
786
+ if (facts.length === 0) return ''
787
+
788
+ const lines = []
789
+ let used = 0
790
+ const push = (line) => {
791
+ const length = line.length + 1
792
+ if (used + length > maxChars) return false
793
+ lines.push(line)
794
+ used += length
795
+ return true
796
+ }
797
+
798
+ push('## 必须遵守的用户设定(优先级高于任何默认署名、风格或模板)')
799
+ push('以下是用户明确设定过的硬性要求。与它们冲突的默认做法、模板示例或历史习惯一律作废;拿不准时按这里执行。')
800
+ for (const fact of facts) {
801
+ const projectTag = markProject && fact.scope === 'project' ? '(本项目)' : ''
802
+ // Title only: the detailed body already travels in the memory section.
803
+ if (!push(`- ${projectTag}${fact.title}`)) break
804
+ }
805
+ return lines.join('\n')
806
+ }
807
+
808
+ /**
809
+ * Render the prompt section from ledger state. Pure: no file access.
810
+ * @param {{ entries: Array<Record<string, unknown>>, incidents: Array<Record<string, unknown>> }} state
811
+ * @param {object} [options] Same options as {@link MemoryStore.renderPromptSection}.
812
+ * @returns {string}
813
+ */
814
+ export function renderPromptSection(state, options = {}) {
815
+ const maxChars = Math.max(200, Math.trunc(options.maxChars ?? 1600) || 1600)
816
+ const maxLessons = Math.max(1, Math.trunc(options.maxLessons ?? 8) || 8)
817
+ const incidentThreshold = Math.max(2, Math.trunc(options.incidentThreshold ?? 2) || 2)
818
+ const includeUsageHint = options.includeUsageHint !== false
819
+ const markProject = options.markProject !== false
820
+
821
+ const entries = Array.isArray(state?.entries) ? state.entries : []
822
+ const facts = entries
823
+ .filter(entry => entry.kind === 'fact' && !entry.archived)
824
+ .sort(compareEntries)
825
+ const lessons = entries
826
+ .filter(entry => entry.kind === 'lesson' && !entry.archived)
827
+ .sort(compareEntries)
828
+ .slice(0, maxLessons)
829
+ const incidents = (Array.isArray(state?.incidents) ? state.incidents : [])
830
+ .filter(incident => !incident.handled && (incident.count ?? 0) >= incidentThreshold)
831
+ .sort((a, b) => (b.count ?? 0) - (a.count ?? 0))
832
+ .slice(0, 5)
833
+
834
+ if (facts.length === 0 && lessons.length === 0 && incidents.length === 0) return ''
835
+
836
+ const lines = []
837
+ let used = 0
838
+ const push = (line) => {
839
+ const length = line.length + 1
840
+ if (used + length > maxChars) return false
841
+ lines.push(line)
842
+ used += length
843
+ return true
844
+ }
845
+
846
+ const projectTag = (entry) => (markProject && entry.scope === 'project' ? '(本项目)' : '')
847
+ const hasProject = [...facts, ...lessons].some(entry => entry.scope === 'project')
848
+
849
+ push('## 长期记忆(跨会话,由 dsh-memory-ledger 注入)')
850
+ push(
851
+ hasProject
852
+ ? '下面是你此前为用户保存的既定事实与已验证经验;标注「本项目」的条目只属于当前工作目录。把它们当作前提,不要重新询问;若与用户当次的明确指令冲突,以当次指令为准,并立刻用 memory_save 更新记忆。'
853
+ : '下面是你此前为用户保存的既定事实与已验证经验。把它们当作前提,不要重新询问;若与用户当次的明确指令冲突,以当次指令为准,并立刻用 memory_save 更新记忆。',
854
+ )
855
+
856
+ if (facts.length > 0) {
857
+ push('')
858
+ push('### 用户设定(长期有效)')
859
+ let kept = 0
860
+ for (const fact of facts) {
861
+ const label = fact.key !== '' ? `[${fact.key}] ` : ''
862
+ const detail = fact.body !== '' ? `:${truncate(fact.body.replace(/\s+/gu, ' '), 160)}` : ''
863
+ if (push(`- ${label}${projectTag(fact)}${fact.title}${detail}`)) kept += 1
864
+ }
865
+ if (kept === 0) push('- (设定过多,已省略;用 memory_search 查看)')
866
+ }
867
+
868
+ if (lessons.length > 0) {
869
+ push('')
870
+ push('### 已验证经验(先照做,别重新试错)')
871
+ let kept = 0
872
+ for (const lesson of lessons) {
873
+ const repeat = (lesson.occurrences ?? 0) > 1 ? `(已遇 ${lesson.occurrences} 次)` : ''
874
+ const detail = lesson.body !== '' ? ` → ${truncate(lesson.body.replace(/\s+/gu, ' '), 180)}` : ''
875
+ if (push(`- ${projectTag(lesson)}${lesson.title}${repeat}${detail}`)) kept += 1
876
+ }
877
+ if (kept === 0) push('- (经验过多,已省略;用 memory_search 查看)')
878
+ }
879
+
880
+ if (incidents.length > 0) {
881
+ push('')
882
+ push('### 重复失败预警(尚无已记录的解法)')
883
+ for (const incident of incidents) {
884
+ const text = truncate(String(incident.message ?? incident.normalized ?? '').replace(/\s+/gu, ' '), 130)
885
+ if (!push(`- ${incident.tool}:${text}(同类失败 ${incident.count} 次)→ 先 memory_search 查是否已有解法,再决定重试;解决后用 memory_save 记成 lesson`)) break
886
+ }
887
+ }
888
+
889
+ if (includeUsageHint) {
890
+ push('')
891
+ push('维护约定:用户给出新的稳定设定时,用 memory_save(kind="fact", key="<槽位>") 写入或覆盖;把某个故障真正修好后,用 memory_save(kind="lesson") 记录成因与解法(默认记进本项目,需要跨项目复用时传 scope="user")。检索全部记忆用 memory_search。')
892
+ }
893
+
894
+ return lines.join('\n')
895
+ }