jeo-code 0.1.0 → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +160 -0
- package/README.ko.md +160 -0
- package/README.md +115 -297
- package/README.zh.md +160 -0
- package/package.json +11 -6
- package/scripts/install.sh +28 -28
- package/scripts/uninstall.sh +17 -15
- package/src/AGENTS.md +50 -0
- package/src/agent/AGENTS.md +49 -0
- package/src/agent/bash-fixups.ts +103 -0
- package/src/agent/compaction.ts +410 -19
- package/src/agent/config-schema.ts +119 -5
- package/src/agent/context-files.ts +314 -17
- package/src/agent/dev/AGENTS.md +36 -0
- package/src/agent/dev/advanced-analyzer.ts +12 -0
- package/src/agent/dev/evolution-bridge.ts +82 -0
- package/src/agent/dev/evolution-logger.ts +41 -0
- package/src/agent/dev/self-analysis.ts +64 -0
- package/src/agent/dev/self-improve.ts +24 -0
- package/src/agent/dev/spec-automation.ts +49 -0
- package/src/agent/engine.ts +808 -54
- package/src/agent/hooks.ts +273 -0
- package/src/agent/loop.ts +21 -1
- package/src/agent/memory.ts +201 -0
- package/src/agent/model-recency.ts +32 -0
- package/src/agent/output-minimizer.ts +108 -0
- package/src/agent/output-util.ts +64 -0
- package/src/agent/plan.ts +187 -0
- package/src/agent/seed.ts +52 -0
- package/src/agent/session.ts +235 -21
- package/src/agent/state.ts +286 -39
- package/src/agent/step-budget.ts +232 -0
- package/src/agent/subagents.ts +223 -26
- package/src/agent/task-tool.ts +272 -0
- package/src/agent/todo-tool.ts +87 -0
- package/src/agent/tokenizer.ts +117 -0
- package/src/agent/tool-registry.ts +54 -0
- package/src/agent/tools.ts +624 -103
- package/src/agent/web-search.ts +538 -0
- package/src/ai/AGENTS.md +44 -0
- package/src/ai/index.ts +1 -0
- package/src/ai/model-catalog-compat.ts +3 -1
- package/src/ai/model-catalog.ts +74 -9
- package/src/ai/model-discovery.ts +215 -17
- package/src/ai/model-manager.ts +346 -32
- package/src/ai/model-picker.ts +1 -1
- package/src/ai/model-registry.ts +4 -2
- package/src/ai/pricing.ts +84 -0
- package/src/ai/provider-registry.ts +23 -0
- package/src/ai/provider-status.ts +60 -16
- package/src/ai/providers/AGENTS.md +42 -0
- package/src/ai/providers/anthropic.ts +250 -31
- package/src/ai/providers/antigravity.ts +219 -0
- package/src/ai/providers/errors.ts +15 -1
- package/src/ai/providers/gemini.ts +196 -13
- package/src/ai/providers/ollama.ts +37 -7
- package/src/ai/providers/openai-responses.ts +173 -0
- package/src/ai/providers/openai.ts +64 -12
- package/src/ai/sse.ts +4 -1
- package/src/ai/types.ts +18 -1
- package/src/auth/AGENTS.md +41 -0
- package/src/auth/callback-server.ts +6 -1
- package/src/auth/flows/AGENTS.md +32 -0
- package/src/auth/flows/antigravity.ts +151 -0
- package/src/auth/flows/google-project.ts +190 -0
- package/src/auth/flows/google.ts +39 -18
- package/src/auth/flows/index.ts +15 -5
- package/src/auth/flows/openai.ts +2 -2
- package/src/auth/oauth.ts +8 -0
- package/src/auth/refresh.ts +44 -27
- package/src/auth/storage.ts +149 -26
- package/src/auth/types.ts +1 -1
- package/src/autopilot.ts +362 -0
- package/src/bun-imports.d.ts +4 -0
- package/src/cli/AGENTS.md +39 -0
- package/src/cli/runner.ts +148 -14
- package/src/cli.ts +13 -4
- package/src/commands/AGENTS.md +40 -0
- package/src/commands/approve.ts +62 -3
- package/src/commands/auth.ts +167 -25
- package/src/commands/chat.ts +37 -8
- package/src/commands/deep-interview.ts +633 -175
- package/src/commands/doctor.ts +84 -37
- package/src/commands/evolve-core.ts +18 -0
- package/src/commands/evolve.ts +2 -1
- package/src/commands/export.ts +176 -0
- package/src/commands/gjc.ts +52 -0
- package/src/commands/launch.ts +3549 -240
- package/src/commands/mcp.ts +3 -3
- package/src/commands/ooo-seed.ts +19 -0
- package/src/commands/ralplan.ts +253 -35
- package/src/commands/resume.ts +1 -1
- package/src/commands/session.ts +183 -0
- package/src/commands/setup-helpers.ts +10 -3
- package/src/commands/setup.ts +57 -16
- package/src/commands/skills.ts +78 -18
- package/src/commands/state.ts +198 -0
- package/src/commands/status.ts +84 -0
- package/src/commands/team.ts +340 -212
- package/src/commands/ultragoal.ts +122 -61
- package/src/commands/update.ts +244 -0
- package/src/ledger.ts +270 -0
- package/src/mcp/AGENTS.md +38 -0
- package/src/mcp/server.ts +115 -14
- package/src/mcp/tools.ts +42 -22
- package/src/md-modules.d.ts +4 -0
- package/src/prompts/AGENTS.md +41 -0
- package/src/prompts/agents/AGENTS.md +35 -0
- package/src/prompts/agents/architect.md +35 -0
- package/src/prompts/agents/critic.md +37 -0
- package/src/prompts/agents/executor.md +36 -0
- package/src/prompts/agents/planner.md +37 -0
- package/src/prompts/skills/AGENTS.md +36 -0
- package/src/prompts/skills/deep-dive/AGENTS.md +31 -0
- package/src/prompts/skills/deep-dive/SKILL.md +13 -0
- package/src/prompts/skills/deep-interview/AGENTS.md +31 -0
- package/src/prompts/skills/deep-interview/SKILL.md +12 -0
- package/src/prompts/skills/gjc/AGENTS.md +31 -0
- package/src/prompts/skills/gjc/SKILL.md +15 -0
- package/src/prompts/skills/ralplan/AGENTS.md +31 -0
- package/src/prompts/skills/ralplan/SKILL.md +11 -0
- package/src/prompts/skills/team/AGENTS.md +31 -0
- package/src/prompts/skills/team/SKILL.md +11 -0
- package/src/prompts/skills/ultragoal/AGENTS.md +31 -0
- package/src/prompts/skills/ultragoal/SKILL.md +11 -0
- package/src/skills/AGENTS.md +38 -0
- package/src/skills/catalog.ts +565 -31
- package/src/tui/AGENTS.md +43 -0
- package/src/tui/app.ts +1181 -92
- package/src/tui/components/AGENTS.md +42 -0
- package/src/tui/components/ascii-art.ts +257 -15
- package/src/tui/components/autocomplete.ts +98 -16
- package/src/tui/components/autopilot-status.ts +65 -0
- package/src/tui/components/category-index.ts +49 -0
- package/src/tui/components/code-view.ts +54 -11
- package/src/tui/components/color.ts +171 -2
- package/src/tui/components/config-panel.ts +82 -15
- package/src/tui/components/duration.ts +38 -0
- package/src/tui/components/evolution.ts +3 -3
- package/src/tui/components/footer.ts +91 -42
- package/src/tui/components/forge.ts +426 -31
- package/src/tui/components/hints.ts +54 -0
- package/src/tui/components/hud.ts +73 -0
- package/src/tui/components/index.ts +4 -0
- package/src/tui/components/input-box.ts +150 -0
- package/src/tui/components/layout.ts +11 -3
- package/src/tui/components/live-model-picker.ts +108 -0
- package/src/tui/components/markdown-table.ts +140 -0
- package/src/tui/components/markdown-text.ts +97 -0
- package/src/tui/components/meter.ts +4 -1
- package/src/tui/components/model-picker.ts +3 -2
- package/src/tui/components/provider-picker.ts +3 -2
- package/src/tui/components/section.ts +70 -0
- package/src/tui/components/select-list.ts +40 -10
- package/src/tui/components/skill-picker.ts +25 -0
- package/src/tui/components/slash.ts +244 -21
- package/src/tui/components/status.ts +272 -11
- package/src/tui/components/step-timeline.ts +218 -0
- package/src/tui/components/stream.ts +26 -9
- package/src/tui/components/themes.ts +212 -6
- package/src/tui/components/todo-card.ts +47 -0
- package/src/tui/components/tool-list.ts +58 -12
- package/src/tui/components/transcript.ts +120 -0
- package/src/tui/components/update-box.ts +31 -0
- package/src/tui/components/welcome.ts +162 -0
- package/src/tui/components/width.ts +163 -0
- package/src/tui/monitoring/AGENTS.md +31 -0
- package/src/tui/monitoring/hud-view.ts +55 -0
- package/src/tui/renderer.ts +112 -3
- package/src/tui/terminal.ts +40 -33
- package/src/util/AGENTS.md +39 -0
- package/src/util/clipboard-image.ts +118 -0
- package/src/util/env.ts +12 -0
- package/src/util/provider-error.ts +78 -0
- package/src/util/retry.ts +91 -6
- package/src/util/update-check.ts +64 -0
- package/src/commands/models.ts +0 -104
package/src/agent/tools.ts
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
|
+
import { applyBashFixups } from "./bash-fixups";
|
|
1
2
|
import * as fs from "node:fs/promises";
|
|
2
3
|
import * as path from "node:path";
|
|
3
|
-
import { readWorkflowState } from "./state";
|
|
4
|
+
import { readWorkflowState, readWorkflowStateStrict, type WorkflowState } from "./state";
|
|
5
|
+
import { jeoEnv } from "../util/env";
|
|
6
|
+
|
|
7
|
+
/** Read the deep-interview lock; on corrupt state fail CLOSED (treat as active lock). */
|
|
8
|
+
async function readMutationLock(cwd: string): Promise<WorkflowState | null> {
|
|
9
|
+
try {
|
|
10
|
+
return await readWorkflowStateStrict("deep-interview", cwd);
|
|
11
|
+
} catch {
|
|
12
|
+
return { active: true, current_phase: "locked", skill: "deep-interview" };
|
|
13
|
+
}
|
|
14
|
+
}
|
|
4
15
|
|
|
5
16
|
export interface ToolResult {
|
|
6
17
|
success: boolean;
|
|
@@ -10,7 +21,7 @@ export interface ToolResult {
|
|
|
10
21
|
|
|
11
22
|
/**
|
|
12
23
|
* Directories that pollute `find`/`search` results and waste time: VCS, build
|
|
13
|
-
* artifacts, dependency trees, and
|
|
24
|
+
* artifacts, dependency trees, and jeo's own runtime dir. gjc's native search
|
|
14
25
|
* respects ignore files; this is the pure-TS equivalent.
|
|
15
26
|
*/
|
|
16
27
|
export const IGNORED_DIRS = [
|
|
@@ -20,32 +31,62 @@ export const IGNORED_DIRS = [
|
|
|
20
31
|
"build",
|
|
21
32
|
"coverage",
|
|
22
33
|
".next",
|
|
23
|
-
".
|
|
34
|
+
".jeo",
|
|
24
35
|
"vendor",
|
|
25
36
|
".cache",
|
|
26
37
|
];
|
|
27
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Read the repo-root `.gitignore` into basename dir + file-glob exclude lists, so
|
|
41
|
+
* find/search match repository intent (build artifacts, logs, .env, …) on top of
|
|
42
|
+
* IGNORED_DIRS. Conservative semantics: single-segment patterns only (entries with
|
|
43
|
+
* an internal `/` are anchored/path patterns that basename excludes can't represent),
|
|
44
|
+
* negations (`!`) and comments skipped. Absent/unreadable .gitignore → empty (no-op).
|
|
45
|
+
*/
|
|
46
|
+
export async function readGitignore(cwd: string): Promise<{ dirs: string[]; fileGlobs: string[] }> {
|
|
47
|
+
let content = "";
|
|
48
|
+
try {
|
|
49
|
+
content = await fs.readFile(path.join(cwd, ".gitignore"), "utf-8");
|
|
50
|
+
} catch {
|
|
51
|
+
return { dirs: [], fileGlobs: [] };
|
|
52
|
+
}
|
|
53
|
+
const dirs = new Set<string>();
|
|
54
|
+
const fileGlobs = new Set<string>();
|
|
55
|
+
for (const raw of content.split("\n")) {
|
|
56
|
+
const line = raw.trim();
|
|
57
|
+
if (!line || line.startsWith("#") || line.startsWith("!")) continue;
|
|
58
|
+
let p = line;
|
|
59
|
+
const dirOnly = p.endsWith("/");
|
|
60
|
+
if (dirOnly) p = p.slice(0, -1);
|
|
61
|
+
if (p.startsWith("/")) p = p.slice(1);
|
|
62
|
+
if (!p || p.includes("/")) continue; // skip anchored/multi-segment patterns
|
|
63
|
+
dirs.add(p);
|
|
64
|
+
if (!dirOnly) fileGlobs.add(p);
|
|
65
|
+
}
|
|
66
|
+
return { dirs: [...dirs], fileGlobs: [...fileGlobs] };
|
|
67
|
+
}
|
|
68
|
+
|
|
28
69
|
/**
|
|
29
70
|
* Validates if codebase mutation tools are blocked due to an active Socratic interview.
|
|
30
71
|
* Mutation is blocked only if deep-interview is active, not completed, and the file
|
|
31
|
-
* is NOT under the `.
|
|
72
|
+
* is NOT under the `.jeo/` directory (planning/spec files are allowed).
|
|
32
73
|
*/
|
|
33
74
|
export async function assertMutationAllowed(
|
|
34
75
|
filePath: string,
|
|
35
76
|
cwd: string = process.cwd()
|
|
36
77
|
): Promise<void> {
|
|
37
|
-
const deepInterviewState = await
|
|
78
|
+
const deepInterviewState = await readMutationLock(cwd);
|
|
38
79
|
if (deepInterviewState && deepInterviewState.active && deepInterviewState.current_phase !== "complete") {
|
|
39
|
-
// Check if the target is NOT inside the local .
|
|
40
|
-
// check (not bare startsWith) so siblings like ".
|
|
80
|
+
// Check if the target is NOT inside the local .jeo folder. Use a path-boundary
|
|
81
|
+
// check (not bare startsWith) so siblings like ".jeo-backup" aren't mistaken for ".jeo/".
|
|
41
82
|
const absPath = path.resolve(cwd, filePath);
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
if (!
|
|
83
|
+
const jeoDir = path.resolve(cwd, ".jeo");
|
|
84
|
+
const insideJeo = absPath === jeoDir || absPath.startsWith(jeoDir + path.sep);
|
|
85
|
+
if (!insideJeo) {
|
|
45
86
|
throw new Error(
|
|
46
|
-
`[MutationGuard Blocked] Code mutation is
|
|
47
|
-
`Current
|
|
48
|
-
`Only spec/planning writes under '.
|
|
87
|
+
`[MutationGuard Blocked] Code mutation is blocked while a Socratic interview is active (the requirements seed is not yet frozen).\n` +
|
|
88
|
+
`Current ambiguity: ${((deepInterviewState.current_ambiguity ?? 1) * 100).toFixed(0)}%. Finish the interview to freeze the seed and unlock writes — run 'jeo deep-interview'. Non-interactive '--auto' can continue clarification, but it does not bypass the ambiguity gate.\n` +
|
|
89
|
+
`Only spec/planning writes under '.jeo/' are permitted until then.`
|
|
49
90
|
);
|
|
50
91
|
}
|
|
51
92
|
}
|
|
@@ -54,46 +95,204 @@ export async function assertMutationAllowed(
|
|
|
54
95
|
export async function assertBashAllowed(
|
|
55
96
|
cwd: string = process.cwd()
|
|
56
97
|
): Promise<void> {
|
|
57
|
-
const deepInterviewState = await
|
|
98
|
+
const deepInterviewState = await readMutationLock(cwd);
|
|
58
99
|
if (deepInterviewState && deepInterviewState.active && deepInterviewState.current_phase !== "complete") {
|
|
59
100
|
throw new Error(
|
|
60
|
-
"[MutationGuard] bash is
|
|
101
|
+
"[MutationGuard] bash is blocked while a Socratic interview is active (requirements seed not frozen). Finish 'jeo deep-interview' to continue; '--auto' does not bypass the ambiguity gate."
|
|
61
102
|
);
|
|
62
103
|
}
|
|
63
104
|
}
|
|
64
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Parse a read line selector into sorted, merged, inclusive [start,end] ranges.
|
|
108
|
+
* Segments are comma-separated; each is one of: "a-b" (range), "a-" (a→EOF),
|
|
109
|
+
* "a" (single line), or "a+n" (n lines starting at a). Out-of-range starts are
|
|
110
|
+
* dropped; an explicit "a-b" with b<a is an error. Mirrors gjc's read selectors.
|
|
111
|
+
*/
|
|
112
|
+
export function parseLineSelector(spec: string | number, total: number): { ranges: [number, number][] } | { error: string } {
|
|
113
|
+
// Field crash guard: models pass `lineRange: 10` (number) or other JSON junk —
|
|
114
|
+
// `spec.split is not a function` killed the read instead of degrading politely.
|
|
115
|
+
if (typeof spec !== "string") {
|
|
116
|
+
if (typeof spec === "number" && Number.isFinite(spec)) spec = String(spec);
|
|
117
|
+
else return { error: `selector must be a string like "10-20", got ${JSON.stringify(spec)}` };
|
|
118
|
+
}
|
|
119
|
+
const segs = spec.split(",").map(s => s.trim()).filter(Boolean);
|
|
120
|
+
if (segs.length === 0) return { error: "empty selector" };
|
|
121
|
+
const ranges: [number, number][] = [];
|
|
122
|
+
for (const seg of segs) {
|
|
123
|
+
let m: RegExpMatchArray | null;
|
|
124
|
+
if ((m = seg.match(/^(\d+)\+(\d+)$/))) {
|
|
125
|
+
const start = Math.max(1, parseInt(m[1]));
|
|
126
|
+
const count = Math.max(1, parseInt(m[2]));
|
|
127
|
+
if (start <= total) ranges.push([start, Math.min(total, start + count - 1)]);
|
|
128
|
+
} else if ((m = seg.match(/^(\d+)-(\d+)$/))) {
|
|
129
|
+
const start = Math.max(1, parseInt(m[1]));
|
|
130
|
+
const end = parseInt(m[2]);
|
|
131
|
+
if (end < start) return { error: `segment '${seg}': end < start (file has ${total} lines)` };
|
|
132
|
+
if (start <= total) ranges.push([start, Math.min(total, end)]);
|
|
133
|
+
} else if ((m = seg.match(/^(\d+)-$/))) {
|
|
134
|
+
const start = Math.max(1, parseInt(m[1]));
|
|
135
|
+
if (start <= total) ranges.push([start, total]);
|
|
136
|
+
} else if ((m = seg.match(/^(\d+)$/))) {
|
|
137
|
+
const start = Math.max(1, parseInt(m[1]));
|
|
138
|
+
if (start <= total) ranges.push([start, start]);
|
|
139
|
+
} else {
|
|
140
|
+
return { error: `invalid segment '${seg}'. Use "a-b", "a-", "a", or "a+n".` };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
ranges.sort((x, y) => x[0] - y[0]);
|
|
144
|
+
const merged: [number, number][] = [];
|
|
145
|
+
for (const [s, e] of ranges) {
|
|
146
|
+
const last = merged[merged.length - 1];
|
|
147
|
+
if (last && s <= last[1] + 1) last[1] = Math.max(last[1], e);
|
|
148
|
+
else merged.push([s, e]);
|
|
149
|
+
}
|
|
150
|
+
return { ranges: merged };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── hashline-lite (plan/gjc-inheritance.md B2, gjc hashline 경량 계승) ──
|
|
154
|
+
// Every annotated read line carries a 2-char CONTENT anchor: `42ab|text`. The
|
|
155
|
+
// hash depends only on the line's content (trailing whitespace/CR ignored), so
|
|
156
|
+
// sibling edits that shift line numbers keep the anchor valid. The edit tool
|
|
157
|
+
// accepts the anchors on `≔` ranges (`≔12ab..15cd`) and verifies them before
|
|
158
|
+
// mutating — a mismatch means the model is editing content it has not seen.
|
|
159
|
+
// The anchor ALWAYS leads with a letter ([a-z]) so a directive like `≔1ab` is
|
|
160
|
+
// unambiguous: the `\d+` line number can never swallow the anchor. (A purely
|
|
161
|
+
// numeric anchor such as `68` would make `≔1`+`68` parse as line 168 with no
|
|
162
|
+
// anchor, silently skipping verification — exactly what hashline must prevent.)
|
|
163
|
+
const ANCHOR_FIRST = 26; // a-z
|
|
164
|
+
const ANCHOR_SECOND = 36; // 0-9a-z
|
|
165
|
+
export function lineAnchor(line: string): string {
|
|
166
|
+
const normalized = line.replace(/\r$/, "").replace(/[ \t]+$/, "");
|
|
167
|
+
const n = Number(BigInt(Bun.hash(normalized)) % BigInt(ANCHOR_FIRST * ANCHOR_SECOND));
|
|
168
|
+
const first = String.fromCharCode(97 + Math.floor(n / ANCHOR_SECOND)); // a-z, never a digit
|
|
169
|
+
return first + (n % ANCHOR_SECOND).toString(36); // second char 0-9a-z
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** A read-output anchor prefix at line start: `42ab|` (hashed) or legacy `42|`. */
|
|
173
|
+
const ANCHOR_PREFIX_RE = /^\d+(?:[a-z0-9]{2})?\|/;
|
|
174
|
+
|
|
175
|
+
/** Strip read-output anchor prefixes from a block IF every non-empty line carries
|
|
176
|
+
* one — the signature of content copy-pasted from read output into a SEARCH
|
|
177
|
+
* block (the dual-protocol trap: anchors are display chrome, not file bytes). */
|
|
178
|
+
function stripAnchorPrefixes(block: string): string | null {
|
|
179
|
+
const lines = block.split("\n");
|
|
180
|
+
if (!lines.some(l => l.trim() !== "")) return null;
|
|
181
|
+
if (!lines.every(l => l.trim() === "" || ANCHOR_PREFIX_RE.test(l))) return null;
|
|
182
|
+
return lines.map(l => l.replace(ANCHOR_PREFIX_RE, "")).join("\n");
|
|
183
|
+
}
|
|
184
|
+
// ── File-freshness guard (plan/gjc-inheritance.md B7, gjc edit/file-read-cache 계승) ──
|
|
185
|
+
// `read` records each file's stat fingerprint; `edit`/`write` verify it before
|
|
186
|
+
// mutating. A file changed by someone else (concurrent agent, user, formatter)
|
|
187
|
+
// between the read and the edit is REJECTED once — with the CURRENT content
|
|
188
|
+
// re-presented so the model can retry immediately (recovery, not just a guard).
|
|
189
|
+
const lastReadSnapshots = new Map<string, { mtimeMs: number; size: number }>();
|
|
190
|
+
const MAX_SNAPSHOT_ENTRIES = 64;
|
|
191
|
+
|
|
192
|
+
function recordReadSnapshot(absPath: string, st: { mtimeMs: number; size: number } | null): void {
|
|
193
|
+
if (!st) return;
|
|
194
|
+
if (lastReadSnapshots.size >= MAX_SNAPSHOT_ENTRIES && !lastReadSnapshots.has(absPath)) {
|
|
195
|
+
const oldest = lastReadSnapshots.keys().next().value;
|
|
196
|
+
if (oldest !== undefined) lastReadSnapshots.delete(oldest);
|
|
197
|
+
}
|
|
198
|
+
lastReadSnapshots.delete(absPath); // re-insert to refresh LRU order
|
|
199
|
+
lastReadSnapshots.set(absPath, { mtimeMs: st.mtimeMs, size: st.size });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Annotated excerpt of the file's CURRENT content for recovery errors (read-format `N|`). */
|
|
203
|
+
function excerptForRecovery(content: string, centerLine?: number): string {
|
|
204
|
+
const lines = content.split("\n");
|
|
205
|
+
const SPAN = 60;
|
|
206
|
+
let start = 1;
|
|
207
|
+
let end = Math.min(lines.length, 2 * SPAN);
|
|
208
|
+
if (centerLine && centerLine >= 1 && centerLine <= lines.length) {
|
|
209
|
+
start = Math.max(1, centerLine - SPAN);
|
|
210
|
+
end = Math.min(lines.length, centerLine + SPAN);
|
|
211
|
+
}
|
|
212
|
+
const body = lines.slice(start - 1, end).map((l, i) => `${start + i}${lineAnchor(l)}|${l}`).join("\n");
|
|
213
|
+
const note = lines.length > end - start + 1 ? `\n…(showing lines ${start}-${end} of ${lines.length})` : "";
|
|
214
|
+
return body + note;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Returns a recovery error when `absPath` changed since the agent last read it; null when fresh.
|
|
218
|
+
* Refreshes the snapshot to CURRENT so the immediate retry (model just saw fresh content) passes. */
|
|
219
|
+
async function staleReadError(absPath: string, filePath: string, verb: string): Promise<ToolResult | null> {
|
|
220
|
+
const snap = lastReadSnapshots.get(absPath);
|
|
221
|
+
if (!snap) return null; // never read → no guard (back-compat; read-first is advisory)
|
|
222
|
+
const st = await fs.stat(absPath).catch(() => null);
|
|
223
|
+
if (!st || !st.isFile()) return null; // deleted/replaced-by-dir: let the op surface its own error
|
|
224
|
+
if (st.mtimeMs === snap.mtimeMs && st.size === snap.size) return null;
|
|
225
|
+
const content = await fs.readFile(absPath, "utf-8").catch(() => null);
|
|
226
|
+
recordReadSnapshot(absPath, st); // the model sees the fresh content below → retry passes
|
|
227
|
+
const excerpt = content !== null ? `\nCurrent content:\n${excerptForRecovery(content)}` : "";
|
|
228
|
+
return {
|
|
229
|
+
success: false,
|
|
230
|
+
output: "",
|
|
231
|
+
error:
|
|
232
|
+
`${verb} rejected: ${filePath} changed on disk since you last read it (another agent, the user, or a formatter touched it). ` +
|
|
233
|
+
`Re-target your ${verb.toLowerCase()} against the CURRENT content below, then retry.${excerpt}`,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
65
236
|
export async function readTool(
|
|
66
237
|
filePath: string,
|
|
67
|
-
lineRange?: string,
|
|
68
|
-
cwd: string = process.cwd()
|
|
238
|
+
lineRange?: string | number,
|
|
239
|
+
cwd: string = process.cwd(),
|
|
240
|
+
raw: boolean = false
|
|
69
241
|
): Promise<ToolResult> {
|
|
70
242
|
try {
|
|
243
|
+
if (typeof filePath !== "string" || filePath.trim() === "") {
|
|
244
|
+
return { success: false, output: "", error: 'read requires a non-empty "filePath".' };
|
|
245
|
+
}
|
|
71
246
|
const absPath = path.resolve(cwd, filePath);
|
|
247
|
+
// gjc parity: reading a directory returns its listing instead of an EISDIR error.
|
|
248
|
+
const st = await fs.stat(absPath).catch(() => null);
|
|
249
|
+
if (st?.isDirectory()) {
|
|
250
|
+
if (raw || lineRange) {
|
|
251
|
+
return { success: false, output: "", error: `${filePath} is a directory — drop raw/lineRange; reading it lists entries.` };
|
|
252
|
+
}
|
|
253
|
+
return lsTool(filePath, cwd);
|
|
254
|
+
}
|
|
72
255
|
const content = await fs.readFile(absPath, "utf-8");
|
|
256
|
+
if (st?.isFile()) recordReadSnapshot(absPath, st); // arm the edit/write freshness guard
|
|
257
|
+
|
|
258
|
+
if (raw) {
|
|
259
|
+
// Verbatim bytes, no "N|" line prefixes (gjc `:raw`), char-capped for context safety.
|
|
260
|
+
const MAX_CHARS = 50_000;
|
|
261
|
+
if (content.length > MAX_CHARS) {
|
|
262
|
+
return { success: true, output: content.slice(0, MAX_CHARS) + `\n…(raw truncated at ${MAX_CHARS} of ${content.length} chars; drop raw and pass lineRange to read a slice)` };
|
|
263
|
+
}
|
|
264
|
+
return { success: true, output: content };
|
|
265
|
+
}
|
|
266
|
+
|
|
73
267
|
const lines = content.split("\n");
|
|
74
268
|
|
|
75
|
-
if (lineRange) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
269
|
+
if (lineRange !== undefined && lineRange !== null && lineRange !== "") {
|
|
270
|
+
const parsed = parseLineSelector(lineRange, lines.length);
|
|
271
|
+
if ("error" in parsed) {
|
|
272
|
+
return { success: false, output: "", error: `Invalid lineRange '${lineRange}': ${parsed.error}` };
|
|
273
|
+
}
|
|
274
|
+
if (parsed.ranges.length === 0) {
|
|
275
|
+
return { success: true, output: `(no lines in range; file has ${lines.length} lines)` };
|
|
276
|
+
}
|
|
277
|
+
const MAX_RANGE_LINES = 2000; // cap selected output so a huge `1-` can't materialize MBs
|
|
278
|
+
const out: string[] = [];
|
|
279
|
+
let emitted = 0;
|
|
280
|
+
let capped = false;
|
|
281
|
+
outer: for (const [i, [start, end]] of parsed.ranges.entries()) {
|
|
282
|
+
if (emitted >= MAX_RANGE_LINES) { capped = true; break; }
|
|
283
|
+
if (i > 0) out.push("…"); // gap marker between non-contiguous ranges
|
|
284
|
+
for (let ln = start; ln <= end; ln++) {
|
|
285
|
+
if (emitted >= MAX_RANGE_LINES) { capped = true; break outer; }
|
|
286
|
+
out.push(`${ln}${lineAnchor(lines[ln - 1] ?? "")}|${lines[ln - 1] ?? ""}`);
|
|
287
|
+
emitted++;
|
|
88
288
|
}
|
|
89
|
-
const sliced = lines.slice(start - 1, end).map((l, i) => `${start + i}|${l}`).join("\n");
|
|
90
|
-
return { success: true, output: sliced };
|
|
91
289
|
}
|
|
92
|
-
|
|
290
|
+
if (capped) out.push(`…(range truncated at ${MAX_RANGE_LINES} lines; narrow the range)`);
|
|
291
|
+
return { success: true, output: out.join("\n") };
|
|
93
292
|
}
|
|
94
293
|
|
|
95
294
|
const MAX_LINES = 500;
|
|
96
|
-
const annotated = lines.slice(0, MAX_LINES).map((l, i) => `${i + 1}|${l}`).join("\n");
|
|
295
|
+
const annotated = lines.slice(0, MAX_LINES).map((l, i) => `${i + 1}${lineAnchor(l)}|${l}`).join("\n");
|
|
97
296
|
if (lines.length > MAX_LINES) {
|
|
98
297
|
const notice = `\n…(showing lines 1-${MAX_LINES} of ${lines.length}; pass lineRange "${MAX_LINES + 1}-" to read the rest)`;
|
|
99
298
|
return { success: true, output: annotated + notice };
|
|
@@ -110,124 +309,251 @@ export async function writeTool(
|
|
|
110
309
|
cwd: string = process.cwd()
|
|
111
310
|
): Promise<ToolResult> {
|
|
112
311
|
try {
|
|
312
|
+
if (typeof filePath !== "string" || filePath.trim() === "") {
|
|
313
|
+
return { success: false, output: "", error: 'write requires a non-empty "filePath".' };
|
|
314
|
+
}
|
|
113
315
|
await assertMutationAllowed(filePath, cwd);
|
|
114
316
|
const absPath = path.resolve(cwd, filePath);
|
|
317
|
+
const stale = await staleReadError(absPath, filePath, "Write");
|
|
318
|
+
if (stale) return stale;
|
|
115
319
|
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
|
116
320
|
await fs.writeFile(absPath, content, "utf-8");
|
|
321
|
+
recordReadSnapshot(absPath, await fs.stat(absPath).catch(() => null)); // own change ≠ stale
|
|
117
322
|
return { success: true, output: `Successfully wrote ${content.length} characters to ${filePath}` };
|
|
118
323
|
} catch (err: any) {
|
|
119
324
|
return { success: false, output: "", error: err.message };
|
|
120
325
|
}
|
|
121
326
|
}
|
|
122
327
|
|
|
328
|
+
/** Strip a single leading and trailing newline (CRLF or LF) — the editBlock framing. */
|
|
329
|
+
function trimOneNewline(s: string): string {
|
|
330
|
+
if (s.startsWith("\r\n")) s = s.slice(2);
|
|
331
|
+
else if (s.startsWith("\n")) s = s.slice(1);
|
|
332
|
+
if (s.endsWith("\r\n")) s = s.slice(0, -2);
|
|
333
|
+
else if (s.endsWith("\n")) s = s.slice(0, -1);
|
|
334
|
+
return s;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Parse one or MORE `<<<<<<< SEARCH / ======= / >>>>>>>` hunks from an edit block.
|
|
339
|
+
* Returns null when there are no SEARCH markers (so the ≔ path / format error stands).
|
|
340
|
+
* Each hunk's search/replace has one framing newline trimmed (same as the legacy
|
|
341
|
+
* single-hunk path). A marker present but malformed (no `=======`/`>>>>>>>`) → null.
|
|
342
|
+
*/
|
|
343
|
+
export function parseEditHunks(block: string): { search: string; replace: string }[] | null {
|
|
344
|
+
if (!block.includes("<<<<<<< SEARCH")) return null;
|
|
345
|
+
const hunks: { search: string; replace: string }[] = [];
|
|
346
|
+
for (const seg of block.split("<<<<<<< SEARCH").slice(1)) {
|
|
347
|
+
const eq = seg.indexOf("=======");
|
|
348
|
+
if (eq === -1) return null;
|
|
349
|
+
const gt = seg.indexOf(">>>>>>>", eq);
|
|
350
|
+
if (gt === -1) return null;
|
|
351
|
+
hunks.push({ search: trimOneNewline(seg.slice(0, eq)), replace: trimOneNewline(seg.slice(eq + 7, gt)) });
|
|
352
|
+
}
|
|
353
|
+
return hunks.length ? hunks : null;
|
|
354
|
+
}
|
|
355
|
+
|
|
123
356
|
export async function editTool(
|
|
124
357
|
filePath: string,
|
|
125
358
|
editBlock: string,
|
|
126
359
|
cwd: string = process.cwd()
|
|
127
360
|
): Promise<ToolResult> {
|
|
128
361
|
try {
|
|
362
|
+
if (typeof filePath !== "string" || filePath.trim() === "") {
|
|
363
|
+
return { success: false, output: "", error: 'edit requires a non-empty "filePath".' };
|
|
364
|
+
}
|
|
365
|
+
if (typeof editBlock !== "string" || editBlock === "") {
|
|
366
|
+
return { success: false, output: "", error: 'edit requires a non-empty "editBlock" (≔ directive or <<<<<<< SEARCH block).' };
|
|
367
|
+
}
|
|
129
368
|
await assertMutationAllowed(filePath, cwd);
|
|
130
369
|
const absPath = path.resolve(cwd, filePath);
|
|
370
|
+
const stale = await staleReadError(absPath, filePath, "Edit");
|
|
371
|
+
if (stale) return stale;
|
|
131
372
|
let content = await fs.readFile(absPath, "utf-8");
|
|
132
373
|
|
|
133
374
|
// Line-anchored edit parser. Modes (payload follows the directive's newline):
|
|
134
375
|
// ≔A..B replace lines A..B ≔A replace line A
|
|
135
376
|
// ≔A+ insert AFTER line A (A=0 prepends)
|
|
136
377
|
// ≔$ append to end of file
|
|
378
|
+
// Line numbers MAY carry the 2-char content anchors from read output
|
|
379
|
+
// (`≔12ab..15cd`, `≔7xy+`) — hashline-lite verifies them against the CURRENT
|
|
380
|
+
// content before mutating, so an edit aimed at moved/changed lines is
|
|
381
|
+
// rejected with the fresh content instead of silently corrupting the file.
|
|
137
382
|
// Falls back to <<<<<<< SEARCH / ======= / >>>>>>> substring replacement.
|
|
138
383
|
const lines = content.split("\n");
|
|
139
384
|
|
|
385
|
+
/** Verify a model-supplied anchor against the current line; null = ok. */
|
|
386
|
+
const anchorMismatch = (lineNo: number, anchor: string | undefined): ToolResult | null => {
|
|
387
|
+
if (!anchor) return null; // anchors are optional — plain ≔A..B stays valid
|
|
388
|
+
const actual = lineAnchor(lines[lineNo - 1] ?? "");
|
|
389
|
+
if (actual === anchor) return null;
|
|
390
|
+
return {
|
|
391
|
+
success: false,
|
|
392
|
+
output: "",
|
|
393
|
+
error:
|
|
394
|
+
`Edit rejected: anchor mismatch at line ${lineNo} — you sent ${lineNo}${anchor} but the current line hashes to ${lineNo}${actual}. ` +
|
|
395
|
+
`The content moved or changed since you read it. Re-target against the CURRENT content below and retry.\n` +
|
|
396
|
+
`Current content:\n${excerptForRecovery(content, lineNo)}`,
|
|
397
|
+
};
|
|
398
|
+
};
|
|
399
|
+
// hashline 3-way re-map (plan/gjc-inheritance.md cycle 9): content-only
|
|
400
|
+
// hashes mean a line that sibling edits SHIFTED still carries its anchor.
|
|
401
|
+
// When a supplied anchor no longer sits at its line number, look within a
|
|
402
|
+
// ±window for the UNIQUE line carrying that anchor and relocate the edit
|
|
403
|
+
// there. Ambiguous (>1 match) or absent → null, so the caller falls back to
|
|
404
|
+
// the existing reject+re-present path rather than guessing.
|
|
405
|
+
const ANCHOR_REMAP_WINDOW = 64;
|
|
406
|
+
const locateAnchor = (anchor: string, near: number): number | null => {
|
|
407
|
+
const lo = Math.max(1, near - ANCHOR_REMAP_WINDOW);
|
|
408
|
+
const hi = Math.min(lines.length, near + ANCHOR_REMAP_WINDOW);
|
|
409
|
+
let found = -1;
|
|
410
|
+
for (let i = lo; i <= hi; i++) {
|
|
411
|
+
if (lineAnchor(lines[i - 1] ?? "") === anchor) {
|
|
412
|
+
if (found !== -1) return null; // ambiguous — refuse to guess
|
|
413
|
+
found = i;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return found === -1 ? null : found;
|
|
417
|
+
};
|
|
418
|
+
|
|
140
419
|
let updated = false;
|
|
141
420
|
if (editBlock.startsWith("≔")) {
|
|
142
421
|
const appendMatch = editBlock.match(/^≔\$\n?([\s\S]*)$/);
|
|
143
|
-
const insertMatch = editBlock.match(/^≔(\d+)
|
|
144
|
-
const replaceMatch = editBlock.match(/^≔(\d+)(?:\.\.(\d+))?\n([\s\S]*)$/);
|
|
422
|
+
const insertMatch = editBlock.match(/^≔(\d+)([a-z0-9]{2})?\+\n?([\s\S]*)$/);
|
|
423
|
+
const replaceMatch = editBlock.match(/^≔(\d+)([a-z0-9]{2})?(?:\.\.(\d+)([a-z0-9]{2})?)?\n([\s\S]*)$/);
|
|
145
424
|
if (appendMatch) {
|
|
146
425
|
const payload = appendMatch[1];
|
|
147
426
|
content = content === "" || content.endsWith("\n") ? content + payload : content + "\n" + payload;
|
|
148
427
|
updated = true;
|
|
149
428
|
} else if (insertMatch) {
|
|
150
|
-
|
|
151
|
-
const payload = insertMatch[
|
|
429
|
+
let at = parseInt(insertMatch[1]); // insert AFTER line `at`; 0 prepends
|
|
430
|
+
const payload = insertMatch[3];
|
|
152
431
|
if (at < 0 || at > lines.length) {
|
|
153
432
|
return { success: false, output: "", error: `Invalid insert position ${at}: out of bounds (file has ${lines.length} lines)` };
|
|
154
433
|
}
|
|
434
|
+
const anchor = insertMatch[2];
|
|
435
|
+
if (at >= 1 && anchor && lineAnchor(lines[at - 1] ?? "") !== anchor) {
|
|
436
|
+
const moved = locateAnchor(anchor, at); // shifted line → re-map the insert point
|
|
437
|
+
if (moved !== null) at = moved;
|
|
438
|
+
}
|
|
439
|
+
if (at >= 1) {
|
|
440
|
+
const mismatch = anchorMismatch(at, anchor);
|
|
441
|
+
if (mismatch) return mismatch;
|
|
442
|
+
}
|
|
155
443
|
lines.splice(at, 0, payload);
|
|
156
444
|
content = lines.join("\n");
|
|
157
445
|
updated = true;
|
|
158
446
|
} else if (replaceMatch) {
|
|
159
447
|
const startLine = parseInt(replaceMatch[1]);
|
|
160
|
-
const endLine = replaceMatch[
|
|
161
|
-
const payload = replaceMatch[
|
|
162
|
-
|
|
448
|
+
const endLine = replaceMatch[3] ? parseInt(replaceMatch[3]) : startLine;
|
|
449
|
+
const payload = replaceMatch[5];
|
|
450
|
+
const aStart = replaceMatch[2];
|
|
451
|
+
const aEnd = replaceMatch[4];
|
|
452
|
+
// 3-way re-map: when the anchors no longer match their line numbers,
|
|
453
|
+
// relocate the WHOLE range by one uniform delta. Both ends must agree
|
|
454
|
+
// (preserves range length + contiguity), else fall through to reject.
|
|
455
|
+
let s = startLine;
|
|
456
|
+
let e = endLine;
|
|
457
|
+
const sBad = !!aStart && lineAnchor(lines[s - 1] ?? "") !== aStart;
|
|
458
|
+
const eBad = !!aEnd && lineAnchor(lines[e - 1] ?? "") !== aEnd;
|
|
459
|
+
if (sBad || eBad) {
|
|
460
|
+
let delta: number | null = null;
|
|
461
|
+
if (sBad) {
|
|
462
|
+
const moved = locateAnchor(aStart!, s);
|
|
463
|
+
if (moved !== null) delta = moved - s;
|
|
464
|
+
}
|
|
465
|
+
if (delta === null && eBad) {
|
|
466
|
+
const moved = locateAnchor(aEnd!, e);
|
|
467
|
+
if (moved !== null) delta = moved - e;
|
|
468
|
+
}
|
|
469
|
+
if (delta !== null && delta !== 0) {
|
|
470
|
+
const ns = s + delta;
|
|
471
|
+
const ne = e + delta;
|
|
472
|
+
const okStart = !aStart || (ns >= 1 && ns <= lines.length && lineAnchor(lines[ns - 1] ?? "") === aStart);
|
|
473
|
+
const okEnd = !aEnd || (ne >= 1 && ne <= lines.length && lineAnchor(lines[ne - 1] ?? "") === aEnd);
|
|
474
|
+
if (okStart && okEnd && ns >= 1 && ne >= ns && ne <= lines.length) {
|
|
475
|
+
s = ns;
|
|
476
|
+
e = ne;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
if (s < 1 || e < s || e > lines.length) {
|
|
163
481
|
return {
|
|
164
482
|
success: false,
|
|
165
483
|
output: "",
|
|
166
484
|
error: `Invalid edit range ${startLine}..${endLine}: out of bounds or reversed (file has ${lines.length} lines)`,
|
|
167
485
|
};
|
|
168
486
|
}
|
|
169
|
-
|
|
487
|
+
const mismatch = anchorMismatch(s, aStart) ?? anchorMismatch(e, aEnd);
|
|
488
|
+
if (mismatch) return mismatch;
|
|
489
|
+
lines.splice(s - 1, e - s + 1, payload);
|
|
170
490
|
content = lines.join("\n");
|
|
171
491
|
updated = true;
|
|
172
492
|
}
|
|
173
493
|
}
|
|
174
494
|
|
|
175
495
|
if (!updated) {
|
|
176
|
-
//
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
if (
|
|
183
|
-
|
|
184
|
-
} else if (searchVal.startsWith("\n")) {
|
|
185
|
-
searchVal = searchVal.slice(1);
|
|
496
|
+
// SEARCH/REPLACE hunks — one or MORE blocks applied in order to a working copy.
|
|
497
|
+
// Atomic: if ANY hunk fails to match, nothing is written to disk.
|
|
498
|
+
const hunks = parseEditHunks(editBlock);
|
|
499
|
+
if (hunks) {
|
|
500
|
+
let working = content;
|
|
501
|
+
for (let [i, h] of hunks.entries()) {
|
|
502
|
+
if (h.search === "") {
|
|
503
|
+
return { success: false, output: "", error: `Failed to apply edit: hunk ${i + 1} has an empty SEARCH block.` };
|
|
186
504
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
505
|
+
// Anchor-strip fixup (B2 dual-protocol trap): a SEARCH block copied from
|
|
506
|
+
// read output carries `42ab|` display prefixes that are not file bytes.
|
|
507
|
+
// When the raw block misses but a fully-prefixed variant strips clean,
|
|
508
|
+
// apply the stripped hunk (replace side stripped by the same rule).
|
|
509
|
+
if (!working.includes(h.search)) {
|
|
510
|
+
const strippedSearch = stripAnchorPrefixes(h.search);
|
|
511
|
+
if (strippedSearch !== null && working.includes(strippedSearch)) {
|
|
512
|
+
h = { search: strippedSearch, replace: stripAnchorPrefixes(h.replace) ?? h.replace };
|
|
513
|
+
}
|
|
191
514
|
}
|
|
192
|
-
|
|
193
|
-
|
|
515
|
+
if (working.includes(h.search)) {
|
|
516
|
+
// Function replacer: bypasses String.replace's `$`-pattern substitution
|
|
517
|
+
// ($&, $`, $', $$) so a replacement containing literal `$` (Makefiles,
|
|
518
|
+
// shell `$'…'`, regex literals) is inserted verbatim, not corrupted.
|
|
519
|
+
working = working.replace(h.search, () => h.replace);
|
|
520
|
+
} else {
|
|
521
|
+
// Near-miss diagnostics so the model can self-correct instead of blindly retrying.
|
|
522
|
+
const firstLine = h.search.split("\n")[0] ?? "";
|
|
523
|
+
const trimmedHit = working.replace(/[ \t]+$/gm, "").includes(h.search.trim())
|
|
524
|
+
? " A whitespace-trimmed version DOES match — fix leading/trailing spaces or indentation."
|
|
525
|
+
: "";
|
|
526
|
+
const anchorHit = !trimmedHit && firstLine.trim() && working.includes(firstLine)
|
|
527
|
+
? " The first search line IS present, so the mismatch is below it."
|
|
528
|
+
: "";
|
|
529
|
+
const which = hunks.length > 1 ? ` (hunk ${i + 1}/${hunks.length})` : "";
|
|
530
|
+
// gjc-style recovery (plan/gjc-inheritance.md B3.5): re-present the CURRENT
|
|
531
|
+
// content around the best anchor so the failed edit costs ONE retry, not a
|
|
532
|
+
// separate read round-trip — failed edits are the #1 step-budget waste.
|
|
533
|
+
const anchorIdx = firstLine.trim() ? working.split("\n").findIndex(l => l.includes(firstLine.trim())) : -1;
|
|
534
|
+
const excerpt = `\nCurrent content near the target:\n${excerptForRecovery(working, anchorIdx >= 0 ? anchorIdx + 1 : undefined)}`;
|
|
194
535
|
return {
|
|
195
536
|
success: false,
|
|
196
537
|
output: "",
|
|
197
|
-
error:
|
|
538
|
+
error: `Failed to apply edit: Search block not found in file${which}.${trimmedHit}${anchorHit} Re-target against the content below and retry.${excerpt}`,
|
|
198
539
|
};
|
|
199
540
|
}
|
|
200
|
-
|
|
201
|
-
const replaceParts = parts[1].split(">>>>>>>");
|
|
202
|
-
if (replaceParts.length > 0) {
|
|
203
|
-
let replaceVal = replaceParts[0];
|
|
204
|
-
if (replaceVal.startsWith("\r\n")) {
|
|
205
|
-
replaceVal = replaceVal.slice(2);
|
|
206
|
-
} else if (replaceVal.startsWith("\n")) {
|
|
207
|
-
replaceVal = replaceVal.slice(1);
|
|
208
|
-
}
|
|
209
|
-
if (replaceVal.endsWith("\r\n")) {
|
|
210
|
-
replaceVal = replaceVal.slice(0, -2);
|
|
211
|
-
} else if (replaceVal.endsWith("\n")) {
|
|
212
|
-
replaceVal = replaceVal.slice(0, -1);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
if (content.includes(searchVal)) {
|
|
216
|
-
content = content.replace(searchVal, replaceVal);
|
|
217
|
-
updated = true;
|
|
218
|
-
} else {
|
|
219
|
-
return {
|
|
220
|
-
success: false,
|
|
221
|
-
output: "",
|
|
222
|
-
error: "Failed to apply edit: Search block not found in file.",
|
|
223
|
-
};
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
541
|
}
|
|
542
|
+
content = working;
|
|
543
|
+
updated = true;
|
|
227
544
|
}
|
|
228
545
|
}
|
|
229
546
|
|
|
230
547
|
if (!updated) {
|
|
548
|
+
// A SEARCH marker present but unparsed means the divider/terminator is missing —
|
|
549
|
+
// point the model at the marker rather than at the unrelated ≔ syntax.
|
|
550
|
+
if (editBlock.includes("<<<<<<< SEARCH")) {
|
|
551
|
+
return {
|
|
552
|
+
success: false,
|
|
553
|
+
output: "",
|
|
554
|
+
error: "Failed to apply edit: unterminated SEARCH block — each hunk needs '<<<<<<< SEARCH', a '=======' divider, and a '>>>>>>>' terminator.",
|
|
555
|
+
};
|
|
556
|
+
}
|
|
231
557
|
return {
|
|
232
558
|
success: false,
|
|
233
559
|
output: "",
|
|
@@ -236,6 +562,7 @@ export async function editTool(
|
|
|
236
562
|
}
|
|
237
563
|
|
|
238
564
|
await fs.writeFile(absPath, content, "utf-8");
|
|
565
|
+
recordReadSnapshot(absPath, await fs.stat(absPath).catch(() => null)); // own change ≠ stale
|
|
239
566
|
return { success: true, output: `Successfully updated ${filePath}` };
|
|
240
567
|
} catch (err: any) {
|
|
241
568
|
return { success: false, output: "", error: err.message };
|
|
@@ -245,15 +572,30 @@ export async function editTool(
|
|
|
245
572
|
export async function bashTool(
|
|
246
573
|
command: string,
|
|
247
574
|
cwd: string = process.cwd(),
|
|
248
|
-
timeoutMs: number = 120_000
|
|
575
|
+
timeoutMs: number = 120_000,
|
|
576
|
+
subdir?: string,
|
|
577
|
+
env?: Record<string, string>
|
|
249
578
|
): Promise<ToolResult> {
|
|
579
|
+
if (jeoEnv("BASH_FIXUPS") === "1") {
|
|
580
|
+
const fx = applyBashFixups(command);
|
|
581
|
+
command = fx.command;
|
|
582
|
+
}
|
|
250
583
|
try {
|
|
584
|
+
// The mutation lock is keyed on the PROJECT cwd, not the run subdir.
|
|
251
585
|
await assertBashAllowed(cwd);
|
|
586
|
+
const runCwd = subdir ? path.resolve(cwd, subdir) : cwd;
|
|
587
|
+
// Sanitize caller env: keep only string values (a model may send numbers/arrays),
|
|
588
|
+
// so a bad value can't make Bun.spawn throw cryptically.
|
|
589
|
+
const safeEnv = env && !Array.isArray(env)
|
|
590
|
+
? Object.fromEntries(Object.entries(env).filter(([, v]) => typeof v === "string")) as Record<string, string>
|
|
591
|
+
: undefined;
|
|
252
592
|
// Run the command using Bun's native spawn
|
|
253
593
|
const proc = Bun.spawn(["bash", "-c", command], {
|
|
254
|
-
cwd,
|
|
594
|
+
cwd: runCwd,
|
|
255
595
|
stdout: "pipe",
|
|
256
596
|
stderr: "pipe",
|
|
597
|
+
// Inherit the parent env; merge caller-supplied (sanitized) vars on top.
|
|
598
|
+
...(safeEnv && Object.keys(safeEnv).length ? { env: { ...process.env, ...safeEnv } } : {}),
|
|
257
599
|
});
|
|
258
600
|
|
|
259
601
|
let timedOut = false;
|
|
@@ -297,22 +639,85 @@ export async function bashTool(
|
|
|
297
639
|
}
|
|
298
640
|
}
|
|
299
641
|
|
|
642
|
+
/** Spawn a command, capture output, and escalate SIGTERM→SIGKILL if it exceeds
|
|
643
|
+
* timeoutMs — so a runaway grep/find over a huge tree can't block the whole turn. */
|
|
644
|
+
async function spawnTextWithTimeout(
|
|
645
|
+
cmd: string[],
|
|
646
|
+
cwd: string,
|
|
647
|
+
timeoutMs = 60_000,
|
|
648
|
+
): Promise<{ stdout: string; stderr: string; exitCode: number | null; timedOut: boolean }> {
|
|
649
|
+
const proc = Bun.spawn(cmd, { cwd, stdout: "pipe", stderr: "pipe" });
|
|
650
|
+
let timedOut = false;
|
|
651
|
+
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
|
652
|
+
const timer = setTimeout(() => {
|
|
653
|
+
timedOut = true;
|
|
654
|
+
try { proc.kill(); } catch {}
|
|
655
|
+
killTimer = setTimeout(() => { try { proc.kill(9); } catch {} }, 3_000);
|
|
656
|
+
}, timeoutMs);
|
|
657
|
+
try {
|
|
658
|
+
await proc.exited;
|
|
659
|
+
} finally {
|
|
660
|
+
clearTimeout(timer);
|
|
661
|
+
if (killTimer) clearTimeout(killTimer);
|
|
662
|
+
}
|
|
663
|
+
const stdout = await new Response(proc.stdout).text();
|
|
664
|
+
const stderr = await new Response(proc.stderr).text();
|
|
665
|
+
return { stdout, stderr, exitCode: proc.exitCode, timedOut };
|
|
666
|
+
}
|
|
667
|
+
|
|
300
668
|
export async function findTool(
|
|
301
669
|
globPattern: string,
|
|
302
670
|
cwd: string = process.cwd()
|
|
303
671
|
): Promise<ToolResult> {
|
|
672
|
+
// Guard loose model input: a missing/empty pattern (model called find with no
|
|
673
|
+
// globPattern) must be a soft tool error, not an uncaught `globPattern.includes`
|
|
674
|
+
// crash that aborts the whole turn.
|
|
675
|
+
if (typeof globPattern !== "string" || globPattern.trim() === "") {
|
|
676
|
+
return { success: false, output: "", error: 'find requires a non-empty "globPattern", e.g. "src/**/*.ts" or "*.ts".' };
|
|
677
|
+
}
|
|
678
|
+
// Bare-name patterns (no path separator, no `**`) → recursive basename match via
|
|
679
|
+
// `find -name`, preserving the "find files by name" contract and the expectation that
|
|
680
|
+
// `*.ts` matches at any depth. Patterns with a `/` or `**` are real PATH globs
|
|
681
|
+
// (`src/**/*.ts`, `src/agent/*.ts`, an exact relative path) which `find -name` can NEVER
|
|
682
|
+
// match (it only sees basenames) — route those through Bun.Glob for correct semantics.
|
|
683
|
+
if (globPattern.includes("/") || globPattern.includes("**")) {
|
|
684
|
+
try {
|
|
685
|
+
const gi = await readGitignore(cwd);
|
|
686
|
+
const prunedDirs = new Set([...IGNORED_DIRS, ...gi.dirs]);
|
|
687
|
+
const fileGlobs = gi.fileGlobs.map(g => new Bun.Glob(g));
|
|
688
|
+
const matches: string[] = [];
|
|
689
|
+
for await (const rel of new Bun.Glob(globPattern).scan({ cwd, onlyFiles: true })) {
|
|
690
|
+
const segs = rel.split("/");
|
|
691
|
+
if (segs.some(seg => prunedDirs.has(seg))) continue;
|
|
692
|
+
const base = segs[segs.length - 1] ?? rel;
|
|
693
|
+
if (fileGlobs.some(g => g.match(base))) continue;
|
|
694
|
+
matches.push(`./${rel}`);
|
|
695
|
+
if (matches.length >= 5000) break;
|
|
696
|
+
}
|
|
697
|
+
matches.sort();
|
|
698
|
+
let output = matches.length ? matches.join("\n") : "No matching files found.";
|
|
699
|
+
const MAX_OUTPUT = 100_000;
|
|
700
|
+
if (output.length > MAX_OUTPUT) {
|
|
701
|
+
output = output.slice(0, MAX_OUTPUT) + "\n…(output truncated at 100000 chars)";
|
|
702
|
+
}
|
|
703
|
+
return { success: true, output };
|
|
704
|
+
} catch (err: any) {
|
|
705
|
+
return { success: false, output: "", error: err.message };
|
|
706
|
+
}
|
|
707
|
+
}
|
|
304
708
|
try {
|
|
709
|
+
const gi = await readGitignore(cwd);
|
|
710
|
+
const pruneNames = [...IGNORED_DIRS, ...gi.dirs];
|
|
305
711
|
const pruneGroup: string[] = [];
|
|
306
|
-
for (let i = 0; i <
|
|
712
|
+
for (let i = 0; i < pruneNames.length; i++) {
|
|
307
713
|
if (i > 0) pruneGroup.push("-o");
|
|
308
|
-
pruneGroup.push("-name",
|
|
714
|
+
pruneGroup.push("-name", pruneNames[i]!);
|
|
309
715
|
}
|
|
310
|
-
const
|
|
716
|
+
const { stdout, timedOut } = await spawnTextWithTimeout(
|
|
311
717
|
["find", ".", "-type", "d", "(", ...pruneGroup, ")", "-prune", "-o", "-name", globPattern, "-print"],
|
|
312
|
-
|
|
718
|
+
cwd,
|
|
313
719
|
);
|
|
314
|
-
|
|
315
|
-
const stdout = await new Response(proc.stdout).text();
|
|
720
|
+
if (timedOut) return { success: false, output: "", error: "find timed out (60s) — narrow the pattern." };
|
|
316
721
|
const files = stdout.split("\n").filter(Boolean);
|
|
317
722
|
let output = files.length > 0 ? files.join("\n") : "No matching files found.";
|
|
318
723
|
const MAX_OUTPUT = 100_000;
|
|
@@ -325,23 +730,51 @@ export async function findTool(
|
|
|
325
730
|
}
|
|
326
731
|
}
|
|
327
732
|
|
|
733
|
+
export interface SearchOptions {
|
|
734
|
+
/** Lines of context before/after each match (grep -B/-A). */
|
|
735
|
+
before?: number;
|
|
736
|
+
after?: number;
|
|
737
|
+
/** Symmetric context (grep -C); overrides before/after when set. */
|
|
738
|
+
context?: number;
|
|
739
|
+
/** Stop after this many matches per file (grep -m). */
|
|
740
|
+
maxMatches?: number;
|
|
741
|
+
}
|
|
742
|
+
|
|
328
743
|
export async function searchTool(
|
|
329
744
|
pattern: string,
|
|
330
745
|
globPattern: string = "*",
|
|
331
|
-
cwd: string = process.cwd()
|
|
746
|
+
cwd: string = process.cwd(),
|
|
747
|
+
ignoreCase: boolean = false,
|
|
748
|
+
opts: SearchOptions = {},
|
|
332
749
|
): Promise<ToolResult> {
|
|
750
|
+
if (typeof pattern !== "string" || pattern === "") {
|
|
751
|
+
return { success: false, output: "", error: 'search requires a non-empty "pattern" (a regex/string to grep for).' };
|
|
752
|
+
}
|
|
333
753
|
try {
|
|
334
|
-
const
|
|
335
|
-
const
|
|
336
|
-
|
|
337
|
-
|
|
754
|
+
const flags = ignoreCase ? "-rnIi" : "-rnI";
|
|
755
|
+
const gi = await readGitignore(cwd);
|
|
756
|
+
const excludes = [
|
|
757
|
+
...[...IGNORED_DIRS, ...gi.dirs].map(d => `--exclude-dir=${d}`),
|
|
758
|
+
...gi.fileGlobs.map(f => `--exclude=${f}`),
|
|
759
|
+
];
|
|
760
|
+
const n = (v: unknown): number | undefined =>
|
|
761
|
+
typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.floor(v) : undefined;
|
|
762
|
+
const ctx: string[] = [];
|
|
763
|
+
const C = n(opts.context), B = n(opts.before), A = n(opts.after), M = n(opts.maxMatches);
|
|
764
|
+
if (C !== undefined) ctx.push("-C", String(C));
|
|
765
|
+
else {
|
|
766
|
+
if (B !== undefined) ctx.push("-B", String(B));
|
|
767
|
+
if (A !== undefined) ctx.push("-A", String(A));
|
|
768
|
+
}
|
|
769
|
+
if (M !== undefined) ctx.push("-m", String(M));
|
|
770
|
+
const { stdout, stderr, exitCode, timedOut } = await spawnTextWithTimeout(
|
|
771
|
+
["grep", flags, ...ctx, "--include", globPattern, ...excludes, "--", pattern, "."],
|
|
772
|
+
cwd,
|
|
338
773
|
);
|
|
339
|
-
|
|
340
|
-
const stdout = await new Response(proc.stdout).text();
|
|
341
|
-
const stderr = await new Response(proc.stderr).text();
|
|
774
|
+
if (timedOut) return { success: false, output: "", error: "search timed out (60s) — narrow the pattern or glob." };
|
|
342
775
|
// grep exit codes: 0 = match, 1 = no match (not an error), >=2 = a real error.
|
|
343
|
-
if (
|
|
344
|
-
return { success: false, output: stdout, error: stderr.trim() || `grep failed (exit ${
|
|
776
|
+
if (exitCode !== null && exitCode >= 2) {
|
|
777
|
+
return { success: false, output: stdout, error: stderr.trim() || `grep failed (exit ${exitCode})` };
|
|
345
778
|
}
|
|
346
779
|
let output = stdout || "No matches found.";
|
|
347
780
|
const MAX_OUTPUT = 100_000;
|
|
@@ -353,3 +786,91 @@ export async function searchTool(
|
|
|
353
786
|
return { success: false, output: "", error: err.message };
|
|
354
787
|
}
|
|
355
788
|
}
|
|
789
|
+
/**
|
|
790
|
+
* List a single directory's entries (read-only): directories first (with a
|
|
791
|
+
* trailing `/`), then files, alphabetically. Hidden entries are included. This
|
|
792
|
+
* gives the model gjc-style directory inspection without shelling out to `ls`.
|
|
793
|
+
*/
|
|
794
|
+
export async function lsTool(
|
|
795
|
+
dirPath: string = ".",
|
|
796
|
+
cwd: string = process.cwd()
|
|
797
|
+
): Promise<ToolResult> {
|
|
798
|
+
try {
|
|
799
|
+
const abs = path.resolve(cwd, dirPath);
|
|
800
|
+
const stat = await fs.stat(abs);
|
|
801
|
+
if (!stat.isDirectory()) {
|
|
802
|
+
return { success: false, output: "", error: `Not a directory: ${dirPath} (use read for files).` };
|
|
803
|
+
}
|
|
804
|
+
const entries = await fs.readdir(abs, { withFileTypes: true });
|
|
805
|
+
if (entries.length === 0) return { success: true, output: "(empty directory)" };
|
|
806
|
+
const sorted = entries.sort((a, b) =>
|
|
807
|
+
a.isDirectory() !== b.isDirectory() ? (a.isDirectory() ? -1 : 1) : a.name.localeCompare(b.name),
|
|
808
|
+
);
|
|
809
|
+
const out = sorted.map(e => (e.isDirectory() ? `${e.name}/` : e.name)).join("\n");
|
|
810
|
+
return { success: true, output: out };
|
|
811
|
+
} catch (err: any) {
|
|
812
|
+
return { success: false, output: "", error: err.message };
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Create a directory (and any missing parents). Idempotent: an already-existing
|
|
818
|
+
* directory is a success, not an error — the model should not need to branch on
|
|
819
|
+
* existence. Respects the deep-interview mutation lock like write/edit.
|
|
820
|
+
*/
|
|
821
|
+
export async function mkdirTool(
|
|
822
|
+
dirPath: string,
|
|
823
|
+
cwd: string = process.cwd()
|
|
824
|
+
): Promise<ToolResult> {
|
|
825
|
+
try {
|
|
826
|
+
if (typeof dirPath !== "string" || dirPath.trim() === "") {
|
|
827
|
+
return { success: false, output: "", error: 'mkdir requires a non-empty "dirPath".' };
|
|
828
|
+
}
|
|
829
|
+
await assertMutationAllowed(dirPath, cwd);
|
|
830
|
+
const abs = path.resolve(cwd, dirPath);
|
|
831
|
+
const existing = await fs.stat(abs).catch(() => null);
|
|
832
|
+
if (existing && !existing.isDirectory()) {
|
|
833
|
+
return { success: false, output: "", error: `Path exists and is not a directory: ${dirPath}` };
|
|
834
|
+
}
|
|
835
|
+
await fs.mkdir(abs, { recursive: true });
|
|
836
|
+
return { success: true, output: `Directory ready: ${dirPath}` };
|
|
837
|
+
} catch (err: any) {
|
|
838
|
+
return { success: false, output: "", error: err.message };
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Delete a file or directory. A directory requires `recursive: true` so a stray
|
|
844
|
+
* call cannot wipe a populated tree by accident. Missing paths are a soft error
|
|
845
|
+
* (nothing to delete) rather than a crash. Respects the mutation lock like
|
|
846
|
+
* write/edit; the file-freshness snapshot is cleared so a later write to the
|
|
847
|
+
* same path is not rejected as stale.
|
|
848
|
+
*/
|
|
849
|
+
export async function deleteTool(
|
|
850
|
+
targetPath: string,
|
|
851
|
+
cwd: string = process.cwd(),
|
|
852
|
+
recursive: boolean = false
|
|
853
|
+
): Promise<ToolResult> {
|
|
854
|
+
try {
|
|
855
|
+
if (typeof targetPath !== "string" || targetPath.trim() === "") {
|
|
856
|
+
return { success: false, output: "", error: 'delete requires a non-empty "path".' };
|
|
857
|
+
}
|
|
858
|
+
await assertMutationAllowed(targetPath, cwd);
|
|
859
|
+
const abs = path.resolve(cwd, targetPath);
|
|
860
|
+
if (abs === path.resolve(cwd)) {
|
|
861
|
+
return { success: false, output: "", error: "Refusing to delete the working directory itself." };
|
|
862
|
+
}
|
|
863
|
+
const st = await fs.stat(abs).catch(() => null);
|
|
864
|
+
if (!st) {
|
|
865
|
+
return { success: false, output: "", error: `Nothing to delete: ${targetPath} (does not exist).` };
|
|
866
|
+
}
|
|
867
|
+
if (st.isDirectory() && !recursive) {
|
|
868
|
+
return { success: false, output: "", error: `${targetPath} is a directory — pass recursive:true to remove it and its contents.` };
|
|
869
|
+
}
|
|
870
|
+
await fs.rm(abs, { recursive, force: false });
|
|
871
|
+
lastReadSnapshots.delete(abs); // a future write to this path must not be flagged stale
|
|
872
|
+
return { success: true, output: `Deleted ${st.isDirectory() ? "directory" : "file"}: ${targetPath}` };
|
|
873
|
+
} catch (err: any) {
|
|
874
|
+
return { success: false, output: "", error: err.message };
|
|
875
|
+
}
|
|
876
|
+
}
|