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.
- package/README.md +29 -14
- package/README.zh-CN.md +430 -0
- package/lib/client.js +33 -33
- package/package.json +1 -1
- package/src/cascade.js +29 -4
- package/src/collector.js +27 -1
- package/src/config.js +5 -1
- package/src/consolidator.js +165 -40
- package/src/content-policy.js +5 -1
- package/src/index.js +17 -0
- package/src/model-log.js +246 -0
package/src/model-log.js
ADDED
|
@@ -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
|
+
}
|