@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/lib/memory.js ADDED
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Session memory ledger.
3
+ *
4
+ * DSH's durable log is the only source of truth for the conversation, so the
5
+ * suite keeps *derived* memory here: short, human-readable facts a session (or
6
+ * its agent) decided to remember. Because every entry records the session
7
+ * sequence it was observed at, a rollback can trim the ledger back to exactly
8
+ * the state the conversation had at that point 鈥?the piece the product design
9
+ * called out as the real differentiator.
10
+ */
11
+
12
+ import fs from 'node:fs/promises'
13
+ import path from 'node:path'
14
+ import { ensureDir, readJson, sha256Text, shortId, writeJson } from './util.js'
15
+
16
+
17
+ export class MemoryLedger {
18
+ /**
19
+ * @param {object} options ledger options
20
+ * @param {string} options.root suite data directory
21
+ */
22
+ constructor({ root }) {
23
+ this.root = path.join(root, 'memory')
24
+ }
25
+
26
+ /** @param {string} scope session id or `_global` */
27
+ #file(scope) {
28
+ return path.join(this.root, `${scope}.json`)
29
+ }
30
+
31
+ /**
32
+ * @param {string} scope session id or `_global`
33
+ * @returns {Promise<{ version: number, scope: string, entries: any[] }>} ledger
34
+ */
35
+ async read(scope) {
36
+ const data = await readJson(this.#file(scope))
37
+ if (!data || !Array.isArray(data.entries)) return { version: 1, scope, entries: [] }
38
+ return data
39
+ }
40
+
41
+ /** @param {string} scope session id or `_global` @param {any} data ledger */
42
+ async write(scope, data) {
43
+ await ensureDir(this.root)
44
+ await writeJson(this.#file(scope), data)
45
+ }
46
+
47
+ /**
48
+ * @param {string} sessionId session
49
+ * @param {{ text: string, tags?: string[], seq?: number, turn?: number, source?: string }} input entry
50
+ * @returns {Promise<any>} stored entry
51
+ */
52
+ async add(sessionId, input) {
53
+ const text = String(input.text ?? '').trim()
54
+ if (!text) throw new Error('memory text is empty')
55
+ const data = await this.read(sessionId)
56
+ const entry = {
57
+ id: `mem_${shortId(text)}`,
58
+ text,
59
+ tags: Array.isArray(input.tags) ? input.tags.filter(tag => typeof tag === 'string') : [],
60
+ seq: typeof input.seq === 'number' ? input.seq : null,
61
+ turn: typeof input.turn === 'number' ? input.turn : null,
62
+ source: input.source ?? 'manual',
63
+ pinned: false,
64
+ createdAt: Date.now(),
65
+ digest: sha256Text(text).slice(0, 16),
66
+ }
67
+ data.entries.push(entry)
68
+ await this.write(sessionId, data)
69
+ return entry
70
+ }
71
+
72
+ /**
73
+ * @param {string} sessionId session
74
+ * @param {string} id entry id
75
+ * @param {{ text?: string, tags?: string[], pinned?: boolean }} patch changes
76
+ * @returns {Promise<any | undefined>} updated entry
77
+ */
78
+ async update(sessionId, id, patch) {
79
+ const data = await this.read(sessionId)
80
+ const entry = data.entries.find(item => item.id === id)
81
+ if (!entry) return undefined
82
+ if (typeof patch.text === 'string' && patch.text.trim()) {
83
+ entry.text = patch.text.trim()
84
+ entry.digest = sha256Text(entry.text).slice(0, 16)
85
+ entry.editedAt = Date.now()
86
+ }
87
+ if (Array.isArray(patch.tags)) entry.tags = patch.tags.filter(tag => typeof tag === 'string')
88
+ if (typeof patch.pinned === 'boolean') entry.pinned = patch.pinned
89
+ await this.write(sessionId, data)
90
+ return entry
91
+ }
92
+
93
+ /**
94
+ * @param {string} sessionId session
95
+ * @param {string} id entry id
96
+ * @returns {Promise<boolean>} removed
97
+ */
98
+ async remove(sessionId, id) {
99
+ const data = await this.read(sessionId)
100
+ const before = data.entries.length
101
+ data.entries = data.entries.filter(item => item.id !== id)
102
+ await this.write(sessionId, data)
103
+ return data.entries.length !== before
104
+ }
105
+
106
+ /**
107
+ * Trim entries observed after a session sequence 鈥?the memory half of a
108
+ * checkpoint rollback.
109
+ *
110
+ * @param {string} sessionId session
111
+ * @param {number} seq inclusive bound to keep
112
+ * @returns {Promise<{ removed: number, kept: number }>} outcome
113
+ */
114
+ async trimTo(sessionId, seq) {
115
+ const data = await this.read(sessionId)
116
+ const kept = []
117
+ let removed = 0
118
+ for (const entry of data.entries) {
119
+ if (typeof entry.seq === 'number' && entry.seq > seq && !entry.pinned) removed += 1
120
+ else kept.push(entry)
121
+ }
122
+ if (removed > 0) {
123
+ data.entries = kept
124
+ await this.write(sessionId, data)
125
+ }
126
+ return { removed, kept: kept.length }
127
+ }
128
+
129
+ /**
130
+ * Copy a ledger into another session, optionally only the entries observed up
131
+ * to a sequence. Used when a rewind or a segment delete forks a child: the
132
+ * conversation continues in a new session, so its memory has to follow.
133
+ *
134
+ * @param {string} fromId source session
135
+ * @param {string} toId destination session
136
+ * @param {number} [maxSeq] inclusive upper bound on `seq`
137
+ * @returns {Promise<{ copied: number }>} outcome
138
+ */
139
+ async clone(fromId, toId, maxSeq) {
140
+ if (fromId === toId) return { copied: 0 }
141
+ const data = await this.read(fromId)
142
+ const entries = data.entries
143
+ .filter(entry => typeof maxSeq !== 'number' || typeof entry.seq !== 'number' || entry.seq <= maxSeq)
144
+ .map(entry => ({ ...entry }))
145
+ await this.write(toId, { version: 1, scope: toId, entries })
146
+ return { copied: entries.length }
147
+ }
148
+
149
+ /**
150
+ * @param {string} sessionId session
151
+ * @returns {Promise<any[]>} entries in insertion order
152
+ */
153
+ async list(sessionId) {
154
+ const data = await this.read(sessionId)
155
+ return data.entries
156
+ }
157
+
158
+ /**
159
+ * Everything this session contributed to the global long-term store.
160
+ *
161
+ * @param {string} sessionId session
162
+ * @param {{ text: string }} input entry
163
+ * @returns {Promise<any>} stored global entry
164
+ */
165
+ async addGlobal(sessionId, input) {
166
+ const text = String(input.text ?? '').trim()
167
+ if (!text) throw new Error('memory text is empty')
168
+ const data = await this.read('_global')
169
+ const entry = {
170
+ id: `gmem_${shortId(text)}`,
171
+ text,
172
+ sessionId,
173
+ tags: [],
174
+ pinned: false,
175
+ createdAt: Date.now(),
176
+ }
177
+ data.entries.push(entry)
178
+ await this.write('_global', data)
179
+ return entry
180
+ }
181
+
182
+ /**
183
+ * @param {string} sessionId session
184
+ * @returns {Promise<any[]>} global entries that mention this session
185
+ */
186
+ async listGlobal(sessionId) {
187
+ const data = await this.read('_global')
188
+ return data.entries.filter(entry => entry.sessionId === sessionId)
189
+ }
190
+
191
+ /**
192
+ * @param {string} sessionId session
193
+ */
194
+ async purge(sessionId) {
195
+ await fs.rm(this.#file(sessionId), { force: true }).catch(() => {})
196
+ const data = await this.read('_global')
197
+ const kept = data.entries.filter(entry => entry.sessionId !== sessionId)
198
+ if (kept.length !== data.entries.length) {
199
+ data.entries = kept
200
+ await this.write('_global', data)
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Whole-ledger snapshot used by the undo bundle.
206
+ *
207
+ * @param {string} sessionId session
208
+ * @returns {Promise<any>} ledger content
209
+ */
210
+ async snapshot(sessionId) {
211
+ return this.read(sessionId)
212
+ }
213
+
214
+ /**
215
+ * @param {string} sessionId session
216
+ * @param {any} data ledger content captured earlier
217
+ */
218
+ async restore(sessionId, data) {
219
+ if (!data || !Array.isArray(data.entries)) return
220
+ await this.write(sessionId, data)
221
+ }
222
+ }
223
+
224
+ export const LEDGER_GLOBAL = '_global'
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Retrieval primitives, deliberately free of I/O and of the memory engine.
3
+ *
4
+ * The engine used to rank with a single lexical signal: bigram coverage weighted
5
+ * by inverse document frequency. That works for precision but has one error
6
+ * profile, and a single route cannot outvote itself. Chapter 3 of *深入理解
7
+ * AI Agent* describes the standard remedy as hybrid retrieval: run several
8
+ * routes with *different* blind spots, then fuse them by rank rather than by
9
+ * score, because the scores live on incomparable scales (cosine 0..1 versus
10
+ * BM25 0..50).
11
+ *
12
+ * The book's example fuses a dense route with a sparse one. We have no embedding
13
+ * model on the recall path — recall runs offline and must stay offline — so this
14
+ * module fuses two *lexical* routes chosen to fail differently:
15
+ *
16
+ * - bigrams (two-character sequences): precise, but blind to any paraphrase
17
+ * that does not reproduce the exact pair, and blind to single characters.
18
+ * - unigrams (single characters plus ASCII words): loose, noisy, but it still
19
+ * finds a memory when only part of the query vocabulary overlaps.
20
+ *
21
+ * Fusing them is not a substitute for a dense route. It recovers ranking order
22
+ * among memories that are already reachable, and it cannot invent a match where
23
+ * no character is shared at all. That limit is documented rather than papered
24
+ * over.
25
+ */
26
+
27
+ /**
28
+ * Tokenise into the bigram-plus-word vocabulary used for precise matching.
29
+ *
30
+ * Returns an array *with multiplicity*, because BM25 needs term frequency. The
31
+ * set form is the same vocabulary deduplicated, so `new Set(tokens(text))` is
32
+ * exactly the shingle set the rest of the engine already uses.
33
+ *
34
+ * @param {string} text input
35
+ * @returns {string[]} tokens, duplicates preserved
36
+ */
37
+ export function tokens(text) {
38
+ const normalized = String(text ?? '').toLowerCase().replace(/[\s\p{P}\p{S}]+/gu, ' ')
39
+ const out = []
40
+ for (const word of normalized.split(' ')) {
41
+ if (!word) continue
42
+ if (/^[\x00-\x7f]+$/.test(word)) {
43
+ out.push(word)
44
+ continue
45
+ }
46
+ const chars = [...word]
47
+ if (chars.length === 1) out.push(chars[0])
48
+ for (let index = 0; index + 1 < chars.length; index += 1) out.push(`${chars[index]}${chars[index + 1]}`)
49
+ }
50
+ return out
51
+ }
52
+
53
+ /**
54
+ * Tokenise into the loose vocabulary: every single character, plus ASCII words.
55
+ *
56
+ * A CJK character carries far less meaning than a bigram, which is exactly the
57
+ * point — this route tolerates a query that shares only part of a word, which
58
+ * the bigram route cannot.
59
+ *
60
+ * @param {string} text input
61
+ * @returns {string[]} tokens, duplicates preserved
62
+ */
63
+ export function unigrams(text) {
64
+ const normalized = String(text ?? '').toLowerCase().replace(/[\s\p{P}\p{S}]+/gu, ' ')
65
+ const out = []
66
+ for (const word of normalized.split(' ')) {
67
+ if (!word) continue
68
+ if (/^[\x00-\x7f]+$/.test(word)) {
69
+ out.push(word)
70
+ continue
71
+ }
72
+ for (const char of word) out.push(char)
73
+ }
74
+ return out
75
+ }
76
+
77
+ /** @param {string[]} list @returns {Map<string, number>} term frequencies */
78
+ function termFrequency(list) {
79
+ const out = new Map()
80
+ for (const token of list) out.set(token, (out.get(token) ?? 0) + 1)
81
+ return out
82
+ }
83
+
84
+ /**
85
+ * Inverse document frequency, always positive.
86
+ *
87
+ * The textbook form `ln((N - df + 0.5) / (df + 0.5))` goes negative once a term
88
+ * appears in more than half the documents, which would make a common word
89
+ * *subtract* from relevance. Chapter 3 notes the same problem and recommends a
90
+ * floor; wrapping the ratio in `ln(1 + ...)` is the floor, and it is what
91
+ * production search engines actually ship.
92
+ *
93
+ * @param {number} total document count
94
+ * @param {number} seen document frequency
95
+ * @returns {number} idf, > 0
96
+ */
97
+ function idf(total, seen) {
98
+ return Math.log(1 + (total - seen + 0.5) / (seen + 0.5))
99
+ }
100
+
101
+ /**
102
+ * BM25 relevance of every document to one query.
103
+ *
104
+ * BM25 is the correction the book describes for TF-IDF's two weaknesses: raw
105
+ * term frequency grows without bound, and long documents win merely by being
106
+ * long. `k1` makes repeated occurrences saturate; `b` normalises by length.
107
+ *
108
+ * @param {string[]} queryTokens query, with multiplicity
109
+ * @param {string[][]} documentTokens one token list per document
110
+ * @param {object} [options] tuning
111
+ * @param {number} [options.k1] term-frequency saturation, default 1.5
112
+ * @param {number} [options.b] length normalisation strength, default 0.75
113
+ * @returns {number[]} one score per document, in input order
114
+ */
115
+ export function bm25(queryTokens, documentTokens, options = {}) {
116
+ const k1 = options.k1 ?? 1.5
117
+ const b = options.b ?? 0.75
118
+ const total = documentTokens.length
119
+ if (total === 0) return []
120
+
121
+ const frequencies = documentTokens.map(termFrequency)
122
+ const lengths = documentTokens.map(list => list.length)
123
+ const averageLength = lengths.reduce((sum, value) => sum + value, 0) / total || 1
124
+
125
+ const seen = new Map()
126
+ for (const frequency of frequencies) {
127
+ for (const token of frequency.keys()) seen.set(token, (seen.get(token) ?? 0) + 1)
128
+ }
129
+
130
+ const queryFrequency = termFrequency(queryTokens)
131
+ const scores = new Array(total).fill(0)
132
+
133
+ for (let index = 0; index < total; index += 1) {
134
+ const length = lengths[index]
135
+ let score = 0
136
+ for (const [token, queryCount] of queryFrequency) {
137
+ const frequency = frequencies[index].get(token) ?? 0
138
+ if (frequency === 0) continue
139
+ const weight = idf(total, seen.get(token) ?? 0)
140
+ const saturation = (frequency * (k1 + 1)) / (frequency + k1 * (1 - b + (b * length) / averageLength))
141
+ score += weight * saturation * Math.min(queryCount, 3)
142
+ }
143
+ scores[index] = score
144
+ }
145
+ return scores
146
+ }
147
+
148
+ /**
149
+ * IDF-weighted fraction of the query that a document accounts for.
150
+ *
151
+ * Coverage, not Jaccard: a memory may be far longer than the query, and being
152
+ * long must not be a penalty when the question is "did it contain what I asked".
153
+ * This is the loose route's scoring function; the precise route uses BM25.
154
+ *
155
+ * @param {string[]} queryTokens query, with multiplicity
156
+ * @param {string[]} documentTokens document, with multiplicity
157
+ * @param {string[][]} allDocumentTokens corpus, for document frequency
158
+ * @returns {number} coverage 0..1
159
+ */
160
+ export function weightedCoverage(queryTokens, documentTokens, allDocumentTokens) {
161
+ const querySet = new Set(queryTokens)
162
+ if (querySet.size === 0) return 0
163
+ const documentSet = new Set(documentTokens)
164
+ const total = allDocumentTokens.length || 1
165
+ const seen = new Map()
166
+ for (const list of allDocumentTokens) {
167
+ for (const token of new Set(list)) seen.set(token, (seen.get(token) ?? 0) + 1)
168
+ }
169
+ let matched = 0
170
+ let available = 0
171
+ for (const token of querySet) {
172
+ const weight = idf(total, seen.get(token) ?? 0)
173
+ available += weight
174
+ if (documentSet.has(token)) matched += weight
175
+ }
176
+ return available > 0 ? matched / available : 0
177
+ }
178
+
179
+ /**
180
+ * Reciprocal Rank Fusion.
181
+ *
182
+ * Fusing by rank rather than by score is the whole point: BM25 returns values
183
+ * spanning tens, coverage returns 0..1, and adding them would let whichever
184
+ * route has the larger scale decide the outcome. RRF ignores the magnitudes and
185
+ * keeps only the ordering, which is the part both routes agree is meaningful.
186
+ *
187
+ * @param {Array<Array<string | number>>} rankings each route's ids, best first
188
+ * @param {number} [k] smoothing constant; 60 is the customary default
189
+ * @returns {Map<string | number, number>} fused score, normalised to 0..1
190
+ */
191
+ export function reciprocalRankFusion(rankings, k = 60) {
192
+ const fused = new Map()
193
+ const routes = rankings.filter(ranking => ranking.length > 0)
194
+ for (const ranking of routes) {
195
+ ranking.forEach((id, index) => {
196
+ fused.set(id, (fused.get(id) ?? 0) + 1 / (k + index + 1))
197
+ })
198
+ }
199
+ // Normalise so a document ranked first by every route scores 1, keeping the
200
+ // fused signal comparable to the 0..1 importance score it is combined with.
201
+ if (routes.length > 0) {
202
+ const ceiling = routes.length / (k + 1)
203
+ for (const [id, score] of fused) fused.set(id, score / ceiling)
204
+ }
205
+ return fused
206
+ }
207
+
208
+ /**
209
+ * Order ids by a score map, best first, with a stable tiebreak.
210
+ *
211
+ * @param {Array<string | number>} ids candidates
212
+ * @param {Map<string | number, number>} scores score per id
213
+ * @returns {Array<string | number>} sorted ids
214
+ */
215
+ export function rankIds(ids, scores) {
216
+ return [...ids].sort((a, b) => (scores.get(b) ?? 0) - (scores.get(a) ?? 0) || String(a).localeCompare(String(b)))
217
+ }