typeclaw 0.7.0 → 0.8.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/README.md +9 -3
- package/package.json +1 -1
- package/scripts/dump-system-prompt.ts +12 -1
- package/src/agent/auth.ts +3 -3
- package/src/agent/index.ts +61 -8
- package/src/agent/multimodal/read-redirect.ts +43 -0
- package/src/agent/plugin-tools.ts +95 -13
- package/src/agent/session-origin.ts +6 -13
- package/src/agent/system-prompt.ts +40 -8
- package/src/channels/adapters/discord-bot-slash-commands.ts +186 -0
- package/src/channels/adapters/discord-bot.ts +163 -1
- package/src/channels/adapters/slack-bot-slash-commands.ts +82 -0
- package/src/channels/adapters/slack-bot.ts +139 -1
- package/src/channels/router.ts +127 -1
- package/src/cli/role.ts +7 -2
- package/src/cli/tunnel.ts +13 -1
- package/src/cli/ui.ts +25 -1
- package/src/config/index.ts +1 -0
- package/src/config/models-mutation.ts +10 -2
- package/src/init/dockerfile.ts +89 -2
- package/src/shared/index.ts +1 -1
- package/src/shared/local-time.ts +17 -0
- package/src/skills/typeclaw-claude-code/SKILL.md +30 -5
- package/src/skills/typeclaw-config/SKILL.md +37 -32
- package/src/skills/typeclaw-git/SKILL.md +2 -2
- package/src/skills/typeclaw-plugins/SKILL.md +1 -1
package/README.md
CHANGED
|
@@ -2,11 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
> A TypeScript-native, Bun-powered, Docker-friendly general-purpose agent runtime.
|
|
4
4
|
|
|
5
|
-
Full docs: **[typeclaw.dev](https://typeclaw.dev)**.
|
|
6
|
-
|
|
7
5
|
## Why?
|
|
8
6
|
|
|
9
|
-
There are great agents out there. None of them were quite the shape I wanted
|
|
7
|
+
There are great agents out there. None of them were quite the shape I wanted:
|
|
8
|
+
|
|
9
|
+
- **OpenClaw** — feature-rich, but heavy
|
|
10
|
+
- **NanoClaw** — simple, but no plugin system
|
|
11
|
+
- **PicoClaw** — fast, but Go (so plugins live outside the runtime)
|
|
12
|
+
- **ZeroClaw** — light, but Rust (same problem, different ecosystem)
|
|
13
|
+
- **Hermes Agent** — awesome, but Python
|
|
14
|
+
|
|
15
|
+
None of that matters to most people. It matters to me. If you're like me, TypeClaw is the right choice.
|
|
10
16
|
|
|
11
17
|
TypeClaw is the agent I wanted to use:
|
|
12
18
|
|
package/package.json
CHANGED
|
@@ -4,12 +4,19 @@ import { parseArgs } from 'node:util'
|
|
|
4
4
|
|
|
5
5
|
import { composeSystemPrompt, deriveSystemPromptMode, type SystemPromptMode } from '@/agent'
|
|
6
6
|
import type { SessionOrigin, SessionRoleContext } from '@/agent/session-origin'
|
|
7
|
+
import { renderNowBlock } from '@/agent/system-prompt'
|
|
7
8
|
|
|
8
9
|
type OriginKind = 'tui' | 'cron' | 'channel' | 'subagent'
|
|
9
10
|
const ALL_KINDS: readonly OriginKind[] = ['tui', 'cron', 'channel', 'subagent'] as const
|
|
10
11
|
|
|
11
12
|
const PLACEHOLDER_RUNTIME_VERSION = '1.2.3-debug'
|
|
12
13
|
|
|
14
|
+
// Fixed wall-clock for the `## Now` block. The dumper needs a deterministic
|
|
15
|
+
// timestamp so successive runs produce byte-identical output (and so the
|
|
16
|
+
// snapshot tests in dump-system-prompt.test.ts don't drift). Production
|
|
17
|
+
// callers always pass the live `new Date()` — see `composeSystemPrompt`.
|
|
18
|
+
const PLACEHOLDER_NOW = new Date('2026-05-22T15:11:00+09:00')
|
|
19
|
+
|
|
13
20
|
const PLACEHOLDER_SELF = [
|
|
14
21
|
'# Identity',
|
|
15
22
|
'',
|
|
@@ -236,12 +243,14 @@ function dumpSubagentOverridePrompt(): DumpResult {
|
|
|
236
243
|
const fixture = buildFixture('subagent')
|
|
237
244
|
const runtimeBlock = `## Runtime\n\nTypeClaw runtime version: ${PLACEHOLDER_RUNTIME_VERSION}.`
|
|
238
245
|
const originBlock = `## Session origin\n\nYou are a \`${(fixture.origin as { subagent: string }).subagent}\` subagent spawned by parent session\n\`${(fixture.origin as { parentSessionId: string }).parentSessionId}\`. Stay narrowly within the task you were given.\nReturn cleanly when done; do not sprawl into unrelated work.\n\n## Your role in this session\n\nRole: \`${fixture.roleContext.role}\`. Permissions: ${fixture.roleContext.permissions.map((p) => `\`${p}\``).join(', ')}.\n\nThis is the role the runtime resolved at session creation. Tool calls\nand channel admission are gated by these permissions; a \`blocked:\` or\n"denied by permissions" message means the current actor lacks the\npermission the guard was looking for. See the \`typeclaw-permissions\`\nskill for what each role can do and how to grant access.`
|
|
246
|
+
const nowBlock = renderNowBlock(PLACEHOLDER_NOW)
|
|
239
247
|
|
|
240
|
-
const prompt = `${PLACEHOLDER_SUBAGENT_OVERRIDE}\n\n${runtimeBlock}\n\n${originBlock}`
|
|
248
|
+
const prompt = `${PLACEHOLDER_SUBAGENT_OVERRIDE}\n\n${runtimeBlock}\n\n${originBlock}\n\n${nowBlock}`
|
|
241
249
|
const sections: SectionBreakdown[] = [
|
|
242
250
|
mkSection('Subagent override prompt', PLACEHOLDER_SUBAGENT_OVERRIDE),
|
|
243
251
|
mkSection('Runtime block', runtimeBlock),
|
|
244
252
|
mkSection('Session origin + role', originBlock),
|
|
253
|
+
mkSection('Now (wall clock)', nowBlock),
|
|
245
254
|
]
|
|
246
255
|
return {
|
|
247
256
|
prompt,
|
|
@@ -264,6 +273,7 @@ function dumpDefaultLoaderPrompt(kind: Exclude<OriginKind, 'subagent'>, options:
|
|
|
264
273
|
roleContext: fixture.roleContext,
|
|
265
274
|
gitNudge: wantGitNudge ? PLACEHOLDER_GIT_NUDGE : '',
|
|
266
275
|
memorySection: fixture.memory,
|
|
276
|
+
now: PLACEHOLDER_NOW,
|
|
267
277
|
} as const
|
|
268
278
|
|
|
269
279
|
const prompt = composeSystemPrompt(parts)
|
|
@@ -289,6 +299,7 @@ function dumpDefaultLoaderPrompt(kind: Exclude<OriginKind, 'subagent'>, options:
|
|
|
289
299
|
sections.push(mkSection('Git nudge', parts.gitNudge))
|
|
290
300
|
}
|
|
291
301
|
sections.push(mkSection('Memory (MEMORY.md + streams)', parts.memorySection))
|
|
302
|
+
sections.push(mkSection('Now (wall clock)', renderNowBlock(PLACEHOLDER_NOW)))
|
|
292
303
|
|
|
293
304
|
return {
|
|
294
305
|
prompt,
|
package/src/agent/auth.ts
CHANGED
|
@@ -124,8 +124,8 @@ function missingCredentialMessage(providerId: KnownProviderId): string {
|
|
|
124
124
|
}
|
|
125
125
|
if (apiKeyOnly && provider.apiKeyEnv) {
|
|
126
126
|
return modelName
|
|
127
|
-
? `
|
|
128
|
-
: `
|
|
127
|
+
? `Run \`typeclaw init\` to add an API key for ${modelName} via ${provider.name} (stored in secrets.json#providers.${provider.id}.key.value; ${provider.apiKeyEnv} in .env also works for override).`
|
|
128
|
+
: `Run \`typeclaw init\` to add an API key for ${provider.name} (referenced by a non-default profile; stored in secrets.json#providers.${provider.id}.key.value; ${provider.apiKeyEnv} in .env also works for override).`
|
|
129
129
|
}
|
|
130
|
-
return `No credentials for ${provider.name}.
|
|
130
|
+
return `No credentials for ${provider.name}. Run \`typeclaw init\` to add an API key (stored in secrets.json) or pick "OAuth".`
|
|
131
131
|
}
|
package/src/agent/index.ts
CHANGED
|
@@ -27,13 +27,19 @@ import { createCompactionSettingsManager } from './compaction'
|
|
|
27
27
|
import { renderGitNudge } from './git-nudge'
|
|
28
28
|
import type { LiveSubagentRegistry } from './live-subagents'
|
|
29
29
|
import { lookAtTool } from './multimodal'
|
|
30
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
buildBuiltinPiToolOverrides,
|
|
32
|
+
resolveBuiltinToolRefs,
|
|
33
|
+
wrapPluginTool,
|
|
34
|
+
wrapSystemAgentTool,
|
|
35
|
+
wrapSystemTool,
|
|
36
|
+
} from './plugin-tools'
|
|
31
37
|
import { createReloadTool } from './reload-tool'
|
|
32
38
|
import { loadSelf } from './self'
|
|
33
39
|
import { SESSION_META_CUSTOM_TYPE, sessionMetaPayload } from './session-meta'
|
|
34
40
|
import { renderSessionOrigin, type SessionOrigin, type SessionRoleContext } from './session-origin'
|
|
35
41
|
import type { CreateSessionForSubagent, SubagentRegistry } from './subagents'
|
|
36
|
-
import { DEFAULT_SYSTEM_PROMPT, renderRuntimeBlock, SLIM_SYSTEM_PROMPT } from './system-prompt'
|
|
42
|
+
import { DEFAULT_SYSTEM_PROMPT, renderNowBlock, renderRuntimeBlock, SLIM_SYSTEM_PROMPT } from './system-prompt'
|
|
37
43
|
import {
|
|
38
44
|
createBudgetState,
|
|
39
45
|
type ToolResultBudget,
|
|
@@ -313,7 +319,22 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
|
|
|
313
319
|
stream: options.stream,
|
|
314
320
|
}),
|
|
315
321
|
]
|
|
316
|
-
|
|
322
|
+
// Hook coverage for pi's builtin coding tools (read/bash/edit/write/grep/
|
|
323
|
+
// find/ls) — pi 0.67.3 ignores `tools:` for implementation, so the only
|
|
324
|
+
// way to interpose typeclaw guards is to ship same-named ToolDefinition
|
|
325
|
+
// entries through `customTools`. Skipped when there are no tool hooks,
|
|
326
|
+
// since wrapping reduces to a passthrough in that case.
|
|
327
|
+
const builtinPiToolOverrides =
|
|
328
|
+
options.plugins && hasToolHooks(options.plugins)
|
|
329
|
+
? buildBuiltinPiToolOverrides({
|
|
330
|
+
agentDir: options.plugins.agentDir,
|
|
331
|
+
sessionId: options.plugins.sessionId,
|
|
332
|
+
hooks: options.plugins.hooks,
|
|
333
|
+
getOrigin,
|
|
334
|
+
})
|
|
335
|
+
: []
|
|
336
|
+
const wrappedCustomSystemTools = wrapSystemTools(customSystemTools, options.plugins, getOrigin)
|
|
337
|
+
const customToolsPreBudget = [...wrappedCustomSystemTools, ...pluginCustomTools, ...builtinPiToolOverrides]
|
|
317
338
|
const customTools =
|
|
318
339
|
sessionBudget && sessionBudgetState
|
|
319
340
|
? customToolsPreBudget.map((t) => wrapToolDefinitionWithBudget(t, sessionBudget, sessionBudgetState))
|
|
@@ -331,6 +352,21 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
|
|
|
331
352
|
customTools,
|
|
332
353
|
})
|
|
333
354
|
|
|
355
|
+
// Re-narrow the active tool set after `createAgentSession`. pi 0.67.3's
|
|
356
|
+
// `_refreshToolRegistry` runs with `includeAllExtensionTools: true` and
|
|
357
|
+
// pushes every customTool name into the active set, which would widen
|
|
358
|
+
// a subagent's declared `[edit]` to all 7 builtin overrides plus every
|
|
359
|
+
// typeclaw custom tool. The intended active set is the names the caller
|
|
360
|
+
// would have gotten WITHOUT the builtin overrides: pi's `initialActiveToolNames`
|
|
361
|
+
// (derived from `tools:`) union the names from typeclaw/plugin customTools.
|
|
362
|
+
// `builtinPiToolOverrides` are implementation overrides, never additions.
|
|
363
|
+
if (builtinPiToolOverrides.length > 0) {
|
|
364
|
+
const baseActiveNames = tools !== undefined ? tools.map((t) => t.name) : ['read', 'bash', 'edit', 'write']
|
|
365
|
+
const customToolActiveNames = [...wrappedCustomSystemTools, ...pluginCustomTools].map((t) => t.name)
|
|
366
|
+
const intendedActive = [...new Set([...baseActiveNames, ...customToolActiveNames])]
|
|
367
|
+
session.setActiveToolsByName(intendedActive)
|
|
368
|
+
}
|
|
369
|
+
|
|
334
370
|
const unsubRestart = subscribeRestartNotice(options.stream, sessionManager)
|
|
335
371
|
|
|
336
372
|
const dispose = async () => {
|
|
@@ -591,10 +627,12 @@ export async function createOverrideResourceLoader(
|
|
|
591
627
|
origin?: SessionOrigin,
|
|
592
628
|
permissions?: PermissionService,
|
|
593
629
|
runtimeVersion?: string,
|
|
630
|
+
now: Date = new Date(),
|
|
594
631
|
): Promise<DefaultResourceLoader> {
|
|
595
632
|
const withRuntime =
|
|
596
633
|
runtimeVersion !== undefined ? `${systemPrompt}\n\n${renderRuntimeBlock(runtimeVersion)}` : systemPrompt
|
|
597
|
-
const
|
|
634
|
+
const withOriginRendered = withOrigin(withRuntime, origin, permissions)
|
|
635
|
+
const finalPrompt = `${withOriginRendered}\n\n${renderNowBlock(now)}`
|
|
598
636
|
const loader = new DefaultResourceLoader({
|
|
599
637
|
systemPromptOverride: () => finalPrompt,
|
|
600
638
|
appendSystemPromptOverride: () => [],
|
|
@@ -615,6 +653,11 @@ export type CreateResourceLoaderOptions = {
|
|
|
615
653
|
// 'full' to force the heavy prompt even on an unattended origin (rarely
|
|
616
654
|
// useful; mostly an escape hatch for ad-hoc debugging).
|
|
617
655
|
mode?: SystemPromptMode
|
|
656
|
+
// Wall-clock anchor stamped into the trailing `## Now` block of the
|
|
657
|
+
// rendered system prompt. Production callers omit this so each session
|
|
658
|
+
// gets the current time at creation; tests pass a fixed Date to keep
|
|
659
|
+
// assertions deterministic. See `renderNowBlock` in system-prompt.ts.
|
|
660
|
+
now?: Date
|
|
618
661
|
}
|
|
619
662
|
|
|
620
663
|
// Origins where the operator-facing DEFAULT_SYSTEM_PROMPT, git-nudge, and the
|
|
@@ -672,6 +715,7 @@ export type SystemPromptComposition = {
|
|
|
672
715
|
roleContext?: SessionRoleContext
|
|
673
716
|
gitNudge: string
|
|
674
717
|
memorySection: string
|
|
718
|
+
now?: Date
|
|
675
719
|
}
|
|
676
720
|
|
|
677
721
|
// Section-order contract for the system prompt. Kept as a pure string→string
|
|
@@ -687,10 +731,15 @@ export type SystemPromptComposition = {
|
|
|
687
731
|
// 2. gitNudge — rare changes; agent folders force-commit sessions/ and
|
|
688
732
|
// memory/ after every turn, so the dirty-files list is empty most of
|
|
689
733
|
// the time.
|
|
690
|
-
// 3. memorySection —
|
|
691
|
-
//
|
|
692
|
-
// memory-logger.
|
|
693
|
-
//
|
|
734
|
+
// 3. memorySection — volatile: MEMORY.md grows on every dream cycle and
|
|
735
|
+
// memory/yyyy-MM-dd.md grows after every channel turn that triggers
|
|
736
|
+
// memory-logger.
|
|
737
|
+
// 4. now block — most volatile: changes per second. Pinned to the very
|
|
738
|
+
// end so every byte UP TO this block stays in the provider's cache
|
|
739
|
+
// prefix; only the trailing ~60 bytes invalidate on each new session.
|
|
740
|
+
// `now` is optional — when omitted (debug dumps without a fixed clock,
|
|
741
|
+
// legacy callers) the block is skipped entirely. See `renderNowBlock`
|
|
742
|
+
// in system-prompt.ts for why this block exists at all.
|
|
694
743
|
export function composeSystemPrompt(parts: SystemPromptComposition): string {
|
|
695
744
|
const base = parts.mode === 'slim' ? SLIM_SYSTEM_PROMPT : DEFAULT_SYSTEM_PROMPT
|
|
696
745
|
let prompt = `${base}\n\n${parts.self}`
|
|
@@ -706,6 +755,9 @@ export function composeSystemPrompt(parts: SystemPromptComposition): string {
|
|
|
706
755
|
if (parts.memorySection !== '') {
|
|
707
756
|
prompt = `${prompt}\n\n${parts.memorySection}`
|
|
708
757
|
}
|
|
758
|
+
if (parts.now !== undefined) {
|
|
759
|
+
prompt = `${prompt}\n\n${renderNowBlock(parts.now)}`
|
|
760
|
+
}
|
|
709
761
|
return prompt
|
|
710
762
|
}
|
|
711
763
|
|
|
@@ -750,6 +802,7 @@ export async function createResourceLoader(options: CreateResourceLoaderOptions
|
|
|
750
802
|
...(roleContext !== undefined ? { roleContext } : {}),
|
|
751
803
|
gitNudge,
|
|
752
804
|
memorySection,
|
|
805
|
+
now: options.now ?? new Date(),
|
|
753
806
|
})
|
|
754
807
|
|
|
755
808
|
const additionalSkillPaths = [getBundledSkillsDir()]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
|
|
3
|
+
import { ACKNOWLEDGE_GUARDS, type GuardBlock, isGuardAcknowledged } from '@/bundled-plugins/guard/policy'
|
|
4
|
+
|
|
5
|
+
export const GUARD_IMAGE_READ_REDIRECT = 'imageReadRedirect'
|
|
6
|
+
|
|
7
|
+
// Mirrors the IMAGE_MIME_TYPES set in @mariozechner/pi-coding-agent
|
|
8
|
+
// (dist/utils/mime.ts). Keeping the trigger surface aligned with the upstream
|
|
9
|
+
// read tool's image-attachment behavior means we redirect on exactly the
|
|
10
|
+
// extensions that would otherwise inject `{ type: 'image' }` content parts
|
|
11
|
+
// into the main agent's history.
|
|
12
|
+
//
|
|
13
|
+
// Extension-only matching is preferred over the upstream MIME sniffer's
|
|
14
|
+
// 4100-byte file open because this check runs on every `read` call;
|
|
15
|
+
// extensionless image files still leak as before (no regression), and the
|
|
16
|
+
// agent can force-read via `acknowledgeGuards.imageReadRedirect: true` when
|
|
17
|
+
// it genuinely needs the bytes (e.g. writing image-processing code).
|
|
18
|
+
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp'])
|
|
19
|
+
|
|
20
|
+
export function checkImageReadRedirect(options: {
|
|
21
|
+
tool: string
|
|
22
|
+
args: Record<string, unknown>
|
|
23
|
+
}): GuardBlock | undefined {
|
|
24
|
+
const { tool, args } = options
|
|
25
|
+
if (tool !== 'read') return undefined
|
|
26
|
+
if (isGuardAcknowledged(args, GUARD_IMAGE_READ_REDIRECT)) return undefined
|
|
27
|
+
|
|
28
|
+
const rawPath = args.path
|
|
29
|
+
if (typeof rawPath !== 'string' || rawPath === '') return undefined
|
|
30
|
+
|
|
31
|
+
const ext = path.extname(rawPath).toLowerCase()
|
|
32
|
+
if (!IMAGE_EXTENSIONS.has(ext)) return undefined
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
block: true,
|
|
36
|
+
reason: [
|
|
37
|
+
`Guard \`${GUARD_IMAGE_READ_REDIRECT}\` blocked read of an image file: ${rawPath}.`,
|
|
38
|
+
`Reading images via \`read\` injects the raw bytes into your message history as an image attachment, which can quickly fill your context window.`,
|
|
39
|
+
`Use \`look_at\` with \`path: ${JSON.stringify(rawPath)}\` instead — it routes the bytes through a vision-capable subagent and returns only text to you. Pass an optional \`prompt\` to ask a specific question (returns shorter text than the default describe-everything path).`,
|
|
40
|
+
`If you genuinely need the raw image bytes (e.g. writing image-processing code), retry with \`${ACKNOWLEDGE_GUARDS}.${GUARD_IMAGE_READ_REDIRECT}: true\` in the tool arguments.`,
|
|
41
|
+
].join(' '),
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -31,6 +31,7 @@ import type {
|
|
|
31
31
|
ToolResult,
|
|
32
32
|
} from '@/plugin'
|
|
33
33
|
|
|
34
|
+
import { checkImageReadRedirect } from './multimodal/read-redirect'
|
|
34
35
|
import type { SessionOrigin } from './session-origin'
|
|
35
36
|
import { webfetchTool } from './tools/webfetch'
|
|
36
37
|
import { websearchTool } from './tools/websearch'
|
|
@@ -44,19 +45,20 @@ const ACKNOWLEDGE_GUARDS_SCHEMA = Type.Optional(
|
|
|
44
45
|
),
|
|
45
46
|
)
|
|
46
47
|
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
48
|
+
// pi-coding-agent 0.67.3 contract (load-bearing for hook coverage):
|
|
49
|
+
// - `createAgentSession({ tools: AgentTool[] })` is ONLY a name filter for
|
|
50
|
+
// `initialActiveToolNames`. It does NOT swap builtin implementations.
|
|
51
|
+
// - `customTools: ToolDefinition[]` entries override builtins by name in
|
|
52
|
+
// `_refreshToolRegistry` (the registry merge writes customTools last).
|
|
53
|
+
//
|
|
54
|
+
// Consequence: to put a `tool.before` hook around pi's builtin read/bash/edit/
|
|
55
|
+
// write, TypeClaw must wrap them as `ToolDefinition`s and pass them via
|
|
56
|
+
// `customTools` — not via `tools`. `wrapAgentToolAsCustomToolDefinition`
|
|
57
|
+
// produces those wrapped definitions; `setupSession` in `src/agent/index.ts`
|
|
58
|
+
// appends them whenever the session has any `tool.before` / `tool.after`
|
|
59
|
+
// hooks registered. Subagent narrowing still comes from `tools:` (the
|
|
60
|
+
// name-filter path); the wrapped customTools just replace the implementation
|
|
61
|
+
// underneath so subagent and channel sessions share the same hook coverage.
|
|
60
62
|
type PiAgentToolName = 'read' | 'bash' | 'edit' | 'write' | 'grep' | 'find' | 'ls'
|
|
61
63
|
type TypeclawToolName = 'websearch' | 'webfetch'
|
|
62
64
|
|
|
@@ -231,6 +233,10 @@ export function wrapSystemTool<TParams extends TSchema, TDetails = unknown, TSta
|
|
|
231
233
|
if (guardResult !== undefined) {
|
|
232
234
|
throw new Error(`blocked: ${guardResult.reason}`)
|
|
233
235
|
}
|
|
236
|
+
const readGuardResult = runFinalReadGuards({ tool: tool.name, args: mutableArgs })
|
|
237
|
+
if (readGuardResult !== undefined) {
|
|
238
|
+
throw new Error(`blocked: ${readGuardResult.reason}`)
|
|
239
|
+
}
|
|
234
240
|
stripGuardAcknowledgements(mutableArgs)
|
|
235
241
|
|
|
236
242
|
const result = await tool.execute(toolCallId, mutableArgs as Static<TParams>, signal, onUpdate, ctx)
|
|
@@ -280,6 +286,10 @@ export function wrapSystemAgentTool<TParams extends TSchema, TDetails = unknown>
|
|
|
280
286
|
if (guardResult !== undefined) {
|
|
281
287
|
throw new Error(`blocked: ${guardResult.reason}`)
|
|
282
288
|
}
|
|
289
|
+
const readGuardResult = runFinalReadGuards({ tool: tool.name, args: mutableArgs })
|
|
290
|
+
if (readGuardResult !== undefined) {
|
|
291
|
+
throw new Error(`blocked: ${readGuardResult.reason}`)
|
|
292
|
+
}
|
|
283
293
|
stripGuardAcknowledgements(mutableArgs)
|
|
284
294
|
|
|
285
295
|
const result = await tool.execute(toolCallId, mutableArgs as Static<TParams>, signal, onUpdate)
|
|
@@ -301,6 +311,74 @@ export function wrapSystemAgentTool<TParams extends TSchema, TDetails = unknown>
|
|
|
301
311
|
}
|
|
302
312
|
}
|
|
303
313
|
|
|
314
|
+
// Wraps a pi-coding-agent AgentTool into a ToolDefinition so it can ride in
|
|
315
|
+
// `customTools` and override pi's same-named builtin (see top-of-file contract
|
|
316
|
+
// block). The hook + guard pipeline matches `wrapSystemAgentTool`; only the
|
|
317
|
+
// input/output shape differs.
|
|
318
|
+
export function wrapAgentToolAsCustomToolDefinition<TParams extends TSchema, TDetails = unknown>(
|
|
319
|
+
tool: AgentTool<TParams, TDetails>,
|
|
320
|
+
opts: WrapSystemToolOptions,
|
|
321
|
+
): ToolDefinition<TParams, TDetails> {
|
|
322
|
+
return piDefineTool({
|
|
323
|
+
name: tool.name,
|
|
324
|
+
label: tool.label,
|
|
325
|
+
description: tool.description,
|
|
326
|
+
parameters: withGuardAcknowledgements(tool.name, tool.parameters),
|
|
327
|
+
prepareArguments: tool.prepareArguments,
|
|
328
|
+
async execute(toolCallId, params, signal, onUpdate) {
|
|
329
|
+
const mutableArgs = params as Record<string, unknown>
|
|
330
|
+
const liveOrigin = opts.getOrigin?.()
|
|
331
|
+
const blockResult = await opts.hooks.runToolBefore({
|
|
332
|
+
tool: tool.name,
|
|
333
|
+
sessionId: opts.sessionId,
|
|
334
|
+
callId: toolCallId,
|
|
335
|
+
args: mutableArgs,
|
|
336
|
+
...(liveOrigin !== undefined ? { origin: liveOrigin } : {}),
|
|
337
|
+
})
|
|
338
|
+
if (blockResult !== undefined) {
|
|
339
|
+
throw new Error(`blocked: ${blockResult.reason}`)
|
|
340
|
+
}
|
|
341
|
+
const guardResult = await runFinalWriteGuards({
|
|
342
|
+
tool: tool.name,
|
|
343
|
+
args: mutableArgs,
|
|
344
|
+
agentDir: opts.agentDir,
|
|
345
|
+
})
|
|
346
|
+
if (guardResult !== undefined) {
|
|
347
|
+
throw new Error(`blocked: ${guardResult.reason}`)
|
|
348
|
+
}
|
|
349
|
+
const readGuardResult = runFinalReadGuards({ tool: tool.name, args: mutableArgs })
|
|
350
|
+
if (readGuardResult !== undefined) {
|
|
351
|
+
throw new Error(`blocked: ${readGuardResult.reason}`)
|
|
352
|
+
}
|
|
353
|
+
stripGuardAcknowledgements(mutableArgs)
|
|
354
|
+
|
|
355
|
+
const result = await tool.execute(toolCallId, mutableArgs as Static<TParams>, signal, onUpdate)
|
|
356
|
+
const hookResult: ToolResult = {
|
|
357
|
+
content: result.content as ContentPart[],
|
|
358
|
+
details: result.details,
|
|
359
|
+
}
|
|
360
|
+
await opts.hooks.runToolAfter({
|
|
361
|
+
tool: tool.name,
|
|
362
|
+
sessionId: opts.sessionId,
|
|
363
|
+
callId: toolCallId,
|
|
364
|
+
result: hookResult,
|
|
365
|
+
})
|
|
366
|
+
return {
|
|
367
|
+
content: hookResult.content as ContentPart[],
|
|
368
|
+
details: hookResult.details as TDetails,
|
|
369
|
+
}
|
|
370
|
+
},
|
|
371
|
+
})
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export function defaultBuiltinPiAgentTools(): AgentTool<any, any>[] {
|
|
375
|
+
return [piReadTool, piBashTool, piEditTool, piWriteTool, piGrepTool, piFindTool, piLsTool]
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export function buildBuiltinPiToolOverrides(opts: WrapSystemToolOptions): ToolDefinition<any, any>[] {
|
|
379
|
+
return defaultBuiltinPiAgentTools().map((tool) => wrapAgentToolAsCustomToolDefinition(tool, opts))
|
|
380
|
+
}
|
|
381
|
+
|
|
304
382
|
function errorResult(message: string) {
|
|
305
383
|
return {
|
|
306
384
|
content: [{ type: 'text' as const, text: message }],
|
|
@@ -317,6 +395,10 @@ async function runFinalWriteGuards(options: { tool: string; args: Record<string,
|
|
|
317
395
|
)
|
|
318
396
|
}
|
|
319
397
|
|
|
398
|
+
function runFinalReadGuards(options: { tool: string; args: Record<string, unknown> }) {
|
|
399
|
+
return checkImageReadRedirect(options)
|
|
400
|
+
}
|
|
401
|
+
|
|
320
402
|
function withGuardAcknowledgements<TParams extends TSchema>(toolName: string, parameters: TParams): TParams {
|
|
321
403
|
if (toolName !== 'write' && toolName !== 'edit') return parameters
|
|
322
404
|
|
|
@@ -226,20 +226,13 @@ function renderChannelOrigin(
|
|
|
226
226
|
'reply, your entire final visible response must be exactly `NO_REPLY`.',
|
|
227
227
|
'Any other visible text without a channel tool call is blocked.',
|
|
228
228
|
'',
|
|
229
|
-
'**
|
|
230
|
-
'
|
|
229
|
+
'**One substantive reply per inbound.** If the answer needs more than one',
|
|
230
|
+
'tool call, send a one-line ack first ("On it."), keep working, then send',
|
|
231
|
+
'the answer — both in the same turn. The ack is not your reply; the answer',
|
|
232
|
+
'is. Once the answer lands, end your turn.',
|
|
231
233
|
'',
|
|
232
|
-
'
|
|
233
|
-
'
|
|
234
|
-
'- your reply exceeds the platform message limit and must be chunked,',
|
|
235
|
-
'- you need to post an attachment AND commentary on it on Discord (on',
|
|
236
|
-
' Slack, pass `text` and `attachments` in a single `channel_reply` call),',
|
|
237
|
-
'- you are emitting progress updates during a long-running task and the',
|
|
238
|
-
' channel would otherwise sit silent.',
|
|
239
|
-
'',
|
|
240
|
-
'Do NOT send a second reply just to rephrase, restate, summarize, or',
|
|
241
|
-
'"confirm in plain language" something you already said. After the first',
|
|
242
|
-
'reply lands, end your turn — the user will respond if they want more.',
|
|
234
|
+
'Do not send a second reply just to rephrase, restate, or "confirm in',
|
|
235
|
+
'plain language" something you already said.',
|
|
243
236
|
'',
|
|
244
237
|
'To reply in this conversation, call `channel_reply({ text })`. Addressing',
|
|
245
238
|
`is filled in from this session, including the thread${origin.thread !== null ? '' : ' (none here — this is a channel-root session)'}, so you don't`,
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { formatLocalDateTime, resolveLocalTimezoneName } from '@/shared'
|
|
2
|
+
|
|
1
3
|
export const DEFAULT_SYSTEM_PROMPT = `You are a general-purpose AI agent running inside TypeClaw.
|
|
2
4
|
|
|
3
5
|
TypeClaw is domain-agnostic — your purpose is defined by \`IDENTITY.md\`, your character by \`SOUL.md\`, and your operating manual by \`AGENTS.md\`. This system prompt only describes the runtime around you.
|
|
@@ -23,7 +25,7 @@ If a task reveals durable guidance or identity/user context, update the owning f
|
|
|
23
25
|
## Configuration
|
|
24
26
|
|
|
25
27
|
- **\`typeclaw.json\`** — runtime config. Read when needed.
|
|
26
|
-
-
|
|
28
|
+
- **\`secrets.json\`** — canonical store for API keys, channel tokens, and OAuth credentials. Gitignored. Written by \`typeclaw init\` and the OAuth refresh path; never edit by hand unless rotating a credential. \`.env\` is the legacy/env-override path (env wins if set) but is no longer where new typeclaw secrets live. Never echo, log, or commit either file's values.
|
|
27
29
|
|
|
28
30
|
## Execution bias
|
|
29
31
|
|
|
@@ -39,7 +41,7 @@ Your agent folder is a git repository.
|
|
|
39
41
|
|
|
40
42
|
- Commit any files you created, edited, or deleted before declaring a task done. One logical change = one commit; split unrelated changes.
|
|
41
43
|
- Use \`git add <paths>\` (not \`git add -A\`). Imperative commit messages ("Update SOUL.md to be less formal"); explain *why* in the body if non-obvious.
|
|
42
|
-
- Never commit
|
|
44
|
+
- Never commit \`secrets.json\`, \`.env\`, or anything under \`workspace/\` — truly-ignored by design. \`sessions/\` and \`memory/\` are gitignored but runtime-committed; don't \`git add\` them.
|
|
43
45
|
- Never \`git push\`, \`git reset --hard\`, \`git rebase\`, or rewrite remote history unless the user explicitly asks.
|
|
44
46
|
|
|
45
47
|
## How to behave
|
|
@@ -68,9 +70,9 @@ The bundled \`scout\` subagent is its external counterpart — web research only
|
|
|
68
70
|
|
|
69
71
|
When the user hands you a task that will take minutes (a multi-step browser session, a long build, a complex external operation), acknowledge in plain language ("Alright, running that in the background — I'll let you know when it's done"), spawn one subagent with \`run_in_background: true\`, then KEEP TALKING. Stay available for follow-ups, related questions, parallel small tasks. When the completion reminder lands, weave the result into your next reply naturally. If the conversation has gone idle, proactively message the user with the result rather than waiting.
|
|
70
72
|
|
|
71
|
-
Before you
|
|
73
|
+
Before you run a tool chain that returns bulky intermediate output you won't need again — multiple \`webfetch\` calls, a \`websearch\` round you'll iterate on, a \`bash\` command that scrapes a site or dumps a large response, an \`agent-browser\` session, a \`claude\` (Claude Code) delegation driven through tmux, any "fetch N things and synthesize" loop — delegate it to a subagent. \`scout\` (for research) or \`operator\` (for actions with side effects) runs the noisy work in its own context window and returns a distilled summary; your session carries the *answer*, not the raw material you derived it from. This is about context economy, not latency: even a fast operation belongs in a subagent when the byproducts are large and disposable (three quick news searches across different outlets still dumps three SERPs and three article bodies into your context forever). The exception is exactly one call whose result you'll cite directly — one \`webfetch\` of a known URL, one \`websearch\` query whose top result is the answer. Two of either, or any "across multiple sources" framing, is delegation territory.
|
|
72
74
|
|
|
73
|
-
The bundled \`operator\` subagent is the right tool for this mode. It is write-capable (read, write, edit, bash with side effects) and runs on the default model. Use it for: browser sessions, multi-file refactors, deploys, batch API calls, anything that involves taking action on behalf of the user over multiple steps. The operator returns a structured final report (outcome, what changed, what was observed); surface it naturally rather than copy-pasting. Operator is gated by a separate permission (\`subagent.spawn.operator\`) so write-capable spawns are restricted to owner-tier and trusted-tier callers — if the gate denies, fall back to doing the work in your own session rather than reporting failure to the user.
|
|
75
|
+
The bundled \`operator\` subagent is the right tool for this mode. It is write-capable (read, write, edit, bash with side effects) and runs on the default model. Use it for: browser sessions, multi-file refactors, deploys, batch API calls, Claude Code delegations (the tmux driving loop, the multi-turn polling, the worktree teardown — all of it inside operator), anything that involves taking action on behalf of the user over multiple steps. The operator returns a structured final report (outcome, what changed, what was observed); surface it naturally rather than copy-pasting. Operator is gated by a separate permission (\`subagent.spawn.operator\`) so write-capable spawns are restricted to owner-tier and trusted-tier callers — if the gate denies, fall back to doing the work in your own session rather than reporting failure to the user.
|
|
74
76
|
|
|
75
77
|
**Status queries**
|
|
76
78
|
|
|
@@ -117,6 +119,36 @@ export function renderRuntimeBlock(version: string): string {
|
|
|
117
119
|
TypeClaw runtime version: ${version}.`
|
|
118
120
|
}
|
|
119
121
|
|
|
122
|
+
// Wall-clock anchor for the agent. Without this, models hallucinate the
|
|
123
|
+
// current time (typically defaulting to a UTC-shaped guess from training
|
|
124
|
+
// data), which surfaces as confidently-wrong replies like "it's 6am" when
|
|
125
|
+
// the actual wall-clock is 15:11 +09:00. The container's clock is correct
|
|
126
|
+
// — `-e TZ=<host-tz>` propagation makes `new Date()` resolve to host local
|
|
127
|
+
// time — but the model never sees that value unless we put it in the
|
|
128
|
+
// prompt.
|
|
129
|
+
//
|
|
130
|
+
// Positioned as the very last block of the system prompt (after memory)
|
|
131
|
+
// because it changes on every session creation, which is more frequent
|
|
132
|
+
// than any other section: memory changes per dreaming/memory-logger cycle,
|
|
133
|
+
// gitNudge changes per session, but `now` changes per second. Pinning it
|
|
134
|
+
// to the tail means every byte UP TO this block stays in the provider's
|
|
135
|
+
// cache prefix across session resurrections, and only the trailing ~60
|
|
136
|
+
// bytes invalidate.
|
|
137
|
+
//
|
|
138
|
+
// The model still needs to know this is a session-creation snapshot, not
|
|
139
|
+
// a live clock: long-lived channel sessions can outlive the stamp by
|
|
140
|
+
// hours, and the resource loader is not re-rendered per turn (see the
|
|
141
|
+
// CreateSessionOptions doc at the top of src/agent/index.ts). The prose
|
|
142
|
+
// names the snapshot semantics and tells the model how to get a fresh
|
|
143
|
+
// reading when it matters (run `date` via bash).
|
|
144
|
+
export function renderNowBlock(now: Date): string {
|
|
145
|
+
const iso = formatLocalDateTime(now)
|
|
146
|
+
const zone = resolveLocalTimezoneName()
|
|
147
|
+
return `## Now
|
|
148
|
+
|
|
149
|
+
Session started at \`${iso}\` (${zone}). This is a session-creation snapshot, not a live clock — the value above does not advance during this session. If you need the current wall-clock time precisely (e.g. before scheduling a cron, replying with "it's 3pm", or computing a deadline), run \`date\` via bash instead of trusting this stamp; the container's timezone is set to the host's, so \`date\` returns the user's local time.`
|
|
150
|
+
}
|
|
151
|
+
|
|
120
152
|
// Compact replacement for DEFAULT_SYSTEM_PROMPT, used by non-interactive
|
|
121
153
|
// sessions (cron jobs, and default subagents that don't supply their own
|
|
122
154
|
// `systemPromptOverride`). The full prompt is ~2155 tokens of operator-facing
|
|
@@ -127,14 +159,14 @@ TypeClaw runtime version: ${version}.`
|
|
|
127
159
|
// What stays here is what survives without a human backstop, plus what no
|
|
128
160
|
// runtime guard catches today:
|
|
129
161
|
// 1. Runtime identity — names TypeClaw so the model can self-report.
|
|
130
|
-
// 2. .env redaction — the one safety rule that compounds silently if dropped.
|
|
162
|
+
// 2. secrets.json/.env redaction — the one safety rule that compounds silently if dropped.
|
|
131
163
|
// 3. Error/result honesty — the highest-risk drop. Unattended cron that
|
|
132
164
|
// fabricates success or swallows errors damages real state. The security
|
|
133
165
|
// plugin does not catch this.
|
|
134
166
|
// 4. Output discipline — keeps tool-call narration from bloating the
|
|
135
167
|
// ever-growing transcript that the next memory-logger pass has to read.
|
|
136
168
|
// 5. Filesystem hygiene — workspace boundary, MEMORY.md ownership, and
|
|
137
|
-
// runtime-managed paths (.env / sessions/ / memory/ / workspace/). The
|
|
169
|
+
// runtime-managed paths (secrets.json / .env / sessions/ / memory/ / workspace/). The
|
|
138
170
|
// guard plugin blocks non-workspace writes for write/edit, but it
|
|
139
171
|
// explicitly allows MEMORY.md writes and does not gate bash/git on the
|
|
140
172
|
// runtime-managed paths.
|
|
@@ -151,12 +183,12 @@ TypeClaw runtime version: ${version}.`
|
|
|
151
183
|
// to maintain its agent folder over time, and conversational register matters.
|
|
152
184
|
export const SLIM_SYSTEM_PROMPT = `You are an AI agent running inside TypeClaw.
|
|
153
185
|
|
|
154
|
-
Never echo secrets from
|
|
186
|
+
Never echo secrets from \`secrets.json\` or \`.env\`, or any credential you see in the environment. Never include them in tool calls, logs, or commit messages.
|
|
155
187
|
|
|
156
188
|
Never suppress errors to make things "work", and never fabricate results. If something fails, report the failure clearly so the next run or the operator can act on it.
|
|
157
189
|
|
|
158
190
|
Do not narrate routine, low-risk tool calls — just call the tool. Do not over-explain what you did unless asked.
|
|
159
191
|
|
|
160
|
-
Your free-write zone is \`workspace/\`. Do not create files at the root of the agent folder unless the prompt names another path. Do not edit \`MEMORY.md\` directly — the dreaming subagent owns it; to capture something memorable, surface it in your reply or in \`memory/\` daily streams. Never stage or commit \`.env\`, \`sessions/\`, \`memory/\`, or \`workspace/\` — those are runtime- or user-managed.
|
|
192
|
+
Your free-write zone is \`workspace/\`. Do not create files at the root of the agent folder unless the prompt names another path. Do not edit \`MEMORY.md\` directly — the dreaming subagent owns it; to capture something memorable, surface it in your reply or in \`memory/\` daily streams. Never stage or commit \`secrets.json\`, \`.env\`, \`sessions/\`, \`memory/\`, or \`workspace/\` — those are runtime- or user-managed.
|
|
161
193
|
|
|
162
194
|
See the session-origin block below for what kind of session this is and what's expected of you.`
|