typeclaw 0.8.0 → 0.9.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 (96) hide show
  1. package/README.md +6 -6
  2. package/package.json +5 -3
  3. package/scripts/require-parallel.ts +41 -0
  4. package/src/agent/index.ts +55 -6
  5. package/src/agent/live-sessions.ts +34 -0
  6. package/src/agent/plugin-tools.ts +2 -0
  7. package/src/agent/session-meta.ts +21 -2
  8. package/src/agent/subagent-completion-reminder.ts +89 -0
  9. package/src/agent/subagents.ts +75 -15
  10. package/src/agent/system-prompt.ts +10 -8
  11. package/src/agent/tools/channel-reply.ts +47 -7
  12. package/src/agent/tools/channel-send.ts +43 -11
  13. package/src/agent/tools/runtime-notice.ts +41 -0
  14. package/src/bundled-plugins/explorer/explorer.ts +2 -2
  15. package/src/bundled-plugins/guard/index.ts +14 -1
  16. package/src/bundled-plugins/guard/policies/managed-config.ts +43 -13
  17. package/src/bundled-plugins/guard/policies/memory-retrieval-cache-write.ts +37 -0
  18. package/src/bundled-plugins/guard/policies/memory-topics-delete.ts +67 -0
  19. package/src/bundled-plugins/guard/policies/memory-topics-write.ts +33 -0
  20. package/src/bundled-plugins/guard/policies/non-workspace-write.ts +8 -2
  21. package/src/bundled-plugins/guard/policy.ts +7 -0
  22. package/src/bundled-plugins/memory/README.md +76 -62
  23. package/src/bundled-plugins/memory/append-tool.ts +3 -2
  24. package/src/bundled-plugins/memory/citation-superset.ts +49 -11
  25. package/src/bundled-plugins/memory/citations.ts +19 -8
  26. package/src/bundled-plugins/memory/delete-tool.ts +57 -0
  27. package/src/bundled-plugins/memory/dreaming-state.ts +1 -1
  28. package/src/bundled-plugins/memory/dreaming.ts +364 -146
  29. package/src/bundled-plugins/memory/frontmatter.ts +165 -0
  30. package/src/bundled-plugins/memory/index.ts +257 -16
  31. package/src/bundled-plugins/memory/injection-plan.ts +15 -0
  32. package/src/bundled-plugins/memory/load-memory.ts +102 -103
  33. package/src/bundled-plugins/memory/load-shards.ts +156 -0
  34. package/src/bundled-plugins/memory/memory-logger.ts +16 -15
  35. package/src/bundled-plugins/memory/memory-retrieval.ts +111 -0
  36. package/src/bundled-plugins/memory/migration.ts +353 -1
  37. package/src/bundled-plugins/memory/paths.ts +42 -0
  38. package/src/bundled-plugins/memory/search-tool.ts +232 -0
  39. package/src/bundled-plugins/memory/secret-detector.ts +2 -2
  40. package/src/bundled-plugins/memory/shard-snapshot.ts +51 -0
  41. package/src/bundled-plugins/memory/slug.ts +59 -0
  42. package/src/bundled-plugins/memory/stream-io.ts +110 -1
  43. package/src/bundled-plugins/memory/strength.ts +3 -3
  44. package/src/bundled-plugins/memory/topics.ts +70 -16
  45. package/src/bundled-plugins/security/index.ts +24 -0
  46. package/src/bundled-plugins/security/permissions.ts +4 -0
  47. package/src/bundled-plugins/security/policies/cron-promotion.ts +349 -0
  48. package/src/bundled-plugins/security/policies/git-exfil.ts +2 -0
  49. package/src/bundled-plugins/security/policies/prompt-injection.ts +3 -0
  50. package/src/bundled-plugins/security/policies/role-promotion.ts +419 -0
  51. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +1 -0
  52. package/src/channels/adapters/kakaotalk-attachment.ts +7 -17
  53. package/src/channels/adapters/kakaotalk-classify.ts +4 -1
  54. package/src/channels/adapters/kakaotalk.ts +65 -38
  55. package/src/channels/adapters/slack-bot-classify.ts +2 -27
  56. package/src/channels/index.ts +5 -0
  57. package/src/channels/router.ts +320 -22
  58. package/src/channels/subagent-completion-bridge.ts +84 -0
  59. package/src/cli/builtins.ts +1 -0
  60. package/src/cli/index.ts +1 -0
  61. package/src/cli/init.ts +122 -14
  62. package/src/cli/inspect.ts +151 -0
  63. package/src/cron/consumer.ts +1 -1
  64. package/src/init/dockerfile.ts +268 -4
  65. package/src/init/hatching.ts +5 -6
  66. package/src/init/kakaotalk-auth.ts +6 -47
  67. package/src/init/validate-api-key.ts +121 -0
  68. package/src/inspect/index.ts +213 -0
  69. package/src/inspect/label.ts +50 -0
  70. package/src/inspect/live.ts +221 -0
  71. package/src/inspect/render.ts +163 -0
  72. package/src/inspect/replay.ts +295 -0
  73. package/src/inspect/session-list.ts +160 -0
  74. package/src/inspect/types.ts +110 -0
  75. package/src/plugin/hooks.ts +23 -1
  76. package/src/plugin/index.ts +2 -0
  77. package/src/plugin/manager.ts +1 -1
  78. package/src/plugin/registry.ts +1 -1
  79. package/src/plugin/types.ts +10 -0
  80. package/src/run/channel-session-factory.ts +7 -1
  81. package/src/run/index.ts +103 -21
  82. package/src/secrets/kakao-renewal.ts +3 -47
  83. package/src/server/index.ts +241 -60
  84. package/src/shared/index.ts +3 -0
  85. package/src/shared/protocol.ts +49 -0
  86. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +9 -9
  87. package/src/skills/typeclaw-claude-code/SKILL.md +57 -39
  88. package/src/skills/typeclaw-claude-code/references/stop-hook.md +2 -0
  89. package/src/skills/typeclaw-claude-code/references/tmux-driving.md +102 -16
  90. package/src/skills/typeclaw-config/SKILL.md +1 -1
  91. package/src/skills/typeclaw-cron/SKILL.md +1 -1
  92. package/src/skills/typeclaw-memory/SKILL.md +16 -163
  93. package/src/skills/typeclaw-permissions/SKILL.md +2 -2
  94. package/src/skills/typeclaw-plugins/SKILL.md +25 -14
  95. package/src/test-helpers/wait-for.ts +7 -1
  96. package/typeclaw.schema.json +15 -1
