@oh-my-pi/pi-coding-agent 15.5.3 → 15.5.6
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 +55 -0
- package/dist/types/config/settings-schema.d.ts +27 -0
- package/dist/types/config.d.ts +31 -5
- package/dist/types/edit/file-snapshot-store.d.ts +18 -0
- package/dist/types/edit/hashline/diff.d.ts +35 -0
- package/dist/types/edit/hashline/execute.d.ts +28 -0
- package/dist/types/edit/hashline/filesystem.d.ts +57 -0
- package/dist/types/edit/hashline/index.d.ts +4 -0
- package/dist/types/edit/hashline/params.d.ts +11 -0
- package/dist/types/edit/index.d.ts +4 -3
- package/dist/types/edit/normalize.d.ts +4 -16
- package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +23 -0
- package/dist/types/index.d.ts +0 -1
- package/dist/types/tools/fetch.d.ts +3 -0
- package/dist/types/tools/find.d.ts +7 -0
- package/dist/types/tools/index.d.ts +6 -5
- package/dist/types/tools/path-utils.d.ts +18 -0
- package/dist/types/utils/changelog.d.ts +8 -3
- package/package.json +8 -15
- package/scripts/build-binary.ts +11 -0
- package/src/config/settings-schema.ts +32 -0
- package/src/config.ts +42 -15
- package/src/edit/diff.ts +5 -3
- package/src/edit/file-snapshot-store.ts +22 -0
- package/src/edit/hashline/diff.ts +95 -0
- package/src/edit/hashline/execute.ts +181 -0
- package/src/edit/hashline/filesystem.ts +129 -0
- package/src/edit/hashline/index.ts +4 -0
- package/src/edit/hashline/params.ts +18 -0
- package/src/edit/index.ts +16 -27
- package/src/edit/normalize.ts +11 -41
- package/src/edit/renderer.ts +15 -8
- package/src/edit/streaming.ts +20 -134
- package/src/extensibility/legacy-pi-ai-shim.ts +24 -0
- package/src/extensibility/plugins/legacy-pi-compat.ts +47 -3
- package/src/index.ts +0 -1
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/main.ts +2 -1
- package/src/modes/rpc/rpc-client.ts +3 -1
- package/src/prompts/tools/find.md +3 -2
- package/src/sdk.ts +8 -1
- package/src/session/agent-session.ts +18 -2
- package/src/tools/ast-edit.ts +1 -1
- package/src/tools/ast-grep.ts +3 -3
- package/src/tools/fetch.ts +93 -50
- package/src/tools/find.ts +38 -6
- package/src/tools/index.ts +6 -5
- package/src/tools/path-utils.ts +81 -0
- package/src/tools/read.ts +71 -75
- package/src/tools/search.ts +136 -17
- package/src/tools/write.ts +3 -3
- package/src/utils/changelog.ts +11 -3
- package/src/utils/file-mentions.ts +1 -1
- package/dist/types/edit/file-read-cache.d.ts +0 -36
- package/dist/types/hashline/anchors.d.ts +0 -26
- package/dist/types/hashline/apply.d.ts +0 -14
- package/dist/types/hashline/constants.d.ts +0 -48
- package/dist/types/hashline/diff-preview.d.ts +0 -2
- package/dist/types/hashline/diff.d.ts +0 -16
- package/dist/types/hashline/execute.d.ts +0 -4
- package/dist/types/hashline/executor.d.ts +0 -56
- package/dist/types/hashline/hash.d.ts +0 -76
- package/dist/types/hashline/index.d.ts +0 -14
- package/dist/types/hashline/input.d.ts +0 -4
- package/dist/types/hashline/prefixes.d.ts +0 -7
- package/dist/types/hashline/recovery.d.ts +0 -21
- package/dist/types/hashline/stream.d.ts +0 -2
- package/dist/types/hashline/tokenizer.d.ts +0 -94
- package/dist/types/hashline/types.d.ts +0 -75
- package/src/edit/file-read-cache.ts +0 -138
- package/src/hashline/anchors.ts +0 -104
- package/src/hashline/apply.ts +0 -790
- package/src/hashline/bigrams.json +0 -649
- package/src/hashline/constants.ts +0 -60
- package/src/hashline/diff-preview.ts +0 -42
- package/src/hashline/diff.ts +0 -82
- package/src/hashline/execute.ts +0 -334
- package/src/hashline/executor.ts +0 -347
- package/src/hashline/grammar.lark +0 -22
- package/src/hashline/hash.ts +0 -131
- package/src/hashline/index.ts +0 -14
- package/src/hashline/input.ts +0 -137
- package/src/hashline/prefixes.ts +0 -111
- package/src/hashline/recovery.ts +0 -139
- package/src/hashline/stream.ts +0 -123
- package/src/hashline/tokenizer.ts +0 -473
- package/src/hashline/types.ts +0 -66
- package/src/prompts/tools/hashline.md +0 -83
package/src/config.ts
CHANGED
|
@@ -18,30 +18,57 @@ const priorityList = [
|
|
|
18
18
|
// =============================================================================
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
21
|
+
* Walk up from `startDir` looking for a `package.json`. Returns the directory
|
|
22
|
+
* containing the marker, or `undefined` when the walk hits the filesystem root
|
|
23
|
+
* without finding one.
|
|
24
|
+
*
|
|
25
|
+
* Exported for unit-testing the resolution contract from arbitrary start
|
|
26
|
+
* directories (notably the `bun --compile` case where `import.meta.dir`
|
|
27
|
+
* resolves to `/$bunfs/root` and no owning package is locatable — issue
|
|
28
|
+
* #1423). Production callers should use {@link getPackageDir} instead.
|
|
23
29
|
*/
|
|
24
|
-
export function
|
|
25
|
-
|
|
26
|
-
const envDir = process.env.PI_PACKAGE_DIR;
|
|
27
|
-
if (envDir) {
|
|
28
|
-
return expandTilde(envDir);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
let dir = import.meta.dir;
|
|
30
|
+
export function walkUpForPackageDir(startDir: string): string | undefined {
|
|
31
|
+
let dir = startDir;
|
|
32
32
|
while (dir !== path.dirname(dir)) {
|
|
33
33
|
if (fs.existsSync(path.join(dir, "package.json"))) {
|
|
34
34
|
return dir;
|
|
35
35
|
}
|
|
36
36
|
dir = path.dirname(dir);
|
|
37
37
|
}
|
|
38
|
-
|
|
39
|
-
return getProjectDir();
|
|
38
|
+
return undefined;
|
|
40
39
|
}
|
|
41
40
|
|
|
42
|
-
/**
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Get the base directory for resolving optional package assets (docs, examples, CHANGELOG.md).
|
|
43
|
+
*
|
|
44
|
+
* Honors the `PI_PACKAGE_DIR` override (useful for Nix/Guix store paths);
|
|
45
|
+
* otherwise walks up from `import.meta.dir` looking for a `package.json`.
|
|
46
|
+
* Returns `undefined` when no owning package is locatable — notably inside
|
|
47
|
+
* `bun --compile` binaries where `import.meta.dir` resolves to `/$bunfs/root`
|
|
48
|
+
* and the walk hits the filesystem root with nothing found.
|
|
49
|
+
*
|
|
50
|
+
* Callers MUST treat `undefined` as "no package assets available" and skip the
|
|
51
|
+
* lookup. NEVER fall back to the user's `cwd` here: that conflates the host
|
|
52
|
+
* project with omp's own assets and was the source of issue #1423 (the host
|
|
53
|
+
* project's `CHANGELOG.md` rendered as omp's startup changelog).
|
|
54
|
+
*/
|
|
55
|
+
export function getPackageDir(): string | undefined {
|
|
56
|
+
const envDir = process.env.PI_PACKAGE_DIR;
|
|
57
|
+
if (envDir) {
|
|
58
|
+
return expandTilde(envDir);
|
|
59
|
+
}
|
|
60
|
+
return walkUpForPackageDir(import.meta.dir);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Path to omp's own `CHANGELOG.md`, or `undefined` when the package directory
|
|
65
|
+
* cannot be resolved (e.g. inside `bun --compile` binaries that don't bundle
|
|
66
|
+
* package assets). Callers MUST skip changelog parsing when this is undefined;
|
|
67
|
+
* see issue #1423.
|
|
68
|
+
*/
|
|
69
|
+
export function getChangelogPath(): string | undefined {
|
|
70
|
+
const packageDir = getPackageDir();
|
|
71
|
+
return packageDir ? path.resolve(packageDir, "CHANGELOG.md") : undefined;
|
|
45
72
|
}
|
|
46
73
|
|
|
47
74
|
// =============================================================================
|
package/src/edit/diff.ts
CHANGED
|
@@ -58,7 +58,7 @@ function formatNumberedDiffLine(prefix: "+" | "-" | " ", lineNum: number, conten
|
|
|
58
58
|
* Generate a unified diff string with line numbers and context.
|
|
59
59
|
* Returns both the diff string and the first changed line number (in the new file).
|
|
60
60
|
*/
|
|
61
|
-
export function generateDiffString(oldContent: string, newContent: string, contextLines =
|
|
61
|
+
export function generateDiffString(oldContent: string, newContent: string, contextLines = 2): DiffResult {
|
|
62
62
|
const parts = Diff.diffLines(oldContent, newContent);
|
|
63
63
|
const output: string[] = [];
|
|
64
64
|
|
|
@@ -119,8 +119,9 @@ export function generateDiffString(oldContent: string, newContent: string, conte
|
|
|
119
119
|
linesToShow = raw.slice(0, contextLimit);
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
// Leading-skip placeholder is omitted: the first emitted line's
|
|
123
|
+
// number already conveys that earlier lines were trimmed.
|
|
122
124
|
if (leadingSkip > 0) {
|
|
123
|
-
output.push(formatNumberedDiffLine(" ", oldLineNum, "..."));
|
|
124
125
|
oldLineNum += leadingSkip;
|
|
125
126
|
newLineNum += leadingSkip;
|
|
126
127
|
}
|
|
@@ -143,8 +144,9 @@ export function generateDiffString(oldContent: string, newContent: string, conte
|
|
|
143
144
|
}
|
|
144
145
|
}
|
|
145
146
|
|
|
147
|
+
// Trailing-skip placeholder is omitted for the same reason: the
|
|
148
|
+
// final emitted line's number tells the reader the file continues.
|
|
146
149
|
if (trailingSkip > 0) {
|
|
147
|
-
output.push(formatNumberedDiffLine(" ", oldLineNum, "..."));
|
|
148
150
|
oldLineNum += trailingSkip;
|
|
149
151
|
newLineNum += trailingSkip;
|
|
150
152
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-bound file snapshot store.
|
|
3
|
+
*
|
|
4
|
+
* Used by `read` and `search` to record exactly what the model saw, and by
|
|
5
|
+
* the hashline patcher to recover from stale section hashes (file changed
|
|
6
|
+
* externally between read and edit, or a prior in-session edit advanced
|
|
7
|
+
* the hash). The store is the {@link InMemorySnapshotStore} implementation
|
|
8
|
+
* from `@oh-my-pi/hashline`; the only coding-agent-specific concern here
|
|
9
|
+
* is wiring it onto the per-session {@link ToolSession} object.
|
|
10
|
+
*/
|
|
11
|
+
import { InMemorySnapshotStore } from "@oh-my-pi/hashline";
|
|
12
|
+
import type { ToolSession } from "../tools";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Look up (or lazily create) the file snapshot store attached to a session.
|
|
16
|
+
* Storage lives on `session.fileSnapshotStore` so it ages out exactly with
|
|
17
|
+
* the session itself.
|
|
18
|
+
*/
|
|
19
|
+
export function getFileSnapshotStore(session: ToolSession): InMemorySnapshotStore {
|
|
20
|
+
if (!session.fileSnapshotStore) session.fileSnapshotStore = new InMemorySnapshotStore();
|
|
21
|
+
return session.fileSnapshotStore;
|
|
22
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only hashline diff preview helpers used by the streaming edit
|
|
3
|
+
* renderer. Reads the target file, parses + applies the section's edits in
|
|
4
|
+
* memory (no FS write, no LSP writethrough), then hands the before/after
|
|
5
|
+
* pair to {@link generateDiffString} so the renderer can show the diff
|
|
6
|
+
* while the tool call is still streaming.
|
|
7
|
+
*
|
|
8
|
+
* Validation is intentionally light: only the section file hash is checked
|
|
9
|
+
* (so the preview goes red when anchors are stale), no plan-mode guards
|
|
10
|
+
* and no auto-generated-file refusal — those belong on the write path.
|
|
11
|
+
*/
|
|
12
|
+
import {
|
|
13
|
+
computeFileHash,
|
|
14
|
+
Patch as HashlinePatch,
|
|
15
|
+
normalizeToLF,
|
|
16
|
+
type Patch,
|
|
17
|
+
type PatchSection,
|
|
18
|
+
stripBom,
|
|
19
|
+
} from "@oh-my-pi/hashline";
|
|
20
|
+
import { resolveToCwd } from "../../tools/path-utils";
|
|
21
|
+
import { generateDiffString } from "../diff";
|
|
22
|
+
import { readEditFileText } from "../read-file";
|
|
23
|
+
|
|
24
|
+
export interface HashlineDiffOptions {
|
|
25
|
+
autoDropPureInsertDuplicates?: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Use the streaming-tolerant applier ({@link PatchSection.applyPartialTo})
|
|
28
|
+
* so trailing in-flight ops do not throw or emit phantom edits. Streaming
|
|
29
|
+
* preview path only.
|
|
30
|
+
*/
|
|
31
|
+
streaming?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function readSectionText(absolutePath: string, sectionPath: string): Promise<string> {
|
|
35
|
+
try {
|
|
36
|
+
return await readEditFileText(absolutePath, sectionPath);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
39
|
+
throw new Error(message || `Unable to read ${sectionPath}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function hasAnchorScoped(section: PatchSection): boolean {
|
|
44
|
+
return section.hasAnchorScopedEdit;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function validateSectionHash(section: PatchSection, text: string): string | null {
|
|
48
|
+
if (section.fileHash === undefined) {
|
|
49
|
+
return hasAnchorScoped(section)
|
|
50
|
+
? `Missing hashline file hash for anchored edit to ${section.path}; use \`¶${section.path}#hash\` from your latest read.`
|
|
51
|
+
: null;
|
|
52
|
+
}
|
|
53
|
+
const currentHash = computeFileHash(text);
|
|
54
|
+
if (currentHash === section.fileHash) return null;
|
|
55
|
+
return `Hashline file hash mismatch for ${section.path}: section is bound to #${section.fileHash}, but current file hashes to #${currentHash}; re-read and try again.`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function computeHashlineSectionDiff(
|
|
59
|
+
section: PatchSection,
|
|
60
|
+
cwd: string,
|
|
61
|
+
options: HashlineDiffOptions = {},
|
|
62
|
+
): Promise<{ diff: string; firstChangedLine: number | undefined } | { error: string }> {
|
|
63
|
+
try {
|
|
64
|
+
const absolutePath = resolveToCwd(section.path, cwd);
|
|
65
|
+
const rawContent = await readSectionText(absolutePath, section.path);
|
|
66
|
+
const { text: content } = stripBom(rawContent);
|
|
67
|
+
const normalized = normalizeToLF(content);
|
|
68
|
+
const hashError = validateSectionHash(section, normalized);
|
|
69
|
+
if (hashError) return { error: hashError };
|
|
70
|
+
const result = options.streaming
|
|
71
|
+
? section.applyPartialTo(normalized, options)
|
|
72
|
+
: section.applyTo(normalized, options);
|
|
73
|
+
if (normalized === result.text) return { error: `No changes would be made to ${section.path}.` };
|
|
74
|
+
return generateDiffString(normalized, result.text);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function computeHashlineDiff(
|
|
81
|
+
input: { input: string },
|
|
82
|
+
cwd: string,
|
|
83
|
+
options: HashlineDiffOptions = {},
|
|
84
|
+
): Promise<{ diff: string; firstChangedLine: number | undefined } | { error: string }> {
|
|
85
|
+
let patch: Patch;
|
|
86
|
+
try {
|
|
87
|
+
patch = HashlinePatch.parse(input.input, { cwd });
|
|
88
|
+
} catch (err) {
|
|
89
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
90
|
+
}
|
|
91
|
+
if (patch.sections.length !== 1) {
|
|
92
|
+
return { error: "Streaming diff preview supports exactly one hashline section." };
|
|
93
|
+
}
|
|
94
|
+
return computeHashlineSectionDiff(patch.sections[0], cwd, options);
|
|
95
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coding-agent runner that drives the hashline {@link Patcher} on behalf of
|
|
3
|
+
* the `edit` tool. Converts a `{input}` tool-call payload into a
|
|
4
|
+
* fully-applied patch, wraps the result in the agent's
|
|
5
|
+
* {@link AgentToolResult} shape, and attaches LSP diagnostics + `outputMeta`
|
|
6
|
+
* for the renderer.
|
|
7
|
+
*
|
|
8
|
+
* Multi-section patches are preflighted up front via {@link Patcher.prepare}
|
|
9
|
+
* so a partial batch never lands; the commit loop then narrows the LSP
|
|
10
|
+
* batch's `flush` flag to true only for the final write so diagnostics
|
|
11
|
+
* round-trip once.
|
|
12
|
+
*/
|
|
13
|
+
import {
|
|
14
|
+
buildCompactDiffPreview,
|
|
15
|
+
MismatchError as HashlineMismatchError,
|
|
16
|
+
Patch,
|
|
17
|
+
Patcher,
|
|
18
|
+
type PatchSectionResult,
|
|
19
|
+
type PreparedSection,
|
|
20
|
+
} from "@oh-my-pi/hashline";
|
|
21
|
+
import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
|
|
22
|
+
import type { FileDiagnosticsResult, WritethroughCallback, WritethroughDeferredHandle } from "../../lsp";
|
|
23
|
+
import type { ToolSession } from "../../tools";
|
|
24
|
+
import { outputMeta } from "../../tools/output-meta";
|
|
25
|
+
import { generateDiffString } from "../diff";
|
|
26
|
+
import { getFileSnapshotStore } from "../file-snapshot-store";
|
|
27
|
+
import type { EditToolDetails, EditToolPerFileResult, LspBatchRequest } from "../renderer";
|
|
28
|
+
import { HashlineFilesystem } from "./filesystem";
|
|
29
|
+
import { type HashlineParams, hashlineEditParamsSchema } from "./params";
|
|
30
|
+
|
|
31
|
+
export interface ExecuteHashlineSingleOptions {
|
|
32
|
+
session: ToolSession;
|
|
33
|
+
input: string;
|
|
34
|
+
signal?: AbortSignal;
|
|
35
|
+
batchRequest?: LspBatchRequest;
|
|
36
|
+
writethrough: WritethroughCallback;
|
|
37
|
+
beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getHashlineApplyOptions(session: ToolSession): { autoDropPureInsertDuplicates: boolean } {
|
|
41
|
+
return {
|
|
42
|
+
autoDropPureInsertDuplicates: session.settings.get("edit.hashlineAutoDropPureInsertDuplicates"),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function noChangeDiagnostic(path: string): string {
|
|
47
|
+
return `Edits to ${path} resulted in no changes being made.`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function assertUniqueCanonicalPaths(prepared: readonly PreparedSection[]): void {
|
|
51
|
+
const seen = new Map<string, string>();
|
|
52
|
+
for (const entry of prepared) {
|
|
53
|
+
const previous = seen.get(entry.canonicalPath);
|
|
54
|
+
if (previous !== undefined) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`Multiple hashline sections resolve to the same file (${previous} and ${entry.section.path}). Merge their ops under one header before applying.`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
seen.set(entry.canonicalPath, entry.section.path);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function narrowBatchRequest(outer: LspBatchRequest | undefined, isLast: boolean): LspBatchRequest | undefined {
|
|
64
|
+
if (!outer) return undefined;
|
|
65
|
+
return { id: outer.id, flush: isLast && outer.flush };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface RenderedSection {
|
|
69
|
+
toolResult: AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema>;
|
|
70
|
+
perFileResult: EditToolPerFileResult;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function renderSection(result: PatchSectionResult, diagnostics: FileDiagnosticsResult | undefined): RenderedSection {
|
|
74
|
+
if (result.op === "noop") {
|
|
75
|
+
const toolResult: AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema> = {
|
|
76
|
+
content: [{ type: "text", text: noChangeDiagnostic(result.path) }],
|
|
77
|
+
details: { diff: "", op: "update", meta: outputMeta().get() },
|
|
78
|
+
};
|
|
79
|
+
return {
|
|
80
|
+
toolResult,
|
|
81
|
+
perFileResult: { path: result.path, diff: "", op: "update" },
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const diff = generateDiffString(result.before, result.after);
|
|
86
|
+
const preview = buildCompactDiffPreview(diff.diff);
|
|
87
|
+
const meta = outputMeta()
|
|
88
|
+
.diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? [])
|
|
89
|
+
.get();
|
|
90
|
+
|
|
91
|
+
const warningsBlock = result.warnings.length > 0 ? `\n\nWarnings:\n${result.warnings.join("\n")}` : "";
|
|
92
|
+
const previewBlock = preview.preview ? `\n${preview.preview}` : "";
|
|
93
|
+
const firstChangedLine = result.firstChangedLine ?? diff.firstChangedLine;
|
|
94
|
+
return {
|
|
95
|
+
toolResult: {
|
|
96
|
+
content: [{ type: "text", text: `${result.header}${previewBlock}${warningsBlock}` }],
|
|
97
|
+
details: {
|
|
98
|
+
diff: diff.diff,
|
|
99
|
+
firstChangedLine,
|
|
100
|
+
diagnostics,
|
|
101
|
+
op: result.op,
|
|
102
|
+
meta,
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
perFileResult: {
|
|
106
|
+
path: result.path,
|
|
107
|
+
diff: diff.diff,
|
|
108
|
+
firstChangedLine,
|
|
109
|
+
diagnostics,
|
|
110
|
+
op: result.op,
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function executeHashlineSingle(
|
|
116
|
+
options: ExecuteHashlineSingleOptions,
|
|
117
|
+
): Promise<AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema>> {
|
|
118
|
+
const patch = Patch.parse(options.input, { cwd: options.session.cwd });
|
|
119
|
+
if (patch.sections.length === 0) {
|
|
120
|
+
throw new Error("No hashline sections found in input.");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const fs = new HashlineFilesystem({
|
|
124
|
+
session: options.session,
|
|
125
|
+
writethrough: options.writethrough,
|
|
126
|
+
beginDeferredDiagnosticsForPath: options.beginDeferredDiagnosticsForPath,
|
|
127
|
+
signal: options.signal,
|
|
128
|
+
batchRequest: options.batchRequest,
|
|
129
|
+
});
|
|
130
|
+
const snapshots = getFileSnapshotStore(options.session);
|
|
131
|
+
const applyOptions = getHashlineApplyOptions(options.session);
|
|
132
|
+
const patcher = new Patcher({ fs, snapshots, applyOptions });
|
|
133
|
+
|
|
134
|
+
// Single-section fast path: prepare, commit, render.
|
|
135
|
+
if (patch.sections.length === 1) {
|
|
136
|
+
fs.setBatchRequest(narrowBatchRequest(options.batchRequest, true));
|
|
137
|
+
const prepared = await patcher.prepare(patch.sections[0]);
|
|
138
|
+
const sectionResult = await patcher.commit(prepared);
|
|
139
|
+
if (sectionResult.op === "noop") {
|
|
140
|
+
return renderSection(sectionResult, undefined).toolResult;
|
|
141
|
+
}
|
|
142
|
+
return renderSection(sectionResult, fs.consumeDiagnostics(sectionResult.path)).toolResult;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Multi-section: prepare every section up front so we fail fast before
|
|
146
|
+
// any write hits the filesystem.
|
|
147
|
+
const prepared: PreparedSection[] = [];
|
|
148
|
+
for (const section of patch.sections) prepared.push(await patcher.prepare(section));
|
|
149
|
+
assertUniqueCanonicalPaths(prepared);
|
|
150
|
+
for (const entry of prepared) {
|
|
151
|
+
if (entry.isNoop) throw new Error(noChangeDiagnostic(entry.section.path));
|
|
152
|
+
}
|
|
153
|
+
// Then commit each one, narrowing the LSP batch flush flag to the final
|
|
154
|
+
// section only. A no-op apply mid-batch is treated as a hard failure —
|
|
155
|
+
// the model authored anchors that match the current file content.
|
|
156
|
+
const rendered: RenderedSection[] = [];
|
|
157
|
+
for (let i = 0; i < prepared.length; i++) {
|
|
158
|
+
const isLast = i === prepared.length - 1;
|
|
159
|
+
fs.setBatchRequest(narrowBatchRequest(options.batchRequest, isLast));
|
|
160
|
+
const sectionResult = await patcher.commit(prepared[i]);
|
|
161
|
+
if (sectionResult.op === "noop") throw new Error(noChangeDiagnostic(sectionResult.path));
|
|
162
|
+
rendered.push(renderSection(sectionResult, fs.consumeDiagnostics(sectionResult.path)));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
content: [
|
|
167
|
+
{
|
|
168
|
+
type: "text",
|
|
169
|
+
text: rendered
|
|
170
|
+
.map(r => r.toolResult.content.map(part => (part.type === "text" ? part.text : "")).join("\n"))
|
|
171
|
+
.join("\n\n"),
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
details: {
|
|
175
|
+
diff: rendered.map(r => r.toolResult.details?.diff ?? "").join("\n"),
|
|
176
|
+
perFileResults: rendered.map(r => r.perFileResult),
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export { HashlineMismatchError, type HashlineParams, hashlineEditParamsSchema };
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coding-agent specific {@link Filesystem} adapter for the hashline patcher.
|
|
3
|
+
*
|
|
4
|
+
* Wires hashline's storage abstraction to the agent runtime:
|
|
5
|
+
*
|
|
6
|
+
* - Section paths are resolved through the plan-mode redirect so a bare
|
|
7
|
+
* `PLAN.md` lands at the canonical session artifact location.
|
|
8
|
+
* - Reads go through `readEditFileText` (notebook-aware) and the
|
|
9
|
+
* auto-generated-file guard.
|
|
10
|
+
* - Writes go through `serializeEditFileText` (notebook-aware) and the
|
|
11
|
+
* LSP writethrough, with FS-scan cache invalidation on success. The
|
|
12
|
+
* resulting `FileDiagnosticsResult` is captured per-path so the
|
|
13
|
+
* orchestrator can attach it to the tool result.
|
|
14
|
+
*
|
|
15
|
+
* Construct one per `executeHashlineSingle` call: per-section state
|
|
16
|
+
* (batch request, diagnostics) lives on the instance and isn't safe to
|
|
17
|
+
* share across concurrent edit tools.
|
|
18
|
+
*/
|
|
19
|
+
import { Filesystem, NotFoundError, type WriteResult } from "@oh-my-pi/hashline";
|
|
20
|
+
import { isEnoent } from "@oh-my-pi/pi-utils";
|
|
21
|
+
import type { FileDiagnosticsResult, WritethroughCallback, WritethroughDeferredHandle } from "../../lsp";
|
|
22
|
+
import type { ToolSession } from "../../tools";
|
|
23
|
+
import { assertEditableFileContent } from "../../tools/auto-generated-guard";
|
|
24
|
+
import { invalidateFsScanAfterWrite } from "../../tools/fs-cache-invalidation";
|
|
25
|
+
import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard";
|
|
26
|
+
import { readEditFileText, serializeEditFileText } from "../read-file";
|
|
27
|
+
import type { LspBatchRequest } from "../renderer";
|
|
28
|
+
|
|
29
|
+
export interface HashlineFilesystemOptions {
|
|
30
|
+
session: ToolSession;
|
|
31
|
+
writethrough: WritethroughCallback;
|
|
32
|
+
beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
|
|
33
|
+
signal?: AbortSignal;
|
|
34
|
+
/**
|
|
35
|
+
* Outer LSP batch request inherited from the tool-call context. The
|
|
36
|
+
* orchestrator narrows this per-section (flush only on the final write)
|
|
37
|
+
* via {@link HashlineFilesystem.setBatchRequest}.
|
|
38
|
+
*/
|
|
39
|
+
batchRequest?: LspBatchRequest;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class HashlineFilesystem extends Filesystem {
|
|
43
|
+
readonly session: ToolSession;
|
|
44
|
+
readonly #writethrough: WritethroughCallback;
|
|
45
|
+
readonly #beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
|
|
46
|
+
readonly #signal: AbortSignal | undefined;
|
|
47
|
+
#batchRequest: LspBatchRequest | undefined;
|
|
48
|
+
#diagnosticsByPath = new Map<string, FileDiagnosticsResult | undefined>();
|
|
49
|
+
|
|
50
|
+
constructor(options: HashlineFilesystemOptions) {
|
|
51
|
+
super();
|
|
52
|
+
this.session = options.session;
|
|
53
|
+
this.#writethrough = options.writethrough;
|
|
54
|
+
this.#beginDeferredDiagnosticsForPath = options.beginDeferredDiagnosticsForPath;
|
|
55
|
+
this.#signal = options.signal;
|
|
56
|
+
this.#batchRequest = options.batchRequest;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Set the LSP batch request used for the next {@link writeText} call.
|
|
61
|
+
* Multi-section orchestrators flip the `flush` flag to true before the
|
|
62
|
+
* final section so LSP diagnostics flush in one round-trip.
|
|
63
|
+
*/
|
|
64
|
+
setBatchRequest(batchRequest: LspBatchRequest | undefined): void {
|
|
65
|
+
this.#batchRequest = batchRequest;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Look up (and clear) the diagnostics captured by the most-recent
|
|
70
|
+
* {@link writeText} call for `path`. Returns `undefined` if no write
|
|
71
|
+
* has happened or the writethrough returned no diagnostics.
|
|
72
|
+
*/
|
|
73
|
+
consumeDiagnostics(path: string): FileDiagnosticsResult | undefined {
|
|
74
|
+
const value = this.#diagnosticsByPath.get(path);
|
|
75
|
+
this.#diagnosticsByPath.delete(path);
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
resolveAbsolute(relativePath: string): string {
|
|
80
|
+
return resolvePlanPath(this.session, relativePath);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
canonicalPath(relativePath: string): string {
|
|
84
|
+
return this.resolveAbsolute(relativePath);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async readText(relativePath: string): Promise<string> {
|
|
88
|
+
const absolutePath = this.resolveAbsolute(relativePath);
|
|
89
|
+
let content: string;
|
|
90
|
+
try {
|
|
91
|
+
content = await readEditFileText(absolutePath, relativePath);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (isEnoent(error)) throw new NotFoundError(relativePath, error);
|
|
94
|
+
if (error instanceof Error && error.message === `File not found: ${relativePath}`) {
|
|
95
|
+
throw new NotFoundError(relativePath, error);
|
|
96
|
+
}
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
// Refuse edits against generated files (lockfiles, models.json, …).
|
|
100
|
+
assertEditableFileContent(content, relativePath);
|
|
101
|
+
return content;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async preflightWrite(relativePath: string): Promise<void> {
|
|
105
|
+
enforcePlanModeWrite(this.session, relativePath, { op: "update" });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async writeText(relativePath: string, content: string): Promise<WriteResult> {
|
|
109
|
+
await this.preflightWrite(relativePath);
|
|
110
|
+
const absolutePath = this.resolveAbsolute(relativePath);
|
|
111
|
+
const finalContent = await serializeEditFileText(absolutePath, relativePath, content);
|
|
112
|
+
const diagnostics = await this.#writethrough(
|
|
113
|
+
absolutePath,
|
|
114
|
+
finalContent,
|
|
115
|
+
this.#signal,
|
|
116
|
+
Bun.file(absolutePath),
|
|
117
|
+
this.#batchRequest,
|
|
118
|
+
dst => (dst === absolutePath ? this.#beginDeferredDiagnosticsForPath(absolutePath) : undefined),
|
|
119
|
+
);
|
|
120
|
+
invalidateFsScanAfterWrite(absolutePath);
|
|
121
|
+
this.#diagnosticsByPath.set(relativePath, diagnostics);
|
|
122
|
+
return { text: finalContent };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async exists(relativePath: string): Promise<boolean> {
|
|
126
|
+
const absolutePath = this.resolveAbsolute(relativePath);
|
|
127
|
+
return Bun.file(absolutePath).exists();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zod schema for the `edit` tool's hashline mode payload. The schema is
|
|
3
|
+
* deliberately permissive (`.passthrough()`) so providers can attach extra
|
|
4
|
+
* keys without rejection; only `input` is required. `_input` is accepted as a
|
|
5
|
+
* provider-emitted alias for `input`.
|
|
6
|
+
*/
|
|
7
|
+
import * as z from "zod/v4";
|
|
8
|
+
|
|
9
|
+
export const hashlineEditParamsSchema = z.preprocess(raw => {
|
|
10
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return raw;
|
|
11
|
+
|
|
12
|
+
const record = raw as Record<string, unknown>;
|
|
13
|
+
if (typeof record.input === "string" || typeof record._input !== "string") return raw;
|
|
14
|
+
|
|
15
|
+
return { ...record, input: record._input };
|
|
16
|
+
}, z.object({ input: z.string() }).passthrough());
|
|
17
|
+
|
|
18
|
+
export type HashlineParams = z.infer<typeof hashlineEditParamsSchema>;
|
package/src/edit/index.ts
CHANGED
|
@@ -1,13 +1,8 @@
|
|
|
1
|
+
import { MismatchError as HashlineMismatchError } from "@oh-my-pi/hashline";
|
|
2
|
+
import hashlineGrammar from "@oh-my-pi/hashline/grammar.lark" with { type: "text" };
|
|
3
|
+
import hashlineDescription from "@oh-my-pi/hashline/prompt.md" with { type: "text" };
|
|
1
4
|
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
|
|
2
5
|
import { prompt } from "@oh-my-pi/pi-utils";
|
|
3
|
-
import {
|
|
4
|
-
executeHashlineSingle,
|
|
5
|
-
HashlineMismatchError,
|
|
6
|
-
type HashlineParams,
|
|
7
|
-
hashlineEditParamsSchema,
|
|
8
|
-
} from "../hashline";
|
|
9
|
-
import hashlineGrammarTemplate from "../hashline/grammar.lark" with { type: "text" };
|
|
10
|
-
import { resolveHashlineGrammarPlaceholders } from "../hashline/hash";
|
|
11
6
|
import {
|
|
12
7
|
createLspWritethrough,
|
|
13
8
|
type FileDiagnosticsResult,
|
|
@@ -16,28 +11,25 @@ import {
|
|
|
16
11
|
writethroughNoop,
|
|
17
12
|
} from "../lsp";
|
|
18
13
|
import applyPatchDescription from "../prompts/tools/apply-patch.md" with { type: "text" };
|
|
19
|
-
import hashlineDescription from "../prompts/tools/hashline.md" with { type: "text" };
|
|
20
14
|
import patchDescription from "../prompts/tools/patch.md" with { type: "text" };
|
|
21
15
|
import replaceDescription from "../prompts/tools/replace.md" with { type: "text" };
|
|
22
16
|
import type { ToolSession } from "../tools";
|
|
23
17
|
import { truncateForPrompt } from "../tools/approval";
|
|
24
18
|
import { isInternalUrlPath } from "../tools/path-utils";
|
|
25
19
|
import { type EditMode, normalizeEditMode, resolveEditMode } from "../utils/edit-mode";
|
|
20
|
+
import { executeHashlineSingle, type HashlineParams, hashlineEditParamsSchema } from "./hashline";
|
|
26
21
|
import { type ApplyPatchParams, applyPatchSchema, expandApplyPatchToEntries } from "./modes/apply-patch";
|
|
27
22
|
import applyPatchGrammar from "./modes/apply-patch.lark" with { type: "text" };
|
|
28
23
|
import { executePatchSingle, type PatchEditEntry, type PatchParams, patchEditSchema } from "./modes/patch";
|
|
29
24
|
import { executeReplaceSingle, type ReplaceEditEntry, type ReplaceParams, replaceEditSchema } from "./modes/replace";
|
|
30
25
|
import { type EditToolDetails, type EditToolPerFileResult, getLspBatchRequest, type LspBatchRequest } from "./renderer";
|
|
31
26
|
|
|
27
|
+
export * from "@oh-my-pi/hashline";
|
|
32
28
|
export { DEFAULT_EDIT_MODE, type EditMode, normalizeEditMode } from "../utils/edit-mode";
|
|
33
29
|
export * from "./apply-patch";
|
|
34
30
|
export * from "./diff";
|
|
35
|
-
export * from "./file-
|
|
36
|
-
|
|
37
|
-
// Resolve hashline grammar placeholders from the TypeScript constants.
|
|
38
|
-
const hashlineGrammar = resolveHashlineGrammarPlaceholders(hashlineGrammarTemplate);
|
|
39
|
-
|
|
40
|
-
export * from "../hashline";
|
|
31
|
+
export * from "./file-snapshot-store";
|
|
32
|
+
export * from "./hashline";
|
|
41
33
|
export * from "./modes/apply-patch";
|
|
42
34
|
export * from "./modes/patch";
|
|
43
35
|
export * from "./modes/replace";
|
|
@@ -270,19 +262,17 @@ async function executeSinglePathEntries(
|
|
|
270
262
|
|
|
271
263
|
function extractApprovalPath(args: unknown): string {
|
|
272
264
|
const record = args && typeof args === "object" ? (args as Record<string, unknown>) : {};
|
|
273
|
-
const targetPath = record.path;
|
|
274
|
-
if (typeof targetPath === "string" && targetPath.length > 0) {
|
|
275
|
-
return targetPath;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
265
|
const input = typeof record.input === "string" ? record.input : undefined;
|
|
279
|
-
if (
|
|
266
|
+
if (input) {
|
|
267
|
+
const hashlineMatch = /^(?:¶|§|@)([^\s#]+)/m.exec(input);
|
|
268
|
+
if (hashlineMatch?.[1]) return hashlineMatch[1];
|
|
280
269
|
|
|
281
|
-
|
|
282
|
-
|
|
270
|
+
const applyPatchMatch = /^\*\*\* (?:Add|Update|Delete) File:\s*(.+)$/m.exec(input);
|
|
271
|
+
if (applyPatchMatch?.[1]) return applyPatchMatch[1].trim();
|
|
272
|
+
}
|
|
283
273
|
|
|
284
|
-
const
|
|
285
|
-
return
|
|
274
|
+
const targetPath = record.path;
|
|
275
|
+
return typeof targetPath === "string" && targetPath.length > 0 ? targetPath : "(unknown)";
|
|
286
276
|
}
|
|
287
277
|
|
|
288
278
|
export class EditTool implements AgentTool<TInput> {
|
|
@@ -438,11 +428,10 @@ export class EditTool implements AgentTool<TInput> {
|
|
|
438
428
|
batchRequest: LspBatchRequest | undefined,
|
|
439
429
|
_onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
|
|
440
430
|
) => {
|
|
441
|
-
const { input
|
|
431
|
+
const { input } = params as HashlineParams;
|
|
442
432
|
return executeHashlineSingle({
|
|
443
433
|
session: tool.session,
|
|
444
434
|
input,
|
|
445
|
-
path,
|
|
446
435
|
signal,
|
|
447
436
|
batchRequest,
|
|
448
437
|
writethrough: tool.#writethrough,
|