typeclaw 0.1.2 → 0.1.4

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 (46) hide show
  1. package/README.md +4 -0
  2. package/auth.schema.json +238 -7
  3. package/package.json +1 -1
  4. package/secrets.schema.json +238 -7
  5. package/src/agent/auth.ts +19 -38
  6. package/src/agent/tools/channel-fetch-attachment.ts +6 -0
  7. package/src/agent/tools/channel-history.ts +10 -1
  8. package/src/agent/tools/channel-log.ts +32 -0
  9. package/src/agent/tools/channel-reply.ts +18 -1
  10. package/src/agent/tools/channel-send.ts +13 -1
  11. package/src/bundled-plugins/tool-result-cap/README.md +67 -0
  12. package/src/bundled-plugins/tool-result-cap/cap-result.ts +56 -0
  13. package/src/bundled-plugins/tool-result-cap/index.ts +51 -0
  14. package/src/channels/adapters/kakaotalk.ts +25 -16
  15. package/src/channels/manager.ts +47 -38
  16. package/src/cli/channel.ts +3 -3
  17. package/src/cli/index.ts +3 -0
  18. package/src/cli/init.ts +2 -1
  19. package/src/cli/ui.ts +11 -0
  20. package/src/config/config.ts +61 -4
  21. package/src/container/index.ts +2 -0
  22. package/src/container/start.ts +98 -2
  23. package/src/doctor/checks.ts +7 -27
  24. package/src/doctor/commit.ts +44 -3
  25. package/src/doctor/plugin-bridge.ts +19 -0
  26. package/src/hostd/daemon.ts +28 -3
  27. package/src/hostd/protocol.ts +7 -0
  28. package/src/init/auto-upgrade.ts +368 -0
  29. package/src/init/dockerfile.ts +83 -14
  30. package/src/init/index.ts +123 -77
  31. package/src/init/kakaotalk-auth.ts +9 -3
  32. package/src/init/run-bun-install.ts +34 -0
  33. package/src/run/bundled-plugins.ts +7 -0
  34. package/src/run/index.ts +9 -0
  35. package/src/secrets/defaults.ts +67 -0
  36. package/src/secrets/hydrate.ts +99 -0
  37. package/src/secrets/index.ts +6 -12
  38. package/src/secrets/kakao-store.ts +129 -0
  39. package/src/secrets/migrate-kakaotalk.ts +82 -0
  40. package/src/secrets/migrate.ts +5 -4
  41. package/src/secrets/resolve.ts +57 -0
  42. package/src/secrets/schema.ts +162 -42
  43. package/src/secrets/storage.ts +253 -47
  44. package/src/skills/typeclaw-config/SKILL.md +47 -8
  45. package/typeclaw.schema.json +49 -2
  46. package/src/secrets/env.ts +0 -43
@@ -7,6 +7,8 @@ import { defineTool } from '@mariozechner/pi-coding-agent'
7
7
  import type { ChannelRouter } from '@/channels/router'
8
8
  import type { AdapterId } from '@/channels/schema'
9
9
 
10
+ import { type ChannelToolLogger, consoleChannelLogger, formatChannelToolFailure } from './channel-log'
11
+
10
12
  export type ChannelFetchAttachmentOrigin = {
11
13
  adapter: AdapterId
12
14
  }
@@ -15,6 +17,7 @@ export type CreateChannelFetchAttachmentToolOptions = {
15
17
  router: ChannelRouter
16
18
  origin: ChannelFetchAttachmentOrigin
17
19
  inboxDir?: string
20
+ logger?: ChannelToolLogger
18
21
  }
19
22
 
20
23
  export const DEFAULT_INBOX_DIR = '/agent/workspace/inbox'