@@ -0,0 +1,165 @@
1
+ export type ShardFrontmatter = {
2
+ heading: string
3
+ cites: number
4
+ days: number
5
+ lastReinforced: string
6
+ tags?: string[]
7
+ }
8
+
9
+ const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/
10
+
11
+ export function parseShard(text: string): { frontmatter: ShardFrontmatter; body: string } {
12
+ const normalized = text.replaceAll('\r\n', '\n')
13
+
14
+ if (!normalized.startsWith('---\n')) {
15
+ throw new Error('frontmatter delimiter missing')
16
+ }
17
+
18
+ const closeIndex = normalized.indexOf('\n---', 4)
19
+ if (closeIndex === -1) {
20
+ throw new Error('frontmatter delimiter missing')
21
+ }
22
+
23
+ const fmText = normalized.slice(4, closeIndex)
24
+ const body = normalized.slice(closeIndex + 5)
25
+
26
+ const frontmatter = parseFrontmatterBlock(fmText)
27
+ return { frontmatter, body }
28
+ }
29
+
30
+ function parseFrontmatterBlock(text: string): ShardFrontmatter {
31
+ const lines = text.split('\n')
32
+ const values: Record<string, unknown> = {}
33
+
34
+ let i = 0
35
+ while (i < lines.length) {
36
+ const line = lines[i]!
37
+ if (line.trim() === '') {
38
+ i++
39
+ continue
40
+ }
41
+
42
+ const colonIndex = line.indexOf(':')
43
+ if (colonIndex === -1) {
44
+ i++
45
+ continue
46
+ }
47
+
48
+ const key = line.slice(0, colonIndex).trim()
49
+ const rest = line.slice(colonIndex + 1).trim()
50
+
51
+ if (key === 'tags') {
52
+ if (rest === '') {
53
+ const listItems: string[] = []
54
+ i++
55
+ while (i < lines.length) {
56
+ const listLine = lines[i]!
57
+ if (!listLine.startsWith(' - ')) break
58
+ listItems.push(listLine.slice(4).trim())
59
+ i++
60
+ }
61
+ values.tags = listItems
62
+ continue
63
+ } else if (rest.startsWith('[') && rest.endsWith(']')) {
64
+ values.tags = rest
65
+ .slice(1, -1)
66
+ .split(',')
67
+ .map((s) => s.trim())
68
+ .filter(Boolean)
69
+ i++
70
+ continue
71
+ } else if (rest === '[]') {
72
+ values.tags = []
73
+ i++
74
+ continue
75
+ } else {
76
+ throw new Error(`frontmatter field 'tags': expected array, got '${rest}'`)
77
+ }
78
+ }
79
+
80
+ if (key in FRONTMATTER_PARSERS) {
81
+ try {
82
+ values[key] = FRONTMATTER_PARSERS[key as keyof typeof FRONTMATTER_PARSERS](rest)
83
+ } catch (err) {
84
+ const message = err instanceof Error ? err.message : String(err)
85
+ throw new Error(`frontmatter field '${key}': ${message}`)
86
+ }
87
+ } else {
88
+ throw new Error(`frontmatter field '${key}': unknown`)
89
+ }
90
+
91
+ i++
92
+ }
93
+
94
+ const heading = values.heading
95
+ if (typeof heading !== 'string' || heading.length === 0) {
96
+ throw new Error("frontmatter field 'heading': required")
97
+ }
98
+
99
+ const cites = values.cites
100
+ if (typeof cites !== 'number') {
101
+ throw new Error(`frontmatter field 'cites': expected integer, got '${values.cites}'`)
102
+ }
103
+
104
+ const days = values.days
105
+ if (typeof days !== 'number') {
106
+ throw new Error(`frontmatter field 'days': expected integer, got '${values.days}'`)
107
+ }
108
+
109
+ const lastReinforced = values.lastReinforced
110
+ if (typeof lastReinforced !== 'string' || !DATE_REGEX.test(lastReinforced)) {
111
+ throw new Error(`frontmatter field 'lastReinforced': expected YYYY-MM-DD, got '${values.lastReinforced}'`)
112
+ }
113
+
114
+ const result: ShardFrontmatter = { heading, cites, days, lastReinforced }
115
+ if ('tags' in values) {
116
+ result.tags = values.tags as string[]
117
+ }
118
+
119
+ return result
120
+ }
121
+
122
+ const FRONTMATTER_PARSERS: {
123
+ [K in keyof Omit<ShardFrontmatter, 'tags'>]: (value: string) => unknown
124
+ } = {
125
+ heading: (v) => v,
126
+ cites: parseNonNegativeInt,
127
+ days: parseNonNegativeInt,
128
+ lastReinforced: (v) => v,
129
+ }
130
+
131
+ function parseNonNegativeInt(value: string): number {
132
+ const trimmed = value.trim()
133
+ const num = Number(trimmed)
134
+ if (!Number.isInteger(num) || String(num) !== trimmed || num < 0) {
135
+ throw new Error(`expected non-negative integer, got '${value}'`)
136
+ }
137
+ return num
138
+ }
139
+
140
+ export function renderShard(frontmatter: ShardFrontmatter, body: string): string {
141
+ const lines = ['---']
142
+ lines.push(`heading: ${frontmatter.heading}`)
143
+ lines.push(`cites: ${frontmatter.cites}`)
144
+ lines.push(`days: ${frontmatter.days}`)
145
+ lines.push(`lastReinforced: ${frontmatter.lastReinforced}`)
146
+ if (frontmatter.tags !== undefined) {
147
+ if (frontmatter.tags.length === 0) {
148
+ lines.push('tags: []')
149
+ } else {
150
+ lines.push(`tags: [${frontmatter.tags.join(', ')}]`)
151
+ }
152
+ }
153
+ lines.push('---')
154
+ lines.push(body)
155
+ return lines.join('\n')
156
+ }
157
+
158
+ export function updateFrontmatter(text: string, patch: Partial<ShardFrontmatter>): string {
159
+ const { frontmatter, body } = parseShard(text)
160
+ const updated: ShardFrontmatter = { ...frontmatter, ...patch }
161
+ if ('tags' in patch && patch.tags === undefined) {
162
+ delete (updated as Record<string, unknown>).tags
163
+ }
164
+ return renderShard(updated, body)
165
+ }
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from 'node:fs'
2
- import { access, constants as fsConstants, mkdir, readdir, stat, writeFile } from 'node:fs/promises'
3
- import { dirname, join } from 'node:path'
2
+ import { access, constants as fsConstants, mkdir, readdir, stat, unlink, writeFile } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
4
 
