typeclaw 0.7.0 → 0.9.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.
Files changed (107) hide show
  1. package/README.md +15 -9
  2. package/package.json +5 -3
  3. package/scripts/dump-system-prompt.ts +12 -1
  4. package/scripts/require-parallel.ts +41 -0
  5. package/src/agent/auth.ts +3 -3
  6. package/src/agent/index.ts +116 -14
  7. package/src/agent/live-sessions.ts +34 -0
  8. package/src/agent/multimodal/read-redirect.ts +43 -0
  9. package/src/agent/plugin-tools.ts +97 -13
  10. package/src/agent/session-meta.ts +21 -2
  11. package/src/agent/session-origin.ts +6 -13
  12. package/src/agent/subagent-completion-reminder.ts +89 -0
  13. package/src/agent/subagents.ts +3 -2
  14. package/src/agent/system-prompt.ts +49 -15
  15. package/src/bundled-plugins/explorer/explorer.ts +2 -2
  16. package/src/bundled-plugins/guard/index.ts +14 -1
  17. package/src/bundled-plugins/guard/policies/managed-config.ts +43 -13
  18. package/src/bundled-plugins/guard/policies/memory-retrieval-cache-write.ts +37 -0
  19. package/src/bundled-plugins/guard/policies/memory-topics-delete.ts +67 -0
  20. package/src/bundled-plugins/guard/policies/memory-topics-write.ts +33 -0
  21. package/src/bundled-plugins/guard/policies/non-workspace-write.ts +8 -2
  22. package/src/bundled-plugins/guard/policy.ts +7 -0
  23. package/src/bundled-plugins/memory/README.md +76 -62
  24. package/src/bundled-plugins/memory/append-tool.ts +3 -2
  25. package/src/bundled-plugins/memory/citation-superset.ts +49 -11
  26. package/src/bundled-plugins/memory/citations.ts +19 -8
  27. package/src/bundled-plugins/memory/delete-tool.ts +57 -0
  28. package/src/bundled-plugins/memory/dreaming-state.ts +1 -1
  29. package/src/bundled-plugins/memory/dreaming.ts +364 -146
  30. package/src/bundled-plugins/memory/frontmatter.ts +165 -0
  31. package/src/bundled-plugins/memory/index.ts +236 -16
  32. package/src/bundled-plugins/memory/injection-plan.ts +15 -0
  33. package/src/bundled-plugins/memory/load-memory.ts +102 -103
  34. package/src/bundled-plugins/memory/load-shards.ts +156 -0
  35. package/src/bundled-plugins/memory/memory-logger.ts +16 -15
  36. package/src/bundled-plugins/memory/memory-retrieval.ts +105 -0
  37. package/src/bundled-plugins/memory/migration.ts +282 -1
  38. package/src/bundled-plugins/memory/paths.ts +42 -0
  39. package/src/bundled-plugins/memory/search-tool.ts +232 -0
  40. package/src/bundled-plugins/memory/secret-detector.ts +2 -2
  41. package/src/bundled-plugins/memory/shard-snapshot.ts +51 -0
  42. package/src/bundled-plugins/memory/slug.ts +59 -0
  43. package/src/bundled-plugins/memory/stream-io.ts +110 -1
  44. package/src/bundled-plugins/memory/strength.ts +3 -3
  45. package/src/bundled-plugins/memory/topics.ts +70 -16
  46. package/src/bundled-plugins/security/index.ts +24 -0
  47. package/src/bundled-plugins/security/permissions.ts +4 -0
  48. package/src/bundled-plugins/security/policies/cron-promotion.ts +349 -0
  49. package/src/bundled-plugins/security/policies/git-exfil.ts +2 -0
  50. package/src/bundled-plugins/security/policies/prompt-injection.ts +3 -0
  51. package/src/bundled-plugins/security/policies/role-promotion.ts +419 -0
  52. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +1 -0
  53. package/src/channels/adapters/discord-bot-slash-commands.ts +186 -0
  54. package/src/channels/adapters/discord-bot.ts +163 -1
  55. package/src/channels/adapters/kakaotalk-attachment.ts +7 -17
  56. package/src/channels/adapters/kakaotalk.ts +64 -37
  57. package/src/channels/adapters/slack-bot-classify.ts +2 -27
  58. package/src/channels/adapters/slack-bot-slash-commands.ts +82 -0
  59. package/src/channels/adapters/slack-bot.ts +139 -1
  60. package/src/channels/index.ts +5 -0
  61. package/src/channels/router.ts +328 -18
  62. package/src/channels/subagent-completion-bridge.ts +84 -0
  63. package/src/cli/builtins.ts +1 -0
  64. package/src/cli/index.ts +1 -0
  65. package/src/cli/init.ts +122 -14
  66. package/src/cli/inspect.ts +151 -0
  67. package/src/cli/role.ts +7 -2
  68. package/src/cli/tunnel.ts +13 -1
  69. package/src/cli/ui.ts +25 -1
  70. package/src/config/index.ts +1 -0
  71. package/src/config/models-mutation.ts +10 -2
  72. package/src/cron/consumer.ts +1 -1
  73. package/src/init/dockerfile.ts +353 -2
  74. package/src/init/hatching.ts +5 -6
  75. package/src/init/kakaotalk-auth.ts +6 -47
  76. package/src/init/validate-api-key.ts +121 -0
  77. package/src/inspect/index.ts +213 -0
  78. package/src/inspect/label.ts +50 -0
  79. package/src/inspect/live.ts +221 -0
  80. package/src/inspect/render.ts +163 -0
  81. package/src/inspect/replay.ts +265 -0
  82. package/src/inspect/session-list.ts +160 -0
  83. package/src/inspect/types.ts +110 -0
  84. package/src/plugin/hooks.ts +23 -1
  85. package/src/plugin/index.ts +2 -0
  86. package/src/plugin/manager.ts +1 -1
  87. package/src/plugin/registry.ts +1 -1
  88. package/src/plugin/types.ts +10 -0
  89. package/src/run/channel-session-factory.ts +7 -1
  90. package/src/run/index.ts +87 -21
  91. package/src/secrets/kakao-renewal.ts +3 -47
  92. package/src/server/index.ts +241 -60
  93. package/src/shared/index.ts +4 -1
  94. package/src/shared/local-time.ts +17 -0
  95. package/src/shared/protocol.ts +49 -0
  96. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +9 -9
  97. package/src/skills/typeclaw-claude-code/SKILL.md +83 -40
  98. package/src/skills/typeclaw-claude-code/references/stop-hook.md +2 -0
  99. package/src/skills/typeclaw-claude-code/references/tmux-driving.md +102 -16
  100. package/src/skills/typeclaw-config/SKILL.md +38 -33
  101. package/src/skills/typeclaw-cron/SKILL.md +1 -1
  102. package/src/skills/typeclaw-git/SKILL.md +2 -2
  103. package/src/skills/typeclaw-memory/SKILL.md +16 -163
  104. package/src/skills/typeclaw-permissions/SKILL.md +2 -2
  105. package/src/skills/typeclaw-plugins/SKILL.md +26 -15
  106. package/src/test-helpers/wait-for.ts +7 -1
  107. package/typeclaw.schema.json +7 -0
