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
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write-time content policy — pure functions, fully unit-testable.
|
|
3
|
+
*
|
|
4
|
+
* Implements the hypatia-memory protocol's write transforms:
|
|
5
|
+
* secret redaction, relative-to-absolute dates, assistant-message capping,
|
|
6
|
+
* error one-lining, slug sanitizing, and the chars/4 token estimator.
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-hypatia-auto-memory/content-policy
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* One redaction rule: pattern plus the label used in the placeholder.
|
|
13
|
+
*
|
|
14
|
+
* `keepKey` rules capture four groups — (key)(separator)(quote)(value) — so the
|
|
15
|
+
* replacement can keep the readable key and any quoting while masking only the
|
|
16
|
+
* value. The `(?!\[REDACTED)` guard on those values stops a later rule from
|
|
17
|
+
* re-masking a placeholder an earlier rule already wrote.
|
|
18
|
+
*/
|
|
19
|
+
const SECRET_RULES = [
|
|
20
|
+
{ label: 'secret:key', re: /\bsk-[A-Za-z0-9_-]{8,}\b/g },
|
|
21
|
+
// JWTs: three base64url segments, the first always starting `eyJ` ('{"').
|
|
22
|
+
{ label: 'secret:jwt', re: /\beyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\b/g },
|
|
23
|
+
{ label: 'secret:bearer', re: /\bBearer\s+[A-Za-z0-9._~+/=-]{8,}\b/gi },
|
|
24
|
+
// Catch-all for the schemes `secret:bearer` does not cover (Basic, Token, …).
|
|
25
|
+
// The value runs to end of line, not to the first space: stopping at `\S+`
|
|
26
|
+
// would mask only the scheme word and leave the credential itself in place.
|
|
27
|
+
// Runs after `secret:bearer` so the more specific label wins, and the
|
|
28
|
+
// `(?!\[REDACTED)` guard then makes this a no-op on what that rule wrote.
|
|
29
|
+
{ label: 'secret:authorization', re: /\b(Authorization)(\s*:\s*)()((?!\[REDACTED)[^\s\n][^\n]{3,})/gi, keepKey: true },
|
|
30
|
+
{ label: 'secret:aws', re: /\b(?:AKIA|ASIA)[A-Z0-9]{12,}\b/g },
|
|
31
|
+
{ label: 'secret:github', re: /\bgh[pousr]_[A-Za-z0-9]{8,}\b/g },
|
|
32
|
+
{ label: 'secret:gitlab', re: /\bglpat-[A-Za-z0-9_-]{8,}\b/g },
|
|
33
|
+
{ label: 'secret:slack', re: /\bxox[baprs]-[A-Za-z0-9-]{6,}\b/g },
|
|
34
|
+
// Accepts `=` or `:` and an optional matching quote pair, so `password="s3cret"`
|
|
35
|
+
// and `token: s3cret` redact as readily as the bare `token=s3cret` form.
|
|
36
|
+
// The lookbehind is load-bearing: placeholders are spelled
|
|
37
|
+
// `[REDACTED:secret:<label>]`, and without it this rule matches the `secret:`
|
|
38
|
+
// inside one an earlier rule already wrote, nesting placeholders.
|
|
39
|
+
{
|
|
40
|
+
label: 'secret:credential',
|
|
41
|
+
re: /(?<!\[REDACTED:)\b(apiKey|api_key|password|passwd|pwd|token|secret|access_token|refresh_token|client_secret)(\s*[=:]\s*)(["']?)((?!\[REDACTED)[^\s&'",;]{4,})\3/gi,
|
|
42
|
+
keepKey: true,
|
|
43
|
+
},
|
|
44
|
+
{ label: 'secret:pem', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g },
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Redact known credential shapes. Replaces each match with a labeled
|
|
49
|
+
* placeholder so the surrounding text stays readable and the redaction is
|
|
50
|
+
* itself auditable. Pure: returns a new string.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} text
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
export function redactSecrets(text) {
|
|
56
|
+
let out = text
|
|
57
|
+
for (const rule of SECRET_RULES) {
|
|
58
|
+
const placeholder = `[REDACTED:${rule.label}]`
|
|
59
|
+
out = rule.keepKey === true
|
|
60
|
+
? out.replace(rule.re, (_match, key, separator, quote) => `${key}${separator}${quote}${placeholder}${quote}`)
|
|
61
|
+
: out.replace(rule.re, placeholder)
|
|
62
|
+
}
|
|
63
|
+
return out
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const pad = (n) => String(n).padStart(2, '0')
|
|
67
|
+
|
|
68
|
+
/** YYYY-MM-DD in local time. */
|
|
69
|
+
export function formatDate(d) {
|
|
70
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** YYYY-MM-DD HH:mm in local time. */
|
|
74
|
+
export function formatDateTime(d) {
|
|
75
|
+
return `${formatDate(d)} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const DAY_MS = 86_400_000
|
|
79
|
+
const HOUR_MS = 3_600_000
|
|
80
|
+
const MINUTE_MS = 60_000
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The moment an event happened, as the base for resolving relative dates in it.
|
|
84
|
+
*
|
|
85
|
+
* "明天" means the day after the message was SENT, not the day after it was
|
|
86
|
+
* written to memory. The two differ by minutes when a span waits for its turn
|
|
87
|
+
* to end, and by days when a task is deferred until its session is reopened —
|
|
88
|
+
* absolutizing against write time would then record the wrong date outright.
|
|
89
|
+
*
|
|
90
|
+
* @param {{time?: number} | undefined} event
|
|
91
|
+
* @param {Date} fallback - used when the event carries no usable timestamp.
|
|
92
|
+
* @returns {Date}
|
|
93
|
+
*/
|
|
94
|
+
export function eventDate(event, fallback) {
|
|
95
|
+
return typeof event?.time === 'number' && Number.isFinite(event.time) ? new Date(event.time) : fallback
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Monday of the ISO week containing `d`. */
|
|
99
|
+
function weekMonday(d) {
|
|
100
|
+
const copy = new Date(d.getTime())
|
|
101
|
+
copy.setHours(0, 0, 0, 0)
|
|
102
|
+
copy.setDate(copy.getDate() - ((copy.getDay() + 6) % 7))
|
|
103
|
+
return copy
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function dayOffset(now, days, withTime) {
|
|
107
|
+
const d = new Date(now.getTime() + days * DAY_MS)
|
|
108
|
+
return withTime ? formatDateTime(d) : formatDate(d)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function unitOffset(now, count, unitMs, withTime) {
|
|
112
|
+
return formatDateTime(new Date(now.getTime() + count * unitMs))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Convert relative date/time expressions to absolute ones against `now`.
|
|
117
|
+
* Conservative: only rewrites high-confidence shapes; unknown phrasings pass
|
|
118
|
+
* through untouched. Handles both Chinese and English expressions.
|
|
119
|
+
*
|
|
120
|
+
* @param {string} text
|
|
121
|
+
* @param {Date} now - the write time everything is absolutized against.
|
|
122
|
+
* @returns {string}
|
|
123
|
+
*/
|
|
124
|
+
export function absolutizeDates(text, now) {
|
|
125
|
+
const thisMonday = weekMonday(now)
|
|
126
|
+
const monday = (weeks) => formatDate(new Date(thisMonday.getTime() + weeks * 7 * DAY_MS))
|
|
127
|
+
let out = text
|
|
128
|
+
|
|
129
|
+
out = out
|
|
130
|
+
.replace(/今天/g, formatDate(now))
|
|
131
|
+
.replace(/昨天/g, () => dayOffset(now, -1))
|
|
132
|
+
.replace(/明天/g, () => dayOffset(now, 1))
|
|
133
|
+
.replace(/上周/g, () => `the week of ${monday(-1)}`)
|
|
134
|
+
.replace(/本周|这周/g, () => `the week of ${monday(0)}`)
|
|
135
|
+
.replace(/下周/g, () => `the week of ${monday(1)}`)
|
|
136
|
+
.replace(/刚才|现在/g, () => formatDateTime(now))
|
|
137
|
+
.replace(/(\d{1,4})\s*天\s*(前)/g, (_m, n) => formatDate(new Date(now.getTime() - Number(n) * DAY_MS)))
|
|
138
|
+
.replace(/(\d{1,4})\s*天\s*(后)/g, (_m, n) => formatDate(new Date(now.getTime() + Number(n) * DAY_MS)))
|
|
139
|
+
.replace(/(\d{1,4})\s*小时\s*(前)/g, (_m, n) => unitOffset(now, -Number(n), HOUR_MS))
|
|
140
|
+
.replace(/(\d{1,4})\s*小时\s*(后)/g, (_m, n) => unitOffset(now, Number(n), HOUR_MS))
|
|
141
|
+
.replace(/(\d{1,4})\s*分钟\s*(前)/g, (_m, n) => unitOffset(now, -Number(n), MINUTE_MS))
|
|
142
|
+
.replace(/(\d{1,4})\s*分钟\s*(后)/g, (_m, n) => unitOffset(now, Number(n), MINUTE_MS))
|
|
143
|
+
.replace(/\btoday\b/gi, () => formatDate(now))
|
|
144
|
+
.replace(/\byesterday\b/gi, () => dayOffset(now, -1))
|
|
145
|
+
.replace(/\btomorrow\b/gi, () => dayOffset(now, 1))
|
|
146
|
+
.replace(/\b(\d{1,4})\s+days?\s+ago\b/gi, (_m, n) => formatDate(new Date(now.getTime() - Number(n) * DAY_MS)))
|
|
147
|
+
.replace(/\bin\s+(\d{1,4})\s+days?\b/gi, (_m, n) => formatDate(new Date(now.getTime() + Number(n) * DAY_MS)))
|
|
148
|
+
.replace(/\b(\d{1,4})\s+hours?\s+ago\b/gi, (_m, n) => unitOffset(now, -Number(n), HOUR_MS))
|
|
149
|
+
.replace(/\b(\d{1,4})\s+minutes?\s+ago\b/gi, (_m, n) => unitOffset(now, -Number(n), MINUTE_MS))
|
|
150
|
+
|
|
151
|
+
return out
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Stack-trace line prefixes stripped by oneLineError. */
|
|
155
|
+
const STACK_LINE = /^\s*(at\s|Traceback|File ".*", line \d+|stack backtrace:|note:|Caused by:|===\s*$|\.\.\.\s*$)/
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Collapse an error/tool output to a single-line description: drop stack
|
|
159
|
+
* frames, keep the first meaningful line, cap the length.
|
|
160
|
+
*
|
|
161
|
+
* @param {string} text
|
|
162
|
+
* @param {number} [cap] - maximum length of the result.
|
|
163
|
+
* @returns {string}
|
|
164
|
+
*/
|
|
165
|
+
export function oneLineError(text, cap = 200) {
|
|
166
|
+
const lines = String(text).split(/\r?\n/)
|
|
167
|
+
let first = ''
|
|
168
|
+
let last = ''
|
|
169
|
+
for (const line of lines) {
|
|
170
|
+
if (STACK_LINE.test(line)) continue
|
|
171
|
+
const trimmed = line.trim()
|
|
172
|
+
if (trimmed === '') continue
|
|
173
|
+
if (first === '') first = trimmed
|
|
174
|
+
last = trimmed
|
|
175
|
+
}
|
|
176
|
+
const chosen = first === last || last === '' ? first : `${first} → ${last}`
|
|
177
|
+
return chosen.length > cap ? `${chosen.slice(0, cap - 1)}…` : chosen
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Sanitize a model-proposed or derived entry name: lowercase, collapse
|
|
182
|
+
* non-alphanumeric (keeping CJK) runs to single dashes, trim, cap length.
|
|
183
|
+
*
|
|
184
|
+
* @param {string} name
|
|
185
|
+
* @param {number} [cap]
|
|
186
|
+
* @returns {string}
|
|
187
|
+
*/
|
|
188
|
+
export function sanitizeSlug(name, cap = 60) {
|
|
189
|
+
const slug = String(name)
|
|
190
|
+
.toLowerCase()
|
|
191
|
+
.replace(/[^a-z0-9\u4e00-\u9fff]+/g, '-')
|
|
192
|
+
.replace(/^-+|-+$/g, '')
|
|
193
|
+
.slice(0, cap)
|
|
194
|
+
.replace(/-+$/, '')
|
|
195
|
+
return slug === '' ? 'memory' : slug
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Project scope of a session whose directory name is empty — one at `/`. No
|
|
200
|
+
* directory name contains a slash, so it can never be some real project's.
|
|
201
|
+
*/
|
|
202
|
+
export const ROOT_PROJECT = '/'
|
|
203
|
+
|
|
204
|
+
/** Unicode White_Space, which Rust's `str::trim` strips — and so hypatia, from each scope. */
|
|
205
|
+
const HYPATIA_TRIM = /^[\t-\r \u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+|[\t-\r \u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+$/g
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Trim `text` as hypatia trims a scope or tag. `String.prototype.trim` is not
|
|
209
|
+
* the same set: it also strips U+FEFF, which hypatia keeps, and keeps U+0085,
|
|
210
|
+
* which hypatia strips.
|
|
211
|
+
*
|
|
212
|
+
* @param {string} text
|
|
213
|
+
* @returns {string}
|
|
214
|
+
*/
|
|
215
|
+
export function trimLikeHypatia(text) {
|
|
216
|
+
return text.replace(HYPATIA_TRIM, '')
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The project scope for a directory name, in a form hypatia stores exactly
|
|
221
|
+
* as queried.
|
|
222
|
+
*
|
|
223
|
+
* Every entry is written with `scopes: [project]` and found again with
|
|
224
|
+
* `["$contains", "scopes", project]`, an exact array-membership test. hypatia
|
|
225
|
+
* splits a written scope list on commas and trims each item, and stores `""`
|
|
226
|
+
* as no scope at all, so these names were stored as something the name's own
|
|
227
|
+
* queries miss: `a,b` as `["a", "b"]` (found under `a` and `b` instead),
|
|
228
|
+
* `foo,` as `["foo", ""]` (global as well), ` padded ` as `padded`, and the
|
|
229
|
+
* empty name of `/` as no scope at all. Messages were still summarized — consolidation reads the session log —
|
|
230
|
+
* but the cascade above the first tier and recall filter by scope and passed
|
|
231
|
+
* them by. Any other name comes back unchanged, so no scope already in use
|
|
232
|
+
* moves.
|
|
233
|
+
*
|
|
234
|
+
* @param {string} name
|
|
235
|
+
* @returns {string}
|
|
236
|
+
*/
|
|
237
|
+
export function projectScope(name) {
|
|
238
|
+
const scope = trimLikeHypatia(String(name ?? '').replaceAll(',', '_'))
|
|
239
|
+
return scope === '' ? ROOT_PROJECT : scope
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Cap assistant message text at `maxChars`, leaving an explicit truncation
|
|
244
|
+
* marker so later readers know the entry is partial.
|
|
245
|
+
*
|
|
246
|
+
* @param {string} text
|
|
247
|
+
* @param {number} maxChars
|
|
248
|
+
* @returns {string}
|
|
249
|
+
*/
|
|
250
|
+
export function capAssistant(text, maxChars) {
|
|
251
|
+
if (text.length <= maxChars) return text
|
|
252
|
+
const omitted = text.length - maxChars
|
|
253
|
+
return `${text.slice(0, maxChars)}\n\n[...truncated ${omitted} chars]`
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The protocol's agent-side token estimator: chars / 4, rounded up. */
|
|
257
|
+
export function estimateTokens(text) {
|
|
258
|
+
return Math.ceil(String(text).length / 4)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Flatten a session ContentBlock[] to plain text for logging: text blocks
|
|
263
|
+
* verbatim, images marked, tool-call blocks dropped, other block types
|
|
264
|
+
* summarized by their type name.
|
|
265
|
+
*
|
|
266
|
+
* `tool-result` blocks nest the real payload one level down (a ToolResultBlock
|
|
267
|
+
* carries its own ContentBlock[]), so they are recursed into rather than
|
|
268
|
+
* summarized — reading only the outer level yields the empty string for every
|
|
269
|
+
* tool outcome, which is what the ledger and the consolidation transcript used
|
|
270
|
+
* to record.
|
|
271
|
+
*
|
|
272
|
+
* @param {readonly any[]} blocks
|
|
273
|
+
* @returns {string}
|
|
274
|
+
*/
|
|
275
|
+
export function blocksToText(blocks) {
|
|
276
|
+
const parts = []
|
|
277
|
+
for (const block of blocks ?? []) {
|
|
278
|
+
if (block.type === 'text') parts.push(block.text)
|
|
279
|
+
else if (block.type === 'image') parts.push('[image]')
|
|
280
|
+
else if (block.type === 'thinking') parts.push(`[thinking: ${String(block.text ?? '').slice(0, 200)}]`)
|
|
281
|
+
else if (block.type === 'tool-result') parts.push(blocksToText(block.content ?? []))
|
|
282
|
+
// A tool-call block is the model asking for a tool, not something it said.
|
|
283
|
+
// Rendered as `[tool-call]` it filled the content of half the stored
|
|
284
|
+
// assistant entries with placeholders; the ledger already lists the calls.
|
|
285
|
+
else if (block.type === 'tool-call') continue
|
|
286
|
+
else parts.push(`[${block.type}]`)
|
|
287
|
+
}
|
|
288
|
+
return parts.join('\n')
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Model-facing text of one `tool/result` event. The payload is a
|
|
293
|
+
* ToolResultMessage whose single `tool-result` block wraps the tool's own
|
|
294
|
+
* blocks; `blocksToText` unwraps that nesting.
|
|
295
|
+
*
|
|
296
|
+
* @param {any} event - a `tool/result` session event.
|
|
297
|
+
* @returns {string}
|
|
298
|
+
*/
|
|
299
|
+
export function flattenToolResult(event) {
|
|
300
|
+
return blocksToText(event?.data?.message?.content ?? [])
|
|
301
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Startup housekeeping for the progress and task tables.
|
|
3
|
+
*
|
|
4
|
+
* A progress row is this plugin's only record of how far a session has been
|
|
5
|
+
* logged and consolidated. It lives in DSH's storage, not in hypatia, so the two
|
|
6
|
+
* can drift apart: a shelf that is replaced or wiped leaves every row claiming
|
|
7
|
+
* work that no longer exists (a live profile had 7 of its 10 rows in that
|
|
8
|
+
* state), and a session that disappears from DSH leaves a row, and possibly
|
|
9
|
+
* tasks, that nothing will ever need again.
|
|
10
|
+
*
|
|
11
|
+
* Both passes are deliberately conservative, because resetting a row is not
|
|
12
|
+
* free: the session is re-logged from the start (writes are get-before-create,
|
|
13
|
+
* so no duplicates, but one CLI round trip per message) and re-consolidated
|
|
14
|
+
* from the start (a fresh summary under a new name, and a model call). So a row
|
|
15
|
+
* is only touched on POSITIVE evidence, and any doubt — a failed query, an
|
|
16
|
+
* empty session listing — leaves it alone.
|
|
17
|
+
*
|
|
18
|
+
* @module dsh-hypatia-auto-memory/housekeeping
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { projectScope } from './content-policy.js'
|
|
22
|
+
import { EMPTY_PROGRESS, advanceProgress } from './progress.js'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Reset every row whose session has no `msg-*` entry left in the shelf.
|
|
26
|
+
*
|
|
27
|
+
* Positive evidence only: a row is reset when the watermark says messages were
|
|
28
|
+
* logged AND the shelf query succeeded AND it returned nothing for that
|
|
29
|
+
* session. A query that fails proves nothing about the shelf — hypatia could be
|
|
30
|
+
* missing, or its database down — so that row is left exactly as it was.
|
|
31
|
+
*
|
|
32
|
+
* Trade-off, by design: a session whose messages were ALL deliberately deleted
|
|
33
|
+
* looks the same as one whose shelf was wiped, and will be logged again the next
|
|
34
|
+
* time it is active. Disable with `housekeeping.reconcileOnStartup: false`.
|
|
35
|
+
*
|
|
36
|
+
* @param {{
|
|
37
|
+
* progress: any,
|
|
38
|
+
* cli: { query: (jse: string) => Promise<any[]> },
|
|
39
|
+
* status: import('./status.js').StatusLog,
|
|
40
|
+
* }} deps
|
|
41
|
+
* @returns {Promise<{checked: number, reset: string[], unverified: number}>}
|
|
42
|
+
*/
|
|
43
|
+
export async function reconcileProgress({ progress, cli, status }) {
|
|
44
|
+
if (typeof progress.entries !== 'function') return { checked: 0, reset: [], unverified: 0 }
|
|
45
|
+
const reset = []
|
|
46
|
+
let checked = 0
|
|
47
|
+
let unverified = 0
|
|
48
|
+
// Snapshot first: resetting rows while iterating the live table would be
|
|
49
|
+
// iterating a collection that is being written.
|
|
50
|
+
for (const [sessionId, row] of [...progress.entries()]) {
|
|
51
|
+
if ((row?.lastLoggedSeq ?? 0) <= 0) continue
|
|
52
|
+
checked += 1
|
|
53
|
+
let rows
|
|
54
|
+
try {
|
|
55
|
+
rows = await cli.query(JSON.stringify({ $knowledge: [['$like', 'name', `msg-${sessionId}-%`]], limit: 1 }))
|
|
56
|
+
} catch (error) {
|
|
57
|
+
unverified += 1
|
|
58
|
+
status.warn(`progress reconcile skipped ${sessionId}: shelf query failed (${String(error)})`)
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
if (rows.length > 0) continue
|
|
62
|
+
await advanceProgress(progress, sessionId, () => ({ ...EMPTY_PROGRESS }))
|
|
63
|
+
reset.push(sessionId)
|
|
64
|
+
}
|
|
65
|
+
if (reset.length > 0) {
|
|
66
|
+
status.info(`progress reconcile: reset ${reset.length} session(s) with no entries left in the shelf; they re-log on their next activity`)
|
|
67
|
+
}
|
|
68
|
+
return { checked, reset, unverified }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Remove the progress row and tasks of every session DSH no longer knows.
|
|
73
|
+
*
|
|
74
|
+
* `knownSessionIds` must be DSH's full persisted listing. An EMPTY listing is
|
|
75
|
+
* treated as "unknown", never as "no sessions exist": a persistence layer that
|
|
76
|
+
* has not finished loading, or failed quietly, would otherwise delete every row
|
|
77
|
+
* in one pass. Sessions currently live are always kept.
|
|
78
|
+
*
|
|
79
|
+
* @param {{
|
|
80
|
+
* progress: any,
|
|
81
|
+
* queue: { sessionIds: () => Set<string>, forgetSession: (id: string) => Promise<number> },
|
|
82
|
+
* knownSessionIds: Set<string>,
|
|
83
|
+
* isLive?: (sessionId: string) => boolean,
|
|
84
|
+
* status: import('./status.js').StatusLog,
|
|
85
|
+
* }} deps
|
|
86
|
+
* @returns {Promise<{removed: string[], skipped: boolean}>}
|
|
87
|
+
*/
|
|
88
|
+
export async function pruneVanishedSessions({ progress, queue, knownSessionIds, isLive = () => false, status }) {
|
|
89
|
+
if (knownSessionIds.size === 0) {
|
|
90
|
+
status.warn('progress prune skipped: DSH reported no persisted sessions, which is not proof that none exist')
|
|
91
|
+
return { removed: [], skipped: true }
|
|
92
|
+
}
|
|
93
|
+
const candidates = new Set(typeof progress.entries === 'function' ? [...progress.entries()].map(([id]) => id) : [])
|
|
94
|
+
for (const id of queue.sessionIds()) candidates.add(id)
|
|
95
|
+
|
|
96
|
+
const removed = []
|
|
97
|
+
for (const sessionId of candidates) {
|
|
98
|
+
if (knownSessionIds.has(sessionId) || isLive(sessionId)) continue
|
|
99
|
+
await queue.forgetSession(sessionId)
|
|
100
|
+
if (progress.get(sessionId) !== undefined) await progress.delete(sessionId)
|
|
101
|
+
removed.push(sessionId)
|
|
102
|
+
}
|
|
103
|
+
if (removed.length > 0) {
|
|
104
|
+
status.info(`progress prune: removed ${removed.length} session(s) DSH no longer has`)
|
|
105
|
+
}
|
|
106
|
+
return { removed, skipped: false }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Consolidate a logged tail that the in-session trigger never reached.
|
|
111
|
+
*
|
|
112
|
+
* The session-end trigger cannot survive a process restart. DSH does run its
|
|
113
|
+
* close path at shutdown — it appends `session/end-seed` to the open session —
|
|
114
|
+
* but nothing this plugin enqueues there becomes durable before the process is
|
|
115
|
+
* gone: measured on a live restart, the storage file was not written at all and
|
|
116
|
+
* the session came back with `lastConsolidatedSeq: 0`. A short session that
|
|
117
|
+
* never reached the turn/token thresholds thus produced no knowledge, which is
|
|
118
|
+
* exactly the case the session-end trigger was added for.
|
|
119
|
+
*
|
|
120
|
+
* Gated on the SAME floor as the live trigger and read from the row, not by
|
|
121
|
+
* loading the session: `pendingTokens` is the estimate accumulated up to the
|
|
122
|
+
* last turn that ended, so a tail too small to be worth a model call while the
|
|
123
|
+
* session was alive stays too small here. Without that gate every restart would
|
|
124
|
+
* spend one model call per session carrying any tail at all.
|
|
125
|
+
*
|
|
126
|
+
* A session DSH no longer lists is skipped (`cwdFor` returns undefined) — its
|
|
127
|
+
* row belongs to {@link pruneVanishedSessions}. One that is listed but not
|
|
128
|
+
* loaded is fine: the executor defers the task until that session is opened.
|
|
129
|
+
*
|
|
130
|
+
* @param {{
|
|
131
|
+
* progress: any,
|
|
132
|
+
* queue: { enqueue: (spec: any) => Promise<void> },
|
|
133
|
+
* cwdFor: (sessionId: string) => string | undefined,
|
|
134
|
+
* projectForCwd: (cwd: string) => Promise<string>,
|
|
135
|
+
* minNewTokens: number,
|
|
136
|
+
* status: import('./status.js').StatusLog,
|
|
137
|
+
* }} deps
|
|
138
|
+
* @returns {Promise<{enqueued: string[], skippedBelowFloor: number}>}
|
|
139
|
+
*/
|
|
140
|
+
export async function backfillConsolidation({ progress, queue, cwdFor, projectForCwd, minNewTokens, status }) {
|
|
141
|
+
if (typeof progress.entries !== 'function') return { enqueued: [], skippedBelowFloor: 0 }
|
|
142
|
+
const enqueued = []
|
|
143
|
+
let skippedBelowFloor = 0
|
|
144
|
+
for (const [sessionId, row] of [...progress.entries()]) {
|
|
145
|
+
const fromSeq = row?.lastConsolidatedSeq ?? 0
|
|
146
|
+
const toSeq = row?.lastLoggedSeq ?? 0
|
|
147
|
+
if (toSeq <= fromSeq) continue
|
|
148
|
+
if ((row?.pendingTokens ?? 0) < minNewTokens) {
|
|
149
|
+
skippedBelowFloor += 1
|
|
150
|
+
continue
|
|
151
|
+
}
|
|
152
|
+
const cwd = cwdFor(sessionId)
|
|
153
|
+
if (cwd === undefined) continue
|
|
154
|
+
const project = await projectForCwd(cwd)
|
|
155
|
+
await queue.enqueue({ kind: 'consolidate', sessionId, fromSeq, toSeq, project, immediate: true })
|
|
156
|
+
enqueued.push(sessionId)
|
|
157
|
+
}
|
|
158
|
+
if (enqueued.length > 0) {
|
|
159
|
+
status.info(`consolidation backfill: queued ${enqueued.length} session(s) whose logged tail was never consolidated`)
|
|
160
|
+
}
|
|
161
|
+
return { enqueued, skippedBelowFloor }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Rewrite each persisted task's project to the scope `projectScope` makes of
|
|
166
|
+
* it.
|
|
167
|
+
*
|
|
168
|
+
* A task carries the project it was queued under, and its executor writes with
|
|
169
|
+
* it as stored. One queued before project names became scopes — for a session
|
|
170
|
+
* at `/`, or in a directory whose name has a comma — would otherwise write one
|
|
171
|
+
* more span that no query of its project finds. Every kind of task is queued
|
|
172
|
+
* with a project, so an empty one can only be the `/` case. Run before any
|
|
173
|
+
* executor is registered, so no task is read mid-rewrite.
|
|
174
|
+
*
|
|
175
|
+
* @param {{tasks: any, status: import('./status.js').StatusLog}} deps
|
|
176
|
+
* @returns {Promise<number>} how many tasks were rewritten.
|
|
177
|
+
*/
|
|
178
|
+
export async function normalizeTaskProjects({ tasks, status }) {
|
|
179
|
+
if (typeof tasks.entries !== 'function') return 0
|
|
180
|
+
let rewritten = 0
|
|
181
|
+
for (const [id, record] of [...tasks.entries()]) {
|
|
182
|
+
// A failed task never runs again; it is kept, or pruned, as it stands.
|
|
183
|
+
if (typeof record?.project !== 'string' || record.status === 'failed') continue
|
|
184
|
+
const scope = projectScope(record.project)
|
|
185
|
+
if (scope === record.project) continue
|
|
186
|
+
try {
|
|
187
|
+
await tasks.update(id, (task) => ({ ...task, project: scope }))
|
|
188
|
+
rewritten += 1
|
|
189
|
+
} catch (error) {
|
|
190
|
+
// Best effort: the task then writes one span under the old name, which
|
|
191
|
+
// is what it would have done anyway. Not a reason to stop collecting.
|
|
192
|
+
status.warn(`could not rewrite the project of task ${id}: ${String(error)}`)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (rewritten > 0) status.info(`rewrote the project of ${rewritten} queued task(s) to a scope hypatia stores as given`)
|
|
196
|
+
return rewritten
|
|
197
|
+
}
|