typeclaw 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -13
- package/auth.schema.json +41 -0
- package/cron.schema.json +8 -0
- package/package.json +1 -1
- package/secrets.schema.json +41 -0
- package/src/agent/auth.ts +45 -22
- package/src/agent/index.ts +189 -19
- package/src/agent/multimodal/index.ts +12 -0
- package/src/agent/multimodal/look-at.ts +185 -0
- package/src/agent/multimodal/looker.ts +145 -0
- package/src/agent/plugin-tools.ts +30 -1
- package/src/agent/session-origin.ts +194 -46
- package/src/agent/subagents.ts +57 -1
- package/src/agent/system-prompt.ts +1 -1
- package/src/agent/tool-result-budget.ts +121 -0
- package/src/bundled-plugins/backup/index.ts +23 -8
- package/src/bundled-plugins/backup/runner.ts +22 -0
- package/src/bundled-plugins/memory/README.md +13 -10
- package/src/bundled-plugins/memory/append-tool.ts +87 -61
- package/src/bundled-plugins/memory/dreaming.ts +137 -7
- package/src/bundled-plugins/memory/find-entry-tool.ts +62 -0
- package/src/bundled-plugins/memory/fragment-parser.ts +19 -44
- package/src/bundled-plugins/memory/index.ts +91 -8
- package/src/bundled-plugins/memory/load-memory.ts +74 -34
- package/src/bundled-plugins/memory/memory-logger.ts +72 -29
- package/src/bundled-plugins/memory/migration.ts +276 -0
- package/src/bundled-plugins/memory/stream-events.ts +55 -0
- package/src/bundled-plugins/memory/stream-io.ts +63 -0
- package/src/bundled-plugins/memory/watermark.ts +48 -8
- package/src/bundled-plugins/security/index.ts +103 -10
- package/src/bundled-plugins/security/permissions.ts +12 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +51 -18
- package/src/bundled-plugins/tool-result-cap/README.md +9 -4
- package/src/bundled-plugins/tool-result-cap/cap-jsonl.ts +115 -0
- package/src/bundled-plugins/tool-result-cap/cap-result.ts +25 -13
- package/src/bundled-plugins/tool-result-cap/index.ts +16 -2
- package/src/channels/adapters/discord-bot-classify.ts +2 -6
- package/src/channels/adapters/discord-bot.ts +4 -45
- package/src/channels/adapters/kakaotalk-classify.ts +3 -7
- package/src/channels/adapters/kakaotalk.ts +28 -47
- package/src/channels/adapters/slack-bot-classify.ts +2 -6
- package/src/channels/adapters/slack-bot.ts +4 -50
- package/src/channels/adapters/telegram-bot-classify.ts +8 -10
- package/src/channels/adapters/telegram-bot.ts +3 -16
- package/src/channels/index.ts +3 -2
- package/src/channels/manager.ts +15 -1
- package/src/channels/persistence.ts +44 -10
- package/src/channels/router.ts +228 -19
- package/src/channels/schema.ts +6 -156
- package/src/cli/channel.ts +200 -4
- package/src/cli/compose-usage.ts +182 -0
- package/src/cli/compose.ts +33 -0
- package/src/cli/hostd.ts +49 -1
- package/src/cli/index.ts +4 -0
- package/src/cli/init.ts +809 -300
- package/src/cli/model.ts +244 -0
- package/src/cli/provider.ts +404 -0
- package/src/cli/reload.ts +11 -3
- package/src/cli/role.ts +156 -0
- package/src/cli/run.ts +3 -1
- package/src/cli/tui.ts +13 -3
- package/src/cli/usage-args.ts +47 -0
- package/src/cli/usage.ts +97 -0
- package/src/compose/index.ts +1 -0
- package/src/compose/usage.ts +65 -0
- package/src/config/config.ts +491 -19
- package/src/config/index.ts +15 -1
- package/src/config/models-mutation.ts +200 -0
- package/src/config/providers-mutation.ts +250 -0
- package/src/config/providers.ts +141 -2
- package/src/config/reloadable.ts +15 -4
- package/src/container/index.ts +6 -1
- package/src/container/port.ts +10 -0
- package/src/container/require-running.ts +33 -0
- package/src/container/start.ts +81 -63
- package/src/cron/consumer.ts +22 -2
- package/src/cron/index.ts +45 -4
- package/src/cron/schema.ts +104 -0
- package/src/doctor/checks.ts +51 -34
- package/src/doctor/plugin-bridge.ts +28 -4
- package/src/git/system-commit.ts +103 -0
- package/src/hostd/daemon.ts +16 -0
- package/src/hostd/kakao-renewal-manager.ts +223 -0
- package/src/hostd/paths.ts +7 -0
- package/src/init/dockerfile.ts +36 -10
- package/src/init/gitignore.ts +1 -1
- package/src/init/index.ts +213 -85
- package/src/init/kakaotalk-auth.ts +18 -1
- package/src/init/models-dev.ts +26 -1
- package/src/init/run-owner-claim.ts +77 -0
- package/src/permissions/builtins.ts +70 -0
- package/src/permissions/grant.ts +99 -0
- package/src/permissions/index.ts +29 -0
- package/src/permissions/match-rule.ts +305 -0
- package/src/permissions/permissions.ts +196 -0
- package/src/permissions/resolve.ts +80 -0
- package/src/permissions/schema.ts +79 -0
- package/src/plugin/context.ts +8 -4
- package/src/plugin/define.ts +2 -0
- package/src/plugin/index.ts +2 -0
- package/src/plugin/manager.ts +41 -0
- package/src/plugin/registry.ts +9 -0
- package/src/plugin/types.ts +35 -1
- package/src/reload/client.ts +25 -1
- package/src/role-claim/client.ts +182 -0
- package/src/role-claim/code.ts +53 -0
- package/src/role-claim/controller.ts +194 -0
- package/src/role-claim/index.ts +19 -0
- package/src/role-claim/match-rule.ts +43 -0
- package/src/role-claim/pending.ts +100 -0
- package/src/run/channel-session-factory.ts +76 -5
- package/src/run/index.ts +68 -7
- package/src/secrets/encryption.ts +116 -0
- package/src/secrets/kakao-renewal.ts +248 -0
- package/src/secrets/kakao-store.ts +66 -7
- package/src/secrets/keys.ts +173 -0
- package/src/secrets/schema.ts +23 -0
- package/src/secrets/storage.ts +83 -0
- package/src/server/index.ts +198 -71
- package/src/shared/index.ts +4 -0
- package/src/shared/protocol.ts +27 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
- package/src/skills/typeclaw-config/SKILL.md +104 -112
- package/src/skills/typeclaw-memory/SKILL.md +9 -9
- package/src/skills/typeclaw-permissions/SKILL.md +166 -0
- package/src/stream/types.ts +7 -1
- package/src/tui/client.ts +66 -5
- package/src/tui/index.ts +61 -9
- package/src/usage/aggregate.ts +117 -0
- package/src/usage/format.ts +30 -0
- package/src/usage/index.ts +68 -0
- package/src/usage/report.ts +354 -0
- package/src/usage/scan.ts +186 -0
- package/typeclaw.schema.json +134 -98
package/src/agent/index.ts
CHANGED
|
@@ -5,8 +5,11 @@ import { fileURLToPath } from 'node:url'
|
|
|
5
5
|
import { createAgentSession, DefaultResourceLoader, SessionManager } from '@mariozechner/pi-coding-agent'
|
|
6
6
|
import type { AgentSession, ToolDefinition } from '@mariozechner/pi-coding-agent'
|
|
7
7
|
|
|
8
|
+
import { loadMemory } from '@/bundled-plugins/memory/load-memory'
|
|
8
9
|
import type { ChannelRouter } from '@/channels/router'
|
|
9
|
-
import { getConfig, resolveModel } from '@/config'
|
|
10
|
+
import { getConfig, resolveModel, resolveProfile } from '@/config'
|
|
11
|
+
import { providerForModelRef } from '@/config/providers'
|
|
12
|
+
import type { PermissionService } from '@/permissions'
|
|
10
13
|
import type {
|
|
11
14
|
BuiltinToolRef,
|
|
12
15
|
HookBus,
|
|
@@ -19,14 +22,21 @@ import { materializeSkills } from '@/plugin'
|
|
|
19
22
|
import type { ReloadRegistry } from '@/reload'
|
|
20
23
|
import type { Stream } from '@/stream'
|
|
21
24
|
|
|
22
|
-
import {
|
|
25
|
+
import { getAuthFor } from './auth'
|
|
23
26
|
import { createCompactionSettingsManager } from './compaction'
|
|
24
27
|
import { renderGitNudge } from './git-nudge'
|
|
28
|
+
import { lookAtTool } from './multimodal'
|
|
25
29
|
import { resolveBuiltinToolRefs, wrapPluginTool, wrapSystemAgentTool, wrapSystemTool } from './plugin-tools'
|
|
26
30
|
import { createReloadTool } from './reload-tool'
|
|
27
31
|
import { loadSelf } from './self'
|
|
28
|
-
import { renderSessionOrigin, type SessionOrigin } from './session-origin'
|
|
32
|
+
import { renderSessionOrigin, type SessionOrigin, type SessionRoleContext } from './session-origin'
|
|
29
33
|
import { DEFAULT_SYSTEM_PROMPT } from './system-prompt'
|
|
34
|
+
import {
|
|
35
|
+
createBudgetState,
|
|
36
|
+
type ToolResultBudget,
|
|
37
|
+
wrapAgentToolWithBudget,
|
|
38
|
+
wrapToolDefinitionWithBudget,
|
|
39
|
+
} from './tool-result-budget'
|
|
30
40
|
import { createChannelFetchAttachmentTool } from './tools/channel-fetch-attachment'
|
|
31
41
|
import { createChannelHistoryTool } from './tools/channel-history'
|
|
32
42
|
import { createChannelReplyTool } from './tools/channel-reply'
|
|
@@ -56,6 +66,15 @@ export type PluginSubagentSelection = {
|
|
|
56
66
|
toolNamePrefix: string
|
|
57
67
|
}
|
|
58
68
|
|
|
69
|
+
// Mutable holder for the live session origin. Pass this when the origin
|
|
70
|
+
// must be updated turn-by-turn after session creation (channel sessions
|
|
71
|
+
// whose `lastInboundAuthorId` changes with each inbound message). Tool
|
|
72
|
+
// wrappers read `.current` at execute time, not at wrap time, so the
|
|
73
|
+
// `tool.before` event carries the per-turn actor identity rather than the
|
|
74
|
+
// stale session-creation snapshot. Sessions that never mutate origin
|
|
75
|
+
// (TUI, cron, subagent) can omit it and pass `origin` instead.
|
|
76
|
+
export type SessionOriginRef = { current: SessionOrigin | undefined }
|
|
77
|
+
|
|
59
78
|
export type CreateSessionOptions = {
|
|
60
79
|
reloadRegistry?: ReloadRegistry
|
|
61
80
|
sessionManager?: SessionManager
|
|
@@ -68,6 +87,13 @@ export type CreateSessionOptions = {
|
|
|
68
87
|
// Rendered into the system prompt so the agent knows who's listening, where
|
|
69
88
|
// its output goes, and what to pass to channel_send.
|
|
70
89
|
origin?: SessionOrigin
|
|
90
|
+
// Live origin holder. When provided, the tool wrappers read this at execute
|
|
91
|
+
// time so `tool.before` events see the current-turn origin. Caller is
|
|
92
|
+
// responsible for keeping `.current` up to date. If both `origin` and
|
|
93
|
+
// `originRef` are passed, the ref wins for tool stamping; the static
|
|
94
|
+
// `origin` still drives the initial system-prompt rendering and channel
|
|
95
|
+
// tool addressing (those are only valid at session-creation time).
|
|
96
|
+
originRef?: SessionOriginRef
|
|
71
97
|
tools?: AgentSessionTools
|
|
72
98
|
customTools?: ToolDefinition[]
|
|
73
99
|
plugins?: PluginSessionWiring
|
|
@@ -78,6 +104,41 @@ export type CreateSessionOptions = {
|
|
|
78
104
|
// Enables the `restart` tool. Set when the agent is running inside a
|
|
79
105
|
// typeclaw-managed container. Read from TYPECLAW_CONTAINER_NAME at the call site.
|
|
80
106
|
containerName?: string
|
|
107
|
+
// The permission service the runtime resolved at boot. When provided, the
|
|
108
|
+
// resolved role and permission list for `options.origin` are rendered into
|
|
109
|
+
// the system prompt under `## Your role in this session`. The block is
|
|
110
|
+
// emitted for channel/cron/subagent sessions, and for TUI sessions only
|
|
111
|
+
// when the resolved role is not the built-in `owner` (because TUI
|
|
112
|
+
// resolving to `owner` is the common case and we save tokens on every
|
|
113
|
+
// interactive session). Omitting `permissions` falls back to the previous
|
|
114
|
+
// behavior (no role annotation), which is what tests and stand-alone
|
|
115
|
+
// callers want.
|
|
116
|
+
//
|
|
117
|
+
// The role rendered here is a session-creation snapshot. Channel sessions
|
|
118
|
+
// re-resolve per-turn through `originRef` for tool gating, but the system
|
|
119
|
+
// prompt is not regenerated; see `typeclaw-permissions` skill for how the
|
|
120
|
+
// agent should interpret the snapshot on later turns.
|
|
121
|
+
permissions?: PermissionService
|
|
122
|
+
// Model profile name. Resolved against `config.models` to pick the concrete
|
|
123
|
+
// model ref this session binds to. Unknown profile names fall back to
|
|
124
|
+
// `default` with a one-time console warning. Omitted → `default`. Threaded
|
|
125
|
+
// through from the caller (subagent declarations, future per-spawn tool
|
|
126
|
+
// overrides) so different sessions on the same agent can run different
|
|
127
|
+
// models without per-session config edits.
|
|
128
|
+
profile?: string
|
|
129
|
+
// Defensive ceiling on cumulative bytes of tool-result text per session,
|
|
130
|
+
// applied to the named tools only. See `src/agent/tool-result-budget.ts`
|
|
131
|
+
// for the rationale. Intended for subagents that read large files
|
|
132
|
+
// (memory-logger, dreaming); leaving this undefined disables the budget
|
|
133
|
+
// entirely, which is the right default for TUI / channel / plugin-tool
|
|
134
|
+
// sessions where the human (or hooks) bound tool-result size.
|
|
135
|
+
toolResultBudget?: ToolResultBudget
|
|
136
|
+
// Optional override for the message returned to the agent once
|
|
137
|
+
// `toolResultBudget` is exhausted. Subagents whose recovery path differs
|
|
138
|
+
// from the default ("advance the watermark from a recent id you have
|
|
139
|
+
// already seen") provide their own here. See `ToolResultBudget` for the
|
|
140
|
+
// shared shape.
|
|
141
|
+
toolResultBudgetMessage?: ToolResultBudget['exhaustedMessage']
|
|
81
142
|
}
|
|
82
143
|
|
|
83
144
|
export type CreateSessionResult = {
|
|
@@ -91,7 +152,11 @@ export async function createSession(options: CreateSessionOptions = {}): Promise
|
|
|
91
152
|
}
|
|
92
153
|
|
|
93
154
|
export async function createSessionWithDispose(options: CreateSessionOptions = {}): Promise<CreateSessionResult> {
|
|
94
|
-
const
|
|
155
|
+
const resolved = resolveProfile(getConfig().models, options.profile)
|
|
156
|
+
if (resolved.fellBackToDefault && options.profile !== undefined && options.profile !== 'default') {
|
|
157
|
+
warnProfileFallbackOnce(options.profile, resolved.ref)
|
|
158
|
+
}
|
|
159
|
+
const { authStorage, modelRegistry } = getAuthFor(providerForModelRef(resolved.ref))
|
|
95
160
|
|
|
96
161
|
const materializedSkills =
|
|
97
162
|
options.plugins && options.plugins.registry.skills.length > 0
|
|
@@ -106,23 +171,46 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
|
|
|
106
171
|
|
|
107
172
|
const resourceLoader =
|
|
108
173
|
options.systemPromptOverride !== undefined
|
|
109
|
-
? await createOverrideResourceLoader(options.systemPromptOverride, options.origin)
|
|
174
|
+
? await createOverrideResourceLoader(options.systemPromptOverride, options.origin, options.permissions)
|
|
110
175
|
: await createResourceLoader({
|
|
111
176
|
...(options.plugins ? { plugins: options.plugins, materializedSkills } : {}),
|
|
112
177
|
...(options.origin ? { origin: options.origin } : {}),
|
|
178
|
+
...(options.permissions ? { permissions: options.permissions } : {}),
|
|
113
179
|
})
|
|
114
180
|
|
|
181
|
+
const getOrigin: () => SessionOrigin | undefined =
|
|
182
|
+
options.originRef !== undefined ? () => options.originRef!.current : () => options.origin
|
|
183
|
+
|
|
115
184
|
const subagentBuiltinTools = options.pluginSubagent?.toolRefs
|
|
116
185
|
? resolveBuiltinToolRefs(options.pluginSubagent.toolRefs)
|
|
117
186
|
: undefined
|
|
118
187
|
const pluginCustomTools = options.pluginSubagent
|
|
119
|
-
? wrapSubagentCustomTools(options.pluginSubagent, options.plugins)
|
|
120
|
-
: wrapRegistryTools(options.plugins)
|
|
188
|
+
? wrapSubagentCustomTools(options.pluginSubagent, options.plugins, getOrigin)
|
|
189
|
+
: wrapRegistryTools(options.plugins, getOrigin)
|
|
190
|
+
|
|
191
|
+
// Per-run budget state for the tool-result byte ceiling. Allocated once per
|
|
192
|
+
// session creation and threaded into every wrapped tool so they share the
|
|
193
|
+
// same counter. Only used when the session declares a budget; the wrappers
|
|
194
|
+
// pass non-listed tools through unchanged, so the counter stays at zero for
|
|
195
|
+
// sessions without a budget configured.
|
|
196
|
+
const sessionBudget: ToolResultBudget | undefined = options.toolResultBudget
|
|
197
|
+
? options.toolResultBudgetMessage !== undefined
|
|
198
|
+
? { ...options.toolResultBudget, exhaustedMessage: options.toolResultBudgetMessage }
|
|
199
|
+
: options.toolResultBudget
|
|
200
|
+
: undefined
|
|
201
|
+
const sessionBudgetState = sessionBudget ? createBudgetState() : undefined
|
|
121
202
|
|
|
122
|
-
const
|
|
203
|
+
const hookWrappedTools = wrapSystemAgentTools(
|
|
123
204
|
options.tools ?? (subagentBuiltinTools as AgentSessionTools | undefined),
|
|
124
205
|
options.plugins,
|
|
206
|
+
getOrigin,
|
|
125
207
|
)
|
|
208
|
+
const tools =
|
|
209
|
+
sessionBudget && sessionBudgetState && hookWrappedTools
|
|
210
|
+
? (hookWrappedTools.map((t) =>
|
|
211
|
+
wrapAgentToolWithBudget(t, sessionBudget, sessionBudgetState),
|
|
212
|
+
) as typeof hookWrappedTools)
|
|
213
|
+
: hookWrappedTools
|
|
126
214
|
|
|
127
215
|
// Hoisted above tool construction so the restart tool can be wired with the
|
|
128
216
|
// session's stable identity (sessionManager.getSessionId()). Subscribers use
|
|
@@ -138,6 +226,7 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
|
|
|
138
226
|
: [
|
|
139
227
|
websearchTool,
|
|
140
228
|
webfetchTool,
|
|
229
|
+
lookAtTool,
|
|
141
230
|
...(options.reloadRegistry ? [createReloadTool({ registry: options.reloadRegistry })] : []),
|
|
142
231
|
...(options.stream ? [createStreamSnapshotTool({ stream: options.stream })] : []),
|
|
143
232
|
...buildChannelTools(options.channelRouter, options.origin),
|
|
@@ -151,9 +240,13 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
|
|
|
151
240
|
]
|
|
152
241
|
: []),
|
|
153
242
|
]
|
|
154
|
-
const
|
|
243
|
+
const customToolsPreBudget = [...wrapSystemTools(customSystemTools, options.plugins, getOrigin), ...pluginCustomTools]
|
|
244
|
+
const customTools =
|
|
245
|
+
sessionBudget && sessionBudgetState
|
|
246
|
+
? customToolsPreBudget.map((t) => wrapToolDefinitionWithBudget(t, sessionBudget, sessionBudgetState))
|
|
247
|
+
: customToolsPreBudget
|
|
155
248
|
|
|
156
|
-
const model = resolveModel(
|
|
249
|
+
const model = resolveModel(resolved.ref)
|
|
157
250
|
const { session } = await createAgentSession({
|
|
158
251
|
model,
|
|
159
252
|
sessionManager,
|
|
@@ -294,7 +387,10 @@ export function buildChannelTools(
|
|
|
294
387
|
return tools
|
|
295
388
|
}
|
|
296
389
|
|
|
297
|
-
function wrapRegistryTools(
|
|
390
|
+
function wrapRegistryTools(
|
|
391
|
+
plugins: PluginSessionWiring | undefined,
|
|
392
|
+
getOrigin: () => SessionOrigin | undefined,
|
|
393
|
+
): ToolDefinition[] {
|
|
298
394
|
if (!plugins) return []
|
|
299
395
|
return plugins.registry.tools.map((t: PluginRegisteredTool) =>
|
|
300
396
|
wrapPluginTool(t.tool, {
|
|
@@ -304,6 +400,7 @@ function wrapRegistryTools(plugins: PluginSessionWiring | undefined): ToolDefini
|
|
|
304
400
|
sessionId: plugins.sessionId,
|
|
305
401
|
logger: t.logger,
|
|
306
402
|
hooks: plugins.hooks,
|
|
403
|
+
getOrigin,
|
|
307
404
|
}),
|
|
308
405
|
)
|
|
309
406
|
}
|
|
@@ -311,6 +408,7 @@ function wrapRegistryTools(plugins: PluginSessionWiring | undefined): ToolDefini
|
|
|
311
408
|
function wrapSystemAgentTools(
|
|
312
409
|
tools: AgentSessionTools | undefined,
|
|
313
410
|
plugins: PluginSessionWiring | undefined,
|
|
411
|
+
getOrigin: () => SessionOrigin | undefined,
|
|
314
412
|
): AgentSessionTools | undefined {
|
|
315
413
|
if (!tools || !hasToolHooks(plugins)) return tools
|
|
316
414
|
return tools.map((tool) =>
|
|
@@ -318,17 +416,23 @@ function wrapSystemAgentTools(
|
|
|
318
416
|
agentDir: plugins.agentDir,
|
|
319
417
|
sessionId: plugins.sessionId,
|
|
320
418
|
hooks: plugins.hooks,
|
|
419
|
+
getOrigin,
|
|
321
420
|
}),
|
|
322
421
|
)
|
|
323
422
|
}
|
|
324
423
|
|
|
325
|
-
function wrapSystemTools(
|
|
424
|
+
function wrapSystemTools(
|
|
425
|
+
tools: ToolDefinition[],
|
|
426
|
+
plugins: PluginSessionWiring | undefined,
|
|
427
|
+
getOrigin: () => SessionOrigin | undefined,
|
|
428
|
+
): ToolDefinition[] {
|
|
326
429
|
if (!hasToolHooks(plugins)) return tools
|
|
327
430
|
return tools.map((tool) =>
|
|
328
431
|
wrapSystemTool(tool, {
|
|
329
432
|
agentDir: plugins.agentDir,
|
|
330
433
|
sessionId: plugins.sessionId,
|
|
331
434
|
hooks: plugins.hooks,
|
|
435
|
+
getOrigin,
|
|
332
436
|
}),
|
|
333
437
|
)
|
|
334
438
|
}
|
|
@@ -341,6 +445,7 @@ function hasToolHooks(plugins: PluginSessionWiring | undefined): plugins is Plug
|
|
|
341
445
|
function wrapSubagentCustomTools(
|
|
342
446
|
selection: PluginSubagentSelection,
|
|
343
447
|
plugins: PluginSessionWiring | undefined,
|
|
448
|
+
getOrigin: () => SessionOrigin | undefined,
|
|
344
449
|
): ToolDefinition[] {
|
|
345
450
|
if (!selection.customTools || !plugins) return []
|
|
346
451
|
const logger = makePluginLogger(selection.pluginName)
|
|
@@ -352,6 +457,7 @@ function wrapSubagentCustomTools(
|
|
|
352
457
|
sessionId: plugins.sessionId,
|
|
353
458
|
logger,
|
|
354
459
|
hooks: plugins.hooks,
|
|
460
|
+
getOrigin,
|
|
355
461
|
}),
|
|
356
462
|
)
|
|
357
463
|
}
|
|
@@ -368,9 +474,11 @@ function makePluginLogger(pluginName: string) {
|
|
|
368
474
|
export async function createOverrideResourceLoader(
|
|
369
475
|
systemPrompt: string,
|
|
370
476
|
origin?: SessionOrigin,
|
|
477
|
+
permissions?: PermissionService,
|
|
371
478
|
): Promise<DefaultResourceLoader> {
|
|
479
|
+
const finalPrompt = withOrigin(systemPrompt, origin, permissions)
|
|
372
480
|
const loader = new DefaultResourceLoader({
|
|
373
|
-
systemPromptOverride: () =>
|
|
481
|
+
systemPromptOverride: () => finalPrompt,
|
|
374
482
|
appendSystemPromptOverride: () => [],
|
|
375
483
|
})
|
|
376
484
|
await loader.reload()
|
|
@@ -382,6 +490,7 @@ export type CreateResourceLoaderOptions = {
|
|
|
382
490
|
plugins?: PluginSessionWiring
|
|
383
491
|
materializedSkills?: MaterializedSkills | null
|
|
384
492
|
origin?: SessionOrigin
|
|
493
|
+
permissions?: PermissionService
|
|
385
494
|
}
|
|
386
495
|
|
|
387
496
|
export async function createResourceLoader(options: CreateResourceLoaderOptions = {}): Promise<DefaultResourceLoader> {
|
|
@@ -395,14 +504,32 @@ export async function createResourceLoader(options: CreateResourceLoaderOptions
|
|
|
395
504
|
systemPrompt = event.prompt
|
|
396
505
|
}
|
|
397
506
|
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
//
|
|
507
|
+
// Cache-suffix ordering: least-volatile sections first, most-volatile last.
|
|
508
|
+
// This minimises the number of cached prompt bytes invalidated when a
|
|
509
|
+
// section changes (the provider's prompt cache hits up to the first byte
|
|
510
|
+
// that differs).
|
|
511
|
+
//
|
|
512
|
+
// 1. origin block — stable across all sessions of the same kind.
|
|
513
|
+
// 2. gitNudge — rare changes; agent folders force-commit sessions/ and
|
|
514
|
+
// memory/ after every turn, so the dirty-files list is empty most of
|
|
515
|
+
// the time.
|
|
516
|
+
// 3. memorySection — most volatile: MEMORY.md grows on every dream cycle
|
|
517
|
+
// and memory/yyyy-MM-dd.md grows after every channel turn that triggers
|
|
518
|
+
// memory-logger. Pinning it to the end keeps everything above it
|
|
519
|
+
// cacheable across session resurrections.
|
|
520
|
+
systemPrompt = withOrigin(systemPrompt, options.origin, options.permissions)
|
|
521
|
+
|
|
401
522
|
const gitNudge = await renderGitNudge(agentDir)
|
|
402
523
|
if (gitNudge !== '') {
|
|
403
524
|
systemPrompt = `${systemPrompt}\n\n${gitNudge}`
|
|
404
525
|
}
|
|
405
526
|
|
|
527
|
+
const memorySection = await loadMemory(agentDir, {
|
|
528
|
+
...(options.origin !== undefined ? { origin: options.origin } : {}),
|
|
529
|
+
...(options.plugins?.sessionId !== undefined ? { currentSessionId: options.plugins.sessionId } : {}),
|
|
530
|
+
})
|
|
531
|
+
systemPrompt = `${systemPrompt}\n\n${memorySection}`
|
|
532
|
+
|
|
406
533
|
const additionalSkillPaths = [getBundledSkillsDir()]
|
|
407
534
|
// pi-coding-agent's DefaultResourceLoader auto-discovers <agentDir>/skills/
|
|
408
535
|
// but not <agentDir>/.agents/skills/. We do not scaffold <agentDir>/skills/
|
|
@@ -433,7 +560,7 @@ export async function createResourceLoader(options: CreateResourceLoaderOptions
|
|
|
433
560
|
}
|
|
434
561
|
|
|
435
562
|
const loader = new DefaultResourceLoader({
|
|
436
|
-
systemPromptOverride: () =>
|
|
563
|
+
systemPromptOverride: () => systemPrompt,
|
|
437
564
|
appendSystemPromptOverride: () => [],
|
|
438
565
|
additionalSkillPaths,
|
|
439
566
|
})
|
|
@@ -441,11 +568,54 @@ export async function createResourceLoader(options: CreateResourceLoaderOptions
|
|
|
441
568
|
return loader
|
|
442
569
|
}
|
|
443
570
|
|
|
444
|
-
function withOrigin(
|
|
571
|
+
function withOrigin(
|
|
572
|
+
systemPrompt: string,
|
|
573
|
+
origin: SessionOrigin | undefined,
|
|
574
|
+
permissions: PermissionService | undefined,
|
|
575
|
+
): string {
|
|
445
576
|
if (!origin) return systemPrompt
|
|
446
|
-
|
|
577
|
+
const roleContext = resolveRoleContext(origin, permissions)
|
|
578
|
+
return `${systemPrompt}\n\n${renderSessionOrigin(origin, Date.now(), roleContext)}`
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function resolveRoleContext(
|
|
582
|
+
origin: SessionOrigin,
|
|
583
|
+
permissions: PermissionService | undefined,
|
|
584
|
+
): SessionRoleContext | undefined {
|
|
585
|
+
if (permissions === undefined) return undefined
|
|
586
|
+
const described = permissions.describe(origin)
|
|
587
|
+
// TUI normally resolves to `owner` via the built-in `owner.match = [tui]`
|
|
588
|
+
// entry, and we skip the role block in that case to save tokens on every
|
|
589
|
+
// interactive session. But user-declared roles can match TUI first (the
|
|
590
|
+
// resolver is first-match-wins in declaration order), so a non-owner TUI
|
|
591
|
+
// role is possible and the agent needs to see it. The "TUI is always owner"
|
|
592
|
+
// shorthand in docs is the common case, not an invariant.
|
|
593
|
+
if (origin.kind === 'tui' && described.role === 'owner') return undefined
|
|
594
|
+
return described
|
|
447
595
|
}
|
|
448
596
|
|
|
449
597
|
export function getBundledSkillsDir(): string {
|
|
450
598
|
return join(dirname(fileURLToPath(import.meta.url)), '..', 'skills')
|
|
451
599
|
}
|
|
600
|
+
|
|
601
|
+
// Profile-fallback warning is fired once per (profile, ref) pair per process.
|
|
602
|
+
// Without rate-limiting, every memory-logger spawn (~every idle event) would
|
|
603
|
+
// emit a fresh warning when the user has only `default` configured — tens of
|
|
604
|
+
// warnings per channel session is noise the operator will learn to ignore.
|
|
605
|
+
// The pair includes `ref` so a config reload that changes `default` re-warns.
|
|
606
|
+
const profileFallbackWarned = new Set<string>()
|
|
607
|
+
|
|
608
|
+
function warnProfileFallbackOnce(profile: string, ref: string): void {
|
|
609
|
+
const key = `${profile}\x00${ref}`
|
|
610
|
+
if (profileFallbackWarned.has(key)) return
|
|
611
|
+
profileFallbackWarned.add(key)
|
|
612
|
+
console.warn(
|
|
613
|
+
`[agent] unknown model profile "${profile}"; falling back to "default" (${ref}). Add it under \`models\` in typeclaw.json to remove this warning. (further occurrences suppressed)`,
|
|
614
|
+
)
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Test-only: clear the rate-limit cache so a test can assert the warning fires
|
|
618
|
+
// once after rate-limit reset.
|
|
619
|
+
export function __resetProfileFallbackWarningsForTesting(): void {
|
|
620
|
+
profileFallbackWarned.clear()
|
|
621
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { lookAtTool } from './look-at'
|
|
2
|
+
export {
|
|
3
|
+
buildMultimodalLookerSystemPrompt,
|
|
4
|
+
imageInputSchema,
|
|
5
|
+
multimodalLookerPayloadSchema,
|
|
6
|
+
resolveImage,
|
|
7
|
+
URL_FETCH_MAX_BYTES,
|
|
8
|
+
URL_FETCH_TIMEOUT_MS,
|
|
9
|
+
type ImageInput,
|
|
10
|
+
type MultimodalLookerPayload,
|
|
11
|
+
type ResolvedImage,
|
|
12
|
+
} from './looker'
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { Type } from '@mariozechner/pi-ai'
|
|
2
|
+
import type { ImageContent } from '@mariozechner/pi-ai'
|
|
3
|
+
import { defineTool } from '@mariozechner/pi-coding-agent'
|
|
4
|
+
|
|
5
|
+
import { createSessionWithDispose, type SessionOrigin } from '@/agent'
|
|
6
|
+
|
|
7
|
+
import { buildMultimodalLookerSystemPrompt, resolveImage, type ImageInput } from './looker'
|
|
8
|
+
|
|
9
|
+
type ImageParam = { url: string } | { path: string } | { data: string; mimeType: string }
|
|
10
|
+
|
|
11
|
+
type LookAtArgs = {
|
|
12
|
+
images: ImageParam[]
|
|
13
|
+
prompt?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type LookAtDetails = {
|
|
17
|
+
count: number
|
|
18
|
+
prompt?: string
|
|
19
|
+
text?: string
|
|
20
|
+
error?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Routes an image-bearing turn to a vision-capable subagent so the main
|
|
24
|
+
// session never sees the bytes. Saves main-agent context: when `models.default`
|
|
25
|
+
// is text-only, this is the only way to get vision; when `models.default` IS
|
|
26
|
+
// vision-capable, it still buys cheaper main-agent inference because the
|
|
27
|
+
// image payload (which can be many KB after base64) only enters the vision
|
|
28
|
+
// model's context.
|
|
29
|
+
//
|
|
30
|
+
// Output is the subagent's text response. The subagent itself decides whether
|
|
31
|
+
// to answer the user's question (when `prompt` is supplied) or describe the
|
|
32
|
+
// image (when `prompt` is omitted) via its dynamic system prompt.
|
|
33
|
+
export const lookAtTool = defineTool({
|
|
34
|
+
name: 'look_at',
|
|
35
|
+
label: 'Look at images',
|
|
36
|
+
description:
|
|
37
|
+
'Route image(s) through a vision-capable subagent and get a text result. ' +
|
|
38
|
+
'Use this when you need to see an image: a screenshot the user shared, a diagram in a doc, a photo, a chart, etc. ' +
|
|
39
|
+
'Each image is specified by ONE of `url` (https://...), `path` (absolute filesystem path), or `data`+`mimeType` (base64). ' +
|
|
40
|
+
'The optional `prompt` is a question to ask about the image(s); without it, the subagent returns a faithful description. ' +
|
|
41
|
+
'The image bytes never enter your context — only the resulting text comes back.',
|
|
42
|
+
parameters: Type.Object({
|
|
43
|
+
images: Type.Array(
|
|
44
|
+
Type.Object({
|
|
45
|
+
url: Type.Optional(Type.String({ description: 'https:// URL to fetch the image from.' })),
|
|
46
|
+
path: Type.Optional(Type.String({ description: 'Absolute filesystem path (inside /agent or a mounted dir).' })),
|
|
47
|
+
data: Type.Optional(Type.String({ description: 'Base64-encoded image bytes (pair with mimeType).' })),
|
|
48
|
+
mimeType: Type.Optional(Type.String({ description: 'MIME type when using `data` (e.g. "image/png").' })),
|
|
49
|
+
}),
|
|
50
|
+
{ minItems: 1, description: 'One or more images to look at.' },
|
|
51
|
+
),
|
|
52
|
+
prompt: Type.Optional(
|
|
53
|
+
Type.String({
|
|
54
|
+
description:
|
|
55
|
+
'Optional question to ask about the image(s). When omitted, the subagent returns a faithful description.',
|
|
56
|
+
}),
|
|
57
|
+
),
|
|
58
|
+
}),
|
|
59
|
+
|
|
60
|
+
async execute(_toolCallId, params, signal) {
|
|
61
|
+
const args = params as LookAtArgs
|
|
62
|
+
try {
|
|
63
|
+
const imageInputs = args.images.map(toImageInput)
|
|
64
|
+
const resolved = await Promise.all(imageInputs.map((i) => resolveImage(i, signal)))
|
|
65
|
+
const imageContents: ImageContent[] = resolved.map((r) => ({
|
|
66
|
+
type: 'image' as const,
|
|
67
|
+
data: r.data,
|
|
68
|
+
mimeType: r.mimeType,
|
|
69
|
+
}))
|
|
70
|
+
|
|
71
|
+
const systemPrompt = buildMultimodalLookerSystemPrompt(args.prompt)
|
|
72
|
+
const userText =
|
|
73
|
+
args.prompt !== undefined && args.prompt.trim() !== ''
|
|
74
|
+
? args.prompt.trim()
|
|
75
|
+
: 'Please describe the attached image(s).'
|
|
76
|
+
|
|
77
|
+
const origin: SessionOrigin = {
|
|
78
|
+
kind: 'subagent',
|
|
79
|
+
subagent: 'multimodal-looker',
|
|
80
|
+
parentSessionId: '<look-at-tool>',
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const { session, dispose } = await createSessionWithDispose({
|
|
84
|
+
systemPromptOverride: systemPrompt,
|
|
85
|
+
origin,
|
|
86
|
+
profile: 'vision',
|
|
87
|
+
// Both knobs are required to fully disarm the subagent's tool surface:
|
|
88
|
+
// `customTools: []` blocks typeclaw's system tools (websearch/webfetch/
|
|
89
|
+
// look_at/restart/...) — without it, the look_at tool would recurse
|
|
90
|
+
// into itself. `tools: []` blocks pi-coding-agent's defaults
|
|
91
|
+
// (read/bash/edit/write) — without it, a vision model could be talked
|
|
92
|
+
// into running shell commands or editing files inside its short-lived
|
|
93
|
+
// session. The looker should only describe images, not act.
|
|
94
|
+
tools: [],
|
|
95
|
+
customTools: [],
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
await session.prompt(userText, { images: imageContents })
|
|
100
|
+
const text = extractLastAssistantText(session.messages)
|
|
101
|
+
if (text === null) {
|
|
102
|
+
return errorResult('multimodal-looker returned no text response', {
|
|
103
|
+
count: resolved.length,
|
|
104
|
+
prompt: args.prompt,
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
return successResult(text, { count: resolved.length, prompt: args.prompt })
|
|
108
|
+
} finally {
|
|
109
|
+
session.dispose()
|
|
110
|
+
await dispose()
|
|
111
|
+
}
|
|
112
|
+
} catch (error) {
|
|
113
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
114
|
+
return errorResult(message, { count: args.images.length, prompt: args.prompt })
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
function toImageInput(p: ImageParam): ImageInput {
|
|
120
|
+
const hasUrl = 'url' in p && p.url !== undefined && p.url !== ''
|
|
121
|
+
const hasPath = 'path' in p && p.path !== undefined && p.path !== ''
|
|
122
|
+
const hasData = 'data' in p && p.data !== undefined && p.data !== ''
|
|
123
|
+
const hasMime = 'mimeType' in p && p.mimeType !== undefined && p.mimeType !== ''
|
|
124
|
+
|
|
125
|
+
// `data` and `mimeType` are paired — accept both as one source. `mimeType`
|
|
126
|
+
// alone with no `data` is rejected as an incomplete base64 spec.
|
|
127
|
+
const sources: string[] = []
|
|
128
|
+
if (hasUrl) sources.push('url')
|
|
129
|
+
if (hasPath) sources.push('path')
|
|
130
|
+
if (hasData || hasMime) sources.push('data+mimeType')
|
|
131
|
+
|
|
132
|
+
if (sources.length === 0) {
|
|
133
|
+
throw new Error('look_at: each image must specify exactly one of `url`, `path`, or `data`+`mimeType`')
|
|
134
|
+
}
|
|
135
|
+
if (sources.length > 1) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
`look_at: each image must specify exactly one of \`url\`, \`path\`, or \`data\`+\`mimeType\` (got: ${sources.join(', ')})`,
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
if (hasUrl) return { kind: 'url', url: (p as { url: string }).url }
|
|
141
|
+
if (hasPath) return { kind: 'file', path: (p as { path: string }).path }
|
|
142
|
+
if (hasData && hasMime) {
|
|
143
|
+
return { kind: 'base64', data: (p as { data: string }).data, mimeType: (p as { mimeType: string }).mimeType }
|
|
144
|
+
}
|
|
145
|
+
throw new Error('look_at: base64 image requires both `data` and `mimeType`')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Pulls the most recent assistant turn's text content. The subagent's reply
|
|
149
|
+
// shows up here once `session.prompt()` resolves. Tool calls in the assistant
|
|
150
|
+
// message are ignored — multimodal-looker's session has no tools wired in
|
|
151
|
+
// (`tools: []` + `customTools: []` at session creation), so in practice this
|
|
152
|
+
// is pure text plus optional thinking blocks (which we skip).
|
|
153
|
+
function extractLastAssistantText(messages: ReadonlyArray<unknown>): string | null {
|
|
154
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
155
|
+
const msg = messages[i] as { role?: unknown; content?: unknown } | undefined
|
|
156
|
+
if (msg === undefined || msg.role !== 'assistant') continue
|
|
157
|
+
const content = msg.content
|
|
158
|
+
if (!Array.isArray(content)) continue
|
|
159
|
+
const texts: string[] = []
|
|
160
|
+
for (const part of content) {
|
|
161
|
+
if (part !== null && typeof part === 'object' && (part as { type?: unknown }).type === 'text') {
|
|
162
|
+
const t = (part as { text?: unknown }).text
|
|
163
|
+
if (typeof t === 'string') texts.push(t)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (texts.length > 0) return texts.join('\n').trim()
|
|
167
|
+
}
|
|
168
|
+
return null
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function successResult(text: string, partial: Omit<LookAtDetails, 'text' | 'error'>) {
|
|
172
|
+
const details: LookAtDetails = { ...partial, text }
|
|
173
|
+
return {
|
|
174
|
+
content: [{ type: 'text' as const, text }],
|
|
175
|
+
details,
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function errorResult(message: string, partial: Omit<LookAtDetails, 'text' | 'error'>) {
|
|
180
|
+
const details: LookAtDetails = { ...partial, error: message }
|
|
181
|
+
return {
|
|
182
|
+
content: [{ type: 'text' as const, text: `look_at failed: ${message}` }],
|
|
183
|
+
details,
|
|
184
|
+
}
|
|
185
|
+
}
|