5
5
  import { CronExpressionParser } from 'cron-parser'
6
6
  import { z } from 'zod'
@@ -9,8 +9,13 @@ import type { SessionOrigin } from '@/agent/session-origin'
9
9
  import { definePlugin } from '@/plugin'
10
10
 
11
11
  import { createDreamingSubagent, type DreamingPayload } from './dreaming'
12
+ import { buildInjectionPlan, DEFAULT_INJECTION_BUDGET_BYTES, MIN_INJECTION_BUDGET_BYTES } from './injection-plan'
13
+ import { loadAllShards } from './load-shards'
12
14
  import { createMemoryLoggerSubagent, type MemoryLoggerPayload } from './memory-logger'
13
- import { runMigration } from './migration'
15
+ import { createMemoryRetrievalSubagent, type MemoryRetrievalPayload } from './memory-retrieval'
16
+ import { runMigration, runShardingMigration } from './migration'
17
+ import { preShardBackupPath, streamFilePath, streamsDir, topicsDir } from './paths'
18
+ import { memorySearchTool } from './search-tool'
14
19
 
15
20
  const DEFAULT_IDLE_MS = 60_000
16
21
  const DEFAULT_BUFFER_BYTES = 500_000
@@ -22,6 +27,17 @@ const MIN_BUFFER_BYTES = 10_000
22
27
  // sporadic agents entirely. Operators can override via `memory.dreaming.schedule`.
