dsh-hypatia-auto-memory 0.2.0 → 0.4.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.
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Durable, bounded record of the model attempts the background pipeline makes.
3
+ *
4
+ * Why this exists. The route one consolidation attempt runs on is chosen by a
5
+ * process-local round-robin cursor (`createConsolidationRouteSelector`) and,
6
+ * before this, was dropped the moment the call was made. "Which model actually
7
+ * summarised that span?" was then unanswerable from outside the process: the
8
+ * settings card shows the configured route LIST, the host logger goes to a
9
+ * terminal that scrolls away, and the usage ledger only folds agent turns — it
10
+ * cannot see a plugin's direct `llm.stream` call at all (see
11
+ * `@linxin666/dsh-usage` `usage-service.ts`, which folds `assistant/message`).
12
+ *
13
+ * The record is written at SELECTION time and updated when the attempt settles,
14
+ * so a row left at `pending` is itself evidence: the process died, or the
15
+ * attempt is still in flight. That distinguishes the three states the cursor
16
+ * alone cannot — selected, completed, and consumed-but-produced-nothing.
17
+ *
18
+ * It lives in its OWN storage domain rather than in the one holding watermarks
19
+ * and queued tasks. In that domain a record failing its schema rejects the whole
20
+ * open and silently disables the plugin (see the note on `taskTable` in
21
+ * state.js); diagnostics must never be able to take the authoritative data down
22
+ * with them. Here the blast radius is one bounded table nobody's correctness
23
+ * depends on. `openModelLog` therefore degrades to a logger-only log when the
24
+ * domain will not open, and callers keep working.
25
+ *
26
+ * @module dsh-hypatia-auto-memory/model-log
27
+ */
28
+
29
+ import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain'
30
+ import { z } from 'zod'
31
+
32
+ /** How many attempts to keep. A ring large enough to answer "the last run(s)". */
33
+ export const MODEL_CALL_LIMIT = 100
34
+
35
+ /**
36
+ * One model attempt. `outcome` is the state that matters:
37
+ *
38
+ * - `pending` — selected, not settled (in flight, or the process died).
39
+ * - `ok` — the call settled AND produced a usable result.
40
+ * - `incomplete` — settled without one: output cap, abort, refusal, a reply
41
+ * that did not parse, or one that parsed into nothing usable. The cursor WAS
42
+ * consumed and nothing was stored. This is a deliberate catch-all: from
43
+ * outside, every one of those looks the same, and a caller that reported `ok`
44
+ * for a stop that yielded no summary would make the table lie.
45
+ * - `error` — the call itself threw (provider/transport failure).
46
+ *
47
+ * `detail` carries a finish kind, a fixed label, or a provider error message —
48
+ * never message content and never model output. The plugin's whole log path is
49
+ * built to keep conversation text out of places a later reader can stumble
50
+ * into, and a `JSON.parse` failure is exactly where that leaks by accident:
51
+ * Node quotes the offending input in the message (`Unexpected token 'H', "Here
52
+ * is th"...`). Parse sites therefore record a fixed label, and `finish` caps
53
+ * whatever it is handed, because a provider `failure.message` can carry a whole
54
+ * HTTP body.
55
+ */
56
+ export const modelCallTable = domainTable(z.object({
57
+ /** Monotonic within the process; recovered from the stored rows on open. */
58
+ seq: z.number().step(1).min(1),
59
+ /** Epoch milliseconds at selection. */
60
+ at: z.number().default(0),
61
+ /** The `purpose` the call was made with ('memory-consolidation', …). */
62
+ purpose: z.string().default(''),
63
+ provider: z.string().default(''),
64
+ model: z.string().default(''),
65
+ outcome: z.string().default('pending'),
66
+ /** Wall time from selection to settle; 0 while pending. */
67
+ ms: z.number().min(0).default(0),
68
+ detail: z.string().default(''),
69
+ }))
70
+
71
+ /** Upper bound on `detail`; see the table doc. Applied on every write. */
72
+ export const DETAIL_MAX = 200
73
+
74
+ /**
75
+ * Diagnostics domain. Separate from `hypatia_auto_memory` on purpose (module
76
+ * doc); its own version line, so the authoritative domain's format never has to
77
+ * move for a table only a human reads.
78
+ */
79
+ export const diagnosticsSpec = defineDomain({
80
+ name: 'hypatia_auto_memory_diag',
81
+ version: 1,
82
+ // Table names must match /^[a-z][a-z0-9_]*$/ — the module-load guard rejects
83
+ // camelCase before any medium is touched.
84
+ tables: { model_calls: modelCallTable },
85
+ })
86
+
87
+ /**
88
+ * Fixed-width key so lexicographic order equals sequence order. A bare numeric
89
+ * key would be re-ordered by the JSON object it is stored in (integer-like keys
90
+ * sort first, numerically), which is why ordering is carried by the padded key
91
+ * AND by the `seq` field the reader sorts on.
92
+ * @param {number} seq
93
+ */
94
+ function keyOf(seq) {
95
+ return String(seq).padStart(12, '0')
96
+ }
97
+
98
+ /**
99
+ * The log a caller gets when none was injected (unit tests, a composition
100
+ * without storage). Call sites default to it, so none of them branches on
101
+ * observability being available.
102
+ */
103
+ export const NULL_MODEL_LOG = Object.freeze({
104
+ begin: () => ({ finish: async () => {} }),
105
+ recent: () => [],
106
+ })
107
+
108
+ /**
109
+ * @param {{
110
+ * table?: any,
111
+ * status: import('./status.js').StatusLog,
112
+ * limit?: number,
113
+ * now?: () => number,
114
+ * }} deps - `table` absent (or the domain failed to open) means: log lines
115
+ * still happen, nothing is stored, and no caller has to branch.
116
+ */
117
+ export function createModelLog({ table, status, limit = MODEL_CALL_LIMIT, now = Date.now }) {
118
+ let counter = 0
119
+ if (table !== undefined) {
120
+ // Every stored row passed the schema at open, so `seq` is always the number
121
+ // the padded key was derived from.
122
+ for (const [, row] of table.entries()) {
123
+ if (row.seq > counter) counter = row.seq
124
+ }
125
+ }
126
+
127
+ function onWriteError(error) {
128
+ status.warn(`model-call log write failed: ${String(error)}`)
129
+ }
130
+
131
+ /** Keep the newest `limit` rows; drop the rest oldest-first. */
132
+ async function prune() {
133
+ while (table.size > limit) {
134
+ let oldest
135
+ for (const [key, row] of table.entries()) {
136
+ if (oldest === undefined || row.seq < oldest.row.seq) oldest = { key, row }
137
+ }
138
+ if (oldest === undefined) return
139
+ await table.delete(oldest.key)
140
+ }
141
+ }
142
+
143
+ async function write(key, record) {
144
+ try {
145
+ await table.put(key, record)
146
+ await prune()
147
+ } catch (error) {
148
+ onWriteError(error)
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Record one attempt as it is selected, before the call is made.
154
+ *
155
+ * @param {string} purpose - The same string handed to `llm.stream`.
156
+ * @param {{provider: string, model: string}} route
157
+ * @returns {{seq: number, finish: (outcome: string, detail?: string) => Promise<void>}}
158
+ */
159
+ function begin(purpose, route) {
160
+ counter += 1
161
+ const seq = counter
162
+ const at = now()
163
+ const key = keyOf(seq)
164
+ const record = {
165
+ seq,
166
+ at,
167
+ purpose,
168
+ provider: String(route?.provider ?? ''),
169
+ model: String(route?.model ?? ''),
170
+ outcome: 'pending',
171
+ ms: 0,
172
+ detail: '',
173
+ }
174
+ // Durable before the call runs, so an attempt that never settles still
175
+ // leaves a row. Awaited by `finish`, never by the caller.
176
+ const stored = table === undefined ? Promise.resolve() : write(key, record)
177
+ return {
178
+ seq,
179
+ async finish(outcome, detail = '') {
180
+ // Never rejects, and callers do not await it: every one of them calls
181
+ // this from a `finally`, where a throw here would replace the error the
182
+ // queue was going to see, and a storage stall would hold the queue task
183
+ // open after its own timeout has already been cleared.
184
+ try {
185
+ await stored
186
+ const ms = Math.max(0, now() - at)
187
+ const bounded = String(detail).slice(0, DETAIL_MAX)
188
+ if (table !== undefined) {
189
+ await write(key, { ...record, outcome, ms, detail: bounded })
190
+ }
191
+ const suffix = bounded === '' ? '' : ` (${bounded})`
192
+ status.info(`model call: ${purpose} ${record.provider}/${record.model} ${outcome} ${ms}ms${suffix}`)
193
+ } catch (error) {
194
+ onWriteError(error)
195
+ }
196
+ },
197
+ }
198
+ }
199
+
200
+ /** Newest-first snapshot, for a human reading the state file or a card. */
201
+ function recent(count = 20) {
202
+ if (table === undefined) return []
203
+ return [...table.entries()]
204
+ .map(([, row]) => row)
205
+ .sort((a, b) => Number(b?.seq) - Number(a?.seq))
206
+ .slice(0, count)
207
+ }
208
+
209
+ return { begin, recent }
210
+ }
211
+
212
+ /**
213
+ * Open the diagnostics domain and build the log. Never throws: a domain that
214
+ * will not open costs the stored history, not the memory pipeline.
215
+ *
216
+ * @param {import('@deepseek-ai/cordis').Context} ctx - context injecting `storageDomain`.
217
+ * @param {{status: import('./status.js').StatusLog, limit?: number, now?: () => number}} deps
218
+ * @returns {Promise<{modelLog: ReturnType<typeof createModelLog>, domain?: any}>}
219
+ * `domain` is the open handle when there is one, so a caller that finds itself
220
+ * disposed can close it (mirrors `openState`).
221
+ */
222
+ export async function openModelLog(ctx, { status, limit, now }) {
223
+ let domain
224
+ try {
225
+ domain = await ctx.storageDomain.open(diagnosticsSpec)
226
+ } catch (error) {
227
+ status.warn(`model-call log unavailable, attempts will only be logged: ${String(error)}`)
228
+ return { modelLog: createModelLog({ status, limit, now }) }
229
+ }
230
+ try {
231
+ ctx.effect(() => () => {
232
+ void domain.close().catch(() => {
233
+ // Closing during teardown must never take the fiber down.
234
+ })
235
+ }, 'hypatia-auto-memory: close diagnostics domain')
236
+ } catch {
237
+ // The fiber went away while the domain was opening, so no disposer will
238
+ // ever run for it — closing here is the only thing that prevents a leak.
239
+ // Deliberately silent: the plugin is being torn down, and a warning about
240
+ // an unavailable log would be noise at exactly the moment nothing can act
241
+ // on it.
242
+ await domain.close().catch(() => {})
243
+ return { modelLog: createModelLog({ status, limit, now }) }
244
+ }
245
+ return { modelLog: createModelLog({ table: domain.table('model_calls'), status, limit, now }), domain }
246
+ }