typeclaw 0.23.0 → 0.24.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/src/agent/index.ts +91 -22
- package/src/agent/plugin-tools.ts +38 -2
- package/src/agent/restart/index.ts +15 -3
- package/src/agent/restart-handoff/index.ts +110 -12
- package/src/agent/subagent-completion-reminder.ts +3 -1
- package/src/agent/subagents.ts +44 -1
- package/src/agent/system-prompt.ts +4 -0
- package/src/agent/todo/continuation-policy.ts +242 -0
- package/src/agent/todo/continuation-state.ts +87 -0
- package/src/agent/todo/continuation-wiring.ts +113 -0
- package/src/agent/todo/continuation.ts +71 -0
- package/src/agent/todo/scope.ts +77 -0
- package/src/agent/todo/store.ts +98 -0
- package/src/agent/tool-not-found-nudge.ts +119 -0
- package/src/agent/tools/channel-reply.ts +51 -0
- package/src/agent/tools/restart.ts +11 -4
- package/src/agent/tools/todo/index.ts +119 -0
- package/src/bundled-plugins/backup/runner.ts +1 -1
- package/src/bundled-plugins/reviewer/reviewer.ts +14 -0
- package/src/channels/adapters/discord-bot-reference.ts +78 -0
- package/src/channels/adapters/discord-bot.ts +25 -3
- package/src/channels/adapters/github/inbound.ts +161 -10
- package/src/channels/adapters/github/index.ts +10 -0
- package/src/channels/adapters/github/review-thread-resolver.ts +246 -0
- package/src/channels/adapters/kakaotalk-classify.ts +67 -6
- package/src/channels/adapters/slack-bot-classify.ts +9 -1
- package/src/channels/adapters/slack-bot-reference.ts +129 -0
- package/src/channels/adapters/slack-bot.ts +67 -8
- package/src/channels/manager.ts +8 -2
- package/src/channels/router.ts +445 -22
- package/src/channels/schema.ts +20 -4
- package/src/channels/types.ts +68 -0
- package/src/cli/inspect-controller.ts +7 -0
- package/src/cli/inspect.ts +2 -1
- package/src/commands/index.ts +9 -0
- package/src/init/gitignore.ts +5 -2
- package/src/inspect/index.ts +22 -0
- package/src/run/index.ts +60 -5
- package/src/sandbox/build.ts +10 -0
- package/src/sandbox/index.ts +2 -0
- package/src/sandbox/policy.ts +10 -0
- package/src/sandbox/writable-zones.ts +78 -0
- package/src/server/index.ts +118 -4
- package/src/skills/typeclaw-channel-github/SKILL.md +34 -7
- package/typeclaw.schema.json +10 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { dirname, isAbsolute, join, relative } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import type { TodoScope } from './scope'
|
|
6
|
+
|
|
7
|
+
export const TODO_STATUSES = ['pending', 'in_progress', 'completed', 'cancelled'] as const
|
|
8
|
+
export type TodoStatus = (typeof TODO_STATUSES)[number]
|
|
9
|
+
|
|
10
|
+
export const TODO_PRIORITIES = ['high', 'medium', 'low'] as const
|
|
11
|
+
export type TodoPriority = (typeof TODO_PRIORITIES)[number]
|
|
12
|
+
|
|
13
|
+
export type Todo = {
|
|
14
|
+
content: string
|
|
15
|
+
status: TodoStatus
|
|
16
|
+
priority?: TodoPriority
|
|
17
|
+
id?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type TodoFile = {
|
|
21
|
+
version: 1
|
|
22
|
+
todos: Todo[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function todoDir(agentDir: string): string {
|
|
26
|
+
return join(agentDir, 'todo')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Defense-in-depth: the resolved file must stay inside todo/. Scope keys from
|
|
30
|
+
// resolveTodoScope are already collision- and traversal-safe, but this function
|
|
31
|
+
// is an exported primitive — a future caller passing a hand-built scope like
|
|
32
|
+
// `{ key: '../sessions/x' }` would otherwise escape. We assert here rather than
|
|
33
|
+
// trust every caller to use resolveTodoScope.
|
|
34
|
+
export function todoContentPath(agentDir: string, scope: TodoScope): string {
|
|
35
|
+
const dir = todoDir(agentDir)
|
|
36
|
+
const path = join(dir, `${scope.key}.json`)
|
|
37
|
+
const rel = relative(dir, path)
|
|
38
|
+
if (rel.startsWith('..') || isAbsolute(rel)) {
|
|
39
|
+
throw new Error(`todo scope key escapes the todo directory: ${JSON.stringify(scope.key)}`)
|
|
40
|
+
}
|
|
41
|
+
return path
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function readTodos(agentDir: string, scope: TodoScope): Promise<Todo[]> {
|
|
45
|
+
const path = todoContentPath(agentDir, scope)
|
|
46
|
+
let raw: string
|
|
47
|
+
try {
|
|
48
|
+
raw = await readFile(path, 'utf8')
|
|
49
|
+
} catch (err) {
|
|
50
|
+
if (isEnoent(err)) return []
|
|
51
|
+
throw err
|
|
52
|
+
}
|
|
53
|
+
let parsed: Partial<TodoFile>
|
|
54
|
+
try {
|
|
55
|
+
parsed = JSON.parse(raw) as Partial<TodoFile>
|
|
56
|
+
} catch {
|
|
57
|
+
return []
|
|
58
|
+
}
|
|
59
|
+
if (!Array.isArray(parsed.todos)) return []
|
|
60
|
+
// The file is force-committed and hand-editable, so a corrupt or partially
|
|
61
|
+
// edited entry can appear. Drop anything that is not a well-formed Todo
|
|
62
|
+
// rather than let a `null`/malformed item crash incompleteTodos (`t.status`)
|
|
63
|
+
// or surface as trusted state to the model.
|
|
64
|
+
return parsed.todos.filter(isValidTodo)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isValidTodo(value: unknown): value is Todo {
|
|
68
|
+
if (typeof value !== 'object' || value === null) return false
|
|
69
|
+
const t = value as Record<string, unknown>
|
|
70
|
+
if (typeof t.content !== 'string' || t.content.length === 0) return false
|
|
71
|
+
if (typeof t.status !== 'string' || !(TODO_STATUSES as readonly string[]).includes(t.status)) return false
|
|
72
|
+
if (t.priority !== undefined && !(TODO_PRIORITIES as readonly string[]).includes(t.priority as string)) return false
|
|
73
|
+
if (t.id !== undefined && typeof t.id !== 'string') return false
|
|
74
|
+
return true
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Write is atomic (temp file + rename) so a crash mid-write can never leave a
|
|
78
|
+
// half-serialized JSON file that the next read would throw on. Mirrors the
|
|
79
|
+
// channels/sessions.json writer. A scope is normally owned by a single live
|
|
80
|
+
// session (see resolveTodoScope), so the only concurrent writers are the rare
|
|
81
|
+
// duplicate-attach case, where last-writer-wins on the rename is acceptable —
|
|
82
|
+
// the alternative (lost-update detection) is not worth a lock for a todo list.
|
|
83
|
+
export async function writeTodos(agentDir: string, scope: TodoScope, todos: Todo[]): Promise<void> {
|
|
84
|
+
const path = todoContentPath(agentDir, scope)
|
|
85
|
+
const payload: TodoFile = { version: 1, todos }
|
|
86
|
+
await mkdir(dirname(path), { recursive: true })
|
|
87
|
+
const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`
|
|
88
|
+
await writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
|
|
89
|
+
await rename(tmp, path)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function incompleteTodos(todos: readonly Todo[]): Todo[] {
|
|
93
|
+
return todos.filter((t) => t.status !== 'completed' && t.status !== 'cancelled')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isEnoent(err: unknown): boolean {
|
|
97
|
+
return typeof err === 'object' && err !== null && (err as { code?: unknown }).code === 'ENOENT'
|
|
98
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Minimal structural view of the pieces of pi's AgentSession this module
|
|
2
|
+
// touches. Declared locally (not imported) so the pure nudge logic stays
|
|
3
|
+
// testable with a hand-rolled fake and does not drag in the full session type.
|
|
4
|
+
export type NudgeableSession = {
|
|
5
|
+
subscribe: (listener: (event: unknown) => void) => () => void
|
|
6
|
+
steer: (text: string) => Promise<void>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const NOT_FOUND_RE = /^Tool (.+?) not found$/
|
|
10
|
+
|
|
11
|
+
// Levenshtein distance ceiling for a name to count as "did you mean". A typo
|
|
12
|
+
// like web_search -> websearch is distance 1 (one '_' removed); read_file ->
|
|
13
|
+
// read is larger but still a clear prefix relationship. Keeping the ceiling
|
|
14
|
+
// small avoids suggesting an unrelated tool for a genuinely unknown name.
|
|
15
|
+
const MAX_SUGGESTION_DISTANCE = 4
|
|
16
|
+
|
|
17
|
+
export function extractNotFoundToolName(resultText: string): string | null {
|
|
18
|
+
const match = NOT_FOUND_RE.exec(resultText.trim())
|
|
19
|
+
return match?.[1] ?? null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function closestToolName(requested: string, known: readonly string[]): string | null {
|
|
23
|
+
let best: string | null = null
|
|
24
|
+
let bestDistance = Number.POSITIVE_INFINITY
|
|
25
|
+
for (const candidate of known) {
|
|
26
|
+
if (candidate === requested) return candidate
|
|
27
|
+
const distance = boundedLevenshtein(requested, candidate, MAX_SUGGESTION_DISTANCE)
|
|
28
|
+
if (distance < bestDistance) {
|
|
29
|
+
bestDistance = distance
|
|
30
|
+
best = candidate
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return bestDistance <= MAX_SUGGESTION_DISTANCE ? best : null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function renderToolNotFoundNudge(requested: string, suggestion: string): string {
|
|
37
|
+
return (
|
|
38
|
+
`<system-reminder>\n` +
|
|
39
|
+
`You called the tool \`${requested}\`, which does not exist. ` +
|
|
40
|
+
`Did you mean \`${suggestion}\`? Re-issue the call using the exact name \`${suggestion}\`.\n` +
|
|
41
|
+
`</system-reminder>`
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function buildToolNotFoundNudge(resultText: string, known: readonly string[]): string | null {
|
|
46
|
+
const requested = extractNotFoundToolName(resultText)
|
|
47
|
+
if (requested === null) return null
|
|
48
|
+
const suggestion = closestToolName(requested, known)
|
|
49
|
+
if (suggestion === null || suggestion === requested) return null
|
|
50
|
+
return renderToolNotFoundNudge(requested, suggestion)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function firstTextChunk(result: unknown): string | null {
|
|
54
|
+
const content = (result as { content?: unknown })?.content
|
|
55
|
+
if (!Array.isArray(content)) return null
|
|
56
|
+
for (const part of content) {
|
|
57
|
+
if (part && typeof part === 'object' && (part as { type?: unknown }).type === 'text') {
|
|
58
|
+
const text = (part as { text?: unknown }).text
|
|
59
|
+
if (typeof text === 'string') return text
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Watches a session's tool-execution events and, when the model calls a tool
|
|
66
|
+
// name that does not exist but is a near-miss of a real one, steers a
|
|
67
|
+
// "did you mean" reminder into the running turn so the model self-corrects.
|
|
68
|
+
//
|
|
69
|
+
// This lives here, on the session event stream, because pi-agent-core's
|
|
70
|
+
// `prepareToolCall` returns the `Tool X not found` result BEFORE any
|
|
71
|
+
// `beforeToolCall`/`afterToolCall` hook runs — so TypeClaw's tool.before/after
|
|
72
|
+
// buses never see an unknown tool name. The emitted `tool_execution_end` event
|
|
73
|
+
// is the only seam reachable without forking pi. `steer` (not `followUp`)
|
|
74
|
+
// delivers the reminder after the current assistant turn's tool calls settle,
|
|
75
|
+
// which is exactly when the model is ready to retry.
|
|
76
|
+
//
|
|
77
|
+
// The model re-issues the call under the suggested (canonical) name, so every
|
|
78
|
+
// security guard, budget, and loop-guard keyed on that real name applies
|
|
79
|
+
// normally — unlike a silent alias, this rescue path cannot bypass policy.
|
|
80
|
+
export function attachToolNotFoundNudge(session: NudgeableSession, knownToolNames: readonly string[]): () => void {
|
|
81
|
+
const known = [...new Set(knownToolNames)]
|
|
82
|
+
return session.subscribe((event) => {
|
|
83
|
+
const e = event as { type?: unknown; isError?: unknown; result?: unknown }
|
|
84
|
+
if (e?.type !== 'tool_execution_end' || e.isError !== true) return
|
|
85
|
+
const text = firstTextChunk(e.result)
|
|
86
|
+
if (text === null) return
|
|
87
|
+
const nudge = buildToolNotFoundNudge(text, known)
|
|
88
|
+
if (nudge === null) return
|
|
89
|
+
void session.steer(nudge)
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Wagner–Fischer with an early bail-out once every cell in a row exceeds the
|
|
94
|
+
// ceiling: a name far from every candidate never produces a suggestion, and
|
|
95
|
+
// the bound keeps the scan cheap when the known-tool list is large.
|
|
96
|
+
function boundedLevenshtein(a: string, b: string, ceiling: number): number {
|
|
97
|
+
if (a === b) return 0
|
|
98
|
+
if (Math.abs(a.length - b.length) > ceiling) return ceiling + 1
|
|
99
|
+
|
|
100
|
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i)
|
|
101
|
+
let curr = Array.from({ length: b.length + 1 }, () => 0)
|
|
102
|
+
|
|
103
|
+
for (let i = 1; i <= a.length; i++) {
|
|
104
|
+
curr[0] = i
|
|
105
|
+
let rowMin = i
|
|
106
|
+
for (let j = 1; j <= b.length; j++) {
|
|
107
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1
|
|
108
|
+
const deletion = (prev[j] ?? 0) + 1
|
|
109
|
+
const insertion = (curr[j - 1] ?? 0) + 1
|
|
110
|
+
const substitution = (prev[j - 1] ?? 0) + cost
|
|
111
|
+
const cell = Math.min(deletion, insertion, substitution)
|
|
112
|
+
curr[j] = cell
|
|
113
|
+
if (cell < rowMin) rowMin = cell
|
|
114
|
+
}
|
|
115
|
+
if (rowMin > ceiling) return ceiling + 1
|
|
116
|
+
;[prev, curr] = [curr, prev]
|
|
117
|
+
}
|
|
118
|
+
return prev[b.length] ?? ceiling + 1
|
|
119
|
+
}
|
|
@@ -80,6 +80,14 @@ export function createChannelReplyTool({
|
|
|
80
80
|
'Do not set it just to seem responsive; only when genuine multi-step work follows in the same turn.',
|
|
81
81
|
}),
|
|
82
82
|
),
|
|
83
|
+
resolve_review_thread: Type.Optional(
|
|
84
|
+
Type.Boolean({
|
|
85
|
+
description:
|
|
86
|
+
'GitHub only. Set `true` when this reply acknowledges that a review-comment thread YOU authored has been addressed, to resolve (close) that thread atomically with the reply. ' +
|
|
87
|
+
'The thread is resolved BEFORE the acknowledgement is posted, and only if its root comment is yours — so it never closes a human reviewer\'s thread, and a failed resolve blocks the misleading "looks resolved" reply. ' +
|
|
88
|
+
'Valid only on a github session replying inside a thread (the origin must carry a `thread`). Ignored elsewhere.',
|
|
89
|
+
}),
|
|
90
|
+
),
|
|
83
91
|
}),
|
|
84
92
|
|
|
85
93
|
async execute(_toolCallId, params) {
|
|
@@ -123,6 +131,22 @@ export function createChannelReplyTool({
|
|
|
123
131
|
}
|
|
124
132
|
}
|
|
125
133
|
|
|
134
|
+
// Resolve BEFORE posting: a successful channel_reply ends the turn, so a
|
|
135
|
+
// resolve attempted "after" the ack would never run (the exact bug this
|
|
136
|
+
// flag fixes). Resolve-failure blocks the reply so the agent never posts
|
|
137
|
+
// a "looks resolved" ack next to a still-open thread; the router enforces
|
|
138
|
+
// that only the bot's own threads can be resolved.
|
|
139
|
+
if (params.resolve_review_thread === true) {
|
|
140
|
+
const resolveError = await resolveReviewThreadBeforeReply(router, origin)
|
|
141
|
+
if (resolveError !== null) {
|
|
142
|
+
logger.warn(formatChannelToolFailure('channel_reply', resolveError))
|
|
143
|
+
return {
|
|
144
|
+
content: [{ type: 'text' as const, text: `channel_reply denied: ${resolveError}` }],
|
|
145
|
+
details: { ok: false, error: resolveError },
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
126
150
|
const result = await router.send({
|
|
127
151
|
adapter: origin.adapter,
|
|
128
152
|
workspace: origin.workspace,
|
|
@@ -192,6 +216,33 @@ export function createChannelReplyTool({
|
|
|
192
216
|
})
|
|
193
217
|
}
|
|
194
218
|
|
|
219
|
+
// Returns an error string when the resolve should block the reply, or null
|
|
220
|
+
// when it's safe to proceed. Only `no-match` (the thread is already gone, so
|
|
221
|
+
// there's nothing to close) joins success as non-blocking; every hard failure
|
|
222
|
+
// — wrong author, permission denial, HTTP 404 on a misdirected lookup,
|
|
223
|
+
// transient API error — blocks, so the agent never claims a thread is settled
|
|
224
|
+
// when the resolve did not actually run.
|
|
225
|
+
async function resolveReviewThreadBeforeReply(
|
|
226
|
+
router: ChannelRouter,
|
|
227
|
+
origin: ChannelReplyOrigin,
|
|
228
|
+
): Promise<string | null> {
|
|
229
|
+
if (origin.adapter !== 'github') {
|
|
230
|
+
return 'resolve_review_thread is only supported on github sessions.'
|
|
231
|
+
}
|
|
232
|
+
if (origin.thread === null) {
|
|
233
|
+
return 'resolve_review_thread requires replying inside a review thread (no thread on this origin).'
|
|
234
|
+
}
|
|
235
|
+
const result = await router.resolveReviewThread({
|
|
236
|
+
adapter: origin.adapter,
|
|
237
|
+
workspace: origin.workspace,
|
|
238
|
+
chat: origin.chat,
|
|
239
|
+
rootCommentId: origin.thread,
|
|
240
|
+
})
|
|
241
|
+
if (result.ok) return null
|
|
242
|
+
if (result.code === 'no-match') return null
|
|
243
|
+
return `could not resolve review thread: ${result.error}`
|
|
244
|
+
}
|
|
245
|
+
|
|
195
246
|
// Tool results reach the model as USER-role messages (OpenAI / Anthropic
|
|
196
247
|
// tool-API contract — the engine cannot tag them as system). Without this
|
|
197
248
|
// marker a persona-rich model reads its own echo as a fresh user inbound
|
|
@@ -2,6 +2,7 @@ import { Type } from '@mariozechner/pi-ai'
|
|
|
2
2
|
import { defineTool } from '@mariozechner/pi-coding-agent'
|
|
3
3
|
|
|
4
4
|
import { requestContainerRestart } from '@/agent/restart'
|
|
5
|
+
import type { RestartHandoffOrigin } from '@/agent/restart-handoff'
|
|
5
6
|
import type { Stream } from '@/stream'
|
|
6
7
|
|
|
7
8
|
const EXIT_DELAY_MS = 500
|
|
@@ -47,11 +48,15 @@ export type CreateRestartToolOptions = {
|
|
|
47
48
|
// so the `typeclaw.restart-self` custom message entry that was just
|
|
48
49
|
// appended is part of the LLM context on the next turn. When omitted,
|
|
49
50
|
// no handoff is written — the new container cold-starts and no
|
|
50
|
-
// "I'm back" greeting fires.
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
// greeting (see issue #291's scoping concerns).
|
|
51
|
+
// "I'm back" greeting fires. Written for persisted TUI and channel
|
|
52
|
+
// origins; cron/subagent/system origins pass undefined so the next boot
|
|
53
|
+
// does not resume an unattended session.
|
|
54
54
|
originatingSessionFile?: string
|
|
55
|
+
// Which subsystem owns resuming the originating session on the next boot
|
|
56
|
+
// (tui → websocket open handler; channel → channel router startup). Required
|
|
57
|
+
// alongside `originatingSessionFile` for the handoff to be written; omit to
|
|
58
|
+
// skip the handoff. See buildRestartHandoffWiring in src/agent/index.ts.
|
|
59
|
+
handoffOrigin?: RestartHandoffOrigin
|
|
55
60
|
}
|
|
56
61
|
|
|
57
62
|
export type RestartToolDetails = { ok: boolean; containerName: string; reason?: string }
|
|
@@ -69,6 +74,7 @@ export function createRestartTool({
|
|
|
69
74
|
ackTimeoutMs,
|
|
70
75
|
agentDir,
|
|
71
76
|
originatingSessionFile,
|
|
77
|
+
handoffOrigin,
|
|
72
78
|
}: CreateRestartToolOptions) {
|
|
73
79
|
const doExit = exit ?? ((code: number) => process.exit(code))
|
|
74
80
|
|
|
@@ -114,6 +120,7 @@ export function createRestartTool({
|
|
|
114
120
|
...(stream !== undefined ? { stream } : {}),
|
|
115
121
|
...(agentDir !== undefined ? { agentDir } : {}),
|
|
116
122
|
...(originatingSessionFile !== undefined ? { originatingSessionFile } : {}),
|
|
123
|
+
...(handoffOrigin !== undefined ? { handoffOrigin } : {}),
|
|
117
124
|
})
|
|
118
125
|
if (!result.ok) {
|
|
119
126
|
const details: RestartToolDetails = { ok: false, containerName, reason: result.reason }
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { Type } from '@mariozechner/pi-ai'
|
|
2
|
+
import { defineTool } from '@mariozechner/pi-coding-agent'
|
|
3
|
+
|
|
4
|
+
import type { SessionOrigin } from '@/agent/session-origin'
|
|
5
|
+
import { resolveTodoScope, type TodoScope } from '@/agent/todo/scope'
|
|
6
|
+
import { incompleteTodos, type Todo, TODO_PRIORITIES, TODO_STATUSES, readTodos, writeTodos } from '@/agent/todo/store'
|
|
7
|
+
|
|
8
|
+
export type CreateTodoToolsOptions = {
|
|
9
|
+
agentDir: string
|
|
10
|
+
getOrigin: () => SessionOrigin | undefined
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const NO_SCOPE_NOTICE =
|
|
14
|
+
'Todos are owned by the originating session. This session (a subagent, system task, or one ' +
|
|
15
|
+
'with no resolvable origin) does not own a todo list, so the call was a no-op.'
|
|
16
|
+
|
|
17
|
+
type TodoToolDetails = {
|
|
18
|
+
ok: boolean
|
|
19
|
+
reason?: string
|
|
20
|
+
total?: number
|
|
21
|
+
remaining?: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Resolve the scope for the current origin, or null when this session owns no
|
|
25
|
+
// todo list. An UNDEFINED origin is treated as no-scope, NOT defaulted to the
|
|
26
|
+
// shared TUI scope — defaulting would fail open, silently routing an unknown
|
|
27
|
+
// actor's todos into the operator's global `tui` list.
|
|
28
|
+
function scopeForOrigin(getOrigin: () => SessionOrigin | undefined): TodoScope | null {
|
|
29
|
+
const origin = getOrigin()
|
|
30
|
+
return origin === undefined ? null : resolveTodoScope(origin)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const TODO_ITEM = Type.Object({
|
|
34
|
+
content: Type.String({ minLength: 1, description: 'What the task is.' }),
|
|
35
|
+
status: Type.Union(
|
|
36
|
+
TODO_STATUSES.map((s) => Type.Literal(s)),
|
|
37
|
+
{ description: 'One of: pending, in_progress, completed, cancelled.' },
|
|
38
|
+
),
|
|
39
|
+
priority: Type.Optional(Type.Union(TODO_PRIORITIES.map((p) => Type.Literal(p)))),
|
|
40
|
+
id: Type.Optional(Type.String()),
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
export function createTodoTools({ agentDir, getOrigin }: CreateTodoToolsOptions) {
|
|
44
|
+
const writeTool = defineTool({
|
|
45
|
+
name: 'todo_write',
|
|
46
|
+
label: 'Write Todos',
|
|
47
|
+
description:
|
|
48
|
+
'Replace your entire todo list for this session with the provided items. Maintain a todo ' +
|
|
49
|
+
'list for any multi-step or long-running task so that if this session is interrupted ' +
|
|
50
|
+
'(restart, crash, or a later turn), you can resume the remaining work instead of silently ' +
|
|
51
|
+
'dropping it. Mark items `completed` (or `cancelled`) as you finish them by writing the full ' +
|
|
52
|
+
'list again with updated statuses. This is a full replace, not a merge: include every item ' +
|
|
53
|
+
'you still care about on each call.',
|
|
54
|
+
parameters: Type.Object({
|
|
55
|
+
todos: Type.Array(TODO_ITEM, { description: 'The complete todo list. Replaces any prior list.' }),
|
|
56
|
+
}),
|
|
57
|
+
async execute(_toolCallId, params) {
|
|
58
|
+
const scope = scopeForOrigin(getOrigin)
|
|
59
|
+
if (scope === null) {
|
|
60
|
+
const details: TodoToolDetails = { ok: false, reason: 'no-scope' }
|
|
61
|
+
return { content: [{ type: 'text' as const, text: NO_SCOPE_NOTICE }], details }
|
|
62
|
+
}
|
|
63
|
+
const todos = params.todos as Todo[]
|
|
64
|
+
await writeTodos(agentDir, scope, todos)
|
|
65
|
+
const remaining = incompleteTodos(todos).length
|
|
66
|
+
const details: TodoToolDetails = { ok: true, total: todos.length, remaining }
|
|
67
|
+
return {
|
|
68
|
+
content: [
|
|
69
|
+
{
|
|
70
|
+
type: 'text' as const,
|
|
71
|
+
text: `Saved ${todos.length} todo(s); ${remaining} remaining (${todos.length - remaining} done).`,
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
details,
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const readTool = defineTool({
|
|
80
|
+
name: 'todo_read',
|
|
81
|
+
label: 'Read Todos',
|
|
82
|
+
description: 'Return your current todo list for this session. Use it to re-sync after an interruption.',
|
|
83
|
+
parameters: Type.Object({}),
|
|
84
|
+
async execute() {
|
|
85
|
+
const scope = scopeForOrigin(getOrigin)
|
|
86
|
+
if (scope === null) {
|
|
87
|
+
const details: TodoToolDetails = { ok: false, reason: 'no-scope' }
|
|
88
|
+
return { content: [{ type: 'text' as const, text: NO_SCOPE_NOTICE }], details }
|
|
89
|
+
}
|
|
90
|
+
const todos = await readTodos(agentDir, scope)
|
|
91
|
+
const details: TodoToolDetails = { ok: true, total: todos.length }
|
|
92
|
+
return {
|
|
93
|
+
content: [{ type: 'text' as const, text: JSON.stringify(todos, null, 2) }],
|
|
94
|
+
details,
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
const clearTool = defineTool({
|
|
100
|
+
name: 'todo_clear',
|
|
101
|
+
label: 'Clear Todos',
|
|
102
|
+
description:
|
|
103
|
+
'Empty your todo list for this session. Call this when all work is genuinely done or the ' +
|
|
104
|
+
'task was abandoned, so the runtime stops tracking pending work.',
|
|
105
|
+
parameters: Type.Object({}),
|
|
106
|
+
async execute() {
|
|
107
|
+
const scope = scopeForOrigin(getOrigin)
|
|
108
|
+
if (scope === null) {
|
|
109
|
+
const details: TodoToolDetails = { ok: false, reason: 'no-scope' }
|
|
110
|
+
return { content: [{ type: 'text' as const, text: NO_SCOPE_NOTICE }], details }
|
|
111
|
+
}
|
|
112
|
+
await writeTodos(agentDir, scope, [])
|
|
113
|
+
const details: TodoToolDetails = { ok: true }
|
|
114
|
+
return { content: [{ type: 'text' as const, text: 'Todo list cleared.' }], details }
|
|
115
|
+
},
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
return [writeTool, readTool, clearTool]
|
|
119
|
+
}
|
|
@@ -5,7 +5,7 @@ export const COMMIT_TIMEOUT_MS = 30_000
|
|
|
5
5
|
export const NETWORK_TIMEOUT_MS = 60_000
|
|
6
6
|
|
|
7
7
|
const RUNTIME_OWNED_PREFIXES = ['memory/'] as const
|
|
8
|
-
const FORCE_ADD_PREFIXES = ['sessions/'] as const
|
|
8
|
+
const FORCE_ADD_PREFIXES = ['sessions/', 'todo/'] as const
|
|
9
9
|
|
|
10
10
|
const NONINTERACTIVE_ENV = {
|
|
11
11
|
GIT_TERMINAL_PROMPT: '0',
|
|
@@ -26,6 +26,19 @@ import { GENERAL_REVIEW_SKILL } from './skills/general'
|
|
|
26
26
|
// no runtime change required.
|
|
27
27
|
export const REVIEWER_SKILLS: readonly LoadableSkill[] = [CODE_REVIEW_SKILL, GENERAL_REVIEW_SKILL]
|
|
28
28
|
|
|
29
|
+
// Without a ceiling, a reviewer whose `session.prompt` stalls mid-turn (model
|
|
30
|
+
// wedges after a tool error, never emits a terminal message) leaves `completion`
|
|
31
|
+
// pending forever: the `subagent.completed` broadcast never fires and the parent
|
|
32
|
+
// channel session is never woken to post the review — the spawn hangs silently.
|
|
33
|
+
// The ceiling makes `awaitWithSubagentTimeout` settle with SubagentTimeoutError,
|
|
34
|
+
// surfacing to the parent as a FAILED completion reminder so the request fails
|
|
35
|
+
// loudly instead of vanishing. Sized for a thorough `deep`-model review (large
|
|
36
|
+
// diff + a few web lookups), well above the typical sub-minute review. This is
|
|
37
|
+
// liveness for the parent, not hard cancellation: pi's `session.prompt` takes no
|
|
38
|
+
// AbortSignal, so the LLM stream may run until the OS reaps it. See
|
|
39
|
+
// src/agent/subagents.ts `timeoutMs`.
|
|
40
|
+
export const REVIEWER_SPAWN_TIMEOUT_MS = 600_000
|
|
41
|
+
|
|
29
42
|
// TODO(#452): Restrict the reviewer's `bash` to git and a curated set of
|
|
30
43
|
// read-only `gh` subcommands once per-subagent bash allowlist support lands.
|
|
31
44
|
// Today the read-only contract is enforced only by this system prompt, the
|
|
@@ -159,6 +172,7 @@ If none of the listed skills fit the target, load \`general\` and explain in \`<
|
|
|
159
172
|
customTools: [loadSkillTool],
|
|
160
173
|
payloadSchema: reviewerPayloadSchema,
|
|
161
174
|
visibility: 'public',
|
|
175
|
+
timeoutMs: REVIEWER_SPAWN_TIMEOUT_MS,
|
|
162
176
|
inFlightKey: (payload) => payload?.requestId ?? `anon-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
163
177
|
toolResultBudget: {
|
|
164
178
|
// Higher than explorer (256KB) because a reviewer typically reads larger
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { InboundReferenceContext, QuoteAnchorSource } from '@/channels/types'
|
|
2
|
+
|
|
3
|
+
export type DiscordResolvedReference = {
|
|
4
|
+
authorId: string
|
|
5
|
+
authorName: string
|
|
6
|
+
text: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type DiscordReferenceFetch = (channelId: string, messageId: string) => Promise<DiscordResolvedReference | null>
|
|
10
|
+
|
|
11
|
+
export type DiscordMessagePointer = {
|
|
12
|
+
channelId: string
|
|
13
|
+
messageId: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function enrichDiscordMessageReferences(args: {
|
|
17
|
+
text: string
|
|
18
|
+
reply?: DiscordMessagePointer
|
|
19
|
+
fetchMessage: DiscordReferenceFetch
|
|
20
|
+
linkLimit?: number
|
|
21
|
+
}): Promise<{ text: string; referenceContext?: InboundReferenceContext }> {
|
|
22
|
+
const sources: QuoteAnchorSource[] = []
|
|
23
|
+
let hasReply = false
|
|
24
|
+
|
|
25
|
+
if (args.reply !== undefined) {
|
|
26
|
+
const parent = await fetchSafely(args.fetchMessage, args.reply)
|
|
27
|
+
if (parent !== null) {
|
|
28
|
+
sources.push(toSource(parent))
|
|
29
|
+
hasReply = true
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const links = extractDiscordMessageLinks(args.text).slice(0, args.linkLimit ?? 3)
|
|
34
|
+
for (const link of links) {
|
|
35
|
+
const message = await fetchSafely(args.fetchMessage, link)
|
|
36
|
+
if (message !== null) sources.push(toSource(message))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (sources.length === 0) return { text: args.text }
|
|
40
|
+
return { text: args.text, referenceContext: { kind: hasReply ? 'reply' : 'link', sources } }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const DISCORD_MESSAGE_LINK = /https?:\/\/(?:canary\.|ptb\.)?discord(?:app)?\.com\/channels\/(\d+|@me)\/(\d+)\/(\d+)/g
|
|
44
|
+
|
|
45
|
+
function extractDiscordMessageLinks(text: string): DiscordMessagePointer[] {
|
|
46
|
+
const seen = new Set<string>()
|
|
47
|
+
const links: DiscordMessagePointer[] = []
|
|
48
|
+
for (const match of text.matchAll(DISCORD_MESSAGE_LINK)) {
|
|
49
|
+
const channelId = match[2]
|
|
50
|
+
const messageId = match[3]
|
|
51
|
+
if (channelId === undefined || messageId === undefined) continue
|
|
52
|
+
const key = `${channelId}:${messageId}`
|
|
53
|
+
if (seen.has(key)) continue
|
|
54
|
+
seen.add(key)
|
|
55
|
+
links.push({ channelId, messageId })
|
|
56
|
+
}
|
|
57
|
+
return links
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function fetchSafely(
|
|
61
|
+
fetchMessage: DiscordReferenceFetch,
|
|
62
|
+
pointer: DiscordMessagePointer,
|
|
63
|
+
): Promise<DiscordResolvedReference | null> {
|
|
64
|
+
try {
|
|
65
|
+
return await fetchMessage(pointer.channelId, pointer.messageId)
|
|
66
|
+
} catch {
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function toSource(message: DiscordResolvedReference): QuoteAnchorSource {
|
|
72
|
+
return {
|
|
73
|
+
adapter: 'discord-bot',
|
|
74
|
+
authorId: message.authorId,
|
|
75
|
+
authorName: message.authorName,
|
|
76
|
+
text: message.text,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
type InboundDropReason,
|
|
40
40
|
renderPlaceholder,
|
|
41
41
|
} from './discord-bot-classify'
|
|
42
|
+
import { enrichDiscordMessageReferences } from './discord-bot-reference'
|
|
42
43
|
import {
|
|
43
44
|
ackInteraction,
|
|
44
45
|
parseInteractionAsCommand,
|
|
@@ -902,11 +903,32 @@ export function createDiscordBotAdapter(options: DiscordBotAdapterOptions): Disc
|
|
|
902
903
|
return
|
|
903
904
|
}
|
|
904
905
|
|
|
905
|
-
const
|
|
906
|
+
const replyMessageId = event.message_reference?.message_id
|
|
907
|
+
const referenceResult = await enrichDiscordMessageReferences({
|
|
908
|
+
text: verdict.payload.text,
|
|
909
|
+
...(replyMessageId !== undefined
|
|
910
|
+
? { reply: { channelId: event.message_reference?.channel_id ?? event.channel_id, messageId: replyMessageId } }
|
|
911
|
+
: {}),
|
|
912
|
+
fetchMessage: async (channelId, messageId) => {
|
|
913
|
+
const message: { author: { id: string; username: string; global_name?: string | null }; content: string } =
|
|
914
|
+
await client.getMessage(channelId, messageId)
|
|
915
|
+
return {
|
|
916
|
+
authorId: message.author.id,
|
|
917
|
+
authorName: message.author.global_name ?? message.author.username,
|
|
918
|
+
text: message.content,
|
|
919
|
+
}
|
|
920
|
+
},
|
|
921
|
+
})
|
|
922
|
+
const payload =
|
|
923
|
+
referenceResult.referenceContext === undefined
|
|
924
|
+
? verdict.payload
|
|
925
|
+
: { ...verdict.payload, referenceContext: referenceResult.referenceContext }
|
|
926
|
+
|
|
927
|
+
const routedTag = await formatChannelTag(payload.workspace, payload.chat)
|
|
906
928
|
logger.info(
|
|
907
|
-
`[discord-bot] routed id=${event.id} ${routedTag} mention=${
|
|
929
|
+
`[discord-bot] routed id=${event.id} ${routedTag} mention=${payload.isBotMention} reply=${payload.replyToBotMessageId !== null}`,
|
|
908
930
|
)
|
|
909
|
-
await options.router.route(
|
|
931
|
+
await options.router.route(payload)
|
|
910
932
|
} catch (err) {
|
|
911
933
|
logger.error(`[discord-bot] handleInbound failed: ${describe(err)}`)
|
|
912
934
|
} finally {
|