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/LICENSE +21 -0
- package/README.md +454 -0
- package/cordis.patch.yml +20 -0
- package/lib/client.js +1081 -0
- package/lib/client.js.map +1 -0
- package/package.json +90 -0
- package/skills/hypatia/SKILL.md +585 -0
- package/skills/hypatia-dream/SKILL.md +246 -0
- package/skills/hypatia-dream/evals/evals.json +92 -0
- package/skills/hypatia-memory/SKILL.md +175 -0
- package/src/auto-approve.js +133 -0
- package/src/cascade.js +190 -0
- package/src/client/SettingsCard.module.css +420 -0
- package/src/client/SettingsCard.tsx +700 -0
- package/src/client/card-form.ts +450 -0
- package/src/client/consolidation-models.ts +69 -0
- package/src/client/css-modules.d.ts +4 -0
- package/src/client/index.tsx +106 -0
- package/src/client/locales.ts +111 -0
- package/src/client/shelves.ts +88 -0
- package/src/client/slot-contract.ts +17 -0
- package/src/collector.js +596 -0
- package/src/config.js +225 -0
- package/src/consolidator.js +592 -0
- package/src/content-policy.js +301 -0
- package/src/housekeeping.js +197 -0
- package/src/hypatia-cli.js +294 -0
- package/src/hypatia-client.js +72 -0
- package/src/hypatia-mcp.js +477 -0
- package/src/index.js +418 -0
- package/src/persisted-session.js +99 -0
- package/src/progress.js +56 -0
- package/src/queue.js +466 -0
- package/src/recall.js +148 -0
- package/src/shelf.js +180 -0
- package/src/skills.js +166 -0
- package/src/state.js +87 -0
- package/src/status.js +53 -0
- package/src/writer.js +320 -0
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
|
+
}
|