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/src/index.js ADDED
@@ -0,0 +1,418 @@
1
+ /**
2
+ * dsh-hypatia-auto-memory — event-driven Hypatia memory for DSH.
3
+ *
4
+ * One host plugin whose collect fiber owns the durable core (state domain,
5
+ * queue, hypatia client, writer, collector); optional child fibers attach the
6
+ * rest, so a deployment missing `llm` still logs conversations and a
7
+ * deployment missing `skills` still remembers:
8
+ *
9
+ * collect (inject sessions, storageDomain, subprocess, settings)
10
+ * ├─ consolidate (inject llm, sessions) — span summaries, cascade, work units
11
+ * ├─ recall (inject agents) — rules/taboos preload at session start
12
+ * ├─ housekeeping (inject sessionPersistence) — prune progress of vanished sessions
13
+ * ├─ auto-approve (inject approval, tools) — the agent's own bash `hypatia` calls
14
+ * ├─ skills (inject skills) — hypatia-memory, hypatia, hypatia-dream
15
+ * └─ shelf-inventory (inject settings) — `hypatia list` for the settings card
16
+ *
17
+ * The last two fibers are here because this plugin REPLACES `dsh-hypatia`:
18
+ * writing memory by asking the model to do it did not happen in practice, and
19
+ * the parts of that plugin still worth having — approving the agent's hypatia
20
+ * calls and shipping the CLI skills — only serve the retrieval half of this
21
+ * one. Installing both is a misconfiguration; see README.
22
+ *
23
+ * @module dsh-hypatia-auto-memory
24
+ */
25
+
26
+ import { dirname, join } from 'node:path'
27
+ import { fileURLToPath } from 'node:url'
28
+
29
+ import { absolutizeDates, blocksToText, eventDate, redactSecrets } from './content-policy.js'
30
+ import { INVENTORY_NAMESPACE, InventorySchema, installConfig } from './config.js'
31
+ import { openState } from './state.js'
32
+ import { EMPTY_PROGRESS, advanceProgress } from './progress.js'
33
+ import { createStatus } from './status.js'
34
+ import { createHypatiaClient } from './hypatia-client.js'
35
+ import { createWriter } from './writer.js'
36
+ import { TaskDeferredError, createQueue } from './queue.js'
37
+ import { countLoggableMessages, createCollector, formatSpan } from './collector.js'
38
+ import { createCascade } from './cascade.js'
39
+ import { createConsolidator, PLUGIN_NAME } from './consolidator.js'
40
+ import { createRecall } from './recall.js'
41
+ import { createPersistedSessions } from './persisted-session.js'
42
+ import { createAutoApprove } from './auto-approve.js'
43
+ import { registerSkills } from './skills.js'
44
+ import { backfillConsolidation, normalizeTaskProjects, pruneVanishedSessions, reconcileProgress } from './housekeeping.js'
45
+ import { publishShelfInventory, shelfTable } from './shelf.js'
46
+
47
+ export const name = 'dsh-hypatia-auto-memory'
48
+
49
+ const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
50
+ const DEFAULT_SKILLS_DIR = join(PACKAGE_ROOT, 'skills')
51
+
52
+ /* -------------------------------------------------------------------------- */
53
+ /* Composition */
54
+ /* -------------------------------------------------------------------------- */
55
+
56
+ export function apply(ctx, config = {}) {
57
+ ctx.plugin({
58
+ name: `${name}/collect`,
59
+ inject: ['sessions', 'storageDomain', 'subprocess', 'settings'],
60
+ apply: (collectCtx) => applyCollect(collectCtx, config),
61
+ })
62
+ }
63
+
64
+ function applyCollect(ctx, cordisConfig) {
65
+ const status = createStatus(ctx, name)
66
+ let disposed = false
67
+ ctx.effect(() => () => {
68
+ disposed = true
69
+ }, `${name}: dispose flag`)
70
+
71
+ void start().catch((error) => {
72
+ status.error('startup failed; plugin will not collect until the profile reloads', error)
73
+ })
74
+
75
+ async function start() {
76
+ const configHandle = installConfig(ctx)
77
+ if (configHandle.get().enabled === false) {
78
+ status.info('disabled via hypatia-auto-memory.enabled=false')
79
+ return
80
+ }
81
+
82
+ const openedState = await openState(ctx)
83
+ if (disposed) {
84
+ await openedState.domain.close().catch(() => {})
85
+ return
86
+ }
87
+
88
+ // Fixed for this run; see shelf.js. Every table read below goes through a
89
+ // view of this shelf's rows, so a switch never carries one shelf's
90
+ // watermarks or queued work into another.
91
+ const shelf = configHandle.get().shelf
92
+ const state = {
93
+ progress: shelfTable(openedState.progress, shelf),
94
+ tasks: shelfTable(openedState.tasks, shelf),
95
+ }
96
+ let announcedShelf = shelf
97
+ configHandle.onChange((value) => {
98
+ if (value.shelf === announcedShelf) return
99
+ announcedShelf = value.shelf
100
+ if (value.shelf !== shelf) {
101
+ status.info(`shelf changed to "${value.shelf}"; still writing to "${shelf}" until the profile reloads`)
102
+ }
103
+ })
104
+
105
+ const queue = createQueue({
106
+ tasks: state.tasks,
107
+ getConfig: () => configHandle.get().queue,
108
+ status,
109
+ })
110
+ const cli = createHypatiaClient(ctx, {
111
+ get binaries() {
112
+ return configHandle.get().binaries
113
+ },
114
+ get transport() {
115
+ return configHandle.get().transport
116
+ },
117
+ shelf,
118
+ }, { status })
119
+ // In-flight tasks drain (up to the queue's deadline) before the process
120
+ // they write through is closed.
121
+ ctx.effect(() => () => {
122
+ void queue.dispose().finally(() => cli.dispose())
123
+ }, `${name}: dispose queue and hypatia connection`)
124
+ // The configured transport; a binary without `mcp` is only found out, and
125
+ // warned about, at the first call.
126
+ status.info(`writing to shelf "${shelf}" (transport: ${configHandle.get().transport})`)
127
+
128
+ // Before the collector can queue anything and before any executor can run
129
+ // a task: see housekeeping.js.
130
+ await normalizeTaskProjects({ tasks: state.tasks, status })
131
+ if (disposed) return
132
+
133
+ // What the settings card offers as choices. Also the one place a missing
134
+ // shelf is noticed before the first write fails on it.
135
+ const inventory = publishShelfInventory({
136
+ ctx,
137
+ cli,
138
+ status,
139
+ namespace: INVENTORY_NAMESPACE,
140
+ schema: InventorySchema,
141
+ label: name,
142
+ })
143
+ configHandle.onChange(() => { void inventory.refresh() })
144
+ void inventory.refresh().then(({ shelves, error }) => {
145
+ if (error !== '') return
146
+ const found = shelves.find((entry) => entry.name === shelf)
147
+ if (found === undefined) status.warn(`shelf "${shelf}" is not registered with hypatia; every write will fail until it is (hypatia connect <dir> --name ${shelf})`)
148
+ else if (!found.connected) status.warn(`shelf "${shelf}" is registered but not connected; writes will fail until it is`)
149
+ })
150
+ // Late-bound: adjudication needs a model route, which only the consolidate
151
+ // fiber has. Without it the writer stores a unit with no relationship, which
152
+ // is the correct degradation — never a guessed one.
153
+ const shared = { consolidator: undefined }
154
+
155
+ const writer = createWriter(cli, {
156
+ status,
157
+ adjudicate: (unit, candidates) => shared.consolidator === undefined
158
+ ? Promise.resolve(undefined)
159
+ : shared.consolidator.adjudicate(unit, candidates),
160
+ })
161
+
162
+ const collector = createCollector({
163
+ ctx,
164
+ queue,
165
+ progress: state.progress,
166
+ getConfig: configHandle.get,
167
+ status,
168
+ onTurnEnd: (sessionId, turn, triggerState) => shared.consolidator === undefined
169
+ ? Promise.resolve({ resetTokens: false, advanceCheckpoint: false })
170
+ : shared.consolidator.onTurnEnd(sessionId, turn, triggerState),
171
+ onSessionEnd: (sessionId, session) => shared.consolidator === undefined
172
+ ? Promise.resolve(false)
173
+ : shared.consolidator.onSessionEnd(sessionId, session),
174
+ })
175
+
176
+ // Live store first, storage second. `session/created` — the signal that
177
+ // wakes a deferred task — fires only where an agent runs, so a finished
178
+ // session (and every subagent session) may never emit it again; storage
179
+ // supplies the same events without publishing anything. Late-bound: a
180
+ // composition without `sessionPersistence` keeps today's behavior, which is
181
+ // to wait. See persisted-session.js.
182
+ let loadPersisted = async () => undefined
183
+ const resolveSession = async (sessionId) => collector.sessionFor(sessionId) ?? await loadPersisted(sessionId)
184
+
185
+ ctx.plugin({
186
+ name: `${name}/persisted-sessions`,
187
+ inject: ['sessionPersistence'],
188
+ apply: (c) => {
189
+ const store = createPersistedSessions({ persistence: c.sessionPersistence, status })
190
+ loadPersisted = store.get
191
+ c.effect(() => () => {
192
+ loadPersisted = async () => undefined
193
+ store.clear()
194
+ }, `${name}: release persisted sessions`)
195
+ },
196
+ })
197
+
198
+ /** Log executor: re-read the span, format per protocol, write idempotently. */
199
+ queue.registerExecutor('log-message', async (task) => {
200
+ // Resolved through the collector, not `ctx.sessions`: a session closing
201
+ // down has already left the store while its final span is still queued.
202
+ const session = await resolveSession(task.sessionId)
203
+ if (session === undefined) {
204
+ throw new TaskDeferredError(`session ${task.sessionId} is not loaded; deferred until it is`)
205
+ }
206
+ const config = configHandle.get()
207
+ const events = session.snapshotEvents(task.fromSeq, task.toSeq)
208
+ // Entry names use a dense message ordinal, recomputed from the log prefix
209
+ // rather than carried in the watermark: the log is immutable, so counting
210
+ // is replay-stable by construction and cannot drift out of sync.
211
+ const inherited = session.inheritedEventCount ?? 0
212
+ const baseIndex = countLoggableMessages(session.snapshotEvents(inherited, task.fromSeq))
213
+ const formatted = formatSpan(events, {
214
+ now: new Date(),
215
+ maxAssistantChars: config.collector.maxAssistantChars,
216
+ maxUserChars: config.collector.maxUserChars,
217
+ toolLedger: config.collector.toolLedger,
218
+ baseIndex,
219
+ before: task.fromSeq > 0 ? session.snapshotEvents(task.fromSeq - 1, task.fromSeq)[0] : undefined,
220
+ })
221
+ for (const item of formatted) {
222
+ await writer.writeMessage({
223
+ sessionId: task.sessionId,
224
+ index: item.index,
225
+ markdown: item.markdown,
226
+ project: task.project,
227
+ })
228
+ }
229
+ const linkTo = baseIndex + formatted.length
230
+ // `belongTo` edges only exist once the session node does. Most sessions
231
+ // never produce one (it needs a host-supplied summary), so this stays a
232
+ // watermark check rather than a lookup per span.
233
+ const before = state.progress.get(task.sessionId) ?? EMPTY_PROGRESS
234
+ if (before.hasSessionNode === 1 && linkTo > before.lastBelongToIndex) {
235
+ await writer.writeSessionNode({
236
+ sessionId: task.sessionId,
237
+ markdown: '',
238
+ project: task.project,
239
+ linkFrom: before.lastBelongToIndex,
240
+ linkTo,
241
+ })
242
+ }
243
+ // Awaited: the task must not be reported done — and deleted — before the
244
+ // watermark that makes its range unnecessary is durable.
245
+ await advanceProgress(state.progress, task.sessionId, (current) => ({
246
+ lastLoggedSeq: Math.max(current.lastLoggedSeq, task.toSeq),
247
+ lastBelongToIndex: current.hasSessionNode === 1
248
+ ? Math.max(current.lastBelongToIndex, linkTo)
249
+ : current.lastBelongToIndex,
250
+ }))
251
+ })
252
+
253
+ /**
254
+ * Session-node executor: build `session-<id>` from the host summary at this
255
+ * seq, then link every message logged so far to it.
256
+ */
257
+ queue.registerExecutor('session-node', async (task) => {
258
+ const session = await resolveSession(task.sessionId)
259
+ if (session === undefined) {
260
+ throw new TaskDeferredError(`session ${task.sessionId} is not loaded; deferred until it is`)
261
+ }
262
+ const [event] = session.snapshotEvents(task.fromSeq, task.toSeq)
263
+ if (event === undefined) return
264
+ const summary = event.type === 'session/title'
265
+ ? String(event.data?.title ?? '')
266
+ : blocksToText(event.data?.summary ?? [])
267
+ const markdown = absolutizeDates(redactSecrets(summary), eventDate(event, new Date())).trim()
268
+ // The protocol forbids inventing a session summary: no text, no node.
269
+ if (markdown === '') return
270
+
271
+ const inherited = session.inheritedEventCount ?? 0
272
+ const current = state.progress.get(task.sessionId) ?? EMPTY_PROGRESS
273
+ const linkTo = countLoggableMessages(session.snapshotEvents(inherited, current.lastLoggedSeq))
274
+ await writer.writeSessionNode({
275
+ sessionId: task.sessionId,
276
+ markdown,
277
+ project: task.project,
278
+ linkFrom: current.lastBelongToIndex,
279
+ linkTo,
280
+ })
281
+ await advanceProgress(state.progress, task.sessionId, (progressRow) => ({
282
+ hasSessionNode: 1,
283
+ lastBelongToIndex: Math.max(progressRow.lastBelongToIndex, linkTo),
284
+ }))
285
+ })
286
+
287
+ // Failed log-message records carry nothing the watermark does not: the range
288
+ // was never marked logged, so the session's next flush covers it again.
289
+ // Pruned at startup so the error message survives the run that produced it.
290
+ const pruned = await queue.pruneFailed(['log-message'])
291
+ if (pruned > 0) status.info(`pruned ${pruned} failed log-message task(s); the watermarks re-derive their ranges`)
292
+
293
+ // Progress rows whose session left nothing in the shelf (a wiped or replaced
294
+ // shelf) are reset BEFORE backfill, so a live session among them is logged
295
+ // from the start in this very run. See housekeeping.js for why it only acts
296
+ // on positive evidence.
297
+ if (configHandle.get().housekeeping?.reconcileOnStartup !== false) {
298
+ await reconcileProgress({ progress: state.progress, cli, status })
299
+ }
300
+
301
+ await collector.backfillLiveSessions()
302
+ status.info('collector running (backfill complete)')
303
+
304
+ // Both passes need `sessionPersistence` for the full listing — live sessions
305
+ // alone would make every unloaded session look gone, and carry the cwd a
306
+ // scope is resolved from. A child fiber, registered only after the reconcile
307
+ // pass above so the two never act on one row at once. Each pass keeps its
308
+ // own switch: turning pruning off must not also stop consolidation backfill.
309
+ const housekeeping = configHandle.get().housekeeping ?? {}
310
+ if (housekeeping.pruneVanishedSessions !== false || housekeeping.backfillConsolidation !== false) {
311
+ ctx.plugin({
312
+ name: `${name}/housekeeping`,
313
+ inject: ['sessionPersistence'],
314
+ apply: (c) => {
315
+ void (async () => {
316
+ const headers = await c.sessionPersistence.list()
317
+ const cwdById = new Map()
318
+ for (const header of headers) {
319
+ const id = String(header?.id ?? '')
320
+ if (id !== '' && header?.cwd !== undefined) cwdById.set(id, String(header.cwd))
321
+ }
322
+ const knownSessionIds = new Set(headers.map((header) => String(header?.id ?? '')).filter((id) => id !== ''))
323
+ if (housekeeping.pruneVanishedSessions !== false) {
324
+ await pruneVanishedSessions({
325
+ progress: state.progress,
326
+ queue,
327
+ knownSessionIds,
328
+ isLive: (id) => ctx.sessions.get(id) !== undefined,
329
+ status,
330
+ })
331
+ }
332
+ // After pruning, so a vanished session's tail is never queued.
333
+ if (housekeeping.backfillConsolidation !== false) {
334
+ await backfillConsolidation({
335
+ progress: state.progress,
336
+ queue,
337
+ cwdFor: (id) => cwdById.get(id),
338
+ projectForCwd: collector.projectForCwd,
339
+ minNewTokens: configHandle.get().consolidation.minNewTokens,
340
+ status,
341
+ })
342
+ }
343
+ })().catch((error) => {
344
+ status.warn(`startup housekeeping skipped: ${String(error)}`)
345
+ })
346
+ },
347
+ })
348
+ }
349
+
350
+ // Optional child fibers — each waits only for the services it needs.
351
+ ctx.plugin({
352
+ name: `${name}/consolidate`,
353
+ inject: ['llm', 'sessions'],
354
+ apply: (c) => {
355
+ const consolidator = createConsolidator({
356
+ queue,
357
+ progress: state.progress,
358
+ // Same resolver as the log executor: end-of-session consolidation runs
359
+ // against a session the store has already released — and a startup
360
+ // backfill runs against one it may never publish again.
361
+ sessions: { get: resolveSession },
362
+ llm: c.llm,
363
+ cli,
364
+ writer,
365
+ getConfig: configHandle.get,
366
+ status,
367
+ projectFor: collector.projectFor,
368
+ })
369
+ queue.registerExecutor('consolidate', consolidator.execute)
370
+ // Shares the consolidator's route cursor so every model attempt — span
371
+ // summary, adjudication, archive — rotates through the selected routes
372
+ // together rather than each keeping its own place in the rotation.
373
+ const cascade = createCascade({
374
+ cli,
375
+ llm: c.llm,
376
+ selectRoute: consolidator.selectRoute,
377
+ getConfig: configHandle.get,
378
+ status,
379
+ })
380
+ queue.registerExecutor('cascade', cascade.execute)
381
+ shared.consolidator = consolidator
382
+ },
383
+ })
384
+
385
+ ctx.plugin({
386
+ name: `${name}/recall`,
387
+ inject: ['agents'],
388
+ apply: (c) => {
389
+ createRecall({ ctx: c, cli, shelf, getConfig: configHandle.get, status, projectFor: collector.projectFor })
390
+ },
391
+ })
392
+
393
+ // Auto-approve is for the agent's OWN bash `hypatia` calls — retrieval,
394
+ // explicit remember/forget. This plugin's writes never pass through it:
395
+ // they go over its own `hypatia mcp` connection (or argv arrays on the
396
+ // CLI fallback), with no shell, no tool call and no approval in the path.
397
+ if (configHandle.get().autoApprove !== false) {
398
+ ctx.plugin({
399
+ name: `${name}/auto-approve`,
400
+ inject: ['approval', 'tools'],
401
+ apply: (c) => {
402
+ createAutoApprove(c, { getBinaries: () => configHandle.get().binaries, status })
403
+ },
404
+ })
405
+ }
406
+
407
+ if (cordisConfig.skills !== false) {
408
+ ctx.plugin({
409
+ name: `${name}/skills`,
410
+ inject: ['skills'],
411
+ apply: (c) => {
412
+ void registerSkills(c, cordisConfig.skillsDir ?? DEFAULT_SKILLS_DIR, status, PLUGIN_NAME)
413
+ .catch((error) => status.warn(`skill registration failed: ${String(error)}`))
414
+ },
415
+ })
416
+ }
417
+ }
418
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Read-only sessions for work whose session may never be opened again.
3
+ *
4
+ * A queued task resolves its session through the live store, and one whose
5
+ * session is not loaded defers instead of failing — DSH loads sessions lazily,
6
+ * so "not loaded yet" is ordinary. The catch is what ends the wait:
7
+ * `session/created` is emitted only where an agent actually runs
8
+ * (`sessions.enter`/`announce` in agent-loop), so a finished session may never
9
+ * emit it again. Subagent sessions are the sharp case — the sidebar does not
10
+ * list them at all. Measured on a live profile: a subagent's consolidation sat
11
+ * `deferred` across a restart, and stayed deferred while its transcript was
12
+ * open on screen, because opening a transcript renders persisted events without
13
+ * publishing a Session.
14
+ *
15
+ * `sessionPersistence.load(id)` returns that same log without publishing
16
+ * anything, and the executors only ever use four members of a Session —
17
+ * `snapshotEvents`, `inheritedEventCount`, `seq`, `header`. So storage can
18
+ * serve them directly, and a tail nothing will reopen still becomes knowledge.
19
+ *
20
+ * @module dsh-hypatia-auto-memory/persisted-session
21
+ */
22
+
23
+ /** How many whole event logs may sit in memory at once. */
24
+ const DEFAULT_CACHE_SIZE = 4
25
+
26
+ /**
27
+ * Wrap one `SessionInspection` (`{meta, inheritedEventCount, events}`) as the
28
+ * read-only subset of `Session` the executors use.
29
+ *
30
+ * Ranges are seq-based, as in the live Session. The log is contiguous, so a
31
+ * slice is enough — but it is taken against the FIRST event's seq rather than
32
+ * array position, so a seeded or forked log (whose stored prefix need not start
33
+ * at 0) still answers the same seq range the watermark asks for.
34
+ *
35
+ * @param {{meta?: any, inheritedEventCount?: number, events?: readonly any[]}} inspection
36
+ * @returns {{header: any, inheritedEventCount: number, seq: number, snapshotEvents: (from?: number, to?: number) => readonly any[]} | undefined}
37
+ */
38
+ export function toReadOnlySession(inspection) {
39
+ const events = inspection?.events
40
+ if (Array.isArray(events) === false) return undefined
41
+ const base = events[0]?.seq ?? 0
42
+ const end = base + events.length
43
+ return {
44
+ header: inspection.meta,
45
+ inheritedEventCount: inspection.inheritedEventCount ?? 0,
46
+ seq: end,
47
+ snapshotEvents(from = base, to = end) {
48
+ return events.slice(Math.max(0, from - base), Math.max(0, to - base))
49
+ },
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Resolve sessions from persistence, with a small bounded cache.
55
+ *
56
+ * A read failure is reported and treated as "no session": the caller then
57
+ * defers exactly as before, which is the correct degradation — never a
58
+ * half-consolidated span written from a partial log.
59
+ *
60
+ * @param {{
61
+ * persistence: {load: (id: string) => Promise<any>},
62
+ * status: import('./status.js').StatusLog,
63
+ * cacheSize?: number,
64
+ * }} deps
65
+ */
66
+ export function createPersistedSessions({ persistence, status, cacheSize = DEFAULT_CACHE_SIZE }) {
67
+ /** @type {Map<string, any>} */
68
+ const cache = new Map()
69
+
70
+ return {
71
+ /**
72
+ * @param {string} sessionId
73
+ * @returns {Promise<any | undefined>}
74
+ */
75
+ async get(sessionId) {
76
+ const cached = cache.get(sessionId)
77
+ if (cached !== undefined) return cached
78
+ let loaded
79
+ try {
80
+ loaded = await persistence.load(sessionId)
81
+ } catch (error) {
82
+ status.warn(`persisted session ${sessionId} could not be read: ${String(error)}`)
83
+ return undefined
84
+ }
85
+ const session = toReadOnlySession(loaded)
86
+ if (session === undefined) return undefined
87
+ cache.set(sessionId, session)
88
+ // One entry is a whole event log; evict in insertion order.
89
+ if (cache.size > cacheSize) {
90
+ const oldest = cache.keys().next().value
91
+ if (oldest !== undefined) cache.delete(oldest)
92
+ }
93
+ return session
94
+ },
95
+ clear() {
96
+ cache.clear()
97
+ },
98
+ }
99
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Progress-watermark helpers — pure, dependency-free. Split out from state.js
3
+ * so queue/collector/consolidator logic stays importable (and unit-testable)
4
+ * without the DSH package graph.
5
+ *
6
+ * @module dsh-hypatia-auto-memory/progress
7
+ */
8
+
9
+ /** Default watermark for a session the plugin has never seen. */
10
+ export const EMPTY_PROGRESS = {
11
+ lastLoggedSeq: 0,
12
+ lastConsolidatedSeq: 0,
13
+ lastCheckTurn: 0,
14
+ pendingTokens: 0,
15
+ hasSessionNode: 0,
16
+ lastBelongToIndex: 0,
17
+ }
18
+
19
+ /** @type {WeakMap<object, Map<string, Promise<void>>>} per table, per session */
20
+ const chains = new WeakMap()
21
+
22
+ /**
23
+ * Read-modify-write one progress record with protocol defaults, so every
24
+ * writer (collector triggers, log executor, consolidation executor) composes
25
+ * without clobbering each other's fields.
26
+ *
27
+ * That guarantee needs two things the storage table does not give for free.
28
+ * Its `put` resolves once the write is durable and only then updates what `get`
29
+ * returns, so a second read-modify-write started before the first landed read
30
+ * the stale record and wrote it back — silently reverting the first writer's
31
+ * field. Updates of one session are therefore chained, each reading only after
32
+ * the previous one landed. And rows written before newer fields existed are
33
+ * filled from `EMPTY_PROGRESS`, so a patch never computes from `undefined`.
34
+ *
35
+ * @param {any} progressTable
36
+ * @param {string} sessionId
37
+ * @param {(current: typeof EMPTY_PROGRESS) => Partial<typeof EMPTY_PROGRESS>} patch
38
+ * @returns {Promise<void>} resolution once this update is durable.
39
+ */
40
+ export function advanceProgress(progressTable, sessionId, patch) {
41
+ let perTable = chains.get(progressTable)
42
+ if (perTable === undefined) {
43
+ perTable = new Map()
44
+ chains.set(progressTable, perTable)
45
+ }
46
+ const previous = perTable.get(sessionId) ?? Promise.resolve()
47
+ const next = previous.catch(() => {}).then(async () => {
48
+ const current = { ...EMPTY_PROGRESS, ...(progressTable.get(sessionId) ?? {}) }
49
+ await progressTable.put(sessionId, { ...current, ...patch(current) })
50
+ })
51
+ perTable.set(sessionId, next)
52
+ next.catch(() => {}).finally(() => {
53
+ if (perTable.get(sessionId) === next) perTable.delete(sessionId)
54
+ })
55
+ return next
56
+ }