23
28
  const DEFAULT_DREAMING_SCHEDULE = '*/30 * * * *'
24
29
 
30
+ // memory-retrieval's ceiling, enforced by the orchestration layer (see
31
+ // `awaitWithSubagentTimeout` in @/agent/subagents). 30s is sized for the
32
+ // declared workload — up to 3 `memory_search` calls + 1 `write` against a
33
+ // `fast`-profile model. The 5+ minute outliers observed in the wild
34
+ // (reasoning-model cold-start on the default profile) require either a
35
+ // genuinely wedged provider, a misconfigured profile that routes retrieval
36
+ // to a reasoning model anyway, or both. In all three cases, releasing the
37
+ // coalescing key after 30s lets the next channel turn spawn a fresh
38
+ // retrieval instead of staying skip-coalesced behind the stuck one.
39
+ const RETRIEVAL_SPAWN_TIMEOUT_MS = 30_000
40
+
25
41
  // Hard ceiling on a single memory-logger spawn. The chain serializes spawns
26
42
  // per agent, so a non-settling spawn would otherwise wedge every subsequent
27
43
  // fire — including the session.end hook path that gates cron consumer's
@@ -75,14 +91,26 @@ const memoryConfigSchema = z
75
91
  message: `memory.bufferBytes must be 0 (disabled) or >= ${MIN_BUFFER_BYTES}`,
76
92
  })
77
93
  .default(DEFAULT_BUFFER_BYTES),
94
+ injectionBudgetBytes: z.number().int().min(MIN_INJECTION_BUDGET_BYTES).default(DEFAULT_INJECTION_BUDGET_BYTES),
78
95
  // Test seam: per-spawn ceiling for memory-logger. Operators have no
79
96
  // reason to tune this; it exists so the wedge-recovery test can fire
80
97
  // the timeout in milliseconds instead of the production 50s. Kept
81
98
  // undocumented for users.
82
99
  spawnTimeoutMs: z.number().int().min(1).default(SPAWN_TIMEOUT_MS),
100
+ // Test seam: per-spawn ceiling for memory-retrieval. Same rationale as
101
+ // `spawnTimeoutMs` — operators have no reason to tune this; it exists
102
+ // so the wedge-recovery test for memory-retrieval can fire the timeout
103
+ // in milliseconds instead of the production 30s.
104
+ retrievalSpawnTimeoutMs: z.number().int().min(1).default(RETRIEVAL_SPAWN_TIMEOUT_MS),
83
105
  dreaming: dreamingConfigSchema.optional(),
84
106
  })
85
- .default({ idleMs: DEFAULT_IDLE_MS, bufferBytes: DEFAULT_BUFFER_BYTES, spawnTimeoutMs: SPAWN_TIMEOUT_MS })
107
+ .default({
108
+ idleMs: DEFAULT_IDLE_MS,
109
+ bufferBytes: DEFAULT_BUFFER_BYTES,
110
+ injectionBudgetBytes: DEFAULT_INJECTION_BUDGET_BYTES,
111
+ spawnTimeoutMs: SPAWN_TIMEOUT_MS,
112
+ retrievalSpawnTimeoutMs: RETRIEVAL_SPAWN_TIMEOUT_MS,
113
+ })
86
114
 
