typeclaw 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/README.md +15 -13
  2. package/auth.schema.json +41 -0
  3. package/cron.schema.json +8 -0
  4. package/package.json +1 -1
  5. package/secrets.schema.json +41 -0
  6. package/src/agent/auth.ts +45 -22
  7. package/src/agent/index.ts +189 -19
  8. package/src/agent/multimodal/index.ts +12 -0
  9. package/src/agent/multimodal/look-at.ts +185 -0
  10. package/src/agent/multimodal/looker.ts +145 -0
  11. package/src/agent/plugin-tools.ts +30 -1
  12. package/src/agent/session-origin.ts +194 -46
  13. package/src/agent/subagents.ts +57 -1
  14. package/src/agent/system-prompt.ts +1 -1
  15. package/src/agent/tool-result-budget.ts +121 -0
  16. package/src/bundled-plugins/backup/index.ts +23 -8
  17. package/src/bundled-plugins/backup/runner.ts +22 -0
  18. package/src/bundled-plugins/memory/README.md +13 -10
  19. package/src/bundled-plugins/memory/append-tool.ts +87 -61
  20. package/src/bundled-plugins/memory/dreaming.ts +137 -7
  21. package/src/bundled-plugins/memory/find-entry-tool.ts +62 -0
  22. package/src/bundled-plugins/memory/fragment-parser.ts +19 -44
  23. package/src/bundled-plugins/memory/index.ts +91 -8
  24. package/src/bundled-plugins/memory/load-memory.ts +74 -34
  25. package/src/bundled-plugins/memory/memory-logger.ts +72 -29
  26. package/src/bundled-plugins/memory/migration.ts +276 -0
  27. package/src/bundled-plugins/memory/stream-events.ts +55 -0
  28. package/src/bundled-plugins/memory/stream-io.ts +63 -0
  29. package/src/bundled-plugins/memory/watermark.ts +48 -8
  30. package/src/bundled-plugins/security/index.ts +103 -10
  31. package/src/bundled-plugins/security/permissions.ts +12 -0
  32. package/src/bundled-plugins/security/policies/git-exfil.ts +51 -18
  33. package/src/bundled-plugins/tool-result-cap/README.md +9 -4
  34. package/src/bundled-plugins/tool-result-cap/cap-jsonl.ts +115 -0
  35. package/src/bundled-plugins/tool-result-cap/cap-result.ts +25 -13
  36. package/src/bundled-plugins/tool-result-cap/index.ts +16 -2
  37. package/src/channels/adapters/discord-bot-classify.ts +2 -6
  38. package/src/channels/adapters/discord-bot.ts +4 -45
  39. package/src/channels/adapters/kakaotalk-classify.ts +3 -7
  40. package/src/channels/adapters/kakaotalk.ts +28 -47
  41. package/src/channels/adapters/slack-bot-classify.ts +2 -6
  42. package/src/channels/adapters/slack-bot.ts +4 -50
  43. package/src/channels/adapters/telegram-bot-classify.ts +8 -10
  44. package/src/channels/adapters/telegram-bot.ts +3 -16
  45. package/src/channels/index.ts +3 -2
  46. package/src/channels/manager.ts +15 -1
  47. package/src/channels/persistence.ts +44 -10
  48. package/src/channels/router.ts +228 -19
  49. package/src/channels/schema.ts +6 -156
  50. package/src/cli/channel.ts +200 -4
  51. package/src/cli/compose-usage.ts +182 -0
  52. package/src/cli/compose.ts +33 -0
  53. package/src/cli/hostd.ts +49 -1
  54. package/src/cli/index.ts +4 -0
  55. package/src/cli/init.ts +809 -300
  56. package/src/cli/model.ts +244 -0
  57. package/src/cli/provider.ts +404 -0
  58. package/src/cli/reload.ts +11 -3
  59. package/src/cli/role.ts +156 -0
  60. package/src/cli/run.ts +3 -1
  61. package/src/cli/tui.ts +13 -3
  62. package/src/cli/usage-args.ts +47 -0
  63. package/src/cli/usage.ts +97 -0
  64. package/src/compose/index.ts +1 -0
  65. package/src/compose/usage.ts +65 -0
  66. package/src/config/config.ts +491 -19
  67. package/src/config/index.ts +15 -1
  68. package/src/config/models-mutation.ts +200 -0
  69. package/src/config/providers-mutation.ts +250 -0
  70. package/src/config/providers.ts +141 -2
  71. package/src/config/reloadable.ts +15 -4
  72. package/src/container/index.ts +6 -1
  73. package/src/container/port.ts +10 -0
  74. package/src/container/require-running.ts +33 -0
  75. package/src/container/start.ts +81 -63
  76. package/src/cron/consumer.ts +22 -2
  77. package/src/cron/index.ts +45 -4
  78. package/src/cron/schema.ts +104 -0
  79. package/src/doctor/checks.ts +51 -34
  80. package/src/doctor/plugin-bridge.ts +28 -4
  81. package/src/git/system-commit.ts +103 -0
  82. package/src/hostd/daemon.ts +16 -0
  83. package/src/hostd/kakao-renewal-manager.ts +223 -0
  84. package/src/hostd/paths.ts +7 -0
  85. package/src/init/dockerfile.ts +36 -10
  86. package/src/init/gitignore.ts +1 -1
  87. package/src/init/index.ts +213 -85
  88. package/src/init/kakaotalk-auth.ts +18 -1
  89. package/src/init/models-dev.ts +26 -1
  90. package/src/init/run-owner-claim.ts +77 -0
  91. package/src/permissions/builtins.ts +70 -0
  92. package/src/permissions/grant.ts +99 -0
  93. package/src/permissions/index.ts +29 -0
  94. package/src/permissions/match-rule.ts +305 -0
  95. package/src/permissions/permissions.ts +196 -0
  96. package/src/permissions/resolve.ts +80 -0
  97. package/src/permissions/schema.ts +79 -0
  98. package/src/plugin/context.ts +8 -4
  99. package/src/plugin/define.ts +2 -0
  100. package/src/plugin/index.ts +2 -0
  101. package/src/plugin/manager.ts +41 -0
  102. package/src/plugin/registry.ts +9 -0
  103. package/src/plugin/types.ts +35 -1
  104. package/src/reload/client.ts +25 -1
  105. package/src/role-claim/client.ts +182 -0
  106. package/src/role-claim/code.ts +53 -0
  107. package/src/role-claim/controller.ts +194 -0
  108. package/src/role-claim/index.ts +19 -0
  109. package/src/role-claim/match-rule.ts +43 -0
  110. package/src/role-claim/pending.ts +100 -0
  111. package/src/run/channel-session-factory.ts +76 -5
  112. package/src/run/index.ts +68 -7
  113. package/src/secrets/encryption.ts +116 -0
  114. package/src/secrets/kakao-renewal.ts +248 -0
  115. package/src/secrets/kakao-store.ts +66 -7
  116. package/src/secrets/keys.ts +173 -0
  117. package/src/secrets/schema.ts +23 -0
  118. package/src/secrets/storage.ts +83 -0
  119. package/src/server/index.ts +198 -71
  120. package/src/shared/index.ts +4 -0
  121. package/src/shared/protocol.ts +27 -0
  122. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
  123. package/src/skills/typeclaw-config/SKILL.md +104 -112
  124. package/src/skills/typeclaw-memory/SKILL.md +9 -9
  125. package/src/skills/typeclaw-permissions/SKILL.md +166 -0
  126. package/src/stream/types.ts +7 -1
  127. package/src/tui/client.ts +66 -5
  128. package/src/tui/index.ts +61 -9
  129. package/src/usage/aggregate.ts +117 -0
  130. package/src/usage/format.ts +30 -0
  131. package/src/usage/index.ts +68 -0
  132. package/src/usage/report.ts +354 -0
  133. package/src/usage/scan.ts +186 -0
  134. package/typeclaw.schema.json +134 -98
