typeclaw 0.1.5 → 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 (128) hide show
  1. package/README.md +14 -12
  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 +7 -4
  19. package/src/bundled-plugins/memory/append-tool.ts +87 -61
  20. package/src/bundled-plugins/memory/dreaming.ts +23 -9
  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 +799 -319
  56. package/src/cli/model.ts +244 -0
  57. package/src/cli/provider.ts +404 -0
  58. package/src/cli/reload.ts +6 -1
  59. package/src/cli/role.ts +156 -0
  60. package/src/cli/run.ts +3 -1
  61. package/src/cli/tui.ts +8 -1
  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 +385 -12
  67. package/src/config/index.ts +7 -0
  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 +5 -0
  73. package/src/container/require-running.ts +33 -0
  74. package/src/container/start.ts +39 -58
  75. package/src/cron/consumer.ts +22 -2
  76. package/src/cron/index.ts +45 -4
  77. package/src/cron/schema.ts +104 -0
  78. package/src/doctor/checks.ts +50 -33
  79. package/src/git/system-commit.ts +103 -0
  80. package/src/hostd/daemon.ts +16 -0
  81. package/src/hostd/kakao-renewal-manager.ts +223 -0
  82. package/src/hostd/paths.ts +7 -0
  83. package/src/init/dockerfile.ts +32 -6
  84. package/src/init/index.ts +183 -62
  85. package/src/init/kakaotalk-auth.ts +18 -1
  86. package/src/init/models-dev.ts +26 -1
  87. package/src/init/run-owner-claim.ts +77 -0
  88. package/src/permissions/builtins.ts +70 -0
  89. package/src/permissions/grant.ts +99 -0
  90. package/src/permissions/index.ts +29 -0
  91. package/src/permissions/match-rule.ts +305 -0
  92. package/src/permissions/permissions.ts +196 -0
  93. package/src/permissions/resolve.ts +80 -0
  94. package/src/permissions/schema.ts +79 -0
  95. package/src/plugin/context.ts +8 -4
  96. package/src/plugin/define.ts +2 -0
  97. package/src/plugin/index.ts +2 -0
  98. package/src/plugin/manager.ts +41 -0
  99. package/src/plugin/registry.ts +9 -0
  100. package/src/plugin/types.ts +35 -1
  101. package/src/role-claim/client.ts +182 -0
  102. package/src/role-claim/code.ts +53 -0
  103. package/src/role-claim/controller.ts +194 -0
  104. package/src/role-claim/index.ts +19 -0
  105. package/src/role-claim/match-rule.ts +43 -0
  106. package/src/role-claim/pending.ts +100 -0
  107. package/src/run/channel-session-factory.ts +76 -5
  108. package/src/run/index.ts +55 -6
  109. package/src/secrets/encryption.ts +116 -0
  110. package/src/secrets/kakao-renewal.ts +248 -0
  111. package/src/secrets/kakao-store.ts +66 -7
  112. package/src/secrets/keys.ts +173 -0
  113. package/src/secrets/schema.ts +23 -0
  114. package/src/secrets/storage.ts +68 -0
  115. package/src/server/index.ts +122 -11
  116. package/src/shared/index.ts +4 -0
  117. package/src/shared/protocol.ts +27 -0
  118. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
  119. package/src/skills/typeclaw-config/SKILL.md +38 -64
  120. package/src/skills/typeclaw-memory/SKILL.md +1 -1
  121. package/src/skills/typeclaw-permissions/SKILL.md +166 -0
  122. package/src/stream/types.ts +7 -1
  123. package/src/usage/aggregate.ts +117 -0
  124. package/src/usage/format.ts +30 -0
  125. package/src/usage/index.ts +68 -0
  126. package/src/usage/report.ts +354 -0
  127. package/src/usage/scan.ts +186 -0
  128. package/typeclaw.schema.json +57 -45
@@ -106,34 +106,49 @@ const DANGEROUS_COMMAND_PATTERNS: ReadonlyArray<{ pattern: RegExp; label: string
106
106
  },
107
107
  ]
108
108
 
