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
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
|
+
}
|
package/src/progress.js
ADDED
|
@@ -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
|
+
}
|