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.
@@ -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
+ }