typeclaw 0.1.4 → 0.1.6
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 +15 -13
- package/auth.schema.json +41 -0
- package/cron.schema.json +8 -0
- package/package.json +1 -1
- package/secrets.schema.json +41 -0
- package/src/agent/auth.ts +45 -22
- package/src/agent/index.ts +189 -19
- package/src/agent/multimodal/index.ts +12 -0
- package/src/agent/multimodal/look-at.ts +185 -0
- package/src/agent/multimodal/looker.ts +145 -0
- package/src/agent/plugin-tools.ts +30 -1
- package/src/agent/session-origin.ts +194 -46
- package/src/agent/subagents.ts +57 -1
- package/src/agent/system-prompt.ts +1 -1
- package/src/agent/tool-result-budget.ts +121 -0
- package/src/bundled-plugins/backup/index.ts +23 -8
- package/src/bundled-plugins/backup/runner.ts +22 -0
- package/src/bundled-plugins/memory/README.md +13 -10
- package/src/bundled-plugins/memory/append-tool.ts +87 -61
- package/src/bundled-plugins/memory/dreaming.ts +137 -7
- package/src/bundled-plugins/memory/find-entry-tool.ts +62 -0
- package/src/bundled-plugins/memory/fragment-parser.ts +19 -44
- package/src/bundled-plugins/memory/index.ts +91 -8
- package/src/bundled-plugins/memory/load-memory.ts +74 -34
- package/src/bundled-plugins/memory/memory-logger.ts +72 -29
- package/src/bundled-plugins/memory/migration.ts +276 -0
- package/src/bundled-plugins/memory/stream-events.ts +55 -0
- package/src/bundled-plugins/memory/stream-io.ts +63 -0
- package/src/bundled-plugins/memory/watermark.ts +48 -8
- package/src/bundled-plugins/security/index.ts +103 -10
- package/src/bundled-plugins/security/permissions.ts +12 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +51 -18
- package/src/bundled-plugins/tool-result-cap/README.md +9 -4
- package/src/bundled-plugins/tool-result-cap/cap-jsonl.ts +115 -0
- package/src/bundled-plugins/tool-result-cap/cap-result.ts +25 -13
- package/src/bundled-plugins/tool-result-cap/index.ts +16 -2
- package/src/channels/adapters/discord-bot-classify.ts +2 -6
- package/src/channels/adapters/discord-bot.ts +4 -45
- package/src/channels/adapters/kakaotalk-classify.ts +3 -7
- package/src/channels/adapters/kakaotalk.ts +28 -47
- package/src/channels/adapters/slack-bot-classify.ts +2 -6
- package/src/channels/adapters/slack-bot.ts +4 -50
- package/src/channels/adapters/telegram-bot-classify.ts +8 -10
- package/src/channels/adapters/telegram-bot.ts +3 -16
- package/src/channels/index.ts +3 -2
- package/src/channels/manager.ts +15 -1
- package/src/channels/persistence.ts +44 -10
- package/src/channels/router.ts +228 -19
- package/src/channels/schema.ts +6 -156
- package/src/cli/channel.ts +200 -4
- package/src/cli/compose-usage.ts +182 -0
- package/src/cli/compose.ts +33 -0
- package/src/cli/hostd.ts +49 -1
- package/src/cli/index.ts +4 -0
- package/src/cli/init.ts +809 -300
- package/src/cli/model.ts +244 -0
- package/src/cli/provider.ts +404 -0
- package/src/cli/reload.ts +11 -3
- package/src/cli/role.ts +156 -0
- package/src/cli/run.ts +3 -1
- package/src/cli/tui.ts +13 -3
- package/src/cli/usage-args.ts +47 -0
- package/src/cli/usage.ts +97 -0
- package/src/compose/index.ts +1 -0
- package/src/compose/usage.ts +65 -0
- package/src/config/config.ts +491 -19
- package/src/config/index.ts +15 -1
- package/src/config/models-mutation.ts +200 -0
- package/src/config/providers-mutation.ts +250 -0
- package/src/config/providers.ts +141 -2
- package/src/config/reloadable.ts +15 -4
- package/src/container/index.ts +6 -1
- package/src/container/port.ts +10 -0
- package/src/container/require-running.ts +33 -0
- package/src/container/start.ts +81 -63
- package/src/cron/consumer.ts +22 -2
- package/src/cron/index.ts +45 -4
- package/src/cron/schema.ts +104 -0
- package/src/doctor/checks.ts +51 -34
- package/src/doctor/plugin-bridge.ts +28 -4
- package/src/git/system-commit.ts +103 -0
- package/src/hostd/daemon.ts +16 -0
- package/src/hostd/kakao-renewal-manager.ts +223 -0
- package/src/hostd/paths.ts +7 -0
- package/src/init/dockerfile.ts +36 -10
- package/src/init/gitignore.ts +1 -1
- package/src/init/index.ts +213 -85
- package/src/init/kakaotalk-auth.ts +18 -1
- package/src/init/models-dev.ts +26 -1
- package/src/init/run-owner-claim.ts +77 -0
- package/src/permissions/builtins.ts +70 -0
- package/src/permissions/grant.ts +99 -0
- package/src/permissions/index.ts +29 -0
- package/src/permissions/match-rule.ts +305 -0
- package/src/permissions/permissions.ts +196 -0
- package/src/permissions/resolve.ts +80 -0
- package/src/permissions/schema.ts +79 -0
- package/src/plugin/context.ts +8 -4
- package/src/plugin/define.ts +2 -0
- package/src/plugin/index.ts +2 -0
- package/src/plugin/manager.ts +41 -0
- package/src/plugin/registry.ts +9 -0
- package/src/plugin/types.ts +35 -1
- package/src/reload/client.ts +25 -1
- package/src/role-claim/client.ts +182 -0
- package/src/role-claim/code.ts +53 -0
- package/src/role-claim/controller.ts +194 -0
- package/src/role-claim/index.ts +19 -0
- package/src/role-claim/match-rule.ts +43 -0
- package/src/role-claim/pending.ts +100 -0
- package/src/run/channel-session-factory.ts +76 -5
- package/src/run/index.ts +68 -7
- package/src/secrets/encryption.ts +116 -0
- package/src/secrets/kakao-renewal.ts +248 -0
- package/src/secrets/kakao-store.ts +66 -7
- package/src/secrets/keys.ts +173 -0
- package/src/secrets/schema.ts +23 -0
- package/src/secrets/storage.ts +83 -0
- package/src/server/index.ts +198 -71
- package/src/shared/index.ts +4 -0
- package/src/shared/protocol.ts +27 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
- package/src/skills/typeclaw-config/SKILL.md +104 -112
- package/src/skills/typeclaw-memory/SKILL.md +9 -9
- package/src/skills/typeclaw-permissions/SKILL.md +166 -0
- package/src/stream/types.ts +7 -1
- package/src/tui/client.ts +66 -5
- package/src/tui/index.ts +61 -9
- package/src/usage/aggregate.ts +117 -0
- package/src/usage/format.ts +30 -0
- package/src/usage/index.ts +68 -0
- package/src/usage/report.ts +354 -0
- package/src/usage/scan.ts +186 -0
- package/typeclaw.schema.json +134 -98
package/src/agent/subagents.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { Stream, Unsubscribe } from '@/stream'
|
|
|
6
6
|
|
|
7
7
|
import { type AgentSession, createSession } from './index'
|
|
8
8
|
import type { SessionOrigin } from './session-origin'
|
|
9
|
+
import type { ToolResultBudget } from './tool-result-budget'
|
|
9
10
|
|
|
10
11
|
type AgentSessionTools = NonNullable<Parameters<typeof createSession>[0]>['tools']
|
|
11
12
|
|
|
@@ -19,10 +20,15 @@ export type RunSession = (override?: { userPrompt?: string }) => Promise<void>
|
|
|
19
20
|
|
|
20
21
|
export type Subagent<P = unknown> = {
|
|
21
22
|
systemPrompt: string
|
|
23
|
+
// Model profile this subagent prefers. Resolved against `config.models` at
|
|
24
|
+
// session construction. Unknown profile names fall back to `default` with
|
|
25
|
+
// a warning. See `Subagent` in `@/plugin/types` for the full contract.
|
|
26
|
+
profile?: string
|
|
22
27
|
tools?: AgentSessionTools
|
|
23
28
|
customTools?: ToolDefinition[]
|
|
24
29
|
payloadSchema?: z.ZodType<P>
|
|
25
30
|
handler?: (ctx: SubagentContext<P>, runSession: RunSession) => Promise<void>
|
|
31
|
+
toolResultBudget?: ToolResultBudget
|
|
26
32
|
}
|
|
27
33
|
|
|
28
34
|
export type SubagentRegistry = Readonly<Record<string, Subagent<any>>>
|
|
@@ -62,6 +68,8 @@ export type CreateSessionForSubagentResult = {
|
|
|
62
68
|
export type CreateSessionForSubagentOptions = {
|
|
63
69
|
name?: string
|
|
64
70
|
parentSessionId?: string
|
|
71
|
+
spawnedByRole?: string
|
|
72
|
+
spawnedByOrigin?: SessionOrigin
|
|
65
73
|
}
|
|
66
74
|
export type CreateSessionForSubagent = (
|
|
67
75
|
subagent: Subagent<any>,
|
|
@@ -75,9 +83,13 @@ export const defaultCreateSessionForSubagent: CreateSessionForSubagent = (subage
|
|
|
75
83
|
kind: 'subagent',
|
|
76
84
|
subagent: options?.name ?? '<unknown>',
|
|
77
85
|
parentSessionId: options?.parentSessionId ?? '<unknown>',
|
|
86
|
+
...(options?.spawnedByRole !== undefined ? { spawnedByRole: options.spawnedByRole } : {}),
|
|
87
|
+
...(options?.spawnedByOrigin !== undefined ? { spawnedByOrigin: options.spawnedByOrigin } : {}),
|
|
78
88
|
},
|
|
79
89
|
...(subagent.tools ? { tools: subagent.tools } : {}),
|
|
80
90
|
customTools: subagent.customTools ?? [],
|
|
91
|
+
...(subagent.profile !== undefined ? { profile: subagent.profile } : {}),
|
|
92
|
+
...(subagent.toolResultBudget !== undefined ? { toolResultBudget: subagent.toolResultBudget } : {}),
|
|
81
93
|
})
|
|
82
94
|
|
|
83
95
|
type NormalizedSubagentSession = {
|
|
@@ -120,6 +132,8 @@ export type InvokeSubagentOptions = {
|
|
|
120
132
|
userPrompt: string
|
|
121
133
|
payload?: unknown
|
|
122
134
|
parentSessionId?: string
|
|
135
|
+
spawnedByRole?: string
|
|
136
|
+
spawnedByOrigin?: SessionOrigin
|
|
123
137
|
}
|
|
124
138
|
|
|
125
139
|
export async function invokeSubagent(name: string, options: InvokeSubagentOptions): Promise<void> {
|
|
@@ -131,6 +145,8 @@ export async function invokeSubagent(name: string, options: InvokeSubagentOption
|
|
|
131
145
|
const sessionOptions: CreateSessionForSubagentOptions = {
|
|
132
146
|
name,
|
|
133
147
|
...(options.parentSessionId !== undefined ? { parentSessionId: options.parentSessionId } : {}),
|
|
148
|
+
...(options.spawnedByRole !== undefined ? { spawnedByRole: options.spawnedByRole } : {}),
|
|
149
|
+
...(options.spawnedByOrigin !== undefined ? { spawnedByOrigin: options.spawnedByOrigin } : {}),
|
|
134
150
|
}
|
|
135
151
|
|
|
136
152
|
const runSession: RunSession = async (override) => {
|
|
@@ -157,11 +173,12 @@ export async function invokeSubagent(name: string, options: InvokeSubagentOption
|
|
|
157
173
|
sessionId,
|
|
158
174
|
parentTranscriptPath: getTranscriptPath?.(),
|
|
159
175
|
idleMs: 0,
|
|
176
|
+
...(origin !== undefined ? { origin } : {}),
|
|
160
177
|
})
|
|
161
178
|
}
|
|
162
179
|
} finally {
|
|
163
180
|
if (hooks && sessionId !== undefined) {
|
|
164
|
-
await hooks.runSessionEnd({ sessionId })
|
|
181
|
+
await hooks.runSessionEnd({ sessionId, ...(origin !== undefined ? { origin } : {}) })
|
|
165
182
|
}
|
|
166
183
|
session.dispose()
|
|
167
184
|
await dispose()
|
|
@@ -215,6 +232,40 @@ const consoleLogger: SubagentConsumerLogger = {
|
|
|
215
232
|
error: (m) => console.error(m),
|
|
216
233
|
}
|
|
217
234
|
|
|
235
|
+
function parseSpawnedByOriginJson(
|
|
236
|
+
raw: string | undefined,
|
|
237
|
+
logger: SubagentConsumerLogger,
|
|
238
|
+
subagentName: string,
|
|
239
|
+
): SessionOrigin | undefined {
|
|
240
|
+
if (raw === undefined) return undefined
|
|
241
|
+
let parsed: unknown
|
|
242
|
+
try {
|
|
243
|
+
parsed = JSON.parse(raw)
|
|
244
|
+
} catch (err) {
|
|
245
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
246
|
+
logger.warn(`[subagent] ${subagentName}: ignoring malformed spawnedByOriginJson on stream target: ${message}`)
|
|
247
|
+
return undefined
|
|
248
|
+
}
|
|
249
|
+
// Shape-validate the decoded value so a malformed sender (or a future
|
|
250
|
+
// bug in cron consumer's encode side) cannot poison the subagent's
|
|
251
|
+
// origin with arbitrary shapes. The check is narrow: object with a
|
|
252
|
+
// `kind` field whose value is one of the SessionOrigin discriminator
|
|
253
|
+
// strings. Permission resolution treats unknown shapes as guest, so
|
|
254
|
+
// failing closed here matches the rest of the system.
|
|
255
|
+
if (!isSessionOriginShape(parsed)) {
|
|
256
|
+
logger.warn(`[subagent] ${subagentName}: ignoring spawnedByOriginJson with unrecognized SessionOrigin shape`)
|
|
257
|
+
return undefined
|
|
258
|
+
}
|
|
259
|
+
return parsed
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const SESSION_ORIGIN_KINDS = new Set(['tui', 'cron', 'channel', 'subagent'])
|
|
263
|
+
function isSessionOriginShape(value: unknown): value is SessionOrigin {
|
|
264
|
+
if (value === null || typeof value !== 'object') return false
|
|
265
|
+
const kind = (value as { kind?: unknown }).kind
|
|
266
|
+
return typeof kind === 'string' && SESSION_ORIGIN_KINDS.has(kind)
|
|
267
|
+
}
|
|
268
|
+
|
|
218
269
|
export function createSubagentConsumer({
|
|
219
270
|
stream,
|
|
220
271
|
getRegistry,
|
|
@@ -234,6 +285,8 @@ export function createSubagentConsumer({
|
|
|
234
285
|
kind: 'new-session'
|
|
235
286
|
subagent: string
|
|
236
287
|
parentSessionId?: string
|
|
288
|
+
spawnedByRole?: string
|
|
289
|
+
spawnedByOriginJson?: string
|
|
237
290
|
}
|
|
238
291
|
const name = target.subagent
|
|
239
292
|
const registry = getRegistry()
|
|
@@ -248,6 +301,7 @@ export function createSubagentConsumer({
|
|
|
248
301
|
}
|
|
249
302
|
inFlight.add(key)
|
|
250
303
|
try {
|
|
304
|
+
const spawnedByOrigin = parseSpawnedByOriginJson(target.spawnedByOriginJson, logger, name)
|
|
251
305
|
await invokeSubagent(name, {
|
|
252
306
|
registry,
|
|
253
307
|
...(createSessionForSubagent !== undefined ? { createSessionForSubagent } : {}),
|
|
@@ -255,6 +309,8 @@ export function createSubagentConsumer({
|
|
|
255
309
|
userPrompt: '',
|
|
256
310
|
payload: msg.payload,
|
|
257
311
|
...(target.parentSessionId !== undefined ? { parentSessionId: target.parentSessionId } : {}),
|
|
312
|
+
...(target.spawnedByRole !== undefined ? { spawnedByRole: target.spawnedByRole } : {}),
|
|
313
|
+
...(spawnedByOrigin !== undefined ? { spawnedByOrigin } : {}),
|
|
258
314
|
})
|
|
259
315
|
} catch (err) {
|
|
260
316
|
const message = err instanceof Error ? err.message : String(err)
|
|
@@ -20,7 +20,7 @@ These files are not decoration. They shape how you behave. If a task reveals som
|
|
|
20
20
|
|
|
21
21
|
- **\`workspace/\`** — the directory where you are free to create files: drafts, notes, downloads, scratch work, generated artifacts, temporary outputs. **Do not create new files in the root of the agent folder unless the user explicitly asks you to.** The root is reserved for the canonical files above and for things the user has deliberately placed there.
|
|
22
22
|
- **\`sessions/\`** — transcripts of past conversations (\`<sessionid>.jsonl\`). Read-only for you in spirit; the runtime manages these.
|
|
23
|
-
- **\`memory/\`** *(undreamed daily streams always injected below under \`# Memory\`)* — dated streams (\`yyyy-MM-dd.
|
|
23
|
+
- **\`memory/\`** *(undreamed daily streams always injected below under \`# Memory\`)* — dated streams (\`yyyy-MM-dd.jsonl\`) of fragments captured by the memory-logger between sessions. Newest day is closest to the current task. Once dreaming consolidates a day's stream into MEMORY.md, the runtime stops injecting it.
|
|
24
24
|
- **\`memory/skills/\`** — *muscle memory*. Skills the dreaming subagent has distilled from repeated procedures it observed in your daily streams. Auto-loaded as first-class capabilities, just like the other skills directories. **You do not write here directly** — dreaming owns it. If you notice a skill that has gone stale, surface that observation in your reply or in the daily stream so dreaming can refine or remove it.
|
|
25
25
|
- **\`.agents/skills/\`** — skills the user installed for you. Treat these as first-class capabilities.
|
|
26
26
|
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { AgentTool } from '@mariozechner/pi-agent-core'
|
|
2
|
+
import type { ToolDefinition } from '@mariozechner/pi-coding-agent'
|
|
3
|
+
import type { TSchema } from '@sinclair/typebox'
|
|
4
|
+
|
|
5
|
+
// Subagents that read large files (memory-logger and dreaming each read parent
|
|
6
|
+
// session transcripts that can run hundreds of KB) are vulnerable to a class
|
|
7
|
+
// of bug where a single tool malfunction — a broken `find_entry`, a missing
|
|
8
|
+
// watermark, a transcript that no longer contains the watermark id — causes
|
|
9
|
+
// the agent to fall back to scanning the file in 50KB chunks. Every chunk
|
|
10
|
+
// stays in the subagent's conversation history and gets re-sent to the model
|
|
11
|
+
// on every turn until the subagent stops, so a 1MB transcript can balloon a
|
|
12
|
+
// memory-logger run from ~10K input tokens to several hundred thousand.
|
|
13
|
+
//
|
|
14
|
+
// The budget here is a fail-safe ceiling on the total bytes of tool-result
|
|
15
|
+
// text a subagent run is allowed to accumulate from a chosen set of tools.
|
|
16
|
+
// Once exhausted, subsequent calls to those tools short-circuit with a
|
|
17
|
+
// constant-size message that tells the agent to advance the watermark to the
|
|
18
|
+
// latest entry and exit. The budget is per-run (one BudgetState per session)
|
|
19
|
+
// and tracked only for the named tools; tools like `append` (which write,
|
|
20
|
+
// not read) are unaffected.
|
|
21
|
+
|
|
22
|
+
export type ToolResultBudget = {
|
|
23
|
+
maxTotalBytes: number
|
|
24
|
+
toolNames: readonly string[]
|
|
25
|
+
exhaustedMessage?: (used: number, max: number) => string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type BudgetState = {
|
|
29
|
+
used: number
|
|
30
|
+
exhausted: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createBudgetState(): BudgetState {
|
|
34
|
+
return { used: 0, exhausted: false }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function defaultExhaustedMessage(used: number, max: number): string {
|
|
38
|
+
const usedKb = Math.round(used / 1024)
|
|
39
|
+
const maxKb = Math.round(max / 1024)
|
|
40
|
+
return [
|
|
41
|
+
`[tool-result budget exhausted: used ${usedKb}KB of ${maxKb}KB this run]`,
|
|
42
|
+
'',
|
|
43
|
+
'Stop reading. This session has consumed its byte budget across calls to',
|
|
44
|
+
'this tool. Do not call this tool again. Stop and exit; future runs will',
|
|
45
|
+
'continue from wherever your normal end-of-run bookkeeping left off.',
|
|
46
|
+
].join('\n')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function bytesOfContent(content: { type: string; text?: string }[] | undefined): number {
|
|
50
|
+
if (!content) return 0
|
|
51
|
+
let total = 0
|
|
52
|
+
for (const part of content) {
|
|
53
|
+
if (part.type === 'text' && typeof part.text === 'string') {
|
|
54
|
+
total += Buffer.byteLength(part.text, 'utf8')
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return total
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function buildExhaustedResult(budget: ToolResultBudget, state: BudgetState) {
|
|
61
|
+
const text = (budget.exhaustedMessage ?? defaultExhaustedMessage)(state.used, budget.maxTotalBytes)
|
|
62
|
+
return {
|
|
63
|
+
content: [{ type: 'text' as const, text }],
|
|
64
|
+
details: { budgetExhausted: true, used: state.used, max: budget.maxTotalBytes },
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Wraps an AgentTool's execute so that returned text content is counted against
|
|
69
|
+
// `state` and the tool short-circuits once `budget.maxTotalBytes` is exceeded.
|
|
70
|
+
// Tools whose name is not in `budget.toolNames` are returned unchanged so the
|
|
71
|
+
// caller can pass an entire `tools` array through and only the tracked tools
|
|
72
|
+
// are affected. The original tool object is preserved by spreading; only
|
|
73
|
+
// `execute` is replaced.
|
|
74
|
+
export function wrapAgentToolWithBudget<TParams extends TSchema, TDetails = unknown>(
|
|
75
|
+
tool: AgentTool<TParams, TDetails>,
|
|
76
|
+
budget: ToolResultBudget,
|
|
77
|
+
state: BudgetState,
|
|
78
|
+
): AgentTool<TParams, TDetails> {
|
|
79
|
+
if (!budget.toolNames.includes(tool.name)) return tool
|
|
80
|
+
const originalExecute = tool.execute.bind(tool)
|
|
81
|
+
return {
|
|
82
|
+
...tool,
|
|
83
|
+
async execute(toolCallId, args, signal, onUpdate) {
|
|
84
|
+
if (state.exhausted) {
|
|
85
|
+
return buildExhaustedResult(budget, state) as Awaited<ReturnType<typeof originalExecute>>
|
|
86
|
+
}
|
|
87
|
+
const result = await originalExecute(toolCallId, args, signal, onUpdate)
|
|
88
|
+
state.used += bytesOfContent(result.content as { type: string; text?: string }[] | undefined)
|
|
89
|
+
if (state.used >= budget.maxTotalBytes) {
|
|
90
|
+
state.exhausted = true
|
|
91
|
+
}
|
|
92
|
+
return result
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Same wrapper for ToolDefinition (the customTools surface). Identical
|
|
98
|
+
// semantics; ToolDefinition's execute has an extra `onUpdate` callback and a
|
|
99
|
+
// `ctx` argument that we forward verbatim.
|
|
100
|
+
export function wrapToolDefinitionWithBudget<TParams extends TSchema, TDetails = unknown, TState = unknown>(
|
|
101
|
+
tool: ToolDefinition<TParams, TDetails, TState>,
|
|
102
|
+
budget: ToolResultBudget,
|
|
103
|
+
state: BudgetState,
|
|
104
|
+
): ToolDefinition<TParams, TDetails, TState> {
|
|
105
|
+
if (!budget.toolNames.includes(tool.name)) return tool
|
|
106
|
+
const originalExecute = tool.execute.bind(tool)
|
|
107
|
+
return {
|
|
108
|
+
...tool,
|
|
109
|
+
async execute(toolCallId, args, signal, onUpdate, ctx) {
|
|
110
|
+
if (state.exhausted) {
|
|
111
|
+
return buildExhaustedResult(budget, state) as Awaited<ReturnType<typeof originalExecute>>
|
|
112
|
+
}
|
|
113
|
+
const result = await originalExecute(toolCallId, args, signal, onUpdate, ctx)
|
|
114
|
+
state.used += bytesOfContent(result.content as { type: string; text?: string }[] | undefined)
|
|
115
|
+
if (state.used >= budget.maxTotalBytes) {
|
|
116
|
+
state.exhausted = true
|
|
117
|
+
}
|
|
118
|
+
return result
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
|
|
3
|
-
import { definePlugin, type Subagent } from '@/plugin'
|
|
3
|
+
import { definePlugin, type PluginContext, type SpawnSubagentOptions, type Subagent } from '@/plugin'
|
|
4
4
|
|
|
5
5
|
import { COMMIT_TIMEOUT_MS, makeDefaultGitSpawn, NETWORK_TIMEOUT_MS, runBackup, type BackupResult } from './runner'
|
|
6
6
|
import {
|
|
@@ -78,10 +78,19 @@ export default definePlugin({
|
|
|
78
78
|
if (activeTurns.size > 0) return
|
|
79
79
|
inFlight = true
|
|
80
80
|
try {
|
|
81
|
-
await ctx.spawnSubagent(
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
81
|
+
await ctx.spawnSubagent(
|
|
82
|
+
SUBAGENT_BACKUP_RUNNER,
|
|
83
|
+
{
|
|
84
|
+
agentDir: ctx.agentDir,
|
|
85
|
+
pushToOrigin,
|
|
86
|
+
} satisfies RunnerPayload,
|
|
87
|
+
// The backup runner is a system-level operation that commits +
|
|
88
|
+
// pushes on the operator's behalf. It runs after every idle
|
|
89
|
+
// window regardless of which session caused activity, so it has
|
|
90
|
+
// no single user session to inherit from. Mark it as TUI-equivalent
|
|
91
|
+
// so it resolves to `owner` and can use git push, etc.
|
|
92
|
+
{ spawnedByOrigin: { kind: 'tui', sessionId: 'backup-runner' } },
|
|
93
|
+
)
|
|
85
94
|
} catch (err) {
|
|
86
95
|
ctx.logger.error(`backup runner spawn failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
87
96
|
} finally {
|
|
@@ -152,12 +161,18 @@ async function runBackupOnce(
|
|
|
152
161
|
ctx: {
|
|
153
162
|
agentDir: string
|
|
154
163
|
logger: { info: (m: string) => void; warn: (m: string) => void }
|
|
155
|
-
spawnSubagent:
|
|
164
|
+
spawnSubagent: PluginContext['spawnSubagent']
|
|
156
165
|
},
|
|
157
166
|
): Promise<BackupResult> {
|
|
158
167
|
const messagePath = messageFilePath(payload.agentDir)
|
|
159
168
|
await ensureMessageDir(messagePath)
|
|
160
169
|
await cleanupMessageFile(messagePath)
|
|
170
|
+
// Inherit the backup-runner's owner privileges for the message-picking
|
|
171
|
+
// and diagnose subagents it spawns. Same rationale as the runner itself
|
|
172
|
+
// — these are system-level operations on the operator's behalf.
|
|
173
|
+
const inheritOwner: SpawnSubagentOptions = {
|
|
174
|
+
spawnedByOrigin: { kind: 'tui', sessionId: 'backup-runner' },
|
|
175
|
+
}
|
|
161
176
|
|
|
162
177
|
const result = await runBackup(
|
|
163
178
|
{ cwd: payload.agentDir, pushToOrigin: payload.pushToOrigin },
|
|
@@ -172,7 +187,7 @@ async function runBackupOnce(
|
|
|
172
187
|
outputPath: messagePath,
|
|
173
188
|
}
|
|
174
189
|
try {
|
|
175
|
-
await ctx.spawnSubagent(SUBAGENT_COMMIT_MESSAGE, messagePayload)
|
|
190
|
+
await ctx.spawnSubagent(SUBAGENT_COMMIT_MESSAGE, messagePayload, inheritOwner)
|
|
176
191
|
} catch (err) {
|
|
177
192
|
ctx.logger.warn(
|
|
178
193
|
`${SUBAGENT_COMMIT_MESSAGE} subagent failed, using fallback: ${err instanceof Error ? err.message : String(err)}`,
|
|
@@ -191,7 +206,7 @@ async function runBackupOnce(
|
|
|
191
206
|
stdout: input.stdout,
|
|
192
207
|
}
|
|
193
208
|
try {
|
|
194
|
-
await ctx.spawnSubagent(SUBAGENT_DIAGNOSE, diagPayload)
|
|
209
|
+
await ctx.spawnSubagent(SUBAGENT_DIAGNOSE, diagPayload, inheritOwner)
|
|
195
210
|
} catch (err) {
|
|
196
211
|
ctx.logger.warn(`${SUBAGENT_DIAGNOSE} subagent failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
197
212
|
}
|
|
@@ -84,6 +84,28 @@ export async function runBackup(options: BackupRunnerOptions, deps: BackupRunner
|
|
|
84
84
|
diffstat: diffstat.stdout.slice(0, 4096),
|
|
85
85
|
})
|
|
86
86
|
|
|
87
|
+
// `pickCommitMessage` may spawn a subagent (the backup plugin's
|
|
88
|
+
// `backup-message`) whose session JSONL lands under `sessions/` after we
|
|
89
|
+
// already staged. Without this second pass that file would sit dirty in
|
|
90
|
+
// the worktree until the NEXT backup cycle, which would then commit it
|
|
91
|
+
// and create another orphan via the same path — a steady-state of
|
|
92
|
+
// one-cycle-behind churn. Re-status, filter to `sessions/` additions
|
|
93
|
+
// only (don't accidentally stage user work that arrived during the
|
|
94
|
+
// window), and force-add anything new.
|
|
95
|
+
const reStatus = await deps.gitSpawn(['status', '--porcelain=v1', '--untracked-files=all'], {
|
|
96
|
+
cwd,
|
|
97
|
+
timeoutMs: COMMIT_TIMEOUT_MS,
|
|
98
|
+
})
|
|
99
|
+
if (reStatus.exitCode === 0) {
|
|
100
|
+
const lateForce = filterForceAdd(parsePorcelain(reStatus.stdout)).filter((p) => existsSync(join(cwd, p)))
|
|
101
|
+
if (lateForce.length > 0) {
|
|
102
|
+
const lateAdd = await deps.gitSpawn(['add', '-f', '--', ...lateForce], { cwd, timeoutMs: COMMIT_TIMEOUT_MS })
|
|
103
|
+
if (lateAdd.exitCode !== 0) {
|
|
104
|
+
return { ok: false, kind: 'commit-failed', reason: `git add -f (post-message) failed: ${shortErr(lateAdd)}` }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
87
109
|
const safeMessage = sanitizeCommitMessage(message)
|
|
88
110
|
const commit = await deps.gitSpawn(['commit', '-m', safeMessage], { cwd, timeoutMs: COMMIT_TIMEOUT_MS })
|
|
89
111
|
if (commit.exitCode !== 0)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# typeclaw-plugin-memory
|
|
2
2
|
|
|
3
|
-
The bundled memory plugin. Owns `MEMORY.md` (long-term memory) and `memory/yyyy-MM-dd.
|
|
3
|
+
The bundled memory plugin. Owns `MEMORY.md` (long-term memory) and `memory/yyyy-MM-dd.jsonl` (daily streams) plus the two subagents that write them: `memory-logger` and `dreaming`.
|
|
4
4
|
|
|
5
5
|
This plugin is **auto-loaded** by every TypeClaw agent. There is no `plugins[]` entry to add and no opt-out. To configure it, add a `memory` block to `typeclaw.json`.
|
|
6
6
|
|
|
@@ -27,19 +27,22 @@ All fields are **restart-required** — the plugin reads them once at boot.
|
|
|
27
27
|
|
|
28
28
|
## What it contributes
|
|
29
29
|
|
|
30
|
-
| Kind | Name | Notes
|
|
31
|
-
| -------- | -------------------------- |
|
|
32
|
-
| Subagent | `memory-logger` | Reads a parent transcript past a watermark and appends fragments to `memory/<today>.
|
|
33
|
-
| Subagent | `dreaming` | Reads `MEMORY.md` plus undreamed daily-stream tails, rewrites `MEMORY.md`, optionally writes muscle-memory skills under `memory/skills/<name>/SKILL.md`, advances the per-day watermark, and `
|
|
34
|
-
| Cron job | `__plugin_memory_dreaming` | `kind: 'prompt'`, `subagent: 'dreaming'`, scheduled per `memory.dreaming.schedule`.
|
|
35
|
-
| Hook | `session.
|
|
36
|
-
| Hook | `session.
|
|
37
|
-
|
|
30
|
+
| Kind | Name | Notes |
|
|
31
|
+
| -------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
32
|
+
| Subagent | `memory-logger` | Reads a parent transcript past a watermark and appends fragments to `memory/<today>.jsonl`. Coalesced per `agentDir`; the plugin chains spawn calls onto a per-agent Promise so two concurrent channel sessions never race on the same daily stream file. |
|
|
33
|
+
| Subagent | `dreaming` | Reads `MEMORY.md` plus undreamed daily-stream tails, rewrites `MEMORY.md`, optionally writes muscle-memory skills under `memory/skills/<name>/SKILL.md`, advances the per-day watermark, and commits the result with a summary message (`dream: <summary> <emoji>`, e.g. `dream: 3 fragments + new skill 'pr-review' 🔮`). Coalesced per `agentDir`. |
|
|
34
|
+
| Cron job | `__plugin_memory_dreaming` | `kind: 'prompt'`, `subagent: 'dreaming'`, scheduled per `memory.dreaming.schedule`. |
|
|
35
|
+
| Hook | `session.idle` | Per-session debouncer with size-based ceiling. Resets a `setTimeout(idleMs)` on every event; on fire, calls `ctx.spawnSubagent('memory-logger', ...)`. Also `fs.stat`s the transcript on every event and spawns immediately when growth since the last run reaches `bufferBytes`. |
|
|
36
|
+
| Hook | `session.end` | Cancels the debounce timer and immediately spawns `memory-logger` (so the final transcript is captured even when the user disconnects right away). |
|
|
37
|
+
|
|
38
|
+
## Memory injection
|
|
39
|
+
|
|
40
|
+
The rendered `# Memory` section (MEMORY.md + undreamed daily-stream tails) is injected into every session's system prompt by core (`src/agent/index.ts` `createResourceLoader` → `loadMemory`), **not** by a plugin hook. It is appended as the last block of the system prompt, after `gitNudge`, so the most-volatile content (daily streams that grow after every memory-logger fire) sits at the bottom of the cache-suffix region. This way a memory change only invalidates the memory section itself rather than everything downstream of it.
|
|
38
41
|
|
|
39
42
|
## Files on disk
|
|
40
43
|
|
|
41
44
|
- **`MEMORY.md`** — long-term memory. Created by the dreaming subagent on first run if absent. Force-committed by the runtime; `skip-worktree` flag is set so the human's `git status` stays clean.
|
|
42
|
-
- **`memory/yyyy-MM-dd.
|
|
45
|
+
- **`memory/yyyy-MM-dd.jsonl`** — daily fragment streams. One event per line, discriminated union of `fragment | watermark | legacy_prose`, lossy-preserving one-shot migration from older `.md` streams. Appended to by `memory-logger`. Created on demand. Gitignored at the agent's level but force-committed alongside `MEMORY.md` after each dreaming run.
|
|
43
46
|
- **`memory/skills/<name>/SKILL.md`** — _muscle memory_. Skills the dreaming subagent distills from repeated procedures it sees in daily streams. Auto-discovered as first-class skills by `createResourceLoader`, and force-committed under the same `memory/` snapshot path as the daily streams. Written or refined with the standard `write` / `edit` tools; the bundled guard plugin enforces the exact `memory/skills/<name>/SKILL.md` path shape, single-segment kebab/snake-case names, matching frontmatter, and symlink/path-traversal safety. There is no runtime skill-delete tool; outright deletion of muscle-memory skills remains a user decision.
|
|
44
47
|
- **`memory/.dreaming-state.json`** — per-day watermarks (line counts already consolidated into `MEMORY.md`). Plain JSON; on malformed input the plugin fails open with empty state.
|
|
45
48
|
|
|
@@ -1,84 +1,110 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { mkdir } from 'node:fs/promises'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
3
4
|
|
|
4
5
|
import { z } from 'zod'
|
|
5
6
|
|
|
6
7
|
import { defineTool } from '@/plugin'
|
|
8
|
+
import { formatLocalDate } from '@/shared'
|
|
7
9
|
|
|
8
|
-
import { fragmentContentHash
|
|
10
|
+
import { fragmentContentHash } from './fragment-parser'
|
|
9
11
|
import { detectSecrets } from './secret-detector'
|
|
10
|
-
|
|
11
|
-
|
|
12
|
+
import type { FragmentEvent, WatermarkEvent } from './stream-events'
|
|
13
|
+
import { appendEvents, readEvents } from './stream-io'
|
|
12
14
|
|
|
13
15
|
export const appendTool = defineTool({
|
|
14
16
|
description:
|
|
15
|
-
|
|
17
|
+
"Append a memory fragment to today's JSONL daily stream and advance the watermark. The runtime serializes your call into a JSON line and chooses the filename — do not emit raw JSON and do not pass a path. `topic`/`body` are the fragment's substance; `source` is the parent session id; `entry` is the transcript-entry-id this fragment anchors to; `latestEntryId` is the latest transcript-entry-id you evaluated in this run (advances the watermark, may equal `entry` or be later). Refuses content with recognized credential patterns and refuses byte-equivalent topic+body within the same daily stream.",
|
|
16
18
|
parameters: z.object({
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
topic: z.string().min(1),
|
|
20
|
+
body: z.string().min(1),
|
|
21
|
+
source: z.string().min(1),
|
|
22
|
+
entry: z.string().min(1),
|
|
23
|
+
latestEntryId: z.string().min(1),
|
|
19
24
|
}),
|
|
20
|
-
async execute({
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
25
|
+
async execute({ topic, body, source, entry, latestEntryId }, ctx) {
|
|
26
|
+
const streamPath = dailyStreamPath(ctx.agentDir)
|
|
27
|
+
assertNoSecrets(`${topic}\n${body}`)
|
|
28
|
+
|
|
29
|
+
const hash = fragmentContentHash({ topic, body })
|
|
30
|
+
const events = await readEvents(streamPath)
|
|
31
|
+
const duplicate = events
|
|
32
|
+
.filter((event) => event.type === 'fragment')
|
|
33
|
+
.find((event) => fragmentContentHash(event) === hash)
|
|
34
|
+
if (duplicate !== undefined) {
|
|
24
35
|
throw new Error(
|
|
25
|
-
`Refusing to append:
|
|
26
|
-
`
|
|
27
|
-
`
|
|
36
|
+
`Refusing to append: fragment "${duplicate.topic}" already exists in ${streamPath} with byte-equivalent content. ` +
|
|
37
|
+
`The dreaming subagent will see the existing fragment; do not write it again. If the new occurrence ` +
|
|
38
|
+
`is genuinely informative, write a fragment that says so explicitly rather than restating the original.`,
|
|
28
39
|
)
|
|
29
40
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
|
|
42
|
+
const fragment: FragmentEvent = {
|
|
43
|
+
type: 'fragment',
|
|
44
|
+
id: randomUUID(),
|
|
45
|
+
ts: new Date().toISOString(),
|
|
46
|
+
source,
|
|
47
|
+
entry,
|
|
48
|
+
topic,
|
|
49
|
+
body,
|
|
50
|
+
}
|
|
51
|
+
const watermark: WatermarkEvent = {
|
|
52
|
+
type: 'watermark',
|
|
53
|
+
id: randomUUID(),
|
|
54
|
+
ts: new Date().toISOString(),
|
|
55
|
+
source,
|
|
56
|
+
entry: latestEntryId,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
await mkdir(dirname(streamPath), { recursive: true })
|
|
60
|
+
await appendEvents(streamPath, [fragment, watermark])
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
content: [{ type: 'text' as const, text: `Appended memory fragment and watermark to ${streamPath}` }],
|
|
64
|
+
details: { path: streamPath, fragmentId: fragment.id, watermarkId: watermark.id },
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
export const advanceWatermarkTool = defineTool({
|
|
70
|
+
description:
|
|
71
|
+
'Advance the daily-stream watermark without writing a fragment. Use this when you evaluated transcript entries this run but decided none warranted a fragment — still call this once so the next run does not re-read the same prefix. The runtime writes the watermark line and chooses the filename.',
|
|
72
|
+
parameters: z.object({
|
|
73
|
+
source: z.string().min(1),
|
|
74
|
+
latestEntryId: z.string().min(1),
|
|
75
|
+
}),
|
|
76
|
+
async execute({ source, latestEntryId }, ctx) {
|
|
77
|
+
const streamPath = dailyStreamPath(ctx.agentDir)
|
|
78
|
+
const watermark: WatermarkEvent = {
|
|
79
|
+
type: 'watermark',
|
|
80
|
+
id: randomUUID(),
|
|
81
|
+
ts: new Date().toISOString(),
|
|
82
|
+
source,
|
|
83
|
+
entry: latestEntryId,
|
|
44
84
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
await
|
|
48
|
-
|
|
85
|
+
|
|
86
|
+
await mkdir(dirname(streamPath), { recursive: true })
|
|
87
|
+
await appendEvents(streamPath, [watermark])
|
|
88
|
+
|
|
49
89
|
return {
|
|
50
|
-
content: [{ type: 'text' as const, text: `
|
|
51
|
-
details: { path
|
|
90
|
+
content: [{ type: 'text' as const, text: `Advanced memory watermark in ${streamPath}` }],
|
|
91
|
+
details: { path: streamPath, watermarkId: watermark.id },
|
|
52
92
|
}
|
|
53
93
|
},
|
|
54
94
|
})
|
|
55
95
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
try {
|
|
59
|
-
content = await readFile(path, 'utf8')
|
|
60
|
-
} catch (err) {
|
|
61
|
-
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return new Set()
|
|
62
|
-
throw err
|
|
63
|
-
}
|
|
64
|
-
return new Set(parseFragments(content).map((f) => fragmentContentHash(f)))
|
|
96
|
+
function dailyStreamPath(agentDir: string): string {
|
|
97
|
+
return join(agentDir, 'memory', `${formatLocalDate()}.jsonl`)
|
|
65
98
|
}
|
|
66
99
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
try {
|
|
78
|
-
const buf = Buffer.alloc(1)
|
|
79
|
-
await fh.read(buf, 0, 1, info.size - 1)
|
|
80
|
-
return buf[0] !== NEWLINE_BYTE
|
|
81
|
-
} finally {
|
|
82
|
-
await fh.close()
|
|
83
|
-
}
|
|
100
|
+
function assertNoSecrets(content: string): void {
|
|
101
|
+
const secrets = detectSecrets(content)
|
|
102
|
+
if (secrets.length === 0) return
|
|
103
|
+
|
|
104
|
+
const ruleNames = [...new Set(secrets.map((s) => s.rule))].join(', ')
|
|
105
|
+
throw new Error(
|
|
106
|
+
`Refusing to append: content contains a recognized credential pattern (${ruleNames}). ` +
|
|
107
|
+
`Memory fragments must never quote secret values verbatim. Record the env var name and how it ` +
|
|
108
|
+
`was discovered, not the value itself.`,
|
|
109
|
+
)
|
|
84
110
|
}
|