dsh-memory-ledger 0.0.0-stage → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README-zh.md +41 -0
- package/README.md +19 -2
- package/client.js +557 -0
- package/cordis.patch.yml +22 -0
- package/docs/DETAIL.md +151 -0
- package/icon.svg +17 -0
- package/index.js +776 -0
- package/ledger.js +352 -0
- package/locale/en.json +7 -0
- package/locale/zh.json +7 -0
- package/package.json +72 -3
- package/reminder.js +83 -0
- package/store.js +895 -0
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
|
+
}
|