typeclaw 0.9.0 → 0.9.2

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 (45) hide show
  1. package/package.json +1 -1
  2. package/scripts/require-parallel.ts +41 -15
  3. package/src/agent/live-subagents.ts +0 -1
  4. package/src/agent/session-origin.ts +10 -0
  5. package/src/agent/subagent-completion-reminder.ts +4 -1
  6. package/src/agent/subagents.ts +72 -13
  7. package/src/agent/system-prompt.ts +5 -5
  8. package/src/agent/tools/channel-reply.ts +47 -7
  9. package/src/agent/tools/channel-send.ts +43 -11
  10. package/src/agent/tools/restart.ts +13 -2
  11. package/src/agent/tools/runtime-notice.ts +41 -0
  12. package/src/agent/tools/spawn-subagent.ts +0 -1
  13. package/src/agent/tools/subagent-output.ts +3 -51
  14. package/src/bundled-plugins/memory/README.md +11 -11
  15. package/src/bundled-plugins/memory/dreaming-state.ts +51 -2
  16. package/src/bundled-plugins/memory/index.ts +77 -26
  17. package/src/bundled-plugins/memory/memory-retrieval.ts +7 -1
  18. package/src/bundled-plugins/memory/migration.ts +91 -16
  19. package/src/bundled-plugins/memory/stream-io.ts +71 -1
  20. package/src/channels/adapters/kakaotalk-classify.ts +4 -1
  21. package/src/channels/adapters/kakaotalk.ts +1 -1
  22. package/src/channels/manager.ts +7 -0
  23. package/src/channels/router.ts +260 -15
  24. package/src/channels/schema.ts +1 -1
  25. package/src/cli/compose.ts +23 -2
  26. package/src/cli/logs.ts +17 -2
  27. package/src/compose/logs.ts +8 -4
  28. package/src/config/config.ts +8 -0
  29. package/src/container/index.ts +1 -1
  30. package/src/container/logs.ts +38 -11
  31. package/src/init/dockerfile.ts +147 -4
  32. package/src/inspect/live.ts +32 -1
  33. package/src/inspect/render.ts +32 -0
  34. package/src/inspect/replay.ts +44 -0
  35. package/src/inspect/types.ts +26 -0
  36. package/src/run/index.ts +28 -11
  37. package/src/server/index.ts +59 -19
  38. package/src/shared/protocol.ts +30 -0
  39. package/src/skills/typeclaw-codex-cli/SKILL.md +324 -0
  40. package/src/skills/typeclaw-codex-cli/references/auth-flow.md +131 -0
  41. package/src/skills/typeclaw-codex-cli/references/stop-hook.md +92 -0
  42. package/src/skills/typeclaw-codex-cli/references/tmux-driving.md +239 -0
  43. package/src/skills/typeclaw-config/SKILL.md +32 -31
  44. package/src/test-helpers/wait-for.ts +15 -7
  45. package/typeclaw.schema.json +24 -11
@@ -28,17 +28,17 @@ All fields are **restart-required** — the plugin reads them once at boot.
28
28
 
29
29
  ## What it contributes
30
30
 
