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.
Files changed (134) hide show
  1. package/README.md +15 -13
  2. package/auth.schema.json +41 -0
  3. package/cron.schema.json +8 -0
  4. package/package.json +1 -1
  5. package/secrets.schema.json +41 -0
  6. package/src/agent/auth.ts +45 -22
  7. package/src/agent/index.ts +189 -19
  8. package/src/agent/multimodal/index.ts +12 -0
  9. package/src/agent/multimodal/look-at.ts +185 -0
  10. package/src/agent/multimodal/looker.ts +145 -0
  11. package/src/agent/plugin-tools.ts +30 -1
  12. package/src/agent/session-origin.ts +194 -46
  13. package/src/agent/subagents.ts +57 -1
  14. package/src/agent/system-prompt.ts +1 -1
  15. package/src/agent/tool-result-budget.ts +121 -0
  16. package/src/bundled-plugins/backup/index.ts +23 -8
  17. package/src/bundled-plugins/backup/runner.ts +22 -0
  18. package/src/bundled-plugins/memory/README.md +13 -10
  19. package/src/bundled-plugins/memory/append-tool.ts +87 -61
  20. package/src/bundled-plugins/memory/dreaming.ts +137 -7
  21. package/src/bundled-plugins/memory/find-entry-tool.ts +62 -0
  22. package/src/bundled-plugins/memory/fragment-parser.ts +19 -44
  23. package/src/bundled-plugins/memory/index.ts +91 -8
  24. package/src/bundled-plugins/memory/load-memory.ts +74 -34
  25. package/src/bundled-plugins/memory/memory-logger.ts +72 -29
  26. package/src/bundled-plugins/memory/migration.ts +276 -0
  27. package/src/bundled-plugins/memory/stream-events.ts +55 -0
  28. package/src/bundled-plugins/memory/stream-io.ts +63 -0
  29. package/src/bundled-plugins/memory/watermark.ts +48 -8
  30. package/src/bundled-plugins/security/index.ts +103 -10
  31. package/src/bundled-plugins/security/permissions.ts +12 -0
  32. package/src/bundled-plugins/security/policies/git-exfil.ts +51 -18
  33. package/src/bundled-plugins/tool-result-cap/README.md +9 -4
  34. package/src/bundled-plugins/tool-result-cap/cap-jsonl.ts +115 -0
  35. package/src/bundled-plugins/tool-result-cap/cap-result.ts +25 -13
  36. package/src/bundled-plugins/tool-result-cap/index.ts +16 -2
  37. package/src/channels/adapters/discord-bot-classify.ts +2 -6
  38. package/src/channels/adapters/discord-bot.ts +4 -45
  39. package/src/channels/adapters/kakaotalk-classify.ts +3 -7
  40. package/src/channels/adapters/kakaotalk.ts +28 -47
  41. package/src/channels/adapters/slack-bot-classify.ts +2 -6
  42. package/src/channels/adapters/slack-bot.ts +4 -50
  43. package/src/channels/adapters/telegram-bot-classify.ts +8 -10
  44. package/src/channels/adapters/telegram-bot.ts +3 -16
  45. package/src/channels/index.ts +3 -2
  46. package/src/channels/manager.ts +15 -1
  47. package/src/channels/persistence.ts +44 -10
  48. package/src/channels/router.ts +228 -19
  49. package/src/channels/schema.ts +6 -156
  50. package/src/cli/channel.ts +200 -4
  51. package/src/cli/compose-usage.ts +182 -0
  52. package/src/cli/compose.ts +33 -0
  53. package/src/cli/hostd.ts +49 -1
  54. package/src/cli/index.ts +4 -0
  55. package/src/cli/init.ts +809 -300
  56. package/src/cli/model.ts +244 -0
  57. package/src/cli/provider.ts +404 -0
  58. package/src/cli/reload.ts +11 -3
  59. package/src/cli/role.ts +156 -0
  60. package/src/cli/run.ts +3 -1
  61. package/src/cli/tui.ts +13 -3
  62. package/src/cli/usage-args.ts +47 -0
  63. package/src/cli/usage.ts +97 -0
  64. package/src/compose/index.ts +1 -0
  65. package/src/compose/usage.ts +65 -0
  66. package/src/config/config.ts +491 -19
  67. package/src/config/index.ts +15 -1
  68. package/src/config/models-mutation.ts +200 -0
  69. package/src/config/providers-mutation.ts +250 -0
  70. package/src/config/providers.ts +141 -2
  71. package/src/config/reloadable.ts +15 -4
  72. package/src/container/index.ts +6 -1
  73. package/src/container/port.ts +10 -0
  74. package/src/container/require-running.ts +33 -0
  75. package/src/container/start.ts +81 -63
  76. package/src/cron/consumer.ts +22 -2
  77. package/src/cron/index.ts +45 -4
  78. package/src/cron/schema.ts +104 -0
  79. package/src/doctor/checks.ts +51 -34
  80. package/src/doctor/plugin-bridge.ts +28 -4
  81. package/src/git/system-commit.ts +103 -0
  82. package/src/hostd/daemon.ts +16 -0
  83. package/src/hostd/kakao-renewal-manager.ts +223 -0
  84. package/src/hostd/paths.ts +7 -0
  85. package/src/init/dockerfile.ts +36 -10
  86. package/src/init/gitignore.ts +1 -1
  87. package/src/init/index.ts +213 -85
  88. package/src/init/kakaotalk-auth.ts +18 -1
  89. package/src/init/models-dev.ts +26 -1
  90. package/src/init/run-owner-claim.ts +77 -0
  91. package/src/permissions/builtins.ts +70 -0
  92. package/src/permissions/grant.ts +99 -0
  93. package/src/permissions/index.ts +29 -0
  94. package/src/permissions/match-rule.ts +305 -0
  95. package/src/permissions/permissions.ts +196 -0
  96. package/src/permissions/resolve.ts +80 -0
  97. package/src/permissions/schema.ts +79 -0
  98. package/src/plugin/context.ts +8 -4
  99. package/src/plugin/define.ts +2 -0
  100. package/src/plugin/index.ts +2 -0
  101. package/src/plugin/manager.ts +41 -0
  102. package/src/plugin/registry.ts +9 -0
  103. package/src/plugin/types.ts +35 -1
  104. package/src/reload/client.ts +25 -1
  105. package/src/role-claim/client.ts +182 -0
  106. package/src/role-claim/code.ts +53 -0
  107. package/src/role-claim/controller.ts +194 -0
  108. package/src/role-claim/index.ts +19 -0
  109. package/src/role-claim/match-rule.ts +43 -0
  110. package/src/role-claim/pending.ts +100 -0
  111. package/src/run/channel-session-factory.ts +76 -5
  112. package/src/run/index.ts +68 -7
  113. package/src/secrets/encryption.ts +116 -0
  114. package/src/secrets/kakao-renewal.ts +248 -0
  115. package/src/secrets/kakao-store.ts +66 -7
  116. package/src/secrets/keys.ts +173 -0
  117. package/src/secrets/schema.ts +23 -0
  118. package/src/secrets/storage.ts +83 -0
  119. package/src/server/index.ts +198 -71
  120. package/src/shared/index.ts +4 -0
  121. package/src/shared/protocol.ts +27 -0
  122. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
  123. package/src/skills/typeclaw-config/SKILL.md +104 -112
  124. package/src/skills/typeclaw-memory/SKILL.md +9 -9
  125. package/src/skills/typeclaw-permissions/SKILL.md +166 -0
  126. package/src/stream/types.ts +7 -1
  127. package/src/tui/client.ts +66 -5
  128. package/src/tui/index.ts +61 -9
  129. package/src/usage/aggregate.ts +117 -0
  130. package/src/usage/format.ts +30 -0
  131. package/src/usage/index.ts +68 -0
  132. package/src/usage/report.ts +354 -0
  133. package/src/usage/scan.ts +186 -0
  134. package/typeclaw.schema.json +134 -98
