typeclaw 0.1.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.
Files changed (213) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +134 -0
  3. package/auth.schema.json +63 -0
  4. package/cron.schema.json +96 -0
  5. package/package.json +72 -0
  6. package/scripts/emit-base-dockerfile.ts +5 -0
  7. package/scripts/generate-schema.ts +34 -0
  8. package/secrets.schema.json +63 -0
  9. package/src/agent/auth.ts +119 -0
  10. package/src/agent/compaction.ts +35 -0
  11. package/src/agent/git-nudge.ts +95 -0
  12. package/src/agent/index.ts +451 -0
  13. package/src/agent/plugin-tools.ts +269 -0
  14. package/src/agent/reload-tool.ts +71 -0
  15. package/src/agent/self.ts +45 -0
  16. package/src/agent/session-origin.ts +288 -0
  17. package/src/agent/subagents.ts +253 -0
  18. package/src/agent/system-prompt.ts +68 -0
  19. package/src/agent/tools/channel-fetch-attachment.ts +118 -0
  20. package/src/agent/tools/channel-history.ts +119 -0
  21. package/src/agent/tools/channel-reply.ts +182 -0
  22. package/src/agent/tools/channel-send.ts +212 -0
  23. package/src/agent/tools/ddg.ts +218 -0
  24. package/src/agent/tools/restart.ts +122 -0
  25. package/src/agent/tools/stream-snapshot.ts +181 -0
  26. package/src/agent/tools/webfetch/fetch.ts +102 -0
  27. package/src/agent/tools/webfetch/index.ts +1 -0
  28. package/src/agent/tools/webfetch/strategies/grep.ts +70 -0
  29. package/src/agent/tools/webfetch/strategies/jq.ts +31 -0
  30. package/src/agent/tools/webfetch/strategies/raw.ts +3 -0
  31. package/src/agent/tools/webfetch/strategies/readability.ts +30 -0
  32. package/src/agent/tools/webfetch/strategies/selector.ts +41 -0
  33. package/src/agent/tools/webfetch/strategies/snapshot.ts +135 -0
  34. package/src/agent/tools/webfetch/tool.ts +281 -0
  35. package/src/agent/tools/webfetch/types.ts +33 -0
  36. package/src/agent/tools/websearch.ts +96 -0
  37. package/src/agent/tools/wikipedia.ts +52 -0
  38. package/src/bundled-plugins/agent-browser/dashboard-discovery.ts +170 -0
  39. package/src/bundled-plugins/agent-browser/dashboard-proxy.ts +421 -0
  40. package/src/bundled-plugins/agent-browser/index.ts +179 -0
  41. package/src/bundled-plugins/agent-browser/shim-install.ts +158 -0
  42. package/src/bundled-plugins/agent-browser/shim.ts +152 -0
  43. package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +113 -0
  44. package/src/bundled-plugins/guard/index.ts +26 -0
  45. package/src/bundled-plugins/guard/policies/non-workspace-write.ts +98 -0
  46. package/src/bundled-plugins/guard/policies/skill-authoring.ts +185 -0
  47. package/src/bundled-plugins/guard/policies/uncommitted-changes.ts +85 -0
  48. package/src/bundled-plugins/guard/policy.ts +18 -0
  49. package/src/bundled-plugins/memory/README.md +71 -0
  50. package/src/bundled-plugins/memory/append-tool.ts +84 -0
  51. package/src/bundled-plugins/memory/dreaming-state.ts +86 -0
  52. package/src/bundled-plugins/memory/dreaming.ts +470 -0
  53. package/src/bundled-plugins/memory/fragment-parser.ts +67 -0
  54. package/src/bundled-plugins/memory/index.ts +238 -0
  55. package/src/bundled-plugins/memory/load-memory.ts +122 -0
  56. package/src/bundled-plugins/memory/memory-logger.ts +257 -0
  57. package/src/bundled-plugins/memory/secret-detector.ts +49 -0
  58. package/src/bundled-plugins/memory/watermark.ts +15 -0
  59. package/src/bundled-plugins/security/index.ts +35 -0
  60. package/src/bundled-plugins/security/policies/git-exfil.ts +120 -0
  61. package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +167 -0
  62. package/src/bundled-plugins/security/policies/prompt-injection.ts +488 -0
  63. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +99 -0
  64. package/src/bundled-plugins/security/policies/secret-exfil-read.ts +127 -0
  65. package/src/bundled-plugins/security/policies/session-search-secrets.ts +86 -0
  66. package/src/bundled-plugins/security/policies/ssrf.ts +196 -0
  67. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +81 -0
  68. package/src/bundled-plugins/security/policy.ts +9 -0
  69. package/src/channels/adapters/discord-bot-channel-resolver.ts +77 -0
  70. package/src/channels/adapters/discord-bot-classify.ts +148 -0
  71. package/src/channels/adapters/discord-bot.ts +640 -0
  72. package/src/channels/adapters/kakaotalk-author-resolver.ts +78 -0
  73. package/src/channels/adapters/kakaotalk-channel-resolver.ts +105 -0
  74. package/src/channels/adapters/kakaotalk-classify.ts +77 -0
  75. package/src/channels/adapters/kakaotalk.ts +622 -0
  76. package/src/channels/adapters/slack-bot-author-resolver.ts +80 -0
  77. package/src/channels/adapters/slack-bot-channel-resolver.ts +84 -0
  78. package/src/channels/adapters/slack-bot-classify.ts +213 -0
  79. package/src/channels/adapters/slack-bot-dedupe.ts +51 -0
  80. package/src/channels/adapters/slack-bot-time.ts +10 -0
  81. package/src/channels/adapters/slack-bot.ts +881 -0
  82. package/src/channels/adapters/telegram-bot-classify.ts +155 -0
  83. package/src/channels/adapters/telegram-bot-format.ts +309 -0
  84. package/src/channels/adapters/telegram-bot.ts +604 -0
  85. package/src/channels/engagement.ts +227 -0
  86. package/src/channels/index.ts +21 -0
  87. package/src/channels/manager.ts +292 -0
  88. package/src/channels/membership-cache.ts +116 -0
  89. package/src/channels/membership-from-history.ts +53 -0
  90. package/src/channels/membership.ts +30 -0
  91. package/src/channels/participants.ts +47 -0
  92. package/src/channels/persistence.ts +209 -0
  93. package/src/channels/reloadable.ts +28 -0
  94. package/src/channels/router.ts +1570 -0
  95. package/src/channels/schema.ts +273 -0
  96. package/src/channels/types.ts +160 -0
  97. package/src/cli/channel.ts +403 -0
  98. package/src/cli/compose-status.ts +95 -0
  99. package/src/cli/compose.ts +240 -0
  100. package/src/cli/hostd.ts +163 -0
  101. package/src/cli/index.ts +27 -0
  102. package/src/cli/init.ts +592 -0
  103. package/src/cli/logs.ts +38 -0
  104. package/src/cli/reload.ts +68 -0
  105. package/src/cli/restart.ts +66 -0
  106. package/src/cli/run.ts +77 -0
  107. package/src/cli/shell.ts +33 -0
  108. package/src/cli/start.ts +57 -0
  109. package/src/cli/status.ts +178 -0
  110. package/src/cli/stop.ts +31 -0
  111. package/src/cli/tui.ts +35 -0
  112. package/src/cli/ui.ts +110 -0
  113. package/src/commands/index.ts +74 -0
  114. package/src/compose/discover.ts +43 -0
  115. package/src/compose/index.ts +25 -0
  116. package/src/compose/logs.ts +162 -0
  117. package/src/compose/restart.ts +69 -0
  118. package/src/compose/start.ts +62 -0
  119. package/src/compose/status.ts +28 -0
  120. package/src/compose/stop.ts +43 -0
  121. package/src/config/config.ts +424 -0
  122. package/src/config/index.ts +25 -0
  123. package/src/config/providers.ts +234 -0
  124. package/src/config/reloadable.ts +47 -0
  125. package/src/container/index.ts +27 -0
  126. package/src/container/logs.ts +37 -0
  127. package/src/container/port.ts +137 -0
  128. package/src/container/shared.ts +290 -0
  129. package/src/container/shell.ts +58 -0
  130. package/src/container/start.ts +670 -0
  131. package/src/container/status.ts +76 -0
  132. package/src/container/stop.ts +120 -0
  133. package/src/container/verify-running.ts +149 -0
  134. package/src/cron/consumer.ts +138 -0
  135. package/src/cron/index.ts +54 -0
  136. package/src/cron/reloadable.ts +64 -0
  137. package/src/cron/scheduler.ts +200 -0
  138. package/src/cron/schema.ts +96 -0
  139. package/src/hostd/client.ts +113 -0
  140. package/src/hostd/daemon.ts +587 -0
  141. package/src/hostd/index.ts +25 -0
  142. package/src/hostd/paths.ts +82 -0
  143. package/src/hostd/portbroker-manager.ts +101 -0
  144. package/src/hostd/protocol.ts +48 -0
  145. package/src/hostd/spawn.ts +224 -0
  146. package/src/hostd/supervisor.ts +60 -0
  147. package/src/hostd/tailscale.ts +172 -0
  148. package/src/hostd/version.ts +115 -0
  149. package/src/init/dockerfile.ts +327 -0
  150. package/src/init/ensure-deps.ts +152 -0
  151. package/src/init/gitignore.ts +46 -0
  152. package/src/init/hatching.ts +60 -0
  153. package/src/init/index.ts +786 -0
  154. package/src/init/kakaotalk-auth.ts +114 -0
  155. package/src/init/models-dev.ts +130 -0
  156. package/src/init/oauth-login.ts +74 -0
  157. package/src/init/packagejson.ts +94 -0
  158. package/src/init/paths.ts +2 -0
  159. package/src/init/run-bun-install.ts +20 -0
  160. package/src/markdown/chunk.ts +299 -0
  161. package/src/markdown/index.ts +1 -0
  162. package/src/plugin/context.ts +40 -0
  163. package/src/plugin/define.ts +35 -0
  164. package/src/plugin/hooks.ts +204 -0
  165. package/src/plugin/index.ts +63 -0
  166. package/src/plugin/loader.ts +111 -0
  167. package/src/plugin/manager.ts +136 -0
  168. package/src/plugin/registry.ts +145 -0
  169. package/src/plugin/skills.ts +62 -0
  170. package/src/plugin/types.ts +172 -0
  171. package/src/portbroker/bind-with-forward.ts +102 -0
  172. package/src/portbroker/container-server.ts +305 -0
  173. package/src/portbroker/forward-result-bus.ts +36 -0
  174. package/src/portbroker/hostd-client.ts +443 -0
  175. package/src/portbroker/index.ts +33 -0
  176. package/src/portbroker/policy.ts +24 -0
  177. package/src/portbroker/proc-net-tcp.ts +72 -0
  178. package/src/portbroker/protocol.ts +39 -0
  179. package/src/reload/client.ts +59 -0
  180. package/src/reload/index.ts +3 -0
  181. package/src/reload/registry.ts +60 -0
  182. package/src/reload/types.ts +13 -0
  183. package/src/run/bundled-plugins.ts +24 -0
  184. package/src/run/channel-session-factory.ts +105 -0
  185. package/src/run/index.ts +432 -0
  186. package/src/run/plugin-runtime.ts +43 -0
  187. package/src/run/schema-with-plugins.ts +14 -0
  188. package/src/secrets/index.ts +13 -0
  189. package/src/secrets/migrate.ts +95 -0
  190. package/src/secrets/schema.ts +75 -0
  191. package/src/secrets/storage.ts +231 -0
  192. package/src/server/index.ts +436 -0
  193. package/src/sessions/index.ts +23 -0
  194. package/src/shared/index.ts +9 -0
  195. package/src/shared/local-time.ts +21 -0
  196. package/src/shared/protocol.ts +25 -0
  197. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +87 -0
  198. package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +64 -0
  199. package/src/skills/typeclaw-config/SKILL.md +643 -0
  200. package/src/skills/typeclaw-cron/SKILL.md +159 -0
  201. package/src/skills/typeclaw-git/SKILL.md +89 -0
  202. package/src/skills/typeclaw-memory/SKILL.md +174 -0
  203. package/src/skills/typeclaw-monorepo/SKILL.md +175 -0
  204. package/src/skills/typeclaw-plugins/SKILL.md +594 -0
  205. package/src/skills/typeclaw-skills/SKILL.md +246 -0
  206. package/src/stream/broker.ts +161 -0
  207. package/src/stream/index.ts +16 -0
  208. package/src/stream/types.ts +69 -0
  209. package/src/tui/client.ts +45 -0
  210. package/src/tui/format.ts +317 -0
  211. package/src/tui/index.ts +225 -0
  212. package/src/tui/theme.ts +41 -0
  213. package/typeclaw.schema.json +826 -0