@@ -23,6 +26,7 @@ export function createChannelFetchAttachmentTool({
23
26
  router,
24
27
  origin,
25
28
  inboxDir,
29
+ logger = consoleChannelLogger,
26
30
  }: CreateChannelFetchAttachmentToolOptions) {
27
31
  const baseDir = inboxDir ?? DEFAULT_INBOX_DIR
28
32
  const adapter = origin.adapter
@@ -60,6 +64,7 @@ export function createChannelFetchAttachmentTool({
60
64
  ...(params.filename !== undefined ? { filename: params.filename } : {}),
61
65
  })
62
66
  if (!result.ok) {
67
+ logger.warn(formatChannelToolFailure('channel_fetch_attachment', `${adapter}: ${result.error}`))
63
68
  const text = `channel_fetch_attachment error: ${result.error}`
64
69
  const details: Details = { ok: false, error: result.error }
65
70
  return { content: [{ type: 'text' as const, text }], details }
@@ -74,6 +79,7 @@ export function createChannelFetchAttachmentTool({
74
79
  await writeFile(targetPath, result.buffer)
75
80
  } catch (err) {
76
81
  const message = err instanceof Error ? err.message : String(err)
82
+ logger.warn(formatChannelToolFailure('channel_fetch_attachment', `${adapter}: write failed: ${message}`))
77
83
  const text = `channel_fetch_attachment error: write failed: ${message}`
78
84
  const details: Details = { ok: false, error: `write failed: ${message}` }
79
85
  return { content: [{ type: 'text' as const, text }], details }
@@ -5,6 +5,8 @@ import type { ChannelRouter } from '@/channels/router'
5
5
  import type { AdapterId } from '@/channels/schema'
6
6
  import type { ChannelHistoryMessage } from '@/channels/types'
7
7
 
8
+ import { type ChannelToolLogger, consoleChannelLogger, formatChannelToolFailure } from './channel-log'
9
+
8
10
  export type ChannelHistoryOrigin = {
9
11
  adapter: AdapterId
10
12
  workspace: string
@@ -15,6 +17,7 @@ export type ChannelHistoryOrigin = {
15
17
  export type CreateChannelHistoryToolOptions = {
16
18
  router: ChannelRouter
17
19
  origin: ChannelHistoryOrigin
20
+ logger?: ChannelToolLogger
18
21
  }
19
22
 
20
23
  // channel_history is a lazy "look back" capability for channel-routed
@@ -27,7 +30,11 @@ export type CreateChannelHistoryToolOptions = {
27
30
  // `scope` defaults to thread when the origin has one, channel otherwise.
28
31
  // Thread scope on a channel-root session is rejected rather than silently
29
32
  // downgraded so the agent doesn't conflate the two views.
30
- export function createChannelHistoryTool({ router, origin }: CreateChannelHistoryToolOptions) {
33
+ export function createChannelHistoryTool({
34
+ router,
35
+ origin,
36
+ logger = consoleChannelLogger,
37
+ }: CreateChannelHistoryToolOptions) {
31
38
  return defineTool({
32
39
  name: 'channel_history',
33
40
  label: 'Channel History',
@@ -64,6 +71,7 @@ export function createChannelHistoryTool({ router, origin }: CreateChannelHistor
64
71
  type Details = { ok: boolean; error?: string; count?: number; nextCursor?: string }
65
72
 
66
73
  if (scope === 'thread' && origin.thread === null) {
74
+ logger.warn(formatChannelToolFailure('channel_history', 'thread-scope-requires-thread-session'))
67
75
  const text =
68
76
  'channel_history error: thread-scope-requires-thread-session — this session is not in a thread; pass `scope: "channel"` instead.'
69
77
  const details: Details = { ok: false, error: 'thread-scope-requires-thread-session' }
@@ -78,6 +86,7 @@ export function createChannelHistoryTool({ router, origin }: CreateChannelHistor
78
86
  })
79
87
 
80
88
  if (!result.ok) {
89
+ logger.warn(formatChannelToolFailure('channel_history', `${origin.adapter}:${origin.chat}: ${result.error}`))
81
90
  const details: Details = { ok: false, error: result.error }
82
91
  return {
83
92
  content: [{ type: 'text' as const, text: `channel_history error: ${result.error}` }],
@@ -0,0 +1,32 @@
1
+ // Shared logger surface for the channel_* agent tools.
2
+ //
3
+ // Until now, channel_send / channel_reply / channel_history /
4
+ // channel_fetch_attachment swallowed every failure into the model-visible
5
+ // tool result and emitted nothing to the container's stdout/stderr. That
6
+ // made operator-side debugging blind: a Slack send that 403'd, a
7
+ // `thread-scope-requires-thread-session` denial, or a Discord attachment
8
+ // fetch that timed out left no trace in `typeclaw logs`. The router layer
9
+ // logs some of these (e.g. `fetchHistory` warns on caught exceptions) but
10
+ // does NOT log `router.send` rejections, and pre-router validation errors
11
+ // inside the tools (missing text, NO_REPLY misuse, thread-scope mismatch,
12
+ // local write failures) never reached the router in the first place.
13
+ //
14
+ // One injectable logger per tool keeps the existing fake-router test
15
+ // pattern intact: tests pass an array-collecting logger to assert the log
16
+ // line, production code defaults to `consoleChannelLogger` which routes to
17
+ // `console.warn` so it lands in `typeclaw logs` alongside the existing
18
+ // `[channels]` lines from manager.ts / router.ts.
19
+ export type ChannelToolLogger = {
20
+ warn: (msg: string) => void
21
+ }
22
+
23
+ export const consoleChannelLogger: ChannelToolLogger = {
24
+ warn: (m) => console.warn(m),
25
+ }
26
+
27
+ // Format a failure log line. Keeps the `[channels]` prefix used by
28
+ // manager.ts and router.ts so operators can `grep '\[channels\]'` and see
29
+ // the full stack of channel-related warnings in one pass.
30
+ export function formatChannelToolFailure(tool: string, error: string): string {
31
+ return `[channels] ${tool} failed: ${error}`
32
+ }
@@ -4,6 +4,8 @@ import { defineTool } from '@mariozechner/pi-coding-agent'
4
4
  import { isNoReplySignal, type ChannelRouter } from '@/channels/router'
5
5
  import type { AdapterId } from '@/channels/schema'
6
6
 
7
+ import { type ChannelToolLogger, consoleChannelLogger, formatChannelToolFailure } from './channel-log'
8
+
7
9
  export type ChannelReplyOrigin = {
8
10
  adapter: AdapterId
9
11
  workspace: string
@@ -14,6 +16,7 @@ export type ChannelReplyOrigin = {
14
16
  export type CreateChannelReplyToolOptions = {
15
17
  router: ChannelRouter
16
18
  origin: ChannelReplyOrigin
19
+ logger?: ChannelToolLogger
17
20
  }
18
21
 
19
22
  // channel_reply is the happy-path companion to channel_send for channel-routed
@@ -25,7 +28,11 @@ export type CreateChannelReplyToolOptions = {
25
28
  // channel_reply takes only `text` and addresses the message from the origin.
26
29
  // channel_send remains for posting somewhere else (different chat, breaking
27
30
  // out of a thread, sending DMs from a channel session, etc.).
28
- export function createChannelReplyTool({ router, origin }: CreateChannelReplyToolOptions) {
31
+ export function createChannelReplyTool({
32
+ router,
33
+ origin,
34
+ logger = consoleChannelLogger,
35
+ }: CreateChannelReplyToolOptions) {
29
36
  return defineTool({
30
37
  name: 'channel_reply',
31
38
  label: 'Channel Reply',
@@ -64,6 +71,7 @@ export function createChannelReplyTool({ router, origin }: CreateChannelReplyToo
64
71
  const text = params.text
65
72
  const attachments = params.attachments
66
73
  if ((text === undefined || text === '') && (attachments === undefined || attachments.length === 0)) {
74
+ logger.warn(formatChannelToolFailure('channel_reply', 'missing text and attachments'))
67
75
  return {
68
76
  content: [
69
77
  { type: 'text' as const, text: 'channel_reply denied: must provide `text`, `attachments`, or both.' },
@@ -74,6 +82,7 @@ export function createChannelReplyTool({ router, origin }: CreateChannelReplyToo
74
82
 
75
83
  const noReplyError = noReplyMisuseError(text)
76
84
  if (noReplyError) {
85
+ logger.warn(formatChannelToolFailure('channel_reply', noReplyError))
77
86
  return {
78
87
  content: [{ type: 'text' as const, text: `channel_reply denied: ${noReplyError}` }],
79
88
  details: { ok: false, error: noReplyError },
@@ -89,6 +98,14 @@ export function createChannelReplyTool({ router, origin }: CreateChannelReplyToo
89
98
  ...(attachments !== undefined ? { attachments } : {}),
90
99
  })
91
100
 
101
+ if (!result.ok) {
102
+ logger.warn(
103
+ formatChannelToolFailure(
104
+ 'channel_reply',
105
+ `${origin.adapter}:${origin.workspace}/${origin.chat}: ${result.error}`,
106
+ ),
107
+ )
108
+ }
92
109
  const details: { ok: boolean; error?: string } = result.ok ? { ok: true } : { ok: false, error: result.error }
93
110
  // Echo the delivered text back to the model. The adapter classifier
94
111
  // drops self-authored messages on the inbound path (`self_author`),
@@ -4,6 +4,7 @@ import { defineTool } from '@mariozechner/pi-coding-agent'
4
4
  import { isNoReplySignal, type ChannelRouter } from '@/channels/router'
5
5
  import { ADAPTER_IDS, type AdapterId } from '@/channels/schema'
6
6
 
7
+ import { type ChannelToolLogger, consoleChannelLogger, formatChannelToolFailure } from './channel-log'
7
8
  import { renderOutboundEcho } from './channel-reply'
8
9
 
9
10
  export type ChannelSendOrigin = {
@@ -21,9 +22,10 @@ export type CreateChannelSendToolOptions = {
21
22
  // the model can self-correct on its next turn. Absent for sessions whose
22
23
  // origin isn't a channel (e.g. cron prompts that send to channels).
23
24
  origin?: ChannelSendOrigin
25
+ logger?: ChannelToolLogger
24
26
  }
25
27
 
26
- export function createChannelSendTool({ router, origin }: CreateChannelSendToolOptions) {
28
+ export function createChannelSendTool({ router, origin, logger = consoleChannelLogger }: CreateChannelSendToolOptions) {
27
29
  return defineTool({
28
30
  name: 'channel_send',
29
31
  label: 'Channel Send',
@@ -93,6 +95,7 @@ export function createChannelSendTool({ router, origin }: CreateChannelSendToolO
93
95
  const bodyText = params.text
94
96
  const attachments = params.attachments
95
97
  if ((bodyText === undefined || bodyText === '') && (attachments === undefined || attachments.length === 0)) {
98
+ logger.warn(formatChannelToolFailure('channel_send', 'missing text and attachments'))
96
99
  return {
97
100
  content: [
98
101
  { type: 'text' as const, text: 'channel_send denied: must provide `text`, `attachments`, or both.' },
@@ -103,6 +106,7 @@ export function createChannelSendTool({ router, origin }: CreateChannelSendToolO
103
106
 
104
107
  const noReplyError = noReplyMisuseError(bodyText)
105
108
  if (noReplyError) {
109
+ logger.warn(formatChannelToolFailure('channel_send', noReplyError))
106
110
  return {
107
111
  content: [{ type: 'text' as const, text: `channel_send denied: ${noReplyError}` }],
108
112
  details: { ok: false, error: noReplyError },
@@ -118,6 +122,14 @@ export function createChannelSendTool({ router, origin }: CreateChannelSendToolO
118
122
  ...(attachments !== undefined ? { attachments } : {}),
119
123
  })
120
124
 
125
+ if (!result.ok) {
126
+ logger.warn(
127
+ formatChannelToolFailure(
128
+ 'channel_send',
129
+ `${params.adapter}:${params.workspace}/${params.chat}: ${result.error}`,
130
+ ),
131
+ )
132
+ }
121
133
  const details: { ok: boolean; error?: string } = result.ok ? { ok: true } : { ok: false, error: result.error }
122
134
  const echo = renderOutboundEcho(bodyText, attachments)
123
135
  const baseText = result.ok
@@ -0,0 +1,67 @@
1
+ # typeclaw-plugin-tool-result-cap
2
+
3
+ The bundled tool-result-cap plugin. Caps the size of `tool.after` results before they get persisted to the session JSONL, so a single oversized tool output cannot bloat the transcript and force the full payload to round-trip to the LLM on every subsequent turn.
4
+
5
+ This plugin is **auto-loaded** by every TypeClaw agent. There is no `plugins[]` entry to add and no opt-out short of `tool-result-cap.enabled: false`. To configure it, add a `tool-result-cap` block to `typeclaw.json`.
6
+
7
+ ## Why it exists
8
+
9
+ `pi-coding-agent`'s built-in tools occasionally return very large payloads that the model only needed once. Two empirically observed cases:
10
+
11
+ 1. **`read` on an image file** returns the base64-encoded image inline (e.g. `{type:"image", data:"<3.2MB of base64>"}`). The model uses it on the turn it was asked for, then sees the same 3.2MB of base64 as conversation context on every subsequent prompt — until compaction fires (which is token-driven, not byte-driven, so a single fat blob may sit in context for many turns before compaction is triggered).
12
+ 2. **`webfetch` on a binary URL** (PNG, ZIP, etc.) receives the raw response body, treats it as text, and stores raw binary as a JSON-encoded string. Same effect: 100KB+ of mojibake sits in the transcript permanently.
13
+
14
+ The result is a session JSONL file that's tens of megabytes on disk but mostly one or two giant tool results, plus 3-minute first-prompt latencies after container restart because the full transcript gets re-shipped to the LLM as context.
15
+
16
+ `tool-result-cap` registers a `tool.after` hook that inspects every tool's result and, in place, replaces oversized image/text parts with a short placeholder before pi-coding-agent appends the entry to the JSONL. The cap happens at the wire-format level, so the bloat never reaches disk or the LLM in the first place.
17
+
18
+ ## Config
19
+
20
+ ```json
21
+ {
22
+ "tool-result-cap": {
23
+ "enabled": true,
24
+ "imageMaxBytes": 262144,
25
+ "textMaxBytes": 65536,
26
+ "exemptTools": []
27
+ }
28
+ }
29
+ ```
30
+
31
+ | Field | Default | Effect |
32
+ | ------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
33
+ | `tool-result-cap.enabled` | `true` | Master switch. When `false`, the plugin returns no hooks at all and tool results pass through untouched. |
34
+ | `tool-result-cap.imageMaxBytes` | `262144` | Maximum size (in bytes of the base64 string, not the decoded binary) for any `{type:"image"}` part in a tool result. Parts above this are replaced with a short text placeholder naming the original mime type and size. Default is ~256KB of base64 ≈ ~190KB of binary. Minimum `1024`. |
35
+ | `tool-result-cap.textMaxBytes` | `65536` | Maximum length (in characters) for any `{type:"text"}` part. Parts above this are truncated: the first `textMaxBytes` characters are kept (so the LLM sees the shape of the output), and an elision marker is appended naming the byte count dropped. Minimum `1024`. |
36
+ | `tool-result-cap.exemptTools` | `[]` | List of tool names to skip entirely. Use when a specific tool genuinely needs to return large payloads and you can absorb the per-turn cost. |
37
+
38
+ All fields are **restart-required** — the plugin reads them once at boot.
39
+
40
+ ## How it works
41
+
42
+ The plugin registers a single `tool.after` hook. The hook receives `event.result: ToolResult` by reference, walks `result.content`, and replaces each `ContentPart` in place when it exceeds its corresponding threshold. Mutation order is unspecified across plugins, but because the wrapper in `src/agent/plugin-tools.ts` reads the same `hookResult.content` reference after the hook chain finishes, mutations are seen by pi-coding-agent and persisted to JSONL.
43
+
44
+ The cap is per-part, not per-result, so a result with one small text part and one giant image is partly preserved (small text untouched, image elided).
45
+
46
+ Placeholders carry the literal substring `tool-result-cap:` so future agents (or human operators inspecting a session) can grep for them and recognize that the original payload was intentionally elided rather than truncated by some other layer.
47
+
48
+ ## What's not capped
49
+
50
+ - `details` on tool results (an opaque structured payload provider-specific to each tool — generally small, and mutating it risks breaking tool-specific telemetry).
51
+ - Tool calls themselves (`assistant` messages with `toolUse` content). These are bounded by the LLM's own output limits.
52
+ - User messages and system prompts.
53
+
54
+ ## Ordering against other bundled plugins
55
+
56
+ Plugin hook order is the order plugins are listed in `src/run/bundled-plugins.ts`. `tool-result-cap` is registered **before** `guard` so guard's `tool.after` advice (the uncommitted-changes warning) appends to the already-capped content. This means a guard advice that fires on the same call sees a small text part and a placeholder, never the original oversized payload — keeping the advice text immediately legible in the JSONL.
57
+
58
+ ## What it contributes
59
+
60
+ | Kind | Name | Notes |
61
+ | ---- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------- |
62
+ | Hook | `tool.after` | Walks `event.result.content` and replaces oversized image/text parts in place. Logs one `info` line per capped call, silent otherwise. |
63
+
64
+ ## Tests
65
+
66
+ - `cap-result.test.ts` — pure-function unit tests for the capping logic (image replacement, text truncation, mixed parts, exempt tools, empty results, in-place mutation invariant).
67
+ - `index.test.ts` — composition tests (config schema defaults and validation, hook registration, disabled-mode short-circuit, logging on cap, silence on no-op).
@@ -0,0 +1,56 @@
1
+ import type { ContentPart, ToolResult } from '@/plugin'
2
+
3
+ export type CapOptions = {
4
+ imageMaxBytes: number
5
+ textMaxBytes: number
6
+ exemptTools: ReadonlySet<string>
7
+ }
8
+
9
+ export type CapStats = {
10
+ imagesReplaced: number
11
+ textsTruncated: number
12
+ bytesElided: number
13
+ }
14
+
15
+ // Sentinel marker used in both image and text replacement payloads so a future
16
+ // pass (or the LLM itself) can recognize that a value was capped rather than
17
+ // truthfully short. Plain English on purpose — these strings get fed to the
18
+ // model on every turn, and unfamiliar tokens cost reasoning bandwidth.
19
+ const ELIDED_MARKER = '[tool-result-cap: '
20
+
21
+ export function capToolResult(tool: string, result: ToolResult, options: CapOptions): CapStats {
22
+ const stats: CapStats = { imagesReplaced: 0, textsTruncated: 0, bytesElided: 0 }
23
+ if (options.exemptTools.has(tool)) return stats
24
+
25
+ for (let i = 0; i < result.content.length; i++) {
26
+ const part = result.content[i]
27
+ if (!part) continue
28
+ if (part.type === 'image') {
29
+ const size = part.data.length
30
+ if (size <= options.imageMaxBytes) continue
31
+ result.content[i] = {
32
+ type: 'text',
33
+ text: `${ELIDED_MARKER}image ${part.mimeType} elided, ${size} bytes of base64 exceeded imageMaxBytes=${options.imageMaxBytes}]`,
34
+ }
35
+ stats.imagesReplaced += 1
36
+ stats.bytesElided += size
37
+ continue
38
+ }
39
+ if (part.type === 'text') {
40
+ const size = part.text.length
41
+ if (size <= options.textMaxBytes) continue
42
+ // Keep a head slice of the original text so the LLM still has a hint of
43
+ // shape (e.g. "fetched HTML starts with <!DOCTYPE..."). Tail is dropped.
44
+ const head = part.text.slice(0, options.textMaxBytes)
45
+ const elided = size - options.textMaxBytes
46
+ const replacement: ContentPart = {
47
+ type: 'text',
48
+ text: `${head}\n\n${ELIDED_MARKER}${elided} bytes truncated from text part; original was ${size} bytes, textMaxBytes=${options.textMaxBytes}]`,
49
+ }
50
+ result.content[i] = replacement
51
+ stats.textsTruncated += 1
52
+ stats.bytesElided += elided
53
+ }
54
+ }
55
+ return stats
56
+ }
@@ -0,0 +1,51 @@
1
+ import { z } from 'zod'
2
+
3
+ import { definePlugin } from '@/plugin'
4
+
5
+ import { capToolResult } from './cap-result'
6
+
7
+ const DEFAULT_IMAGE_MAX_BYTES = 262_144
8
+ const DEFAULT_TEXT_MAX_BYTES = 65_536
9
+ const MIN_IMAGE_MAX_BYTES = 1_024
10
+ const MIN_TEXT_MAX_BYTES = 1_024
11
+
12
+ const toolResultCapConfigSchema = z
13
+ .object({
14
+ enabled: z.boolean().default(true),
15
+ imageMaxBytes: z.number().int().min(MIN_IMAGE_MAX_BYTES).default(DEFAULT_IMAGE_MAX_BYTES),
16
+ textMaxBytes: z.number().int().min(MIN_TEXT_MAX_BYTES).default(DEFAULT_TEXT_MAX_BYTES),
17
+ exemptTools: z.array(z.string()).default([]),
18
+ })
19
+ .default({
20
+ enabled: true,
21
+ imageMaxBytes: DEFAULT_IMAGE_MAX_BYTES,
22
+ textMaxBytes: DEFAULT_TEXT_MAX_BYTES,
23
+ exemptTools: [],
24
+ })
25
+
26
+ export default definePlugin({
27
+ configSchema: toolResultCapConfigSchema,
28
+ plugin: async (ctx) => {
29
+ const { enabled, imageMaxBytes, textMaxBytes, exemptTools } = ctx.config
30
+ if (!enabled) return {}
31
+
32
+ const options = {
33
+ imageMaxBytes,
34
+ textMaxBytes,
35
+ exemptTools: new Set(exemptTools),
36
+ }
37
+
38
+ return {
39
+ hooks: {
40
+ 'tool.after': (event) => {
41
+ const stats = capToolResult(event.tool, event.result, options)
42
+ if (stats.imagesReplaced > 0 || stats.textsTruncated > 0) {
43
+ ctx.logger.info(
44
+ `[tool-result-cap] capped ${event.tool} call=${event.callId}: imagesReplaced=${stats.imagesReplaced} textsTruncated=${stats.textsTruncated} bytesElided=${stats.bytesElided}`,
45
+ )
46
+ }
47
+ },
48
+ },
49
+ }
50
+ },
51
+ })
@@ -11,6 +11,7 @@ import {
11
11
  type KakaoTalkPushEmoticonEvent,
12
12
  type KakaoTalkPushMessageEvent,
13
13
  } from 'agent-messenger/kakaotalk'
14
+ import type { KakaoAccountCredentials, KakaoConfig, PendingLoginState } from 'agent-messenger/kakaotalk'
14
15
 
15
16
  import type { ChannelRouter } from '@/channels/router'
16
17
  import { isAllowed, type ChannelAdapterConfig, type KakaotalkAdapterConfig } from '@/channels/schema'
@@ -75,6 +76,19 @@ export interface KakaoTalkListener {
75
76
  ): this
76
77
  }
77
78
 
79
+ export type KakaoCredentialStore = {
80
+ load(): Promise<KakaoConfig>
81
+ save(config: KakaoConfig): Promise<void>
82
+ getAccount(id?: string): Promise<KakaoAccountCredentials | null>
83
+ setAccount(account: KakaoAccountCredentials): Promise<void>
84
+ removeAccount(id: string): Promise<void>
85
+ listAccounts(): Promise<Array<KakaoAccountCredentials & { is_current: boolean }>>
86
+ setCurrentAccount(id: string): Promise<void>
87
+ savePendingLogin(state: PendingLoginState): Promise<void>
88
+ loadPendingLogin(): Promise<PendingLoginState | null>
89
+ clearPendingLogin(): Promise<void>
90
+ }
91
+
78
92
  const KakaoTalkClient = RealKakaoTalkClient as unknown as new () => KakaoTalkClient
79
93
  const KakaoTalkListener = RealKakaoTalkListener as unknown as new (client: KakaoTalkClient) => KakaoTalkListener
80
94
 
@@ -95,13 +109,9 @@ export type KakaotalkAdapterOptions = {
95
109
  configRef: () => KakaotalkAdapterConfig
96
110
  logger?: KakaotalkAdapterLogger
97
111
  selfAliasesRef?: () => readonly string[]
98
- // When set, the adapter loads KakaoTalk credentials from this directory
99
- // (via KakaoCredentialManager(credentialsDir)) instead of relying on
100
- // the SDK's AGENT_MESSENGER_CONFIG_DIR env-var fallback. Production
101
- // wiring in src/channels/manager.ts passes the agent-folder workspace
102
- // path here so the adapter's credential resolution does NOT depend on
103
- // process.env state — easier to test, and removes a hidden coupling
104
- // with whatever set the env var (Dockerfile, CLI shell, etc.).
112
+ credentialsStore?: KakaoCredentialStore
113
+ // Deprecated compatibility path for old tests/callers. Production uses
114
+ // credentialsStore so secrets.json remains the credential source of truth.
105
115
  credentialsDir?: string
106
116
  client?: KakaoTalkClient
107
117
  listenerFactory?: (client: KakaoTalkClient) => KakaoTalkListener
@@ -416,17 +426,16 @@ export function createKakaotalkAdapter(options: KakaotalkAdapterOptions): Kakaot
416
426
  lastConnectedAt = null
417
427
  resetRecoveryEpisode()
418
428
  try {
419
- if (options.credentialsDir !== undefined) {
420
- // Explicit credential path: read the file ourselves and pass the
421
- // tokens directly to client.login(). This bypasses the SDK's
422
- // ensureKakaoAuth() (which reads AGENT_MESSENGER_CONFIG_DIR or
423
- // ~/.config/agent-messenger), making the adapter independent of
424
- // process.env state.
425
- const credManager = new KakaoCredentialManager(options.credentialsDir)
426
- const account = await credManager.getAccount()
429
+ const credentialStore =
430
+ options.credentialsStore ??
431
+ (options.credentialsDir !== undefined ? new KakaoCredentialManager(options.credentialsDir) : null)
432
+ if (credentialStore !== null) {
433
+ const account = await credentialStore.getAccount()
427
434
  if (account === null) {
428
435
  throw new Error(
429
- `no KakaoTalk account in ${options.credentialsDir}/kakaotalk-credentials.json (run typeclaw init to authenticate)`,
436
+ options.credentialsDir !== undefined
437
+ ? `no KakaoTalk account in ${options.credentialsDir}/kakaotalk-credentials.json (run typeclaw init to authenticate)`
438
+ : 'no KakaoTalk account in secrets.json#channels.kakaotalk (run typeclaw init to authenticate)',
430
439
  )
431
440
  }
432
441
  await client.login({
@@ -1,7 +1,9 @@
1
1
  import { createHash } from 'node:crypto'
2
- import { existsSync, readFileSync } from 'node:fs'
3
2
  import { join } from 'node:path'
4
3
 
4
+ import { SecretsKakaoCredentialStore } from '@/secrets/kakao-store'
5
+ import { SecretsBackend } from '@/secrets/storage'
6
+
5
7
  import { createDiscordBotAdapter, type DiscordBotAdapter } from './adapters/discord-bot'
6
8
  import { createKakaotalkAdapter, type KakaotalkAdapter } from './adapters/kakaotalk'
7
9
  import { createSlackBotAdapter, type SlackBotAdapter } from './adapters/slack-bot'
@@ -62,10 +64,10 @@ type AnyAdapter = DiscordBotAdapter | KakaotalkAdapter | SlackBotAdapter | Teleg
62
64
  // Credential signature is the comparison key for credential-rotation
63
65
  // detection on reload. Discord and Telegram each use a single bot token;
64
66
  // Slack needs both a bot token and an app-level token (Socket Mode);
65
- // KakaoTalk authenticates via a credentials file under
66
- // AGENT_MESSENGER_CONFIG_DIR (workspace/), so its signature is the file's
67
- // content hash. The "credential" naming (vs "token") generalizes across the
68
- // env-var-based adapters and KakaoTalk's file-based credential pathway.
67
+ // KakaoTalk authenticates via a structured multi-account block in
68
+ // secrets.json#channels.kakaotalk, so its signature is that block's content
69
+ // hash. The "credential" naming (vs "token") generalizes across the
70
+ // env-var-based adapters and KakaoTalk's account credential pathway.
69
71
  type AdapterEntry = {
70
72
  adapter: AnyAdapter
71
73
  credentialSignature: string
@@ -89,7 +91,7 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
89
91
  const live = new Map<AdapterId, AdapterEntry>()
90
92
 
91
93
  const buildCredentialSignature = (name: AdapterId): { signature: string; missing: string[] } => {
92
- if (name === 'kakaotalk') return buildKakaotalkSignature(options.agentDir, env)
94
+ if (name === 'kakaotalk') return buildKakaotalkSignature(options.agentDir)
93
95
  const requiredEnvs = TOKEN_ENV[name]
94
96
  const parts: string[] = []
95
97
  const missing: string[] = []
@@ -132,7 +134,7 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
132
134
  configRef: () => options.channelsConfigRef()[name] ?? cfg,
133
135
  logger,
134
136
  selfAliasesRef: () => router.getSelfAliases(),
135
- credentialsDir: resolveKakaoConfigDir(options.agentDir, env),
137
+ credentialsStore: createContainerKakaoCredentialStore(options.agentDir, env),
136
138
  })
137
139
  }
138
140
  if (name === 'telegram-bot') {
@@ -223,7 +225,7 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
223
225
  const { signature, missing } = buildCredentialSignature(name)
224
226
  if (missing.length > 0) {
225
227
  // Required credentials disappeared (env vars removed from .env, or
226
- // KakaoTalk credentials file deleted). Continuing to use the
228
+ // KakaoTalk credentials removed from secrets.json). Continuing to use the
227
229
  // in-memory credentials would silently honor a credential the
228
230
  // operator explicitly removed, so stop the adapter instead of
229
231
  // waiting for a manual restart.
@@ -244,49 +246,56 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
244
246
  }
245
247
  }
246
248
 
247
- // Token-based adapters only. KakaoTalk's credentials live in a file under
248
- // AGENT_MESSENGER_CONFIG_DIR (workspace/.agent-messenger/), not in env, so
249
- // it goes through buildKakaotalkSignature instead.
249
+ // Token-based adapters only. KakaoTalk's credentials live in
250
+ // secrets.json#channels.kakaotalk, not in env, so it goes through
251
+ // buildKakaotalkSignature instead.
250
252
  const TOKEN_ENV: Record<Exclude<AdapterId, 'kakaotalk'>, readonly string[]> = {
251
253
  'discord-bot': ['DISCORD_BOT_TOKEN'],
252
254
  'slack-bot': ['SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN'],
253
255
  'telegram-bot': ['TELEGRAM_BOT_TOKEN'],
254
256
  }
255
257
 
256
- const KAKAO_DEFAULT_SUBDIR = '.agent-messenger'
257
- const KAKAO_CREDENTIALS_FILE = 'kakaotalk-credentials.json'
258
-
259
- function resolveKakaoConfigDir(agentDir: string, env: NodeJS.ProcessEnv): string {
260
- const override = env.AGENT_MESSENGER_CONFIG_DIR
261
- if (override !== undefined && override.trim() !== '') return override
262
- return join(agentDir, 'workspace', KAKAO_DEFAULT_SUBDIR)
263
- }
264
-
265
- function resolveKakaoCredentialsPath(agentDir: string, env: NodeJS.ProcessEnv): string {
266
- return join(resolveKakaoConfigDir(agentDir, env), KAKAO_CREDENTIALS_FILE)
258
+ function createContainerKakaoCredentialStore(agentDir: string, env: NodeJS.ProcessEnv): SecretsKakaoCredentialStore {
259
+ const hostdUrl = env.TYPECLAW_HOSTD_URL
260
+ const restartToken = env.TYPECLAW_HOSTD_TOKEN
261
+ const containerName = env.TYPECLAW_CONTAINER_NAME
262
+ if (!hostdUrl || !restartToken || !containerName) {
263
+ throw new Error(
264
+ 'KakaoTalk credentials require TYPECLAW_HOSTD_URL, TYPECLAW_HOSTD_TOKEN, and TYPECLAW_CONTAINER_NAME',
265
+ )
266
+ }
267
+ return new SecretsKakaoCredentialStore({
268
+ mode: 'container',
269
+ secretsPath: join(agentDir, 'secrets.json'),
270
+ hostdUrl,
271
+ restartToken,
272
+ containerName,
273
+ })
267
274
  }
268
275
 
269
- function buildKakaotalkSignature(agentDir: string, env: NodeJS.ProcessEnv): { signature: string; missing: string[] } {
270
- const path = resolveKakaoCredentialsPath(agentDir, env)
271
- if (!existsSync(path)) {
272
- return { signature: '', missing: [`kakaotalk credentials file at ${path}`] }
273
- }
276
+ function buildKakaotalkSignature(agentDir: string): { signature: string; missing: string[] } {
277
+ const path = join(agentDir, 'secrets.json')
274
278
  try {
275
- // Content hash, not mtime+size: KakaoTalk's credential file is small
276
- // (a few hundred bytes of JSON) and is rewritten on every OAuth token
277
- // refresh. Hashing avoids two failure modes mtime+size could miss:
278
- // (a) a refresh that produces byte-identical content (rare but
279
- // possible when nothing actually rotated) — we correctly skip;
280
- // (b) a refresh that lands on the same mtime due to FS resolution
281
- // (some host filesystems quantize to seconds).
282
- const buf = readFileSync(path)
283
- const digest = createHash('sha256').update(buf).digest('hex')
284
- return { signature: `${path}@sha256:${digest}`, missing: [] }
279
+ const block = new SecretsBackend(path).tryReadChannelsSync()?.kakaotalk
280
+ if (!isKakaoCredentialBlock(block)) {
281
+ return { signature: '', missing: ['secrets.json#channels.kakaotalk'] }
282
+ }
283
+ const digest = createHash('sha256').update(JSON.stringify(block)).digest('hex')
284
+ return { signature: `secrets.json#channels.kakaotalk@sha256:${digest}`, missing: [] }
285
285
  } catch (err) {
286
- return { signature: '', missing: [`kakaotalk credentials file at ${path} (${describe(err)})`] }
286
+ return { signature: '', missing: [`secrets.json#channels.kakaotalk (${describe(err)})`] }
287
287
  }
288
288
  }
289
289
 
290
+ function isKakaoCredentialBlock(value: unknown): value is { accounts: Record<string, unknown> } {
291
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
292
+ if (!('accounts' in value)) return false
293
+ const accounts = value.accounts
294
+ return (
295
+ typeof accounts === 'object' && accounts !== null && !Array.isArray(accounts) && Object.keys(accounts).length > 0
296
+ )
297
+ }
298
+
290
299
  function describe(err: unknown): string {
291
300
  return err instanceof Error ? err.message : String(err)
292
301
  }
@@ -336,7 +336,7 @@ function reportProgress(events: AddChannelStepEvent[]): (event: AddChannelStepEv
336
336
  s.stop('Updated typeclaw.json.')
337
337
  break
338
338
  case 'secrets':
339
- s.stop('Appended credentials to .env.')
339
+ s.stop('Saved credentials to secrets.json.')
340
340
  break
341
341
  }
342
342
  }
@@ -345,11 +345,11 @@ function reportProgress(events: AddChannelStepEvent[]): (event: AddChannelStepEv
345
345
  const START_MESSAGES: Record<AddChannelStepEvent['step'], string> = {
346
346
  'kakaotalk-auth': 'Logging in to KakaoTalk...',
347
347
  config: 'Updating typeclaw.json...',
348
- secrets: 'Appending credentials to .env...',
348
+ secrets: 'Saving credentials to secrets.json...',
349
349
  }
350
350
 
351
351
  function reportKakaotalkAuth(result: KakaotalkAuthResult): string {
352
- if (result.ok) return 'KakaoTalk credentials saved to workspace/.agent-messenger/.'
352
+ if (result.ok) return 'KakaoTalk credentials saved to secrets.json.'
353
353
  return `KakaoTalk login failed: ${result.reason}`
354
354
  }
355
355