@@ -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.md\`) 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.
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(SUBAGENT_BACKUP_RUNNER, {
82
- agentDir: ctx.agentDir,
83
- pushToOrigin,
84
- } satisfies RunnerPayload)
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: (name: string, payload?: unknown) => Promise<void>
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.md` (daily streams) plus the two subagents that write them: `memory-logger` and `dreaming`.
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>.md`. 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 `git commit -m Dream` the result. Coalesced per `agentDir`. |
34
- | Cron job | `__plugin_memory_dreaming` | `kind: 'prompt'`, `subagent: 'dreaming'`, scheduled per `memory.dreaming.schedule`. |
35
- | Hook | `session.prompt` | Appends the rendered memory section (`# Memory`, `MEMORY.md`, undreamed stream tails) to `event.prompt`. |
36
- | 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`. |
37
- | 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). |
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.md`** — daily fragment 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.
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 { appendFile, mkdir, open, readFile, stat } from 'node:fs/promises'
2
- import { dirname } from 'node:path'
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, parseFragments } from './fragment-parser'
10
+ import { fragmentContentHash } from './fragment-parser'
9
11
  import { detectSecrets } from './secret-detector'
10
-
11
- const NEWLINE_BYTE = 0x0a
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
- 'Append content to a file. Creates the file (and any missing parent directories) if needed. Never truncates or overwrites existing content. If the file is non-empty and does not already end in a newline, a single newline is inserted before the appended content so consecutive appends do not run together. Refuses to write content that contains recognizable credential patterns (API keys, tokens, private keys); record the variable name and how it was discovered, never the value. Refuses to append a fragment whose topic+body already exists in the file (case-by-case; topics legitimately repeat across days, but byte-equivalent fragments within the same daily stream are duplicates by design).',
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
- path: z.string().describe('Path to the file to append to (relative or absolute).'),
18
- content: z.string().describe('Content to append, exactly as given.'),
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({ path, content }) {
21
- const secrets = detectSecrets(content)
22
- if (secrets.length > 0) {
23
- const ruleNames = [...new Set(secrets.map((s) => s.rule))].join(', ')
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: content contains a recognized credential pattern (${ruleNames}). ` +
26
- `Memory fragments must never quote secret values verbatim. Record the env var name and how it ` +
27
- `was discovered, not the value itself.`,
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
- const incomingFragments = parseFragments(content)
31
- if (incomingFragments.length > 0) {
32
- const existingHashes = await readExistingFragmentHashes(path)
33
- const duplicates = incomingFragments.filter((f) => existingHashes.has(fragmentContentHash(f)))
34
- if (duplicates.length > 0) {
35
- const topics = duplicates.map((d) => `"${d.topic}"`).join(', ')
36
- throw new Error(
37
- `Refusing to append: ${duplicates.length} fragment${duplicates.length === 1 ? '' : 's'} (${topics}) ` +
38
- `already exist in ${path} with byte-equivalent content. The dreaming subagent will see the existing ` +
39
- `fragment; do not write it again. If the new occurrence is genuinely informative (e.g. a recurrence ` +
40
- `that establishes a pattern), write a fragment that says so explicitly rather than restating the ` +
41
- `original.`,
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
- await mkdir(dirname(path), { recursive: true })
46
- const prefix = (await needsLeadingNewline(path)) ? '\n' : ''
47
- await appendFile(path, prefix + content, 'utf-8')
48
- const bytesAppended = prefix.length + content.length
85
+
86
+ await mkdir(dirname(streamPath), { recursive: true })
87
+ await appendEvents(streamPath, [watermark])
88
+
49
89
  return {
50
- content: [{ type: 'text' as const, text: `Appended ${bytesAppended} bytes to ${path}` }],
51
- details: { path, bytesAppended, leadingNewlineInserted: prefix.length > 0 },
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
- async function readExistingFragmentHashes(path: string): Promise<Set<string>> {
57
- let content: string
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
- async function needsLeadingNewline(path: string): Promise<boolean> {
68
- let info: Awaited<ReturnType<typeof stat>>
69
- try {
70
- info = await stat(path)
71
- } catch (err) {
72
- if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false
73
- throw err
74
- }
75
- if (info.size === 0) return false
76
- const fh = await open(path, 'r')
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
  }