31
- | Kind | Name | Notes |
32
- | -------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
33
- | Subagent | `memory-logger` | Reads a parent transcript past a watermark and appends fragments to `memory/streams/<today>.jsonl`. Coalesced per `agentDir`. |
34
- | Subagent | `dreaming` | Reads shards under `memory/topics/` plus undreamed daily-stream events and rebalances the topic shards. Coalesced per `agentDir`. Citation-superset invariant enforced on every run. |
35
- | Subagent | `memory-retrieval` | On `session.turn.start` when injection plan is `index` mode, reads the user's actual prompt for this turn + shard listing, writes a focused summary to `memory/.retrieval-cache/<sessionId>.md`. Coalesced per `parentSessionId`. |
36
- | Tool | `memory_search` | Main-agent tool. Substring/regex search across BOTH topic shards (slugs, frontmatter, bodies) and undreamed daily-stream events (fragment topic/body, legacy prose). Results are discriminated by `source: "topic" \| "stream"`; topics come first, then streams newest-first. |
37
- | Tool | `delete_topic_shard` | Subagent-only (dreaming). Deletes a topic shard at `memory/topics/<slug>.md`. Path-guarded. |
38
- | Cron | `__plugin_memory_dreaming` | `kind: 'prompt'`, `subagent: 'dreaming'`, scheduled per `memory.dreaming.schedule`. |
39
- | Hook | `session.idle` | Per-session debouncer with size-based ceiling. Spawns `memory-logger` on idle or buffer-trip. |
40
- | Hook | `session.end` | Spawns `memory-logger` immediately; also unlinks the retrieval-cache file for this session. |
41
- | Hook | `session.turn.start` | When `buildInjectionPlan` returns `mode: 'index'` and origin is not a subagent, spawns `memory-retrieval` (detached) with the turn's `userPrompt` so the cache reflects the user's current question, not the assembling system prompt. Fire-and-forget; failures route through the plugin logger. |
31
+ | Kind | Name | Notes |
32
+ | -------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
33
+ | Subagent | `memory-logger` | Reads a parent transcript past a watermark and appends fragments to `memory/streams/<today>.jsonl`. Coalesced per `agentDir`. |
34
+ | Subagent | `dreaming` | Reads shards under `memory/topics/` plus undreamed daily-stream events and rebalances the topic shards. Coalesced per `agentDir`. Citation-superset invariant enforced on every run. |
35
+ | Subagent | `memory-retrieval` | On `session.turn.start` when injection plan is `index` mode, reads the user's actual prompt for this turn + shard listing, writes a focused summary to `memory/.retrieval-cache/<sessionId>.md`. Coalesced per `parentSessionId`. Declares `profile: 'fast'` (retrieval is "≤3 keyword searches + 1 write", no reasoning required) and `timeoutMs: 30_000` so a wedged provider call releases the coalescing key instead of poisoning the cache for every subsequent turn. |
36
+ | Tool | `memory_search` | Main-agent tool. Substring/regex search across BOTH topic shards (slugs, frontmatter, bodies) and undreamed daily-stream events (fragment topic/body, legacy prose). Results are discriminated by `source: "topic" \| "stream"`; topics come first, then streams newest-first. |
37
+ | Tool | `delete_topic_shard` | Subagent-only (dreaming). Deletes a topic shard at `memory/topics/<slug>.md`. Path-guarded. |
38
+ | Cron | `__plugin_memory_dreaming` | `kind: 'prompt'`, `subagent: 'dreaming'`, scheduled per `memory.dreaming.schedule`. |
39
+ | Hook | `session.idle` | Per-session debouncer with size-based ceiling. Spawns `memory-logger` on idle or buffer-trip. |
40
+ | Hook | `session.end` | Spawns `memory-logger` immediately; also unlinks the retrieval-cache file for this session. |
41
+ | Hook | `session.turn.start` | When `buildInjectionPlan` returns `mode: 'index'` and origin is not a subagent, spawns `memory-retrieval` (detached) with the turn's `userPrompt` so the cache reflects the user's current question, not the assembling system prompt. Fire-and-forget; failures route through the plugin logger. |
42
42
 
43
43
  ## Memory injection (two-tier, topic shards only)
44
44
 
@@ -1,11 +1,29 @@
1
1
  import { existsSync } from 'node:fs'
2
- import { mkdir, readFile, writeFile } from 'node:fs/promises'
2
+ import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
3
3
  import { dirname, join } from 'node:path'
4
4
 
5
5
  export const DREAMING_STATE_FILE = 'memory/.dreaming-state.json'
6
6
 
7
7
  const VERSION = 2
8
8
 
9
+ // Stat-keyed cache for `.dreaming-state.json`. The file is read once at
10
+ // the start of every dreaming run AND once per `readAllStreamDays` call
11
+ // (which fires inside every `memory_search` invocation). For a retrieval
12
+ // subagent that issues 3 parallel searches, this cache turns 3 reads +
13
+ // 3 JSON.parses into 3 stats + 1 parse — small per-call savings, but the
14
+ // file is tiny so the win is mostly avoiding GC pressure on busy
15
+ // channel sessions. Invalidation key matches the stream-file cache
16
+ // (`load-shards.ts` and `stream-io.ts` use the same `(mtimeMs, ctimeMs,
17
+ // size)` shape); `saveDreamingState` uses `writeFile` which bumps both
18
+ // mtime and ctime.
19
+ type DreamingStateCacheEntry = {
20
+ mtimeMs: number
21
+ ctimeMs: number
22
+ size: number
23
+ state: DreamingState
24
+ }
25
+ const dreamingStateCache = new Map<string, DreamingStateCacheEntry>()
26
+
9
27
  // Per-day "dreamed" set: the set of stream-event ids dreaming has already
10
28
  // reasoned over for a given day. Anything in this set is either cited from
11
29
  // memory/topics/ (must survive compaction) or was consciously discarded by a