@@ -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
@@ -75,6 +80,7 @@ const memoryConfigSchema = z
75
80
  message: `memory.bufferBytes must be 0 (disabled) or >= ${MIN_BUFFER_BYTES}`,
76
81
  })
77
82
  .default(DEFAULT_BUFFER_BYTES),
83
+ injectionBudgetBytes: z.number().int().min(MIN_INJECTION_BUDGET_BYTES).default(DEFAULT_INJECTION_BUDGET_BYTES),
78
84
  // Test seam: per-spawn ceiling for memory-logger. Operators have no
79
85
  // reason to tune this; it exists so the wedge-recovery test can fire
80
86
  // the timeout in milliseconds instead of the production 50s. Kept
@@ -82,7 +88,12 @@ const memoryConfigSchema = z
82
88
  spawnTimeoutMs: z.number().int().min(1).default(SPAWN_TIMEOUT_MS),
83
89
  dreaming: dreamingConfigSchema.optional(),
84
90
  })
85
- .default({ idleMs: DEFAULT_IDLE_MS, bufferBytes: DEFAULT_BUFFER_BYTES, spawnTimeoutMs: SPAWN_TIMEOUT_MS })
91
+ .default({
92
+ idleMs: DEFAULT_IDLE_MS,
93
+ bufferBytes: DEFAULT_BUFFER_BYTES,
94
+ injectionBudgetBytes: DEFAULT_INJECTION_BUDGET_BYTES,
95
+ spawnTimeoutMs: SPAWN_TIMEOUT_MS,
96
+ })
86
97
 
