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.
- package/LICENSE +21 -0
- package/README.md +134 -0
- package/auth.schema.json +63 -0
- package/cron.schema.json +96 -0
- package/package.json +72 -0
- package/scripts/emit-base-dockerfile.ts +5 -0
- package/scripts/generate-schema.ts +34 -0
- package/secrets.schema.json +63 -0
- package/src/agent/auth.ts +119 -0
- package/src/agent/compaction.ts +35 -0
- package/src/agent/git-nudge.ts +95 -0
- package/src/agent/index.ts +451 -0
- package/src/agent/plugin-tools.ts +269 -0
- package/src/agent/reload-tool.ts +71 -0
- package/src/agent/self.ts +45 -0
- package/src/agent/session-origin.ts +288 -0
- package/src/agent/subagents.ts +253 -0
- package/src/agent/system-prompt.ts +68 -0
- package/src/agent/tools/channel-fetch-attachment.ts +118 -0
- package/src/agent/tools/channel-history.ts +119 -0
- package/src/agent/tools/channel-reply.ts +182 -0
- package/src/agent/tools/channel-send.ts +212 -0
- package/src/agent/tools/ddg.ts +218 -0
- package/src/agent/tools/restart.ts +122 -0
- package/src/agent/tools/stream-snapshot.ts +181 -0
- package/src/agent/tools/webfetch/fetch.ts +102 -0
- package/src/agent/tools/webfetch/index.ts +1 -0
- package/src/agent/tools/webfetch/strategies/grep.ts +70 -0
- package/src/agent/tools/webfetch/strategies/jq.ts +31 -0
- package/src/agent/tools/webfetch/strategies/raw.ts +3 -0
- package/src/agent/tools/webfetch/strategies/readability.ts +30 -0
- package/src/agent/tools/webfetch/strategies/selector.ts +41 -0
- package/src/agent/tools/webfetch/strategies/snapshot.ts +135 -0
- package/src/agent/tools/webfetch/tool.ts +281 -0
- package/src/agent/tools/webfetch/types.ts +33 -0
- package/src/agent/tools/websearch.ts +96 -0
- package/src/agent/tools/wikipedia.ts +52 -0
- package/src/bundled-plugins/agent-browser/dashboard-discovery.ts +170 -0
- package/src/bundled-plugins/agent-browser/dashboard-proxy.ts +421 -0
- package/src/bundled-plugins/agent-browser/index.ts +179 -0
- package/src/bundled-plugins/agent-browser/shim-install.ts +158 -0
- package/src/bundled-plugins/agent-browser/shim.ts +152 -0
- package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +113 -0
- package/src/bundled-plugins/guard/index.ts +26 -0
- package/src/bundled-plugins/guard/policies/non-workspace-write.ts +98 -0
- package/src/bundled-plugins/guard/policies/skill-authoring.ts +185 -0
- package/src/bundled-plugins/guard/policies/uncommitted-changes.ts +85 -0
- package/src/bundled-plugins/guard/policy.ts +18 -0
- package/src/bundled-plugins/memory/README.md +71 -0
- package/src/bundled-plugins/memory/append-tool.ts +84 -0
- package/src/bundled-plugins/memory/dreaming-state.ts +86 -0
- package/src/bundled-plugins/memory/dreaming.ts +470 -0
- package/src/bundled-plugins/memory/fragment-parser.ts +67 -0
- package/src/bundled-plugins/memory/index.ts +238 -0
- package/src/bundled-plugins/memory/load-memory.ts +122 -0
- package/src/bundled-plugins/memory/memory-logger.ts +257 -0
- package/src/bundled-plugins/memory/secret-detector.ts +49 -0
- package/src/bundled-plugins/memory/watermark.ts +15 -0
- package/src/bundled-plugins/security/index.ts +35 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +120 -0
- package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +167 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +488 -0
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +99 -0
- package/src/bundled-plugins/security/policies/secret-exfil-read.ts +127 -0
- package/src/bundled-plugins/security/policies/session-search-secrets.ts +86 -0
- package/src/bundled-plugins/security/policies/ssrf.ts +196 -0
- package/src/bundled-plugins/security/policies/system-prompt-leak.ts +81 -0
- package/src/bundled-plugins/security/policy.ts +9 -0
- package/src/channels/adapters/discord-bot-channel-resolver.ts +77 -0
- package/src/channels/adapters/discord-bot-classify.ts +148 -0
- package/src/channels/adapters/discord-bot.ts +640 -0
- package/src/channels/adapters/kakaotalk-author-resolver.ts +78 -0
- package/src/channels/adapters/kakaotalk-channel-resolver.ts +105 -0
- package/src/channels/adapters/kakaotalk-classify.ts +77 -0
- package/src/channels/adapters/kakaotalk.ts +622 -0
- package/src/channels/adapters/slack-bot-author-resolver.ts +80 -0
- package/src/channels/adapters/slack-bot-channel-resolver.ts +84 -0
- package/src/channels/adapters/slack-bot-classify.ts +213 -0
- package/src/channels/adapters/slack-bot-dedupe.ts +51 -0
- package/src/channels/adapters/slack-bot-time.ts +10 -0
- package/src/channels/adapters/slack-bot.ts +881 -0
- package/src/channels/adapters/telegram-bot-classify.ts +155 -0
- package/src/channels/adapters/telegram-bot-format.ts +309 -0
- package/src/channels/adapters/telegram-bot.ts +604 -0
- package/src/channels/engagement.ts +227 -0
- package/src/channels/index.ts +21 -0
- package/src/channels/manager.ts +292 -0
- package/src/channels/membership-cache.ts +116 -0
- package/src/channels/membership-from-history.ts +53 -0
- package/src/channels/membership.ts +30 -0
- package/src/channels/participants.ts +47 -0
- package/src/channels/persistence.ts +209 -0
- package/src/channels/reloadable.ts +28 -0
- package/src/channels/router.ts +1570 -0
- package/src/channels/schema.ts +273 -0
- package/src/channels/types.ts +160 -0
- package/src/cli/channel.ts +403 -0
- package/src/cli/compose-status.ts +95 -0
- package/src/cli/compose.ts +240 -0
- package/src/cli/hostd.ts +163 -0
- package/src/cli/index.ts +27 -0
- package/src/cli/init.ts +592 -0
- package/src/cli/logs.ts +38 -0
- package/src/cli/reload.ts +68 -0
- package/src/cli/restart.ts +66 -0
- package/src/cli/run.ts +77 -0
- package/src/cli/shell.ts +33 -0
- package/src/cli/start.ts +57 -0
- package/src/cli/status.ts +178 -0
- package/src/cli/stop.ts +31 -0
- package/src/cli/tui.ts +35 -0
- package/src/cli/ui.ts +110 -0
- package/src/commands/index.ts +74 -0
- package/src/compose/discover.ts +43 -0
- package/src/compose/index.ts +25 -0
- package/src/compose/logs.ts +162 -0
- package/src/compose/restart.ts +69 -0
- package/src/compose/start.ts +62 -0
- package/src/compose/status.ts +28 -0
- package/src/compose/stop.ts +43 -0
- package/src/config/config.ts +424 -0
- package/src/config/index.ts +25 -0
- package/src/config/providers.ts +234 -0
- package/src/config/reloadable.ts +47 -0
- package/src/container/index.ts +27 -0
- package/src/container/logs.ts +37 -0
- package/src/container/port.ts +137 -0
- package/src/container/shared.ts +290 -0
- package/src/container/shell.ts +58 -0
- package/src/container/start.ts +670 -0
- package/src/container/status.ts +76 -0
- package/src/container/stop.ts +120 -0
- package/src/container/verify-running.ts +149 -0
- package/src/cron/consumer.ts +138 -0
- package/src/cron/index.ts +54 -0
- package/src/cron/reloadable.ts +64 -0
- package/src/cron/scheduler.ts +200 -0
- package/src/cron/schema.ts +96 -0
- package/src/hostd/client.ts +113 -0
- package/src/hostd/daemon.ts +587 -0
- package/src/hostd/index.ts +25 -0
- package/src/hostd/paths.ts +82 -0
- package/src/hostd/portbroker-manager.ts +101 -0
- package/src/hostd/protocol.ts +48 -0
- package/src/hostd/spawn.ts +224 -0
- package/src/hostd/supervisor.ts +60 -0
- package/src/hostd/tailscale.ts +172 -0
- package/src/hostd/version.ts +115 -0
- package/src/init/dockerfile.ts +327 -0
- package/src/init/ensure-deps.ts +152 -0
- package/src/init/gitignore.ts +46 -0
- package/src/init/hatching.ts +60 -0
- package/src/init/index.ts +786 -0
- package/src/init/kakaotalk-auth.ts +114 -0
- package/src/init/models-dev.ts +130 -0
- package/src/init/oauth-login.ts +74 -0
- package/src/init/packagejson.ts +94 -0
- package/src/init/paths.ts +2 -0
- package/src/init/run-bun-install.ts +20 -0
- package/src/markdown/chunk.ts +299 -0
- package/src/markdown/index.ts +1 -0
- package/src/plugin/context.ts +40 -0
- package/src/plugin/define.ts +35 -0
- package/src/plugin/hooks.ts +204 -0
- package/src/plugin/index.ts +63 -0
- package/src/plugin/loader.ts +111 -0
- package/src/plugin/manager.ts +136 -0
- package/src/plugin/registry.ts +145 -0
- package/src/plugin/skills.ts +62 -0
- package/src/plugin/types.ts +172 -0
- package/src/portbroker/bind-with-forward.ts +102 -0
- package/src/portbroker/container-server.ts +305 -0
- package/src/portbroker/forward-result-bus.ts +36 -0
- package/src/portbroker/hostd-client.ts +443 -0
- package/src/portbroker/index.ts +33 -0
- package/src/portbroker/policy.ts +24 -0
- package/src/portbroker/proc-net-tcp.ts +72 -0
- package/src/portbroker/protocol.ts +39 -0
- package/src/reload/client.ts +59 -0
- package/src/reload/index.ts +3 -0
- package/src/reload/registry.ts +60 -0
- package/src/reload/types.ts +13 -0
- package/src/run/bundled-plugins.ts +24 -0
- package/src/run/channel-session-factory.ts +105 -0
- package/src/run/index.ts +432 -0
- package/src/run/plugin-runtime.ts +43 -0
- package/src/run/schema-with-plugins.ts +14 -0
- package/src/secrets/index.ts +13 -0
- package/src/secrets/migrate.ts +95 -0
- package/src/secrets/schema.ts +75 -0
- package/src/secrets/storage.ts +231 -0
- package/src/server/index.ts +436 -0
- package/src/sessions/index.ts +23 -0
- package/src/shared/index.ts +9 -0
- package/src/shared/local-time.ts +21 -0
- package/src/shared/protocol.ts +25 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +87 -0
- package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +64 -0
- package/src/skills/typeclaw-config/SKILL.md +643 -0
- package/src/skills/typeclaw-cron/SKILL.md +159 -0
- package/src/skills/typeclaw-git/SKILL.md +89 -0
- package/src/skills/typeclaw-memory/SKILL.md +174 -0
- package/src/skills/typeclaw-monorepo/SKILL.md +175 -0
- package/src/skills/typeclaw-plugins/SKILL.md +594 -0
- package/src/skills/typeclaw-skills/SKILL.md +246 -0
- package/src/stream/broker.ts +161 -0
- package/src/stream/index.ts +16 -0
- package/src/stream/types.ts +69 -0
- package/src/tui/client.ts +45 -0
- package/src/tui/format.ts +317 -0
- package/src/tui/index.ts +225 -0
- package/src/tui/theme.ts +41 -0
- 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
|
+
}
|