@@ -0,0 +1,269 @@
1
+ import type { AgentTool } from '@mariozechner/pi-agent-core'
2
+ import {
3
+ bashTool as piBashTool,
4
+ defineTool as piDefineTool,
5
+ editTool as piEditTool,
6
+ findTool as piFindTool,
7
+ grepTool as piGrepTool,
8
+ lsTool as piLsTool,
9
+ readTool as piReadTool,
10
+ writeTool as piWriteTool,
11
+ } from '@mariozechner/pi-coding-agent'
12
+ import type { ToolDefinition } from '@mariozechner/pi-coding-agent'
13
+ import type { Static, TSchema } from '@sinclair/typebox'
14
+ import { Type } from '@sinclair/typebox'
15
+ import { z } from 'zod'
16
+
17
+ import {
18
+ ACKNOWLEDGE_GUARDS,
19
+ checkNonWorkspaceWriteGuard,
20
+ checkSkillAuthoringGuard,
21
+ } from '@/bundled-plugins/guard/policy'
22
+ import type {
23
+ BuiltinToolRef,
24
+ ContentPart,
25
+ HookBus,
26
+ PluginLogger,
27
+ Tool,
28
+ ToolBeforeEvent,
29
+ ToolContext,
30
+ ToolResult,
31
+ } from '@/plugin'
32
+
33
+ type AnyAgentTool =
34
+ | typeof piReadTool
35
+ | typeof piBashTool
36
+ | typeof piEditTool
37
+ | typeof piWriteTool
38
+ | typeof piGrepTool
39
+ | typeof piFindTool
40
+ | typeof piLsTool
41
+
42
+ const ACKNOWLEDGE_GUARDS_SCHEMA = Type.Optional(
43
+ Type.Object(
44
+ {
45
+ nonWorkspaceWrite: Type.Optional(Type.Boolean()),
46
+ },
47
+ { additionalProperties: false },
48
+ ),
49
+ )
50
+
51
+ const BUILTIN_TOOL_MAP: Record<string, AnyAgentTool> = {
52
+ bash: piBashTool,
53
+ edit: piEditTool,
54
+ find: piFindTool,
55
+ grep: piGrepTool,
56
+ ls: piLsTool,
57
+ read: piReadTool,
58
+ write: piWriteTool,
59
+ }
60
+
61
+ export function resolveBuiltinToolRefs(refs: BuiltinToolRef[]): AnyAgentTool[] {
62
+ return refs.map((ref) => {
63
+ const tool = BUILTIN_TOOL_MAP[ref.__builtinTool]
64
+ if (!tool) throw new Error(`unknown built-in tool ref: ${ref.__builtinTool}`)
65
+ return tool
66
+ })
67
+ }
68
+
69
+ export type WrapToolOptions = {
70
+ pluginName: string
71
+ toolName: string
72
+ agentDir: string
73
+ sessionId: string
74
+ logger: PluginLogger
75
+ hooks: HookBus
76
+ }
77
+
78
+ export type WrapSystemToolOptions = {
79
+ agentDir: string
80
+ sessionId: string
81
+ hooks: HookBus
82
+ }
83
+
84
+ export function zodToToolParameters(schema: z.ZodType<unknown>): TSchema {
85
+ const json = z.toJSONSchema(schema, { io: 'input', reused: 'inline' })
86
+ return json as unknown as TSchema
87
+ }
88
+
89
+ export function wrapPluginTool(tool: Tool<any>, opts: WrapToolOptions): ToolDefinition {
90
+ const parameters = zodToToolParameters(tool.parameters)
91
+
92
+ return piDefineTool({
93
+ name: opts.toolName,
94
+ label: opts.toolName,
95
+ description: tool.description,
96
+ parameters,
97
+ async execute(toolCallId, params, signal) {
98
+ const validated = tool.parameters.safeParse(params)
99
+ if (!validated.success) {
100
+ return errorResult(`invalid arguments: ${validated.error.message}`)
101
+ }
102
+
103
+ const mutableArgs = validated.data as Record<string, unknown>
104
+ const before: ToolBeforeEvent = {
105
+ tool: opts.toolName,
106
+ sessionId: opts.sessionId,
107
+ callId: toolCallId,
108
+ args: mutableArgs,
109
+ }
110
+ const blockResult = await opts.hooks.runToolBefore(before)
111
+ if (blockResult !== undefined) {
112
+ return errorResult(`blocked: ${blockResult.reason}`)
113
+ }
114
+
115
+ const toolCtx: ToolContext = {
116
+ signal,
117
+ sessionId: opts.sessionId,
118
+ agentDir: opts.agentDir,
119
+ logger: opts.logger,
120
+ }
121
+
122
+ let result: ToolResult
123
+ try {
124
+ result = await tool.execute(before.args, toolCtx)
125
+ } catch (err) {
126
+ const message = err instanceof Error ? err.message : String(err)
127
+ return errorResult(message)
128
+ }
129
+
130
+ await opts.hooks.runToolAfter({
131
+ tool: opts.toolName,
132
+ sessionId: opts.sessionId,
133
+ callId: toolCallId,
134
+ result,
135
+ })
136
+
137
+ return {
138
+ content: result.content as ContentPart[],
139
+ details: result.details,
140
+ }
141
+ },
142
+ })
143
+ }
144
+
145
+ export function wrapSystemTool<TParams extends TSchema, TDetails = unknown, TState = unknown>(
146
+ tool: ToolDefinition<TParams, TDetails, TState>,
147
+ opts: WrapSystemToolOptions,
148
+ ): ToolDefinition<TParams, TDetails, TState> {
149
+ return piDefineTool({
150
+ ...tool,
151
+ parameters: withGuardAcknowledgements(tool.name, tool.parameters),
152
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
153
+ const mutableArgs = params as Record<string, unknown>
154
+ const blockResult = await opts.hooks.runToolBefore({
155
+ tool: tool.name,
156
+ sessionId: opts.sessionId,
157
+ callId: toolCallId,
158
+ args: mutableArgs,
159
+ })
160
+ if (blockResult !== undefined) {
161
+ throw new Error(`blocked: ${blockResult.reason}`)
162
+ }
163
+ const guardResult = await runFinalWriteGuards({
164
+ tool: tool.name,
165
+ args: mutableArgs,
166
+ agentDir: opts.agentDir,
167
+ })
168
+ if (guardResult !== undefined) {
169
+ throw new Error(`blocked: ${guardResult.reason}`)
170
+ }
171
+ stripGuardAcknowledgements(mutableArgs)
172
+
173
+ const result = await tool.execute(toolCallId, mutableArgs as Static<TParams>, signal, onUpdate, ctx)
174
+ const hookResult: ToolResult = {
175
+ content: result.content as ContentPart[],
176
+ details: result.details,
177
+ }
178
+ await opts.hooks.runToolAfter({
179
+ tool: tool.name,
180
+ sessionId: opts.sessionId,
181
+ callId: toolCallId,
182
+ result: hookResult,
183
+ })
184
+ return {
185
+ content: hookResult.content,
186
+ details: hookResult.details as TDetails,
187
+ }
188
+ },
189
+ })
190
+ }
191
+
192
+ export function wrapSystemAgentTool<TParams extends TSchema, TDetails = unknown>(
193
+ tool: AgentTool<TParams, TDetails>,
194
+ opts: WrapSystemToolOptions,
195
+ ): AgentTool<TParams, TDetails> {
196
+ return {
197
+ ...tool,
198
+ parameters: withGuardAcknowledgements(tool.name, tool.parameters),
199
+ async execute(toolCallId, params, signal, onUpdate) {
200
+ const mutableArgs = params as Record<string, unknown>
201
+ const blockResult = await opts.hooks.runToolBefore({
202
+ tool: tool.name,
203
+ sessionId: opts.sessionId,
204
+ callId: toolCallId,
205
+ args: mutableArgs,
206
+ })
207
+ if (blockResult !== undefined) {
208
+ throw new Error(`blocked: ${blockResult.reason}`)
209
+ }
210
+ const guardResult = await runFinalWriteGuards({
211
+ tool: tool.name,
212
+ args: mutableArgs,
213
+ agentDir: opts.agentDir,
214
+ })
215
+ if (guardResult !== undefined) {
216
+ throw new Error(`blocked: ${guardResult.reason}`)
217
+ }
218
+ stripGuardAcknowledgements(mutableArgs)
219
+
220
+ const result = await tool.execute(toolCallId, mutableArgs as Static<TParams>, signal, onUpdate)
221
+ const hookResult: ToolResult = {
222
+ content: result.content as ContentPart[],
223
+ details: result.details,
224
+ }
225
+ await opts.hooks.runToolAfter({
226
+ tool: tool.name,
227
+ sessionId: opts.sessionId,
228
+ callId: toolCallId,
229
+ result: hookResult,
230
+ })
231
+ return {
232
+ content: hookResult.content,
233
+ details: hookResult.details as TDetails,
234
+ }
235
+ },
236
+ }
237
+ }
238
+
239
+ function errorResult(message: string) {
240
+ return {
241
+ content: [{ type: 'text' as const, text: message }],
242
+ details: { error: true, message },
243
+ isError: true,
244
+ }
245
+ }
246
+
247
+ async function runFinalWriteGuards(options: { tool: string; args: Record<string, unknown>; agentDir: string }) {
248
+ return (await checkSkillAuthoringGuard(options)) ?? checkNonWorkspaceWriteGuard(options)
249
+ }
250
+
251
+ function withGuardAcknowledgements<TParams extends TSchema>(toolName: string, parameters: TParams): TParams {
252
+ if (toolName !== 'write' && toolName !== 'edit') return parameters
253
+
254
+ const schema = parameters as Record<string, unknown>
255
+ const properties = schema.properties
256
+ if (!properties || typeof properties !== 'object' || Array.isArray(properties)) return parameters
257
+
258
+ return {
259
+ ...schema,
260
+ properties: {
261
+ ...(properties as Record<string, unknown>),
262
+ [ACKNOWLEDGE_GUARDS]: ACKNOWLEDGE_GUARDS_SCHEMA,
263
+ },
264
+ } as unknown as TParams
265
+ }
266
+
267
+ function stripGuardAcknowledgements(args: Record<string, unknown>): void {
268
+ delete args[ACKNOWLEDGE_GUARDS]
269
+ }
@@ -0,0 +1,71 @@
1
+ import { defineTool } from '@mariozechner/pi-coding-agent'
2
+ import { Type } from '@sinclair/typebox'
3
+
4
+ import type { ReloadRegistry, ReloadResult } from '@/reload'
5
+
6
+ export type CreateReloadToolOptions = {
7
+ registry: ReloadRegistry
8
+ }
9
+
10
+ export function createReloadTool({ registry }: CreateReloadToolOptions) {
11
+ return defineTool({
12
+ name: 'reload',
13
+ label: 'Reload',
14
+ description:
15
+ 'Reload typeclaw subsystems whose on-disk source has changed. Each reloadable is ' +
16
+ 'all-or-nothing: invalid input leaves its live state unchanged and the failure reason is ' +
17
+ "reported in that scope's result. Boot-only config fields (port, mounts, memory.idleMs) " +
18
+ 'are reported as restart-required. Safe to call any time. ' +
19
+ 'Without a scope arg, runs every registered reloadable in registration order so later ' +
20
+ 'scopes observe earlier swaps (e.g. cron sees a freshly-loaded plugins registry). ' +
21
+ 'With a scope arg, runs only that one reloadable.',
22
+ parameters: Type.Object({
23
+ scope: Type.Optional(
24
+ Type.String({
25
+ description:
26
+ 'Optional reload scope name. Common scopes: "config" (typeclaw.json), ' +
27
+ '"plugins" (re-resolve and re-run plugin factories), "skills" (read-only diagnostic ' +
28
+ 'reporting which skills are visible to a new session), "cron" (cron.json). ' +
29
+ 'Omit to reload all scopes.',
30
+ }),
31
+ ),
32
+ }),
33
+ execute: async (_id, args) => {
34
+ const items = registry.list()
35
+ if (items.length === 0) {
36
+ return {
37
+ content: [{ type: 'text', text: 'nothing to reload (no reloadable subsystems registered)' }],
38
+ details: { results: [] },
39
+ }
40
+ }
41
+
42
+ const scope = (args as { scope?: string }).scope
43
+ if (scope !== undefined && scope.length > 0) {
44
+ const result = await registry.reloadOne(scope)
45
+ return {
46
+ content: [{ type: 'text', text: formatResults([result]) }],
47
+ details: { results: [result] },
48
+ }
49
+ }
50
+
51
+ const { results } = await registry.reloadAll()
52
+ return {
53
+ content: [{ type: 'text', text: formatResults(results) }],
54
+ details: { results },
55
+ }
56
+ },
57
+ })
58
+ }
59
+
60
+ function formatResults(results: ReloadResult[]): string {
61
+ const lines = results.map((r) => {
62
+ if (r.ok) return `[${r.scope}] ok: ${r.summary}`
63
+ return `[${r.scope}] failed: ${r.reason}`
64
+ })
65
+ const failedCount = results.filter((r) => !r.ok).length
66
+ const header =
67
+ failedCount === 0
68
+ ? `Reloaded ${results.length} subsystem(s).`
69
+ : `Reloaded ${results.length} subsystem(s); ${failedCount} failed.`
70
+ return [header, ...lines].join('\n')
71
+ }
@@ -0,0 +1,45 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+
4
+ const MAX_FILE_BYTES = 12 * 1024
5
+
6
+ const SOUL_FRAMING =
7
+ 'If SOUL.md has content below, embody its persona and tone. Avoid stiff, generic replies; follow its guidance unless higher-priority instructions override it.'
8
+
9
+ type FileEntry = {
10
+ name: string
11
+ path: string
12
+ content: string | null
13
+ }
14
+
15
+ export async function loadSelf(agentDir: string): Promise<string> {
16
+ const entries = await Promise.all([readEntry(agentDir, 'IDENTITY.md'), readEntry(agentDir, 'SOUL.md')])
17
+ return renderSection(entries)
18
+ }
19
+
20
+ async function readEntry(agentDir: string, name: string): Promise<FileEntry> {
21
+ const filePath = join(agentDir, name)
22
+ try {
23
+ const raw = await readFile(filePath, 'utf8')
24
+ const trimmed = raw.length > MAX_FILE_BYTES ? `${raw.slice(0, MAX_FILE_BYTES)}\n\n[truncated]` : raw
25
+ return { name, path: filePath, content: trimmed }
26
+ } catch {
27
+ return { name, path: filePath, content: null }
28
+ }
29
+ }
30
+
31
+ function renderSection(entries: FileEntry[]): string {
32
+ const lines = ['# Identity', '', SOUL_FRAMING, '']
33
+ for (const entry of entries) {
34
+ lines.push(`## ${entry.name}`, '')
35
+ if (entry.content === null) {
36
+ lines.push(`[MISSING] Expected at: ${entry.path}`)
37
+ } else if (entry.content.trim() === '') {
38
+ lines.push(`[EMPTY] Present at ${entry.path} but has no content yet.`)
39
+ } else {
40
+ lines.push(entry.content.trimEnd())
41
+ }
42
+ lines.push('')
43
+ }
44
+ return lines.join('\n').trimEnd()
45
+ }
@@ -0,0 +1,288 @@
1
+ import { MEMBERSHIP_FRESHNESS_MS, type MembershipCount } from '@/channels/membership'
2
+ import type { AdapterId } from '@/channels/schema'
3
+
4
+ export type ChannelParticipant = {
5
+ authorId: string
6
+ authorName: string
7
+ firstMessageAt: number
8
+ lastMessageAt: number
9
+ messageCount: number
10
+ // Optional with default false so persisted records from prior versions
11
+ // load cleanly. The solo-human engagement fallback in `decideEngagement`
12
+ // counts only `!isBot` participants, so a missing flag must read as
13
+ // human (current behavior) — never as bot (would silently disable the
14
+ // fallback for legacy channels).
15
+ isBot?: boolean
16
+ }
17
+
18
+ export type ChannelOriginContext = {
19
+ lastInboundAuthorId?: string
20
+ participants?: readonly ChannelParticipant[]
21
+ }
22
+
23
+ export type SessionOrigin =
24
+ | { kind: 'tui'; sessionId: string }
25
+ | { kind: 'cron'; jobId: string; jobKind: 'prompt' | 'exec' | 'subagent' }
26
+ | {
27
+ kind: 'channel'
28
+ adapter: AdapterId
29
+ workspace: string
30
+ workspaceName?: string
31
+ chat: string
32
+ chatName?: string
33
+ thread: string | null
34
+ lastInboundAuthorId?: string
35
+ participants?: readonly ChannelParticipant[]
36
+ membership?: MembershipCount
37
+ }
38
+ | { kind: 'subagent'; subagent: string; parentSessionId: string }
39
+
40
+ export const PARTICIPANTS_TOP_K = 10
41
+ export const PARTICIPANTS_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
42
+
43
+ export function renderSessionOrigin(origin: SessionOrigin, now: number = Date.now()): string {
44
+ switch (origin.kind) {
45
+ case 'tui':
46
+ return renderTuiOrigin()
47
+ case 'cron':
48
+ return renderCronOrigin(origin)
49
+ case 'channel':
50
+ return renderChannelOrigin(origin, now)
51
+ case 'subagent':
52
+ return renderSubagentOrigin(origin)
53
+ }
54
+ }
55
+
56
+ function renderTuiOrigin(): string {
57
+ return [
58
+ '## Session origin',
59
+ '',
60
+ 'You are running in the TUI session that the operator is currently',
61
+ 'attached to. Verbose explanations are welcome. The operator can see',
62
+ 'your tool calls and outputs in real time.',
63
+ ].join('\n')
64
+ }
65
+
66
+ function renderCronOrigin(origin: { jobId: string; jobKind: 'prompt' | 'exec' | 'subagent' }): string {
67
+ return [
68
+ '## Session origin',
69
+ '',
70
+ 'You are running an unattended cron job.',
71
+ '',
72
+ `- Job ID: \`${origin.jobId}\``,
73
+ `- Job kind: ${origin.jobKind}`,
74
+ '',
75
+ 'No human is watching this turn. Produce side effects (e.g. via',
76
+ '`channel_send`) where appropriate. Do not ask clarifying questions —',
77
+ "the prompt has everything you should need. If you can't proceed, log",
78
+ 'your blockers and exit.',
79
+ ].join('\n')
80
+ }
81
+
82
+ function renderSubagentOrigin(origin: { subagent: string; parentSessionId: string }): string {
83
+ return [
84
+ '## Session origin',
85
+ '',
86
+ `You are a \`${origin.subagent}\` subagent spawned by parent session`,
87
+ `\`${origin.parentSessionId}\`. Stay narrowly within the task you were given.`,
88
+ 'Return cleanly when done; do not sprawl into unrelated work.',
89
+ ].join('\n')
90
+ }
91
+
92
+ function renderChannelOrigin(
93
+ origin: {
94
+ adapter: AdapterId
95
+ workspace: string
96
+ workspaceName?: string
97
+ chat: string
98
+ chatName?: string
99
+ thread: string | null
100
+ participants?: readonly ChannelParticipant[]
101
+ membership?: MembershipCount
102
+ },
103
+ now: number,
104
+ ): string {
105
+ // The OBLIGATION-not-permission framing here exists because Kimi K2.5 Turbo
106
+ // (and likely other models) will otherwise treat short / casual messages as
107
+ // "ambient observation" and emit no tool call. Plain-text output from the
108
+ // agent in a channel session is dead text — there is no human attached to
109
+ // a stdout to read it. The only way to talk to the user is the tool. Making
110
+ // that obligation crisp and pre-filling the addressing fields removed a
111
+ // class of "model finishes silently, no reply ever lands" failures that we
112
+ // could only see in the logs as `prompted` followed by no `outbound`.
113
+ //
114
+ // The original wording told the model to call channel_send and copy
115
+ // adapter/workspace/chat/thread verbatim. Models still routinely dropped
116
+ // `thread`, so the same conversation got bisected into a fresh top-level
117
+ // thread on Slack. channel_reply now exists for that reason: it takes
118
+ // only `text` and pulls addressing from this origin. We point the model at
119
+ // it as the default, and keep channel_send as the escape hatch for posting
120
+ // elsewhere (different chat, breaking out of the thread on purpose, etc.).
121
+ const platform = origin.adapter === 'slack-bot' ? 'Slack' : 'Discord'
122
+ const lines: string[] = [
123
+ '## Session origin',
124
+ '',
125
+ `You are responding inside a ${platform} channel session. There is no human`,
126
+ 'attached to a console here — your only way to communicate with the user',
127
+ 'is a tool call. Plain-text output is invisible.',
128
+ ]
129
+
130
+ const conversationLine = renderConversationLine(origin)
131
+ if (conversationLine !== null) lines.push('', conversationLine)
132
+
133
+ lines.push(
134
+ '',
135
+ '**For every user message in this session, you MUST call `channel_reply`',
136
+ '(or `channel_send`) at least once before ending your turn**, unless the',
137
+ 'user explicitly told you to stay silent. If you intentionally do not',
138
+ 'reply, your entire final visible response must be exactly `NO_REPLY`.',
139
+ 'Any other visible text without a channel tool call is blocked.',
140
+ '',
141
+ 'To reply in this conversation, call `channel_reply({ text })`. Addressing',
142
+ `is filled in from this session, including the thread${origin.thread !== null ? '' : ' (none here — this is a channel-root session)'}, so you don't`,
143
+ 'need to copy any of these fields:',
144
+ '',
145
+ '```json',
146
+ '{',
147
+ ` "adapter": ${JSON.stringify(origin.adapter)},`,
148
+ ` "workspace": ${JSON.stringify(origin.workspace)},`,
149
+ ` "chat": ${JSON.stringify(origin.chat)},`,
150
+ origin.thread !== null ? ` "thread": ${JSON.stringify(origin.thread)}` : ' "thread": null',
151
+ '}',
152
+ '```',
153
+ '',
154
+ 'To post somewhere else (different chat, break out of the current',
155
+ 'thread on purpose, send a DM from this channel session, etc.), use',
156
+ '`channel_send` and pass the addressing fields explicitly. Only chats',
157
+ "matching the channel's `allow` rules are accepted (the tool returns",
158
+ '`{ ok: false }` otherwise).',
159
+ '',
160
+ `To mention someone in your reply, use ${platform} syntax \`<@USER_ID>\`.`,
161
+ ...renderMentionExample(origin.participants ?? [], platform, now),
162
+ )
163
+
164
+ const participantsBlock = renderParticipants(origin.participants ?? [], now)
165
+ const membershipLine = renderMembershipSummary(origin, now)
166
+ if (membershipLine !== null) lines.push('', membershipLine)
167
+ if (participantsBlock) lines.push('', participantsBlock)
168
+
169
+ lines.push('', 'Be concise; chat clients punish multi-paragraph replies.')
170
+ return lines.join('\n')
171
+ }
172
+
173
+ function renderMembershipSummary(
174
+ origin: { adapter: AdapterId; workspace: string; membership?: MembershipCount },
175
+ now: number,
176
+ ): string | null {
177
+ const membership = origin.membership
178
+ if (membership === undefined) return null
179
+
180
+ const total = membership.humans + membership.bots
181
+ const caveat =
182
+ origin.adapter === 'discord-bot' && origin.workspace !== '@dm'
183
+ ? ' (Note: this is the count of guild members; private channels with permission overwrites may have fewer actual viewers.)'
184
+ : ''
185
+ const isExact = !membership.truncated && now - membership.fetchedAt < MEMBERSHIP_FRESHNESS_MS
186
+ if (isExact) {
187
+ return `This channel has ${total} members: ${membership.humans} humans, ${membership.bots} bots.${caveat} The 10 most recent speakers are listed below.`
188
+ }
189
+ 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
+ }
191
+
192
+ function renderMentionExample(
193
+ participants: readonly ChannelParticipant[],
194
+ platform: 'Discord' | 'Slack',
195
+ now: number,
196
+ ): 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
+ const cutoff = now - PARTICIPANTS_MAX_AGE_MS
210
+ const fresh = [...participants]
211
+ .filter((p) => p.lastMessageAt >= cutoff)
212
+ .sort((a, b) => b.lastMessageAt - a.lastMessageAt)
213
+ const peerBot = fresh.find((p) => p.isBot === true)
214
+ const anyPeer = peerBot ?? fresh[0]
215
+ const exampleId = anyPeer?.authorId ?? '123456789'
216
+ 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
+ ]
222
+ }
223
+
224
+ function renderConversationLine(origin: {
225
+ adapter: AdapterId
226
+ workspace: string
227
+ workspaceName?: string
228
+ chat: string
229
+ chatName?: string
230
+ }): string | null {
231
+ const hasChat = origin.chatName !== undefined && origin.chatName !== ''
232
+ const hasWorkspace = origin.workspaceName !== undefined && origin.workspaceName !== ''
233
+ if (!hasChat && !hasWorkspace) return null
234
+
235
+ const chatPrefix = origin.adapter === 'slack-bot' ? '#' : ''
236
+ const chatLabel = hasChat ? `**${chatPrefix}${origin.chatName!}** (${origin.chat})` : `\`${origin.chat}\``
237
+ const workspaceLabel = hasWorkspace ? `**${origin.workspaceName!}** (${origin.workspace})` : `\`${origin.workspace}\``
238
+
239
+ return `Conversation: ${chatLabel} in ${workspaceLabel}.`
240
+ }
241
+
242
+ function renderParticipants(participants: readonly ChannelParticipant[], now: number): string {
243
+ const cutoff = now - PARTICIPANTS_MAX_AGE_MS
244
+ const fresh = participants.filter((p) => p.lastMessageAt >= cutoff)
245
+ if (fresh.length === 0) return ''
246
+
247
+ const top = [...fresh].sort((a, b) => b.lastMessageAt - a.lastMessageAt).slice(0, PARTICIPANTS_TOP_K)
248
+
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
+ const lines = ['## Recent participants (last 7 days, top 10 by recency)', '']
259
+ for (const p of top) {
260
+ const ago = formatAgo(now - p.lastMessageAt)
261
+ lines.push(`- <@${p.authorId}> (${p.authorName}) — last message: ${ago}, total: ${p.messageCount}`)
262
+ }
263
+ lines.push(
264
+ '',
265
+ 'This list is **bounded** — it shows only the 10 most recently active',
266
+ 'authors in this conversation, all of whom have posted in the last 7',
267
+ 'days. Older or less recent authors are not shown even if they exist.',
268
+ 'This is **not** the full guild member list, and **not** an audit log',
269
+ 'of everyone who ever spoke here.',
270
+ '',
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.',
275
+ )
276
+ return lines.join('\n')
277
+ }
278
+
279
+ function formatAgo(ms: number): string {
280
+ const sec = Math.max(0, Math.round(ms / 1000))
281
+ if (sec < 60) return `${sec} seconds ago`
282
+ const min = Math.round(sec / 60)
283
+ if (min < 60) return `${min} minute${min === 1 ? '' : 's'} ago`
284
+ const hr = Math.round(min / 60)
285
+ if (hr < 48) return `${hr} hour${hr === 1 ? '' : 's'} ago`
286
+ const days = Math.round(hr / 24)
287
+ return `${days} day${days === 1 ? '' : 's'} ago`
288
+ }