87
98
  export default definePlugin({
88
99
  configSchema: memoryConfigSchema,
@@ -100,6 +111,18 @@ export default definePlugin({
100
111
  ctx.logger.info(`[memory] migrated ${migrationResult.migrated.length} daily stream(s) to JSONL`)
101
112
  }
102
113
 
114
+ const shardingResult = await runShardingMigration({
115
+ agentDir: ctx.agentDir,
116
+ logger: ctx.logger,
117
+ })
118
+ if (shardingResult.migrated) {
119
+ ctx.logger.info(
120
+ `[memory] sharded ${shardingResult.topicCount} topics + ${shardingResult.streamCount} streams (pre-shard backup at memory/MEMORY.md.pre-shard.bak)`,
121
+ )
122
+ } else if (shardingResult.error !== undefined) {
123
+ ctx.logger.warn(`[memory] sharding migration aborted: ${shardingResult.error}`)
124
+ }
125
+
103
126
  const idleTimers = new Map<string, ReturnType<typeof setTimeout>>()
104
127
  const lastIdleEvent = new Map<string, { parentTranscriptPath: string | undefined; origin?: SessionOrigin }>()
105
128
  const bytesAtLastRun = new Map<string, number>()
@@ -164,6 +187,30 @@ export default definePlugin({
164
187
  return currentSize - baseline >= bufferBytes
165
188
  }
166
189
 
190
+ const runMemoryRetrieval = async (event: {
191
+ sessionId: string
192
+ agentDir: string
193
+ userPrompt: string
194
+ origin?: SessionOrigin
195
+ }): Promise<void> => {
196
+ const shards = await loadAllShards(event.agentDir)
197
+ const plan = buildInjectionPlan(shards, { budgetBytes: ctx.config.injectionBudgetBytes })
198
+ if (plan.mode === 'direct') return
199
+
200
+ const cacheFilePath = join(event.agentDir, 'memory', '.retrieval-cache', `${event.sessionId}.md`)
201
+ const payload: MemoryRetrievalPayload = {
202
+ parentSessionId: event.sessionId,
203
+ agentDir: event.agentDir,
204
+ recentPrompt: event.userPrompt,
205
+ cacheFilePath,
206
+ ...(event.origin !== undefined ? { origin: event.origin } : {}),
207
+ }
208
+ await ctx.spawnSubagent('memory-retrieval', payload, {
209
+ parentSessionId: event.sessionId,
210
+ ...(event.origin !== undefined ? { spawnedByOrigin: event.origin } : {}),
211
+ })
212
+ }
213
+
167
214
  // Subagents are constructed at boot here (rather than imported as constants)
168
215
  // so their lifecycle logs route through the plugin logger and pick up the
169
216
  // `[plugin:memory]` prefix. Without this, they would write directly to
@@ -177,8 +224,12 @@ export default definePlugin({
177
224
  return {
178
225
  subagents: {
179
226
  'memory-logger': createMemoryLoggerSubagent({ logger: subagentLogger }),
227
+ 'memory-retrieval': createMemoryRetrievalSubagent({ logger: subagentLogger }),
180
228
  dreaming: createDreamingSubagent({ logger: subagentLogger }),
181
229
  },
230
+ tools: {
231
+ memory_search: memorySearchTool,
232
+ },
182
233
  cronJobs: {
183
234
  dreaming: {
184
235
  schedule: dreamingSchedule,
@@ -193,7 +244,7 @@ export default definePlugin({
193
244
  // directly, appended LAST in the system prompt). It does not run from a
194
245
  // plugin hook because positioning matters for cache-prefix stability:
195
246
  // the daily-stream file grows after every channel turn (memory-logger
196
- // appends a fragment + watermark) and MEMORY.md changes on every dream.
247
+ // appends a fragment + watermark) and memory/topics/ changes on every dream.
197
248
  // A volatile region in the middle of the system prompt invalidates the
198
249
  // entire cacheable suffix below it on every session resurrection
199
250
  // (channel sessions evicted by idle GC, container restarts). Pinning
@@ -230,27 +281,91 @@ export default definePlugin({
230
281
  await fireMemoryLogger(sessionId, 'buffer-trip')
231
282
  }
232
283
  },
284
+ // memory-retrieval used to run from `session.prompt`, which fires
285
+ // during system-prompt assembly (createResourceLoader) and carries
286
+ // the ASSEMBLING SYSTEM PROMPT as `event.prompt` — not the user's
287
+ // message. The plugin was feeding that string into the subagent as
288
+ // `recentPrompt`, so the LLM keyword-mined TypeClaw's framing prose
289
+ // (`TypeClaw`, `subagent`, `AGENTS.md`, `systemPromptLeak`, etc.)
290
+ // and burned 15+ memory_search calls per session on terms the user
291
+ // never said. `session.turn.start` is the correct trigger: it fires
292
+ // before each `session.prompt(text)` call with the actual text the
293
+ // session is about to receive.
294
+ //
295
+ // The hook body is fully detached. `runSessionTurnStart` has no
296
+ // per-handler timeout (unlike session.prompt/idle/end), and the
297
+ // caller awaits it before `session.prompt(text)` runs — so an
298
+ // inline `await loadAllShards(...)` would gate every channel turn
299
+ // on N shard reads. Detaching mirrors PR #337's "fire-and-forget
300
+ // the slow work" pattern, pushed one level earlier to cover both
301
+ // the shard read AND the LLM spawn.
302
+ //
303
+ // Per-turn instead of per-session-creation means N spawns over the
304
+ // session's lifetime, but the subagent's own `inFlightKey` on
305
+ // `parentSessionId` (memory-retrieval.ts) coalesces overlapping
306
+ // fires: if turn N's retrieval is still running when turn N+1 fires,
307
+ // the second spawn is dropped with a warning, and the cache from
308
+ // turn N is still consumed by turn N+1 via the documented
309
+ // lag-by-one-prompt contract (load-memory.ts:appendRetrievalCache).
310
+ //
311
+ // Known limitation: a detached spawn can theoretically settle after
312
+ // `session.end` has unlinked the cache file, leaving a single ~5 KB
313
+ // file in memory/.retrieval-cache/ that never gets read. The next
314
+ // session.end that matches this sessionId would clean it up, but
315
+ // sessionIds are UUIDv7 so reuse is effectively never. Fix would
316
+ // serialize session.end behind the in-flight spawn, which
317
+ // re-introduces the cold-start blocking shape PR #337 fixed. The
318
+ // leaked file is bounded (one per disconnected session) and lives
319
+ // in a dir already marked transient.
320
+ //
321
+ // ctx.spawnSubagent IS reject-able in production: it wraps
322
+ // dispatchSpawnSubagent (src/run/index.ts) which calls invokeSubagent
323
+ // directly with no try/catch. SubagentConsumer's catch only protects
324
+ // stream-initiated spawns (target.kind === 'new-session'), not the
325
+ // direct ctx.spawnSubagent path the hooks use. Same for
326
+ // loadAllShards' fs errors. The .catch() on the void-discarded
327
+ // promise below is load-bearing — without it, every shard-read or
328
+ // handler failure (LLM provider error, payload validation throw)
329
+ // would surface as an unhandled rejection because nothing awaits
330
+ // the promise.
331
+ 'session.turn.start': (event) => {
332
+ if (event.origin?.kind === 'subagent') return
333
+ void runMemoryRetrieval(event).catch((err) => {
334
+ ctx.logger.error(`memory-retrieval spawn failed: ${err instanceof Error ? err.message : String(err)}`)
335
+ })
336
+ },
233
337
  'session.end': async (event) => {
234
338
  if (event.origin?.kind === 'subagent') return
235
339
  cancelTimer(event.sessionId)
236
340
  await fireMemoryLogger(event.sessionId, 'session-end')
341
+ const cacheFilePath = join(ctx.agentDir, 'memory', '.retrieval-cache', `${event.sessionId}.md`)
342
+ try {
343
+ await unlink(cacheFilePath)
344
+ } catch (err) {
345
+ if (!isEnoent(err)) ctx.logger.warn(`[memory] failed to clean retrieval cache: ${err}`)
346
+ }
237
347
  lastIdleEvent.delete(event.sessionId)
238
348
  bytesAtLastRun.delete(event.sessionId)
239
349
  },
240
350
  },
241
351
  doctorChecks: {
242
352
  'dir-writable': {
243
- description: 'memory/ exists and is writable',
353
+ description: 'memory/topics/ exists and is writable',
244
354
  run: async (dctx) => {
245
- const dir = join(dctx.agentDir, 'memory')
355
+ const dir = topicsDir(dctx.agentDir)
246
356
  try {
247
357
  await access(dir, fsConstants.W_OK)
248
358
  return { status: 'ok', message: `${dir} writable` }
249
359
  } 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.' },
360
+ try {
361
+ await mkdir(dir, { recursive: true })
362
+ return { status: 'ok', message: `created ${dir}` }
363
+ } catch {
364
+ return {
365
+ status: 'error',
366
+ message: `${dir} is missing and could not be created`,
367
+ fix: { description: 'Create memory/topics/ in the agent folder or fix its permissions on the host.' },
368
+ }
254
369
  }
255
370
  }
256
371
  },
@@ -259,8 +374,8 @@ export default definePlugin({
259
374
  description: "today's daily stream file exists",
260
375
  run: async (dctx) => {
261
376
  const today = new Date().toISOString().slice(0, 10)
262
- const rel = `memory/${today}.jsonl`
263
- const abs = join(dctx.agentDir, rel)
377
+ const rel = join('memory', 'streams', `${today}.jsonl`)
378
+ const abs = streamFilePath(dctx.agentDir, today)
264
379
  if (existsSync(abs)) return { status: 'ok', message: `${rel} present` }
265
380
  return {
266
381
  status: 'warning',
@@ -268,7 +383,7 @@ export default definePlugin({
268
383
  fix: {
269
384
  description: `Create empty ${rel} so memory-logger has a target.`,
270
385
  apply: async () => {
271
- await mkdir(dirname(abs), { recursive: true })
386
+ await mkdir(streamsDir(dctx.agentDir), { recursive: true })
272
387
  await writeFile(abs, '', 'utf8')
273
388
  return { summary: `created ${rel}`, changedPaths: [rel] }
274
389
  },
@@ -277,17 +392,85 @@ export default definePlugin({
277
392
  },
278
393
  },
279
394
  'legacy-md-cleanup': {
280
- description: 'Check for legacy .md daily stream files that should have been migrated to .jsonl',
395
+ description: 'Check for legacy .md daily stream files and un-migrated root MEMORY.md',
281
396
  run: async (dctx) => {
282
397
  const memoryDir = join(dctx.agentDir, 'memory')
398
+ // kept: pre-migration agents may still have a root MEMORY.md.
399
+ const rootMemoryPath = join(dctx.agentDir, 'MEMORY.md')
400
+ const hasRootMemory = existsSync(rootMemoryPath)
401
+ const hasTopicsDir = existsSync(topicsDir(dctx.agentDir))
402
+
283
403
  let files: string[]
284
404
  try {
285
405
  files = await readdir(memoryDir)
286
406
  } catch {
287
- return { status: 'ok', message: 'memory/ does not exist yet' }
407
+ if (!hasRootMemory) return { status: 'ok', message: 'memory/ does not exist yet' }
408
+ return {
409
+ status: 'warning',
410
+ message: 'root MEMORY.md present but not sharded',
411
+ fix: {
412
+ description: 'Run sharding migration to convert root MEMORY.md to topic shards',
413
+ apply: async (fixCtx) => {
414
+ const result = await runShardingMigration({ agentDir: fixCtx.agentDir, logger: fixCtx.logger })
415
+ return {
416
+ summary: result.migrated
417
+ ? `sharded ${result.topicCount} topic(s) and ${result.streamCount} stream(s)`
418
+ : `sharding migration did not run${result.error ? `: ${result.error}` : ''}`,
419
+ changedPaths: result.migrated
420
+ ? [
421
+ join('memory', 'topics'),
422
+ join('memory', 'streams'),
423
+ join('memory', 'MEMORY.md.pre-shard.bak'),
424
+ ]
425
+ : [],
426
+ }
427
+ },
428
+ },
429
+ }
288
430
  }
289
431
 
290
432
  const mdFiles = files.filter((f) => /^\d{4}-\d{2}-\d{2}\.md$/.test(f))
433
+
434
+ if (hasRootMemory) {
435
+ if (!hasTopicsDir) {
436
+ const mdMsg = mdFiles.length > 0 ? `; also ${mdFiles.length} legacy .md daily stream(s)` : ''
437
+ return {
438
+ status: 'warning',
439
+ message: `root MEMORY.md present but not sharded${mdMsg}`,
440
+ fix: {
441
+ description: 'Run sharding migration to convert root MEMORY.md to topic shards',
442
+ apply: async (fixCtx) => {
443
+ const result = await runShardingMigration({ agentDir: fixCtx.agentDir, logger: fixCtx.logger })
444
+ return {
445
+ summary: result.migrated
446
+ ? `sharded ${result.topicCount} topic(s) and ${result.streamCount} stream(s)`
447
+ : `sharding migration did not run${result.error ? `: ${result.error}` : ''}`,
448
+ changedPaths: result.migrated
449
+ ? [
450
+ join('memory', 'topics'),
451
+ join('memory', 'streams'),
452
+ join('memory', 'MEMORY.md.pre-shard.bak'),
453
+ ]
454
+ : [],
455
+ }
456
+ },
457
+ },
458
+ }
459
+ }
460
+ const mdMsg = mdFiles.length > 0 ? `; also ${mdFiles.length} legacy .md daily stream(s)` : ''
461
+ return {
462
+ status: 'warning',
463
+ message: `orphaned root MEMORY.md after sharding migration${mdMsg}`,
464
+ fix: {
465
+ description: 'Delete the orphaned root MEMORY.md file',
466
+ apply: async () => {
467
+ await unlink(rootMemoryPath)
468
+ return { summary: 'deleted orphaned root MEMORY.md', changedPaths: ['MEMORY.md'] }
469
+ },
470
+ },
471
+ }
472
+ }
473
+
291
474
  if (mdFiles.length === 0) return { status: 'ok', message: 'no legacy .md daily streams found' }
292
475
 
293
476
  const caseA: string[] = []
@@ -335,6 +518,39 @@ export default definePlugin({
335
518
  return { status: 'ok', message: 'no legacy .md daily streams found' }
336
519
  },
337
520
  },
521
+ 'pre-shard-backup-age': {
522
+ description: 'Warn when pre-shard backup is older than 30 days',
523
+ run: async (dctx) => {
524
+ const backupPath = preShardBackupPath(dctx.agentDir)
525
+ let s
526
+ try {
527
+ s = await stat(backupPath)
528
+ } catch {
529
+ return { status: 'ok', message: 'no pre-shard backup present' }
530
+ }
531
+ const ageDays = (Date.now() - s.mtimeMs) / 86_400_000
532
+ if (ageDays > 30) {
533
+ return {
534
+ status: 'warning',
535
+ message: `pre-shard backup is ${Math.round(ageDays)} days old; safe to delete if migration is verified`,
536
+ fix: {
537
+ description: 'Delete the pre-shard backup file',
538
+ apply: async () => {
539
+ await unlink(backupPath)
540
+ return {
541
+ summary: 'deleted pre-shard backup',
542
+ changedPaths: [join('memory', 'MEMORY.md.pre-shard.bak')],
543
+ }
544
+ },
545
+ },
546
+ }
547
+ }
548
+ return {
549
+ status: 'ok',
550
+ message: `pre-shard backup is ${Math.round(ageDays)} days old (under 30-day threshold)`,
551
+ }
552
+ },
553
+ },
338
554
  },
339
555
  }
340
556
  },
@@ -360,3 +576,7 @@ async function raceSpawn(work: Promise<void>, ms: number): Promise<void> {
360
576
  if (timer !== null) clearTimeout(timer)
361
577
  }
362
578
  }
579
+
580
+ function isEnoent(err: unknown): boolean {
581
+ return typeof err === 'object' && err !== null && 'code' in err && err.code === 'ENOENT'
582
+ }
@@ -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
+ }