typeclaw 0.36.8 → 0.37.1

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.
Files changed (112) hide show
  1. package/README.md +3 -3
  2. package/package.json +3 -2
  3. package/src/agent/index.ts +31 -11
  4. package/src/agent/live-sessions.ts +12 -0
  5. package/src/agent/model-fallback.ts +17 -15
  6. package/src/agent/model-overrides.ts +2 -2
  7. package/src/agent/session-meta.ts +10 -0
  8. package/src/agent/subagents.ts +30 -3
  9. package/src/agent/system-prompt.ts +9 -3
  10. package/src/agent/todo/continuation-policy.ts +6 -3
  11. package/src/agent/todo/continuation-wiring.ts +4 -2
  12. package/src/agent/todo/continuation.ts +3 -3
  13. package/src/agent/tools/todo/index.ts +27 -4
  14. package/src/bundled-plugins/agent-browser/index.ts +33 -108
  15. package/src/bundled-plugins/agent-browser/shim.ts +3 -94
  16. package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +8 -33
  17. package/src/bundled-plugins/doc-render/skills/typeclaw-render-pdf/SKILL.md +2 -2
  18. package/src/bundled-plugins/guard/policies/memory-retrieval-cache-write.ts +7 -1
  19. package/src/bundled-plugins/memory/README.md +80 -23
  20. package/src/bundled-plugins/memory/append-tool.ts +74 -53
  21. package/src/bundled-plugins/memory/citation-superset.ts +4 -0
  22. package/src/bundled-plugins/memory/citations.ts +54 -0
  23. package/src/bundled-plugins/memory/dreaming-metrics.ts +30 -0
  24. package/src/bundled-plugins/memory/dreaming.ts +444 -21
  25. package/src/bundled-plugins/memory/index.ts +544 -400
  26. package/src/bundled-plugins/memory/load-memory.ts +87 -10
  27. package/src/bundled-plugins/memory/load-shards.ts +48 -22
  28. package/src/bundled-plugins/memory/memory-logger.ts +95 -106
  29. package/src/bundled-plugins/memory/memory-retrieval.ts +3 -3
  30. package/src/bundled-plugins/memory/parent-link.ts +33 -0
  31. package/src/bundled-plugins/memory/paths.ts +12 -0
  32. package/src/bundled-plugins/memory/references/frontmatter.ts +197 -0
  33. package/src/bundled-plugins/memory/references/load-references.ts +212 -0
  34. package/src/bundled-plugins/memory/references/store-reference-tool.ts +59 -0
  35. package/src/bundled-plugins/memory/search-tool.ts +282 -45
  36. package/src/bundled-plugins/memory/stream-events.ts +1 -0
  37. package/src/bundled-plugins/memory/stream-io.ts +28 -3
  38. package/src/bundled-plugins/memory/turn-dedup.ts +40 -0
  39. package/src/bundled-plugins/memory/vector/cache-write.ts +19 -0
  40. package/src/bundled-plugins/memory/vector/config.ts +28 -0
  41. package/src/bundled-plugins/memory/vector/doctor.ts +124 -0
  42. package/src/bundled-plugins/memory/vector/embedder.ts +246 -0
  43. package/src/bundled-plugins/memory/vector/hybrid.ts +439 -0
  44. package/src/bundled-plugins/memory/vector/index-on-write.ts +34 -0
  45. package/src/bundled-plugins/memory/vector/inspect.ts +111 -0
  46. package/src/bundled-plugins/memory/vector/passages.ts +125 -0
  47. package/src/bundled-plugins/memory/vector/reference-index-on-write.ts +50 -0
  48. package/src/bundled-plugins/memory/vector/relevance-gate.ts +93 -0
  49. package/src/bundled-plugins/memory/vector/startup.ts +71 -0
  50. package/src/bundled-plugins/memory/vector/store.ts +203 -0
  51. package/src/bundled-plugins/memory/vector/truncation.ts +124 -0
  52. package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +2 -0
  53. package/src/channels/router.ts +239 -40
  54. package/src/cli/incomplete-init.ts +57 -0
  55. package/src/cli/init.ts +166 -18
  56. package/src/cli/inspect.ts +11 -5
  57. package/src/cli/model.ts +115 -36
  58. package/src/cli/provider.ts +5 -3
  59. package/src/cli/restart.ts +24 -0
  60. package/src/cli/start.ts +24 -0
  61. package/src/cli/tunnel.ts +53 -8
  62. package/src/config/config.ts +110 -19
  63. package/src/config/index.ts +5 -1
  64. package/src/config/models-mutation.ts +29 -11
  65. package/src/config/providers-mutation.ts +2 -2
  66. package/src/config/providers.ts +146 -12
  67. package/src/container/shared.ts +9 -0
  68. package/src/container/start.ts +87 -4
  69. package/src/cron/consumer.ts +13 -7
  70. package/src/hostd/models.ts +64 -0
  71. package/src/hostd/paths.ts +6 -0
  72. package/src/hostd/portbroker-manager.ts +2 -2
  73. package/src/init/checkpoint.ts +201 -0
  74. package/src/init/dockerfile.ts +121 -34
  75. package/src/init/gitignore.ts +7 -7
  76. package/src/init/index.ts +41 -9
  77. package/src/init/models-dev.ts +96 -21
  78. package/src/init/oauth-login.ts +3 -3
  79. package/src/init/progress.ts +29 -0
  80. package/src/init/validate-api-key.ts +4 -0
  81. package/src/inspect/index.ts +13 -6
  82. package/src/inspect/item-list.ts +11 -2
  83. package/src/inspect/live-list.ts +65 -0
  84. package/src/inspect/open-item.ts +22 -1
  85. package/src/inspect/session-list.ts +29 -0
  86. package/src/models/embedding-model.ts +114 -0
  87. package/src/models/transformers-version.ts +55 -0
  88. package/src/plugin/types.ts +3 -0
  89. package/src/portbroker/container-server.ts +23 -0
  90. package/src/portbroker/forward-request-bus.ts +35 -0
  91. package/src/portbroker/forward-result-bus.ts +2 -3
  92. package/src/portbroker/hostd-client.ts +182 -36
  93. package/src/portbroker/index.ts +6 -1
  94. package/src/portbroker/protocol.ts +9 -2
  95. package/src/run/channel-session-factory.ts +11 -1
  96. package/src/run/index.ts +65 -8
  97. package/src/server/command-runner.ts +24 -1
  98. package/src/server/index.ts +42 -8
  99. package/src/shared/index.ts +2 -0
  100. package/src/shared/protocol.ts +31 -0
  101. package/src/skills/typeclaw-channels/SKILL.md +4 -4
  102. package/src/skills/typeclaw-config/SKILL.md +2 -2
  103. package/src/skills/typeclaw-memory/SKILL.md +3 -1
  104. package/src/skills/typeclaw-permissions/SKILL.md +3 -3
  105. package/src/skills/typeclaw-skills/SKILL.md +1 -1
  106. package/src/skills/typeclaw-tunnels/SKILL.md +22 -1
  107. package/src/tunnels/providers/cloudflare-quick.ts +65 -7
  108. package/src/tunnels/upstream-probe.ts +25 -0
  109. package/typeclaw.schema.json +156 -67
  110. package/src/bundled-plugins/agent-browser/dashboard-discovery.ts +0 -170
  111. package/src/bundled-plugins/agent-browser/dashboard-proxy.ts +0 -421
  112. package/src/portbroker/bind-with-forward.ts +0 -102
@@ -11,11 +11,27 @@ import { formatLocalDate } from '@/shared'
11
11
 
12
12
  import { createDreamingSubagent, type DreamingPayload } from './dreaming'
