@tduyng/pi-edit-path 0.0.0-stage → 0.0.2

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/LICENSE ADDED
@@ -0,0 +1,178 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ Use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ Copyright 2026 tduyng
package/README.md CHANGED
@@ -1,3 +1,39 @@
1
- # Temporary Holding Version
1
+ # @tduyng/pi-edit-path
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Two halves of one edit path. `then_run` fuses the validation command into the edit that needs it, and the edit hint names the region on disk a failed edit meant, so the retry lands without a re-read.
4
+
5
+ ## then_run
6
+
7
+ `then_run` is an optional parameter on `edit` and `write`: an object with `command` and `timeout` (seconds), or the command as a plain string. It runs after the mutation succeeds and is skipped if the mutation fails.
8
+
9
+ A string carrying an object literal, `then_run: '{command: "npm test"}'`, is read for its `command` and `timeout` rather than handed to bash, which used to run the braces and answer `command not found`. A string with no key in it stays the command. A literal naming no command is reported as a `then_run` failure after the mutation has landed, so the model is told the file changed and what the argument should have been.
10
+
11
+ Success and failure use sentinel markers. On success the command output is appended to the mutation result preceded by `[then_run:succeeded]`. On failure the mutation output is reported first, followed by `[then_run:failed]` and the error message, all wrapped in a single thrown error so the mutation result is not lost.
12
+
13
+ The tool definitions are extended, not replaced: the built-in `edit` and `write` schemas keep their properties and gain an optional `then_run`. Each working directory gets its own cached tool definition.
14
+
15
+ Two guidelines ride on the tool prompts. Pass `then_run` when the check follows from the same file the edit or write just changed. Use a separate `bash` call when the check does not follow from the edit, spans files not yet written, or when the model needs to read the edit result before deciding what to run.
16
+
17
+ ## The edit hint
18
+
19
+ On a `tool_result` where pi's own edit could not find the old text or matched it more than once, the hint appends what the read was for: the closest region as it reads on disk now, with line numbers and current text, or the line of every match when the old text matches more than once. The retry copies old text from those lines.
20
+
21
+ The hint reports, it never repairs. A miss that survives pi's fuzzy matching is a real mismatch, and silently applying a guess there would write code nobody asked for.
22
+
23
+ A region is found by line agreement, not by character count. Every distinctive line of old text (at least four characters, so a lone `}` never votes) is looked up in the file as pi's own matcher would read it, and the window of oldText's height holding the most of them wins. Under half agreeing means no region is shown, because a weak guess invites an edit against the wrong lines.
24
+
25
+ Agreement alone hides the commonest cause. On a miss where every distinctive line of a doc comment was present in the file, the model had re-wrapped a paragraph at a different column and written one word from memory. A line-for-line search cannot see a re-wrap, so the lines that are in no way on disk are listed by name next to the region.
26
+
27
+ ## Cost
28
+
29
+ The hint adds no tool, no schema and no prompt text. It reads the file pi already named in the error. A file over 2MB is not read. `then_run` costs two guideline bullets on the `edit` and `write` prompts and saves one provider round trip per fused check.
30
+
31
+ `PI_HINT_EDIT_HINT=0` disables the hint. It accepts `false`, `no`, and `off` too.
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ pi install npm:@tduyng/pi-edit-path
37
+ ```
38
+
39
+ Local checkout: `pi install /path/to/dotfiles/pi/agent/extensions/pi-edit-path`
package/package.json CHANGED
@@ -1,6 +1,45 @@
1
1
  {
2
2
  "name": "@tduyng/pi-edit-path",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.2",
4
+ "description": "Two halves of one edit path: fuse the validation command into the edit that needs it, and name the region on disk a failed edit meant so the retry lands without a re-read.",
5
+ "keywords": [
6
+ "pi",
7
+ "pi-package",
8
+ "pi-extension",
9
+ "pi-coding-agent",
10
+ "edit",
11
+ "round-trip",
12
+ "tokens"
13
+ ],
14
+ "homepage": "https://gitlab.com/tduyng/pi-agent/-/tree/main/extensions/pi-edit-path#readme",
15
+ "bugs": "https://gitlab.com/tduyng/pi-agent/-/issues",
16
+ "license": "Apache-2.0",
17
+ "author": "tduyng",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://gitlab.com/tduyng/pi-agent.git",
21
+ "directory": "extensions/pi-edit-path"
22
+ },
23
+ "type": "module",
24
+ "files": [
25
+ "src",
26
+ "!src/**/*.test.ts",
27
+ "README.md",
28
+ "LICENSE"
29
+ ],
30
+ "peerDependencies": {
31
+ "@earendil-works/pi-coding-agent": "*"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "pi": {
37
+ "extensions": [
38
+ "./src/edit-hint.ts",
39
+ "./src/action-fusion.ts"
40
+ ]
41
+ },
42
+ "dependencies": {
43
+ "ata-validator": "^1.62.1"
44
+ }
45
+ }
@@ -0,0 +1,221 @@
1
+ import {
2
+ type AgentToolResult,
3
+ createEditToolDefinition,
4
+ createWriteToolDefinition,
5
+ type ExtensionAPI,
6
+ type ExtensionToolContext
7
+ } from '@earendil-works/pi-coding-agent'
8
+ import { Validator } from 'ata-validator'
9
+ import { t } from 'ata-validator/t'
10
+
11
+ const isString = new Validator(t.string()).isValidObject
12
+
13
+ const SUCCEEDED = '[then_run:succeeded]'
14
+ const FAILED = '[then_run:failed]'
15
+
16
+ const GUIDELINES = [
17
+ 'Use then_run for a check that follows this edit or write.',
18
+ 'Use bash when the check needs other files or the mutation result.'
19
+ ]
20
+
21
+ const EDIT_GUIDELINES = [
22
+ 'Use edit for precise changes (edits[].oldText must match exactly)',
23
+ 'Batch disjoint edits; keep oldText small and unique.'
24
+ ]
25
+
26
+ interface ThenRun {
27
+ command: string
28
+ timeout?: number
29
+ }
30
+
31
+ interface EditArgs {
32
+ path: string
33
+ edits: Array<{ oldText: string; newText: string }>
34
+ then_run?: ThenRun | string
35
+ }
36
+
37
+ interface WriteArgs {
38
+ path: string
39
+ content: string
40
+ then_run?: ThenRun | string
41
+ }
42
+
43
+ const thenRunValue = t.object({
44
+ command: t.string(),
45
+ timeout: t.optional(t.number())
46
+ })
47
+ const isThenRunValue = new Validator(thenRunValue).isValidObject
48
+ const isCommandObject = new Validator(t.object({ command: t.string() })).isValidObject
49
+
50
+ const thenRunSchema = t.optional(t.union([t.string(), thenRunValue]))
51
+
52
+ const NO_COMMAND =
53
+ 'then_run named no command, so nothing ran. Pass the command as a string, or as {command, timeout}, not as a serialised object.'
54
+
55
+ export function coerceThenRun(value: ThenRun | string | undefined): ThenRun | undefined {
56
+ if (value === undefined) return undefined
57
+ if (isString(value)) return commandFrom(value)
58
+ if (isThenRunValue(value) && value.command.trim() !== '') return value
59
+ throw new Error(NO_COMMAND)
60
+ }
61
+
62
+ function commandFrom(literal: string): ThenRun {
63
+ const trimmed = literal.trim()
64
+ if (trimmed === '') throw new Error(NO_COMMAND)
65
+ if (!/^[{[]/.test(trimmed)) return { command: trimmed }
66
+ const command = literalCommand(trimmed)
67
+ if (command === undefined) throw new Error(NO_COMMAND)
68
+ const ran: ThenRun = { command }
69
+ const timeout = /\btimeout"?\s*:\s*(\d+(?:\.\d+)?)/.exec(trimmed)
70
+ if (timeout) ran.timeout = Number(timeout[1])
71
+ return ran
72
+ }
73
+
74
+ function literalCommand(literal: string): string | undefined {
75
+ let parsed: unknown
76
+ try {
77
+ parsed = JSON.parse(literal)
78
+ } catch {
79
+ parsed = undefined
80
+ }
81
+ if (isCommandObject(parsed)) return parsed.command.trim()
82
+ const match = /"?command"?\s*:\s*(?:"([^"]*)"|'([^']*)'|([^,}]*))/.exec(literal)
83
+ const command = (match?.[1] ?? match?.[2] ?? match?.[3] ?? '').trim()
84
+ return command === '' ? undefined : command
85
+ }
86
+
87
+ const MAX_OUTPUT_CHARS = 12_000
88
+
89
+ type JsonValue = null | boolean | number | string | JsonValue[] | JsonObject
90
+
91
+ interface JsonObject {
92
+ [key: string]: JsonValue
93
+ }
94
+
95
+ const ObjectRecordSchema = t.object({}, { additionalProperties: true })
96
+ const isObjectRecordSchema = new Validator(ObjectRecordSchema).isValidObject
97
+
98
+ function isJsonObject(value: unknown): value is JsonObject {
99
+ return isObjectRecordSchema(value)
100
+ }
101
+
102
+ function isJsonRecord(value: JsonValue): value is JsonObject {
103
+ return isObjectRecordSchema(value)
104
+ }
105
+
106
+ export function normalizeEditInput(input: JsonObject): JsonObject {
107
+ const edits = input['edits']
108
+ if (!Array.isArray(edits)) return input
109
+ let nestedPath: string | undefined
110
+ let nestedThenRun: JsonValue | undefined
111
+ const normalizedEdits = edits.map((edit) => {
112
+ if (!isJsonRecord(edit) || Array.isArray(edit)) return edit
113
+ const { path, then_run, ...rest } = edit
114
+ if (nestedPath === undefined && isString(path)) nestedPath = path
115
+ if (then_run !== undefined && nestedThenRun === undefined) nestedThenRun = then_run
116
+ return rest
117
+ })
118
+ const result: JsonObject = { ...input, edits: normalizedEdits }
119
+ if (result['path'] === undefined && nestedPath !== undefined) result['path'] = nestedPath
120
+ if (result['then_run'] === undefined && nestedThenRun !== undefined) result['then_run'] = nestedThenRun
121
+ return result
122
+ }
123
+
124
+ export function resultText(result: AgentToolResult<unknown>): string {
125
+ return result.content.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join('\n')
126
+ }
127
+
128
+ export function fuseResults<T>(mutation: AgentToolResult<T>, ran: AgentToolResult<unknown>): AgentToolResult<T> {
129
+ const raw = resultText(ran)
130
+ const output =
131
+ raw.length > MAX_OUTPUT_CHARS
132
+ ? `${raw.slice(-MAX_OUTPUT_CHARS)}\n[then_run output truncated to the last ${MAX_OUTPUT_CHARS} characters]`
133
+ : raw
134
+ return {
135
+ ...mutation,
136
+ content: [...mutation.content, { type: 'text' as const, text: output ? `${SUCCEEDED}\n${output}` : SUCCEEDED }]
137
+ }
138
+ }
139
+
140
+ export function fusedFailure(mutation: AgentToolResult<unknown>, reason: string): string {
141
+ return [resultText(mutation), FAILED, reason].filter(Boolean).join('\n\n')
142
+ }
143
+
144
+ async function appendThenRun<T>(
145
+ mutation: AgentToolResult<T>,
146
+ thenRun: ThenRun | string | undefined,
147
+ signal: AbortSignal | undefined,
148
+ ctx: ExtensionToolContext
149
+ ): Promise<AgentToolResult<T>> {
150
+ try {
151
+ const coerced = coerceThenRun(thenRun)
152
+ if (coerced === undefined) return mutation
153
+ const outcome = await ctx.executeTool('bash', coerced, { signal })
154
+ if (outcome.isError) throw new Error(resultText(outcome.result))
155
+ return fuseResults(mutation, outcome.result)
156
+ } catch (error) {
157
+ const reason = error instanceof Error ? error.message : String(error)
158
+ throw new Error(fusedFailure(mutation, reason), { cause: error })
159
+ }
160
+ }
161
+
162
+ function perCwd<T>(create: (cwd: string) => T): (cwd: string) => T {
163
+ const cache = new Map<string, T>()
164
+ return (cwd) => {
165
+ let cached = cache.get(cwd)
166
+ if (!cached) {
167
+ cached = create(cwd)
168
+ cache.set(cwd, cached)
169
+ }
170
+ return cached
171
+ }
172
+ }
173
+
174
+ export default function actionFusionExtension(pi: ExtensionAPI): void {
175
+ if (process.env['PI_ACTION_FUSION'] === '0') return
176
+
177
+ const editFor = perCwd((cwd: string) => createEditToolDefinition(cwd))
178
+ const writeFor = perCwd((cwd: string) => createWriteToolDefinition(cwd))
179
+
180
+ const editBuiltin = editFor(process.cwd())
181
+ pi.registerTool({
182
+ ...editBuiltin,
183
+ description:
184
+ 'Edit one file by replacing exact, unique, non-overlapping text. Batch disjoint edits in one call; keep oldText small and do not pad it.',
185
+ promptGuidelines: [...EDIT_GUIDELINES, ...GUIDELINES],
186
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
187
+ parameters: t.object({
188
+ path: t.string(),
189
+ edits: t.array(t.object({ oldText: t.string(), newText: t.string() })),
190
+ then_run: thenRunSchema
191
+ }),
192
+ prepareArguments: (input) => {
193
+ const prepared = editBuiltin.prepareArguments?.(input) ?? input
194
+ return (isJsonObject(prepared) ? normalizeEditInput(prepared) : prepared) as EditArgs
195
+ },
196
+ async execute(toolCallId, input: EditArgs, signal, onUpdate, ctx) {
197
+ const { then_run, ...mutation } = input as typeof input & { then_run?: ThenRun }
198
+ const result = await editFor(ctx.cwd).execute(toolCallId, mutation, signal, onUpdate, ctx)
199
+ return then_run === undefined ? result : await appendThenRun(result, then_run, signal, ctx)
200
+ }
201
+ })
202
+
203
+ const writeBuiltin = writeFor(process.cwd())
204
+ pi.registerTool({
205
+ ...writeBuiltin,
206
+ description: 'Create or overwrite a file, making parent directories.',
207
+ promptGuidelines: [...(writeBuiltin.promptGuidelines ?? []), ...GUIDELINES],
208
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
209
+ parameters: t.object({
210
+ path: t.string(),
211
+ content: t.string(),
212
+ then_run: thenRunSchema
213
+ }),
214
+ prepareArguments: (input) => (isJsonObject(input) ? normalizeEditInput(input) : input) as WriteArgs,
215
+ async execute(toolCallId, input: WriteArgs, signal, onUpdate, ctx) {
216
+ const { then_run, ...mutation } = input as typeof input & { then_run?: ThenRun }
217
+ const result = await writeFor(ctx.cwd).execute(toolCallId, mutation, signal, onUpdate, ctx)
218
+ return then_run === undefined ? result : await appendThenRun(result, then_run, signal, ctx)
219
+ }
220
+ })
221
+ }
@@ -0,0 +1,194 @@
1
+ import { readFileSync, statSync } from 'node:fs'
2
+ import { isAbsolute, resolve } from 'node:path'
3
+ import {
4
+ isEditToolResult,
5
+ type EditToolInput,
6
+ type ExtensionAPI,
7
+ type ToolResultEvent
8
+ } from '@earendil-works/pi-coding-agent'
9
+
10
+ const MIN_LINE = 4
11
+ const CONTEXT = 2
12
+ const MAX_REGION_LINES = 80
13
+ const MAX_LISTED = 10
14
+ const MAX_MISSING = 3
15
+ const MAX_FILE_CHARS = 2_000_000
16
+
17
+ const NOT_FOUND = /Could not find (?:the exact text|edits\[(\d+)\]) in /
18
+ const DUPLICATE = /Found (\d+) occurrences of (?:the text|edits\[(\d+)\]) in /
19
+
20
+ export interface EditError {
21
+ kind: 'not-found' | 'duplicate'
22
+ index: number
23
+ }
24
+
25
+ export function parseEditError(message: string): EditError | null {
26
+ const missing = NOT_FOUND.exec(message)
27
+ if (missing) return { kind: 'not-found', index: Number(missing[1] ?? 0) }
28
+ const duplicate = DUPLICATE.exec(message)
29
+ if (duplicate) return { kind: 'duplicate', index: Number(duplicate[2] ?? 0) }
30
+ return null
31
+ }
32
+
33
+ function toLines(text: string): string[] {
34
+ const lines = text.replace(/\r\n?/g, '\n').split('\n')
35
+ if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop()
36
+ return lines
37
+ }
38
+
39
+ function fuzzy(line: string): string {
40
+ return line
41
+ .normalize('NFKC')
42
+ .trimEnd()
43
+ .replace(/[\u2018\u2019\u201a\u201b]/g, "'")
44
+ .replace(/[\u201c\u201d\u201e\u201f]/g, '"')
45
+ .replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, '-')
46
+ .replace(/[\u00a0\u2002-\u200a\u202f\u205f\u3000]/g, ' ')
47
+ }
48
+
49
+ export interface Region {
50
+ start: number
51
+ end: number
52
+ agreeing: number
53
+ distinctive: number
54
+ }
55
+
56
+ export function nearestRegion(content: string, oldText: string): Region | null {
57
+ const file = toLines(content)
58
+ const oldLines = toLines(oldText)
59
+ const wanted = new Set(oldLines.map(fuzzy).filter((line) => line.trim().length >= MIN_LINE))
60
+ if (wanted.size === 0) return null
61
+
62
+ const height = Math.min(oldLines.length, file.length)
63
+ const strict = bestWindow(file, wanted, height, false)
64
+ const best = strict && strict.agreeing * 2 >= wanted.size ? strict : bestWindow(file, wanted, height, true)
65
+ if (!best || best.agreeing * 2 < wanted.size) return null
66
+ return { start: best.start + 1, end: best.start + height, agreeing: best.agreeing, distinctive: wanted.size }
67
+ }
68
+
69
+ function bestWindow(
70
+ file: string[],
71
+ wanted: Set<string>,
72
+ height: number,
73
+ trim: boolean
74
+ ): { start: number; agreeing: number } | null {
75
+ const needles = trim ? new Set([...wanted].map((line) => line.trim())) : wanted
76
+ const matched = file.map((raw) => {
77
+ const line = fuzzy(raw)
78
+ return needles.has(trim ? line.trim() : line) ? line : undefined
79
+ })
80
+ const counts = new Map<string, number>()
81
+ let distinct = 0
82
+ const slide = (line: string | undefined, delta: 1 | -1): void => {
83
+ if (line === undefined) return
84
+ const before = counts.get(line) ?? 0
85
+ const after = before + delta
86
+ if (after === 0) counts.delete(line)
87
+ else counts.set(line, after)
88
+ if (before === 0 && after > 0) distinct++
89
+ if (before > 0 && after === 0) distinct--
90
+ }
91
+ let best: { start: number; agreeing: number } | null = null
92
+ for (let end = 0; end < matched.length; end++) {
93
+ slide(matched[end], 1)
94
+ if (end >= height) slide(matched[end - height], -1)
95
+ const start = end - height + 1
96
+ if (start < 0) continue
97
+ if (!best || distinct > best.agreeing) best = { start, agreeing: distinct }
98
+ }
99
+ return best
100
+ }
101
+
102
+ export function missingLines(content: string, oldText: string): string[] {
103
+ const inFile = new Set(toLines(content).map(fuzzy))
104
+ const absent = new Set<string>()
105
+ for (const line of toLines(oldText)) {
106
+ if (line.trim().length >= MIN_LINE && !inFile.has(fuzzy(line))) absent.add(line)
107
+ }
108
+ return [...absent]
109
+ }
110
+
111
+ export function occurrenceLines(content: string, oldText: string): number[] {
112
+ const strip = (text: string): string =>
113
+ toLines(text)
114
+ .map((line) => line.replace(/\s+$/, ''))
115
+ .join('\n')
116
+ const haystack = strip(content)
117
+ const needle = strip(oldText)
118
+ if (!needle) return []
119
+ const lines: number[] = []
120
+ for (let at = haystack.indexOf(needle); at !== -1; at = haystack.indexOf(needle, at + 1)) {
121
+ lines.push(haystack.slice(0, at).split('\n').length)
122
+ }
123
+ return lines
124
+ }
125
+
126
+ export function buildHint(content: string, oldText: string, error: EditError, label: string): string | null {
127
+ if (error.kind === 'duplicate') {
128
+ const lines = occurrenceLines(content, oldText)
129
+ if (lines.length < 2) return null
130
+ const listed = lines.slice(0, MAX_LISTED).join(', ')
131
+ const more = lines.length > MAX_LISTED ? ` and ${lines.length - MAX_LISTED} more` : ''
132
+ return `[hint: ${label} matches at lines ${listed}${more}. Add a neighbouring line that only one of them has.]`
133
+ }
134
+
135
+ const region = nearestRegion(content, oldText)
136
+ if (!region) {
137
+ return `[hint: no region of the file shares at least half the distinctive lines of ${label}. The file may have changed, or this is the wrong file.]`
138
+ }
139
+ const absent = missingLines(content, oldText)
140
+ const named = absent
141
+ .slice(0, MAX_MISSING)
142
+ .map((line) => JSON.stringify(line))
143
+ .join(', ')
144
+ const why =
145
+ absent.length === 0
146
+ ? ''
147
+ : ` ${absent.length} of them are not in the file: ${named}` +
148
+ `${absent.length > MAX_MISSING ? `, and ${absent.length - MAX_MISSING} more` : ''}. ` +
149
+ 'Copy them from the region below, spacing included.'
150
+ const file = toLines(content)
151
+ const from = Math.max(1, region.start - CONTEXT)
152
+ const to = Math.min(file.length, region.end + CONTEXT, from + MAX_REGION_LINES - 1)
153
+ const cut = to < Math.min(file.length, region.end + CONTEXT) ? '\n[region cut]' : ''
154
+ return (
155
+ `[hint: closest match for ${label} is lines ${region.start}-${region.end} ` +
156
+ `(${region.agreeing} of ${region.distinctive} distinctive lines agree).${why} ` +
157
+ `Lines ${from}-${to} as they read now:]\n` +
158
+ '```\n' +
159
+ file.slice(from - 1, to).join('\n') +
160
+ `\n\`\`\`${cut}\n` +
161
+ 'Copy oldText from these lines and retry, no re-read needed.'
162
+ )
163
+ }
164
+
165
+ export default function editHint(pi: ExtensionAPI): void {
166
+ if (/^(0|false|no|off)$/i.test(process.env['PI_HINT_EDIT_HINT'] ?? '')) return
167
+
168
+ pi.on('tool_result', (event: ToolResultEvent, ctx) => {
169
+ if (!isEditToolResult(event) || !event.isError || !Array.isArray(event.content)) return undefined
170
+ const message = event.content.flatMap((part) => (part.type === 'text' ? [part.text] : [])).join('\n')
171
+ const error = parseEditError(message)
172
+ if (!error) return undefined
173
+
174
+ const input = event.input as EditToolInput
175
+ const oldText = input.edits[error.index]?.oldText
176
+ const requested = input.path
177
+ if (!oldText || !requested) return undefined
178
+
179
+ let content: string
180
+ try {
181
+ const path = isAbsolute(requested) ? requested : resolve(ctx?.cwd ?? process.cwd(), requested)
182
+ if (statSync(path).size > MAX_FILE_CHARS) return undefined
183
+ content = readFileSync(path, 'utf8')
184
+ } catch {
185
+ return undefined
186
+ }
187
+ if (content.length > MAX_FILE_CHARS) return undefined
188
+
189
+ const label = message.includes('edits[') ? `edits[${error.index}]` : 'oldText'
190
+ const hint = buildHint(content, oldText, error, label)
191
+ if (!hint) return undefined
192
+ return { content: [...event.content, { type: 'text' as const, text: hint }] }
193
+ })
194
+ }