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