@@ -32,8 +50,35 @@ export function emptyState(): DreamingState {
32
50
 
33
51
  export async function loadDreamingState(agentDir: string): Promise<DreamingState> {
34
52
  const path = join(agentDir, DREAMING_STATE_FILE)
35
- if (!existsSync(path)) return emptyState()
53
+ if (!existsSync(path)) {
54
+ dreamingStateCache.delete(path)
55
+ return emptyState()
56
+ }
36
57
 
58
+ let fileStat: { mtimeMs: number; ctimeMs: number; size: number }
59
+ try {
60
+ const s = await stat(path)
61
+ fileStat = { mtimeMs: s.mtimeMs, ctimeMs: s.ctimeMs, size: s.size }
62
+ } catch {
63
+ return emptyState()
64
+ }
65
+
66
+ const cached = dreamingStateCache.get(path)
67
+ if (
68
+ cached !== undefined &&
69
+ cached.mtimeMs === fileStat.mtimeMs &&
70
+ cached.ctimeMs === fileStat.ctimeMs &&
71
+ cached.size === fileStat.size
72
+ ) {
73
+ return cached.state
74
+ }
75
+
76
+ const state = await loadDreamingStateFromDisk(path)
77
+ dreamingStateCache.set(path, { ...fileStat, state })
78
+ return state
79
+ }
80
+
81
+ async function loadDreamingStateFromDisk(path: string): Promise<DreamingState> {
37
82
  let raw: string
38
83
  try {
39
84
  raw = await readFile(path, 'utf8')
@@ -58,6 +103,10 @@ export async function saveDreamingState(agentDir: string, state: DreamingState):
58
103
  await writeFile(path, `${JSON.stringify(state, null, 2)}\n`, 'utf8')
59
104
  }
60
105
 
106
+ export function __resetDreamingStateCacheForTests(): void {
107
+ dreamingStateCache.clear()
108
+ }
109
+
61
110
  export function getDreamedIds(state: DreamingState, date: string): ReadonlySet<string> {
62
111
  const ids = state.dreamedThrough[date]?.dreamedIds
63
112
  return ids === undefined ? EMPTY_SET : new Set(ids)
@@ -27,6 +27,17 @@ const MIN_BUFFER_BYTES = 10_000
27
27
  // sporadic agents entirely. Operators can override via `memory.dreaming.schedule`.
28
28
  const DEFAULT_DREAMING_SCHEDULE = '*/30 * * * *'
29
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
+
30
41
  // Hard ceiling on a single memory-logger spawn. The chain serializes spawns
31
42
  // per agent, so a non-settling spawn would otherwise wedge every subsequent
32
43
  // fire — including the session.end hook path that gates cron consumer's
@@ -86,6 +97,11 @@ const memoryConfigSchema = z
86
97
  // the timeout in milliseconds instead of the production 50s. Kept
87
98
  // undocumented for users.
88
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),
89
105
  dreaming: dreamingConfigSchema.optional(),
90
106
  })
91
107
  .default({
@@ -93,6 +109,7 @@ const memoryConfigSchema = z
93
109
  bufferBytes: DEFAULT_BUFFER_BYTES,
94
110
  injectionBudgetBytes: DEFAULT_INJECTION_BUDGET_BYTES,
95
111
  spawnTimeoutMs: SPAWN_TIMEOUT_MS,
112
+ retrievalSpawnTimeoutMs: RETRIEVAL_SPAWN_TIMEOUT_MS,
96
113
  })
97
114
 
