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.
- package/package.json +1 -1
- package/scripts/require-parallel.ts +41 -15
- package/src/agent/live-subagents.ts +0 -1
- package/src/agent/session-origin.ts +10 -0
- package/src/agent/subagent-completion-reminder.ts +4 -1
- package/src/agent/subagents.ts +72 -13
- package/src/agent/system-prompt.ts +5 -5
- package/src/agent/tools/channel-reply.ts +47 -7
- package/src/agent/tools/channel-send.ts +43 -11
- package/src/agent/tools/restart.ts +13 -2
- package/src/agent/tools/runtime-notice.ts +41 -0
- package/src/agent/tools/spawn-subagent.ts +0 -1
- package/src/agent/tools/subagent-output.ts +3 -51
- package/src/bundled-plugins/memory/README.md +11 -11
- package/src/bundled-plugins/memory/dreaming-state.ts +51 -2
- package/src/bundled-plugins/memory/index.ts +77 -26
- package/src/bundled-plugins/memory/memory-retrieval.ts +7 -1
- package/src/bundled-plugins/memory/migration.ts +91 -16
- package/src/bundled-plugins/memory/stream-io.ts +71 -1
- package/src/channels/adapters/kakaotalk-classify.ts +4 -1
- package/src/channels/adapters/kakaotalk.ts +1 -1
- package/src/channels/manager.ts +7 -0
- package/src/channels/router.ts +260 -15
- package/src/channels/schema.ts +1 -1
- package/src/cli/compose.ts +23 -2
- package/src/cli/logs.ts +17 -2
- package/src/compose/logs.ts +8 -4
- package/src/config/config.ts +8 -0
- package/src/container/index.ts +1 -1
- package/src/container/logs.ts +38 -11
- package/src/init/dockerfile.ts +147 -4
- package/src/inspect/live.ts +32 -1
- package/src/inspect/render.ts +32 -0
- package/src/inspect/replay.ts +44 -0
- package/src/inspect/types.ts +26 -0
- package/src/run/index.ts +28 -11
- package/src/server/index.ts +59 -19
- package/src/shared/protocol.ts +30 -0
- package/src/skills/typeclaw-codex-cli/SKILL.md +324 -0
- package/src/skills/typeclaw-codex-cli/references/auth-flow.md +131 -0
- package/src/skills/typeclaw-codex-cli/references/stop-hook.md +92 -0
- package/src/skills/typeclaw-codex-cli/references/tmux-driving.md +239 -0
- package/src/skills/typeclaw-config/SKILL.md +32 -31
- package/src/test-helpers/wait-for.ts +15 -7
- 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))
|
|
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
|
|
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
|
|
137
|
-
//
|
|
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
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
393
|
+
void fireMemoryLogger(event.sessionId, 'session-end')
|
|
341
394
|
const cacheFilePath = join(ctx.agentDir, 'memory', '.retrieval-cache', `${event.sessionId}.md`)
|
|
342
|
-
|
|
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:
|
|
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(
|
|
245
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/channels/manager.ts
CHANGED
|
@@ -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
|