87
115
  export default definePlugin({
88
116
  configSchema: memoryConfigSchema,
@@ -90,6 +118,7 @@ export default definePlugin({
90
118
  const idleMs = ctx.config.idleMs
91
119
  const bufferBytes = ctx.config.bufferBytes
92
120
  const spawnTimeoutMs = ctx.config.spawnTimeoutMs
121
+ const retrievalSpawnTimeoutMs = ctx.config.retrievalSpawnTimeoutMs
93
122
  const dreamingSchedule = ctx.config.dreaming?.schedule ?? DEFAULT_DREAMING_SCHEDULE
94
123
 
95
124
  const migrationResult = await runMigration({
@@ -100,6 +129,18 @@ export default definePlugin({
100
129
  ctx.logger.info(`[memory] migrated ${migrationResult.migrated.length} daily stream(s) to JSONL`)
101
130
  }
102
131
 
132
+ const shardingResult = await runShardingMigration({
133
+ agentDir: ctx.agentDir,
134
+ logger: ctx.logger,
135
+ })
136
+ if (shardingResult.migrated) {
137
+ ctx.logger.info(
138
+ `[memory] sharded ${shardingResult.topicCount} topics + ${shardingResult.streamCount} streams (pre-shard backup at memory/MEMORY.md.pre-shard.bak)`,
139
+ )
140
+ } else if (shardingResult.error !== undefined) {
141
+ ctx.logger.warn(`[memory] sharding migration aborted: ${shardingResult.error}`)
142
+ }
143
+
103
144
  const idleTimers = new Map<string, ReturnType<typeof setTimeout>>()
104
145
  const lastIdleEvent = new Map<string, { parentTranscriptPath: string | undefined; origin?: SessionOrigin }>()
105
146
  const bytesAtLastRun = new Map<string, number>()
@@ -164,6 +205,30 @@ export default definePlugin({
164
205
  return currentSize - baseline >= bufferBytes
165
206
  }
166
207
 
208
+ const runMemoryRetrieval = async (event: {
209
+ sessionId: string
210
+ agentDir: string
211
+ userPrompt: string
212
+ origin?: SessionOrigin
213
+ }): Promise<void> => {
214
+ const shards = await loadAllShards(event.agentDir)
215
+ const plan = buildInjectionPlan(shards, { budgetBytes: ctx.config.injectionBudgetBytes })
216
+ if (plan.mode === 'direct') return
217
+
218
+ const cacheFilePath = join(event.agentDir, 'memory', '.retrieval-cache', `${event.sessionId}.md`)
219
+ const payload: MemoryRetrievalPayload = {
220
+ parentSessionId: event.sessionId,
221
+ agentDir: event.agentDir,
222
+ recentPrompt: event.userPrompt,
223
+ cacheFilePath,
224
+ ...(event.origin !== undefined ? { origin: event.origin } : {}),
225
+ }
226
+ await ctx.spawnSubagent('memory-retrieval', payload, {
227
+ parentSessionId: event.sessionId,
228
+ ...(event.origin !== undefined ? { spawnedByOrigin: event.origin } : {}),
229
+ })
230
+ }
231
+
167
232
  // Subagents are constructed at boot here (rather than imported as constants)
168
233
  // so their lifecycle logs route through the plugin logger and pick up the
169
234
  // `[plugin:memory]` prefix. Without this, they would write directly to
@@ -177,8 +242,15 @@ export default definePlugin({
177
242
  return {
178
243
  subagents: {
179
244
  'memory-logger': createMemoryLoggerSubagent({ logger: subagentLogger }),
245
+ 'memory-retrieval': createMemoryRetrievalSubagent({
246
+ logger: subagentLogger,
247
+ timeoutMs: retrievalSpawnTimeoutMs,
248
+ }),
180
249
  dreaming: createDreamingSubagent({ logger: subagentLogger }),
181
250
  },
251
+ tools: {
252
+ memory_search: memorySearchTool,
253
+ },
182
254
  cronJobs: {
183
255
  dreaming: {
184
256
  schedule: dreamingSchedule,
@@ -193,7 +265,7 @@ export default definePlugin({
193
265
  // directly, appended LAST in the system prompt). It does not run from a
194
266
  // plugin hook because positioning matters for cache-prefix stability:
195
267
  // the daily-stream file grows after every channel turn (memory-logger
196
- // appends a fragment + watermark) and MEMORY.md changes on every dream.
268
+ // appends a fragment + watermark) and memory/topics/ changes on every dream.
197
269
  // A volatile region in the middle of the system prompt invalidates the
198
270
  // entire cacheable suffix below it on every session resurrection
199
271
  // (channel sessions evicted by idle GC, container restarts). Pinning
@@ -230,27 +302,91 @@ export default definePlugin({
230
302
  await fireMemoryLogger(sessionId, 'buffer-trip')
231
303
  }
232
304
  },
305
+ // memory-retrieval used to run from `session.prompt`, which fires
306
+ // during system-prompt assembly (createResourceLoader) and carries
307
+ // the ASSEMBLING SYSTEM PROMPT as `event.prompt` — not the user's
308
+ // message. The plugin was feeding that string into the subagent as
309
+ // `recentPrompt`, so the LLM keyword-mined TypeClaw's framing prose
310
+ // (`TypeClaw`, `subagent`, `AGENTS.md`, `systemPromptLeak`, etc.)
311
+ // and burned 15+ memory_search calls per session on terms the user
312
+ // never said. `session.turn.start` is the correct trigger: it fires
313
+ // before each `session.prompt(text)` call with the actual text the
314
+ // session is about to receive.
315
+ //
316
+ // The hook body is fully detached. `runSessionTurnStart` has no
317
+ // per-handler timeout (unlike session.prompt/idle/end), and the
318
+ // caller awaits it before `session.prompt(text)` runs — so an
319
+ // inline `await loadAllShards(...)` would gate every channel turn
320
+ // on N shard reads. Detaching mirrors PR #337's "fire-and-forget
321
+ // the slow work" pattern, pushed one level earlier to cover both
322
+ // the shard read AND the LLM spawn.
323
+ //
324
+ // Per-turn instead of per-session-creation means N spawns over the
325
+ // session's lifetime, but the subagent's own `inFlightKey` on
326
+ // `parentSessionId` (memory-retrieval.ts) coalesces overlapping
327
+ // fires: if turn N's retrieval is still running when turn N+1 fires,
328
+ // the second spawn is dropped with a warning, and the cache from
329
+ // turn N is still consumed by turn N+1 via the documented
330
+ // lag-by-one-prompt contract (load-memory.ts:appendRetrievalCache).
331
+ //
332
+ // Known limitation: a detached spawn can theoretically settle after
333
+ // `session.end` has unlinked the cache file, leaving a single ~5 KB
334
+ // file in memory/.retrieval-cache/ that never gets read. The next
335
+ // session.end that matches this sessionId would clean it up, but
336
+ // sessionIds are UUIDv7 so reuse is effectively never. Fix would
337
+ // serialize session.end behind the in-flight spawn, which
338
+ // re-introduces the cold-start blocking shape PR #337 fixed. The
339
+ // leaked file is bounded (one per disconnected session) and lives
340
+ // in a dir already marked transient.
341
+ //
342
+ // ctx.spawnSubagent IS reject-able in production: it wraps
343
+ // dispatchSpawnSubagent (src/run/index.ts) which calls invokeSubagent
344
+ // directly with no try/catch. SubagentConsumer's catch only protects
345
+ // stream-initiated spawns (target.kind === 'new-session'), not the
346
+ // direct ctx.spawnSubagent path the hooks use. Same for
347
+ // loadAllShards' fs errors. The .catch() on the void-discarded
348
+ // promise below is load-bearing — without it, every shard-read or
349
+ // handler failure (LLM provider error, payload validation throw)
350
+ // would surface as an unhandled rejection because nothing awaits
351
+ // the promise.
352
+ 'session.turn.start': (event) => {
353
+ if (event.origin?.kind === 'subagent') return
354
+ void runMemoryRetrieval(event).catch((err) => {
355
+ ctx.logger.error(`memory-retrieval spawn failed: ${err instanceof Error ? err.message : String(err)}`)
356
+ })
357
+ },
233
358
  'session.end': async (event) => {
234
359
  if (event.origin?.kind === 'subagent') return
235
360
  cancelTimer(event.sessionId)
236
361
  await fireMemoryLogger(event.sessionId, 'session-end')
362
+ const cacheFilePath = join(ctx.agentDir, 'memory', '.retrieval-cache', `${event.sessionId}.md`)
363
+ try {
364
+ await unlink(cacheFilePath)
365
+ } catch (err) {
366
+ if (!isEnoent(err)) ctx.logger.warn(`[memory] failed to clean retrieval cache: ${err}`)
367
+ }
237
368
  lastIdleEvent.delete(event.sessionId)
238
369
  bytesAtLastRun.delete(event.sessionId)
239
370
  },
240
371
  },
241
372
  doctorChecks: {
242
373
  'dir-writable': {
243
- description: 'memory/ exists and is writable',
374
+ description: 'memory/topics/ exists and is writable',
244
375
  run: async (dctx) => {
245
- const dir = join(dctx.agentDir, 'memory')
376
+ const dir = topicsDir(dctx.agentDir)
246
377
  try {
247
378
  await access(dir, fsConstants.W_OK)
248
379
  return { status: 'ok', message: `${dir} writable` }
249
380
  } catch {
250
- return {
251
- status: 'error',
252
- message: `${dir} is missing or not writable`,
253
- fix: { description: 'Create memory/ in the agent folder or fix its permissions on the host.' },
381
+ try {
382
+ await mkdir(dir, { recursive: true })
383
+ return { status: 'ok', message: `created ${dir}` }
384
+ } catch {
385
+ return {
386
+ status: 'error',
387
+ message: `${dir} is missing and could not be created`,
388
+ fix: { description: 'Create memory/topics/ in the agent folder or fix its permissions on the host.' },
389
+ }
254
390
  }
255
391
  }
256
392
  },
@@ -259,8 +395,8 @@ export default definePlugin({
259
395
  description: "today's daily stream file exists",
260
396
  run: async (dctx) => {
261
397
  const today = new Date().toISOString().slice(0, 10)
262
- const rel = `memory/${today}.jsonl`
263
- const abs = join(dctx.agentDir, rel)
398
+ const rel = join('memory', 'streams', `${today}.jsonl`)
399
+ const abs = streamFilePath(dctx.agentDir, today)
264
400
  if (existsSync(abs)) return { status: 'ok', message: `${rel} present` }
265
401
  return {
266
402
  status: 'warning',
@@ -268,7 +404,7 @@ export default definePlugin({
268
404
  fix: {
269
405
  description: `Create empty ${rel} so memory-logger has a target.`,
270
406
  apply: async () => {
271
- await mkdir(dirname(abs), { recursive: true })
407
+ await mkdir(streamsDir(dctx.agentDir), { recursive: true })
272
408
  await writeFile(abs, '', 'utf8')
273
409
  return { summary: `created ${rel}`, changedPaths: [rel] }
274
410
  },
@@ -277,17 +413,85 @@ export default definePlugin({
277
413
  },
278
414
  },
279
415
  'legacy-md-cleanup': {
280
- description: 'Check for legacy .md daily stream files that should have been migrated to .jsonl',
416
+ description: 'Check for legacy .md daily stream files and un-migrated root MEMORY.md',
281
417
  run: async (dctx) => {
282
418
  const memoryDir = join(dctx.agentDir, 'memory')
419
+ // kept: pre-migration agents may still have a root MEMORY.md.
420
+ const rootMemoryPath = join(dctx.agentDir, 'MEMORY.md')
421
+ const hasRootMemory = existsSync(rootMemoryPath)
422
+ const hasTopicsDir = existsSync(topicsDir(dctx.agentDir))
423
+
283
424
  let files: string[]
284
425
  try {
285
426
  files = await readdir(memoryDir)
286
427
  } catch {
287
- return { status: 'ok', message: 'memory/ does not exist yet' }
428
+ if (!hasRootMemory) return { status: 'ok', message: 'memory/ does not exist yet' }
429
+ return {
430
+ status: 'warning',
431
+ message: 'root MEMORY.md present but not sharded',
432
+ fix: {
433
+ description: 'Run sharding migration to convert root MEMORY.md to topic shards',
434
+ apply: async (fixCtx) => {
435
+ const result = await runShardingMigration({ agentDir: fixCtx.agentDir, logger: fixCtx.logger })
436
+ return {
437
+ summary: result.migrated
438
+ ? `sharded ${result.topicCount} topic(s) and ${result.streamCount} stream(s)`
439
+ : `sharding migration did not run${result.error ? `: ${result.error}` : ''}`,
440
+ changedPaths: result.migrated
441
+ ? [
442
+ join('memory', 'topics'),
443
+ join('memory', 'streams'),
444
+ join('memory', 'MEMORY.md.pre-shard.bak'),
445
+ ]
446
+ : [],
447
+ }
448
+ },
449
+ },
450
+ }
288
451
  }
289
452
 
290
453
  const mdFiles = files.filter((f) => /^\d{4}-\d{2}-\d{2}\.md$/.test(f))
454
+
455
+ if (hasRootMemory) {
456
+ if (!hasTopicsDir) {
457
+ const mdMsg = mdFiles.length > 0 ? `; also ${mdFiles.length} legacy .md daily stream(s)` : ''
458
+ return {
459
+ status: 'warning',
460
+ message: `root MEMORY.md present but not sharded${mdMsg}`,
461
+ fix: {
462
+ description: 'Run sharding migration to convert root MEMORY.md to topic shards',
463
+ apply: async (fixCtx) => {
464
+ const result = await runShardingMigration({ agentDir: fixCtx.agentDir, logger: fixCtx.logger })
465
+ return {
466
+ summary: result.migrated
467
+ ? `sharded ${result.topicCount} topic(s) and ${result.streamCount} stream(s)`
468
+ : `sharding migration did not run${result.error ? `: ${result.error}` : ''}`,
469
+ changedPaths: result.migrated
470
+ ? [
471
+ join('memory', 'topics'),
472
+ join('memory', 'streams'),
473
+ join('memory', 'MEMORY.md.pre-shard.bak'),
474
+ ]
475
+ : [],
476
+ }
477
+ },
478
+ },
479
+ }
480
+ }
481
+ const mdMsg = mdFiles.length > 0 ? `; also ${mdFiles.length} legacy .md daily stream(s)` : ''
482
+ return {
483
+ status: 'warning',
484
+ message: `orphaned root MEMORY.md after sharding migration${mdMsg}`,
485
+ fix: {
486
+ description: 'Delete the orphaned root MEMORY.md file',
487
+ apply: async () => {
488
+ await unlink(rootMemoryPath)
489
+ return { summary: 'deleted orphaned root MEMORY.md', changedPaths: ['MEMORY.md'] }
490
+ },
491
+ },
492
+ }
493
+ }
494
+
291
495
  if (mdFiles.length === 0) return { status: 'ok', message: 'no legacy .md daily streams found' }
292
496
 
293
497
  const caseA: string[] = []
@@ -335,6 +539,39 @@ export default definePlugin({
335
539
  return { status: 'ok', message: 'no legacy .md daily streams found' }
336
540
  },
337
541
  },
542
+ 'pre-shard-backup-age': {
543
+ description: 'Warn when pre-shard backup is older than 30 days',
544
+ run: async (dctx) => {
545
+ const backupPath = preShardBackupPath(dctx.agentDir)
546
+ let s
547
+ try {
548
+ s = await stat(backupPath)
549
+ } catch {
550
+ return { status: 'ok', message: 'no pre-shard backup present' }
551
+ }
552
+ const ageDays = (Date.now() - s.mtimeMs) / 86_400_000
553
+ if (ageDays > 30) {
554
+ return {
555
+ status: 'warning',
556
+ message: `pre-shard backup is ${Math.round(ageDays)} days old; safe to delete if migration is verified`,
557
+ fix: {
558
+ description: 'Delete the pre-shard backup file',
559
+ apply: async () => {
560
+ await unlink(backupPath)
561
+ return {
562
+ summary: 'deleted pre-shard backup',
563
+ changedPaths: [join('memory', 'MEMORY.md.pre-shard.bak')],
564
+ }
565
+ },
566
+ },
567
+ }
568
+ }
569
+ return {
570
+ status: 'ok',
571
+ message: `pre-shard backup is ${Math.round(ageDays)} days old (under 30-day threshold)`,
572
+ }
573
+ },
574
+ },
338
575
  },
339
576
  }
340
577
  },
@@ -360,3 +597,7 @@ async function raceSpawn(work: Promise<void>, ms: number): Promise<void> {
360
597
  if (timer !== null) clearTimeout(timer)
361
598
  }
362
599
  }
600
+
601
+ function isEnoent(err: unknown): boolean {
602
+ return typeof err === 'object' && err !== null && 'code' in err && err.code === 'ENOENT'
603
+ }
@@ -0,0 +1,15 @@
1
+ import type { TopicShard } from './load-shards'
2
+
3
+ export const DEFAULT_INJECTION_BUDGET_BYTES = 16 * 1024
4
+ export const MIN_INJECTION_BUDGET_BYTES = 4 * 1024
5
+
6
+ export type InjectionPlan =
7
+ | { mode: 'direct'; shards: TopicShard[] }
8
+ | { mode: 'index'; shards: TopicShard[]; budget: number; totalBytes: number }
9
+
10
+ export function buildInjectionPlan(shards: TopicShard[], options: { budgetBytes?: number } = {}): InjectionPlan {
11
+ const budget = options.budgetBytes ?? DEFAULT_INJECTION_BUDGET_BYTES
12
+ const totalBytes = shards.reduce((sum, shard) => sum + Buffer.byteLength(shard.body, 'utf8'), 0)
13
+ if (totalBytes <= budget) return { mode: 'direct', shards }
14
+ return { mode: 'index', shards, budget, totalBytes }
15
+ }