typeclaw 0.1.5 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -12
- package/auth.schema.json +41 -0
- package/cron.schema.json +8 -0
- package/package.json +1 -1
- package/secrets.schema.json +41 -0
- package/src/agent/auth.ts +45 -22
- package/src/agent/index.ts +189 -19
- package/src/agent/multimodal/index.ts +12 -0
- package/src/agent/multimodal/look-at.ts +185 -0
- package/src/agent/multimodal/looker.ts +145 -0
- package/src/agent/plugin-tools.ts +30 -1
- package/src/agent/session-origin.ts +194 -46
- package/src/agent/subagents.ts +57 -1
- package/src/agent/system-prompt.ts +1 -1
- package/src/agent/tool-result-budget.ts +121 -0
- package/src/bundled-plugins/backup/index.ts +23 -8
- package/src/bundled-plugins/backup/runner.ts +22 -0
- package/src/bundled-plugins/memory/README.md +7 -4
- package/src/bundled-plugins/memory/append-tool.ts +87 -61
- package/src/bundled-plugins/memory/dreaming.ts +23 -9
- package/src/bundled-plugins/memory/find-entry-tool.ts +62 -0
- package/src/bundled-plugins/memory/fragment-parser.ts +19 -44
- package/src/bundled-plugins/memory/index.ts +91 -8
- package/src/bundled-plugins/memory/load-memory.ts +74 -34
- package/src/bundled-plugins/memory/memory-logger.ts +72 -29
- package/src/bundled-plugins/memory/migration.ts +276 -0
- package/src/bundled-plugins/memory/stream-events.ts +55 -0
- package/src/bundled-plugins/memory/stream-io.ts +63 -0
- package/src/bundled-plugins/memory/watermark.ts +48 -8
- package/src/bundled-plugins/security/index.ts +103 -10
- package/src/bundled-plugins/security/permissions.ts +12 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +51 -18
- package/src/bundled-plugins/tool-result-cap/README.md +9 -4
- package/src/bundled-plugins/tool-result-cap/cap-jsonl.ts +115 -0
- package/src/bundled-plugins/tool-result-cap/cap-result.ts +25 -13
- package/src/bundled-plugins/tool-result-cap/index.ts +16 -2
- package/src/channels/adapters/discord-bot-classify.ts +2 -6
- package/src/channels/adapters/discord-bot.ts +4 -45
- package/src/channels/adapters/kakaotalk-classify.ts +3 -7
- package/src/channels/adapters/kakaotalk.ts +28 -47
- package/src/channels/adapters/slack-bot-classify.ts +2 -6
- package/src/channels/adapters/slack-bot.ts +4 -50
- package/src/channels/adapters/telegram-bot-classify.ts +8 -10
- package/src/channels/adapters/telegram-bot.ts +3 -16
- package/src/channels/index.ts +3 -2
- package/src/channels/manager.ts +15 -1
- package/src/channels/persistence.ts +44 -10
- package/src/channels/router.ts +228 -19
- package/src/channels/schema.ts +6 -156
- package/src/cli/channel.ts +200 -4
- package/src/cli/compose-usage.ts +182 -0
- package/src/cli/compose.ts +33 -0
- package/src/cli/hostd.ts +49 -1
- package/src/cli/index.ts +4 -0
- package/src/cli/init.ts +799 -319
- package/src/cli/model.ts +244 -0
- package/src/cli/provider.ts +404 -0
- package/src/cli/reload.ts +6 -1
- package/src/cli/role.ts +156 -0
- package/src/cli/run.ts +3 -1
- package/src/cli/tui.ts +8 -1
- package/src/cli/usage-args.ts +47 -0
- package/src/cli/usage.ts +97 -0
- package/src/compose/index.ts +1 -0
- package/src/compose/usage.ts +65 -0
- package/src/config/config.ts +385 -12
- package/src/config/index.ts +7 -0
- package/src/config/models-mutation.ts +209 -0
- package/src/config/providers-mutation.ts +250 -0
- package/src/config/providers.ts +141 -2
- package/src/config/reloadable.ts +15 -4
- package/src/container/index.ts +5 -0
- package/src/container/require-running.ts +33 -0
- package/src/container/start.ts +39 -58
- package/src/cron/consumer.ts +22 -2
- package/src/cron/index.ts +45 -4
- package/src/cron/schema.ts +104 -0
- package/src/doctor/checks.ts +50 -33
- package/src/git/system-commit.ts +103 -0
- package/src/hostd/daemon.ts +16 -0
- package/src/hostd/kakao-renewal-manager.ts +223 -0
- package/src/hostd/paths.ts +7 -0
- package/src/init/dockerfile.ts +32 -6
- package/src/init/index.ts +190 -61
- package/src/init/kakaotalk-auth.ts +18 -1
- package/src/init/models-dev.ts +26 -1
- package/src/init/run-owner-claim.ts +77 -0
- package/src/permissions/builtins.ts +70 -0
- package/src/permissions/grant.ts +99 -0
- package/src/permissions/index.ts +29 -0
- package/src/permissions/match-rule.ts +305 -0
- package/src/permissions/permissions.ts +196 -0
- package/src/permissions/resolve.ts +80 -0
- package/src/permissions/schema.ts +79 -0
- package/src/plugin/context.ts +8 -4
- package/src/plugin/define.ts +2 -0
- package/src/plugin/index.ts +2 -0
- package/src/plugin/manager.ts +41 -0
- package/src/plugin/registry.ts +9 -0
- package/src/plugin/types.ts +35 -1
- package/src/role-claim/client.ts +182 -0
- package/src/role-claim/code.ts +53 -0
- package/src/role-claim/controller.ts +194 -0
- package/src/role-claim/index.ts +19 -0
- package/src/role-claim/match-rule.ts +43 -0
- package/src/role-claim/pending.ts +100 -0
- package/src/run/channel-session-factory.ts +76 -5
- package/src/run/index.ts +55 -6
- package/src/secrets/encryption.ts +116 -0
- package/src/secrets/kakao-renewal.ts +248 -0
- package/src/secrets/kakao-store.ts +66 -7
- package/src/secrets/keys.ts +173 -0
- package/src/secrets/schema.ts +23 -0
- package/src/secrets/storage.ts +68 -0
- package/src/server/index.ts +122 -11
- package/src/shared/index.ts +4 -0
- package/src/shared/protocol.ts +27 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
- package/src/skills/typeclaw-config/SKILL.md +38 -64
- package/src/skills/typeclaw-memory/SKILL.md +1 -1
- package/src/skills/typeclaw-permissions/SKILL.md +166 -0
- package/src/stream/types.ts +7 -1
- package/src/usage/aggregate.ts +117 -0
- package/src/usage/format.ts +30 -0
- package/src/usage/index.ts +68 -0
- package/src/usage/report.ts +354 -0
- package/src/usage/scan.ts +186 -0
- package/typeclaw.schema.json +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
|
|
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
|
-
|
|
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
|
|
61
|
-
|
|
|
62
|
-
| Hook
|
|
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
|
-
- `
|
|
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
|
|
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
|
-
|
|
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
|
|
32
|
+
if (options.exemptTools?.has(tool)) return stats
|
|
24
33
|
|
|
25
|
-
for (let i = 0; i <
|
|
26
|
-
const part =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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,
|
|
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,
|
|
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,
|
|
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 {
|
|
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' | '
|
|
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
|
-
|
|
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
|