13
13
  import { buildInjectionPlan, DEFAULT_INJECTION_BUDGET_BYTES, MIN_INJECTION_BUDGET_BYTES } from './injection-plan'
14
+ import {
15
+ forceIndexForChannel,
16
+ loadMemoryInjectionPlan,
17
+ renderDedupedMemorySection,
18
+ renderMemorySection,
19
+ renderRetrievedMemorySection,
20
+ } from './load-memory'
14
21
  import { loadAllShards } from './load-shards'
15
22
  import { createMemoryLoggerSubagent, type MemoryLoggerPayload } from './memory-logger'
16
23
  import { createMemoryRetrievalSubagent, type MemoryRetrievalPayload } from './memory-retrieval'
17
24
  import { preShardBackupPath, streamFilePath, streamsDir, topicsDir } from './paths'
18
- import { memorySearchTool } from './search-tool'
25
+ import { bumpReferenceAccess } from './references/load-references'
26
+ import { createMemorySearchTool } from './search-tool'
27
+ import { type InjectedShardState, partitionDirectShards } from './turn-dedup'
28
+ import { vectorConfigSchema } from './vector/config'
29
+ import { runVectorIndexDoctor } from './vector/doctor'
30
+ import { embed } from './vector/embedder'
31
+ import { hybridSearch, type EmbedFn } from './vector/hybrid'
32
+ import { makeAppendHook } from './vector/index-on-write'
33
+ import { makeReferenceStoredHook } from './vector/reference-index-on-write'
34
+ import { VectorStore } from './vector/store'
19
35
 
20
36
  const DEFAULT_IDLE_MS = 60_000
21
37
  const DEFAULT_BUFFER_BYTES = 500_000
@@ -120,6 +136,7 @@ const memoryConfigSchema = z
120
136
  // in milliseconds instead of the production 30s.
121
137
  retrievalSpawnTimeoutMs: z.number().int().min(1).default(RETRIEVAL_SPAWN_TIMEOUT_MS),
122
138
  dreaming: dreamingConfigSchema.optional(),
139
+ vector: vectorConfigSchema,
123
140
  })
124
141
  .default({
125
142
  idleMs: DEFAULT_IDLE_MS,
@@ -128,442 +145,569 @@ const memoryConfigSchema = z
128
145
  minIdleDeltaLines: DEFAULT_MIN_IDLE_DELTA_LINES,
129
146
  spawnTimeoutMs: SPAWN_TIMEOUT_MS,
130
147
  retrievalSpawnTimeoutMs: RETRIEVAL_SPAWN_TIMEOUT_MS,
148
+ vector: { enabled: false },
131
149
  })
132
150
 
