typeclaw 0.12.0 → 0.13.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/package.json +1 -1
- package/scripts/dump-system-prompt.ts +12 -11
- package/src/agent/index.ts +15 -22
- package/src/agent/loop-guard.ts +170 -0
- package/src/agent/model-fallback.ts +2 -1
- package/src/agent/multimodal/index.ts +1 -1
- package/src/agent/multimodal/look-at.ts +118 -55
- package/src/agent/plugin-tools.ts +57 -0
- package/src/agent/subagents.ts +2 -1
- package/src/agent/system-prompt.ts +28 -25
- package/src/agent/tools/channel-fetch-attachment.ts +45 -16
- package/src/agent/tools/normalize-ref.ts +11 -0
- package/src/bundled-plugins/reviewer/index.ts +11 -0
- package/src/bundled-plugins/reviewer/reviewer.ts +171 -0
- package/src/bundled-plugins/reviewer/skills/code-review.ts +73 -0
- package/src/bundled-plugins/reviewer/skills/general.ts +68 -0
- package/src/channels/adapters/discord-bot-classify.ts +32 -24
- package/src/channels/adapters/github/inbound.ts +19 -2
- package/src/channels/adapters/kakaotalk-attachment.ts +140 -133
- package/src/channels/adapters/kakaotalk-classify.ts +8 -1
- package/src/channels/adapters/kakaotalk.ts +19 -11
- package/src/channels/adapters/slack-bot-classify.ts +30 -14
- package/src/channels/adapters/slack-bot.ts +3 -2
- package/src/channels/adapters/telegram-bot-classify.ts +36 -13
- package/src/channels/adapters/telegram-bot.ts +3 -3
- package/src/channels/outbound-flood-filter.ts +57 -0
- package/src/channels/router.ts +93 -5
- package/src/channels/types.ts +52 -1
- package/src/cli/builtins.ts +1 -0
- package/src/cli/index.ts +1 -0
- package/src/cli/mount.ts +157 -0
- package/src/cli/update.ts +6 -4
- package/src/config/mounts-mutation.ts +161 -0
- package/src/init/hatching.ts +1 -1
- package/src/plugin/index.ts +6 -0
- package/src/plugin/load-skill.ts +99 -0
- package/src/run/bundled-plugins.ts +2 -0
- package/src/run/index.ts +14 -1
- package/src/secrets/codex-auth-json.ts +67 -0
- package/src/secrets/export-codex-auth-file.ts +243 -0
- package/src/secrets/index.ts +6 -0
- package/src/server/command-runner.ts +2 -1
- package/src/server/index.ts +3 -2
- package/src/shared/index.ts +7 -1
- package/src/shared/local-time.ts +32 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +47 -13
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +10 -11
- package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +8 -0
- package/src/skills/typeclaw-codex-cli/SKILL.md +2 -1
- package/src/skills/typeclaw-codex-cli/references/auth-flow.md +22 -0
- package/src/skills/typeclaw-kaomoji/SKILL.md +116 -0
- package/src/update/index.ts +95 -26
package/src/cli/mount.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { defineCommand } from 'citty'
|
|
2
|
+
|
|
3
|
+
import { addMount, listMounts, removeMount, type MountListEntry } from '@/config/mounts-mutation'
|
|
4
|
+
import { findAgentDir, isInitialized } from '@/init'
|
|
5
|
+
|
|
6
|
+
import { c, errorLine, successLine } from './ui'
|
|
7
|
+
|
|
8
|
+
const listSub = defineCommand({
|
|
9
|
+
meta: {
|
|
10
|
+
name: 'list',
|
|
11
|
+
description: 'list host directories mounted into the agent container',
|
|
12
|
+
},
|
|
13
|
+
args: {
|
|
14
|
+
json: {
|
|
15
|
+
type: 'boolean',
|
|
16
|
+
description: 'emit mounts as JSON',
|
|
17
|
+
default: false,
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
async run({ args }) {
|
|
21
|
+
const cwd = ensureAgentDir()
|
|
22
|
+
const mounts = listMounts(cwd)
|
|
23
|
+
if (args.json) {
|
|
24
|
+
process.stdout.write(`${JSON.stringify({ mounts }, null, 2)}\n`)
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
process.stdout.write(`${formatMountList(mounts)}\n`)
|
|
28
|
+
},
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const addSub = defineCommand({
|
|
32
|
+
meta: {
|
|
33
|
+
name: 'add',
|
|
34
|
+
description: 'add a host directory mount to typeclaw.json',
|
|
35
|
+
},
|
|
36
|
+
args: {
|
|
37
|
+
name: {
|
|
38
|
+
type: 'positional',
|
|
39
|
+
description: 'mount name; appears inside the container at /agent/mounts/<name>',
|
|
40
|
+
required: true,
|
|
41
|
+
},
|
|
42
|
+
path: {
|
|
43
|
+
type: 'positional',
|
|
44
|
+
description: 'host directory path to expose inside the container',
|
|
45
|
+
required: true,
|
|
46
|
+
},
|
|
47
|
+
'read-only': {
|
|
48
|
+
type: 'boolean',
|
|
49
|
+
description: 'mount read-only inside the container',
|
|
50
|
+
default: false,
|
|
51
|
+
},
|
|
52
|
+
description: {
|
|
53
|
+
type: 'string',
|
|
54
|
+
description: 'optional human-readable note stored in typeclaw.json',
|
|
55
|
+
required: false,
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
async run({ args }) {
|
|
59
|
+
const cwd = ensureAgentDir()
|
|
60
|
+
const result = addMount(cwd, args.name, args.path, {
|
|
61
|
+
readOnly: args['read-only'] === true,
|
|
62
|
+
...(args.description !== undefined ? { description: args.description } : {}),
|
|
63
|
+
})
|
|
64
|
+
if (!result.ok) {
|
|
65
|
+
console.error(errorLine(result.reason))
|
|
66
|
+
process.exit(1)
|
|
67
|
+
}
|
|
68
|
+
process.stdout.write(`${successLine(`Added mount "${result.entry.name}".`)}\n`)
|
|
69
|
+
process.stdout.write(`${formatMountEntry(result.entry)}\n`)
|
|
70
|
+
process.stdout.write(`${c.dim('Apply change:')} ${c.cyan('typeclaw restart')}\n`)
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const removeSub = defineCommand({
|
|
75
|
+
meta: {
|
|
76
|
+
name: 'remove',
|
|
77
|
+
description: 'remove a host directory mount from typeclaw.json',
|
|
78
|
+
},
|
|
79
|
+
args: {
|
|
80
|
+
name: {
|
|
81
|
+
type: 'positional',
|
|
82
|
+
description: 'mount name to remove',
|
|
83
|
+
required: true,
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
async run({ args }) {
|
|
87
|
+
const cwd = ensureAgentDir()
|
|
88
|
+
const result = removeMount(cwd, args.name)
|
|
89
|
+
if (!result.ok) {
|
|
90
|
+
console.error(errorLine(result.reason))
|
|
91
|
+
process.exit(1)
|
|
92
|
+
}
|
|
93
|
+
process.stdout.write(`${successLine(`Removed mount "${result.removed.name}".`)}\n`)
|
|
94
|
+
process.stdout.write(`${c.dim('Apply change:')} ${c.cyan('typeclaw restart')}\n`)
|
|
95
|
+
},
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
export const mountCommand = defineCommand({
|
|
99
|
+
meta: {
|
|
100
|
+
name: 'mount',
|
|
101
|
+
description: 'manage host directories mounted into the agent container',
|
|
102
|
+
},
|
|
103
|
+
subCommands: {
|
|
104
|
+
list: listSub,
|
|
105
|
+
add: addSub,
|
|
106
|
+
remove: removeSub,
|
|
107
|
+
},
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
export function formatMountList(mounts: readonly MountListEntry[]): string {
|
|
111
|
+
if (mounts.length === 0) return c.dim('No mounts configured.')
|
|
112
|
+
|
|
113
|
+
const nameWidth = Math.max(4, ...mounts.map((m) => m.name.length))
|
|
114
|
+
const modeWidth = 'MODE'.length
|
|
115
|
+
const statusWidth = Math.max(6, ...mounts.map((m) => m.status.length))
|
|
116
|
+
const lines: string[] = []
|
|
117
|
+
lines.push(
|
|
118
|
+
c.dim(
|
|
119
|
+
`${'NAME'.padEnd(nameWidth)} ${'MODE'.padEnd(modeWidth)} ${'STATUS'.padEnd(statusWidth)} HOST PATH -> CONTAINER PATH`,
|
|
120
|
+
),
|
|
121
|
+
)
|
|
122
|
+
for (const mount of mounts) {
|
|
123
|
+
const mode = mount.readOnly ? 'ro' : 'rw'
|
|
124
|
+
const statusText = mount.status.padEnd(statusWidth)
|
|
125
|
+
const status = mount.status === 'ok' ? c.green(statusText) : c.red(statusText)
|
|
126
|
+
lines.push(
|
|
127
|
+
`${mount.name.padEnd(nameWidth)} ${mode.padEnd(modeWidth)} ${status} ${mount.resolvedPath} -> ${mount.targetPath}`,
|
|
128
|
+
)
|
|
129
|
+
if (mount.description !== undefined) {
|
|
130
|
+
lines.push(`${' '.repeat(nameWidth + modeWidth + statusWidth + 6)}${c.dim(mount.description)}`)
|
|
131
|
+
}
|
|
132
|
+
if (mount.statusReason !== undefined) {
|
|
133
|
+
lines.push(`${' '.repeat(nameWidth + modeWidth + statusWidth + 6)}${c.yellow(mount.statusReason)}`)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return lines.join('\n')
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function formatMountEntry(mount: MountListEntry): string {
|
|
140
|
+
const mode = mount.readOnly ? 'read-only' : 'read-write'
|
|
141
|
+
const details = [
|
|
142
|
+
`${c.dim('host:')} ${mount.resolvedPath}`,
|
|
143
|
+
`${c.dim('container:')} ${mount.targetPath}`,
|
|
144
|
+
`${c.dim('mode:')} ${mode}`,
|
|
145
|
+
]
|
|
146
|
+
if (mount.description !== undefined) details.push(`${c.dim('description:')} ${mount.description}`)
|
|
147
|
+
return details.join('\n')
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function ensureAgentDir(): string {
|
|
151
|
+
const cwd = findAgentDir(process.cwd()) ?? process.cwd()
|
|
152
|
+
if (!isInitialized(cwd)) {
|
|
153
|
+
console.error(errorLine('TypeClaw config file not found. Run `typeclaw init` first, or cd into an agent folder.'))
|
|
154
|
+
process.exit(1)
|
|
155
|
+
}
|
|
156
|
+
return cwd
|
|
157
|
+
}
|
package/src/cli/update.ts
CHANGED
|
@@ -9,7 +9,7 @@ const MANAGERS = ['auto', 'bun', 'npm', 'pnpm', 'yarn'] as const
|
|
|
9
9
|
export const updateCommand = defineCommand({
|
|
10
10
|
meta: {
|
|
11
11
|
name: 'update',
|
|
12
|
-
description: 'update the
|
|
12
|
+
description: 'update the installed typeclaw CLI (auto-detects global vs local)',
|
|
13
13
|
},
|
|
14
14
|
args: {
|
|
15
15
|
manager: {
|
|
@@ -42,8 +42,9 @@ export const updateCommand = defineCommand({
|
|
|
42
42
|
return
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
const scopeLabel = plan.scope === 'global' ? 'global' : `local (${plan.cwd ?? '.'})`
|
|
46
|
+
process.stdout.write(`${c.cyan(`Updating TypeClaw [${scopeLabel}] with:`)} ${rendered}\n`)
|
|
47
|
+
const exitCode = await runUpdateCommand(plan.command, plan.cwd)
|
|
47
48
|
if (exitCode !== 0) {
|
|
48
49
|
console.error(errorLine(`Update command exited with code ${exitCode}.`))
|
|
49
50
|
process.exit(exitCode)
|
|
@@ -58,7 +59,7 @@ function parseManager(value: string | undefined): UpdateManagerSelection | null
|
|
|
58
59
|
return (MANAGERS as readonly string[]).includes(value) ? (value as UpdateManagerSelection) : null
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
async function runUpdateCommand(command: string[]): Promise<number> {
|
|
62
|
+
async function runUpdateCommand(command: string[], cwd: string | undefined): Promise<number> {
|
|
62
63
|
const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
|
|
63
64
|
if (!bun) {
|
|
64
65
|
console.error(errorLine('bun runtime not available'))
|
|
@@ -67,6 +68,7 @@ async function runUpdateCommand(command: string[]): Promise<number> {
|
|
|
67
68
|
try {
|
|
68
69
|
const proc = bun.spawn({
|
|
69
70
|
cmd: command,
|
|
71
|
+
cwd,
|
|
70
72
|
stdin: 'inherit',
|
|
71
73
|
stdout: 'inherit',
|
|
72
74
|
stderr: 'inherit',
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { commitSystemFileSync } from '@/git/system-commit'
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
configSchema,
|
|
8
|
+
expandMountPath,
|
|
9
|
+
loadConfigSyncOrDefaults,
|
|
10
|
+
mountSchema,
|
|
11
|
+
validateMount,
|
|
12
|
+
type Mount,
|
|
13
|
+
} from './config'
|
|
14
|
+
|
|
15
|
+
const CONFIG_FILE = 'typeclaw.json'
|
|
16
|
+
const MOUNT_TARGET_PREFIX = '/agent/mounts'
|
|
17
|
+
|
|
18
|
+
export type MountListEntry = Mount & {
|
|
19
|
+
resolvedPath: string
|
|
20
|
+
targetPath: string
|
|
21
|
+
status: 'ok' | 'error'
|
|
22
|
+
statusReason?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type AddMountOptions = {
|
|
26
|
+
readOnly?: boolean
|
|
27
|
+
description?: string | undefined
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type AddMountResult = { ok: true; entry: MountListEntry } | { ok: false; reason: string }
|
|
31
|
+
export type RemoveMountResult = { ok: true; removed: MountListEntry } | { ok: false; reason: string }
|
|
32
|
+
|
|
33
|
+
export function listMounts(cwd: string): MountListEntry[] {
|
|
34
|
+
const mounts = loadConfigSyncOrDefaults(cwd).mounts
|
|
35
|
+
return mounts.map((mount) => toListEntry(mount, cwd))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function addMount(cwd: string, name: string, path: string, options: AddMountOptions = {}): AddMountResult {
|
|
39
|
+
const mount = buildMount(name, path, options)
|
|
40
|
+
if (!mount.ok) return mount
|
|
41
|
+
|
|
42
|
+
const check = validateMount(mount.value, cwd)
|
|
43
|
+
if (!check.ok) return check
|
|
44
|
+
|
|
45
|
+
const parsed = readConfigRecord(cwd)
|
|
46
|
+
if (!parsed.ok) return parsed
|
|
47
|
+
|
|
48
|
+
const current = readMounts(parsed.value)
|
|
49
|
+
if (!current.ok) return current
|
|
50
|
+
if (current.value.some((m) => m.name === mount.value.name)) {
|
|
51
|
+
return {
|
|
52
|
+
ok: false,
|
|
53
|
+
reason: `Mount "${mount.value.name}" already exists. Remove it first with \`typeclaw mount remove ${mount.value.name}\`.`,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const next = { ...parsed.value, mounts: [...current.value, mount.value] }
|
|
58
|
+
const write = writeMounts(cwd, next, `mount: add ${mount.value.name}`)
|
|
59
|
+
if (!write.ok) return write
|
|
60
|
+
return { ok: true, entry: toListEntry(mount.value, cwd) }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function removeMount(cwd: string, name: string): RemoveMountResult {
|
|
64
|
+
const trimmed = name.trim()
|
|
65
|
+
if (trimmed.length === 0) return { ok: false, reason: 'Mount name cannot be empty.' }
|
|
66
|
+
|
|
67
|
+
const parsed = readConfigRecord(cwd)
|
|
68
|
+
if (!parsed.ok) return parsed
|
|
69
|
+
|
|
70
|
+
const current = readMounts(parsed.value)
|
|
71
|
+
if (!current.ok) return current
|
|
72
|
+
|
|
73
|
+
const removed = current.value.find((m) => m.name === trimmed)
|
|
74
|
+
if (removed === undefined) return { ok: false, reason: `Mount "${trimmed}" not found in ${CONFIG_FILE}.` }
|
|
75
|
+
|
|
76
|
+
const next = { ...parsed.value, mounts: current.value.filter((m) => m.name !== trimmed) }
|
|
77
|
+
const write = writeMounts(cwd, next, `mount: remove ${trimmed}`)
|
|
78
|
+
if (!write.ok) return write
|
|
79
|
+
return { ok: true, removed: toListEntry(removed, cwd) }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function buildMount(
|
|
83
|
+
name: string,
|
|
84
|
+
path: string,
|
|
85
|
+
options: AddMountOptions,
|
|
86
|
+
): { ok: true; value: Mount } | { ok: false; reason: string } {
|
|
87
|
+
const description = options.description?.trim()
|
|
88
|
+
const raw = {
|
|
89
|
+
name: name.trim(),
|
|
90
|
+
path,
|
|
91
|
+
readOnly: options.readOnly ?? false,
|
|
92
|
+
...(description !== undefined && description.length > 0 ? { description } : {}),
|
|
93
|
+
}
|
|
94
|
+
const parsed = mountSchema.safeParse(raw)
|
|
95
|
+
if (!parsed.success) {
|
|
96
|
+
return { ok: false, reason: parsed.error.issues.map(formatIssue).join('; ') }
|
|
97
|
+
}
|
|
98
|
+
return { ok: true, value: parsed.data }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function readConfigRecord(cwd: string): { ok: true; value: Record<string, unknown> } | { ok: false; reason: string } {
|
|
102
|
+
try {
|
|
103
|
+
const raw = readFileSync(join(cwd, CONFIG_FILE), 'utf8')
|
|
104
|
+
const parsed = JSON.parse(raw) as unknown
|
|
105
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
106
|
+
return { ok: false, reason: `${CONFIG_FILE} must contain a JSON object.` }
|
|
107
|
+
}
|
|
108
|
+
return { ok: true, value: parsed as Record<string, unknown> }
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
111
|
+
return { ok: false, reason: `${CONFIG_FILE} not found at ${cwd}. Run \`typeclaw init\` first.` }
|
|
112
|
+
}
|
|
113
|
+
return { ok: false, reason: `Failed to read ${CONFIG_FILE}: ${(error as Error).message}` }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function readMounts(record: Record<string, unknown>): { ok: true; value: Mount[] } | { ok: false; reason: string } {
|
|
118
|
+
const parsed = configSchema.safeParse(record)
|
|
119
|
+
if (!parsed.success) {
|
|
120
|
+
return { ok: false, reason: `${CONFIG_FILE} is invalid: ${parsed.error.issues.map(formatIssue).join('; ')}` }
|
|
121
|
+
}
|
|
122
|
+
return { ok: true, value: parsed.data.mounts }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function writeMounts(
|
|
126
|
+
cwd: string,
|
|
127
|
+
record: Record<string, unknown>,
|
|
128
|
+
commitMessage: string,
|
|
129
|
+
): { ok: true } | { ok: false; reason: string } {
|
|
130
|
+
const parsed = configSchema.safeParse(record)
|
|
131
|
+
if (!parsed.success) {
|
|
132
|
+
return { ok: false, reason: `mounts block would be invalid: ${parsed.error.issues.map(formatIssue).join('; ')}` }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
writeFileSync(join(cwd, CONFIG_FILE), `${JSON.stringify(record, null, 2)}\n`)
|
|
137
|
+
} catch (error) {
|
|
138
|
+
return { ok: false, reason: `Failed to write ${CONFIG_FILE}: ${(error as Error).message}` }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
commitSystemFileSync(cwd, CONFIG_FILE, commitMessage)
|
|
142
|
+
return { ok: true }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function toListEntry(mount: Mount, cwd: string): MountListEntry {
|
|
146
|
+
const resolvedPath = expandMountPath(mount.path, cwd)
|
|
147
|
+
const targetPath = `${MOUNT_TARGET_PREFIX}/${mount.name}`
|
|
148
|
+
const check = validateMount(mount, cwd)
|
|
149
|
+
return {
|
|
150
|
+
...mount,
|
|
151
|
+
resolvedPath,
|
|
152
|
+
targetPath,
|
|
153
|
+
status: check.ok ? 'ok' : 'error',
|
|
154
|
+
...(!check.ok ? { statusReason: check.reason } : {}),
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function formatIssue(issue: { path: PropertyKey[]; message: string }): string {
|
|
159
|
+
const path = issue.path.length > 0 ? issue.path.map(String).join('.') : '<root>'
|
|
160
|
+
return `${path}: ${issue.message}`
|
|
161
|
+
}
|
package/src/init/hatching.ts
CHANGED
|
@@ -38,7 +38,7 @@ Routing answers:
|
|
|
38
38
|
1. \`write\` your name into \`IDENTITY.md\` (a first-person one-liner is fine: "I am <name>.").
|
|
39
39
|
2. ${q1AliasStep} The agent folder's directory name is already an implicit alias — only add the answered name explicitly when it differs from the dir name (different casing, a different word, or extra forms like "<name>" plus a Latin transliteration). This wires plain-text addressing in channels: when a user writes your name in chat without an @-mention, the engagement layer will recognize it. \`alias\` is live-reloadable.
|
|
40
40
|
2. **Q2 — the user's name.** Ask what to call them. After the answer: \`write\` it to both \`IDENTITY.md\` and \`USER.md\`.
|
|
41
|
-
3. **Q3 — tone/personality.** Ask how they want you to show up (tone, language, formality). After the answer: \`write\` it into \`SOUL.md\`. If they shrug or don't care: **default to warm, friendly, and easygoing** — a kind colleague who genuinely likes the person they work with, uses contractions, makes small jokes, never stiff. Write that as the default into \`SOUL.md\`.
|
|
41
|
+
3. **Q3 — tone/personality.** Ask how they want you to show up (tone, language, formality). After the answer: \`write\` it into \`SOUL.md\`. If they shrug or don't care: **default to warm, friendly, and easygoing** — a kind colleague who genuinely likes the person they work with, uses contractions, makes small jokes, never stiff. Write that as the default into \`SOUL.md\`. **Kaomoji affinity** — if their answer leans cute, adorable, warm, playful, soft, cozy, or is in Korean asking for 친근/귀엽/다정한 tone, append a line to \`SOUL.md\` like: \`I lean on kaomojis like (◕‿◕✿) and (。・ω・。) to carry warmth — emojis still welcome when they actually mean something, but kaomojis lead.\` This makes the bundled \`typeclaw-kaomoji\` skill auto-load later when you need it. Do not force this line if the user asked for a neutral, professional, or terse tone.
|
|
42
42
|
|
|
43
43
|
**Do not ask what they want you to do, what project you'll work on, or why they installed you.** That reveals itself when they give you a real task. Probing here makes the tool feel heavy for someone just trying it out.
|
|
44
44
|
|
package/src/plugin/index.ts
CHANGED
|
@@ -75,6 +75,12 @@ export type { PermissionService } from '@/permissions'
|
|
|
75
75
|
export type { LoadPluginEntryFn, ResolvedPlugin } from './loader'
|
|
76
76
|
export { loadPluginEntry, derivePluginNameFromPackage } from './loader'
|
|
77
77
|
export { materializeSkills, type MaterializedSkills, type SkillEntry } from './skills'
|
|
78
|
+
export {
|
|
79
|
+
createLoadSkillTool,
|
|
80
|
+
type CreateLoadSkillToolOptions,
|
|
81
|
+
type LoadableSkill,
|
|
82
|
+
type LoadSkillArgs,
|
|
83
|
+
} from './load-skill'
|
|
78
84
|
export {
|
|
79
85
|
buildPluginCronGlobalId,
|
|
80
86
|
RESERVED_COMMAND_NAMES,
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
import { defineTool } from './define'
|
|
4
|
+
import type { Tool } from './types'
|
|
5
|
+
|
|
6
|
+
// One unit of curated skill content a subagent can load on demand. The
|
|
7
|
+
// `name` becomes a value in the tool's `name` enum; `description` is what
|
|
8
|
+
// the model sees in the tool description block so it can decide which
|
|
9
|
+
// skill to load WITHOUT having to load it first; `content` is the body
|
|
10
|
+
// returned by the tool when the model picks this skill.
|
|
11
|
+
export type LoadableSkill = {
|
|
12
|
+
name: string
|
|
13
|
+
description: string
|
|
14
|
+
content: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type CreateLoadSkillToolOptions = {
|
|
18
|
+
skills: readonly LoadableSkill[]
|
|
19
|
+
// Override the tool's top-level description. Defaults to a generic
|
|
20
|
+
// explanation of how the tool works followed by the per-skill menu.
|
|
21
|
+
// Plugins can pass a more specific framing (e.g. "Load a review skill
|
|
22
|
+
// …") so the subagent's instructions line up with the tool name.
|
|
23
|
+
description?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type LoadSkillArgs = { name: string }
|
|
27
|
+
|
|
28
|
+
// Build a typed `load_skill` tool a subagent can call to fetch the body
|
|
29
|
+
// of a curated skill on demand. The factory closes over the `skills`
|
|
30
|
+
// list so:
|
|
31
|
+
// - the `name` parameter is a Zod enum narrowed to exactly the skill
|
|
32
|
+
// names the caller supplied (typo-resistant; the model sees the
|
|
33
|
+
// allowed values in the tool's JSON Schema),
|
|
34
|
+
// - the tool's `description` lists every skill's name + description so
|
|
35
|
+
// the model can choose the right one BEFORE calling the tool, paying
|
|
36
|
+
// only one tool-call's worth of context for the body it actually
|
|
37
|
+
// needs,
|
|
38
|
+
// - unknown names are rejected by parameter validation, not by the
|
|
39
|
+
// handler — the runtime returns the validation error before
|
|
40
|
+
// `execute` runs.
|
|
41
|
+
//
|
|
42
|
+
// This is the runtime-loaded counterpart to TypeClaw's startup-time
|
|
43
|
+
// skill discovery (`additionalSkillPaths` in `src/agent/index.ts`).
|
|
44
|
+
// Subagents bypass the file-based resource loader, so the startup path
|
|
45
|
+
// is unavailable to them; this factory gives plugin authors a typed way
|
|
46
|
+
// to expose curated skills to their subagents via `customTools`.
|
|
47
|
+
export function createLoadSkillTool(options: CreateLoadSkillToolOptions): Tool<LoadSkillArgs> {
|
|
48
|
+
const { skills } = options
|
|
49
|
+
|
|
50
|
+
if (skills.length === 0) {
|
|
51
|
+
throw new Error('createLoadSkillTool: `skills` must contain at least one entry')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const seen = new Set<string>()
|
|
55
|
+
for (const skill of skills) {
|
|
56
|
+
if (skill.name.length === 0) {
|
|
57
|
+
throw new Error('createLoadSkillTool: skill name must be non-empty')
|
|
58
|
+
}
|
|
59
|
+
if (seen.has(skill.name)) {
|
|
60
|
+
throw new Error(`createLoadSkillTool: duplicate skill name ${JSON.stringify(skill.name)}`)
|
|
61
|
+
}
|
|
62
|
+
seen.add(skill.name)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// z.enum requires a `[string, ...string[]]` tuple. Build it from the
|
|
66
|
+
// skill list so the JSON Schema surfaced to the model lists exactly
|
|
67
|
+
// the allowed values.
|
|
68
|
+
const names = skills.map((s) => s.name) as [string, ...string[]]
|
|
69
|
+
|
|
70
|
+
const description = options.description ?? buildDefaultDescription(skills)
|
|
71
|
+
|
|
72
|
+
return defineTool<LoadSkillArgs>({
|
|
73
|
+
description,
|
|
74
|
+
parameters: z.object({
|
|
75
|
+
name: z.enum(names).describe('The name of the skill to load. Must match one of the available skills.'),
|
|
76
|
+
}),
|
|
77
|
+
async execute(args) {
|
|
78
|
+
const skill = skills.find((s) => s.name === args.name)
|
|
79
|
+
if (skill === undefined) {
|
|
80
|
+
// Defensive: Zod enum validation should have rejected this
|
|
81
|
+
// before reaching the handler. Surface a clear message anyway.
|
|
82
|
+
const available = names.join(', ')
|
|
83
|
+
throw new Error(`Unknown skill ${JSON.stringify(args.name)}. Available skills: ${available}.`)
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
content: [{ type: 'text' as const, text: skill.content }],
|
|
87
|
+
details: { name: skill.name, contentBytes: skill.content.length },
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function buildDefaultDescription(skills: readonly LoadableSkill[]): string {
|
|
94
|
+
const menu = skills.map((s) => `- \`${s.name}\` — ${s.description}`).join('\n')
|
|
95
|
+
return `Load a curated skill by name. Returns the full skill body as text so you can apply it to the current task. Call this when you have identified which skill matches the task; do NOT load multiple skills speculatively.
|
|
96
|
+
|
|
97
|
+
Available skills:
|
|
98
|
+
${menu}`
|
|
99
|
+
}
|
|
@@ -4,6 +4,7 @@ import explorerPlugin from '@/bundled-plugins/explorer'
|
|
|
4
4
|
import guardPlugin from '@/bundled-plugins/guard'
|
|
5
5
|
import memoryPlugin from '@/bundled-plugins/memory'
|
|
6
6
|
import operatorPlugin from '@/bundled-plugins/operator'
|
|
7
|
+
import reviewerPlugin from '@/bundled-plugins/reviewer'
|
|
7
8
|
import scoutPlugin from '@/bundled-plugins/scout'
|
|
8
9
|
import securityPlugin from '@/bundled-plugins/security'
|
|
9
10
|
import toolResultCapPlugin from '@/bundled-plugins/tool-result-cap'
|
|
@@ -41,5 +42,6 @@ export const BUNDLED_PLUGINS: ResolvedPlugin[] = [
|
|
|
41
42
|
{ name: 'agent-browser', version: undefined, source: '<bundled>', defined: agentBrowserPlugin },
|
|
42
43
|
{ name: 'explorer', version: undefined, source: '<bundled>', defined: explorerPlugin },
|
|
43
44
|
{ name: 'scout', version: undefined, source: '<bundled>', defined: scoutPlugin },
|
|
45
|
+
{ name: 'reviewer', version: undefined, source: '<bundled>', defined: reviewerPlugin },
|
|
44
46
|
{ name: 'operator', version: undefined, source: '<bundled>', defined: operatorPlugin },
|
|
45
47
|
]
|
package/src/run/index.ts
CHANGED
|
@@ -43,7 +43,7 @@ import type { CronHandlerContext } from '@/plugin/types'
|
|
|
43
43
|
import { createContainerBroker, publishForwardResult } from '@/portbroker'
|
|
44
44
|
import { ReloadRegistry } from '@/reload'
|
|
45
45
|
import { createClaimController } from '@/role-claim'
|
|
46
|
-
import { hydrateChannelEnvFromSecrets } from '@/secrets'
|
|
46
|
+
import { exportCodexAuthFileForAgent, hydrateChannelEnvFromSecrets } from '@/secrets'
|
|
47
47
|
import { createServer, type Server } from '@/server'
|
|
48
48
|
import {
|
|
49
49
|
createCommandRunner,
|
|
@@ -181,6 +181,19 @@ export async function startAgent({
|
|
|
181
181
|
// stay in env, the file stays user-owned. See src/secrets/hydrate.ts.
|
|
182
182
|
hydrateChannelEnvFromSecrets({ agentDir: cwd })
|
|
183
183
|
|
|
184
|
+
// When the user has `docker.file.codexCli: true` AND a typeclaw-managed
|
|
185
|
+
// openai-codex OAuth credential in secrets.json, write ~/.codex/auth.json
|
|
186
|
+
// so the Codex CLI in the container can run without a second login. The
|
|
187
|
+
// exporter is failure-tolerant by design: any error (gate miss, fs error,
|
|
188
|
+
// corrupt file) returns a non-fatal result and the agent boot continues.
|
|
189
|
+
// See src/secrets/export-codex-auth-file.ts for the newer-wins compare
|
|
190
|
+
// that prevents clobbering Codex CLI's in-place token refreshes.
|
|
191
|
+
exportCodexAuthFileForAgent({
|
|
192
|
+
agentDir: cwd,
|
|
193
|
+
codexCliEnabled: cwdConfig.docker.file.codexCli,
|
|
194
|
+
log: (message) => console.warn(message),
|
|
195
|
+
})
|
|
196
|
+
|
|
184
197
|
const claimController = createClaimController({
|
|
185
198
|
cwd,
|
|
186
199
|
permissions: pluginsLoaded.permissions,
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { ProviderCredential } from './schema'
|
|
2
|
+
|
|
3
|
+
// Emit the on-disk shape Codex CLI consumes at ~/.codex/auth.json. Mirrors
|
|
4
|
+
// the modern (>= 0.93) shape: a single `tokens` object with access_token,
|
|
5
|
+
// refresh_token, and optional account_id. Codex re-derives token expiry
|
|
6
|
+
// from the JWT's `exp` claim on every load, so we deliberately omit a
|
|
7
|
+
// top-level `expires` field even though typeclaw stores one.
|
|
8
|
+
//
|
|
9
|
+
// Pre-0.93 codex used a different layout (top-level OPENAI_API_KEY +
|
|
10
|
+
// auth_mode discriminator + optional tokens). We don't emit that legacy
|
|
11
|
+
// shape — every codex install old enough to require it has been replaced
|
|
12
|
+
// by the version `docker.file.codexCli` installs in the container.
|
|
13
|
+
export function emitCodexAuthJson(credential: ProviderCredential): string {
|
|
14
|
+
if (credential.type !== 'oauth') {
|
|
15
|
+
throw new Error('emitCodexAuthJson only accepts oauth-typed credentials')
|
|
16
|
+
}
|
|
17
|
+
const fields = credential as ProviderCredential & {
|
|
18
|
+
access?: unknown
|
|
19
|
+
refresh?: unknown
|
|
20
|
+
accountId?: unknown
|
|
21
|
+
}
|
|
22
|
+
const access = fields.access
|
|
23
|
+
const refresh = fields.refresh
|
|
24
|
+
if (typeof access !== 'string' || access.length === 0) {
|
|
25
|
+
throw new Error('credential is missing a non-empty `access` field')
|
|
26
|
+
}
|
|
27
|
+
if (typeof refresh !== 'string' || refresh.length === 0) {
|
|
28
|
+
throw new Error('credential is missing a non-empty `refresh` field')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const tokens: Record<string, string> = { access_token: access, refresh_token: refresh }
|
|
32
|
+
if (typeof fields.accountId === 'string' && fields.accountId.length > 0) {
|
|
33
|
+
tokens['account_id'] = fields.accountId
|
|
34
|
+
}
|
|
35
|
+
return `${JSON.stringify({ tokens }, null, 2)}\n`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Extracts the JWT `exp` claim (seconds since epoch) and converts to ms.
|
|
39
|
+
// Used by the runtime exporter's newer-wins compare: ~/.codex/auth.json
|
|
40
|
+
// carries no top-level expiry, but the JWT inside `tokens.access_token`
|
|
41
|
+
// does. Returns null on any decode failure; the caller treats that as
|
|
42
|
+
// "unknown freshness" and falls back to overwriting from typeclaw's copy.
|
|
43
|
+
export function decodeCodexAccessTokenExpiryMs(accessToken: string): number | null {
|
|
44
|
+
const parts = accessToken.split('.')
|
|
45
|
+
if (parts.length !== 3) return null
|
|
46
|
+
const middle = parts[1] ?? ''
|
|
47
|
+
if (middle === '') return null
|
|
48
|
+
// base64url → base64, then pad to a multiple of 4 (atob is strict).
|
|
49
|
+
const normalized = middle.replace(/-/g, '+').replace(/_/g, '/')
|
|
50
|
+
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4)
|
|
51
|
+
let payload: Record<string, unknown>
|
|
52
|
+
try {
|
|
53
|
+
const decoded = typeof atob === 'function' ? atob(padded) : Buffer.from(padded, 'base64').toString('utf8')
|
|
54
|
+
const parsed: unknown = JSON.parse(decoded)
|
|
55
|
+
if (!isPlainObject(parsed)) return null
|
|
56
|
+
payload = parsed
|
|
57
|
+
} catch {
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
const exp = payload['exp']
|
|
61
|
+
if (typeof exp !== 'number' || !Number.isFinite(exp)) return null
|
|
62
|
+
return Math.floor(exp * 1000)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
66
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
67
|
+
}
|