@oh-my-pi/pi-coding-agent 14.4.0 → 14.4.3
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/CHANGELOG.md +70 -0
- package/package.json +7 -7
- package/src/cli.ts +0 -1
- package/src/config/prompt-templates.ts +1 -31
- package/src/config/settings-schema.ts +27 -37
- package/src/config/settings.ts +1 -1
- package/src/edit/index.ts +1 -53
- package/src/edit/line-hash.ts +13 -63
- package/src/edit/modes/atom.ts +334 -64
- package/src/edit/modes/hashline.ts +19 -26
- package/src/edit/renderer.ts +6 -8
- package/src/edit/streaming.ts +90 -114
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.js +10 -15
- package/src/internal-urls/docs-index.generated.ts +1 -2
- package/src/lsp/defaults.json +142 -652
- package/src/modes/components/session-selector.ts +3 -3
- package/src/modes/components/settings-defs.ts +0 -5
- package/src/modes/components/tool-execution.ts +2 -5
- package/src/modes/controllers/btw-controller.ts +17 -105
- package/src/modes/controllers/todo-command-controller.ts +537 -0
- package/src/modes/interactive-mode.ts +35 -9
- package/src/modes/types.ts +2 -0
- package/src/modes/utils/ui-helpers.ts +17 -0
- package/src/prompts/system/irc-incoming.md +8 -0
- package/src/prompts/system/subagent-system-prompt.md +8 -0
- package/src/prompts/tools/ast-edit.md +1 -1
- package/src/prompts/tools/ast-grep.md +1 -0
- package/src/prompts/tools/atom.md +55 -53
- package/src/prompts/tools/bash.md +2 -2
- package/src/prompts/tools/grep.md +2 -5
- package/src/prompts/tools/irc.md +49 -0
- package/src/prompts/tools/job.md +11 -0
- package/src/prompts/tools/read.md +12 -13
- package/src/prompts/tools/task.md +1 -1
- package/src/prompts/tools/todo-write.md +14 -5
- package/src/registry/agent-registry.ts +139 -0
- package/src/sdk.ts +35 -0
- package/src/session/agent-session.ts +217 -5
- package/src/session/session-manager.ts +4 -1
- package/src/session/streaming-output.ts +1 -1
- package/src/slash-commands/builtin-registry.ts +24 -0
- package/src/task/executor.ts +14 -0
- package/src/tools/bash.ts +1 -1
- package/src/tools/fetch.ts +18 -6
- package/src/tools/fs-cache-invalidation.ts +0 -5
- package/src/tools/grep.ts +5 -125
- package/src/tools/index.ts +12 -6
- package/src/tools/irc.ts +258 -0
- package/src/tools/job.ts +489 -0
- package/src/tools/match-line-format.ts +8 -7
- package/src/tools/output-meta.ts +1 -1
- package/src/tools/read.ts +37 -131
- package/src/tools/renderers.ts +2 -0
- package/src/tools/todo-write.ts +243 -12
- package/src/tools/write.ts +2 -2
- package/src/utils/edit-mode.ts +1 -2
- package/src/utils/file-display-mode.ts +0 -3
- package/src/cli/read-cli.ts +0 -67
- package/src/commands/read.ts +0 -33
- package/src/edit/modes/chunk.ts +0 -832
- package/src/prompts/tools/cancel-job.md +0 -5
- package/src/prompts/tools/chunk-edit.md +0 -158
- package/src/prompts/tools/poll.md +0 -5
- package/src/prompts/tools/read-chunk.md +0 -73
- package/src/tools/cancel-job.ts +0 -95
- package/src/tools/poll-tool.ts +0 -173
package/src/tools/read.ts
CHANGED
|
@@ -9,20 +9,11 @@ import { Text } from "@oh-my-pi/pi-tui";
|
|
|
9
9
|
import { getRemoteDir, prompt, readImageMetadata, untilAborted } from "@oh-my-pi/pi-utils";
|
|
10
10
|
import { type Static, Type } from "@sinclair/typebox";
|
|
11
11
|
import { formatHashLines } from "../edit/line-hash";
|
|
12
|
-
import {
|
|
13
|
-
type ChunkReadTarget,
|
|
14
|
-
formatChunkedRead,
|
|
15
|
-
parseChunkReadPath,
|
|
16
|
-
parseChunkSelector,
|
|
17
|
-
resolveAnchorStyle,
|
|
18
|
-
resolveChunkAutoIndent,
|
|
19
|
-
} from "../edit/modes/chunk";
|
|
20
12
|
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
21
13
|
import { parseInternalUrl } from "../internal-urls/parse";
|
|
22
14
|
import type { InternalUrl } from "../internal-urls/types";
|
|
23
15
|
import { getLanguageFromPath, type Theme } from "../modes/theme/theme";
|
|
24
16
|
import readDescription from "../prompts/tools/read.md" with { type: "text" };
|
|
25
|
-
import readChunkDescription from "../prompts/tools/read-chunk.md" with { type: "text" };
|
|
26
17
|
import type { ToolSession } from "../sdk";
|
|
27
18
|
import {
|
|
28
19
|
DEFAULT_MAX_BYTES,
|
|
@@ -34,7 +25,6 @@ import {
|
|
|
34
25
|
} from "../session/streaming-output";
|
|
35
26
|
import { renderCodeCell, renderStatusLine } from "../tui";
|
|
36
27
|
import { CachedOutputBlock } from "../tui/output-block";
|
|
37
|
-
import { resolveEditMode } from "../utils/edit-mode";
|
|
38
28
|
import { resolveFileDisplayMode } from "../utils/file-display-mode";
|
|
39
29
|
import { ImageInputTooLargeError, loadImageInput, MAX_IMAGE_INPUT_BYTES } from "../utils/image-loading";
|
|
40
30
|
import { convertFileWithMarkit } from "../utils/markit";
|
|
@@ -71,12 +61,6 @@ import {
|
|
|
71
61
|
import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
|
|
72
62
|
import { toolResult } from "./tool-result";
|
|
73
63
|
|
|
74
|
-
const PROSE_LANGUAGES = new Set(["markdown", "text", "log", "asciidoc", "restructuredtext"]);
|
|
75
|
-
|
|
76
|
-
function isProseLanguage(language: string | undefined): boolean {
|
|
77
|
-
return language !== undefined && PROSE_LANGUAGES.has(language);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
64
|
// Document types converted to markdown via markit.
|
|
81
65
|
const CONVERTIBLE_EXTENSIONS = new Set([".pdf", ".doc", ".docx", ".ppt", ".pptx", ".xls", ".xlsx", ".rtf", ".epub"]);
|
|
82
66
|
|
|
@@ -92,17 +76,13 @@ function prependLineNumbers(text: string, startNum: number): string {
|
|
|
92
76
|
return textLines.map((line, i) => `${startNum + i}|${line}`).join("\n");
|
|
93
77
|
}
|
|
94
78
|
|
|
95
|
-
function prependHashLines(text: string, startNum: number): string {
|
|
96
|
-
return formatHashLines(text, startNum);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
79
|
function formatTextWithMode(
|
|
100
80
|
text: string,
|
|
101
81
|
startNum: number,
|
|
102
82
|
shouldAddHashLines: boolean,
|
|
103
83
|
shouldAddLineNumbers: boolean,
|
|
104
84
|
): string {
|
|
105
|
-
if (shouldAddHashLines) return
|
|
85
|
+
if (shouldAddHashLines) return formatHashLines(text, startNum);
|
|
106
86
|
if (shouldAddLineNumbers) return prependLineNumbers(text, startNum);
|
|
107
87
|
return text;
|
|
108
88
|
}
|
|
@@ -362,7 +342,7 @@ function prependSuffixResolutionNotice(text: string, suffixResolution?: { from:
|
|
|
362
342
|
|
|
363
343
|
const readSchema = Type.Object({
|
|
364
344
|
path: Type.String({ description: "path or url", examples: ["src/foo.ts", "https://example.com"] }),
|
|
365
|
-
sel: Type.Optional(Type.String({ description: "line range or mode", examples: ["
|
|
345
|
+
sel: Type.Optional(Type.String({ description: "line range or mode", examples: ["50", "50-200", "50+150", "raw"] })),
|
|
366
346
|
timeout: Type.Optional(Type.Number({ description: "timeout in seconds", default: 20 })),
|
|
367
347
|
});
|
|
368
348
|
|
|
@@ -374,7 +354,6 @@ export interface ReadToolDetails {
|
|
|
374
354
|
isDirectory?: boolean;
|
|
375
355
|
resolvedPath?: string;
|
|
376
356
|
suffixResolution?: { from: string; to: string };
|
|
377
|
-
chunk?: ChunkReadTarget;
|
|
378
357
|
url?: string;
|
|
379
358
|
finalUrl?: string;
|
|
380
359
|
contentType?: string;
|
|
@@ -393,28 +372,37 @@ type ReadParams = ReadToolInput;
|
|
|
393
372
|
type ParsedSelector =
|
|
394
373
|
| { kind: "none" }
|
|
395
374
|
| { kind: "raw" }
|
|
396
|
-
| { kind: "lines"; startLine: number; endLine: number | undefined }
|
|
397
|
-
| { kind: "chunk"; selector: string };
|
|
375
|
+
| { kind: "lines"; startLine: number; endLine: number | undefined };
|
|
398
376
|
|
|
399
|
-
const LINE_RANGE_RE = /^L(\d+)(
|
|
377
|
+
const LINE_RANGE_RE = /^L?(\d+)(?:([-+])L?(\d+))?$/i;
|
|
400
378
|
|
|
401
379
|
function parseSel(sel: string | undefined): ParsedSelector {
|
|
402
380
|
if (!sel || sel.length === 0) return { kind: "none" };
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
const lineMatch = LINE_RANGE_RE.exec(normalizedSelector);
|
|
381
|
+
if (sel === "raw") return { kind: "raw" };
|
|
382
|
+
const lineMatch = LINE_RANGE_RE.exec(sel);
|
|
406
383
|
if (lineMatch) {
|
|
407
384
|
const rawStart = Number.parseInt(lineMatch[1]!, 10);
|
|
408
385
|
if (rawStart < 1) {
|
|
409
|
-
throw new ToolError("
|
|
386
|
+
throw new ToolError("sel=0 is invalid; lines are 1-indexed. Use sel=1.");
|
|
410
387
|
}
|
|
411
|
-
const
|
|
412
|
-
|
|
413
|
-
|
|
388
|
+
const sep = lineMatch[2];
|
|
389
|
+
const rhs = lineMatch[3] ? Number.parseInt(lineMatch[3], 10) : undefined;
|
|
390
|
+
let rawEnd: number | undefined;
|
|
391
|
+
if (sep === "+") {
|
|
392
|
+
if (rhs === undefined || rhs < 1) {
|
|
393
|
+
throw new ToolError(`Invalid range ${rawStart}+${rhs ?? 0}: count must be >= 1.`);
|
|
394
|
+
}
|
|
395
|
+
rawEnd = rawStart + rhs - 1;
|
|
396
|
+
} else if (sep === "-") {
|
|
397
|
+
if (rhs === undefined || rhs < rawStart) {
|
|
398
|
+
throw new ToolError(`Invalid range ${rawStart}-${rhs ?? 0}: end must be >= start.`);
|
|
399
|
+
}
|
|
400
|
+
rawEnd = rhs;
|
|
414
401
|
}
|
|
415
402
|
return { kind: "lines", startLine: rawStart, endLine: rawEnd };
|
|
416
403
|
}
|
|
417
|
-
|
|
404
|
+
// Unrecognized selectors fall through; sqlite/archive/url readers consume `sel` themselves.
|
|
405
|
+
return { kind: "none" };
|
|
418
406
|
}
|
|
419
407
|
|
|
420
408
|
/** Convert a line-range selector to the offset/limit pair used by internal pagination. */
|
|
@@ -481,18 +469,12 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
481
469
|
Math.min(session.settings.get("read.defaultLimit") ?? DEFAULT_MAX_LINES, DEFAULT_MAX_LINES),
|
|
482
470
|
);
|
|
483
471
|
this.#inspectImageEnabled = session.settings.get("inspect_image.enabled");
|
|
484
|
-
this.description =
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
: prompt.render(readDescription, {
|
|
491
|
-
DEFAULT_LIMIT: String(this.#defaultLimit),
|
|
492
|
-
DEFAULT_MAX_LINES: String(DEFAULT_MAX_LINES),
|
|
493
|
-
IS_HASHLINE_MODE: displayMode.hashLines,
|
|
494
|
-
IS_LINE_NUMBER_MODE: !displayMode.hashLines && displayMode.lineNumbers,
|
|
495
|
-
});
|
|
472
|
+
this.description = prompt.render(readDescription, {
|
|
473
|
+
DEFAULT_LIMIT: String(this.#defaultLimit),
|
|
474
|
+
DEFAULT_MAX_LINES: String(DEFAULT_MAX_LINES),
|
|
475
|
+
IS_HASHLINE_MODE: displayMode.hashLines,
|
|
476
|
+
IS_LINE_NUMBER_MODE: !displayMode.hashLines && displayMode.lineNumbers,
|
|
477
|
+
});
|
|
496
478
|
}
|
|
497
479
|
|
|
498
480
|
async #resolveArchiveReadPath(readPath: string, signal?: AbortSignal): Promise<ResolvedArchiveReadPath | null> {
|
|
@@ -621,7 +603,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
621
603
|
const suggestion =
|
|
622
604
|
allLines.length === 0
|
|
623
605
|
? `The ${options.entityLabel} is empty.`
|
|
624
|
-
: `Use sel=
|
|
606
|
+
: `Use sel=1 to read from the start, or sel=${allLines.length} to read the last line.`;
|
|
625
607
|
return resultBuilder
|
|
626
608
|
.text(
|
|
627
609
|
`Line ${startLineDisplay} is beyond end of ${options.entityLabel} (${allLines.length} lines total). ${suggestion}`,
|
|
@@ -683,7 +665,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
683
665
|
const nextOffset = startLine + userLimitedLines + 1;
|
|
684
666
|
|
|
685
667
|
outputText = formatText(selectedContent, startLineDisplay);
|
|
686
|
-
outputText += `\n\n[${remaining} more lines in ${options.entityLabel}. Use sel
|
|
668
|
+
outputText += `\n\n[${remaining} more lines in ${options.entityLabel}. Use sel=${nextOffset} to continue]`;
|
|
687
669
|
} else {
|
|
688
670
|
outputText = formatText(truncation.content, startLineDisplay);
|
|
689
671
|
}
|
|
@@ -934,7 +916,6 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
934
916
|
readPath = expandPath(readPath);
|
|
935
917
|
}
|
|
936
918
|
const displayMode = resolveFileDisplayMode(this.session);
|
|
937
|
-
const chunkMode = resolveEditMode(this.session) === "chunk";
|
|
938
919
|
|
|
939
920
|
// Handle internal URLs (agent://, artifact://, memory://, skill://, rule://, local://, mcp://)
|
|
940
921
|
const internalRouter = this.session.internalRouter;
|
|
@@ -968,13 +949,8 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
968
949
|
return executeReadUrl(this.session, { path: parsedUrlTarget.path, timeout, raw: parsedUrlTarget.raw }, signal);
|
|
969
950
|
}
|
|
970
951
|
|
|
971
|
-
const
|
|
972
|
-
const
|
|
973
|
-
const pathSelectorParsed = chunkMode ? parseSel(parsedReadPath.selector) : { kind: "none" as const };
|
|
974
|
-
const pathChunkSelector = pathSelectorParsed.kind === "chunk" ? pathSelectorParsed.selector : undefined;
|
|
975
|
-
const selectorInput = sel ?? parsedReadPath.selector;
|
|
976
|
-
const rawSelectorInput = sel ?? parsedReadPath.selector;
|
|
977
|
-
const parsed = parseSel(selectorInput);
|
|
952
|
+
const localReadPath = readPath;
|
|
953
|
+
const parsed = parseSel(sel);
|
|
978
954
|
|
|
979
955
|
const archivePath = await this.#resolveArchiveReadPath(localReadPath, signal);
|
|
980
956
|
if (archivePath) {
|
|
@@ -1034,54 +1010,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1034
1010
|
const imageMetadata = await readImageMetadata(absolutePath);
|
|
1035
1011
|
const mimeType = imageMetadata?.mimeType;
|
|
1036
1012
|
const ext = path.extname(absolutePath).toLowerCase();
|
|
1037
|
-
const
|
|
1038
|
-
const
|
|
1039
|
-
const
|
|
1040
|
-
const skipChunksForProse = isProseLanguage(language) && !this.session.settings.get("read.prosechunks");
|
|
1041
|
-
const shouldConvertWithMarkit =
|
|
1042
|
-
CONVERTIBLE_EXTENSIONS.has(ext) || (ext === ".ipynb" && (parsed.kind === "raw" || !chunkMode));
|
|
1043
|
-
|
|
1044
|
-
if (chunkMode && parsed.kind !== "raw" && !skipChunksForExplore && !skipChunksForProse) {
|
|
1045
|
-
const absoluteLineRange =
|
|
1046
|
-
pathChunkSelector && parsed.kind === "lines"
|
|
1047
|
-
? { startLine: parsed.startLine, endLine: parsed.endLine }
|
|
1048
|
-
: undefined;
|
|
1049
|
-
// sel= wins over path:chunk when both are provided (explicit param > embedded path).
|
|
1050
|
-
const effectiveSelector = sel ? selectorInput : (pathChunkSelector ?? selectorInput);
|
|
1051
|
-
const rawEffectiveSelector = sel ? selectorInput : (rawSelectorInput ?? effectiveSelector);
|
|
1052
|
-
const chunkReadPath =
|
|
1053
|
-
parsed.kind === "chunk" || (pathChunkSelector && !sel)
|
|
1054
|
-
? rawEffectiveSelector
|
|
1055
|
-
? `${localReadPath}:${rawEffectiveSelector}`
|
|
1056
|
-
: localReadPath
|
|
1057
|
-
: parsed.kind === "lines"
|
|
1058
|
-
? parsed.endLine !== undefined
|
|
1059
|
-
? `${localReadPath}:L${parsed.startLine}-L${parsed.endLine}`
|
|
1060
|
-
: `${localReadPath}:L${parsed.startLine}`
|
|
1061
|
-
: localReadPath;
|
|
1062
|
-
const chunkResult = await formatChunkedRead({
|
|
1063
|
-
filePath: absolutePath,
|
|
1064
|
-
readPath: chunkReadPath,
|
|
1065
|
-
cwd: this.session.cwd,
|
|
1066
|
-
language,
|
|
1067
|
-
omitChecksum: !hasEditTool,
|
|
1068
|
-
anchorStyle: resolveAnchorStyle(this.session.settings),
|
|
1069
|
-
absoluteLineRange,
|
|
1070
|
-
});
|
|
1071
|
-
let text = chunkResult.text;
|
|
1072
|
-
if (suffixResolution) {
|
|
1073
|
-
text = prependSuffixResolutionNotice(text, suffixResolution);
|
|
1074
|
-
}
|
|
1075
|
-
return toolResult<ReadToolDetails>({
|
|
1076
|
-
resolvedPath: absolutePath,
|
|
1077
|
-
suffixResolution,
|
|
1078
|
-
chunk: chunkResult.chunk,
|
|
1079
|
-
})
|
|
1080
|
-
.text(text)
|
|
1081
|
-
.sourcePath(absolutePath)
|
|
1082
|
-
.done();
|
|
1083
|
-
}
|
|
1084
|
-
|
|
1013
|
+
const _hasEditTool = this.session.hasEditTool ?? true;
|
|
1014
|
+
const _language = getLanguageFromPath(absolutePath);
|
|
1015
|
+
const shouldConvertWithMarkit = CONVERTIBLE_EXTENSIONS.has(ext) || (ext === ".ipynb" && parsed.kind === "raw");
|
|
1085
1016
|
// Read the file based on type
|
|
1086
1017
|
let content: Array<TextContent | ImageContent>;
|
|
1087
1018
|
let details: ReadToolDetails = {};
|
|
@@ -1164,31 +1095,6 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1164
1095
|
content = [{ type: "text", text: `[Cannot read ${ext} file: conversion failed]` }];
|
|
1165
1096
|
}
|
|
1166
1097
|
} else {
|
|
1167
|
-
// Chunk mode: dispatch to chunk tree unless raw or line range requested
|
|
1168
|
-
if (chunkMode && parsed.kind !== "raw" && parsed.kind !== "lines") {
|
|
1169
|
-
const chunkSel = parsed.kind === "chunk" ? parsed.selector : undefined;
|
|
1170
|
-
const chunkResult = await formatChunkedRead({
|
|
1171
|
-
filePath: absolutePath,
|
|
1172
|
-
readPath: chunkSel ? `${localReadPath}:${chunkSel}` : localReadPath,
|
|
1173
|
-
cwd: this.session.cwd,
|
|
1174
|
-
language: getLanguageFromPath(absolutePath),
|
|
1175
|
-
omitChecksum: !(this.session.hasEditTool ?? true),
|
|
1176
|
-
anchorStyle: resolveAnchorStyle(this.session.settings),
|
|
1177
|
-
});
|
|
1178
|
-
let text = chunkResult.text;
|
|
1179
|
-
if (suffixResolution) {
|
|
1180
|
-
text = prependSuffixResolutionNotice(text, suffixResolution);
|
|
1181
|
-
}
|
|
1182
|
-
return toolResult<ReadToolDetails>({
|
|
1183
|
-
resolvedPath: absolutePath,
|
|
1184
|
-
suffixResolution,
|
|
1185
|
-
chunk: chunkResult.chunk,
|
|
1186
|
-
})
|
|
1187
|
-
.text(text)
|
|
1188
|
-
.sourcePath(absolutePath)
|
|
1189
|
-
.done();
|
|
1190
|
-
}
|
|
1191
|
-
|
|
1192
1098
|
// Raw text or line-range mode
|
|
1193
1099
|
const { offset, limit } = selToOffsetLimit(parsed);
|
|
1194
1100
|
const startLine = offset ? Math.max(0, offset - 1) : 0;
|
|
@@ -1222,7 +1128,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1222
1128
|
const suggestion =
|
|
1223
1129
|
totalFileLines === 0
|
|
1224
1130
|
? "The file is empty."
|
|
1225
|
-
: `Use sel=
|
|
1131
|
+
: `Use sel=1 to read from the start, or sel=${totalFileLines} to read the last line.`;
|
|
1226
1132
|
return toolResult<ReadToolDetails>({ resolvedPath: absolutePath, suffixResolution })
|
|
1227
1133
|
.text(`Line ${startLineDisplay} is beyond end of file (${totalFileLines} lines total). ${suggestion}`)
|
|
1228
1134
|
.done();
|
|
@@ -1294,7 +1200,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1294
1200
|
const nextOffset = startLine + userLimitedLines + 1;
|
|
1295
1201
|
|
|
1296
1202
|
outputText = formatText(truncation.content, startLineDisplay);
|
|
1297
|
-
outputText += `\n\n[${remaining} more lines in file. Use sel
|
|
1203
|
+
outputText += `\n\n[${remaining} more lines in file. Use sel=${nextOffset} to continue]`;
|
|
1298
1204
|
details = {};
|
|
1299
1205
|
sourcePath = absolutePath;
|
|
1300
1206
|
} else {
|
package/src/tools/renderers.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { findToolRenderer } from "./find";
|
|
|
20
20
|
import { githubToolRenderer } from "./gh-renderer";
|
|
21
21
|
import { grepToolRenderer } from "./grep";
|
|
22
22
|
import { inspectImageToolRenderer } from "./inspect-image-renderer";
|
|
23
|
+
import { jobToolRenderer } from "./job";
|
|
23
24
|
import { notebookToolRenderer } from "./notebook";
|
|
24
25
|
import { pythonToolRenderer } from "./python";
|
|
25
26
|
import { readToolRenderer } from "./read";
|
|
@@ -58,6 +59,7 @@ export const toolRenderers: Record<string, ToolRenderer> = {
|
|
|
58
59
|
notebook: notebookToolRenderer as ToolRenderer,
|
|
59
60
|
inspect_image: inspectImageToolRenderer as ToolRenderer,
|
|
60
61
|
read: readToolRenderer as ToolRenderer,
|
|
62
|
+
job: jobToolRenderer as ToolRenderer,
|
|
61
63
|
resolve: resolveToolRenderer as ToolRenderer,
|
|
62
64
|
search_tool_bm25: searchToolBm25Renderer as ToolRenderer,
|
|
63
65
|
ssh: sshToolRenderer as ToolRenderer,
|
package/src/tools/todo-write.ts
CHANGED
|
@@ -23,6 +23,13 @@ export interface TodoItem {
|
|
|
23
23
|
id: string;
|
|
24
24
|
content: string;
|
|
25
25
|
status: TodoStatus;
|
|
26
|
+
/**
|
|
27
|
+
* Append-only list of freeform notes attached by `op: "note"`.
|
|
28
|
+
* Each element is one note and may itself be multi-line.
|
|
29
|
+
* Rendered as text only when the task is in_progress; otherwise shown as a
|
|
30
|
+
* dim marker indicating the task has notes.
|
|
31
|
+
*/
|
|
32
|
+
notes?: string[];
|
|
26
33
|
}
|
|
27
34
|
|
|
28
35
|
export interface TodoPhase {
|
|
@@ -40,7 +47,7 @@ export interface TodoWriteToolDetails {
|
|
|
40
47
|
// Schema
|
|
41
48
|
// =============================================================================
|
|
42
49
|
|
|
43
|
-
const TodoOp = StringEnum(["replace", "start", "done", "rm", "drop", "append"] as const, {
|
|
50
|
+
const TodoOp = StringEnum(["replace", "start", "done", "rm", "drop", "append", "note"] as const, {
|
|
44
51
|
description: "operation to apply",
|
|
45
52
|
});
|
|
46
53
|
|
|
@@ -54,7 +61,7 @@ const InputTask = Type.Object({
|
|
|
54
61
|
});
|
|
55
62
|
|
|
56
63
|
const InputPhase = Type.Object({
|
|
57
|
-
name: Type.String({ description: "phase name", examples: ["
|
|
64
|
+
name: Type.String({ description: "phase name", examples: ["I. Foundation", "II. Auth", "III. Verification"] }),
|
|
58
65
|
tasks: Type.Optional(Type.Array(InputTask)),
|
|
59
66
|
});
|
|
60
67
|
|
|
@@ -71,6 +78,7 @@ const TodoOpEntry = Type.Object({
|
|
|
71
78
|
Type.String({ description: "phase id for done/rm/drop/append", examples: ["Implementation", "phase-1"] }),
|
|
72
79
|
),
|
|
73
80
|
items: Type.Optional(Type.Array(AppendItem, { minItems: 1, description: "items to append for op=append" })),
|
|
81
|
+
text: Type.Optional(Type.String({ description: "note text for op=note (appended with newline)" })),
|
|
74
82
|
});
|
|
75
83
|
|
|
76
84
|
const todoWriteSchema = Type.Object(
|
|
@@ -160,8 +168,14 @@ function fileFromPhases(phases: TodoPhase[]): TodoFile {
|
|
|
160
168
|
return { phases, nextTaskId, nextPhaseId };
|
|
161
169
|
}
|
|
162
170
|
|
|
171
|
+
function cloneTask(task: TodoItem): TodoItem {
|
|
172
|
+
const out: TodoItem = { id: task.id, content: task.content, status: task.status };
|
|
173
|
+
if (task.notes && task.notes.length > 0) out.notes = [...task.notes];
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
163
177
|
function clonePhases(phases: TodoPhase[]): TodoPhase[] {
|
|
164
|
-
return phases.map(phase => ({ ...phase, tasks: phase.tasks.map(
|
|
178
|
+
return phases.map(phase => ({ ...phase, tasks: phase.tasks.map(cloneTask) }));
|
|
165
179
|
}
|
|
166
180
|
|
|
167
181
|
function normalizeInProgressTask(phases: TodoPhase[]): void {
|
|
@@ -181,11 +195,19 @@ function normalizeInProgressTask(phases: TodoPhase[]): void {
|
|
|
181
195
|
if (firstPendingTask) firstPendingTask.status = "in_progress";
|
|
182
196
|
}
|
|
183
197
|
|
|
198
|
+
export const USER_TODO_EDIT_CUSTOM_TYPE = "user_todo_edit";
|
|
199
|
+
|
|
184
200
|
export function getLatestTodoPhasesFromEntries(entries: SessionEntry[]): TodoPhase[] {
|
|
185
201
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
186
202
|
const entry = entries[i];
|
|
203
|
+
if (entry.type === "custom" && entry.customType === USER_TODO_EDIT_CUSTOM_TYPE) {
|
|
204
|
+
const data = entry.data as { phases?: unknown } | undefined;
|
|
205
|
+
if (data && Array.isArray(data.phases)) {
|
|
206
|
+
return clonePhases(data.phases as TodoPhase[]);
|
|
207
|
+
}
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
187
210
|
if (entry.type !== "message") continue;
|
|
188
|
-
|
|
189
211
|
const message = entry.message as { role?: string; toolName?: string; details?: unknown; isError?: boolean };
|
|
190
212
|
if (message.role !== "toolResult" || message.toolName !== "todo_write" || message.isError) continue;
|
|
191
213
|
|
|
@@ -328,6 +350,17 @@ function applyEntry(file: TodoFile, entry: TodoOpEntryValue, errors: string[]):
|
|
|
328
350
|
removeTasks(file, entry, errors);
|
|
329
351
|
return file;
|
|
330
352
|
}
|
|
353
|
+
case "note": {
|
|
354
|
+
const task = resolveTaskOrError(file.phases, entry.task, errors);
|
|
355
|
+
if (!task) return file;
|
|
356
|
+
const text = (entry.text ?? "").replace(/\s+$/u, "");
|
|
357
|
+
if (!text) {
|
|
358
|
+
errors.push("Missing text for note operation");
|
|
359
|
+
return file;
|
|
360
|
+
}
|
|
361
|
+
task.notes = task.notes ? [...task.notes, text] : [text];
|
|
362
|
+
return file;
|
|
363
|
+
}
|
|
331
364
|
case "append": {
|
|
332
365
|
appendItems(file, entry, errors);
|
|
333
366
|
return file;
|
|
@@ -343,6 +376,142 @@ function applyParams(file: TodoFile, params: TodoWriteParams): { file: TodoFile;
|
|
|
343
376
|
normalizeInProgressTask(file.phases);
|
|
344
377
|
return { file, errors };
|
|
345
378
|
}
|
|
379
|
+
/** Apply an array of `todo_write`-style ops to existing phases. Used by /todo slash command. */
|
|
380
|
+
export function applyOpsToPhases(
|
|
381
|
+
currentPhases: TodoPhase[],
|
|
382
|
+
ops: TodoWriteParams["ops"],
|
|
383
|
+
): { phases: TodoPhase[]; errors: string[] } {
|
|
384
|
+
const startFile = fileFromPhases(currentPhases);
|
|
385
|
+
const { file, errors } = applyParams(startFile, { ops });
|
|
386
|
+
return { phases: file.phases, errors };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// =============================================================================
|
|
390
|
+
// Markdown round-trip
|
|
391
|
+
// =============================================================================
|
|
392
|
+
|
|
393
|
+
const STATUS_TO_MARKER: Record<TodoStatus, string> = {
|
|
394
|
+
pending: " ",
|
|
395
|
+
in_progress: "/",
|
|
396
|
+
completed: "x",
|
|
397
|
+
abandoned: "-",
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
/** Render todo phases as a Markdown checklist suitable for editing/copying. */
|
|
401
|
+
export function phasesToMarkdown(phases: TodoPhase[]): string {
|
|
402
|
+
if (phases.length === 0) return "# I. Todos\n";
|
|
403
|
+
const out: string[] = [];
|
|
404
|
+
for (let i = 0; i < phases.length; i++) {
|
|
405
|
+
if (i > 0) out.push("");
|
|
406
|
+
out.push(`# ${phases[i].name}`);
|
|
407
|
+
for (const task of phases[i].tasks) {
|
|
408
|
+
out.push(`- [${STATUS_TO_MARKER[task.status]}] ${task.content}`);
|
|
409
|
+
if (task.notes && task.notes.length > 0) {
|
|
410
|
+
for (let j = 0; j < task.notes.length; j++) {
|
|
411
|
+
if (j > 0) out.push(" >");
|
|
412
|
+
for (const noteLine of task.notes[j].split("\n")) {
|
|
413
|
+
out.push(noteLine === "" ? " >" : ` > ${noteLine}`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return `${out.join("\n")}\n`;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const MARKER_TO_STATUS: Record<string, TodoStatus> = {
|
|
423
|
+
" ": "pending",
|
|
424
|
+
"": "pending",
|
|
425
|
+
x: "completed",
|
|
426
|
+
X: "completed",
|
|
427
|
+
"/": "in_progress",
|
|
428
|
+
">": "in_progress",
|
|
429
|
+
"-": "abandoned",
|
|
430
|
+
"~": "abandoned",
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Parse a Markdown checklist back into todo phases. Task and phase ids are
|
|
435
|
+
* regenerated; the agent observes the new ids in the system reminder.
|
|
436
|
+
*/
|
|
437
|
+
export function markdownToPhases(md: string): { phases: TodoPhase[]; errors: string[] } {
|
|
438
|
+
const errors: string[] = [];
|
|
439
|
+
const phases: TodoPhase[] = [];
|
|
440
|
+
let currentPhase: TodoPhase | undefined;
|
|
441
|
+
let currentTask: TodoItem | undefined;
|
|
442
|
+
let noteBuf: string[] = [];
|
|
443
|
+
let nextPhaseId = 1;
|
|
444
|
+
let nextTaskId = 1;
|
|
445
|
+
|
|
446
|
+
const flushNote = () => {
|
|
447
|
+
if (!currentTask || noteBuf.length === 0) {
|
|
448
|
+
noteBuf = [];
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
while (noteBuf.length > 0 && noteBuf[noteBuf.length - 1] === "") noteBuf.pop();
|
|
452
|
+
if (noteBuf.length === 0) return;
|
|
453
|
+
const joined = noteBuf.join("\n");
|
|
454
|
+
currentTask.notes = currentTask.notes ? [...currentTask.notes, joined] : [joined];
|
|
455
|
+
noteBuf = [];
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
const lines = md.split(/\r?\n/);
|
|
459
|
+
for (let lineNum = 0; lineNum < lines.length; lineNum++) {
|
|
460
|
+
const raw = lines[lineNum];
|
|
461
|
+
|
|
462
|
+
// Blockquote line attached to the current task: ` > text` or ` >`
|
|
463
|
+
const noteMatch = /^\s*>\s?(.*)$/.exec(raw);
|
|
464
|
+
if (noteMatch && currentTask) {
|
|
465
|
+
const noteLine = noteMatch[1];
|
|
466
|
+
if (noteLine === "") {
|
|
467
|
+
// Blank `>` separates two distinct notes
|
|
468
|
+
flushNote();
|
|
469
|
+
} else {
|
|
470
|
+
noteBuf.push(noteLine);
|
|
471
|
+
}
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const trimmed = raw.trim();
|
|
476
|
+
if (!trimmed) continue;
|
|
477
|
+
|
|
478
|
+
const headingMatch = /^#{1,6}\s+(.+?)\s*$/.exec(trimmed);
|
|
479
|
+
if (headingMatch) {
|
|
480
|
+
flushNote();
|
|
481
|
+
currentTask = undefined;
|
|
482
|
+
currentPhase = { id: `phase-${nextPhaseId++}`, name: headingMatch[1].trim(), tasks: [] };
|
|
483
|
+
phases.push(currentPhase);
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const taskMatch = /^[-*+]\s*\[(.?)\]\s+(.+?)\s*$/.exec(trimmed);
|
|
488
|
+
if (taskMatch) {
|
|
489
|
+
flushNote();
|
|
490
|
+
if (!currentPhase) {
|
|
491
|
+
currentPhase = { id: `phase-${nextPhaseId++}`, name: "I. Todos", tasks: [] };
|
|
492
|
+
phases.push(currentPhase);
|
|
493
|
+
}
|
|
494
|
+
const marker = taskMatch[1];
|
|
495
|
+
const status = MARKER_TO_STATUS[marker];
|
|
496
|
+
if (!status) {
|
|
497
|
+
errors.push(`Line ${lineNum + 1}: unknown status marker "[${marker}]" (use [ ], [x], [/], [-])`);
|
|
498
|
+
currentTask = undefined;
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
currentTask = { id: `task-${nextTaskId++}`, content: taskMatch[2].trim(), status };
|
|
502
|
+
currentPhase.tasks.push(currentTask);
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
flushNote();
|
|
507
|
+
currentTask = undefined;
|
|
508
|
+
errors.push(`Line ${lineNum + 1}: unrecognized syntax "${trimmed}"`);
|
|
509
|
+
}
|
|
510
|
+
flushNote();
|
|
511
|
+
|
|
512
|
+
normalizeInProgressTask(phases);
|
|
513
|
+
return { phases, errors };
|
|
514
|
+
}
|
|
346
515
|
|
|
347
516
|
function formatSummary(phases: TodoPhase[], errors: string[]): string {
|
|
348
517
|
const tasks = phases.flatMap(phase => phase.tasks);
|
|
@@ -387,7 +556,17 @@ function formatSummary(phases: TodoPhase[], errors: string[]): string {
|
|
|
387
556
|
: task.status === "abandoned"
|
|
388
557
|
? "✗"
|
|
389
558
|
: "○";
|
|
390
|
-
|
|
559
|
+
const noteCount = task.notes?.length ?? 0;
|
|
560
|
+
const noteMarker = noteCount > 0 ? ` (+${noteCount} note${noteCount === 1 ? "" : "s"})` : "";
|
|
561
|
+
lines.push(` ${sym} ${task.id} ${task.content}${noteMarker}`);
|
|
562
|
+
if (task.status === "in_progress" && task.notes && task.notes.length > 0) {
|
|
563
|
+
for (let j = 0; j < task.notes.length; j++) {
|
|
564
|
+
if (j > 0) lines.push(" ---");
|
|
565
|
+
for (const noteLine of task.notes[j].split("\n")) {
|
|
566
|
+
lines.push(` ${noteLine}`);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
391
570
|
}
|
|
392
571
|
}
|
|
393
572
|
return lines.join("\n");
|
|
@@ -442,18 +621,65 @@ type TodoWriteRenderArgs = {
|
|
|
442
621
|
}>;
|
|
443
622
|
};
|
|
444
623
|
|
|
624
|
+
const SUP_DIGITS: Record<string, string> = {
|
|
625
|
+
"0": "\u2070",
|
|
626
|
+
"1": "\u00b9",
|
|
627
|
+
"2": "\u00b2",
|
|
628
|
+
"3": "\u00b3",
|
|
629
|
+
"4": "\u2074",
|
|
630
|
+
"5": "\u2075",
|
|
631
|
+
"6": "\u2076",
|
|
632
|
+
"7": "\u2077",
|
|
633
|
+
"8": "\u2078",
|
|
634
|
+
"9": "\u2079",
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
function toSuperscript(n: number): string {
|
|
638
|
+
return n
|
|
639
|
+
.toString()
|
|
640
|
+
.split("")
|
|
641
|
+
.map(d => SUP_DIGITS[d] ?? d)
|
|
642
|
+
.join("");
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function noteMarker(count: number, uiTheme: Theme): string {
|
|
646
|
+
if (count <= 0) return "";
|
|
647
|
+
return uiTheme.fg("dim", chalk.italic(` \u207a${toSuperscript(count)}`));
|
|
648
|
+
}
|
|
649
|
+
|
|
445
650
|
function formatTodoLine(item: TodoItem, uiTheme: Theme, prefix: string): string {
|
|
446
651
|
const checkbox = uiTheme.checkbox;
|
|
652
|
+
const marker = noteMarker(item.notes?.length ?? 0, uiTheme);
|
|
447
653
|
switch (item.status) {
|
|
448
654
|
case "completed":
|
|
449
|
-
return uiTheme.fg("success", `${prefix}${checkbox.checked} ${chalk.strikethrough(item.content)}`);
|
|
655
|
+
return uiTheme.fg("success", `${prefix}${checkbox.checked} ${chalk.strikethrough(item.content)}`) + marker;
|
|
450
656
|
case "in_progress":
|
|
451
|
-
return uiTheme.fg("accent", `${prefix}${checkbox.unchecked} ${item.content}`);
|
|
657
|
+
return uiTheme.fg("accent", `${prefix}${checkbox.unchecked} ${item.content}`) + marker;
|
|
452
658
|
case "abandoned":
|
|
453
|
-
return uiTheme.fg("error", `${prefix}${checkbox.unchecked} ${chalk.strikethrough(item.content)}`);
|
|
659
|
+
return uiTheme.fg("error", `${prefix}${checkbox.unchecked} ${chalk.strikethrough(item.content)}`) + marker;
|
|
454
660
|
default:
|
|
455
|
-
return uiTheme.fg("dim", `${prefix}${checkbox.unchecked} ${item.content}`);
|
|
661
|
+
return uiTheme.fg("dim", `${prefix}${checkbox.unchecked} ${item.content}`) + marker;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function renderNoteAttachments(phases: TodoPhase[], uiTheme: Theme): string[] {
|
|
666
|
+
const lines: string[] = [];
|
|
667
|
+
for (const phase of phases) {
|
|
668
|
+
for (const task of phase.tasks) {
|
|
669
|
+
if (task.status !== "in_progress" || !task.notes || task.notes.length === 0) continue;
|
|
670
|
+
const bar = uiTheme.fg("dim", uiTheme.tree.vertical);
|
|
671
|
+
const title = uiTheme.fg("dim", chalk.italic(`\u00a7 notes \u2014 ${task.content}`));
|
|
672
|
+
lines.push("");
|
|
673
|
+
lines.push(` ${title}`);
|
|
674
|
+
for (let j = 0; j < task.notes.length; j++) {
|
|
675
|
+
if (j > 0) lines.push(` ${bar}`);
|
|
676
|
+
for (const noteLine of task.notes[j].split("\n")) {
|
|
677
|
+
lines.push(` ${bar} ${uiTheme.fg("dim", noteLine)}`);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
456
681
|
}
|
|
682
|
+
return lines;
|
|
457
683
|
}
|
|
458
684
|
|
|
459
685
|
export const todoWriteToolRenderer = {
|
|
@@ -488,9 +714,11 @@ export const todoWriteToolRenderer = {
|
|
|
488
714
|
|
|
489
715
|
const { expanded } = options;
|
|
490
716
|
const lines: string[] = [header];
|
|
491
|
-
for (
|
|
717
|
+
for (let p = 0; p < phases.length; p++) {
|
|
718
|
+
const phase = phases[p];
|
|
719
|
+
if (p > 0) lines.push("");
|
|
492
720
|
if (phases.length > 1) {
|
|
493
|
-
lines.push(uiTheme.fg("accent", ` ${
|
|
721
|
+
lines.push(uiTheme.fg("accent", chalk.bold(` ${phase.name}`)));
|
|
494
722
|
}
|
|
495
723
|
const treeLines = renderTreeList(
|
|
496
724
|
{
|
|
@@ -502,8 +730,11 @@ export const todoWriteToolRenderer = {
|
|
|
502
730
|
},
|
|
503
731
|
uiTheme,
|
|
504
732
|
);
|
|
505
|
-
|
|
733
|
+
for (const line of treeLines) {
|
|
734
|
+
lines.push(` ${line}`);
|
|
735
|
+
}
|
|
506
736
|
}
|
|
737
|
+
lines.push(...renderNoteAttachments(phases, uiTheme));
|
|
507
738
|
return new Text(lines.join("\n"), 0, 0);
|
|
508
739
|
},
|
|
509
740
|
mergeCallAndResult: true,
|
package/src/tools/write.ts
CHANGED
|
@@ -59,7 +59,7 @@ export interface WriteToolDetails {
|
|
|
59
59
|
/**
|
|
60
60
|
* Strip hashline display prefixes from write content.
|
|
61
61
|
*
|
|
62
|
-
* Only active when hashline edit mode is enabled — the model sees `LINE+ID
|
|
62
|
+
* Only active when hashline edit mode is enabled — the model sees `LINE+ID|`
|
|
63
63
|
* prefixes in read output and sometimes copies them into write content.
|
|
64
64
|
*/
|
|
65
65
|
function stripWriteContent(session: ToolSession, content: string): { text: string; stripped: boolean } {
|
|
@@ -418,7 +418,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
|
|
|
418
418
|
context?: AgentToolContext,
|
|
419
419
|
): Promise<AgentToolResult<WriteToolDetails>> {
|
|
420
420
|
return untilAborted(signal, async () => {
|
|
421
|
-
// Strip hashline display prefixes (LINE+ID
|
|
421
|
+
// Strip hashline display prefixes (LINE+ID|) if the model copied them from read output
|
|
422
422
|
const { text: cleanContent, stripped } = stripWriteContent(this.session, content);
|
|
423
423
|
const resolvedArchivePath = await this.#resolveArchiveWritePath(path);
|
|
424
424
|
if (resolvedArchivePath) {
|