@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.
- package/LICENSE +21 -0
- package/README.md +381 -2
- package/README.zh.md +178 -0
- package/THIRD-PARTY.md +64 -0
- package/client.js +2974 -0
- package/cordis.patch.yml +77 -0
- package/icon.svg +19 -0
- package/index.js +211 -0
- package/lib/api.js +387 -0
- package/lib/checkpoints.js +774 -0
- package/lib/data/jieba-dict.txt +109750 -0
- package/lib/factkeys.js +212 -0
- package/lib/markdown.js +252 -0
- package/lib/memory-engine.js +1323 -0
- package/lib/memory-host.js +640 -0
- package/lib/memory.js +224 -0
- package/lib/retrieval.js +217 -0
- package/lib/service.js +1273 -0
- package/lib/share.js +641 -0
- package/lib/step-tag-filter.js +199 -0
- package/lib/tags.js +140 -0
- package/lib/util.js +261 -0
- package/lib/wordlist.js +183 -0
- package/locale/en.json +87 -0
- package/locale/zh.json +87 -0
- package/package.json +75 -4
|
@@ -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 }
|