typeclaw 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -13
- package/auth.schema.json +41 -0
- package/cron.schema.json +8 -0
- package/package.json +1 -1
- package/secrets.schema.json +41 -0
- package/src/agent/auth.ts +45 -22
- package/src/agent/index.ts +189 -19
- package/src/agent/multimodal/index.ts +12 -0
- package/src/agent/multimodal/look-at.ts +185 -0
- package/src/agent/multimodal/looker.ts +145 -0
- package/src/agent/plugin-tools.ts +30 -1
- package/src/agent/session-origin.ts +194 -46
- package/src/agent/subagents.ts +57 -1
- package/src/agent/system-prompt.ts +1 -1
- package/src/agent/tool-result-budget.ts +121 -0
- package/src/bundled-plugins/backup/index.ts +23 -8
- package/src/bundled-plugins/backup/runner.ts +22 -0
- package/src/bundled-plugins/memory/README.md +13 -10
- package/src/bundled-plugins/memory/append-tool.ts +87 -61
- package/src/bundled-plugins/memory/dreaming.ts +137 -7
- package/src/bundled-plugins/memory/find-entry-tool.ts +62 -0
- package/src/bundled-plugins/memory/fragment-parser.ts +19 -44
- package/src/bundled-plugins/memory/index.ts +91 -8
- package/src/bundled-plugins/memory/load-memory.ts +74 -34
- package/src/bundled-plugins/memory/memory-logger.ts +72 -29
- package/src/bundled-plugins/memory/migration.ts +276 -0
- package/src/bundled-plugins/memory/stream-events.ts +55 -0
- package/src/bundled-plugins/memory/stream-io.ts +63 -0
- package/src/bundled-plugins/memory/watermark.ts +48 -8
- package/src/bundled-plugins/security/index.ts +103 -10
- package/src/bundled-plugins/security/permissions.ts +12 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +51 -18
- package/src/bundled-plugins/tool-result-cap/README.md +9 -4
- package/src/bundled-plugins/tool-result-cap/cap-jsonl.ts +115 -0
- package/src/bundled-plugins/tool-result-cap/cap-result.ts +25 -13
- package/src/bundled-plugins/tool-result-cap/index.ts +16 -2
- package/src/channels/adapters/discord-bot-classify.ts +2 -6
- package/src/channels/adapters/discord-bot.ts +4 -45
- package/src/channels/adapters/kakaotalk-classify.ts +3 -7
- package/src/channels/adapters/kakaotalk.ts +28 -47
- package/src/channels/adapters/slack-bot-classify.ts +2 -6
- package/src/channels/adapters/slack-bot.ts +4 -50
- package/src/channels/adapters/telegram-bot-classify.ts +8 -10
- package/src/channels/adapters/telegram-bot.ts +3 -16
- package/src/channels/index.ts +3 -2
- package/src/channels/manager.ts +15 -1
- package/src/channels/persistence.ts +44 -10
- package/src/channels/router.ts +228 -19
- package/src/channels/schema.ts +6 -156
- package/src/cli/channel.ts +200 -4
- package/src/cli/compose-usage.ts +182 -0
- package/src/cli/compose.ts +33 -0
- package/src/cli/hostd.ts +49 -1
- package/src/cli/index.ts +4 -0
- package/src/cli/init.ts +809 -300
- package/src/cli/model.ts +244 -0
- package/src/cli/provider.ts +404 -0
- package/src/cli/reload.ts +11 -3
- package/src/cli/role.ts +156 -0
- package/src/cli/run.ts +3 -1
- package/src/cli/tui.ts +13 -3
- package/src/cli/usage-args.ts +47 -0
- package/src/cli/usage.ts +97 -0
- package/src/compose/index.ts +1 -0
- package/src/compose/usage.ts +65 -0
- package/src/config/config.ts +491 -19
- package/src/config/index.ts +15 -1
- package/src/config/models-mutation.ts +200 -0
- package/src/config/providers-mutation.ts +250 -0
- package/src/config/providers.ts +141 -2
- package/src/config/reloadable.ts +15 -4
- package/src/container/index.ts +6 -1
- package/src/container/port.ts +10 -0
- package/src/container/require-running.ts +33 -0
- package/src/container/start.ts +81 -63
- package/src/cron/consumer.ts +22 -2
- package/src/cron/index.ts +45 -4
- package/src/cron/schema.ts +104 -0
- package/src/doctor/checks.ts +51 -34
- package/src/doctor/plugin-bridge.ts +28 -4
- package/src/git/system-commit.ts +103 -0
- package/src/hostd/daemon.ts +16 -0
- package/src/hostd/kakao-renewal-manager.ts +223 -0
- package/src/hostd/paths.ts +7 -0
- package/src/init/dockerfile.ts +36 -10
- package/src/init/gitignore.ts +1 -1
- package/src/init/index.ts +213 -85
- package/src/init/kakaotalk-auth.ts +18 -1
- package/src/init/models-dev.ts +26 -1
- package/src/init/run-owner-claim.ts +77 -0
- package/src/permissions/builtins.ts +70 -0
- package/src/permissions/grant.ts +99 -0
- package/src/permissions/index.ts +29 -0
- package/src/permissions/match-rule.ts +305 -0
- package/src/permissions/permissions.ts +196 -0
- package/src/permissions/resolve.ts +80 -0
- package/src/permissions/schema.ts +79 -0
- package/src/plugin/context.ts +8 -4
- package/src/plugin/define.ts +2 -0
- package/src/plugin/index.ts +2 -0
- package/src/plugin/manager.ts +41 -0
- package/src/plugin/registry.ts +9 -0
- package/src/plugin/types.ts +35 -1
- package/src/reload/client.ts +25 -1
- package/src/role-claim/client.ts +182 -0
- package/src/role-claim/code.ts +53 -0
- package/src/role-claim/controller.ts +194 -0
- package/src/role-claim/index.ts +19 -0
- package/src/role-claim/match-rule.ts +43 -0
- package/src/role-claim/pending.ts +100 -0
- package/src/run/channel-session-factory.ts +76 -5
- package/src/run/index.ts +68 -7
- package/src/secrets/encryption.ts +116 -0
- package/src/secrets/kakao-renewal.ts +248 -0
- package/src/secrets/kakao-store.ts +66 -7
- package/src/secrets/keys.ts +173 -0
- package/src/secrets/schema.ts +23 -0
- package/src/secrets/storage.ts +83 -0
- package/src/server/index.ts +198 -71
- package/src/shared/index.ts +4 -0
- package/src/shared/protocol.ts +27 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
- package/src/skills/typeclaw-config/SKILL.md +104 -112
- package/src/skills/typeclaw-memory/SKILL.md +9 -9
- package/src/skills/typeclaw-permissions/SKILL.md +166 -0
- package/src/stream/types.ts +7 -1
- package/src/tui/client.ts +66 -5
- package/src/tui/index.ts +61 -9
- package/src/usage/aggregate.ts +117 -0
- package/src/usage/format.ts +30 -0
- package/src/usage/index.ts +68 -0
- package/src/usage/report.ts +354 -0
- package/src/usage/scan.ts +186 -0
- package/typeclaw.schema.json +134 -98
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { extname, isAbsolute } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { z } from 'zod'
|
|
5
|
+
|
|
6
|
+
const SUPPORTED_MIME_TYPES = {
|
|
7
|
+
'.png': 'image/png',
|
|
8
|
+
'.jpg': 'image/jpeg',
|
|
9
|
+
'.jpeg': 'image/jpeg',
|
|
10
|
+
'.gif': 'image/gif',
|
|
11
|
+
'.webp': 'image/webp',
|
|
12
|
+
} as const
|
|
13
|
+
|
|
14
|
+
// Caps on URL-fetched images. The agent chooses URLs autonomously, so a
|
|
15
|
+
// malicious or accidentally-large response could otherwise hang the tool
|
|
16
|
+
// (no timeout) or fill memory (no size cap). 20 MB is well above any
|
|
17
|
+
// reasonable screenshot/photo and well below container memory budgets;
|
|
18
|
+
// 30 s is generous for a single HTTP image fetch over a slow link.
|
|
19
|
+
export const URL_FETCH_TIMEOUT_MS = 30_000
|
|
20
|
+
export const URL_FETCH_MAX_BYTES = 20 * 1024 * 1024
|
|
21
|
+
|
|
22
|
+
type Mime = (typeof SUPPORTED_MIME_TYPES)[keyof typeof SUPPORTED_MIME_TYPES]
|
|
23
|
+
|
|
24
|
+
export type ImageInput =
|
|
25
|
+
| { kind: 'url'; url: string }
|
|
26
|
+
| { kind: 'file'; path: string }
|
|
27
|
+
| { kind: 'base64'; data: string; mimeType: string }
|
|
28
|
+
|
|
29
|
+
export const imageInputSchema = z.union([
|
|
30
|
+
z.object({ kind: z.literal('url'), url: z.string().url() }),
|
|
31
|
+
z.object({ kind: z.literal('file'), path: z.string().min(1) }),
|
|
32
|
+
z.object({ kind: z.literal('base64'), data: z.string().min(1), mimeType: z.string().min(1) }),
|
|
33
|
+
])
|
|
34
|
+
|
|
35
|
+
export const multimodalLookerPayloadSchema = z.object({
|
|
36
|
+
images: z.array(imageInputSchema).min(1),
|
|
37
|
+
prompt: z.string().min(1).optional(),
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
export type MultimodalLookerPayload = z.infer<typeof multimodalLookerPayloadSchema>
|
|
41
|
+
|
|
42
|
+
// System prompt is built per-invocation so the agent sees the exact task. With
|
|
43
|
+
// `prompt`: focused Q&A. Without: open-ended description. Tone the same in
|
|
44
|
+
// both branches so callers can plug either form into the same downstream
|
|
45
|
+
// pipeline (the look_at tool just relays the resulting text).
|
|
46
|
+
export function buildMultimodalLookerSystemPrompt(prompt: string | undefined): string {
|
|
47
|
+
const base =
|
|
48
|
+
'You are a multimodal vision subagent. The user message contains one or more images attached to a short instruction.'
|
|
49
|
+
if (prompt !== undefined && prompt.trim() !== '') {
|
|
50
|
+
return [
|
|
51
|
+
base,
|
|
52
|
+
'',
|
|
53
|
+
'Your job is to ANSWER the question below using ONLY what is visible in the attached image(s). Be precise, concrete, and faithful to the visual content. If the image does not contain enough information to answer, say so explicitly.',
|
|
54
|
+
'',
|
|
55
|
+
`Question: ${prompt.trim()}`,
|
|
56
|
+
'',
|
|
57
|
+
'Reply with the answer directly. No preamble, no acknowledgement of the task, no markdown headings.',
|
|
58
|
+
].join('\n')
|
|
59
|
+
}
|
|
60
|
+
return [
|
|
61
|
+
base,
|
|
62
|
+
'',
|
|
63
|
+
"Your job is to DESCRIBE the attached image(s) faithfully and in detail. Cover: subject(s), composition, colors, text content (transcribed verbatim if legible), notable visual details, and anything that would help a downstream reader who cannot see the image. Do not speculate beyond what's visible.",
|
|
64
|
+
'',
|
|
65
|
+
'Reply with the description directly. No preamble, no markdown headings, no bullet list unless multiple images.',
|
|
66
|
+
].join('\n')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type ResolvedImage = {
|
|
70
|
+
data: string
|
|
71
|
+
mimeType: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Materializes an ImageInput into the base64-encoded form pi-ai expects.
|
|
75
|
+
// - `url`: passthrough; pi-ai's image content does not accept URLs, so we fetch
|
|
76
|
+
// the bytes and base64-encode here (lazy; only when the tool is invoked).
|
|
77
|
+
// - `file`: read from disk, infer MIME from extension. Path must be absolute or
|
|
78
|
+
// resolvable against the caller's cwd (callers should normalize ahead of
|
|
79
|
+
// time; this function rejects relative paths to avoid ambiguity).
|
|
80
|
+
// - `base64`: passthrough.
|
|
81
|
+
export async function resolveImage(input: ImageInput, signal?: AbortSignal): Promise<ResolvedImage> {
|
|
82
|
+
if (input.kind === 'base64') {
|
|
83
|
+
if (!input.mimeType.startsWith('image/')) {
|
|
84
|
+
throw new Error(`look_at: base64 mimeType must be image/* (got "${input.mimeType}")`)
|
|
85
|
+
}
|
|
86
|
+
return { data: input.data, mimeType: input.mimeType }
|
|
87
|
+
}
|
|
88
|
+
if (input.kind === 'file') {
|
|
89
|
+
if (!isAbsolute(input.path)) {
|
|
90
|
+
throw new Error(`look_at: file path must be absolute (got "${input.path}")`)
|
|
91
|
+
}
|
|
92
|
+
if (!existsSync(input.path)) {
|
|
93
|
+
throw new Error(`look_at: file not found at ${input.path}`)
|
|
94
|
+
}
|
|
95
|
+
const ext = extname(input.path).toLowerCase() as keyof typeof SUPPORTED_MIME_TYPES
|
|
96
|
+
const mimeType = (SUPPORTED_MIME_TYPES[ext] ?? null) as Mime | null
|
|
97
|
+
if (mimeType === null) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`look_at: unsupported image extension "${ext}" for ${input.path} (supported: ${Object.keys(SUPPORTED_MIME_TYPES).join(', ')})`,
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
const bytes = readFileSync(input.path)
|
|
103
|
+
return { data: bytes.toString('base64'), mimeType }
|
|
104
|
+
}
|
|
105
|
+
// URL branch: independent timeout + size cap on top of any caller-provided
|
|
106
|
+
// signal. The two abort signals are merged so the tool's overall abort wins
|
|
107
|
+
// over our timeout AND vice versa.
|
|
108
|
+
const timeoutSignal = AbortSignal.timeout(URL_FETCH_TIMEOUT_MS)
|
|
109
|
+
const mergedSignal = signal !== undefined ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal
|
|
110
|
+
const res = await fetch(input.url, { signal: mergedSignal })
|
|
111
|
+
if (!res.ok) {
|
|
112
|
+
throw new Error(`look_at: failed to fetch ${input.url}: HTTP ${res.status}`)
|
|
113
|
+
}
|
|
114
|
+
const mimeType = res.headers.get('content-type')?.split(';')[0]?.trim() ?? 'application/octet-stream'
|
|
115
|
+
if (!mimeType.startsWith('image/')) {
|
|
116
|
+
throw new Error(`look_at: ${input.url} did not return an image content-type (got "${mimeType}")`)
|
|
117
|
+
}
|
|
118
|
+
// Streaming size check: arrayBuffer() would read the whole body before we
|
|
119
|
+
// could enforce a cap. Read chunk-by-chunk and abort once we cross the
|
|
120
|
+
// limit. Content-Length is checked first when present, but absent or lying
|
|
121
|
+
// headers fall through to the streaming check.
|
|
122
|
+
const declared = Number(res.headers.get('content-length') ?? '')
|
|
123
|
+
if (Number.isFinite(declared) && declared > URL_FETCH_MAX_BYTES) {
|
|
124
|
+
throw new Error(`look_at: ${input.url} response too large (${declared} bytes > ${URL_FETCH_MAX_BYTES} cap)`)
|
|
125
|
+
}
|
|
126
|
+
const reader = res.body?.getReader()
|
|
127
|
+
if (reader === undefined) {
|
|
128
|
+
throw new Error(`look_at: ${input.url} returned an empty body`)
|
|
129
|
+
}
|
|
130
|
+
const chunks: Uint8Array[] = []
|
|
131
|
+
let total = 0
|
|
132
|
+
while (true) {
|
|
133
|
+
const { done, value } = await reader.read()
|
|
134
|
+
if (done) break
|
|
135
|
+
if (value === undefined) continue
|
|
136
|
+
total += value.byteLength
|
|
137
|
+
if (total > URL_FETCH_MAX_BYTES) {
|
|
138
|
+
await reader.cancel()
|
|
139
|
+
throw new Error(`look_at: ${input.url} response exceeded ${URL_FETCH_MAX_BYTES}-byte cap`)
|
|
140
|
+
}
|
|
141
|
+
chunks.push(value)
|
|
142
|
+
}
|
|
143
|
+
const buf = Buffer.concat(chunks)
|
|
144
|
+
return { data: buf.toString('base64'), mimeType }
|
|
145
|
+
}
|
|
@@ -30,6 +30,8 @@ import type {
|
|
|
30
30
|
ToolResult,
|
|
31
31
|
} from '@/plugin'
|
|
32
32
|
|
|
33
|
+
import type { SessionOrigin } from './session-origin'
|
|
34
|
+
|
|
33
35
|
type AnyAgentTool =
|
|
34
36
|
| typeof piReadTool
|
|
35
37
|
| typeof piBashTool
|
|
@@ -73,16 +75,37 @@ export type WrapToolOptions = {
|
|
|
73
75
|
sessionId: string
|
|
74
76
|
logger: PluginLogger
|
|
75
77
|
hooks: HookBus
|
|
78
|
+
// Called at tool-execute time (not at wrap time) so channel sessions whose
|
|
79
|
+
// origin mutates per turn surface the current-turn `lastInboundAuthorId`
|
|
80
|
+
// to `tool.before`. Sessions with a fixed origin can pass `() => origin`.
|
|
81
|
+
getOrigin?: () => SessionOrigin | undefined
|
|
76
82
|
}
|
|
77
83
|
|
|
78
84
|
export type WrapSystemToolOptions = {
|
|
79
85
|
agentDir: string
|
|
80
86
|
sessionId: string
|
|
81
87
|
hooks: HookBus
|
|
88
|
+
getOrigin?: () => SessionOrigin | undefined
|
|
82
89
|
}
|
|
83
90
|
|
|
91
|
+
// Zod 4 emits a top-level `"$schema": "https://json-schema.org/draft/2020-12/schema"`
|
|
92
|
+
// pointer on every converted schema. Ajv v8 (used by pi-ai's runtime tool-argument
|
|
93
|
+
// validator and by ModelRegistry's models.json validator) is configured for
|
|
94
|
+
// Draft 7 and rejects unknown `$schema` URIs with:
|
|
95
|
+
//
|
|
96
|
+
// no schema with key or ref "https://json-schema.org/draft/2020-12/schema"
|
|
97
|
+
//
|
|
98
|
+
// That error is raised before the tool's execute is even invoked, so the model
|
|
99
|
+
// sees the failure as a tool-call result and reacts by retrying or falling back
|
|
100
|
+
// to other tools. In the memory-logger / dreaming subagents this meant the
|
|
101
|
+
// `find_entry` tool was permanently broken: the subagent kept falling back to
|
|
102
|
+
// `read(offset=1, limit=2000)` and chunked through entire multi-hundred-KB
|
|
103
|
+
// transcripts on every channel turn. Stripping `$schema` is the minimal,
|
|
104
|
+
// converter-version-independent fix; it leaves the actual JSON-schema body
|
|
105
|
+
// untouched and lets Ajv use its default draft.
|
|
84
106
|
export function zodToToolParameters(schema: z.ZodType<unknown>): TSchema {
|
|
85
|
-
const json = z.toJSONSchema(schema, { io: 'input', reused: 'inline' })
|
|
107
|
+
const json = z.toJSONSchema(schema, { io: 'input', reused: 'inline' }) as Record<string, unknown>
|
|
108
|
+
delete json.$schema
|
|
86
109
|
return json as unknown as TSchema
|
|
87
110
|
}
|
|
88
111
|
|
|
@@ -101,11 +124,13 @@ export function wrapPluginTool(tool: Tool<any>, opts: WrapToolOptions): ToolDefi
|
|
|
101
124
|
}
|
|
102
125
|
|
|
103
126
|
const mutableArgs = validated.data as Record<string, unknown>
|
|
127
|
+
const liveOrigin = opts.getOrigin?.()
|
|
104
128
|
const before: ToolBeforeEvent = {
|
|
105
129
|
tool: opts.toolName,
|
|
106
130
|
sessionId: opts.sessionId,
|
|
107
131
|
callId: toolCallId,
|
|
108
132
|
args: mutableArgs,
|
|
133
|
+
...(liveOrigin !== undefined ? { origin: liveOrigin } : {}),
|
|
109
134
|
}
|
|
110
135
|
const blockResult = await opts.hooks.runToolBefore(before)
|
|
111
136
|
if (blockResult !== undefined) {
|
|
@@ -151,11 +176,13 @@ export function wrapSystemTool<TParams extends TSchema, TDetails = unknown, TSta
|
|
|
151
176
|
parameters: withGuardAcknowledgements(tool.name, tool.parameters),
|
|
152
177
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
153
178
|
const mutableArgs = params as Record<string, unknown>
|
|
179
|
+
const liveOrigin = opts.getOrigin?.()
|
|
154
180
|
const blockResult = await opts.hooks.runToolBefore({
|
|
155
181
|
tool: tool.name,
|
|
156
182
|
sessionId: opts.sessionId,
|
|
157
183
|
callId: toolCallId,
|
|
158
184
|
args: mutableArgs,
|
|
185
|
+
...(liveOrigin !== undefined ? { origin: liveOrigin } : {}),
|
|
159
186
|
})
|
|
160
187
|
if (blockResult !== undefined) {
|
|
161
188
|
throw new Error(`blocked: ${blockResult.reason}`)
|
|
@@ -198,11 +225,13 @@ export function wrapSystemAgentTool<TParams extends TSchema, TDetails = unknown>
|
|
|
198
225
|
parameters: withGuardAcknowledgements(tool.name, tool.parameters),
|
|
199
226
|
async execute(toolCallId, params, signal, onUpdate) {
|
|
200
227
|
const mutableArgs = params as Record<string, unknown>
|
|
228
|
+
const liveOrigin = opts.getOrigin?.()
|
|
201
229
|
const blockResult = await opts.hooks.runToolBefore({
|
|
202
230
|
tool: tool.name,
|
|
203
231
|
sessionId: opts.sessionId,
|
|
204
232
|
callId: toolCallId,
|
|
205
233
|
args: mutableArgs,
|
|
234
|
+
...(liveOrigin !== undefined ? { origin: liveOrigin } : {}),
|
|
206
235
|
})
|
|
207
236
|
if (blockResult !== undefined) {
|
|
208
237
|
throw new Error(`blocked: ${blockResult.reason}`)
|
|
@@ -22,7 +22,13 @@ export type ChannelOriginContext = {
|
|
|
22
22
|
|
|
23
23
|
export type SessionOrigin =
|
|
24
24
|
| { kind: 'tui'; sessionId: string }
|
|
25
|
-
| {
|
|
25
|
+
| {
|
|
26
|
+
kind: 'cron'
|
|
27
|
+
jobId: string
|
|
28
|
+
jobKind: 'prompt' | 'exec' | 'subagent'
|
|
29
|
+
scheduledByRole?: string
|
|
30
|
+
scheduledByOrigin?: SessionOrigin | { kind: 'config-file' }
|
|
31
|
+
}
|
|
26
32
|
| {
|
|
27
33
|
kind: 'channel'
|
|
28
34
|
adapter: AdapterId
|
|
@@ -35,24 +41,105 @@ export type SessionOrigin =
|
|
|
35
41
|
participants?: readonly ChannelParticipant[]
|
|
36
42
|
membership?: MembershipCount
|
|
37
43
|
}
|
|
38
|
-
| {
|
|
44
|
+
| {
|
|
45
|
+
kind: 'subagent'
|
|
46
|
+
subagent: string
|
|
47
|
+
parentSessionId: string
|
|
48
|
+
spawnedByRole?: string
|
|
49
|
+
spawnedByOrigin?: SessionOrigin
|
|
50
|
+
}
|
|
39
51
|
|
|
40
52
|
export const PARTICIPANTS_TOP_K = 10
|
|
41
53
|
export const PARTICIPANTS_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
|
|
42
54
|
|
|
43
|
-
|
|
55
|
+
// Each adapter renders mentions differently and the model has to copy the
|
|
56
|
+
// exact shape to actually notify a peer. Until this table existed, the
|
|
57
|
+
// channel origin block hardcoded Discord syntax (`<@USER_ID>`) for every
|
|
58
|
+
// non-Slack adapter, which silently misled KakaoTalk and Telegram sessions
|
|
59
|
+
// into emitting addressing tokens that the platform doesn't recognise. The
|
|
60
|
+
// participants block kept rendering `<@authorId> (name)` lines for the
|
|
61
|
+
// same reason — see `renderParticipants`.
|
|
62
|
+
//
|
|
63
|
+
// `mentionMode` semantics:
|
|
64
|
+
// - 'angle-id' — Slack/Discord: `<@USER_ID>` where USER_ID is the
|
|
65
|
+
// raw `authorId` we already surface in participants.
|
|
66
|
+
// - 'at-username' — Telegram: `@username` plain text. The numeric
|
|
67
|
+
// `authorId` is NOT what gets mentioned; usernames are
|
|
68
|
+
// a separate field that not every user has.
|
|
69
|
+
// - 'alias' — KakaoTalk: type the bot's alias as plain text. The
|
|
70
|
+
// adapter's classifier (`kakaotalk-classify.ts`) does
|
|
71
|
+
// a substring match against configured aliases; there
|
|
72
|
+
// is no in-band syntax to copy.
|
|
73
|
+
type PlatformInfo = {
|
|
74
|
+
displayName: string
|
|
75
|
+
mentionMode: 'angle-id' | 'at-username' | 'alias'
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const PLATFORM_INFO: Record<AdapterId, PlatformInfo> = {
|
|
79
|
+
'slack-bot': { displayName: 'Slack', mentionMode: 'angle-id' },
|
|
80
|
+
'discord-bot': { displayName: 'Discord', mentionMode: 'angle-id' },
|
|
81
|
+
'telegram-bot': { displayName: 'Telegram', mentionMode: 'at-username' },
|
|
82
|
+
kakaotalk: { displayName: 'KakaoTalk', mentionMode: 'alias' },
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function getPlatformInfo(adapter: AdapterId): PlatformInfo {
|
|
86
|
+
return PLATFORM_INFO[adapter]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Compact description of the role the runtime resolved for this session at
|
|
90
|
+
// creation time. Rendered as a single block under the origin text for
|
|
91
|
+
// non-TUI sessions so the agent knows what it can and cannot do without
|
|
92
|
+
// having to call into the PermissionService itself. TUI is omitted because
|
|
93
|
+
// TUI is always `owner` by construction — annotating it would add noise to
|
|
94
|
+
// every interactive session for zero new information.
|
|
95
|
+
//
|
|
96
|
+
// For channel sessions this is a session-creation snapshot. The router
|
|
97
|
+
// re-resolves per-turn for tool gating, but the system prompt is not
|
|
98
|
+
// regenerated mid-session; the role line is accurate at admission and the
|
|
99
|
+
// `typeclaw-permissions` skill spells out how to interpret it on later
|
|
100
|
+
// turns when a different speaker may have spoken last.
|
|
101
|
+
export type SessionRoleContext = {
|
|
102
|
+
role: string
|
|
103
|
+
permissions: readonly string[]
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function renderSessionOrigin(
|
|
107
|
+
origin: SessionOrigin,
|
|
108
|
+
now: number = Date.now(),
|
|
109
|
+
roleContext?: SessionRoleContext,
|
|
110
|
+
): string {
|
|
44
111
|
switch (origin.kind) {
|
|
45
112
|
case 'tui':
|
|
46
|
-
return renderTuiOrigin()
|
|
113
|
+
return withRoleContext(renderTuiOrigin(), roleContext)
|
|
47
114
|
case 'cron':
|
|
48
|
-
return renderCronOrigin(origin)
|
|
115
|
+
return withRoleContext(renderCronOrigin(origin), roleContext)
|
|
49
116
|
case 'channel':
|
|
50
|
-
return renderChannelOrigin(origin, now)
|
|
117
|
+
return withRoleContext(renderChannelOrigin(origin, now), roleContext)
|
|
51
118
|
case 'subagent':
|
|
52
|
-
return renderSubagentOrigin(origin)
|
|
119
|
+
return withRoleContext(renderSubagentOrigin(origin), roleContext)
|
|
53
120
|
}
|
|
54
121
|
}
|
|
55
122
|
|
|
123
|
+
function withRoleContext(block: string, ctx: SessionRoleContext | undefined): string {
|
|
124
|
+
if (ctx === undefined) return block
|
|
125
|
+
return `${block}\n\n${renderRoleContext(ctx)}`
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function renderRoleContext(ctx: SessionRoleContext): string {
|
|
129
|
+
const permList = ctx.permissions.length === 0 ? 'none' : ctx.permissions.map((p) => `\`${p}\``).join(', ')
|
|
130
|
+
return [
|
|
131
|
+
'## Your role in this session',
|
|
132
|
+
'',
|
|
133
|
+
`Role: \`${ctx.role}\`. Permissions: ${permList}.`,
|
|
134
|
+
'',
|
|
135
|
+
'This is the role the runtime resolved at session creation. Tool calls',
|
|
136
|
+
'and channel admission are gated by these permissions; a `blocked:` or',
|
|
137
|
+
'"denied by permissions" message means the current actor lacks the',
|
|
138
|
+
'permission the guard was looking for. See the `typeclaw-permissions`',
|
|
139
|
+
'skill for what each role can do and how to grant access.',
|
|
140
|
+
].join('\n')
|
|
141
|
+
}
|
|
142
|
+
|
|
56
143
|
function renderTuiOrigin(): string {
|
|
57
144
|
return [
|
|
58
145
|
'## Session origin',
|
|
@@ -118,11 +205,11 @@ function renderChannelOrigin(
|
|
|
118
205
|
// only `text` and pulls addressing from this origin. We point the model at
|
|
119
206
|
// it as the default, and keep channel_send as the escape hatch for posting
|
|
120
207
|
// elsewhere (different chat, breaking out of the thread on purpose, etc.).
|
|
121
|
-
const
|
|
208
|
+
const platformInfo = getPlatformInfo(origin.adapter)
|
|
122
209
|
const lines: string[] = [
|
|
123
210
|
'## Session origin',
|
|
124
211
|
'',
|
|
125
|
-
`You are responding inside a ${
|
|
212
|
+
`You are responding inside a ${platformInfo.displayName} channel session. There is no human`,
|
|
126
213
|
'attached to a console here — your only way to communicate with the user',
|
|
127
214
|
'is a tool call. Plain-text output is invisible.',
|
|
128
215
|
]
|
|
@@ -157,11 +244,10 @@ function renderChannelOrigin(
|
|
|
157
244
|
"matching the channel's `allow` rules are accepted (the tool returns",
|
|
158
245
|
'`{ ok: false }` otherwise).',
|
|
159
246
|
'',
|
|
160
|
-
|
|
161
|
-
...renderMentionExample(origin.participants ?? [], platform, now),
|
|
247
|
+
...renderMentionGuidance(platformInfo, origin.participants ?? [], now),
|
|
162
248
|
)
|
|
163
249
|
|
|
164
|
-
const participantsBlock = renderParticipants(origin.participants ?? [], now)
|
|
250
|
+
const participantsBlock = renderParticipants(origin.participants ?? [], platformInfo, now)
|
|
165
251
|
const membershipLine = renderMembershipSummary(origin, now)
|
|
166
252
|
if (membershipLine !== null) lines.push('', membershipLine)
|
|
167
253
|
if (participantsBlock) lines.push('', participantsBlock)
|
|
@@ -189,23 +275,11 @@ function renderMembershipSummary(
|
|
|
189
275
|
return `This channel has approximately ${total} members (about ${membership.humans} humans, ${membership.bots} bots — the bot count is approximate, the full member list was not enumerated because it exceeds the 50-member cap).${caveat} The 10 most recent speakers are listed below.`
|
|
190
276
|
}
|
|
191
277
|
|
|
192
|
-
function
|
|
278
|
+
function renderMentionGuidance(
|
|
279
|
+
platformInfo: PlatformInfo,
|
|
193
280
|
participants: readonly ChannelParticipant[],
|
|
194
|
-
platform: 'Discord' | 'Slack',
|
|
195
281
|
now: number,
|
|
196
282
|
): string[] {
|
|
197
|
-
// Concrete worked example anchored on a REAL participant when possible.
|
|
198
|
-
// Models reliably copy concrete examples; abstract `<@USER_ID>` placeholders
|
|
199
|
-
// get treated as generic instructions and ignored. Prefer a peer bot for
|
|
200
|
-
// the example because that's the addressing case where plain-text names
|
|
201
|
-
// silently fail (the human path is forgiving — humans see their name and
|
|
202
|
-
// respond regardless of mention syntax). Fall back to any non-self
|
|
203
|
-
// participant, then to a generic placeholder if the channel is brand new.
|
|
204
|
-
//
|
|
205
|
-
// Apply the SAME staleness cutoff as `renderParticipants` so we never name
|
|
206
|
-
// someone in the example who isn't shown in the participants block — that
|
|
207
|
-
// would surface a "ghost" name from >7d ago and confuse the model about
|
|
208
|
-
// who is actually around.
|
|
209
283
|
const cutoff = now - PARTICIPANTS_MAX_AGE_MS
|
|
210
284
|
const fresh = [...participants]
|
|
211
285
|
.filter((p) => p.lastMessageAt >= cutoff)
|
|
@@ -214,11 +288,32 @@ function renderMentionExample(
|
|
|
214
288
|
const anyPeer = peerBot ?? fresh[0]
|
|
215
289
|
const exampleId = anyPeer?.authorId ?? '123456789'
|
|
216
290
|
const exampleName = anyPeer?.authorName ?? 'PeerBot'
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
291
|
+
|
|
292
|
+
switch (platformInfo.mentionMode) {
|
|
293
|
+
case 'angle-id':
|
|
294
|
+
return [
|
|
295
|
+
`To mention someone in your reply, use ${platformInfo.displayName} syntax \`<@USER_ID>\`.`,
|
|
296
|
+
`For example, to address ${exampleName} in this conversation, write \`<@${exampleId}> hello\` —`,
|
|
297
|
+
`**not** "${exampleName} hello". Plain-text names do not notify the recipient on ${platformInfo.displayName},`,
|
|
298
|
+
'and other bots in this channel will not see the message as addressed to them.',
|
|
299
|
+
]
|
|
300
|
+
case 'at-username':
|
|
301
|
+
return [
|
|
302
|
+
`To mention someone in your reply, use Telegram syntax \`@username\` in plain text.`,
|
|
303
|
+
`Telegram usernames are a SEPARATE field from \`authorId\`. The \`<@id>\` tokens you see in the participants`,
|
|
304
|
+
'block below are a typeclaw convention for parsing inbound mentions — do not echo them back as outbound mentions.',
|
|
305
|
+
'If you only know an author by their display name and they have no `@username`, address them by display name',
|
|
306
|
+
'and they will see the message via the reply context.',
|
|
307
|
+
]
|
|
308
|
+
case 'alias':
|
|
309
|
+
return [
|
|
310
|
+
'KakaoTalk has no in-band mention syntax. To address someone, just type their display name as plain text;',
|
|
311
|
+
"the participants block below shows display names. To get the BOT's attention from outside this session,",
|
|
312
|
+
"a user types one of the bot's configured aliases — they do not need to copy any token from the participants list.",
|
|
313
|
+
`The \`<@id>\` tokens in the participants block below are a typeclaw convention for parsing inbound mentions —`,
|
|
314
|
+
'do not echo them back as outbound mentions; KakaoTalk would render them as literal text.',
|
|
315
|
+
]
|
|
316
|
+
}
|
|
222
317
|
}
|
|
223
318
|
|
|
224
319
|
function renderConversationLine(origin: {
|
|
@@ -239,26 +334,22 @@ function renderConversationLine(origin: {
|
|
|
239
334
|
return `Conversation: ${chatLabel} in ${workspaceLabel}.`
|
|
240
335
|
}
|
|
241
336
|
|
|
242
|
-
function renderParticipants(
|
|
337
|
+
function renderParticipants(
|
|
338
|
+
participants: readonly ChannelParticipant[],
|
|
339
|
+
platformInfo: PlatformInfo,
|
|
340
|
+
now: number,
|
|
341
|
+
): string {
|
|
243
342
|
const cutoff = now - PARTICIPANTS_MAX_AGE_MS
|
|
244
343
|
const fresh = participants.filter((p) => p.lastMessageAt >= cutoff)
|
|
245
344
|
if (fresh.length === 0) return ''
|
|
246
345
|
|
|
247
346
|
const top = [...fresh].sort((a, b) => b.lastMessageAt - a.lastMessageAt).slice(0, PARTICIPANTS_TOP_K)
|
|
248
347
|
|
|
249
|
-
// Format flipped from `name (id: 123)` to `<@123> (name)` so the model sees
|
|
250
|
-
// the SAME shape it will need to emit when addressing someone — copy-paste
|
|
251
|
-
// the leading `<@id>` token verbatim. The previous format presented the
|
|
252
|
-
// human-readable name first and the ID parenthetically, which (combined
|
|
253
|
-
// with `<@id> (name) [bot]:` in inbound message lines) trained the model
|
|
254
|
-
// to treat `<@id>` as Discord's render-time decoration rather than syntax
|
|
255
|
-
// it must produce. Symptom in the wild: 돌쇠 addressing Winky as "Winky님"
|
|
256
|
-
// (plain text), which never trips Winky's `isBotMention` check, so Winky
|
|
257
|
-
// observes silently and the conversation stalls.
|
|
258
348
|
const lines = ['## Recent participants (last 7 days, top 10 by recency)', '']
|
|
259
349
|
for (const p of top) {
|
|
260
350
|
const ago = formatAgo(now - p.lastMessageAt)
|
|
261
|
-
|
|
351
|
+
const addressing = renderParticipantAddressing(p, platformInfo)
|
|
352
|
+
lines.push(`- ${addressing} — last message: ${ago}, total: ${p.messageCount}`)
|
|
262
353
|
}
|
|
263
354
|
lines.push(
|
|
264
355
|
'',
|
|
@@ -268,14 +359,71 @@ function renderParticipants(participants: readonly ChannelParticipant[], now: nu
|
|
|
268
359
|
'This is **not** the full guild member list, and **not** an audit log',
|
|
269
360
|
'of everyone who ever spoke here.',
|
|
270
361
|
'',
|
|
271
|
-
|
|
272
|
-
'address them — `<@authorId>` works for any author you have seen,',
|
|
273
|
-
'even once. The list is a convenience for "who\'s been around lately,"',
|
|
274
|
-
'not an exhaustive directory.',
|
|
362
|
+
...renderParticipantsTrailing(platformInfo),
|
|
275
363
|
)
|
|
276
364
|
return lines.join('\n')
|
|
277
365
|
}
|
|
278
366
|
|
|
367
|
+
// Per-line addressing token shown for each participant. The shape must match
|
|
368
|
+
// what the model will need to emit when addressing that participant, so the
|
|
369
|
+
// model can copy-paste the leading token verbatim. The previous unconditional
|
|
370
|
+
// `<@id> (name)` format trained the model toward angle-id syntax on every
|
|
371
|
+
// platform — correct for Discord/Slack, wrong for KakaoTalk (no in-band
|
|
372
|
+
// mention syntax) and Telegram (uses `@username`, where `authorId` is a
|
|
373
|
+
// numeric id and NOT the username). See issue #188.
|
|
374
|
+
//
|
|
375
|
+
// Symptom in the wild before PR #183 + this fix: 돌쇠 addressing Winky as
|
|
376
|
+
// "Winky님" (plain text) on Discord, which never trips Winky's `isBotMention`
|
|
377
|
+
// check, so Winky observes silently and the conversation stalls. The
|
|
378
|
+
// angle-id branch here is exactly the fix for that case; the at-username
|
|
379
|
+
// and alias branches keep the platform contract honest for KakaoTalk and
|
|
380
|
+
// Telegram instead of self-contradicting the per-adapter mention guidance
|
|
381
|
+
// produced by `renderMentionGuidance`.
|
|
382
|
+
function renderParticipantAddressing(p: ChannelParticipant, platformInfo: PlatformInfo): string {
|
|
383
|
+
switch (platformInfo.mentionMode) {
|
|
384
|
+
case 'angle-id':
|
|
385
|
+
return `<@${p.authorId}> (${p.authorName})`
|
|
386
|
+
case 'at-username':
|
|
387
|
+
case 'alias':
|
|
388
|
+
return `${p.authorName} (${p.authorId})`
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Closing prose for the participants block. Mirrors the per-platform branch
|
|
393
|
+
// in `renderParticipantAddressing` so the trailing "address them" guidance
|
|
394
|
+
// matches the format the bullet points just demonstrated. The previous
|
|
395
|
+
// unconditional `<@authorId>` prose was the second voice in the
|
|
396
|
+
// self-contradiction noted in issue #188 — it told KakaoTalk/Telegram
|
|
397
|
+
// sessions to address peers with a syntax `renderMentionGuidance` had
|
|
398
|
+
// just told them not to use.
|
|
399
|
+
function renderParticipantsTrailing(platformInfo: PlatformInfo): string[] {
|
|
400
|
+
switch (platformInfo.mentionMode) {
|
|
401
|
+
case 'angle-id':
|
|
402
|
+
return [
|
|
403
|
+
"If a sender in the current turn isn't in the list, you can still",
|
|
404
|
+
'address them — `<@authorId>` works for any author you have seen,',
|
|
405
|
+
'even once. The list is a convenience for "who\'s been around lately,"',
|
|
406
|
+
'not an exhaustive directory.',
|
|
407
|
+
]
|
|
408
|
+
case 'at-username':
|
|
409
|
+
return [
|
|
410
|
+
"If a sender in the current turn isn't in the list, you can still",
|
|
411
|
+
'address them by `@username` — Telegram usernames are a SEPARATE field',
|
|
412
|
+
'from the numeric `authorId` shown in parentheses above, and not every',
|
|
413
|
+
'user has one. The list is a convenience for "who\'s been around',
|
|
414
|
+
'lately," not an exhaustive directory.',
|
|
415
|
+
]
|
|
416
|
+
case 'alias':
|
|
417
|
+
return [
|
|
418
|
+
"If a sender in the current turn isn't in the list, you can still",
|
|
419
|
+
'address them by display name as plain text — KakaoTalk has no in-band',
|
|
420
|
+
'mention syntax, so the `authorId` shown in parentheses above is for',
|
|
421
|
+
'your reference only and must not be echoed back. The list is a',
|
|
422
|
+
'convenience for "who\'s been around lately," not an exhaustive directory.',
|
|
423
|
+
]
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
279
427
|
function formatAgo(ms: number): string {
|
|
280
428
|
const sec = Math.max(0, Math.round(ms / 1000))
|
|
281
429
|
if (sec < 60) return `${sec} seconds ago`
|