@@ -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
- | { kind: 'cron'; jobId: string; jobKind: 'prompt' | 'exec' | 'subagent' }
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
- | { kind: 'subagent'; subagent: string; parentSessionId: string }
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
- export function renderSessionOrigin(origin: SessionOrigin, now: number = Date.now()): string {
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 platform = origin.adapter === 'slack-bot' ? 'Slack' : 'Discord'
208
+ const platformInfo = getPlatformInfo(origin.adapter)
122
209
  const lines: string[] = [
123
210
  '## Session origin',
124
211
  '',
125
- `You are responding inside a ${platform} channel session. There is no human`,
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
- `To mention someone in your reply, use ${platform} syntax \`<@USER_ID>\`.`,
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 renderMentionExample(
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
- return [
218
- `For example, to address ${exampleName} in this conversation, write \`<@${exampleId}> hello\` —`,
219
- `**not** "${exampleName} hello". Plain-text names do not notify the recipient on ${platform},`,
220
- 'and other bots in this channel will not see the message as addressed to them.',
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(participants: readonly ChannelParticipant[], now: number): string {
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
- lines.push(`- <@${p.authorId}> (${p.authorName}) — last message: ${ago}, total: ${p.messageCount}`)
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
- "If a sender in the current turn isn't in the list, you can still",
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`