98
115
  export default definePlugin({
@@ -101,6 +118,7 @@ export default definePlugin({
101
118
  const idleMs = ctx.config.idleMs
102
119
  const bufferBytes = ctx.config.bufferBytes
103
120
  const spawnTimeoutMs = ctx.config.spawnTimeoutMs
121
+ const retrievalSpawnTimeoutMs = ctx.config.retrievalSpawnTimeoutMs
104
122
  const dreamingSchedule = ctx.config.dreaming?.schedule ?? DEFAULT_DREAMING_SCHEDULE
105
123
 
106
124
  const migrationResult = await runMigration({
@@ -127,39 +145,46 @@ export default definePlugin({
127
145
  const lastIdleEvent = new Map<string, { parentTranscriptPath: string | undefined; origin?: SessionOrigin }>()
128
146
  const bytesAtLastRun = new Map<string, number>()
129
147
 
130
- // memory-logger is now coalesced per agentDir (not per parentSessionId) so that
148
+ // memory-logger is coalesced per agentDir (not per parentSessionId) so that
131
149
  // two concurrent channel sessions for the same agent never write to the same
132
150
  // daily stream file at the same time. The subagent consumer would silently drop
133
151
  // a colliding fire, so we serialize spawn calls *here* (chaining each onto the
134
152
  // previous one's settlement) instead of letting the consumer choose between
135
153
  // dropping or queueing. The chain holds at most one in-flight promise plus one
136
- // queued; older queued fires for the same session are superseded by newer ones
137
- // through the lastIdleEvent map (each fire reads the latest snapshot).
154
+ // queued.
155
+ //
156
+ // The `lastIdleEvent` lookup happens SYNCHRONOUSLY at call time and the
157
+ // snapshot is captured in `payload` before any await. This is load-bearing
158
+ // for `session.end`'s fire-and-forget path (see hook below): the hook
159
+ // synchronously cleans up `lastIdleEvent.delete(sessionId)` immediately
160
+ // after calling fireMemoryLogger, so if the snapshot were read lazily
161
+ // inside the chained `.then`, it would race with cleanup and the spawn
162
+ // would silently no-op. Capturing the payload up front decouples the
163
+ // session-end snapshot from the cleanup that follows.
138
164
  let spawnChain: Promise<void> = Promise.resolve()
139
165
 
140
166
  const fireMemoryLogger = (sessionId: string, reason: 'idle' | 'buffer-trip' | 'session-end'): Promise<void> => {
167
+ const last = lastIdleEvent.get(sessionId)
168
+ if (!last || last.parentTranscriptPath === undefined) return Promise.resolve()
169
+ const parentTranscriptPath = last.parentTranscriptPath
170
+ const payload: MemoryLoggerPayload = {
171
+ parentSessionId: sessionId,
172
+ parentTranscriptPath,
173
+ agentDir: ctx.agentDir,
174
+ ...(last.origin !== undefined ? { origin: last.origin } : {}),
175
+ }
176
+ const spawnOptions = {
177
+ parentSessionId: sessionId,
178
+ ...(last.origin !== undefined ? { spawnedByOrigin: last.origin } : {}),
179
+ }
141
180
  const next = spawnChain
142
181
  .catch(() => undefined)
143
182
  .then(async () => {
144
- const last = lastIdleEvent.get(sessionId)
145
- if (!last || last.parentTranscriptPath === undefined) return
146
- const payload: MemoryLoggerPayload = {
147
- parentSessionId: sessionId,
148
- parentTranscriptPath: last.parentTranscriptPath,
149
- agentDir: ctx.agentDir,
150
- ...(last.origin !== undefined ? { origin: last.origin } : {}),
151
- }
152
- const currentSize = await readSize(last.parentTranscriptPath)
183
+ const currentSize = await readSize(parentTranscriptPath)
153
184
  bytesAtLastRun.set(sessionId, currentSize)
154
185
  ctx.logger.info(`memory-logger spawn ${sessionId} reason=${reason} transcript_bytes=${currentSize}`)
155
186
  try {
156
- await raceSpawn(
157
- ctx.spawnSubagent('memory-logger', payload, {
158
- parentSessionId: sessionId,
159
- ...(last.origin !== undefined ? { spawnedByOrigin: last.origin } : {}),
160
- }),
161
- spawnTimeoutMs,
162
- )
187
+ await raceSpawn(ctx.spawnSubagent('memory-logger', payload, spawnOptions), spawnTimeoutMs)
163
188
  } catch (err) {
164
189
  ctx.logger.error(`memory-logger spawn failed: ${err instanceof Error ? err.message : String(err)}`)
165
190
  }
@@ -224,7 +249,10 @@ export default definePlugin({
224
249
  return {
225
250
  subagents: {
226
251
  'memory-logger': createMemoryLoggerSubagent({ logger: subagentLogger }),
227
- 'memory-retrieval': createMemoryRetrievalSubagent({ logger: subagentLogger }),
252
+ 'memory-retrieval': createMemoryRetrievalSubagent({
253
+ logger: subagentLogger,
254
+ timeoutMs: retrievalSpawnTimeoutMs,
255
+ }),
228
256
  dreaming: createDreamingSubagent({ logger: subagentLogger }),
229
257
  },
230
258
  tools: {
@@ -334,16 +362,39 @@ export default definePlugin({
334
362
  ctx.logger.error(`memory-retrieval spawn failed: ${err instanceof Error ? err.message : String(err)}`)
335
363
  })
336
364
  },
337
- 'session.end': async (event) => {
365
+ // The memory-logger spawn is intentionally detached (`void`) instead
366
+ // of awaited. The channel router calls `tearDownLive` synchronously
367
+ // inside `ensureLive`'s stale-rollover path (router.ts:718), and
368
+ // `tearDownLive` awaits `fireSessionEnd` which awaits this hook. An
369
+ // awaited memory-logger spawn here would block new-session creation
370
+ // for the full subagent runtime — observed as 22+ seconds of channel
371
+ // silence on a 22 KB transcript before the new session even starts
372
+ // its cold-start chain.
373
+ //
374
+ // Safety: `fireMemoryLogger` captures the payload synchronously from
375
+ // `lastIdleEvent` (see comment above), so the `delete` calls below
376
+ // cannot race with the chained spawn. `spawnChain` still serializes
377
+ // memory-logger fires per agentDir — the detached promise is queued
378
+ // onto the chain before this hook returns, so a subsequent fire from
379
+ // the new session (idle, buffer-trip, or session-end) waits for the
380
+ // session-end spawn to settle before running.
381
+ //
382
+ // The only durability tradeoff: if the agent process dies between
383
+ // this hook returning and `spawnChain` settling, the session-end
384
+ // memory-logger fire is lost (its transcript fragments don't make
385
+ // it into today's daily stream). This is already true for the idle
386
+ // and buffer-trip paths, which are timer-driven and fire-and-forget
387
+ // by design. Session JSONLs are force-committed elsewhere, so no
388
+ // user-visible transcript is lost — only the LLM-distilled stream
389
+ // fragments for the final batch.
390
+ 'session.end': (event) => {
338
391
  if (event.origin?.kind === 'subagent') return
339
392
  cancelTimer(event.sessionId)
340
- await fireMemoryLogger(event.sessionId, 'session-end')
393
+ void fireMemoryLogger(event.sessionId, 'session-end')
341
394
  const cacheFilePath = join(ctx.agentDir, 'memory', '.retrieval-cache', `${event.sessionId}.md`)
342
- try {
343
- await unlink(cacheFilePath)
344
- } catch (err) {
395
+ unlink(cacheFilePath).catch((err) => {
345
396
  if (!isEnoent(err)) ctx.logger.warn(`[memory] failed to clean retrieval cache: ${err}`)
346
- }
397
+ })
347
398
  lastIdleEvent.delete(event.sessionId)
348
399
  bytesAtLastRun.delete(event.sessionId)
349
400
  },
@@ -26,11 +26,12 @@ export type MemoryRetrievalLogger = {
26
26
 
27
27
  export type CreateMemoryRetrievalSubagentOptions = {
28
28
  logger?: MemoryRetrievalLogger
29
+ timeoutMs?: number
29
30
  }
30
31
 
31
32
  export const MEMORY_RETRIEVAL_SYSTEM_PROMPT = `You are the memory-retrieval subagent. Read the user's most recent prompt and decide what's relevant from BOTH topic shards in \`memory/topics/\` (consolidated long-term memory) AND undreamed daily-stream events under \`memory/streams/\` (recent fragments not yet folded into shards). Use \`memory_search\` to query both surfaces; use \`read\`/\`ls\` to pull full shard bodies when needed. Synthesize a focused ≤8 KB summary of the relevant memory. Save by \`write\`ing it to the exact path provided in your payload as \`cacheFilePath\`. Be ruthlessly concise. Do NOT write anywhere else. Do NOT delete files.
32
33
 
33
- Search discipline: make AT MOST 3 \`memory_search\` calls before writing the cache. Pick queries that match the user's literal phrasing — not framing vocabulary, not metadata (session ids, dates), not words from your own system prompt. If 3 well-chosen searches turn up nothing relevant, write the empty-context note and stop.`
34
+ Search discipline: issue ALL your \`memory_search\` queries in a SINGLE response as parallel tool calls (up to 3 at once), then wait for every result before deciding what to do next. Different angles in parallel, NEVER one search per turn — sequential searches waste a full LLM round-trip per query (~3s each) on file I/O that takes milliseconds. Pick queries that match the user's literal phrasing — not framing vocabulary, not metadata (session ids, dates), not words from your own system prompt. If the parallel batch turns up nothing relevant, write the empty-context note and stop.`
34
35
 
35
36
  export function memoryRetrievalExhaustedMessage(used: number, max: number): string {
36
37
  const usedKb = Math.round(used / 1024)
@@ -56,10 +57,15 @@ export function createMemoryRetrievalSubagent(
56
57
  const logger = options.logger ?? consoleLogger
57
58
  return {
58
59
  systemPrompt: MEMORY_RETRIEVAL_SYSTEM_PROMPT,
60
+ // Retrieval is "4 keyword searches + 1 write" — no reasoning required.
61
+ // `fast` falls back to `default` (with a one-time warning) when the
62
+ // operator hasn't configured it, so this is safe by construction.
63
+ profile: 'fast',
59
64
  tools: [readTool, writeTool, lsTool],
60
65
  customTools: [memorySearchTool],
61
66
  payloadSchema: memoryRetrievalPayloadSchema,
62
67
  inFlightKey: (payload) => payload.parentSessionId,
68
+ ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
63
69
  // 256 KB read + memory_search budget. Sized for one retrieval pass:
64
70
  // ~16 KB of memory_search hits (3 queries × ~5 KB excerpts) plus a few
65
71
  // shard reads (~5 KB each). A smaller budget would systematically
@@ -77,7 +77,7 @@ export async function runShardingMigration(options: RunShardingMigrationOptions)
77
77
  ...extra,
78
78
  })
79
79
 
80
- await recoverShardingOrphans(options.agentDir, options.logger)
80
+ await recoverShardingOrphans(options.agentDir, options.logger, options.git)
81
81
 
82
82
  if (existsSync(topicsDir(options.agentDir)) || !existsSync(rootMemoryPath(options.agentDir))) {
83
83
  return empty()
@@ -241,25 +241,38 @@ async function recoverShardingMigration(agentDir: string, logger: MigrationLogge
241
241
  )
242
242
  }
243
243
 
244
- async function recoverShardingOrphans(agentDir: string, logger: MigrationLogger): Promise<void> {
245
- if (!existsSync(topicsDir(agentDir))) return
244
+ async function recoverShardingOrphans(
245
+ agentDir: string,
246
+ logger: MigrationLogger,
247
+ git: MigrationGit | undefined,
248
+ ): Promise<void> {
249
+ if (existsSync(topicsDir(agentDir))) {
250
+ let cleaned = false
251
+ const memoryPath = rootMemoryPath(agentDir)
252
+ if (existsSync(memoryPath)) {
253
+ await unlink(memoryPath)
254
+ cleaned = true
255
+ }
246
256
 
247
- let cleaned = false
248
- const memoryPath = rootMemoryPath(agentDir)
249
- if (existsSync(memoryPath)) {
250
- await unlink(memoryPath)
251
- cleaned = true
252
- }
257
+ const memoryDir = join(agentDir, 'memory')
258
+ const dates = await collectFlatJsonlDates(memoryDir)
259
+ for (const date of dates) {
260
+ if (!existsSync(streamFilePath(agentDir, date))) continue
261
+ await unlink(join(memoryDir, `${date}.jsonl`))
262
+ cleaned = true
263
+ }
253
264
 
254
- const memoryDir = join(agentDir, 'memory')
255
- const dates = await collectFlatJsonlDates(memoryDir)
256
- for (const date of dates) {
257
- if (!existsSync(streamFilePath(agentDir, date))) continue
258
- await unlink(join(memoryDir, `${date}.jsonl`))
259
- cleaned = true
265
+ if (cleaned) logger.info('[memory:migration] cleaned orphaned pre-shard memory files')
260
266
  }
261
267
 
262
- if (cleaned) logger.info('[memory:migration] cleaned orphaned pre-shard memory files')
268
+ // Always called, even when nothing was cleaned this boot AND even when the
269
+ // sharded layout never landed on this agent: pre-#315 migrations and
270
+ // earlier runs of this function unlinked without committing, leaving
271
+ // staged deletions that survive across reboots until cleared explicitly.
272
+ // The earlier guard (`return` when topicsDir is absent) stranded any agent
273
+ // whose pre-shard files were deleted but whose sharding never completed —
274
+ // their staged deletions sat in the index forever.
275
+ await commitPendingLegacyDeletions(agentDir, logger, git)
263
276
  }
264
277
 
265
278
  async function collectFlatJsonlDates(memoryDir: string): Promise<string[]> {
@@ -540,6 +553,68 @@ async function commitShardingMigration(
540
553
  }
541
554
  }
542
555
 
556
+ async function commitPendingLegacyDeletions(
557
+ agentDir: string,
558
+ logger: MigrationLogger,
559
+ git: MigrationGit | undefined,
560
+ ): Promise<void> {
561
+ const spawn = git?.spawn ?? spawnGit
562
+ const inside = await spawn(['rev-parse', '--is-inside-work-tree'], { cwd: agentDir })
563
+ if (inside.exitCode !== 0) return
564
+
565
+ const pending = await collectLegacyDeletions(agentDir, spawn)
566
+ if (pending.all.length === 0) return
567
+
568
+ // `git add -u` errors with "pathspec did not match" on paths whose deletion
569
+ // is already in the index, so stage only the working-tree-only deletions.
570
+ // The already-staged set is picked up by the commit directly.
571
+ if (pending.workingTreeOnly.length > 0) {
572
+ const addDeletions = await spawn(['add', '-u', '--', ...pending.workingTreeOnly], { cwd: agentDir })
573
+ if (addDeletions.exitCode !== 0) {
574
+ logger.warn(`[memory:migration] git add failed: ${addDeletions.stderr || addDeletions.stdout}`.trim())
575
+ return
576
+ }
577
+ }
578
+
579
+ const commit = await spawn(
580
+ [
581
+ 'commit',
582
+ '-m',
583
+ `memory: clean up ${pending.all.length} pre-shard file(s) orphaned by earlier migration`,
584
+ '--no-edit',
585
+ ],
586
+ { cwd: agentDir },
587
+ )
588
+ if (commit.exitCode !== 0) {
589
+ logger.warn(`[memory:migration] git commit failed: ${commit.stderr || commit.stdout}`.trim())
590
+ }
591
+ }
592
+
593
+ async function collectLegacyDeletions(
594
+ agentDir: string,
595
+ spawn: NonNullable<MigrationGit['spawn']>,
596
+ ): Promise<{ all: string[]; workingTreeOnly: string[] }> {
597
+ const isLegacy = (line: string): boolean => line === 'MEMORY.md' || /^memory\/\d{4}-\d{2}-\d{2}\.jsonl$/.test(line)
598
+ const parse = (out: string): string[] =>
599
+ out
600
+ .split('\n')
601
+ .map((line) => line.trim())
602
+ .filter(isLegacy)
603
+
604
+ const allDiff = await spawn(['diff', 'HEAD', '--name-only', '--diff-filter=D', '--', 'memory/', 'MEMORY.md'], {
605
+ cwd: agentDir,
606
+ })
607
+ if (allDiff.exitCode !== 0) return { all: [], workingTreeOnly: [] }
608
+ const all = parse(allDiff.stdout)
609
+ if (all.length === 0) return { all: [], workingTreeOnly: [] }
610
+
611
+ const wtDiff = await spawn(['diff', '--name-only', '--diff-filter=D', '--', 'memory/', 'MEMORY.md'], {
612
+ cwd: agentDir,
613
+ })
614
+ const workingTreeOnly = wtDiff.exitCode === 0 ? parse(wtDiff.stdout) : []
615
+ return { all, workingTreeOnly }
616
+ }
617
+
543
618
  async function spawnGit(
544
619
  args: string[],
545
620
  options: { cwd: string },
@@ -1,4 +1,4 @@
1
- import { readFile, appendFile, readdir, writeFile, rename } from 'node:fs/promises'
1
+ import { readFile, appendFile, readdir, stat, writeFile, rename } from 'node:fs/promises'
2
2
  import { join } from 'node:path'
3
3
 
4
4
  import { getDreamedIds, loadDreamingState } from './dreaming-state'
@@ -8,7 +8,59 @@ import { parseEventLine, type StreamEvent } from './stream-events'
8
8
  const STREAM_FILE_PATTERN = /^\d{4}-\d{2}-\d{2}\.jsonl$/
9
9
  const STREAM_DATE_FROM_FILENAME = /^(\d{4}-\d{2}-\d{2})\.jsonl$/
10
10
 
11
+ // Per-file event cache. `(mtimeMs, ctimeMs, size)` is the invalidation key,
12
+ // mirroring `load-shards.ts`'s shard cache. The three writers in this module
13
+ // — `appendEvents` (memory-logger appends), `writeEventsAtomic` (dreaming
14
+ // compaction + migration), and any external `writeFile` — all bump mtime
15
+ // and/or ctime, so stat-based invalidation is sufficient without explicit
16
+ // hooks. ctimeMs guards metadata-preserving external edits (rsync -t,
17
+ // `touch -r`, restored backups, `git checkout` with timestamps): the kernel
18
+ // always bumps ctime on inode content changes and ctime cannot be backdated
19
+ // via utimes.
20
+ //
21
+ // Module-level keyed by absolute file path. One Bun process owns one agent
22
+ // dir in production (the container stage), so cardinality is small. Multi-
23
+ // path support exists because dreaming compacts multiple files per run and
24
+ // memory_search reads every dated stream.
25
+ type StreamFileCacheEntry = {
26
+ mtimeMs: number
27
+ ctimeMs: number
28
+ size: number
29
+ events: StreamEvent[]
30
+ }
31
+ const streamFileCache = new Map<string, StreamFileCacheEntry>()
32
+
11
33
  export async function readEvents(path: string): Promise<StreamEvent[]> {
34
+ const fileStat = await statFile(path)
35
+ if (fileStat === null) {
36
+ // File disappeared since last cache populate (e.g. dreaming dropped a
37
+ // fully-GC'd day). Drop the entry so a future recreate gets fresh
38
+ // content.
39
+ streamFileCache.delete(path)
40
+ return []
41
+ }
42
+
43
+ const cached = streamFileCache.get(path)
44
+ if (
45
+ cached !== undefined &&
46
+ cached.mtimeMs === fileStat.mtimeMs &&
47
+ cached.ctimeMs === fileStat.ctimeMs &&
48
+ cached.size === fileStat.size
49
+ ) {
50
+ return cached.events
51
+ }
52
+
53
+ const events = await readEventsFromDisk(path)
54
+ streamFileCache.set(path, {
55
+ mtimeMs: fileStat.mtimeMs,
56
+ ctimeMs: fileStat.ctimeMs,
57
+ size: fileStat.size,
58
+ events,
59
+ })
60
+ return events
61
+ }
62
+
63
+ async function readEventsFromDisk(path: string): Promise<StreamEvent[]> {
12
64
  let raw: string
13
65
  try {
14
66
  raw = await readFile(path, 'utf-8')
@@ -34,6 +86,24 @@ export async function readEvents(path: string): Promise<StreamEvent[]> {
34
86
  return events
35
87
  }
36
88
 
89
+ async function statFile(path: string): Promise<{ mtimeMs: number; ctimeMs: number; size: number } | null> {
90
+ try {
91
+ const s = await stat(path)
92
+ return { mtimeMs: s.mtimeMs, ctimeMs: s.ctimeMs, size: s.size }
93
+ } catch (err) {
94
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null
95
+ throw err
96
+ }
97
+ }
98
+
99
+ // Test-only helper. Clears the in-memory stream-file cache so tests that
100
+ // exercise the cache invalidation path can simulate a cold start without
101
+ // spinning up a fresh process. Mirrors `__resetShardCacheForTests` in
102
+ // `load-shards.ts`.
103
+ export function __resetStreamFileCacheForTests(): void {
104
+ streamFileCache.clear()
105
+ }
106
+
37
107
  export async function appendEvents(path: string, events: readonly StreamEvent[]): Promise<void> {
38
108
  if (events.length === 0) return
39
109
  const joined = events.map((e) => `${JSON.stringify(e)}\n`).join('')
@@ -67,7 +67,10 @@ export function classifyInbound(
67
67
  mentionsOthers: false,
68
68
  replyToOtherMessageId: null,
69
69
  isDm: chatInfo.isDm,
70
- ts: event.sent_at,
70
+ // SDK delivers `sent_at` in Unix seconds (LOCO `sendAt`); contract
71
+ // wants ms (see `src/channels/types.ts`). Without `* 1000`, ms-based
72
+ // renderers (inspect -f, etc.) produce 1970-01-21-shaped dates.
73
+ ts: event.sent_at * 1000,
71
74
  },
72
75
  }
73
76
  }
@@ -257,7 +257,7 @@ export function createKakaoHistoryCallback(deps: {
257
257
  authorId,
258
258
  authorName,
259
259
  text: formatHistoryText(m),
260
- ts: m.sent_at,
260
+ ts: m.sent_at * 1000,
261
261
  isBot: selfId !== null && authorId === selfId,
262
262
  replyToBotMessageId: null,
263
263
  }
@@ -5,6 +5,7 @@ import type { PermissionService } from '@/permissions'
5
5
  import type { GithubSecretsBlock } from '@/secrets'
6
6
  import { SecretsKakaoCredentialStore } from '@/secrets/kakao-store'
7
7
  import { SecretsBackend } from '@/secrets/storage'
8
+ import type { Stream } from '@/stream'
8
9
 
9
10
  import { createDiscordBotAdapter, type DiscordBotAdapter } from './adapters/discord-bot'
10
11
  import { createGithubAdapter, type GithubAdapter } from './adapters/github'
@@ -78,6 +79,11 @@ export type ChannelManagerOptions = {
78
79
  // a URL" so error logs can be precise. Same shape as
79
80
  // `tunnelUrlForChannel` for consistency. Optional for tests.
80
81
  tunnelConfiguredForChannel?: (channelName: string) => boolean
82
+ // Forwarded to the router as `stream`. When set, every inbound the
83
+ // router sees is published as a tagged broadcast for inspect surfacing.
84
+ // Production wiring (`src/run/index.ts`) always passes the agent's
85
+ // Stream; tests typically omit it.
86
+ stream?: Stream
81
87
  }
82
88
 
83
89
  export type ChannelManager = {
@@ -113,6 +119,7 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
113
119
  ...(options.createSessionForChannel ? { createSessionForChannel: options.createSessionForChannel } : {}),
114
120
  ...(options.permissions ? { permissions: options.permissions } : {}),
115
121
  ...(options.claimHandler ? { claimHandler: options.claimHandler } : {}),
122
+ ...(options.stream ? { stream: options.stream } : {}),
116
123
  })
117
124
  const createDiscordAdapter = options.createDiscordAdapter ?? createDiscordBotAdapter
118
125
  const createGithub = options.createGithubAdapter ?? createGithubAdapter