@oh-my-pi/pi-coding-agent 3.13.1337 → 3.15.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/CHANGELOG.md +88 -0
- package/docs/theme.md +38 -5
- package/examples/sdk/11-sessions.ts +2 -2
- package/package.json +7 -4
- package/src/cli/file-processor.ts +51 -2
- package/src/cli/plugin-cli.ts +25 -19
- package/src/cli/update-cli.ts +4 -3
- package/src/core/agent-session.ts +31 -4
- package/src/core/compaction/branch-summarization.ts +4 -32
- package/src/core/compaction/compaction.ts +6 -84
- package/src/core/compaction/utils.ts +2 -3
- package/src/core/custom-tools/types.ts +2 -0
- package/src/core/export-html/index.ts +1 -1
- package/src/core/hooks/index.ts +1 -1
- package/src/core/hooks/tool-wrapper.ts +0 -1
- package/src/core/hooks/types.ts +2 -2
- package/src/core/plugins/doctor.ts +9 -1
- package/src/core/sdk.ts +2 -1
- package/src/core/session-manager.ts +552 -41
- package/src/core/settings-manager.ts +174 -0
- package/src/core/system-prompt.ts +9 -14
- package/src/core/title-generator.ts +2 -8
- package/src/core/tools/ask.ts +19 -37
- package/src/core/tools/bash.ts +2 -37
- package/src/core/tools/edit.ts +2 -9
- package/src/core/tools/exa/render.ts +52 -48
- package/src/core/tools/find.ts +10 -8
- package/src/core/tools/grep.ts +45 -17
- package/src/core/tools/ls.ts +22 -2
- package/src/core/tools/lsp/clients/biome-client.ts +207 -0
- package/src/core/tools/lsp/clients/index.ts +49 -0
- package/src/core/tools/lsp/clients/lsp-linter-client.ts +98 -0
- package/src/core/tools/lsp/config.ts +3 -0
- package/src/core/tools/lsp/index.ts +107 -55
- package/src/core/tools/lsp/render.ts +192 -79
- package/src/core/tools/lsp/types.ts +27 -0
- package/src/core/tools/lsp/utils.ts +62 -22
- package/src/core/tools/notebook.ts +9 -1
- package/src/core/tools/output.ts +37 -14
- package/src/core/tools/read.ts +349 -34
- package/src/core/tools/renderers.ts +290 -89
- package/src/core/tools/review.ts +12 -5
- package/src/core/tools/task/agents.ts +5 -5
- package/src/core/tools/task/commands.ts +3 -3
- package/src/core/tools/task/executor.ts +33 -1
- package/src/core/tools/task/index.ts +93 -6
- package/src/core/tools/task/render.ts +147 -66
- package/src/core/tools/task/types.ts +14 -9
- package/src/core/tools/web-fetch.ts +242 -103
- package/src/core/tools/web-search/index.ts +64 -20
- package/src/core/tools/web-search/providers/exa.ts +68 -172
- package/src/core/tools/web-search/render.ts +264 -74
- package/src/core/tools/write.ts +2 -8
- package/src/main.ts +10 -6
- package/src/modes/cleanup.ts +23 -0
- package/src/modes/index.ts +9 -4
- package/src/modes/interactive/components/bash-execution.ts +6 -3
- package/src/modes/interactive/components/branch-summary-message.ts +1 -1
- package/src/modes/interactive/components/compaction-summary-message.ts +1 -1
- package/src/modes/interactive/components/dynamic-border.ts +1 -1
- package/src/modes/interactive/components/extensions/extension-dashboard.ts +4 -5
- package/src/modes/interactive/components/extensions/extension-list.ts +18 -16
- package/src/modes/interactive/components/extensions/inspector-panel.ts +8 -8
- package/src/modes/interactive/components/hook-message.ts +2 -2
- package/src/modes/interactive/components/hook-selector.ts +1 -1
- package/src/modes/interactive/components/model-selector.ts +22 -9
- package/src/modes/interactive/components/oauth-selector.ts +20 -4
- package/src/modes/interactive/components/plugin-settings.ts +4 -2
- package/src/modes/interactive/components/session-selector.ts +9 -6
- package/src/modes/interactive/components/settings-defs.ts +285 -1
- package/src/modes/interactive/components/settings-selector.ts +176 -3
- package/src/modes/interactive/components/status-line/index.ts +4 -0
- package/src/modes/interactive/components/status-line/presets.ts +94 -0
- package/src/modes/interactive/components/status-line/segments.ts +350 -0
- package/src/modes/interactive/components/status-line/separators.ts +55 -0
- package/src/modes/interactive/components/status-line/types.ts +81 -0
- package/src/modes/interactive/components/status-line-segment-editor.ts +357 -0
- package/src/modes/interactive/components/status-line.ts +169 -233
- package/src/modes/interactive/components/tool-execution.ts +446 -211
- package/src/modes/interactive/components/tree-selector.ts +17 -6
- package/src/modes/interactive/components/ttsr-notification.ts +4 -4
- package/src/modes/interactive/components/welcome.ts +27 -19
- package/src/modes/interactive/interactive-mode.ts +98 -13
- package/src/modes/interactive/theme/dark.json +3 -2
- package/src/modes/interactive/theme/defaults/dark-arctic.json +111 -0
- package/src/modes/interactive/theme/defaults/dark-catppuccin.json +106 -0
- package/src/modes/interactive/theme/defaults/dark-cyberpunk.json +109 -0
- package/src/modes/interactive/theme/defaults/dark-dracula.json +105 -0
- package/src/modes/interactive/theme/defaults/dark-forest.json +103 -0
- package/src/modes/interactive/theme/defaults/dark-github.json +112 -0
- package/src/modes/interactive/theme/defaults/dark-gruvbox.json +119 -0
- package/src/modes/interactive/theme/defaults/dark-monochrome.json +101 -0
- package/src/modes/interactive/theme/defaults/dark-monokai.json +105 -0
- package/src/modes/interactive/theme/defaults/dark-nord.json +104 -0
- package/src/modes/interactive/theme/defaults/dark-ocean.json +108 -0
- package/src/modes/interactive/theme/defaults/dark-one.json +107 -0
- package/src/modes/interactive/theme/defaults/dark-retro.json +99 -0
- package/src/modes/interactive/theme/defaults/dark-rose-pine.json +95 -0
- package/src/modes/interactive/theme/defaults/dark-solarized.json +96 -0
- package/src/modes/interactive/theme/defaults/dark-sunset.json +106 -0
- package/src/modes/interactive/theme/defaults/dark-synthwave.json +102 -0
- package/src/modes/interactive/theme/defaults/dark-tokyo-night.json +108 -0
- package/src/modes/interactive/theme/defaults/index.ts +67 -0
- package/src/modes/interactive/theme/defaults/light-arctic.json +106 -0
- package/src/modes/interactive/theme/defaults/light-catppuccin.json +105 -0
- package/src/modes/interactive/theme/defaults/light-cyberpunk.json +103 -0
- package/src/modes/interactive/theme/defaults/light-forest.json +107 -0
- package/src/modes/interactive/theme/defaults/light-github.json +114 -0
- package/src/modes/interactive/theme/defaults/light-gruvbox.json +115 -0
- package/src/modes/interactive/theme/defaults/light-monochrome.json +100 -0
- package/src/modes/interactive/theme/defaults/light-ocean.json +106 -0
- package/src/modes/interactive/theme/defaults/light-one.json +105 -0
- package/src/modes/interactive/theme/defaults/light-retro.json +105 -0
- package/src/modes/interactive/theme/defaults/light-solarized.json +101 -0
- package/src/modes/interactive/theme/defaults/light-sunset.json +106 -0
- package/src/modes/interactive/theme/defaults/light-synthwave.json +105 -0
- package/src/modes/interactive/theme/defaults/light-tokyo-night.json +118 -0
- package/src/modes/interactive/theme/light.json +3 -2
- package/src/modes/interactive/theme/theme-schema.json +120 -4
- package/src/modes/interactive/theme/theme.ts +1228 -14
- package/src/prompts/branch-summary-preamble.md +3 -0
- package/src/prompts/branch-summary.md +28 -0
- package/src/prompts/compaction-summary.md +34 -0
- package/src/prompts/compaction-turn-prefix.md +16 -0
- package/src/prompts/compaction-update-summary.md +41 -0
- package/src/prompts/init.md +30 -0
- package/src/{core/tools/task/bundled-agents → prompts}/reviewer.md +6 -0
- package/src/prompts/summarization-system.md +3 -0
- package/src/prompts/system-prompt.md +27 -0
- package/src/{core/tools/task/bundled-agents → prompts}/task.md +2 -0
- package/src/prompts/title-system.md +8 -0
- package/src/prompts/tools/ask.md +24 -0
- package/src/prompts/tools/bash.md +23 -0
- package/src/prompts/tools/edit.md +9 -0
- package/src/prompts/tools/find.md +6 -0
- package/src/prompts/tools/grep.md +12 -0
- package/src/prompts/tools/lsp.md +14 -0
- package/src/prompts/tools/output.md +23 -0
- package/src/prompts/tools/read.md +25 -0
- package/src/prompts/tools/web-fetch.md +8 -0
- package/src/prompts/tools/web-search.md +10 -0
- package/src/prompts/tools/write.md +10 -0
- package/src/commands/init.md +0 -20
- /package/src/{core/tools/task/bundled-commands → prompts}/architect-plan.md +0 -0
- /package/src/{core/tools/task/bundled-agents → prompts}/browser.md +0 -0
- /package/src/{core/tools/task/bundled-agents → prompts}/explore.md +0 -0
- /package/src/{core/tools/task/bundled-commands → prompts}/implement-with-critic.md +0 -0
- /package/src/{core/tools/task/bundled-commands → prompts}/implement.md +0 -0
- /package/src/{core/tools/task/bundled-agents → prompts}/plan.md +0 -0
package/src/core/tools/output.ts
CHANGED
|
@@ -9,6 +9,7 @@ import * as path from "node:path";
|
|
|
9
9
|
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
|
|
10
10
|
import type { TextContent } from "@oh-my-pi/pi-ai";
|
|
11
11
|
import { Type } from "@sinclair/typebox";
|
|
12
|
+
import outputDescription from "../../prompts/tools/output.md" with { type: "text" };
|
|
12
13
|
import type { SessionContext } from "./index";
|
|
13
14
|
import { getArtifactsDir } from "./task/artifacts";
|
|
14
15
|
|
|
@@ -25,11 +26,18 @@ const outputSchema = Type.Object({
|
|
|
25
26
|
});
|
|
26
27
|
|
|
27
28
|
/** Metadata for a single output file */
|
|
29
|
+
interface OutputProvenance {
|
|
30
|
+
agent: string;
|
|
31
|
+
index: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
28
34
|
interface OutputEntry {
|
|
29
35
|
id: string;
|
|
30
36
|
path: string;
|
|
31
37
|
lineCount: number;
|
|
32
38
|
charCount: number;
|
|
39
|
+
provenance?: OutputProvenance;
|
|
40
|
+
previewLines?: string[];
|
|
33
41
|
}
|
|
34
42
|
|
|
35
43
|
export interface OutputToolDetails {
|
|
@@ -60,6 +68,26 @@ function formatBytes(bytes: number): string {
|
|
|
60
68
|
return `${(bytes / (1024 * 1024)).toFixed(1)}M`;
|
|
61
69
|
}
|
|
62
70
|
|
|
71
|
+
function parseOutputProvenance(id: string): OutputProvenance | undefined {
|
|
72
|
+
const match = id.match(/^(.*)_(\d+)$/);
|
|
73
|
+
if (!match) return undefined;
|
|
74
|
+
const agent = match[1];
|
|
75
|
+
const index = Number(match[2]);
|
|
76
|
+
if (!agent || Number.isNaN(index)) return undefined;
|
|
77
|
+
return { agent, index };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function extractPreviewLines(content: string, maxLines: number): string[] {
|
|
81
|
+
const lines = content.split("\n");
|
|
82
|
+
const preview: string[] = [];
|
|
83
|
+
for (const line of lines) {
|
|
84
|
+
if (!line.trim()) continue;
|
|
85
|
+
preview.push(line);
|
|
86
|
+
if (preview.length >= maxLines) break;
|
|
87
|
+
}
|
|
88
|
+
return preview;
|
|
89
|
+
}
|
|
90
|
+
|
|
63
91
|
export function createOutputTool(
|
|
64
92
|
_cwd: string,
|
|
65
93
|
sessionContext?: SessionContext,
|
|
@@ -67,18 +95,7 @@ export function createOutputTool(
|
|
|
67
95
|
return {
|
|
68
96
|
name: "output",
|
|
69
97
|
label: "Output",
|
|
70
|
-
description:
|
|
71
|
-
|
|
72
|
-
Use when the Task tool's truncated preview isn't sufficient for your needs.
|
|
73
|
-
The Task tool already returns summaries with line/char counts in its result.
|
|
74
|
-
|
|
75
|
-
Parameters:
|
|
76
|
-
- ids: Array of output IDs (e.g., ["reviewer_0", "explore_1"])
|
|
77
|
-
- format: "raw" (default), "json" (structured object), or "stripped" (no ANSI codes)
|
|
78
|
-
|
|
79
|
-
Returns the full output content. For unknown IDs, returns an error with available IDs.
|
|
80
|
-
|
|
81
|
-
Example: { "ids": ["reviewer_0"] }`,
|
|
98
|
+
description: outputDescription,
|
|
82
99
|
parameters: outputSchema,
|
|
83
100
|
execute: async (
|
|
84
101
|
_toolCallId: string,
|
|
@@ -103,6 +120,7 @@ Example: { "ids": ["reviewer_0"] }`,
|
|
|
103
120
|
|
|
104
121
|
const outputs: OutputEntry[] = [];
|
|
105
122
|
const notFound: string[] = [];
|
|
123
|
+
const outputContentById = new Map<string, string>();
|
|
106
124
|
const format = params.format ?? "raw";
|
|
107
125
|
|
|
108
126
|
for (const id of params.ids) {
|
|
@@ -114,11 +132,14 @@ Example: { "ids": ["reviewer_0"] }`,
|
|
|
114
132
|
}
|
|
115
133
|
|
|
116
134
|
const content = fs.readFileSync(outputPath, "utf-8");
|
|
135
|
+
outputContentById.set(id, content);
|
|
117
136
|
outputs.push({
|
|
118
137
|
id,
|
|
119
138
|
path: outputPath,
|
|
120
139
|
lineCount: content.split("\n").length,
|
|
121
140
|
charCount: content.length,
|
|
141
|
+
provenance: parseOutputProvenance(id),
|
|
142
|
+
previewLines: extractPreviewLines(content, 4),
|
|
122
143
|
});
|
|
123
144
|
}
|
|
124
145
|
|
|
@@ -144,13 +165,15 @@ Example: { "ids": ["reviewer_0"] }`,
|
|
|
144
165
|
id: o.id,
|
|
145
166
|
lineCount: o.lineCount,
|
|
146
167
|
charCount: o.charCount,
|
|
147
|
-
|
|
168
|
+
provenance: o.provenance,
|
|
169
|
+
previewLines: o.previewLines,
|
|
170
|
+
content: outputContentById.get(o.id) ?? "",
|
|
148
171
|
}));
|
|
149
172
|
contentText = JSON.stringify(jsonData, null, 2);
|
|
150
173
|
} else {
|
|
151
174
|
// raw or stripped
|
|
152
175
|
const parts = outputs.map((o) => {
|
|
153
|
-
let content =
|
|
176
|
+
let content = outputContentById.get(o.id) ?? "";
|
|
154
177
|
if (format === "stripped") {
|
|
155
178
|
content = stripAnsi(content);
|
|
156
179
|
}
|
package/src/core/tools/read.ts
CHANGED
|
@@ -1,18 +1,299 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import { constants } from "node:fs";
|
|
3
|
-
import { access, readFile } from "node:fs/promises";
|
|
4
|
-
import
|
|
2
|
+
import { constants, existsSync } from "node:fs";
|
|
3
|
+
import { access, readFile, stat } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
5
|
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
|
|
6
6
|
import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
|
|
7
7
|
import { Type } from "@sinclair/typebox";
|
|
8
|
+
import { globSync } from "glob";
|
|
9
|
+
import readDescription from "../../prompts/tools/read.md" with { type: "text" };
|
|
8
10
|
import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime";
|
|
11
|
+
import { ensureTool } from "../../utils/tools-manager";
|
|
9
12
|
import { untilAborted } from "../utils";
|
|
10
|
-
import {
|
|
13
|
+
import { createLsTool } from "./ls";
|
|
14
|
+
import { resolveReadPath, resolveToCwd } from "./path-utils";
|
|
11
15
|
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate";
|
|
12
16
|
|
|
13
17
|
// Document types convertible via markitdown
|
|
14
18
|
const CONVERTIBLE_EXTENSIONS = new Set([".pdf", ".doc", ".docx", ".ppt", ".pptx", ".xls", ".xlsx", ".rtf", ".epub"]);
|
|
15
19
|
|
|
20
|
+
// Maximum image file size (20MB) - larger images will be rejected to prevent OOM during serialization
|
|
21
|
+
const MAX_IMAGE_SIZE = 20 * 1024 * 1024;
|
|
22
|
+
const MAX_FUZZY_RESULTS = 5;
|
|
23
|
+
const MAX_FUZZY_CANDIDATES = 20000;
|
|
24
|
+
const MIN_BASE_SIMILARITY = 0.5;
|
|
25
|
+
const MIN_FULL_SIMILARITY = 0.6;
|
|
26
|
+
|
|
27
|
+
function normalizePathForMatch(value: string): string {
|
|
28
|
+
return value
|
|
29
|
+
.replace(/\\/g, "/")
|
|
30
|
+
.replace(/^\.\/+/, "")
|
|
31
|
+
.replace(/\/+$/, "")
|
|
32
|
+
.toLowerCase();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isNotFoundError(error: unknown): boolean {
|
|
36
|
+
if (!error || typeof error !== "object") return false;
|
|
37
|
+
const code = (error as { code?: string }).code;
|
|
38
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isPathWithin(basePath: string, targetPath: string): boolean {
|
|
42
|
+
const relativePath = path.relative(basePath, targetPath);
|
|
43
|
+
return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function findExistingDirectory(startDir: string): Promise<string | null> {
|
|
47
|
+
let current = startDir;
|
|
48
|
+
const root = path.parse(startDir).root;
|
|
49
|
+
|
|
50
|
+
while (true) {
|
|
51
|
+
try {
|
|
52
|
+
const stats = await stat(current);
|
|
53
|
+
if (stats.isDirectory()) {
|
|
54
|
+
return current;
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
// Keep walking up.
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (current === root) {
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
current = path.dirname(current);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function formatScopeLabel(searchRoot: string, cwd: string): string {
|
|
70
|
+
const relative = path.relative(cwd, searchRoot).replace(/\\/g, "/");
|
|
71
|
+
if (relative === "" || relative === ".") {
|
|
72
|
+
return ".";
|
|
73
|
+
}
|
|
74
|
+
if (!relative.startsWith("..") && !path.isAbsolute(relative)) {
|
|
75
|
+
return relative;
|
|
76
|
+
}
|
|
77
|
+
return searchRoot;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function buildDisplayPath(searchRoot: string, cwd: string, relativePath: string): string {
|
|
81
|
+
const scopeLabel = formatScopeLabel(searchRoot, cwd);
|
|
82
|
+
const normalized = relativePath.replace(/\\/g, "/");
|
|
83
|
+
if (scopeLabel === ".") {
|
|
84
|
+
return normalized;
|
|
85
|
+
}
|
|
86
|
+
if (scopeLabel.startsWith("..") || path.isAbsolute(scopeLabel)) {
|
|
87
|
+
return path.join(searchRoot, normalized).replace(/\\/g, "/");
|
|
88
|
+
}
|
|
89
|
+
return `${scopeLabel}/${normalized}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function levenshteinDistance(a: string, b: string): number {
|
|
93
|
+
if (a === b) return 0;
|
|
94
|
+
const aLen = a.length;
|
|
95
|
+
const bLen = b.length;
|
|
96
|
+
if (aLen === 0) return bLen;
|
|
97
|
+
if (bLen === 0) return aLen;
|
|
98
|
+
|
|
99
|
+
let prev = new Array<number>(bLen + 1);
|
|
100
|
+
let curr = new Array<number>(bLen + 1);
|
|
101
|
+
for (let j = 0; j <= bLen; j++) {
|
|
102
|
+
prev[j] = j;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
for (let i = 1; i <= aLen; i++) {
|
|
106
|
+
curr[0] = i;
|
|
107
|
+
const aCode = a.charCodeAt(i - 1);
|
|
108
|
+
for (let j = 1; j <= bLen; j++) {
|
|
109
|
+
const cost = aCode === b.charCodeAt(j - 1) ? 0 : 1;
|
|
110
|
+
const deletion = prev[j] + 1;
|
|
111
|
+
const insertion = curr[j - 1] + 1;
|
|
112
|
+
const substitution = prev[j - 1] + cost;
|
|
113
|
+
curr[j] = Math.min(deletion, insertion, substitution);
|
|
114
|
+
}
|
|
115
|
+
const tmp = prev;
|
|
116
|
+
prev = curr;
|
|
117
|
+
curr = tmp;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return prev[bLen];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function similarityScore(a: string, b: string): number {
|
|
124
|
+
if (a.length === 0 && b.length === 0) {
|
|
125
|
+
return 1;
|
|
126
|
+
}
|
|
127
|
+
const maxLen = Math.max(a.length, b.length);
|
|
128
|
+
if (maxLen === 0) {
|
|
129
|
+
return 1;
|
|
130
|
+
}
|
|
131
|
+
const distance = levenshteinDistance(a, b);
|
|
132
|
+
return 1 - distance / maxLen;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function listCandidateFiles(
|
|
136
|
+
searchRoot: string,
|
|
137
|
+
): Promise<{ files: string[]; truncated: boolean; error?: string }> {
|
|
138
|
+
let fdPath: string | undefined;
|
|
139
|
+
try {
|
|
140
|
+
fdPath = await ensureTool("fd", true);
|
|
141
|
+
} catch {
|
|
142
|
+
return { files: [], truncated: false, error: "fd not available" };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!fdPath) {
|
|
146
|
+
return { files: [], truncated: false, error: "fd not available" };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const args: string[] = ["--type", "f", "--color=never", "--hidden", "--max-results", String(MAX_FUZZY_CANDIDATES)];
|
|
150
|
+
|
|
151
|
+
const gitignoreFiles = new Set<string>();
|
|
152
|
+
const rootGitignore = path.join(searchRoot, ".gitignore");
|
|
153
|
+
if (existsSync(rootGitignore)) {
|
|
154
|
+
gitignoreFiles.add(rootGitignore);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const nestedGitignores = globSync("**/.gitignore", {
|
|
159
|
+
cwd: searchRoot,
|
|
160
|
+
dot: true,
|
|
161
|
+
absolute: true,
|
|
162
|
+
ignore: ["**/node_modules/**", "**/.git/**"],
|
|
163
|
+
});
|
|
164
|
+
for (const file of nestedGitignores) {
|
|
165
|
+
gitignoreFiles.add(file);
|
|
166
|
+
}
|
|
167
|
+
} catch {
|
|
168
|
+
// Ignore glob errors.
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
for (const gitignorePath of gitignoreFiles) {
|
|
172
|
+
args.push("--ignore-file", gitignorePath);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
args.push(".", searchRoot);
|
|
176
|
+
|
|
177
|
+
const result = Bun.spawnSync([fdPath, ...args], {
|
|
178
|
+
stdin: "ignore",
|
|
179
|
+
stdout: "pipe",
|
|
180
|
+
stderr: "pipe",
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const output = result.stdout.toString().trim();
|
|
184
|
+
|
|
185
|
+
if (result.exitCode !== 0 && !output) {
|
|
186
|
+
const errorMsg = result.stderr.toString().trim() || `fd exited with code ${result.exitCode}`;
|
|
187
|
+
return { files: [], truncated: false, error: errorMsg };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (!output) {
|
|
191
|
+
return { files: [], truncated: false };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const files = output
|
|
195
|
+
.split("\n")
|
|
196
|
+
.map((line) => line.replace(/\r$/, "").trim())
|
|
197
|
+
.filter((line) => line.length > 0);
|
|
198
|
+
|
|
199
|
+
return { files, truncated: files.length >= MAX_FUZZY_CANDIDATES };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function findReadPathSuggestions(
|
|
203
|
+
rawPath: string,
|
|
204
|
+
cwd: string,
|
|
205
|
+
): Promise<{ suggestions: string[]; scopeLabel?: string; truncated?: boolean; error?: string } | null> {
|
|
206
|
+
const resolvedPath = resolveToCwd(rawPath, cwd);
|
|
207
|
+
const searchRoot = await findExistingDirectory(path.dirname(resolvedPath));
|
|
208
|
+
if (!searchRoot) {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (!isPathWithin(cwd, resolvedPath)) {
|
|
213
|
+
const root = path.parse(searchRoot).root;
|
|
214
|
+
if (searchRoot === root) {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const { files, truncated, error } = await listCandidateFiles(searchRoot);
|
|
220
|
+
const scopeLabel = formatScopeLabel(searchRoot, cwd);
|
|
221
|
+
|
|
222
|
+
if (error && files.length === 0) {
|
|
223
|
+
return { suggestions: [], scopeLabel, truncated, error };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (files.length === 0) {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const queryPath = (() => {
|
|
231
|
+
if (path.isAbsolute(rawPath)) {
|
|
232
|
+
const relative = path.relative(cwd, resolvedPath).replace(/\\/g, "/");
|
|
233
|
+
if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) {
|
|
234
|
+
return normalizePathForMatch(relative);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return normalizePathForMatch(rawPath);
|
|
238
|
+
})();
|
|
239
|
+
const baseQuery = path.posix.basename(queryPath);
|
|
240
|
+
|
|
241
|
+
const matches: Array<{ path: string; score: number; baseScore: number; fullScore: number }> = [];
|
|
242
|
+
const seen = new Set<string>();
|
|
243
|
+
|
|
244
|
+
for (const file of files) {
|
|
245
|
+
const cleaned = file.replace(/\r$/, "").trim();
|
|
246
|
+
if (!cleaned) continue;
|
|
247
|
+
|
|
248
|
+
const relativePath = path.isAbsolute(cleaned)
|
|
249
|
+
? cleaned.startsWith(searchRoot)
|
|
250
|
+
? cleaned.slice(searchRoot.length + 1)
|
|
251
|
+
: path.relative(searchRoot, cleaned)
|
|
252
|
+
: cleaned;
|
|
253
|
+
|
|
254
|
+
if (!relativePath || relativePath.startsWith("..")) {
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const displayPath = buildDisplayPath(searchRoot, cwd, relativePath);
|
|
259
|
+
if (seen.has(displayPath)) {
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
seen.add(displayPath);
|
|
263
|
+
|
|
264
|
+
const normalizedDisplay = normalizePathForMatch(displayPath);
|
|
265
|
+
const baseCandidate = path.posix.basename(normalizedDisplay);
|
|
266
|
+
|
|
267
|
+
const fullScore = similarityScore(queryPath, normalizedDisplay);
|
|
268
|
+
const baseScore = baseQuery ? similarityScore(baseQuery, baseCandidate) : 0;
|
|
269
|
+
|
|
270
|
+
if (baseQuery) {
|
|
271
|
+
if (baseScore < MIN_BASE_SIMILARITY && fullScore < MIN_FULL_SIMILARITY) {
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
} else if (fullScore < MIN_FULL_SIMILARITY) {
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const score = baseQuery ? baseScore * 0.75 + fullScore * 0.25 : fullScore;
|
|
279
|
+
matches.push({ path: displayPath, score, baseScore, fullScore });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (matches.length === 0) {
|
|
283
|
+
return { suggestions: [], scopeLabel, truncated };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
matches.sort((a, b) => {
|
|
287
|
+
if (b.score !== a.score) return b.score - a.score;
|
|
288
|
+
if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
|
|
289
|
+
return a.path.localeCompare(b.path);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const suggestions = matches.slice(0, MAX_FUZZY_RESULTS).map((match) => match.path);
|
|
293
|
+
|
|
294
|
+
return { suggestions, scopeLabel, truncated };
|
|
295
|
+
}
|
|
296
|
+
|
|
16
297
|
function convertWithMarkitdown(filePath: string): { content: string; ok: boolean; error?: string } {
|
|
17
298
|
const cmd = Bun.which("markitdown");
|
|
18
299
|
if (!cmd) {
|
|
@@ -40,56 +321,90 @@ const readSchema = Type.Object({
|
|
|
40
321
|
|
|
41
322
|
export interface ReadToolDetails {
|
|
42
323
|
truncation?: TruncationResult;
|
|
324
|
+
redirectedTo?: "ls";
|
|
43
325
|
}
|
|
44
326
|
|
|
45
327
|
export function createReadTool(cwd: string): AgentTool<typeof readSchema> {
|
|
328
|
+
const lsTool = createLsTool(cwd);
|
|
46
329
|
return {
|
|
47
330
|
name: "read",
|
|
48
331
|
label: "Read",
|
|
49
|
-
description:
|
|
50
|
-
Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.
|
|
51
|
-
|
|
52
|
-
Usage:
|
|
53
|
-
- The file_path parameter must be an absolute path, not a relative path
|
|
54
|
-
- By default, it reads up to ${DEFAULT_MAX_LINES} lines starting from the beginning of the file
|
|
55
|
-
- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters
|
|
56
|
-
- Any lines longer than 500 characters will be truncated
|
|
57
|
-
- Results are returned using cat -n format, with line numbers starting at 1
|
|
58
|
-
- This tool allows Claude Code to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as Claude Code is a multimodal LLM.
|
|
59
|
-
- This tool can read PDF files (.pdf). PDFs are processed page by page, extracting both text and visual content for analysis.
|
|
60
|
-
- This tool can read Jupyter notebooks (.ipynb files) and returns all cells with their outputs, combining code, text, and visualizations.
|
|
61
|
-
- This tool can only read files, not directories. To read a directory, use an ls command via the bash tool.
|
|
62
|
-
- You can call multiple tools in a single response. It is always better to speculatively read multiple potentially useful files in parallel.
|
|
63
|
-
- You will regularly be asked to read screenshots. If the user provides a path to a screenshot, ALWAYS use this tool to view the file at the path. This tool will work with all temporary file paths.
|
|
64
|
-
- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.`,
|
|
332
|
+
description: readDescription.replace("{{DEFAULT_MAX_LINES}}", String(DEFAULT_MAX_LINES)),
|
|
65
333
|
parameters: readSchema,
|
|
66
334
|
execute: async (
|
|
67
|
-
|
|
68
|
-
{ path, offset, limit }: { path: string; offset?: number; limit?: number },
|
|
335
|
+
toolCallId: string,
|
|
336
|
+
{ path: readPath, offset, limit }: { path: string; offset?: number; limit?: number },
|
|
69
337
|
signal?: AbortSignal,
|
|
70
338
|
) => {
|
|
71
|
-
const absolutePath = resolveReadPath(
|
|
339
|
+
const absolutePath = resolveReadPath(readPath, cwd);
|
|
72
340
|
|
|
73
341
|
return untilAborted(signal, async () => {
|
|
74
|
-
|
|
342
|
+
let fileStat: Awaited<ReturnType<typeof stat>>;
|
|
343
|
+
try {
|
|
344
|
+
fileStat = await stat(absolutePath);
|
|
345
|
+
} catch (error) {
|
|
346
|
+
if (isNotFoundError(error)) {
|
|
347
|
+
const suggestions = await findReadPathSuggestions(readPath, cwd);
|
|
348
|
+
let message = `File not found: ${readPath}`;
|
|
349
|
+
|
|
350
|
+
if (suggestions?.suggestions.length) {
|
|
351
|
+
const scopeLabel = suggestions.scopeLabel ? ` in ${suggestions.scopeLabel}` : "";
|
|
352
|
+
message += `\n\nClosest matches${scopeLabel}:\n${suggestions.suggestions
|
|
353
|
+
.map((match) => `- ${match}`)
|
|
354
|
+
.join("\n")}`;
|
|
355
|
+
if (suggestions.truncated) {
|
|
356
|
+
message += `\n[Search truncated to first ${MAX_FUZZY_CANDIDATES} paths. Refine the path if the match isn't listed.]`;
|
|
357
|
+
}
|
|
358
|
+
} else if (suggestions?.error) {
|
|
359
|
+
message += `\n\nFuzzy match failed: ${suggestions.error}`;
|
|
360
|
+
} else if (suggestions?.scopeLabel) {
|
|
361
|
+
message += `\n\nNo similar paths found in ${suggestions.scopeLabel}.`;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
throw new Error(message);
|
|
365
|
+
}
|
|
366
|
+
throw error;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (fileStat.isDirectory()) {
|
|
370
|
+
const lsResult = await lsTool.execute(toolCallId, { path: readPath, limit }, signal);
|
|
371
|
+
return {
|
|
372
|
+
content: lsResult.content,
|
|
373
|
+
details: { redirectedTo: "ls", truncation: lsResult.details?.truncation },
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
75
377
|
await access(absolutePath, constants.R_OK);
|
|
76
378
|
|
|
77
379
|
const mimeType = await detectSupportedImageMimeTypeFromFile(absolutePath);
|
|
78
|
-
const ext = extname(absolutePath).toLowerCase();
|
|
380
|
+
const ext = path.extname(absolutePath).toLowerCase();
|
|
79
381
|
|
|
80
382
|
// Read the file based on type
|
|
81
383
|
let content: (TextContent | ImageContent)[];
|
|
82
384
|
let details: ReadToolDetails | undefined;
|
|
83
385
|
|
|
84
386
|
if (mimeType) {
|
|
85
|
-
//
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
387
|
+
// Check image file size before reading to prevent OOM during serialization
|
|
388
|
+
const fileStat = await stat(absolutePath);
|
|
389
|
+
if (fileStat.size > MAX_IMAGE_SIZE) {
|
|
390
|
+
const sizeStr = formatSize(fileStat.size);
|
|
391
|
+
const maxStr = formatSize(MAX_IMAGE_SIZE);
|
|
392
|
+
content = [
|
|
393
|
+
{
|
|
394
|
+
type: "text",
|
|
395
|
+
text: `[Image file too large: ${sizeStr} exceeds ${maxStr} limit. Use an image viewer or resize the image.]`,
|
|
396
|
+
},
|
|
397
|
+
];
|
|
398
|
+
} else {
|
|
399
|
+
// Read as image (binary)
|
|
400
|
+
const buffer = await readFile(absolutePath);
|
|
401
|
+
const base64 = buffer.toString("base64");
|
|
402
|
+
|
|
403
|
+
content = [
|
|
404
|
+
{ type: "text", text: `Read image file [${mimeType}]` },
|
|
405
|
+
{ type: "image", data: base64, mimeType },
|
|
406
|
+
];
|
|
407
|
+
}
|
|
93
408
|
} else if (CONVERTIBLE_EXTENSIONS.has(ext)) {
|
|
94
409
|
// Convert document via markitdown
|
|
95
410
|
const result = convertWithMarkitdown(absolutePath);
|
|
@@ -150,7 +465,7 @@ Usage:
|
|
|
150
465
|
const firstLineSize = formatSize(Buffer.byteLength(allLines[startLine], "utf-8"));
|
|
151
466
|
outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(
|
|
152
467
|
DEFAULT_MAX_BYTES,
|
|
153
|
-
)} limit. Use bash: sed -n '${startLineDisplay}p' ${
|
|
468
|
+
)} limit. Use bash: sed -n '${startLineDisplay}p' ${readPath} | head -c ${DEFAULT_MAX_BYTES}]`;
|
|
154
469
|
details = { truncation };
|
|
155
470
|
} else if (truncation.truncated) {
|
|
156
471
|
// Truncation occurred - build actionable notice
|