109
+ // Records remote-taint for any `git remote add/set-url` in this bash
110
+ // command IF the command would have been allowed to proceed (either
111
+ // gitExfil was acknowledged on the call, or the caller is bypassing
112
+ // gitExfil via permission -- caller signals the latter with
113
+ // `permittedBypass: true`). The taint is what makes the second-step
114
+ // gitRemoteTainted defense work, so recording must NOT depend on the
115
+ // gitExfil guard's return value: a permission-bypassed actor would
116
+ // otherwise skip taint recording entirely and a later push to the
117
+ // re-pointed remote would escape detection.
118
+ //
119
+ // When the command would have been blocked (no ack, no bypass), nothing
120
+ // is recorded -- the agent never actually ran the set-url so the remote
121
+ // state on disk is unchanged.
122
+ export function recordGitRemoteTaintIfAny(options: {
123
+ tool: string
124
+ args: Record<string, unknown>
125
+ sessionId?: string
126
+ permittedBypass?: boolean
127
+ }): void {
128
+ const { tool, args, sessionId, permittedBypass } = options
129
+ if (tool !== 'bash') return
130
+ if (!sessionId) return
131
+ const command = args.command
132
+ if (typeof command !== 'string') return
133
+ const allowed = permittedBypass === true || isGuardAcknowledged(args, GUARD_GIT_EXFIL)
134
+ if (!allowed) return
135
+ for (const change of parseRemoteChanges(command)) {
136
+ recordRemoteTaint(sessionId, { remoteName: change.remoteName, url: change.url })
137
+ }
138
+ }
139
+
109
140
  export function checkGitExfilGuard(options: {
110
141
  tool: string
111
142
  args: Record<string, unknown>
112
143
  sessionId?: string
113
144
  }): SecurityBlock | undefined {
114
- const { tool, args, sessionId } = options
145
+ const { tool, args } = options
115
146
  if (tool !== 'bash') return undefined
116
147
 
117
148
  const command = args.command
118
149
  if (typeof command !== 'string') return undefined
119
150
 
120
- const taintBlock = checkPushToTaintedRemote({ command, args, sessionId })
121
- if (taintBlock) return taintBlock
122
-
123
- if (isGuardAcknowledged(args, GUARD_GIT_EXFIL)) {
124
- // The user acknowledged that this command may exfil. If the command is a
125
- // `git remote add/set-url`, treat the ack as the commit point and taint
126
- // the affected remote so any later push must be acknowledged separately.
127
- // Done here (and not at tool.after) so the taint is recorded even if the
128
- // subsequent shell exec fails -- a partially-applied remote change still
129
- // leaves the repo in an exfil-shaped state.
130
- if (sessionId) {
131
- for (const change of parseRemoteChanges(command)) {
132
- recordRemoteTaint(sessionId, { remoteName: change.remoteName, url: change.url })
133
- }
134
- }
135
- return undefined
136
- }
151
+ if (isGuardAcknowledged(args, GUARD_GIT_EXFIL)) return undefined
137
152
 
138
153
  const matched = DANGEROUS_COMMAND_PATTERNS.find(({ pattern }) => pattern.test(command))
139
154
  if (!matched) return undefined
@@ -148,6 +163,24 @@ export function checkGitExfilGuard(options: {
148
163
  }
149
164
  }
150
165
 
166
+ // Separate top-level guard so `security.bypass.gitRemoteTainted` can be
167
+ // granted independently of `security.bypass.gitExfil`. The two defend
168
+ // different shapes: gitExfil blocks the first step (re-point or push),
169
+ // gitRemoteTainted blocks the second step (push to a remote that was
170
+ // re-pointed earlier in the same session). Bypassing one must not silently
171
+ // disable the other.
172
+ export function checkGitRemoteTaintedGuard(options: {
173
+ tool: string
174
+ args: Record<string, unknown>
175
+ sessionId?: string
176
+ }): SecurityBlock | undefined {
177
+ const { tool, args, sessionId } = options
178
+ if (tool !== 'bash') return undefined
179
+ const command = args.command
180
+ if (typeof command !== 'string') return undefined
181
+ return checkPushToTaintedRemote({ command, args, sessionId })
182
+ }
183
+
151
184
  function checkPushToTaintedRemote(options: {
152
185
  command: string
153
186
  args: Record<string, unknown>
@@ -15,6 +15,8 @@ The result is a session JSONL file that's tens of megabytes on disk but mostly o
15
15
 
16
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
17
 
18
+ For sessions that already contain oversized tool results from before this plugin was active (or before its limits were tightened), the **channel session factory also runs the same cap policy at rehydrate time**: just before `SessionManager.open(path)` reads the JSONL, the file is scanned for oversized `toolResult` entries and those entries are rewritten in place. The pass is idempotent and skipped entirely when `tool-result-cap.enabled` is `false`. **`typeclaw restart` is therefore the single user-facing recovery action for a poisoned channel session** — no scrubber subcommand, no manual surgery on `channels/sessions.json`.
19
+
18
20
  ## Config
19
21
 
20
22
  ```json
@@ -57,11 +59,14 @@ Plugin hook order is the order plugins are listed in `src/run/bundled-plugins.ts
57
59
 
58
60
  ## What it contributes
59
61
 
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. |
62
+ | Kind | Name | Notes |
63
+ | -------------------- | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
64
+ | Hook | `tool.after` | Walks `event.result.content` and replaces oversized image/text parts in place. Logs one `info` line per capped call, silent otherwise. |
65
+ | Load-time pass | `capJsonlFileInPlace` | Called by `src/run/channel-session-factory.ts` before `SessionManager.open(path)` on every channel-session rehydrate. Walks JSONL entries, applies the same `capToolResult` per `toolResult` message, and rewrites the file atomically (temp + rename) when any entry mutated. Idempotent; passes malformed lines verbatim. |
66
+ | Config-bridge helper | `resolveCapOptionsFromConfig` | Parses the `tool-result-cap` config block through the plugin's `configSchema` and returns the runtime `CapOptions` (or `null` when `enabled: false`). Lets non-plugin call sites share the same disable rule as the `tool.after` hook. |
63
67
 
64
68
  ## Tests
65
69
 
66
70
  - `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).
71
+ - `cap-jsonl.test.ts` — load-time pass: rewrite-on-mutation, no-write-when-clean, idempotency, exemptTools respected, non-toolResult entries preserved, malformed-line passthrough, missing-file safety, multi-entry batching, text truncation in JSONL form.
72
+ - `index.test.ts` — composition tests (config schema defaults and validation, hook registration, disabled-mode short-circuit, logging on cap, silence on no-op, `resolveCapOptionsFromConfig` semantics).
@@ -0,0 +1,115 @@
1
+ import { chmodSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
2
+
3
+ import type { ContentPart } from '@/plugin'
4
+
5
+ import { type CapOptions, type CapStats, capContentParts } from './cap-result'
6
+
7
+ export type CapJsonlStats = CapStats & {
8
+ // Number of toolResult entries that had at least one part capped. Distinct
9
+ // from imagesReplaced + textsTruncated because a single entry can have
10
+ // multiple oversized parts.
11
+ entriesMutated: number
12
+ }
13
+
14
+ export class CapJsonlReadError extends Error {
15
+ constructor(
16
+ public readonly path: string,
17
+ public override readonly cause: unknown,
18
+ ) {
19
+ super(`capJsonlFileInPlace: read failed for ${path}: ${cause instanceof Error ? cause.message : String(cause)}`)
20
+ this.name = 'CapJsonlReadError'
21
+ }
22
+ }
23
+
24
+ function parseLine(line: string): unknown {
25
+ try {
26
+ return JSON.parse(line) as unknown
27
+ } catch {
28
+ return null
29
+ }
30
+ }
31
+
32
+ function isToolResultMessageEntry(value: unknown): value is {
33
+ type: 'message'
34
+ message: { role: 'toolResult'; toolName?: string; content: ContentPart[] }
35
+ } {
36
+ if (typeof value !== 'object' || value === null) return false
37
+ const entry = value as { type?: unknown; message?: unknown }
38
+ if (entry.type !== 'message') return false
39
+ if (typeof entry.message !== 'object' || entry.message === null) return false
40
+ const message = entry.message as { role?: unknown; content?: unknown }
41
+ if (message.role !== 'toolResult') return false
42
+ if (!Array.isArray(message.content)) return false
43
+ return true
44
+ }
45
+
46
+ // Apply `capContentParts` to every toolResult entry parsed from a JSONL file
47
+ // and rewrite the file in place when anything mutated. Idempotent.
48
+ //
49
+ // Why we own the file IO (rather than going through SessionManager): the
50
+ // `_rewriteFile` method on SessionManager is not on the public type, and
51
+ // running BEFORE `SessionManager.open(path)` is called means pi-coding-agent
52
+ // reads the already-capped file. No private API touched, no race against
53
+ // pi's internal state.
54
+ //
55
+ // Throws CapJsonlReadError when the file can't be read (missing, unreadable,
56
+ // directory, etc.). Callers that want best-effort no-op semantics should
57
+ // wrap in try/catch — see tryReopenOrCreate.
58
+ //
59
+ // Malformed lines are passed through verbatim — matches pi's parser, which
60
+ // silently skips them. We never delete or reorder entries; we only shrink
61
+ // `content` parts of toolResult messages.
62
+ //
63
+ // File mode is preserved across the temp+rename so a 0600 session JSONL
64
+ // stays 0600 after capping.
65
+ export function capJsonlFileInPlace(path: string, options: CapOptions): CapJsonlStats {
66
+ let raw: string
67
+ let originalMode: number
68
+ try {
69
+ raw = readFileSync(path, 'utf8')
70
+ originalMode = statSync(path).mode & 0o777
71
+ } catch (err) {
72
+ throw new CapJsonlReadError(path, err)
73
+ }
74
+
75
+ const lines = raw.split('\n')
76
+ const stats: CapJsonlStats = { imagesReplaced: 0, textsTruncated: 0, bytesElided: 0, entriesMutated: 0 }
77
+ const out: string[] = Array.from({ length: lines.length })
78
+ let anyMutated = false
79
+
80
+ for (let i = 0; i < lines.length; i++) {
81
+ const line = lines[i] ?? ''
82
+ if (line.length === 0) {
83
+ out[i] = line
84
+ continue
85
+ }
86
+ const parsed = parseLine(line)
87
+ if (parsed === null || !isToolResultMessageEntry(parsed)) {
88
+ out[i] = line
89
+ continue
90
+ }
91
+ const toolName = parsed.message.toolName ?? ''
92
+ const partStats = capContentParts(toolName, parsed.message.content, options)
93
+ if (partStats.imagesReplaced > 0 || partStats.textsTruncated > 0) {
94
+ stats.imagesReplaced += partStats.imagesReplaced
95
+ stats.textsTruncated += partStats.textsTruncated
96
+ stats.bytesElided += partStats.bytesElided
97
+ stats.entriesMutated += 1
98
+ anyMutated = true
99
+ out[i] = JSON.stringify(parsed)
100
+ } else {
101
+ out[i] = line
102
+ }
103
+ }
104
+
105
+ if (anyMutated) {
106
+ // Write-then-rename so a crash mid-write can't leave a truncated JSONL
107
+ // (which would corrupt the next rehydrate).
108
+ const tmp = `${path}.cap.tmp`
109
+ writeFileSync(tmp, out.join('\n'), { mode: originalMode })
110
+ chmodSync(tmp, originalMode)
111
+ renameSync(tmp, path)
112
+ }
113
+
114
+ return stats
115
+ }
@@ -3,7 +3,7 @@ import type { ContentPart, ToolResult } from '@/plugin'
3
3
  export type CapOptions = {
4
4
  imageMaxBytes: number
5
5
  textMaxBytes: number
6
- exemptTools: ReadonlySet<string>
6
+ exemptTools?: ReadonlySet<string>
7
7
  }
8
8
 
9
9
  export type CapStats = {
@@ -12,23 +12,32 @@ export type CapStats = {
12
12
  bytesElided: number
13
13
  }
14
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
15
  const ELIDED_MARKER = '[tool-result-cap: '
20
16
 
21
- export function capToolResult(tool: string, result: ToolResult, options: CapOptions): CapStats {
17
+ // A capped text part is exactly the placeholder we generated: starts with
18
+ // `[tool-result-cap: `, ends with `]`, and contains no inner `]`. The shape
19
+ // check exists for idempotency so a previously-capped entry survives a second
20
+ // pass untouched — but tight enough that real tool output that merely STARTS
21
+ // with the marker (e.g. quotes a prior placeholder then continues with more
22
+ // content) still gets capped on its trailing bulk. A prefix-only check would
23
+ // be an oversized-text bypass.
24
+ const ELIDED_PLACEHOLDER_PATTERN = /^\[tool-result-cap: [^\]]*\]$/
25
+
26
+ function isElidedPlaceholderText(text: string): boolean {
27
+ return ELIDED_PLACEHOLDER_PATTERN.test(text)
28
+ }
29
+
30
+ export function capContentParts(tool: string, content: ContentPart[], options: CapOptions): CapStats {
22
31
  const stats: CapStats = { imagesReplaced: 0, textsTruncated: 0, bytesElided: 0 }
23
- if (options.exemptTools.has(tool)) return stats
32
+ if (options.exemptTools?.has(tool)) return stats
24
33
 
25
- for (let i = 0; i < result.content.length; i++) {
26
- const part = result.content[i]
34
+ for (let i = 0; i < content.length; i++) {
35
+ const part = content[i]
27
36
  if (!part) continue
28
37
  if (part.type === 'image') {
29
38
  const size = part.data.length
30
39
  if (size <= options.imageMaxBytes) continue
31
- result.content[i] = {
40
+ content[i] = {
32
41
  type: 'text',
33
42
  text: `${ELIDED_MARKER}image ${part.mimeType} elided, ${size} bytes of base64 exceeded imageMaxBytes=${options.imageMaxBytes}]`,
34
43
  }
@@ -39,18 +48,21 @@ export function capToolResult(tool: string, result: ToolResult, options: CapOpti
39
48
  if (part.type === 'text') {
40
49
  const size = part.text.length
41
50
  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.
51
+ if (isElidedPlaceholderText(part.text)) continue
44
52
  const head = part.text.slice(0, options.textMaxBytes)
45
53
  const elided = size - options.textMaxBytes
46
54
  const replacement: ContentPart = {
47
55
  type: 'text',
48
56
  text: `${head}\n\n${ELIDED_MARKER}${elided} bytes truncated from text part; original was ${size} bytes, textMaxBytes=${options.textMaxBytes}]`,
49
57
  }
50
- result.content[i] = replacement
58
+ content[i] = replacement
51
59
  stats.textsTruncated += 1
52
60
  stats.bytesElided += elided
53
61
  }
54
62
  }
55
63
  return stats
56
64
  }
65
+
66
+ export function capToolResult(tool: string, result: ToolResult, options: CapOptions): CapStats {
67
+ return capContentParts(tool, result.content, options)
68
+ }
@@ -2,14 +2,14 @@ import { z } from 'zod'
2
2
 
3
3
  import { definePlugin } from '@/plugin'
4
4
 
5
- import { capToolResult } from './cap-result'
5
+ import { type CapOptions, capToolResult } from './cap-result'
6
6
 
7
7
  const DEFAULT_IMAGE_MAX_BYTES = 262_144
8
8
  const DEFAULT_TEXT_MAX_BYTES = 65_536
9
9
  const MIN_IMAGE_MAX_BYTES = 1_024
10
10
  const MIN_TEXT_MAX_BYTES = 1_024
11
11
 
12
- const toolResultCapConfigSchema = z
12
+ export const toolResultCapConfigSchema = z
13
13
  .object({
14
14
  enabled: z.boolean().default(true),
15
15
  imageMaxBytes: z.number().int().min(MIN_IMAGE_MAX_BYTES).default(DEFAULT_IMAGE_MAX_BYTES),
@@ -23,6 +23,20 @@ const toolResultCapConfigSchema = z
23
23
  exemptTools: [],
24
24
  })
25
25
 
26
+ // Helper for non-plugin call sites (e.g. channel-session-factory's load-time
27
+ // pass) to parse the same `tool-result-cap` config block and resolve it to
28
+ // the runtime options shape, or `null` when the plugin is disabled. Keeps
29
+ // the schema and the disable rule in one place.
30
+ export function resolveCapOptionsFromConfig(raw: unknown): CapOptions | null {
31
+ const parsed = toolResultCapConfigSchema.parse(raw)
32
+ if (!parsed.enabled) return null
33
+ return {
34
+ imageMaxBytes: parsed.imageMaxBytes,
35
+ textMaxBytes: parsed.textMaxBytes,
36
+ exemptTools: new Set(parsed.exemptTools),
37
+ }
38
+ }
39
+
26
40
  export default definePlugin({
27
41
  configSchema: toolResultCapConfigSchema,
28
42
  plugin: async (ctx) => {
@@ -5,13 +5,12 @@ import type {
5
5
  DiscordGatewayStickerItem,
6
6
  } from 'agent-messenger/discordbot'
7
7
 
8
- import { isAllowed, type ChannelAdapterConfig } from '@/channels/schema'
8
+ import type { ChannelAdapterConfig } from '@/channels/schema'
9
9
  import type { InboundMessage } from '@/channels/types'
10
10
 
11
11
  export type InboundDropReason =
12
12
  | 'self_author' // event.author.id === botUserId; we never route our own messages back to ourselves
13
13
  | 'empty_content' // SDK delivered content: '' — usually missing MessageContent intent
14
- | 'not_in_allow_list' // workspace/channel not admitted by typeclaw.json `channels.discord-bot.allow`
15
14
  | 'pre_connect' // bot identity is not known yet, so mention/self/reply classification cannot be trusted
16
15
 
17
16
  export type InboundClassification =
@@ -26,7 +25,7 @@ export type InboundClassification =
26
25
  // forces logging to stay exhaustive.
27
26
  export function classifyInbound(
28
27
  event: DiscordGatewayMessageCreateEvent,
29
- config: ChannelAdapterConfig,
28
+ _config: ChannelAdapterConfig,
30
29
  botUserId: string | null,
31
30
  ): InboundClassification {
32
31
  // Self-drop is the hard floor: we must never route our own messages back to
@@ -41,9 +40,6 @@ export function classifyInbound(
41
40
 
42
41
  const isDm = event.guild_id === undefined
43
42
  const workspace = isDm ? '@dm' : event.guild_id!
44
- if (!isAllowed(config.allow, workspace, event.channel_id)) {
45
- return { kind: 'drop', reason: 'not_in_allow_list' }
46
- }
47
43
 
48
44
  if (botUserId === null) {
49
45
  return { kind: 'drop', reason: 'pre_connect' }
@@ -9,7 +9,7 @@ import {
9
9
  } from '@/channels/membership'
10
10
  import { deriveMembershipFromHistory } from '@/channels/membership-from-history'
11
11
  import type { ChannelRouter } from '@/channels/router'
12
- import { isAllowed, type ChannelAdapterConfig } from '@/channels/schema'
12
+ import type { ChannelAdapterConfig } from '@/channels/schema'
13
13
  import type {
14
14
  ChannelHistoryMessage,
15
15
  FetchAttachmentCallback,
@@ -82,11 +82,10 @@ export type DiscordBotAdapter = {
82
82
  // router caps cadence per-channel at 8s.
83
83
  export function createTypingCallback(deps: {
84
84
  token: string
85
- configRef: () => ChannelAdapterConfig
86
85
  logger: DiscordBotAdapterLogger
87
86
  formatChannelTag?: (workspace: string, chat: string) => Promise<string>
88
87
  }): TypingCallback {
89
- const { token, configRef, logger, formatChannelTag } = deps
88
+ const { token, logger, formatChannelTag } = deps
90
89
  return async (target: TypingTarget): Promise<void> => {
91
90
  if (target.adapter !== 'discord-bot') return
92
91
  // Discord's typing indicator auto-expires after ~10s on Discord's side,
@@ -94,8 +93,6 @@ export function createTypingCallback(deps: {
94
93
  // for platforms (Slack) that need an explicit clear; for Discord it
95
94
  // would be extra POSTs that confuse the indicator into reappearing.
96
95
  if (target.phase === 'stop') return
97
- const config = configRef()
98
- if (!isAllowed(config.allow, target.workspace, target.chat)) return
99
96
  // Threads are channels in Discord, so the typing endpoint takes the
100
97
  // thread id directly when present.
101
98
  const channelId = target.thread ?? target.chat
@@ -240,19 +237,13 @@ type DiscordRawHistoryMessage = {
240
237
  // design where `chat` is the parent and `thread` is the thread channel id).
241
238
  export function createDiscordHistoryCallback(deps: {
242
239
  token: string
243
- configRef: () => ChannelAdapterConfig
244
240
  logger: DiscordBotAdapterLogger
245
241
  botUserIdRef: () => string | null
246
242
  fetchImpl?: typeof fetch
247
243
  }): HistoryCallback {
248
- const { token, configRef, logger, botUserIdRef } = deps
244
+ const { token, logger, botUserIdRef } = deps
249
245
  const fetchFn = deps.fetchImpl ?? fetch
250
246
  return async (args: FetchHistoryArgs): Promise<FetchHistoryResult> => {
251
- const config = configRef()
252
- if (!isAllowedAnyGuild(config.allow, args.chat)) {
253
- return { ok: false, error: 'denied by allow rules' }
254
- }
255
-
256
247
  const channelId = args.thread ?? args.chat
257
248
  const limit = clampLimit(args.limit, DISCORD_HISTORY_LIMIT_MAX)
258
249
  const params = new URLSearchParams({ limit: String(limit) })
@@ -315,27 +306,6 @@ function clampLimit(requested: number, max: number): number {
315
306
  return Math.min(Math.floor(requested), max)
316
307
  }
317
308
 
318
- // Discord channel ids are globally unique snowflakes, so a `channel:<id>`
319
- // or `guild:<g>/<id>` rule for any guild admits this chat. We match this
320
- // way because at fetch time the tool has resolved the chat from session
321
- // origin but does not always re-supply the guild id (esp. across cursor
322
- // pagination), so the workspace-aware `isAllowed` is too narrow here.
323
- function isAllowedAnyGuild(rules: readonly string[], chat: string): boolean {
324
- for (const rule of rules) {
325
- if (rule === '*') return true
326
- if (rule === 'guild:*' || rule === 'team:*') return true
327
- if (rule === 'dm:*') return true
328
- if (rule.startsWith('channel:') && rule.slice(8) === chat) return true
329
- if (rule.startsWith('dm:') && rule.slice(3) === chat) return true
330
- if (rule.startsWith('guild:')) {
331
- const body = rule.slice(6)
332
- const slash = body.indexOf('/')
333
- if (slash !== -1 && body.slice(slash + 1) === chat) return true
334
- }
335
- }
336
- return false
337
- }
338
-
339
309
  // Discord-side asymmetry: agent-messenger's upstream `uploadFile` posts the
340
310
  // file to `POST /channels/{id}/messages` as a multipart-only request. It does
341
311
  // not accept a `content` body or a `thread_id`. So when the agent wants to
@@ -353,21 +323,15 @@ function isAllowedAnyGuild(rules: readonly string[], chat: string): boolean {
353
323
  // after every upload succeeds.
354
324
  export function createOutboundCallback(deps: {
355
325
  client: Pick<DiscordBotClient, 'sendMessage' | 'uploadFile'>
356
- configRef: () => ChannelAdapterConfig
357
326
  logger: DiscordBotAdapterLogger
358
327
  formatChannelTag: (workspace: string, chat: string) => Promise<string>
359
328
  resolvePath?: (path: string) => string
360
329
  }): OutboundCallback {
361
- const { client, configRef, logger, formatChannelTag, resolvePath } = deps
330
+ const { client, logger, formatChannelTag, resolvePath } = deps
362
331
  return async (msg: OutboundMessage): Promise<SendResult> => {
363
332
  if (msg.adapter !== 'discord-bot') {
364
333
  return { ok: false, error: `unknown adapter: ${msg.adapter}` }
365
334
  }
366
- const config = configRef()
367
- if (!isAllowed(config.allow, msg.workspace, msg.chat)) {
368
- logger.warn(`[discord-bot] outbound denied by allow rules: ${msg.workspace}/${msg.chat}`)
369
- return { ok: false, error: 'denied by allow rules' }
370
- }
371
335
  const text = msg.text ?? ''
372
336
  const attachments = msg.attachments ?? []
373
337
  if (text === '' && attachments.length === 0) {
@@ -491,14 +455,12 @@ export function createDiscordBotAdapter(options: DiscordBotAdapterOptions): Disc
491
455
 
492
456
  const typingCallback = createTypingCallback({
493
457
  token: options.token,
494
- configRef: options.configRef,
495
458
  logger,
496
459
  formatChannelTag,
497
460
  })
498
461
 
499
462
  const historyCallback = createDiscordHistoryCallback({
500
463
  token: options.token,
501
- configRef: options.configRef,
502
464
  logger,
503
465
  botUserIdRef: () => botUserId,
504
466
  })
@@ -511,7 +473,6 @@ export function createDiscordBotAdapter(options: DiscordBotAdapterOptions): Disc
511
473
 
512
474
  const outboundCallback = createOutboundCallback({
513
475
  client,
514
- configRef: options.configRef,
515
476
  logger,
516
477
  formatChannelTag,
517
478
  })
@@ -631,8 +592,6 @@ function dropHint(reason: InboundDropReason): string {
631
592
  switch (reason) {
632
593
  case 'empty_content':
633
594
  return ' (enable MESSAGE CONTENT INTENT in Discord Developer Portal and restart)'
634
- case 'not_in_allow_list':
635
- return ' (extend channels.discord-bot.allow in typeclaw.json to admit this workspace/channel)'
636
595
  case 'pre_connect':
637
596
  case 'self_author':
638
597
  return ''
@@ -1,10 +1,10 @@
1
1
  import type { KakaoTalkPushMessageEvent } from 'agent-messenger/kakaotalk'
2
2
 
3
3
  import { matchesAnyAlias } from '@/channels/engagement'
4
- import { isAllowed, type ChannelAdapterConfig } from '@/channels/schema'
4
+ import type { ChannelAdapterConfig } from '@/channels/schema'
5
5
  import type { InboundMessage } from '@/channels/types'
6
6
 
7
- export type InboundDropReason = 'self_author' | 'empty_text' | 'unknown_chat' | 'not_in_allow_list' | 'pre_connect'
7
+ export type InboundDropReason = 'self_author' | 'empty_text' | 'unknown_chat' | 'pre_connect'
8
8
 
9
9
  export type InboundClassification =
10
10
  | { kind: 'drop'; reason: InboundDropReason }
@@ -23,7 +23,7 @@ export type KakaoInboundContext = {
23
23
 
24
24
  export function classifyInbound(
25
25
  event: KakaoTalkPushMessageEvent,
26
- config: ChannelAdapterConfig,
26
+ _config: ChannelAdapterConfig,
27
27
  context: KakaoInboundContext,
28
28
  ): InboundClassification {
29
29
  if (context.selfUserId === null) {
@@ -41,10 +41,6 @@ export function classifyInbound(
41
41
  return { kind: 'drop', reason: 'unknown_chat' }
42
42
  }
43
43
 
44
- if (!isAllowed(config.allow, chatInfo.workspace, event.chat_id)) {
45
- return { kind: 'drop', reason: 'not_in_allow_list' }
46
- }
47
-
48
44
  // KakaoTalk has no native @-mention syntax in the LOCO protocol that the
49
45
  // SDK exposes (mention rendering happens client-side via display_name
50
46
  // matching). Mention-equivalent engagement comes solely from alias