dsh-hypatia-auto-memory 0.1.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/src/writer.js ADDED
@@ -0,0 +1,320 @@
1
+ /**
2
+ * Idempotent hypatia writer.
3
+ *
4
+ * Every write is get-before-create, so a crash between check and create, a
5
+ * queue replay, or a backfill re-run converges instead of duplicating. Names are
6
+ * derived, never random: `msg-<session>-<index>` from the dense message ordinal,
7
+ * `sum-<session>-<from>-<to>` from the span, `wu-<slug>-<hash>` from the unit's
8
+ * own content. Multi-step writes assert their edges unconditionally, so a replay
9
+ * repairs a graph a crash left half-written.
10
+ *
11
+ * @module dsh-hypatia-auto-memory/writer
12
+ */
13
+
14
+ import { createHash } from 'node:crypto'
15
+
16
+ import { sanitizeSlug } from './content-policy.js'
17
+
18
+ /**
19
+ * Name prefixes and tags that mark the operational layer — raw logs, span
20
+ * summaries, session nodes, dream run markers.
21
+ *
22
+ * These entries are indexed for full-text and vector search like everything
23
+ * else, so they dominate any similarity query run against a shelf that has been
24
+ * logging a while. They are bookkeeping, not knowledge: relating a new work unit
25
+ * to one says nothing, and `hypatia-dream` excludes the same set when curating
26
+ * the graph (`skills/hypatia-dream/SKILL.md`).
27
+ */
28
+ const OPERATIONAL_PREFIXES = ['msg-', 'sum', 'session-', 'hypatia-dream-run-']
29
+ const OPERATIONAL_TAGS = new Set(['message', 'session', 'hypatia-dream-run'])
30
+
31
+ /**
32
+ * How many rows to ask `similar` for per candidate actually wanted.
33
+ *
34
+ * The operational layer cannot be excluded in the query (see `findCandidates`),
35
+ * outnumbers knowledge on an active shelf, and ranks nearer, so the fetch has
36
+ * to absorb it. Four covers the measured 7-in-10 ratio with room to spare.
37
+ */
38
+ const CANDIDATE_OVERSAMPLE = 4
39
+
40
+ /** Upper bound on one oversampled fetch — rows travel back through the CLI. */
41
+ const CANDIDATE_FETCH_CEILING = 40
42
+
43
+ /** Name of a hypatia row: `similar`/query rows carry `name`, `search` rows `key`. */
44
+ function rowName(row) {
45
+ return typeof row?.name === 'string' ? row.name
46
+ : typeof row?.key === 'string' ? row.key : ''
47
+ }
48
+
49
+ /** Whether a candidate row belongs to the operational layer rather than knowledge. */
50
+ export function isOperationalRow(row) {
51
+ const name = rowName(row)
52
+ if (name === '') return true
53
+ if (OPERATIONAL_PREFIXES.some((prefix) => name.startsWith(prefix))) return true
54
+ const tags = row?.content?.tags
55
+ return Array.isArray(tags) && tags.some((tag) => (
56
+ OPERATIONAL_TAGS.has(tag) || tag === 'summary' || String(tag).startsWith('summary ')
57
+ ))
58
+ }
59
+
60
+ /**
61
+ * How a new work unit relates to an existing one, and what that means for the
62
+ * graph. Mirrors the protocol's vocabulary (`docs/memory.md`, and the
63
+ * relationship set `hypatia-dream` preserves).
64
+ *
65
+ * `contradicts` deliberately keeps BOTH entries and records `supersedes`:
66
+ * `docs/memory-nolinear.md` is explicit that a correction must leave a trace —
67
+ * *"否定会留下冲突的痕迹而不是悄悄覆盖——记忆系统最忌讳的就是悄悄忘记自己曾经
68
+ * 知道什么"*.
69
+ */
70
+ const VERDICTS = {
71
+ duplicate: { write: false, predicate: undefined },
72
+ refines: { write: true, predicate: 'refines' },
73
+ extends: { write: true, predicate: 'extends' },
74
+ supersedes: { write: true, predicate: 'supersedes' },
75
+ contradicts: { write: true, predicate: 'supersedes' },
76
+ unrelated: { write: true, predicate: undefined },
77
+ }
78
+
79
+ /**
80
+ * hypatia entry naming per the hypatia-memory graph schema.
81
+ *
82
+ * `index` is the DENSE message ordinal within the session, not a session-log
83
+ * seq — see `countLoggableMessages` in collector.js for why that distinction
84
+ * matters to `$not-summaried` and to anything walking `msg-*` in order.
85
+ */
86
+ export function messageName(sessionId, index) {
87
+ return `msg-${sessionId}-${index}`
88
+ }
89
+
90
+ /** The session node a message's `belongTo` edge points at. */
91
+ export function sessionNodeName(sessionId) {
92
+ return `session-${sessionId}`
93
+ }
94
+
95
+ /** Deterministic span summary name; stable across replays of the same range. */
96
+ export function summaryName(sessionId, fromSeq, toSeq) {
97
+ return `sum-${sessionId}-${fromSeq}-${toSeq}`
98
+ }
99
+
100
+ /**
101
+ * @param {ReturnType<import('./hypatia-client.js').createHypatiaClient>} cli
102
+ * @param {{
103
+ * status: import('./status.js').StatusLog,
104
+ * adjudicate?: (unit: any, candidates: readonly any[]) => Promise<{verdict: string, target?: string} | undefined>,
105
+ * }} deps - `adjudicate` decides how a new work unit relates to nearby ones.
106
+ * It is injected rather than implemented here because it needs a model route,
107
+ * which the writer has no business owning; absent it, a unit is stored with no
108
+ * relationship rather than a guessed one.
109
+ */
110
+ export function createWriter(cli, { status, adjudicate }) {
111
+ /**
112
+ * Log one conversation message. Duplicates (same name already stored, e.g.
113
+ * from a replayed task) are counted and skipped — the watermark must still
114
+ * advance past them, which the queue does based on the resolved range.
115
+ *
116
+ * @param {{sessionId: string, index: number, markdown: string, project: string}} entry
117
+ */
118
+ async function writeMessage({ sessionId, index, markdown, project }) {
119
+ const name = messageName(sessionId, index)
120
+ const existing = await cli.knowledgeGet(name)
121
+ if (existing.found) {
122
+ status.count('duplicate')
123
+ return { name, written: false }
124
+ }
125
+ await cli.knowledgeCreate(name, { data: markdown, tags: ['message'], scopes: [project] })
126
+ status.count('written')
127
+ return { name, written: true }
128
+ }
129
+
130
+ /**
131
+ * Store one span summary and link it to every covered item with `summary`
132
+ * statements (the protocol predicate, oriented `summary -> item`: hypatia's
133
+ * `$not-summaried` anti-joins on `statement.tail`, so the summary must be the
134
+ * head).
135
+ *
136
+ * Two properties this owes its callers:
137
+ *
138
+ * - **`items` is the list of entries actually written**, not a seq range to
139
+ * probe. Walking `[fromSeq, toSeq)` and calling `knowledgeGet` per step
140
+ * looked equivalent but is not: DSH appends one `assistant/chunk` event per
141
+ * streamed delta, so seq counts tokens, not messages. A single span meant
142
+ * thousands of serial CLI spawns, almost all of them looking up keys that
143
+ * could never exist — enough to wedge the session's task chain outright.
144
+ * - **Edges are asserted even when the entry already exists.** The entry and
145
+ * its edges are separate CLI calls, so a crash between them leaves a
146
+ * summary with no links; returning early on `found` made that state
147
+ * permanent. `statementCreate` swallows primary-key collisions, so
148
+ * re-asserting an existing edge is free.
149
+ *
150
+ * @param {{sessionId: string, fromSeq: number, toSeq: number, markdown: string,
151
+ * project: string, items?: readonly string[], level?: number}} entry
152
+ * `level` is the archive tier this summary sits at (1 = summarises messages).
153
+ * It is carried as the `summary <N>` tag the protocol's cascade queries with
154
+ * `$not-summaried`. Spaces inside a tag are safe: tags travel as one argv
155
+ * element that hypatia splits on commas, never through a shell.
156
+ */
157
+ async function writeSummary({ sessionId, fromSeq, toSeq, markdown, project, items = [], level = 1 }) {
158
+ const name = summaryName(sessionId, fromSeq, toSeq)
159
+ const existing = await cli.knowledgeGet(name)
160
+ if (existing.found) {
161
+ status.count('duplicate')
162
+ } else {
163
+ await cli.knowledgeCreate(name, {
164
+ data: markdown,
165
+ tags: ['summary', `summary ${level}`],
166
+ scopes: [project],
167
+ })
168
+ status.count('written')
169
+ }
170
+ for (const item of items) {
171
+ await cli.statementCreate(name, 'summary', item, { scopes: [project] })
172
+ }
173
+ return { name, written: existing.found === false, links: items.length }
174
+ }
175
+
176
+ /**
177
+ * Store the session node and link messages to it with `belongTo`.
178
+ *
179
+ * The protocol is explicit that this node is built from a summary the host
180
+ * produced — a session title, or a compaction summary — and that a session
181
+ * without one is simply skipped rather than given an invented summary
182
+ * (`skills/hypatia-memory/SKILL.md`, "do not fabricate session summaries").
183
+ *
184
+ * hypatia has no `knowledge-update`, so a second title cannot replace the
185
+ * first; the original stands and the caller is told nothing was written.
186
+ *
187
+ * @param {{sessionId: string, markdown: string, project: string,
188
+ * linkFrom: number, linkTo: number}} entry - `[linkFrom, linkTo)` is
189
+ * the half-open range of message ordinals still needing a `belongTo` edge.
190
+ */
191
+ async function writeSessionNode({ sessionId, markdown, project, linkFrom = 0, linkTo = 0 }) {
192
+ const name = sessionNodeName(sessionId)
193
+ const existing = await cli.knowledgeGet(name)
194
+ if (existing.found) {
195
+ status.count('duplicate')
196
+ } else if (markdown.trim() === '') {
197
+ // The log path calls in with no text purely to settle owed `belongTo`
198
+ // edges. If the node has since been deleted, recreating it empty would
199
+ // fabricate the session summary the protocol forbids inventing — so leave
200
+ // it absent and drop the edges with it.
201
+ return { name, written: false, links: 0 }
202
+ } else {
203
+ await cli.knowledgeCreate(name, { data: markdown, tags: ['session'], scopes: [project] })
204
+ status.count('written')
205
+ }
206
+ for (let index = linkFrom; index < linkTo; index += 1) {
207
+ await cli.statementCreate(messageName(sessionId, index), 'belongTo', name, { scopes: [project] })
208
+ }
209
+ return { name, written: existing.found === false, links: Math.max(0, linkTo - linkFrom) }
210
+ }
211
+
212
+ /**
213
+ * Candidates a new unit might relate to: semantically near, above the
214
+ * similarity floor, and not part of the operational layer.
215
+ *
216
+ * Uses `similar` rather than `search` because only vector search reports a
217
+ * `distance`. The old keyword pass had no comparable score, so it treated
218
+ * *any* hit as a relationship — and since `msg-*` entries outnumber knowledge
219
+ * by orders of magnitude, the "nearest" entry was usually a raw chat log.
220
+ */
221
+ async function findCandidates(unit, name, { maxDistance, limit }) {
222
+ try {
223
+ // OVERSAMPLED, because the exclusion below cannot be pushed into the
224
+ // query: `hypatia similar` takes only target/limit/shelf, no tag or scope
225
+ // filter. The operational layer is the majority of an active shelf (101
226
+ // of 183 knowledge entries on the profile this was measured on) and it
227
+ // scores CLOSER than real knowledge — a raw `msg-*` holds the very words
228
+ // of the conversation the unit was extracted from. Measured: 7 of the
229
+ // nearest 10 rows were operational, so asking for 5 and filtering
230
+ // afterwards left one candidate, often none, and adjudication silently
231
+ // degraded to "no relationship" on every write.
232
+ const rows = await cli.similar(`${unit.title}\n${unit.content}`.slice(0, 500), {
233
+ target: 'knowledge',
234
+ limit: Math.min(limit * CANDIDATE_OVERSAMPLE, CANDIDATE_FETCH_CEILING),
235
+ })
236
+ return rows
237
+ .filter((row) => rowName(row) !== name)
238
+ .filter((row) => !isOperationalRow(row))
239
+ .filter((row) => typeof row?.distance !== 'number' || row.distance <= maxDistance)
240
+ .slice(0, 3)
241
+ } catch (error) {
242
+ // Best-effort by design. A shelf with no embedding model fails every
243
+ // `similar` call outright; that must cost the relationship edge, never
244
+ // the memory itself.
245
+ status.warn(`work-unit candidate lookup unavailable: ${String(error)}`)
246
+ return []
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Store one extracted work-unit memory with its protocol statements, after
252
+ * adjudicating how it relates to the memories already nearby.
253
+ *
254
+ * @param {{title: string, content: string, tags: string[], project: string,
255
+ * derivedFrom?: string, date: string, maxDistance?: number,
256
+ * candidateLimit?: number}} unit
257
+ */
258
+ async function writeWorkUnit(unit) {
259
+ const slug = sanitizeSlug(unit.title)
260
+ // Content-addressed rather than `wu-<date>-<slug>`, which failed in both
261
+ // directions: two different units whose titles slugged alike on one day
262
+ // collided and the second was silently dropped, while the same lesson
263
+ // re-extracted on another day produced a second entry. The date lives in the
264
+ // body and its statements instead.
265
+ const digest = createHash('sha256').update(`${slug}\n${unit.content.trim()}`).digest('hex').slice(0, 8)
266
+ const name = `wu-${slug}-${digest}`
267
+ const existing = await cli.knowledgeGet(name)
268
+
269
+ let verdict = 'duplicate'
270
+ let nearest = ''
271
+ if (existing.found) {
272
+ status.count('duplicate')
273
+ } else {
274
+ verdict = 'unrelated'
275
+ const candidates = await findCandidates(unit, name, {
276
+ maxDistance: unit.maxDistance ?? 0.45,
277
+ limit: unit.candidateLimit ?? 5,
278
+ })
279
+ if (candidates.length > 0 && adjudicate !== undefined) {
280
+ try {
281
+ const decision = await adjudicate(unit, candidates)
282
+ if (decision !== undefined && Object.hasOwn(VERDICTS, decision.verdict)) {
283
+ verdict = decision.verdict
284
+ nearest = rowName(candidates.find((row) => rowName(row) === decision.target) ?? candidates[0])
285
+ }
286
+ } catch (error) {
287
+ // An adjudication failure means "no opinion", not "no memory".
288
+ status.warn(`work-unit adjudication failed, storing unrelated: ${String(error)}`)
289
+ }
290
+ }
291
+
292
+ if (VERDICTS[verdict].write === false) {
293
+ status.count('duplicate')
294
+ return { name, written: false, verdict, nearest }
295
+ }
296
+
297
+ await cli.knowledgeCreate(name, {
298
+ data: `${unit.content}\n\n_Recorded: ${unit.date}_`,
299
+ tags: ['memory', 'work-unit', ...unit.tags.map((t) => sanitizeSlug(t))].slice(0, 8),
300
+ scopes: [unit.project],
301
+ })
302
+ status.count('written')
303
+ }
304
+
305
+ // Asserted whether or not the entry is new: a crash between the entry and
306
+ // its edges must be repairable by a replay. Collisions are swallowed by the
307
+ // CLI wrapper, so re-asserting is free.
308
+ await cli.statementCreate(name, 'is_a', 'work-unit', { scopes: [unit.project] })
309
+ if (unit.derivedFrom) {
310
+ await cli.statementCreate(name, 'derivedFrom', unit.derivedFrom, { scopes: [unit.project] })
311
+ }
312
+ const predicate = VERDICTS[verdict]?.predicate
313
+ if (predicate !== undefined && nearest !== '') {
314
+ await cli.statementCreate(name, predicate, nearest, { scopes: [unit.project] })
315
+ }
316
+ return { name, written: existing.found === false, verdict, nearest }
317
+ }
318
+
319
+ return { writeMessage, writeSummary, writeSessionNode, writeWorkUnit, findCandidates, messageName, summaryName }
320
+ }