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/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
+ }