133
- export default definePlugin({
134
- configSchema: memoryConfigSchema,
135
- plugin: async (ctx) => {
136
- const idleMs = ctx.config.idleMs
137
- const bufferBytes = ctx.config.bufferBytes
138
- const minIdleDeltaLines = ctx.config.minIdleDeltaLines
139
- const spawnTimeoutMs = ctx.config.spawnTimeoutMs
140
- const retrievalSpawnTimeoutMs = ctx.config.retrievalSpawnTimeoutMs
141
- const dreamingSchedule = ctx.config.dreaming?.schedule ?? DEFAULT_DREAMING_SCHEDULE
142
-
143
- const idleTimers = new Map<string, ReturnType<typeof setTimeout>>()
144
- const lastIdleEvent = new Map<string, { parentTranscriptPath: string | undefined; origin?: SessionOrigin }>()
145
- const bytesAtLastRun = new Map<string, number>()
146
- const linesAtLastRun = new Map<string, number>()
147
- // Per-session stream-file cursor: the JSONL line count of the daily
148
- // stream file at the END of this session's most recent memory-logger
149
- // spawn. Keyed by sessionId, valued by `{ date, lineCount }`. Honored
150
- // only when `date` matches today's date yesterday's cursor points
151
- // into yesterday's file and the spawn's payload omits it.
152
- const streamCursorAtLastRun = new Map<string, { date: string; lineCount: number }>()
153
-
154
- // memory-logger is coalesced per agentDir (not per parentSessionId) so that
155
- // two concurrent channel sessions for the same agent never write to the same
156
- // daily stream file at the same time. The subagent consumer would silently drop
157
- // a colliding fire, so we serialize spawn calls *here* (chaining each onto the
158
- // previous one's settlement) instead of letting the consumer choose between
159
- // dropping or queueing. The chain holds at most one in-flight promise plus one
160
- // queued.
161
- //
162
- // The `lastIdleEvent` lookup happens SYNCHRONOUSLY at call time and the
163
- // snapshot is captured in `payload` before any await. This is load-bearing
164
- // for `session.end`'s fire-and-forget path (see hook below): the hook
165
- // synchronously cleans up `lastIdleEvent.delete(sessionId)` immediately
166
- // after calling fireMemoryLogger, so if the snapshot were read lazily
167
- // inside the chained `.then`, it would race with cleanup and the spawn
168
- // would silently no-op. Capturing the payload up front decouples the
169
- // session-end snapshot from the cleanup that follows.
170
- let spawnChain: Promise<void> = Promise.resolve()
171
-
172
- const fireMemoryLogger = (sessionId: string, reason: 'idle' | 'buffer-trip' | 'session-end'): Promise<void> => {
173
- const last = lastIdleEvent.get(sessionId)
174
- if (!last || last.parentTranscriptPath === undefined) return Promise.resolve()
175
- const parentTranscriptPath = last.parentTranscriptPath
176
- const today = formatLocalDate()
177
- const priorCursor = streamCursorAtLastRun.get(sessionId)
178
- const streamLineCursor =
179
- priorCursor !== undefined && priorCursor.date === today ? priorCursor.lineCount : undefined
180
- const payload: MemoryLoggerPayload = {
181
- parentSessionId: sessionId,
182
- parentTranscriptPath,
183
- agentDir: ctx.agentDir,
184
- ...(last.origin !== undefined ? { origin: last.origin } : {}),
185
- ...(streamLineCursor !== undefined ? { streamLineCursor } : {}),
151
+ const VECTOR_TURN_TOP_K = 10
152
+
153
+ // Per-instance collaborators for the vector index-mode retrieval path. Injected
154
+ // through the plugin factory (not a module global or PluginContext field) so a
155
+ // test can override exactly one — `queryEmbedFn` to drive the real hybridSearch
156
+ // without loading the ~279 MB model, or `hybridSearch` to fake retrieval while
157
+ // testing hook orchestration — without leaking state across other tests in the
158
+ // same worker. Production uses the real `embed` and `hybridSearch`.
159
+ type MemoryPluginDeps = {
160
+ hybridSearch: typeof hybridSearch
161
+ queryEmbedFn: EmbedFn
162
+ }
163
+
164
+ const defaultDeps: MemoryPluginDeps = { hybridSearch, queryEmbedFn: embed }
165
+
166
+ // Builds the per-turn user-prompt memory block for a vector agent. Under budget
167
+ // (direct mode) injects shard bodies, but de-duplicates across turns: a shard
168
+ // whose body was already injected in full this session is rendered as a compact
169
+ // slug reference (see `partitionDirectShards`) so a long conversation stops
170
+ // re-sending identical bodies every turn while keeping every topic named and
171
+ // recoverable. Over budget falls back to top-K hybrid search.
172
+ //
173
+ // Channel origins never carry bodies (memory-bleed defense). A channel direct-mode
174
+ // turn is force-indexed to a headings/slugs-only section over EVERY shard, not run
175
+ // through hybridSearch: hybrid is relevance-filtered top-K, so an off-topic turn or
176
+ // stale vector index could silently drop headings that direct mode always had.
177
+ async function renderVectorTurnMemory(
178
+ event: { agentDir: string; userPrompt: string; origin?: SessionOrigin },
179
+ injectionBudgetBytes: number,
180
+ injectedState: InjectedShardState,
181
+ deps: MemoryPluginDeps,
182
+ logger?: { info: (msg: string) => void },
183
+ ): Promise<string> {
184
+ const plan = await loadMemoryInjectionPlan(event.agentDir, { injectionBudgetBytes })
185
+ const isChannel = event.origin?.kind === 'channel'
186
+ if (plan.mode === 'direct' && isChannel) {
187
+ const indexed = forceIndexForChannel(plan, { origin: event.origin, injectionBudgetBytes })
188
+ logger?.info(`[vector-retrieval] mode=index topics=${plan.shards.length} channel=forced`)
189
+ return renderMemorySection(indexed, { origin: event.origin })
190
+ }
191
+ if (plan.mode === 'direct') {
192
+ const { full, unchanged } = partitionDirectShards(plan.shards, injectedState)
193
+ logger?.info(`[vector-retrieval] mode=direct topics=${plan.shards.length} full=${full.length}`)
194
+ return renderDedupedMemorySection(full, unchanged)
195
+ }
196
+ const store = VectorStore.open(join(event.agentDir, 'memory', '.vectors', 'index.db'))
197
+ try {
198
+ const startedAt = Date.now()
199
+ const results = await deps.hybridSearch(
200
+ event.userPrompt,
201
+ store,
202
+ event.agentDir,
203
+ VECTOR_TURN_TOP_K,
204
+ deps.queryEmbedFn,
205
+ )
206
+ const elapsedMs = Date.now() - startedAt
207
+ let topicHits = 0
208
+ let referenceHits = 0
209
+ for (const result of results) {
210
+ if (result.source === 'topic') topicHits += 1
211
+ else if (result.source === 'reference') referenceHits += 1
212
+ }
213
+ const streamHits = results.length - topicHits - referenceHits
214
+ // results.length === 0 on a non-empty query means the relevance gate suppressed
215
+ // every candidate (or nothing matched) — an empty memory block, indistinguishable
216
+ // from "no memory" without this explicit signal.
217
+ const suppressed = results.length === 0 ? ' suppressed=1' : ''
218
+ logger?.info(
219
+ `[vector-retrieval] mode=index topic_results=${topicHits} stream_results=${streamHits} reference_results=${referenceHits} elapsed_ms=${elapsedMs}${suppressed}`,
220
+ )
221
+ // Count a vector-surfaced reference as an access so it survives dreaming's
222
+ // time-decay the same way a memory_search hit does. Fire-and-forget: the
223
+ // bump only feeds the 30-min dreaming saturation pass, so it must not add a
224
+ // frontmatter write to the per-turn response critical path.
225
+ const referenceSlugs = results.flatMap((r) => (r.source === 'reference' ? [r.key] : []))
226
+ if (referenceSlugs.length > 0) {
227
+ void bumpReferenceAccess(event.agentDir, referenceSlugs).catch((err) => {
228
+ logger?.info(`[vector-retrieval] reference access bump failed: ${err instanceof Error ? err.message : err}`)
229
+ })
230
+ }
231
+ return renderRetrievedMemorySection(results, { origin: event.origin })
232
+ } finally {
233
+ store.close()
234
+ }
235
+ }
236
+
237
+ function createMemoryPlugin(deps: MemoryPluginDeps = defaultDeps) {
238
+ return definePlugin({
239
+ configSchema: memoryConfigSchema,
240
+ plugin: async (ctx) => {
241
+ const idleMs = ctx.config.idleMs
242
+ const bufferBytes = ctx.config.bufferBytes
243
+ const minIdleDeltaLines = ctx.config.minIdleDeltaLines
244
+ const spawnTimeoutMs = ctx.config.spawnTimeoutMs
245
+ const retrievalSpawnTimeoutMs = ctx.config.retrievalSpawnTimeoutMs
246
+ const dreamingSchedule = ctx.config.dreaming?.schedule ?? DEFAULT_DREAMING_SCHEDULE
247
+
248
+ const idleTimers = new Map<string, ReturnType<typeof setTimeout>>()
249
+ const lastIdleEvent = new Map<string, { parentTranscriptPath: string | undefined; origin?: SessionOrigin }>()
250
+ const bytesAtLastRun = new Map<string, number>()
251
+ const linesAtLastRun = new Map<string, number>()
252
+ // Per-session stream-file cursor: the JSONL line count of the daily
253
+ // stream file at the END of this session's most recent memory-logger
254
+ // spawn. Keyed by sessionId, valued by `{ date, lineCount }`. Honored
255
+ // only when `date` matches today's date — yesterday's cursor points
256
+ // into yesterday's file and the spawn's payload omits it.
257
+ const streamCursorAtLastRun = new Map<string, { date: string; lineCount: number }>()
258
+ // Per-session record of shard bodies already injected in full this session,
259
+ // so direct-mode vector turns can de-duplicate unchanged bodies across turns.
260
+ // Cleared on session.end alongside the other per-session bookkeeping below.
261
+ const injectedShards = new Map<string, InjectedShardState>()
262
+
263
+ // memory-logger is coalesced per agentDir (not per parentSessionId) so that
264
+ // two concurrent channel sessions for the same agent never write to the same
265
+ // daily stream file at the same time. The subagent consumer would silently drop
266
+ // a colliding fire, so we serialize spawn calls *here* (chaining each onto the
267
+ // previous one's settlement) instead of letting the consumer choose between
268
+ // dropping or queueing. The chain holds at most one in-flight promise plus one
269
+ // queued.
270
+ //
271
+ // The `lastIdleEvent` lookup happens SYNCHRONOUSLY at call time and the
272
+ // snapshot is captured in `payload` before any await. This is load-bearing
273
+ // for `session.end`'s fire-and-forget path (see hook below): the hook
274
+ // synchronously cleans up `lastIdleEvent.delete(sessionId)` immediately
275
+ // after calling fireMemoryLogger, so if the snapshot were read lazily
276
+ // inside the chained `.then`, it would race with cleanup and the spawn
277
+ // would silently no-op. Capturing the payload up front decouples the
278
+ // session-end snapshot from the cleanup that follows.
279
+ let spawnChain: Promise<void> = Promise.resolve()
280
+
281
+ const fireMemoryLogger = (sessionId: string, reason: 'idle' | 'buffer-trip' | 'session-end'): Promise<void> => {
282
+ const last = lastIdleEvent.get(sessionId)
283
+ if (!last || last.parentTranscriptPath === undefined) return Promise.resolve()
284
+ const parentTranscriptPath = last.parentTranscriptPath
285
+ const today = formatLocalDate()
286
+ const priorCursor = streamCursorAtLastRun.get(sessionId)
287
+ const streamLineCursor =
288
+ priorCursor !== undefined && priorCursor.date === today ? priorCursor.lineCount : undefined
289
+ const payload: MemoryLoggerPayload = {
290
+ parentSessionId: sessionId,
291
+ parentTranscriptPath,
292
+ agentDir: ctx.agentDir,
293
+ ...(last.origin !== undefined ? { origin: last.origin } : {}),
294
+ ...(streamLineCursor !== undefined ? { streamLineCursor } : {}),
295
+ }
296
+ // Execution authority is `system` (resolves to owner), NOT the
297
+ // triggering turn's role: memory-logging is TypeClaw infrastructure over
298
+ // operator-owned sessions//memory/, so a guest channel turn that triggers
299
+ // it must not demote the logger to guest and get its transcript read
300
+ // blocked by privateSurfaceRead. The triggering origin is preserved two
301
+ // ways: `triggeredBy` for audit provenance, and `payload.origin` for
302
+ // content provenance (memory extraction/retrieval channel-safety).
303
+ const spawnOptions: SpawnSubagentOptions = {
304
+ parentSessionId: sessionId,
305
+ spawnedByOrigin: {
306
+ kind: 'system',
307
+ component: 'memory-logger',
308
+ ...(last.origin !== undefined ? { triggeredBy: last.origin } : {}),
309
+ },
310
+ }
311
+ const next = spawnChain
312
+ .catch(() => undefined)
313
+ .then(async () => {
314
+ const currentSize = await readSize(parentTranscriptPath)
315
+ const currentLines = await readLineCount(parentTranscriptPath)
316
+ bytesAtLastRun.set(sessionId, currentSize)
317
+ linesAtLastRun.set(sessionId, currentLines)
318
+ ctx.logger.info(`memory-logger spawn ${sessionId} reason=${reason} transcript_bytes=${currentSize}`)
319
+ try {
320
+ await raceSpawn(ctx.spawnSubagent('memory-logger', payload, spawnOptions), spawnTimeoutMs)
321
+ } catch (err) {
322
+ ctx.logger.error(`memory-logger spawn failed: ${err instanceof Error ? err.message : String(err)}`)
323
+ }
324
+ // Capture the daily-stream line count POST-spawn so the next spawn
325
+ // (in the same session, on the same day) can resume past anything
326
+ // this spawn appended. Tied to today's date — `fireMemoryLogger`
327
+ // checks the date before honoring the cursor.
328
+ const todayAfterSpawn = formatLocalDate()
329
+ const streamPath = streamFilePath(ctx.agentDir, todayAfterSpawn)
330
+ const streamLineCount = await readLineCount(streamPath)
331
+ streamCursorAtLastRun.set(sessionId, { date: todayAfterSpawn, lineCount: streamLineCount })
332
+ })
333
+ spawnChain = next
334
+ return next
186
335
  }
187
- // Execution authority is `system` (resolves to owner), NOT the
188
- // triggering turn's role: memory-logging is TypeClaw infrastructure over
189
- // operator-owned sessions//memory/, so a guest channel turn that triggers
190
- // it must not demote the logger to guest and get its transcript read
191
- // blocked by privateSurfaceRead. The triggering origin is preserved two
192
- // ways: `triggeredBy` for audit provenance, and `payload.origin` for
193
- // content provenance (memory extraction/retrieval channel-safety).
194
- const spawnOptions: SpawnSubagentOptions = {
195
- parentSessionId: sessionId,
196
- spawnedByOrigin: {
197
- kind: 'system',
198
- component: 'memory-logger',
199
- ...(last.origin !== undefined ? { triggeredBy: last.origin } : {}),
200
- },
336
+
337
+ const cancelTimer = (sessionId: string): void => {
338
+ const t = idleTimers.get(sessionId)
339
+ if (t !== undefined) {
340
+ clearTimeout(t)
341
+ idleTimers.delete(sessionId)
342
+ }
201
343
  }
202
- const next = spawnChain
203
- .catch(() => undefined)
204
- .then(async () => {
205
- const currentSize = await readSize(parentTranscriptPath)
206
- const currentLines = await readLineCount(parentTranscriptPath)
207
- bytesAtLastRun.set(sessionId, currentSize)
208
- linesAtLastRun.set(sessionId, currentLines)
209
- ctx.logger.info(`memory-logger spawn ${sessionId} reason=${reason} transcript_bytes=${currentSize}`)
210
- try {
211
- await raceSpawn(ctx.spawnSubagent('memory-logger', payload, spawnOptions), spawnTimeoutMs)
212
- } catch (err) {
213
- ctx.logger.error(`memory-logger spawn failed: ${err instanceof Error ? err.message : String(err)}`)
214
- }
215
- // Capture the daily-stream line count POST-spawn so the next spawn
216
- // (in the same session, on the same day) can resume past anything
217
- // this spawn appended. Tied to today's date — `fireMemoryLogger`
218
- // checks the date before honoring the cursor.
219
- const todayAfterSpawn = formatLocalDate()
220
- const streamPath = streamFilePath(ctx.agentDir, todayAfterSpawn)
221
- const streamLineCount = await readLineCount(streamPath)
222
- streamCursorAtLastRun.set(sessionId, { date: todayAfterSpawn, lineCount: streamLineCount })
223
- })
224
- spawnChain = next
225
- return next
226
- }
227
344
 
228
- const cancelTimer = (sessionId: string): void => {
229
- const t = idleTimers.get(sessionId)
230
- if (t !== undefined) {
231
- clearTimeout(t)
232
- idleTimers.delete(sessionId)
345
+ const shouldTripBufferCeiling = async (sessionId: string, transcriptPath: string): Promise<boolean> => {
346
+ if (bufferBytes === 0) return false
347
+ const currentSize = await readSize(transcriptPath)
348
+ const baseline = bytesAtLastRun.get(sessionId)
349
+ if (baseline === undefined) {
350
+ bytesAtLastRun.set(sessionId, currentSize)
351
+ return false
352
+ }
353
+ return currentSize - baseline >= bufferBytes
233
354
  }
234
- }
235
355
 
236
- const shouldTripBufferCeiling = async (sessionId: string, transcriptPath: string): Promise<boolean> => {
237
- if (bufferBytes === 0) return false
238
- const currentSize = await readSize(transcriptPath)
239
- const baseline = bytesAtLastRun.get(sessionId)
240
- if (baseline === undefined) {
241
- bytesAtLastRun.set(sessionId, currentSize)
242
- return false
356
+ const shouldSkipIdleSpawn = async (sessionId: string, transcriptPath: string): Promise<boolean> => {
357
+ if (minIdleDeltaLines === 0) return false
358
+ const currentLines = await readLineCount(transcriptPath)
359
+ if (currentLines === 0) return false
360
+ const baseline = linesAtLastRun.get(sessionId) ?? 0
361
+ return currentLines - baseline < minIdleDeltaLines
243
362
  }
244
- return currentSize - baseline >= bufferBytes
245
- }
246
363
 
247
- const shouldSkipIdleSpawn = async (sessionId: string, transcriptPath: string): Promise<boolean> => {
248
- if (minIdleDeltaLines === 0) return false
249
- const currentLines = await readLineCount(transcriptPath)
250
- if (currentLines === 0) return false
251
- const baseline = linesAtLastRun.get(sessionId) ?? 0
252
- return currentLines - baseline < minIdleDeltaLines
253
- }
364
+ const runMemoryRetrieval = async (event: {
365
+ sessionId: string
366
+ agentDir: string
367
+ userPrompt: string
368
+ origin?: SessionOrigin
369
+ }): Promise<void> => {
370
+ const shards = await loadAllShards(event.agentDir)
371
+ const plan = buildInjectionPlan(shards, { budgetBytes: ctx.config.injectionBudgetBytes })
372
+ if (plan.mode === 'direct') return
254
373
 
255
- const runMemoryRetrieval = async (event: {
256
- sessionId: string
257
- agentDir: string
258
- userPrompt: string
259
- origin?: SessionOrigin
260
- }): Promise<void> => {
261
- const shards = await loadAllShards(event.agentDir)
262
- const plan = buildInjectionPlan(shards, { budgetBytes: ctx.config.injectionBudgetBytes })
263
- if (plan.mode === 'direct') return
264
-
265
- const cacheFilePath = join(event.agentDir, 'memory', '.retrieval-cache', `${event.sessionId}.md`)
266
- const payload: MemoryRetrievalPayload = {
267
- parentSessionId: event.sessionId,
268
- agentDir: event.agentDir,
269
- recentPrompt: event.userPrompt,
270
- cacheFilePath,
271
- ...(event.origin !== undefined ? { origin: event.origin } : {}),
374
+ const cacheFilePath = join(event.agentDir, 'memory', '.retrieval-cache', `${event.sessionId}.md`)
375
+ const payload: MemoryRetrievalPayload = {
376
+ parentSessionId: event.sessionId,
377
+ agentDir: event.agentDir,
378
+ recentPrompt: event.userPrompt,
379
+ cacheFilePath,
380
+ ...(event.origin !== undefined ? { origin: event.origin } : {}),
381
+ }
382
+ // System authority, not the triggering turn's role — see the
383
+ // memory-logger spawn above. memory-retrieval writes
384
+ // memory/.retrieval-cache/, which a guest-demoted role cannot.
385
+ const retrievalSpawnOptions: SpawnSubagentOptions = {
386
+ parentSessionId: event.sessionId,
387
+ spawnedByOrigin: {
388
+ kind: 'system',
389
+ component: 'memory-retrieval',
390
+ ...(event.origin !== undefined ? { triggeredBy: event.origin } : {}),
391
+ },
392
+ }
393
+ await ctx.spawnSubagent('memory-retrieval', payload, retrievalSpawnOptions)
272
394
  }
273
- // System authority, not the triggering turn's role — see the
274
- // memory-logger spawn above. memory-retrieval writes
275
- // memory/.retrieval-cache/, which a guest-demoted role cannot.
276
- const retrievalSpawnOptions: SpawnSubagentOptions = {
277
- parentSessionId: event.sessionId,
278
- spawnedByOrigin: {
279
- kind: 'system',
280
- component: 'memory-retrieval',
281
- ...(event.origin !== undefined ? { triggeredBy: event.origin } : {}),
282
- },
395
+
396
+ // Subagents are constructed at boot here (rather than imported as constants)
397
+ // so their lifecycle logs route through the plugin logger and pick up the
398
+ // `[plugin:memory]` prefix. Without this, they would write directly to
399
+ // console and bypass the plugin namespace.
400
+ const subagentLogger = {
401
+ info: (m: string) => ctx.logger.info(m),
402
+ warn: (m: string) => ctx.logger.warn(m),
403
+ error: (m: string) => ctx.logger.error(m),
283
404
  }
284
- await ctx.spawnSubagent('memory-retrieval', payload, retrievalSpawnOptions)
285
- }
286
405
 
287
- // Subagents are constructed at boot here (rather than imported as constants)
288
- // so their lifecycle logs route through the plugin logger and pick up the
289
- // `[plugin:memory]` prefix. Without this, they would write directly to
290
- // console and bypass the plugin namespace.
291
- const subagentLogger = {
292
- info: (m: string) => ctx.logger.info(m),
293
- warn: (m: string) => ctx.logger.warn(m),
294
- error: (m: string) => ctx.logger.error(m),
295
- }
406
+ // Open a long-lived VectorStore for append-time indexing when vector is enabled.
407
+ const appendVectorStore = ctx.config.vector.enabled
408
+ ? VectorStore.open(join(ctx.agentDir, 'memory', '.vectors', 'index.db'))
409
+ : undefined
296
410
 
297
- return {
298
- subagents: {
299
- 'memory-logger': createMemoryLoggerSubagent({ logger: subagentLogger }),
300
- 'memory-retrieval': createMemoryRetrievalSubagent({
301
- logger: subagentLogger,
302
- timeoutMs: retrievalSpawnTimeoutMs,
303
- }),
304
- dreaming: createDreamingSubagent({ logger: subagentLogger }),
305
- },
306
- tools: {
307
- memory_search: memorySearchTool,
308
- },
309
- cronJobs: {
310
- dreaming: {
311
- schedule: dreamingSchedule,
312
- kind: 'prompt' as const,
313
- prompt: '(internal: dreaming consolidation; user prompt is built by the dreaming subagent handler)',
314
- subagent: 'dreaming',
315
- payload: { agentDir: ctx.agentDir } satisfies DreamingPayload,
411
+ return {
412
+ subagents: {
413
+ 'memory-logger': createMemoryLoggerSubagent({
414
+ logger: subagentLogger,
415
+ ...(appendVectorStore !== undefined
416
+ ? {
417
+ onFragmentsAppended: makeAppendHook(appendVectorStore),
418
+ onReferenceStored: makeReferenceStoredHook(appendVectorStore),
419
+ }
420
+ : {}),
421
+ }),
422
+ 'memory-retrieval': createMemoryRetrievalSubagent({
423
+ logger: subagentLogger,
424
+ timeoutMs: retrievalSpawnTimeoutMs,
425
+ }),
426
+ dreaming: createDreamingSubagent({
427
+ logger: subagentLogger,
428
+ }),
316
429
  },
317
- },
318
- hooks: {
319
- // Memory injection lives in core (`createResourceLoader` calls `loadMemory`
320
- // directly, appended LAST in the system prompt). It does not run from a
321
- // plugin hook because positioning matters for cache-prefix stability:
322
- // the daily-stream file grows after every channel turn (memory-logger
323
- // appends a fragment + watermark) and memory/topics/ changes on every dream.
324
- // A volatile region in the middle of the system prompt invalidates the
325
- // entire cacheable suffix below it on every session resurrection
326
- // (channel sessions evicted by idle GC, container restarts). Pinning
327
- // memory to the bottom of the system prompt keeps everything above it
328
- // cacheable across resurrections, at the cost of re-billing only the
329
- // memory section itself when it grows.
330
- //
331
- // Core fires `session.idle` immediately after every prompt completion;
332
- // the plugin owns the debounce timer so memory-logger only spawns
333
- // after the user has been quiet for `idleMs`. Re-arming a still-armed
334
- // timer cancels it first, matching the previous core IdleDetector.
335
- // The size-based ceiling fires synchronously when the transcript has
336
- // grown by `bufferBytes` since the last run, so busy channel sessions
337
- // (which rarely go idle) still produce memory updates.
338
- 'session.idle': async (event) => {
339
- if (event.origin?.kind === 'subagent') return
340
- lastIdleEvent.set(event.sessionId, {
341
- parentTranscriptPath: event.parentTranscriptPath,
342
- ...(event.origin !== undefined ? { origin: event.origin } : {}),
343
- })
344
- cancelTimer(event.sessionId)
345
- const sessionId = event.sessionId
346
- const transcriptPath = event.parentTranscriptPath
347
- const timer = setTimeout(() => {
348
- idleTimers.delete(sessionId)
349
- void (async () => {
350
- if (transcriptPath !== undefined && (await shouldSkipIdleSpawn(sessionId, transcriptPath))) {
351
- ctx.logger.info(
352
- `memory-logger idle skip ${sessionId} (delta below minIdleDeltaLines=${minIdleDeltaLines})`,
353
- )
354
- return
355
- }
356
- void fireMemoryLogger(sessionId, 'idle')
357
- })()
358
- }, idleMs)
359
- idleTimers.set(sessionId, timer)
360
- if (
361
- event.parentTranscriptPath !== undefined &&
362
- (await shouldTripBufferCeiling(sessionId, event.parentTranscriptPath))
363
- ) {
364
- ctx.logger.info(`buffer-ceiling trip ${sessionId} bufferBytes=${bufferBytes}`)
365
- cancelTimer(sessionId)
366
- await fireMemoryLogger(sessionId, 'buffer-trip')
367
- }
430
+ tools: {
431
+ memory_search: createMemorySearchTool(),
368
432
  },
369
- // memory-retrieval used to run from `session.prompt`, which fires
370
- // during system-prompt assembly (createResourceLoader) and carries
371
- // the ASSEMBLING SYSTEM PROMPT as `event.prompt` — not the user's
372
- // message. The plugin was feeding that string into the subagent as
373
- // `recentPrompt`, so the LLM keyword-mined TypeClaw's framing prose
374
- // (`TypeClaw`, `subagent`, `AGENTS.md`, `systemPromptLeak`, etc.)
375
- // and burned 15+ memory_search calls per session on terms the user
376
- // never said. `session.turn.start` is the correct trigger: it fires
377
- // before each `session.prompt(text)` call with the actual text the
378
- // session is about to receive.
379
- //
380
- // The hook body is fully detached. `runSessionTurnStart` has no
381
- // per-handler timeout (unlike session.prompt/idle/end), and the
382
- // caller awaits it before `session.prompt(text)` runs — so an
383
- // inline `await loadAllShards(...)` would gate every channel turn
384
- // on N shard reads. Detaching mirrors PR #337's "fire-and-forget
385
- // the slow work" pattern, pushed one level earlier to cover both
386
- // the shard read AND the LLM spawn.
387
- //
388
- // Per-turn instead of per-session-creation means N spawns over the
389
- // session's lifetime, but the subagent's own `inFlightKey` on
390
- // `parentSessionId` (memory-retrieval.ts) coalesces overlapping
391
- // fires: if turn N's retrieval is still running when turn N+1 fires,
392
- // the second spawn is dropped with a warning, and the cache from
393
- // turn N is still consumed by turn N+1 via the documented
394
- // lag-by-one-prompt contract (load-memory.ts:appendRetrievalCache).
395
- //
396
- // Known limitation: a detached spawn can theoretically settle after
397
- // `session.end` has unlinked the cache file, leaving a single ~5 KB
398
- // file in memory/.retrieval-cache/ that never gets read. The next
399
- // session.end that matches this sessionId would clean it up, but
400
- // sessionIds are UUIDv7 so reuse is effectively never. Fix would
401
- // serialize session.end behind the in-flight spawn, which
402
- // re-introduces the cold-start blocking shape PR #337 fixed. The
403
- // leaked file is bounded (one per disconnected session) and lives
404
- // in a dir already marked transient.
405
- //
406
- // ctx.spawnSubagent IS reject-able in production: it wraps
407
- // dispatchSpawnSubagent (src/run/index.ts) which calls invokeSubagent
408
- // directly with no try/catch. SubagentConsumer's catch only protects
409
- // stream-initiated spawns (target.kind === 'new-session'), not the
410
- // direct ctx.spawnSubagent path the hooks use. Same for
411
- // loadAllShards' fs errors. The .catch() on the void-discarded
412
- // promise below is load-bearing — without it, every shard-read or
413
- // handler failure (LLM provider error, payload validation throw)
414
- // would surface as an unhandled rejection because nothing awaits
415
- // the promise.
416
- 'session.turn.start': (event) => {
417
- if (event.origin?.kind === 'subagent') return
418
- void runMemoryRetrieval(event).catch((err) => {
419
- ctx.logger.error(`memory-retrieval spawn failed: ${err instanceof Error ? err.message : String(err)}`)
420
- })
433
+ cronJobs: {
434
+ dreaming: {
435
+ schedule: dreamingSchedule,
436
+ kind: 'prompt' as const,
437
+ prompt: '(internal: dreaming consolidation; user prompt is built by the dreaming subagent handler)',
438
+ subagent: 'dreaming',
439
+ payload: { agentDir: ctx.agentDir } satisfies DreamingPayload,
440
+ },
421
441
  },
422
- // The memory-logger spawn is intentionally detached (`void`) instead
423
- // of awaited. The channel router calls `tearDownLive` synchronously
424
- // inside `ensureLive`'s stale-rollover path (router.ts:718), and
425
- // `tearDownLive` awaits `fireSessionEnd` which awaits this hook. An
426
- // awaited memory-logger spawn here would block new-session creation
427
- // for the full subagent runtime observed as 22+ seconds of channel
428
- // silence on a 22 KB transcript before the new session even starts
429
- // its cold-start chain.
430
- //
431
- // Safety: `fireMemoryLogger` captures the payload synchronously from
432
- // `lastIdleEvent` (see comment above), so the `delete` calls below
433
- // cannot race with the chained spawn. `spawnChain` still serializes
434
- // memory-logger fires per agentDir — the detached promise is queued
435
- // onto the chain before this hook returns, so a subsequent fire from
436
- // the new session (idle, buffer-trip, or session-end) waits for the
437
- // session-end spawn to settle before running.
438
- //
439
- // The only durability tradeoff: if the agent process dies between
440
- // this hook returning and `spawnChain` settling, the session-end
441
- // memory-logger fire is lost (its transcript fragments don't make
442
- // it into today's daily stream). This is already true for the idle
443
- // and buffer-trip paths, which are timer-driven and fire-and-forget
444
- // by design. Session JSONLs are force-committed elsewhere, so no
445
- // user-visible transcript is lost — only the LLM-distilled stream
446
- // fragments for the final batch.
447
- 'session.end': (event) => {
448
- if (event.origin?.kind === 'subagent') return
449
- cancelTimer(event.sessionId)
450
- const sessionId = event.sessionId
451
- // The skip path detaches via `void (async () => …)()` because
452
- // readSize requires an await. fireMemoryLogger itself captures its
453
- // payload synchronously from `lastIdleEvent` (see fireMemoryLogger
454
- // comment block), so the `lastIdleEvent.delete` that follows can
455
- // never race with the chained spawn. The cache-cleanup and
456
- // bookkeeping deletes are dispatched alongside (not blocking the
457
- // hook return) to preserve the "session.end returns synchronously"
458
- // contract that the channel router's tearDownLive path depends on
459
- // (see the comment block above this hook).
460
- void (async () => {
461
- const last = lastIdleEvent.get(sessionId)
462
- let skip = false
463
- if (last?.parentTranscriptPath !== undefined) {
464
- const baseline = bytesAtLastRun.get(sessionId)
465
- if (baseline !== undefined && baseline > 0) {
466
- const currentSize = await readSize(last.parentTranscriptPath)
467
- if (currentSize === baseline) {
442
+ hooks: {
443
+ // Memory injection lives in core (`createResourceLoader` calls `loadMemory`
444
+ // directly, appended LAST in the system prompt). It does not run from a
445
+ // plugin hook because positioning matters for cache-prefix stability:
446
+ // the daily-stream file grows after every channel turn (memory-logger
447
+ // appends a fragment + watermark) and memory/topics/ changes on every dream.
448
+ // A volatile region in the middle of the system prompt invalidates the
449
+ // entire cacheable suffix below it on every session resurrection
450
+ // (channel sessions evicted by idle GC, container restarts). Pinning
451
+ // memory to the bottom of the system prompt keeps everything above it
452
+ // cacheable across resurrections, at the cost of re-billing only the
453
+ // memory section itself when it grows.
454
+ //
455
+ // Core fires `session.idle` immediately after every prompt completion;
456
+ // the plugin owns the debounce timer so memory-logger only spawns
457
+ // after the user has been quiet for `idleMs`. Re-arming a still-armed
458
+ // timer cancels it first, matching the previous core IdleDetector.
459
+ // The size-based ceiling fires synchronously when the transcript has
460
+ // grown by `bufferBytes` since the last run, so busy channel sessions
461
+ // (which rarely go idle) still produce memory updates.
462
+ 'session.idle': async (event) => {
463
+ if (event.origin?.kind === 'subagent') return
464
+ lastIdleEvent.set(event.sessionId, {
465
+ parentTranscriptPath: event.parentTranscriptPath,
466
+ ...(event.origin !== undefined ? { origin: event.origin } : {}),
467
+ })
468
+ cancelTimer(event.sessionId)
469
+ const sessionId = event.sessionId
470
+ const transcriptPath = event.parentTranscriptPath
471
+ const timer = setTimeout(() => {
472
+ idleTimers.delete(sessionId)
473
+ void (async () => {
474
+ if (transcriptPath !== undefined && (await shouldSkipIdleSpawn(sessionId, transcriptPath))) {
468
475
  ctx.logger.info(
469
- `memory-logger session-end skip ${sessionId} (no new bytes since last spawn at ${baseline})`,
476
+ `memory-logger idle skip ${sessionId} (delta below minIdleDeltaLines=${minIdleDeltaLines})`,
470
477
  )
471
- skip = true
478
+ return
472
479
  }
473
- }
480
+ void fireMemoryLogger(sessionId, 'idle')
481
+ })()
482
+ }, idleMs)
483
+ idleTimers.set(sessionId, timer)
484
+ if (
485
+ event.parentTranscriptPath !== undefined &&
486
+ (await shouldTripBufferCeiling(sessionId, event.parentTranscriptPath))
487
+ ) {
488
+ ctx.logger.info(`buffer-ceiling trip ${sessionId} bufferBytes=${bufferBytes}`)
489
+ cancelTimer(sessionId)
490
+ await fireMemoryLogger(sessionId, 'buffer-trip')
474
491
  }
475
- if (!skip) void fireMemoryLogger(sessionId, 'session-end')
476
- lastIdleEvent.delete(sessionId)
477
- bytesAtLastRun.delete(sessionId)
478
- linesAtLastRun.delete(sessionId)
479
- streamCursorAtLastRun.delete(sessionId)
480
- })()
481
- const cacheFilePath = join(ctx.agentDir, 'memory', '.retrieval-cache', `${sessionId}.md`)
482
- unlink(cacheFilePath).catch((err) => {
483
- if (!isEnoent(err)) ctx.logger.warn(`[memory] failed to clean retrieval cache: ${err}`)
484
- })
485
- },
486
- },
487
- doctorChecks: {
488
- 'dir-writable': {
489
- description: 'memory/topics/ exists and is writable',
490
- run: async (dctx) => {
491
- const dir = topicsDir(dctx.agentDir)
492
- try {
493
- await access(dir, fsConstants.W_OK)
494
- return { status: 'ok', message: `${dir} writable` }
495
- } catch {
492
+ },
493
+ // memory-retrieval used to run from `session.prompt`, which fires
494
+ // during system-prompt assembly (createResourceLoader) and carries
495
+ // the ASSEMBLING SYSTEM PROMPT as `event.prompt` — not the user's
496
+ // message. The plugin was feeding that string into the subagent as
497
+ // `recentPrompt`, so the LLM keyword-mined TypeClaw's framing prose
498
+ // (`TypeClaw`, `subagent`, `AGENTS.md`, `systemPromptLeak`, etc.)
499
+ // and burned 15+ memory_search calls per session on terms the user
500
+ // never said. `session.turn.start` is the correct trigger: it fires
501
+ // before each `session.prompt(text)` call with the actual text the
502
+ // session is about to receive.
503
+ //
504
+ 'session.turn.start': async (event) => {
505
+ if (ctx.config.vector.enabled) {
506
+ // Vector agents inject long-term memory PER-TURN into the user
507
+ // prompt (the system-prompt `# Memory` section is suppressed at
508
+ // session creation). This runs for every origin that supplies a
509
+ // retrievalContext bag — including subagents, which no longer get
510
+ // memory via the system prompt either.
511
+ if (event.retrievalContext === undefined) return
496
512
  try {
497
- await mkdir(dir, { recursive: true })
498
- return { status: 'ok', message: `created ${dir}` }
499
- } catch {
500
- return {
501
- status: 'error',
502
- message: `${dir} is missing and could not be created`,
503
- fix: { description: 'Create memory/topics/ in the agent folder or fix its permissions on the host.' },
513
+ let injectedState = injectedShards.get(event.sessionId)
514
+ if (injectedState === undefined) {
515
+ injectedState = new Map()
516
+ injectedShards.set(event.sessionId, injectedState)
504
517
  }
518
+ event.retrievalContext.results = await renderVectorTurnMemory(
519
+ event,
520
+ ctx.config.injectionBudgetBytes,
521
+ injectedState,
522
+ deps,
523
+ ctx.logger,
524
+ )
525
+ } catch (err) {
526
+ ctx.logger.error(`vector-retrieval failed: ${err instanceof Error ? err.message : String(err)}`)
505
527
  }
528
+ return
506
529
  }
530
+ // Non-vector agents keep memory in the system prompt. The index-mode
531
+ // retrieval subagent must NOT fire for subagent-origin turns (it would
532
+ // recurse: the subagent it spawns triggers another turn.start).
533
+ if (event.origin?.kind === 'subagent') return
534
+ void runMemoryRetrieval(event).catch((err) => {
535
+ ctx.logger.error(`memory-retrieval spawn failed: ${err instanceof Error ? err.message : String(err)}`)
536
+ })
507
537
  },
508
- },
509
- 'daily-stream-current': {
510
- description: "today's daily stream file exists",
511
- run: async (dctx) => {
512
- const today = new Date().toISOString().slice(0, 10)
513
- const rel = join('memory', 'streams', `${today}.jsonl`)
514
- const abs = streamFilePath(dctx.agentDir, today)
515
- if (existsSync(abs)) return { status: 'ok', message: `${rel} present` }
516
- return {
517
- status: 'warning',
518
- message: `${rel} missing`,
519
- fix: {
520
- description: `Create empty ${rel} so memory-logger has a target.`,
521
- apply: async () => {
522
- await mkdir(streamsDir(dctx.agentDir), { recursive: true })
523
- await writeFile(abs, '', 'utf8')
524
- return { summary: `created ${rel}`, changedPaths: [rel] }
525
- },
526
- },
527
- }
538
+ // The memory-logger spawn is intentionally detached (`void`) instead
539
+ // of awaited. The channel router calls `tearDownLive` synchronously
540
+ // inside `ensureLive`'s stale-rollover path (router.ts:718), and
541
+ // `tearDownLive` awaits `fireSessionEnd` which awaits this hook. An
542
+ // awaited memory-logger spawn here would block new-session creation
543
+ // for the full subagent runtime — observed as 22+ seconds of channel
544
+ // silence on a 22 KB transcript before the new session even starts
545
+ // its cold-start chain.
546
+ //
547
+ // Safety: `fireMemoryLogger` captures the payload synchronously from
548
+ // `lastIdleEvent` (see comment above), so the `delete` calls below
549
+ // cannot race with the chained spawn. `spawnChain` still serializes
550
+ // memory-logger fires per agentDir the detached promise is queued
551
+ // onto the chain before this hook returns, so a subsequent fire from
552
+ // the new session (idle, buffer-trip, or session-end) waits for the
553
+ // session-end spawn to settle before running.
554
+ //
555
+ // The only durability tradeoff: if the agent process dies between
556
+ // this hook returning and `spawnChain` settling, the session-end
557
+ // memory-logger fire is lost (its transcript fragments don't make
558
+ // it into today's daily stream). This is already true for the idle
559
+ // and buffer-trip paths, which are timer-driven and fire-and-forget
560
+ // by design. Session JSONLs are force-committed elsewhere, so no
561
+ // user-visible transcript is lost — only the LLM-distilled stream
562
+ // fragments for the final batch.
563
+ 'session.end': (event) => {
564
+ // Dedup state is populated for every vector turn (subagents included),
565
+ // so it must be cleared before the subagent-origin early-return below.
566
+ injectedShards.delete(event.sessionId)
567
+ if (event.origin?.kind === 'subagent') return
568
+ cancelTimer(event.sessionId)
569
+ const sessionId = event.sessionId
570
+ // The skip path detaches via `void (async () => …)()` because
571
+ // readSize requires an await. fireMemoryLogger itself captures its
572
+ // payload synchronously from `lastIdleEvent` (see fireMemoryLogger
573
+ // comment block), so the `lastIdleEvent.delete` that follows can
574
+ // never race with the chained spawn. The cache-cleanup and
575
+ // bookkeeping deletes are dispatched alongside (not blocking the
576
+ // hook return) to preserve the "session.end returns synchronously"
577
+ // contract that the channel router's tearDownLive path depends on
578
+ // (see the comment block above this hook).
579
+ void (async () => {
580
+ const last = lastIdleEvent.get(sessionId)
581
+ let skip = false
582
+ if (last?.parentTranscriptPath !== undefined) {
583
+ const baseline = bytesAtLastRun.get(sessionId)
584
+ if (baseline !== undefined && baseline > 0) {
585
+ const currentSize = await readSize(last.parentTranscriptPath)
586
+ if (currentSize === baseline) {
587
+ ctx.logger.info(
588
+ `memory-logger session-end skip ${sessionId} (no new bytes since last spawn at ${baseline})`,
589
+ )
590
+ skip = true
591
+ }
592
+ }
593
+ }
594
+ if (!skip) void fireMemoryLogger(sessionId, 'session-end')
595
+ lastIdleEvent.delete(sessionId)
596
+ bytesAtLastRun.delete(sessionId)
597
+ linesAtLastRun.delete(sessionId)
598
+ streamCursorAtLastRun.delete(sessionId)
599
+ })()
600
+ const cacheFilePath = join(ctx.agentDir, 'memory', '.retrieval-cache', `${sessionId}.md`)
601
+ unlink(cacheFilePath).catch((err) => {
602
+ if (!isEnoent(err)) ctx.logger.warn(`[memory] failed to clean retrieval cache: ${err}`)
603
+ })
528
604
  },
529
605
  },
530
- 'pre-shard-backup-age': {
531
- description: 'Warn when pre-shard backup is older than 30 days',
532
- run: async (dctx) => {
533
- const backupPath = preShardBackupPath(dctx.agentDir)
534
- let s
535
- try {
536
- s = await stat(backupPath)
537
- } catch {
538
- return { status: 'ok', message: 'no pre-shard backup present' }
539
- }
540
- const ageDays = (Date.now() - s.mtimeMs) / 86_400_000
541
- if (ageDays > 30) {
606
+ doctorChecks: {
607
+ 'dir-writable': {
608
+ description: 'memory/topics/ exists and is writable',
609
+ run: async (dctx) => {
610
+ const dir = topicsDir(dctx.agentDir)
611
+ try {
612
+ await access(dir, fsConstants.W_OK)
613
+ return { status: 'ok', message: `${dir} writable` }
614
+ } catch {
615
+ try {
616
+ await mkdir(dir, { recursive: true })
617
+ return { status: 'ok', message: `created ${dir}` }
618
+ } catch {
619
+ return {
620
+ status: 'error',
621
+ message: `${dir} is missing and could not be created`,
622
+ fix: {
623
+ description: 'Create memory/topics/ in the agent folder or fix its permissions on the host.',
624
+ },
625
+ }
626
+ }
627
+ }
628
+ },
629
+ },
630
+ 'daily-stream-current': {
631
+ description: "today's daily stream file exists",
632
+ run: async (dctx) => {
633
+ const today = new Date().toISOString().slice(0, 10)
634
+ const rel = join('memory', 'streams', `${today}.jsonl`)
635
+ const abs = streamFilePath(dctx.agentDir, today)
636
+ if (existsSync(abs)) return { status: 'ok', message: `${rel} present` }
542
637
  return {
543
638
  status: 'warning',
544
- message: `pre-shard backup is ${Math.round(ageDays)} days old; safe to delete if migration is verified`,
639
+ message: `${rel} missing`,
545
640
  fix: {
546
- description: 'Delete the pre-shard backup file',
641
+ description: `Create empty ${rel} so memory-logger has a target.`,
547
642
  apply: async () => {
548
- await unlink(backupPath)
549
- return {
550
- summary: 'deleted pre-shard backup',
551
- changedPaths: [join('memory', 'MEMORY.md.pre-shard.bak')],
552
- }
643
+ await mkdir(streamsDir(dctx.agentDir), { recursive: true })
644
+ await writeFile(abs, '', 'utf8')
645
+ return { summary: `created ${rel}`, changedPaths: [rel] }
553
646
  },
554
647
  },
555
648
  }
556
- }
557
- return {
558
- status: 'ok',
559
- message: `pre-shard backup is ${Math.round(ageDays)} days old (under 30-day threshold)`,
560
- }
649
+ },
650
+ },
651
+ 'pre-shard-backup-age': {
652
+ description: 'Warn when pre-shard backup is older than 30 days',
653
+ run: async (dctx) => {
654
+ const backupPath = preShardBackupPath(dctx.agentDir)
655
+ let s
656
+ try {
657
+ s = await stat(backupPath)
658
+ } catch {
659
+ return { status: 'ok', message: 'no pre-shard backup present' }
660
+ }
661
+ const ageDays = (Date.now() - s.mtimeMs) / 86_400_000
662
+ if (ageDays > 30) {
663
+ return {
664
+ status: 'warning',
665
+ message: `pre-shard backup is ${Math.round(ageDays)} days old; safe to delete if migration is verified`,
666
+ fix: {
667
+ description: 'Delete the pre-shard backup file',
668
+ apply: async () => {
669
+ await unlink(backupPath)
670
+ return {
671
+ summary: 'deleted pre-shard backup',
672
+ changedPaths: [join('memory', 'MEMORY.md.pre-shard.bak')],
673
+ }
674
+ },
675
+ },
676
+ }
677
+ }
678
+ return {
679
+ status: 'ok',
680
+ message: `pre-shard backup is ${Math.round(ageDays)} days old (under 30-day threshold)`,
681
+ }
682
+ },
683
+ },
684
+ 'vector-index': {
685
+ description: 'vector index is consistent with memory (only when memory.vector is enabled)',
686
+ run: async (dctx) => {
687
+ const config = dctx.config as typeof ctx.config
688
+ if (!vectorEnabled(config)) {
689
+ return { status: 'ok', message: 'vector memory not enabled; skipping index health check' }
690
+ }
691
+ return runVectorIndexDoctor(dctx.agentDir)
692
+ },
561
693
  },
562
694
  },
563
- },
564
- }
565
- },
566
- })
695
+ }
696
+ },
697
+ })
698
+ }
699
+
700
+ export default createMemoryPlugin()
701
+
702
+ export function createMemoryPluginForTests(overrides: Partial<MemoryPluginDeps> = {}) {
703
+ return createMemoryPlugin({ ...defaultDeps, ...overrides })
704
+ }
705
+
706
+ function vectorEnabled(config: unknown): boolean {
707
+ if (typeof config !== 'object' || config === null) return false
708
+ const parsed = vectorConfigSchema.safeParse((config as Record<string, unknown>).vector)
709
+ return parsed.success && parsed.data.enabled
710
+ }
567
711
 
568
712
  async function readSize(path: string): Promise<number> {
569
713
  try {