typeclaw 0.8.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.
- package/README.md +6 -6
- package/package.json +5 -3
- package/scripts/require-parallel.ts +41 -0
- package/src/agent/index.ts +55 -6
- package/src/agent/live-sessions.ts +34 -0
- package/src/agent/plugin-tools.ts +2 -0
- package/src/agent/session-meta.ts +21 -2
- package/src/agent/subagent-completion-reminder.ts +89 -0
- package/src/agent/subagents.ts +3 -2
- package/src/agent/system-prompt.ts +10 -8
- package/src/bundled-plugins/explorer/explorer.ts +2 -2
- package/src/bundled-plugins/guard/index.ts +14 -1
- package/src/bundled-plugins/guard/policies/managed-config.ts +43 -13
- package/src/bundled-plugins/guard/policies/memory-retrieval-cache-write.ts +37 -0
- package/src/bundled-plugins/guard/policies/memory-topics-delete.ts +67 -0
- package/src/bundled-plugins/guard/policies/memory-topics-write.ts +33 -0
- package/src/bundled-plugins/guard/policies/non-workspace-write.ts +8 -2
- package/src/bundled-plugins/guard/policy.ts +7 -0
- package/src/bundled-plugins/memory/README.md +76 -62
- package/src/bundled-plugins/memory/append-tool.ts +3 -2
- package/src/bundled-plugins/memory/citation-superset.ts +49 -11
- package/src/bundled-plugins/memory/citations.ts +19 -8
- package/src/bundled-plugins/memory/delete-tool.ts +57 -0
- package/src/bundled-plugins/memory/dreaming-state.ts +1 -1
- package/src/bundled-plugins/memory/dreaming.ts +364 -146
- package/src/bundled-plugins/memory/frontmatter.ts +165 -0
- package/src/bundled-plugins/memory/index.ts +236 -16
- package/src/bundled-plugins/memory/injection-plan.ts +15 -0
- package/src/bundled-plugins/memory/load-memory.ts +102 -103
- package/src/bundled-plugins/memory/load-shards.ts +156 -0
- package/src/bundled-plugins/memory/memory-logger.ts +16 -15
- package/src/bundled-plugins/memory/memory-retrieval.ts +105 -0
- package/src/bundled-plugins/memory/migration.ts +282 -1
- package/src/bundled-plugins/memory/paths.ts +42 -0
- package/src/bundled-plugins/memory/search-tool.ts +232 -0
- package/src/bundled-plugins/memory/secret-detector.ts +2 -2
- package/src/bundled-plugins/memory/shard-snapshot.ts +51 -0
- package/src/bundled-plugins/memory/slug.ts +59 -0
- package/src/bundled-plugins/memory/stream-io.ts +110 -1
- package/src/bundled-plugins/memory/strength.ts +3 -3
- package/src/bundled-plugins/memory/topics.ts +70 -16
- package/src/bundled-plugins/security/index.ts +24 -0
- package/src/bundled-plugins/security/permissions.ts +4 -0
- package/src/bundled-plugins/security/policies/cron-promotion.ts +349 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +2 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +3 -0
- package/src/bundled-plugins/security/policies/role-promotion.ts +419 -0
- package/src/bundled-plugins/security/policies/system-prompt-leak.ts +1 -0
- package/src/channels/adapters/kakaotalk-attachment.ts +7 -17
- package/src/channels/adapters/kakaotalk.ts +64 -37
- package/src/channels/adapters/slack-bot-classify.ts +2 -27
- package/src/channels/index.ts +5 -0
- package/src/channels/router.ts +201 -17
- package/src/channels/subagent-completion-bridge.ts +84 -0
- package/src/cli/builtins.ts +1 -0
- package/src/cli/index.ts +1 -0
- package/src/cli/init.ts +122 -14
- package/src/cli/inspect.ts +151 -0
- package/src/cron/consumer.ts +1 -1
- package/src/init/dockerfile.ts +268 -4
- package/src/init/hatching.ts +5 -6
- package/src/init/kakaotalk-auth.ts +6 -47
- package/src/init/validate-api-key.ts +121 -0
- package/src/inspect/index.ts +213 -0
- package/src/inspect/label.ts +50 -0
- package/src/inspect/live.ts +221 -0
- package/src/inspect/render.ts +163 -0
- package/src/inspect/replay.ts +265 -0
- package/src/inspect/session-list.ts +160 -0
- package/src/inspect/types.ts +110 -0
- package/src/plugin/hooks.ts +23 -1
- package/src/plugin/index.ts +2 -0
- package/src/plugin/manager.ts +1 -1
- package/src/plugin/registry.ts +1 -1
- package/src/plugin/types.ts +10 -0
- package/src/run/channel-session-factory.ts +7 -1
- package/src/run/index.ts +87 -21
- package/src/secrets/kakao-renewal.ts +3 -47
- package/src/server/index.ts +241 -60
- package/src/shared/index.ts +3 -0
- package/src/shared/protocol.ts +49 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +9 -9
- package/src/skills/typeclaw-claude-code/SKILL.md +57 -39
- package/src/skills/typeclaw-claude-code/references/stop-hook.md +2 -0
- package/src/skills/typeclaw-claude-code/references/tmux-driving.md +102 -16
- package/src/skills/typeclaw-config/SKILL.md +1 -1
- package/src/skills/typeclaw-cron/SKILL.md +1 -1
- package/src/skills/typeclaw-memory/SKILL.md +16 -163
- package/src/skills/typeclaw-permissions/SKILL.md +2 -2
- package/src/skills/typeclaw-plugins/SKILL.md +25 -14
- package/src/test-helpers/wait-for.ts +7 -1
- 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 {
|
|
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 {
|
|
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({
|
|
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
|
|
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 =
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
message:
|
|
253
|
-
|
|
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 =
|
|
263
|
-
const abs =
|
|
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(
|
|
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
|
|
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
|
+
}
|