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/queue.js
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent work queue for auto-memory tasks.
|
|
3
|
+
*
|
|
4
|
+
* Design properties:
|
|
5
|
+
* - Durable before scheduled: every task is written to the state domain
|
|
6
|
+
* before any timer starts, so a crash never loses an accepted unit of work.
|
|
7
|
+
* - Per-session ordering: tasks of one session run on one promise chain;
|
|
8
|
+
* cross-session concurrency is bounded by `queue.concurrency`.
|
|
9
|
+
* - Coalescing: a pending task absorbs adjacent ranges of the same
|
|
10
|
+
* (kind, session), collapsing burst writes into fewer runs.
|
|
11
|
+
* - Crash convergence: executors are idempotent (get-before-create writer),
|
|
12
|
+
* and backfill re-enqueues unfinished ranges under the same task id, so a
|
|
13
|
+
* replay converges instead of duplicating.
|
|
14
|
+
* - Deferral: an executor that cannot run yet throws `TaskDeferredError`; the
|
|
15
|
+
* task waits as `deferred`, spending no attempts, until `resumeSession`.
|
|
16
|
+
* - Dispose: timers cleared, in-flight chains awaited up to a deadline;
|
|
17
|
+
* everything not finished stays durable for the next boot.
|
|
18
|
+
*
|
|
19
|
+
* @module dsh-hypatia-auto-memory/queue
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const DISPOSE_DEADLINE_MS = 5000
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Thrown by an executor that cannot run YET — typically because the session it
|
|
26
|
+
* reads from is not loaded — as opposed to one that failed.
|
|
27
|
+
*
|
|
28
|
+
* The remedy differs, so the signal must too. A timed retry is the right answer
|
|
29
|
+
* to a flaky CLI call and the wrong one here: DSH loads sessions lazily, so a
|
|
30
|
+
* session that is not live now may stay that way for hours, and three retries
|
|
31
|
+
* five seconds apart only stamped `failed` on work that was never attempted. A
|
|
32
|
+
* deferred task keeps its attempt count, holds no timer, and is scheduled again
|
|
33
|
+
* by `resumeSession` when its session next appears.
|
|
34
|
+
*/
|
|
35
|
+
export class TaskDeferredError extends Error {
|
|
36
|
+
constructor(message) {
|
|
37
|
+
super(message)
|
|
38
|
+
this.name = 'TaskDeferredError'
|
|
39
|
+
this.deferred = true
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {{
|
|
45
|
+
* tasks: any, // storageDomain tasks table
|
|
46
|
+
* getConfig: () => {concurrency: number, maxAttempts: number, retryDelayMs: number, flushWindowMs: number},
|
|
47
|
+
* executors: Record<string, (task: any) => Promise<void>>,
|
|
48
|
+
* status: import('./status.js').StatusLog,
|
|
49
|
+
* now?: () => number,
|
|
50
|
+
* }} deps
|
|
51
|
+
*/
|
|
52
|
+
export function createQueue({ tasks, getConfig, executors = {}, status, now = Date.now }) {
|
|
53
|
+
/** @type {Map<string, Promise<void>>} one chain per session */
|
|
54
|
+
const chains = new Map()
|
|
55
|
+
/** @type {Map<string, NodeJS.Timeout>} flush-window timers, key = task id */
|
|
56
|
+
const timers = new Map()
|
|
57
|
+
/** @type {Set<string>} task ids currently on a chain */
|
|
58
|
+
const scheduled = new Set()
|
|
59
|
+
/** @type {Set<string>} ids that absorbed new work mid-run and must run again */
|
|
60
|
+
const followups = new Set()
|
|
61
|
+
let running = 0
|
|
62
|
+
let waiters = []
|
|
63
|
+
let disposed = false
|
|
64
|
+
|
|
65
|
+
const taskId = (kind, sessionId) => `${kind}:${sessionId}`
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Take one of the `concurrency` slots.
|
|
69
|
+
* @returns {Promise<boolean>} true when a slot was granted; false when dispose
|
|
70
|
+
* cancelled the wait, in which case the caller must NOT release.
|
|
71
|
+
*/
|
|
72
|
+
const acquire = () => new Promise((resolve) => {
|
|
73
|
+
// Deliberately NOT short-circuiting on `disposed`: dispose's contract is to
|
|
74
|
+
// await in-flight work, and a task already on a chain counts as in-flight.
|
|
75
|
+
// Only waiters still queued for a slot are cancelled, by `cancelWaiters`.
|
|
76
|
+
if (running < getConfig().concurrency) {
|
|
77
|
+
running += 1
|
|
78
|
+
resolve(true)
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
waiters.push((granted) => {
|
|
82
|
+
if (granted) running += 1
|
|
83
|
+
resolve(granted)
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
/** Hand the freed slot to exactly one waiter — never more. */
|
|
88
|
+
const release = () => {
|
|
89
|
+
running -= 1
|
|
90
|
+
const next = waiters.shift()
|
|
91
|
+
if (next) next(true)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Dispose only: drop every queued waiter without granting it a slot. */
|
|
95
|
+
const cancelWaiters = () => {
|
|
96
|
+
const pending = waiters
|
|
97
|
+
waiters = []
|
|
98
|
+
for (const waiter of pending) waiter(false)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** @type {Map<string, Promise<string>>} in-flight persist per task id */
|
|
102
|
+
const persisting = new Map()
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Load-or-create the durable record, absorbing the range into an existing
|
|
106
|
+
* pending task of the same (kind, session) when present.
|
|
107
|
+
*
|
|
108
|
+
* Two properties of the storage table shape this:
|
|
109
|
+
*
|
|
110
|
+
* - A write lands in memory only once it is durable: `put`/`update` queue the
|
|
111
|
+
* write and resolve after it, and `get` sees nothing until then. Returning
|
|
112
|
+
* before the write landed let `runTask` look the id up, find nothing, and
|
|
113
|
+
* return — a freshly enqueued immediate task silently never ran and sat in
|
|
114
|
+
* the table as `pending`. Every write here is awaited.
|
|
115
|
+
* - Two enqueues of one id interleaving would each see "no record" and each
|
|
116
|
+
* `put`, the later one discarding the earlier range. Persists of one id are
|
|
117
|
+
* therefore chained.
|
|
118
|
+
*
|
|
119
|
+
* @returns {Promise<string>} the task id, once its record is readable.
|
|
120
|
+
*/
|
|
121
|
+
function persistTask(spec) {
|
|
122
|
+
const id = taskId(spec.kind, spec.sessionId)
|
|
123
|
+
const previous = persisting.get(id) ?? Promise.resolve()
|
|
124
|
+
const next = previous.catch(() => {}).then(() => writeTask(id, spec))
|
|
125
|
+
persisting.set(id, next)
|
|
126
|
+
next.catch(() => {}).finally(() => {
|
|
127
|
+
if (persisting.get(id) === next) persisting.delete(id)
|
|
128
|
+
})
|
|
129
|
+
return next
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function writeTask(id, spec) {
|
|
133
|
+
const existing = tasks.get(id)
|
|
134
|
+
if (existing !== undefined && existing.status !== 'failed') {
|
|
135
|
+
await tasks.update(id, (t) => ({
|
|
136
|
+
...t,
|
|
137
|
+
fromSeq: Math.min(t.fromSeq, spec.fromSeq),
|
|
138
|
+
toSeq: Math.max(t.toSeq, spec.toSeq),
|
|
139
|
+
project: spec.project || t.project,
|
|
140
|
+
}))
|
|
141
|
+
return id
|
|
142
|
+
}
|
|
143
|
+
await tasks.put(id, {
|
|
144
|
+
kind: spec.kind,
|
|
145
|
+
sessionId: spec.sessionId,
|
|
146
|
+
fromSeq: spec.fromSeq,
|
|
147
|
+
toSeq: spec.toSeq,
|
|
148
|
+
project: spec.project,
|
|
149
|
+
status: 'pending',
|
|
150
|
+
attempts: 0,
|
|
151
|
+
// Written explicitly: the stored schema requires the key, and the storage
|
|
152
|
+
// service only checks that on the next read — i.e. at the next startup.
|
|
153
|
+
error: null,
|
|
154
|
+
enqueuedAt: now(),
|
|
155
|
+
})
|
|
156
|
+
return id
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Schedule one persisted task onto its session chain. */
|
|
160
|
+
function schedule(id) {
|
|
161
|
+
if (disposed || scheduled.has(id)) return
|
|
162
|
+
scheduled.add(id)
|
|
163
|
+
const sessionId = id.slice(id.indexOf(':') + 1)
|
|
164
|
+
const prev = chains.get(sessionId) ?? Promise.resolve()
|
|
165
|
+
const next = prev
|
|
166
|
+
.catch(() => {})
|
|
167
|
+
.then(() => runTask(id))
|
|
168
|
+
.finally(() => {
|
|
169
|
+
scheduled.delete(id)
|
|
170
|
+
if (chains.get(sessionId) === next) chains.delete(sessionId)
|
|
171
|
+
// Re-schedule only AFTER leaving `scheduled`, otherwise the guard at the
|
|
172
|
+
// top of this function would swallow the follow-up run.
|
|
173
|
+
if (followups.delete(id)) schedule(id)
|
|
174
|
+
})
|
|
175
|
+
chains.set(sessionId, next)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Load, execute with retry bookkeeping, and settle one task. */
|
|
179
|
+
async function runTask(id) {
|
|
180
|
+
const record = tasks.get(id)
|
|
181
|
+
if (record === undefined || record.status === 'failed') return
|
|
182
|
+
const executor = executors[record.kind]
|
|
183
|
+
if (executor === undefined) {
|
|
184
|
+
status.error(`no executor for task kind "${record.kind}"`, new Error(id))
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
if ((await acquire()) === false) return
|
|
188
|
+
try {
|
|
189
|
+
// The previous attempt's error clears WITH the status. A task that
|
|
190
|
+
// deferred and was later resumed otherwise runs — and succeeds — while
|
|
191
|
+
// still carrying `session … is not loaded`, which reads like a live
|
|
192
|
+
// failure to anyone inspecting the table mid-run.
|
|
193
|
+
await tasks.update(id, (t) => ({ ...t, status: 'running', error: null }))
|
|
194
|
+
try {
|
|
195
|
+
const snapshot = tasks.get(id)
|
|
196
|
+
await executor(snapshot)
|
|
197
|
+
// `enqueue` widens a running task's range in place, but cannot schedule
|
|
198
|
+
// it (the id is already in `scheduled`). Deleting unconditionally here
|
|
199
|
+
// therefore discarded everything that arrived mid-run — and because the
|
|
200
|
+
// log executor advances its watermark past what it DID cover, the gap
|
|
201
|
+
// became unreachable to boot backfill too. Compare before deleting.
|
|
202
|
+
const latest = tasks.get(id)
|
|
203
|
+
const grewForward = latest !== undefined && latest.toSeq > snapshot.toSeq
|
|
204
|
+
const grewBackward = latest !== undefined && latest.fromSeq < snapshot.fromSeq
|
|
205
|
+
if (grewForward || grewBackward) {
|
|
206
|
+
await tasks.update(id, (t) => ({
|
|
207
|
+
...t,
|
|
208
|
+
// Forward growth re-runs only the uncovered tail. Backward growth
|
|
209
|
+
// (a backfill reaching under this task) re-runs the whole widened
|
|
210
|
+
// range; writes are get-before-create, so replay converges.
|
|
211
|
+
fromSeq: grewBackward ? latest.fromSeq : snapshot.toSeq,
|
|
212
|
+
status: 'pending',
|
|
213
|
+
attempts: 0,
|
|
214
|
+
error: null,
|
|
215
|
+
}))
|
|
216
|
+
followups.add(id)
|
|
217
|
+
} else {
|
|
218
|
+
await tasks.delete(id)
|
|
219
|
+
}
|
|
220
|
+
status.count('tasksDone')
|
|
221
|
+
} catch (error) {
|
|
222
|
+
if (error?.deferred === true) {
|
|
223
|
+
await tasks.update(id, (t) => ({
|
|
224
|
+
...t,
|
|
225
|
+
status: 'deferred',
|
|
226
|
+
error: error instanceof Error ? error.message : String(error),
|
|
227
|
+
}))
|
|
228
|
+
status.info(`task ${id} deferred: ${error.message}`)
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
// Permanent failures (e.g. malformed model output) get no retries —
|
|
232
|
+
// re-running an unhealable task only wedges the session chain.
|
|
233
|
+
const attempts = error?.permanent === true
|
|
234
|
+
? getConfig().maxAttempts
|
|
235
|
+
: (tasks.get(id)?.attempts ?? record.attempts) + 1
|
|
236
|
+
const config = getConfig()
|
|
237
|
+
if (attempts < config.maxAttempts) {
|
|
238
|
+
await tasks.update(id, (t) => ({
|
|
239
|
+
...t,
|
|
240
|
+
status: 'pending',
|
|
241
|
+
attempts,
|
|
242
|
+
error: error instanceof Error ? error.message : String(error),
|
|
243
|
+
}))
|
|
244
|
+
status.warn(`task ${id} failed (attempt ${attempts}), retrying: ${String(error)}`)
|
|
245
|
+
setTimer(id, () => schedule(id), config.retryDelayMs)
|
|
246
|
+
} else {
|
|
247
|
+
await tasks.update(id, (t) => ({
|
|
248
|
+
...t,
|
|
249
|
+
status: 'failed',
|
|
250
|
+
attempts,
|
|
251
|
+
error: error instanceof Error ? error.message : String(error),
|
|
252
|
+
}))
|
|
253
|
+
status.count('tasksFailed')
|
|
254
|
+
status.error(`task ${id} failed permanently after ${attempts} attempts`, error)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
} finally {
|
|
258
|
+
release()
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function setTimer(id, fn, delay) {
|
|
263
|
+
if (disposed) return
|
|
264
|
+
const timer = setTimeout(() => {
|
|
265
|
+
timers.delete(id)
|
|
266
|
+
fn()
|
|
267
|
+
}, delay)
|
|
268
|
+
timers.set(id, timer)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Schedule every persisted, unfinished task of one kind.
|
|
273
|
+
*
|
|
274
|
+
* Log ranges were always recoverable from the watermarks, but a pending
|
|
275
|
+
* consolidate / cascade / session-node record carries its own range and
|
|
276
|
+
* nothing re-derived it: a restart between persisting one and running it left
|
|
277
|
+
* it sitting in the table until some later trigger happened to reuse its id.
|
|
278
|
+
* `running` is included on purpose — seen at registration it can only mean
|
|
279
|
+
* the previous process died mid-task. `failed` stays put for inspection, and
|
|
280
|
+
* `deferred` waits for its session to come back (see `resumeSession`).
|
|
281
|
+
*/
|
|
282
|
+
function resumeKind(kind) {
|
|
283
|
+
if (typeof tasks.entries !== 'function') return
|
|
284
|
+
for (const [id, record] of tasks.entries()) {
|
|
285
|
+
if (record?.kind !== kind || record.status === 'failed' || record.status === 'deferred') continue
|
|
286
|
+
schedule(id)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
/**
|
|
292
|
+
* Register or replace the executor for one task kind (late binding), and
|
|
293
|
+
* resume any backlog of that kind a previous process left persisted.
|
|
294
|
+
*
|
|
295
|
+
* Executors attach at different times — `consolidate` and `cascade` only
|
|
296
|
+
* once the `llm` service arrives — so resuming per kind, at registration, is
|
|
297
|
+
* what guarantees a task is never scheduled before something can run it.
|
|
298
|
+
*/
|
|
299
|
+
registerExecutor(kind, executor) {
|
|
300
|
+
executors[kind] = executor
|
|
301
|
+
resumeKind(kind)
|
|
302
|
+
},
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Accept one unit of work: persist, then run after the flush window
|
|
306
|
+
* (which coalesces bursts). Resolves once the durable record exists.
|
|
307
|
+
*/
|
|
308
|
+
async enqueue(spec) {
|
|
309
|
+
if (disposed) return
|
|
310
|
+
const id = await persistTask(spec)
|
|
311
|
+
// `immediate` skips the coalescing window for a span that is already known
|
|
312
|
+
// to be complete — a finished turn, or a session shutting down. Waiting
|
|
313
|
+
// would only delay it, and for a turn it risks the next arrival splitting
|
|
314
|
+
// a span that must stay whole.
|
|
315
|
+
const window = spec.immediate === true ? 0 : getConfig().flushWindowMs
|
|
316
|
+
if (spec.immediate === true) {
|
|
317
|
+
const pending = timers.get(id)
|
|
318
|
+
if (pending !== undefined) {
|
|
319
|
+
clearTimeout(pending)
|
|
320
|
+
timers.delete(id)
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (window > 0 && !scheduled.has(id)) {
|
|
324
|
+
if (!timers.has(id)) {
|
|
325
|
+
setTimer(id, () => schedule(id), window)
|
|
326
|
+
}
|
|
327
|
+
} else {
|
|
328
|
+
schedule(id)
|
|
329
|
+
}
|
|
330
|
+
},
|
|
331
|
+
|
|
332
|
+
/** Resume every unfinished task whose kind currently has an executor. */
|
|
333
|
+
async requeueAll() {
|
|
334
|
+
for (const kind of Object.keys(executors)) resumeKind(kind)
|
|
335
|
+
},
|
|
336
|
+
|
|
337
|
+
/** One task currently persisted for (kind, session), if any. */
|
|
338
|
+
peek(kind, sessionId) {
|
|
339
|
+
return tasks.get(taskId(kind, sessionId))
|
|
340
|
+
},
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Schedule every deferred task of one session — called when the session
|
|
344
|
+
* becomes live again, which is the only thing a deferred task waits for.
|
|
345
|
+
* @param {string} sessionId
|
|
346
|
+
* @returns {number} how many tasks were resumed.
|
|
347
|
+
*/
|
|
348
|
+
resumeSession(sessionId) {
|
|
349
|
+
if (typeof tasks.entries !== 'function') return 0
|
|
350
|
+
const ids = []
|
|
351
|
+
for (const [id, record] of tasks.entries()) {
|
|
352
|
+
if (record?.sessionId === sessionId && record.status === 'deferred') ids.push(id)
|
|
353
|
+
}
|
|
354
|
+
for (const id of ids) schedule(id)
|
|
355
|
+
return ids.length
|
|
356
|
+
},
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Delete `failed` records of the given kinds.
|
|
360
|
+
*
|
|
361
|
+
* Only for kinds whose work is fully re-derivable from progress watermarks:
|
|
362
|
+
* a failed `log-message` never advanced `lastLoggedSeq`, so that session's
|
|
363
|
+
* next flush covers the same range anyway, and the record carries nothing
|
|
364
|
+
* but an error message. Called once at startup, so the message survives for
|
|
365
|
+
* the whole run that produced it.
|
|
366
|
+
* @param {readonly string[]} kinds
|
|
367
|
+
* @returns {Promise<number>} how many records were removed, once removed.
|
|
368
|
+
*/
|
|
369
|
+
async pruneFailed(kinds) {
|
|
370
|
+
if (typeof tasks.entries !== 'function') return 0
|
|
371
|
+
const ids = []
|
|
372
|
+
for (const [id, record] of tasks.entries()) {
|
|
373
|
+
if (record?.status === 'failed' && kinds.includes(record.kind)) ids.push(id)
|
|
374
|
+
}
|
|
375
|
+
for (const id of ids) await tasks.delete(id)
|
|
376
|
+
return ids.length
|
|
377
|
+
},
|
|
378
|
+
|
|
379
|
+
/** Every session id that currently owns at least one task record. */
|
|
380
|
+
sessionIds() {
|
|
381
|
+
const ids = new Set()
|
|
382
|
+
if (typeof tasks.entries !== 'function') return ids
|
|
383
|
+
for (const [, record] of tasks.entries()) {
|
|
384
|
+
if (typeof record?.sessionId === 'string') ids.add(record.sessionId)
|
|
385
|
+
}
|
|
386
|
+
return ids
|
|
387
|
+
},
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Drop every task of one session — for a session that no longer exists, whose
|
|
391
|
+
* work could never run again and would otherwise wait forever as `deferred`.
|
|
392
|
+
* A task already executing is left to finish.
|
|
393
|
+
* @param {string} sessionId
|
|
394
|
+
* @returns {Promise<number>} how many records were removed.
|
|
395
|
+
*/
|
|
396
|
+
async forgetSession(sessionId) {
|
|
397
|
+
if (typeof tasks.entries !== 'function') return 0
|
|
398
|
+
const ids = []
|
|
399
|
+
for (const [id, record] of tasks.entries()) {
|
|
400
|
+
if (record?.sessionId !== sessionId) continue
|
|
401
|
+
if (record.status === 'running' || scheduled.has(id)) continue
|
|
402
|
+
ids.push(id)
|
|
403
|
+
}
|
|
404
|
+
for (const id of ids) {
|
|
405
|
+
const timer = timers.get(id)
|
|
406
|
+
if (timer !== undefined) {
|
|
407
|
+
clearTimeout(timer)
|
|
408
|
+
timers.delete(id)
|
|
409
|
+
}
|
|
410
|
+
await tasks.delete(id)
|
|
411
|
+
}
|
|
412
|
+
return ids.length
|
|
413
|
+
},
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Resolve once this session has no work left in flight.
|
|
417
|
+
*
|
|
418
|
+
* Used to decide when a disposed session's object can be released: its
|
|
419
|
+
* executors still need `snapshotEvents` from it, and the store has already
|
|
420
|
+
* let go. Waiting on the chain alone is not enough — a task sitting on a
|
|
421
|
+
* retry timer holds no chain — so pending records are checked too.
|
|
422
|
+
*
|
|
423
|
+
* @param {string} sessionId
|
|
424
|
+
* @param {{timeoutMs?: number, kinds?: readonly string[]}} [options]
|
|
425
|
+
*/
|
|
426
|
+
async whenIdle(sessionId, { timeoutMs = 60_000, kinds = ['log-message', 'consolidate'] } = {}) {
|
|
427
|
+
const deadline = now() + timeoutMs
|
|
428
|
+
while (now() < deadline) {
|
|
429
|
+
const chain = chains.get(sessionId)
|
|
430
|
+
if (chain !== undefined) {
|
|
431
|
+
await chain.catch(() => {})
|
|
432
|
+
continue
|
|
433
|
+
}
|
|
434
|
+
const outstanding = kinds.some((kind) => {
|
|
435
|
+
const record = tasks.get(taskId(kind, sessionId))
|
|
436
|
+
// Deferred work cannot run until its session is back, so it is not
|
|
437
|
+
// something to wait for here.
|
|
438
|
+
return record !== undefined && record.status !== 'failed' && record.status !== 'deferred'
|
|
439
|
+
})
|
|
440
|
+
if (!outstanding) return true
|
|
441
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
442
|
+
}
|
|
443
|
+
return false
|
|
444
|
+
},
|
|
445
|
+
|
|
446
|
+
pendingCount() {
|
|
447
|
+
return scheduled.size + timers.size + followups.size
|
|
448
|
+
},
|
|
449
|
+
|
|
450
|
+
/** Stop accepting work; await in-flight up to the deadline. */
|
|
451
|
+
async dispose() {
|
|
452
|
+
disposed = true
|
|
453
|
+
for (const timer of timers.values()) clearTimeout(timer)
|
|
454
|
+
timers.clear()
|
|
455
|
+
const pending = [...chains.values()]
|
|
456
|
+
await Promise.race([
|
|
457
|
+
Promise.allSettled(pending),
|
|
458
|
+
new Promise((resolve) => setTimeout(resolve, DISPOSE_DEADLINE_MS)),
|
|
459
|
+
])
|
|
460
|
+
chains.clear()
|
|
461
|
+
scheduled.clear()
|
|
462
|
+
followups.clear()
|
|
463
|
+
cancelWaiters()
|
|
464
|
+
},
|
|
465
|
+
}
|
|
466
|
+
}
|
package/src/recall.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recall: seed each session with the project's standing rules and taboos, and
|
|
3
|
+
* leave everything else to the agent.
|
|
4
|
+
*
|
|
5
|
+
* Why only a seed. `docs/memory-nolinear.md` sorts agents into three kinds and
|
|
6
|
+
* puts harness explicitly in the second: *"对于拥有持续上下文、能自主调用工具的
|
|
7
|
+
* Agent(harness、openclaw 这一类),更自然的方式是让回忆保持在技能层——Agent
|
|
8
|
+
* 在判断需要时自己发起检索,甚至在任务中途发现线索后中途补查。"* Deterministic
|
|
9
|
+
* injection is prescribed for the first kind (agents that rebuild their prompt
|
|
10
|
+
* every turn) and, as a last resort, the third (agents with no initiative).
|
|
11
|
+
*
|
|
12
|
+
* The earlier design injected `## Reference Information` on every human turn
|
|
13
|
+
* from an `agent/pre-step` listener. That cost more than it looked:
|
|
14
|
+
*
|
|
15
|
+
* - It returned a `PreStepDecision` WITHOUT calling `next()`. Per cordis's
|
|
16
|
+
* waterfall contract that is a veto of the rest of the chain *including the
|
|
17
|
+
* loop's built-in behavior* — and the built-in is what appends DSH's own
|
|
18
|
+
* assembled runtime context section. The plugin was silently swallowing it.
|
|
19
|
+
* - It blocked every turn on a `hypatia similar` subprocess (30s ceiling) whose
|
|
20
|
+
* query was the raw user text, which `docs/memory.md` specifically says not
|
|
21
|
+
* to do: reference entries should be found *"by analyzing (not
|
|
22
|
+
* verbatim-searching) the user input"*.
|
|
23
|
+
* - It fixed the retrieval to the first human message of the turn, so the agent
|
|
24
|
+
* could not follow a lead it discovered mid-task.
|
|
25
|
+
*
|
|
26
|
+
* Rules and taboos stay pushed rather than pulled because they are preferences
|
|
27
|
+
* the agent cannot know it should ask for. The bundled `hypatia-memory` skill
|
|
28
|
+
* carries the query recipes for everything else.
|
|
29
|
+
*
|
|
30
|
+
* The seed also names the shelf when it is not hypatia's `default`: the agent's
|
|
31
|
+
* own `hypatia` calls would otherwise search one shelf while this plugin writes
|
|
32
|
+
* to another. That line is sent even with the preload switched off or nothing
|
|
33
|
+
* to preload, because it is not a preference — without it retrieval is wrong.
|
|
34
|
+
*
|
|
35
|
+
* The seed carries `source: {kind: 'plugin', plugin, form: 'recall'}` so the
|
|
36
|
+
* collector skips it — recall output must never be re-logged into memory.
|
|
37
|
+
*
|
|
38
|
+
* @module dsh-hypatia-auto-memory/recall
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
42
|
+
import { PLUGIN_NAME } from './consolidator.js'
|
|
43
|
+
import { DEFAULT_SHELF } from './shelf.js'
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* JSE from the hypatia-memory protocol: entries tagged `tag` whose scopes carry
|
|
47
|
+
* this project or the global marker.
|
|
48
|
+
*
|
|
49
|
+
* `$contains` on `scopes` routes to exact array membership, not a substring
|
|
50
|
+
* match, so the `''` arm matches only entries deliberately written global (a
|
|
51
|
+
* trailing comma in `--scopes`) rather than every entry in the shelf.
|
|
52
|
+
*/
|
|
53
|
+
function scopedTagQuery(tag, project) {
|
|
54
|
+
return JSON.stringify([
|
|
55
|
+
'$knowledge',
|
|
56
|
+
['$contains', 'tags', tag],
|
|
57
|
+
['$or', ['$contains', 'scopes', project], ['$contains', 'scopes', '']],
|
|
58
|
+
])
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Best-effort name extraction from a hypatia row (shape owned by hypatia;
|
|
62
|
+
* search/similar rows carry `key`, JSE query rows carry `name`). */
|
|
63
|
+
function rowName(row) {
|
|
64
|
+
return typeof row?.key === 'string' ? row.key
|
|
65
|
+
: typeof row?.name === 'string' ? row.name : ''
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Best-effort text extraction from a row's content field. */
|
|
69
|
+
function rowText(row, cap) {
|
|
70
|
+
const content = row?.content
|
|
71
|
+
const raw = typeof content === 'string' ? content
|
|
72
|
+
: typeof content?.data === 'string' ? content.data
|
|
73
|
+
: typeof content?.text === 'string' ? content.text
|
|
74
|
+
: JSON.stringify(content ?? '')
|
|
75
|
+
const flat = raw.replace(/\s+/g, ' ').trim()
|
|
76
|
+
return flat.length > cap ? `${flat.slice(0, cap)}…` : flat
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Render one tag's rows as a markdown bullet list, dropping empty entries. */
|
|
80
|
+
function renderRows(rows, cap) {
|
|
81
|
+
return rows
|
|
82
|
+
.map((row) => {
|
|
83
|
+
const name = rowName(row)
|
|
84
|
+
const text = rowText(row, cap)
|
|
85
|
+
if (name === '' && text === '') return ''
|
|
86
|
+
return name === '' ? `- ${text}` : `- **${name}**: ${text}`
|
|
87
|
+
})
|
|
88
|
+
.filter((line) => line !== '')
|
|
89
|
+
.join('\n')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {{
|
|
94
|
+
* ctx: import('@deepseek-ai/cordis').Context,
|
|
95
|
+
* cli: ReturnType<import('./hypatia-client.js').createHypatiaClient>,
|
|
96
|
+
* shelf?: string,
|
|
97
|
+
* getConfig: () => any,
|
|
98
|
+
* status: import('./status.js').StatusLog,
|
|
99
|
+
* projectFor: (session: any) => Promise<string>,
|
|
100
|
+
* }} deps
|
|
101
|
+
*/
|
|
102
|
+
export function createRecall({ ctx, cli, shelf = DEFAULT_SHELF, getConfig, status, projectFor }) {
|
|
103
|
+
async function queryRulesAndTaboos(agent) {
|
|
104
|
+
const project = await projectFor(agent.session)
|
|
105
|
+
return Promise.all([
|
|
106
|
+
cli.query(scopedTagQuery('rule', project)).catch((error) => {
|
|
107
|
+
status.warn(`rules preload query failed: ${String(error)}`)
|
|
108
|
+
return []
|
|
109
|
+
}),
|
|
110
|
+
cli.query(scopedTagQuery('taboo', project)).catch((error) => {
|
|
111
|
+
status.warn(`taboos preload query failed: ${String(error)}`)
|
|
112
|
+
return []
|
|
113
|
+
}),
|
|
114
|
+
])
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function preloadRulesAndTaboos(agent) {
|
|
118
|
+
const config = getConfig()
|
|
119
|
+
const preload = config.recall.preloadRulesTaboos !== false
|
|
120
|
+
const namesShelf = shelf !== DEFAULT_SHELF
|
|
121
|
+
if (!preload && !namesShelf) return
|
|
122
|
+
const [rules, taboos] = preload ? await queryRulesAndTaboos(agent) : [[], []]
|
|
123
|
+
const rulesText = renderRows(rules, 400)
|
|
124
|
+
const taboosText = renderRows(taboos, 400)
|
|
125
|
+
if (rulesText === '' && taboosText === '' && !namesShelf) return
|
|
126
|
+
|
|
127
|
+
const sections = ['## Long-term memory (auto-loaded from hypatia)', '']
|
|
128
|
+
if (namesShelf) {
|
|
129
|
+
sections.push(`Memory lives on the hypatia shelf \`${shelf}\`: add \`--shelf ${shelf}\` to every \`hypatia\` command you run.`, '')
|
|
130
|
+
}
|
|
131
|
+
if (rulesText !== '') sections.push('### Rules', rulesText, '')
|
|
132
|
+
if (taboosText !== '') sections.push('### Taboos', taboosText, '')
|
|
133
|
+
agent.inject(createUserMessage({
|
|
134
|
+
content: [{ type: 'text', text: sections.join('\n') }],
|
|
135
|
+
source: { kind: 'plugin', plugin: PLUGIN_NAME, form: 'recall' },
|
|
136
|
+
}))
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
ctx.on('agent/session-start', (payload) => {
|
|
140
|
+
const config = getConfig()
|
|
141
|
+
if (config.enabled === false || config.recall.enabled === false) return
|
|
142
|
+
void preloadRulesAndTaboos(payload.agent).catch((error) => {
|
|
143
|
+
status.warn(`rules preload failed: ${String(error)}`)
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
return { preloadRulesAndTaboos }
|
|
148
|
+
}
|