dsh-hashline-edit 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/README.md +57 -45
  2. package/cordis.patch.yml +9 -3
  3. package/lib/client.js +231 -0
  4. package/lib/index.js +3298 -3957
  5. package/lib/types/dsh/contracts.d.ts +12 -3
  6. package/lib/types/dsh/fs.d.ts +3 -0
  7. package/lib/types/dsh/guidance.d.ts +12 -6
  8. package/lib/types/dsh/hide.d.ts +79 -0
  9. package/lib/types/dsh/host-compat.d.ts +7 -78
  10. package/lib/types/dsh/index.d.ts +53 -10
  11. package/lib/types/dsh/metrics/events.d.ts +1 -1
  12. package/lib/types/dsh/metrics/observer.d.ts +1 -1
  13. package/lib/types/dsh/sandbox.d.ts +2 -2
  14. package/lib/types/dsh/shell-hook.d.ts +1 -1
  15. package/lib/types/dsh/tools/apply-patch.d.ts +38 -0
  16. package/lib/types/dsh/tools/read.d.ts +3 -3
  17. package/lib/types/src/apply-patch/commit.d.ts +49 -0
  18. package/lib/types/src/apply-patch/grammar.d.ts +29 -0
  19. package/lib/types/src/apply-patch/lifecycle.d.ts +32 -0
  20. package/lib/types/src/apply-patch/plan.d.ts +50 -0
  21. package/lib/types/src/apply-patch/render.d.ts +51 -0
  22. package/lib/types/src/apply-patch/resolve.d.ts +129 -0
  23. package/lib/types/src/apply-patch/tool.d.ts +54 -0
  24. package/lib/types/src/apply-patch/types.d.ts +362 -0
  25. package/lib/types/src/domain-errors.d.ts +16 -2
  26. package/lib/types/src/edit-undo.d.ts +11 -2
  27. package/lib/types/src/file-content/index.d.ts +1 -1
  28. package/lib/types/src/file-content/preview.d.ts +1 -1
  29. package/lib/types/src/fs-lifecycle.d.ts +44 -0
  30. package/lib/types/src/hashline/alphabet.d.ts +4 -3
  31. package/lib/types/src/hashline/hash-identity.d.ts +12 -7
  32. package/lib/types/src/host-utils.d.ts +63 -0
  33. package/lib/types/src/mutation-engine/edit-request.d.ts +34 -0
  34. package/lib/types/src/mutation-engine/pipeline.d.ts +8 -1
  35. package/lib/types/src/mutation-engine/types.d.ts +40 -50
  36. package/lib/types/src/served-session/serve-render.d.ts +6 -0
  37. package/lib/types/src/undo-store.d.ts +17 -1
  38. package/package.json +37 -21
  39. package/prompts/apply-patch-guidelines.md +4 -0
  40. package/prompts/apply-patch.md +1 -0
  41. package/prompts/read-guidelines.md +6 -6
  42. package/prompts/read.md +1 -1
  43. package/prompts/undo-last-edit-guidelines.md +2 -2
  44. package/prompts/undo-last-edit-snippet.md +1 -1
  45. package/prompts/undo-last-edit.md +1 -1
  46. package/prompts/zh/apply-patch-guidelines.md +4 -0
  47. package/prompts/zh/apply-patch.md +1 -0
  48. package/prompts/zh/read-guidelines.md +6 -0
  49. package/prompts/zh/read-snippet.md +1 -0
  50. package/prompts/zh/read.md +1 -0
  51. package/prompts/zh/undo-last-edit-guidelines.md +2 -0
  52. package/prompts/zh/undo-last-edit-snippet.md +1 -0
  53. package/prompts/zh/undo-last-edit.md +1 -0
  54. package/src/apply-patch/commit.ts +190 -0
  55. package/src/apply-patch/grammar.ts +620 -0
  56. package/src/apply-patch/index.ts +17 -0
  57. package/src/apply-patch/lifecycle.ts +195 -0
  58. package/src/apply-patch/plan.ts +800 -0
  59. package/src/apply-patch/render.ts +137 -0
  60. package/src/apply-patch/resolve.ts +470 -0
  61. package/src/apply-patch/tool.ts +257 -0
  62. package/src/apply-patch/types.ts +387 -0
  63. package/src/cut-repair.ts +77 -11
  64. package/src/domain-errors.ts +35 -3
  65. package/src/edit-undo.ts +379 -25
  66. package/src/file-content/index.ts +1 -1
  67. package/src/file-content/preview.ts +1 -1
  68. package/src/file-content/served-header.ts +1 -1
  69. package/src/fs-lifecycle.ts +201 -0
  70. package/src/hash-store.ts +18 -1
  71. package/src/hashline/alphabet.ts +7 -7
  72. package/src/hashline/hash-identity.ts +12 -7
  73. package/src/host-utils.ts +224 -0
  74. package/src/mutation-engine/edit-request.ts +39 -0
  75. package/src/mutation-engine/pipeline.ts +198 -27
  76. package/src/mutation-engine/types.ts +39 -57
  77. package/src/mutation-queue.ts +1 -1
  78. package/src/prompts.ts +3 -30
  79. package/src/served-session/serve-render.ts +2 -2
  80. package/src/undo-store.ts +74 -5
  81. package/lib/types/dsh/tools/edit.d.ts +0 -10
  82. package/lib/types/src/edit-tool.d.ts +0 -41
  83. package/lib/types/src/mutation-engine/engine.d.ts +0 -31
  84. package/lib/types/src/payload-contract.d.ts +0 -135
  85. package/prompts/edit-guidelines.md +0 -10
  86. package/prompts/edit-snippet.md +0 -1
  87. package/prompts/edit.md +0 -1
  88. package/prompts/read-snippet.md +0 -1
  89. package/src/bash-classifier.ts +0 -1626
  90. package/src/bash-conversion/decide.ts +0 -812
  91. package/src/bash-display-transform.ts +0 -50
  92. package/src/bash-multifile-config.ts +0 -239
  93. package/src/bash-view-config.ts +0 -158
  94. package/src/edit-normalize.ts +0 -8
  95. package/src/edit-render.ts +0 -161
  96. package/src/edit-tool.ts +0 -118
  97. package/src/edit.ts +0 -136
  98. package/src/integrations/pi-lens/command.ts +0 -712
  99. package/src/integrations/pi-lens/config.ts +0 -148
  100. package/src/integrations/pi-lens/io-bridge-adapter.ts +0 -234
  101. package/src/integrations/pi-lens/io-bridge.ts +0 -112
  102. package/src/integrations/pi-lens/settings-overlay.ts +0 -272
  103. package/src/lifecycle-hooks/index.ts +0 -1992
  104. package/src/lifecycle-hooks/types.ts +0 -64
  105. package/src/mutated-files.ts +0 -75
  106. package/src/mutation-engine/engine.ts +0 -239
  107. package/src/mutation-engine/index.ts +0 -17
  108. package/src/payload-contract.ts +0 -685
  109. package/src/preview-controller.ts +0 -84
  110. package/src/read.ts +0 -865
  111. package/src/search-hook.ts +0 -41
  112. package/src/store-config.ts +0 -73
  113. package/src/store-janitor-warning.ts +0 -169
  114. package/src/store-janitor.ts +0 -539
  115. package/src/store-kernel/settings.ts +0 -233
  116. package/src/store.ts +0 -154
  117. package/src/tui-presenter.ts +0 -134
  118. package/src/write-hook.ts +0 -130
@@ -1,148 +0,0 @@
1
- /**
2
- * pi-lens bridge configuration — the on-disk `lens.bridge` setting and its precedence ladder.
3
- *
4
- * WHY a seam: the mode is read on every served-row notification and written only by the
5
- * `/pi-better-edit` command, so both sides must agree on one ladder — env, then project file,
6
- * then global file, then the `auto` default. A malformed config is never an error: it degrades
7
- * to "not configured" so a hand-edited file can never break editing.
8
- *
9
- * Shape of the shared config file (`CONFIG_REL`, owned by `src/json-config.ts`):
10
- * `{ "$schema": <LENS_SCHEMA_ID>, "lens": { "bridge": "auto" | "on" | "off" } }`
11
- * (documented by `schemas/pi-better-edit.json`). Unknown keys are preserved on write.
12
- *
13
- * The FILE, the scopes, the read and the preserving atomic write belong to
14
- * `src/json-config.ts`, which the store settings use too: one ladder, not one per settings owner.
15
- * This module owns only the `lens.*` keys.
16
- */
17
- import { DomainError } from "../../domain-errors.js";
18
- import {
19
- CONFIG_REL,
20
- CONFIG_SCHEMA_ID,
21
- configFilePath,
22
- isRecord,
23
- isConfigScope,
24
- readScopeConfig,
25
- updateConfigFile,
26
- type ConfigScope,
27
- type ConfigRung,
28
- } from "../../json-config.js";
29
-
30
- export type LensBridgeMode = "auto" | "on" | "off";
31
- /** The ladder's scopes ARE the config seam's scopes — one enumeration under two names. */
32
- export type LensBridgeScope = ConfigScope;
33
-
34
- /** Environment override; wins over both files. Values outside the enum are ignored, not fatal. */
35
- export const LENS_BRIDGE_ENV_VAR = "PI_BETTER_EDIT_LENS_BRIDGE";
36
-
37
- /** Config location relative to the project root or to the home directory. */
38
- export const LENS_CONFIG_REL = CONFIG_REL;
39
-
40
- /** Published schema id written into config files (see `schemas/pi-better-edit.json`). */
41
- export const LENS_SCHEMA_ID = CONFIG_SCHEMA_ID;
42
-
43
- const LENS_BRIDGE_MODES: readonly LensBridgeMode[] = ["auto", "on", "off"];
44
-
45
- /** Runtime guard for values arriving from files, env vars and command arguments. */
46
- export function isLensBridgeMode(value: unknown): value is LensBridgeMode {
47
- return typeof value === "string" && LENS_BRIDGE_MODES.some((mode) => mode === value);
48
- }
49
-
50
- /** Runtime guard for the two config scopes. */
51
- export function isLensBridgeScope(value: unknown): value is LensBridgeScope {
52
- return isConfigScope(value);
53
- }
54
-
55
- function readScopeMode(scope: LensBridgeScope, cwd: string): LensBridgeMode | undefined {
56
- const config = readScopeConfig(scope, cwd);
57
- if (config === undefined) return undefined;
58
- const lens = config.lens;
59
- if (!isRecord(lens)) return undefined;
60
- return isLensBridgeMode(lens.bridge) ? lens.bridge : undefined;
61
- }
62
-
63
- /**
64
- * Both scopes' configured modes. Pure read: never creates the directory or the file, never
65
- * throws. A scope is absent (rather than `undefined`-valued) when its file is missing or holds a
66
- * value outside the enum.
67
- */
68
- export function readLensBridgeConfig(cwd: string): {
69
- project?: LensBridgeMode;
70
- global?: LensBridgeMode;
71
- } {
72
- const project = readScopeMode("project", cwd);
73
- const global = readScopeMode("global", cwd);
74
- return {
75
- ...(project !== undefined ? { project } : {}),
76
- ...(global !== undefined ? { global } : {}),
77
- };
78
- }
79
-
80
- function readEnvMode(): LensBridgeMode | undefined {
81
- const raw = process.env[LENS_BRIDGE_ENV_VAR];
82
- if (raw === undefined) return undefined;
83
- const normalized = raw.trim().toLowerCase();
84
- return isLensBridgeMode(normalized) ? normalized : undefined;
85
- }
86
-
87
- /**
88
- * The effective mode and the rung that supplied it. `default` is the built-in `"auto"`, which is a
89
- * value of the ladder rather than a rung of the file.
90
- */
91
- export interface EffectiveLensBridgeMode {
92
- readonly mode: LensBridgeMode;
93
- readonly source: ConfigRung;
94
- }
95
-
96
- /**
97
- * Effective mode WITH its rung: env override, then project, then global, then `"auto"`. Never throws.
98
- * This is the ladder itself, and `resolveEffectiveLensMode` is its value half, so the two cannot
99
- * disagree about which rung won — a caller that has to NAME the winner (the `/pi-better-edit` status
100
- * text does) reads it here instead of re-walking the two files.
101
- */
102
- export function resolveEffectiveLensModeWithSource(cwd: string): EffectiveLensBridgeMode {
103
- const fromEnv = readEnvMode();
104
- if (fromEnv !== undefined) return { mode: fromEnv, source: "env" };
105
- const config = readLensBridgeConfig(cwd);
106
- if (config.project !== undefined) return { mode: config.project, source: "project" };
107
- if (config.global !== undefined) return { mode: config.global, source: "global" };
108
- return { mode: "auto", source: "default" };
109
- }
110
-
111
- /** Effective mode: env override, then project, then global, then `"auto"`. Never throws. */
112
- export function resolveEffectiveLensMode(cwd: string): LensBridgeMode {
113
- return resolveEffectiveLensModeWithSource(cwd).mode;
114
- }
115
-
116
- /**
117
- * Persist `mode` for `scope`, preserving unknown top-level keys and other `lens.*` keys already
118
- * in the file. Creates the directory as needed and replaces the file atomically (sibling temp
119
- * file + rename, same filesystem). Throws `E_INTERNAL_INVARIANT` — and writes nothing — for a
120
- * mode or scope outside the enum, which no caller can reach: the command and the overlay narrow
121
- * both to the enum.
122
- */
123
- export function saveLensBridgeConfig(
124
- mode: LensBridgeMode,
125
- scope: LensBridgeScope,
126
- cwd: string,
127
- ): { path: string } {
128
- if (!isLensBridgeMode(mode)) {
129
- throw new DomainError("E_INTERNAL_INVARIANT", {
130
- message: `Unknown lens bridge mode "${String(mode)}".`,
131
- });
132
- }
133
- if (!isLensBridgeScope(scope)) {
134
- throw new DomainError("E_INTERNAL_INVARIANT", {
135
- message: `Unknown lens bridge scope "${String(scope)}".`,
136
- });
137
- }
138
-
139
- const path = configFilePath(scope, cwd);
140
- // WHY: only `lens.bridge` is ours to set — an existing `$schema` is a deliberate editor-time
141
- // WHY: choice and unknown top-level or `lens.*` keys belong to the user, so all of them survive.
142
- updateConfigFile(path, (existing) => ({
143
- ...existing,
144
- lens: { ...(isRecord(existing.lens) ? existing.lens : {}), bridge: mode },
145
- }));
146
-
147
- return { path };
148
- }
@@ -1,234 +0,0 @@
1
- /**
2
- * Unified I/O bridge adapter — the only place that records to pi-lens v2.
3
- *
4
- * WHY one module for both seams: a v2 `record` entry carries at most one facet
5
- * of each kind, and the two domain-neutral seams (`src/served-spans.ts`,
6
- * `src/mutated-files.ts`) already separate reads from mutations, so each
7
- * notification maps to single-facet `record` calls. Edit-plus-preview pairs
8
- * therefore travel as two back-to-back calls in producer order, never as one
9
- * atomic compound.
10
- *
11
- * Read evidence: a notification that carries verbatim `content` is recorded span
12
- * by span, each span in its own single-range call with its slice (`content` is
13
- * valid only for a single contiguous range, and slicing keeps multi-span
14
- * notifications honest). A span whose slice fails is dropped, never sent short.
15
- * A notification without `content` — diff previews and reject-and-serve rows,
16
- * where no raw text is at hand — is recorded as ONE call carrying every range
17
- * with `evidence: "disk"`, the v1-equivalent behavior. `lineHashes` are never
18
- * sent: they would need the bridge's hash algorithm, while `content` lets the
19
- * bridge hash in memory with its own.
20
- *
21
- * Silence is the contract: these observers run inside core tool execution, so
22
- * nothing here logs and nothing throws — a broken bridge degrades to "no I/O
23
- * mirrored", never to a failed read, edit or write. Drops are counted instead,
24
- * for tests only. An explicit `off` mode is a configuration choice, not a failed
25
- * mirror, so it is never a drop.
26
- */
27
- import { isAbsolute } from "node:path";
28
- import { addMutatedFileObserver, type MutatedFileNotification } from "../../mutated-files.js";
29
- import { addServedSpanObserver, type ServedSpanNotification } from "../../served-spans.js";
30
- import { resolveEffectiveLensMode } from "./config.js";
31
- import {
32
- getIOBridge,
33
- IO_BRIDGE_CONSUMER,
34
- sliceSpanContent,
35
- type IOBridge,
36
- type IOBridgeMutate,
37
- type IOBridgeRange,
38
- } from "./io-bridge.js";
39
-
40
- let dropCount = 0;
41
-
42
- /** Test helper: how many mirrors this adapter dropped since the last reset. */
43
- export function getIODropCountForTests(): number {
44
- return dropCount;
45
- }
46
-
47
- /** Test helper: reset the drop tally so counts cannot leak between tests. */
48
- export function resetIODropCountForTests(): void {
49
- dropCount = 0;
50
- }
51
-
52
- /**
53
- * SAFETY: pi-lens rejects a malformed range outright (`isValidRange`), so a span
54
- * that cannot be expressed as one is dropped here rather than sent as a guess.
55
- * The seams guarantee valid spans; this guard is defense in depth.
56
- */
57
- function toBridgeRange(startLine: number, lineCount: number): IOBridgeRange | undefined {
58
- if (!Number.isInteger(startLine) || !Number.isInteger(lineCount)) return undefined;
59
- if (startLine < 1 || lineCount < 1) return undefined;
60
- return [startLine, startLine + lineCount - 1];
61
- }
62
-
63
- /**
64
- * Map one mutation notification to its facet, or `undefined` when it cannot be
65
- * expressed truthfully. An empty range list means the whole file changed, which
66
- * v2 names `write`: `edit` requires a non-empty `ranges`.
67
- */
68
- function toMutateFacet(notification: MutatedFileNotification): IOBridgeMutate | undefined {
69
- if (!isAbsolute(notification.filePath)) return undefined;
70
- if (notification.kind === "write" || notification.ranges.length === 0) {
71
- return { kind: "write" };
72
- }
73
- const ranges: IOBridgeRange[] = [];
74
- for (const span of notification.ranges) {
75
- const range = toBridgeRange(span.startLine, span.lineCount);
76
- if (range === undefined) return undefined;
77
- ranges.push(range);
78
- }
79
- return { kind: "edit", ranges };
80
- }
81
-
82
- /** Record one mutation notification; every failure path counts one drop. */
83
- function recordMutated(bridge: IOBridge, notification: MutatedFileNotification): void {
84
- const mutate = toMutateFacet(notification);
85
- if (mutate === undefined) {
86
- dropCount += 1;
87
- return;
88
- }
89
- try {
90
- const result = bridge.record({
91
- filePath: notification.filePath,
92
- consumer: IO_BRIDGE_CONSUMER,
93
- mutate,
94
- });
95
- if (result?.mutate?.accepted !== true) dropCount += 1;
96
- } catch {
97
- // SAFETY: advisory-only — a refused record must not propagate into the edit
98
- // SAFETY: or write that already landed, and the failure is the bridge's to
99
- // SAFETY: report.
100
- dropCount += 1;
101
- }
102
- }
103
-
104
- /** Record the caller slice of one single-range span; a failed slice counts a drop. */
105
- function recordSlicedSpan(
106
- bridge: IOBridge,
107
- filePath: string,
108
- startLine: number,
109
- lineCount: number,
110
- content: string,
111
- ): void {
112
- const range = toBridgeRange(startLine, lineCount);
113
- const slice = range === undefined ? undefined : sliceSpanContent(content, startLine, lineCount);
114
- if (range === undefined || slice === undefined) {
115
- dropCount += 1;
116
- return;
117
- }
118
- try {
119
- const result = bridge.record({
120
- filePath,
121
- consumer: IO_BRIDGE_CONSUMER,
122
- read: { ranges: [range], content: slice },
123
- });
124
- if (result?.read?.accepted !== true) dropCount += 1;
125
- } catch {
126
- // SAFETY: advisory-only — one rejected span must not stop the remaining
127
- // SAFETY: spans of the same notification, and the failure is the bridge's
128
- // SAFETY: to report.
129
- dropCount += 1;
130
- }
131
- }
132
-
133
- /** Record every span of a text-less notification as ONE disk-evidence call. */
134
- function recordDiskReads(
135
- bridge: IOBridge,
136
- filePath: string,
137
- notification: ServedSpanNotification,
138
- ): void {
139
- const ranges: IOBridgeRange[] = [];
140
- for (const span of notification.spans) {
141
- const range = toBridgeRange(span.startLine, span.lineCount);
142
- if (range === undefined) {
143
- dropCount += 1;
144
- return;
145
- }
146
- ranges.push(range);
147
- }
148
- try {
149
- const result = bridge.record({
150
- filePath,
151
- consumer: IO_BRIDGE_CONSUMER,
152
- read: { ranges, evidence: "disk" },
153
- });
154
- if (result?.read?.accepted !== true) dropCount += 1;
155
- } catch {
156
- // SAFETY: advisory-only — the bridge's failure belongs to the bridge, never
157
- // SAFETY: to the read or edit that served the rows.
158
- dropCount += 1;
159
- }
160
- }
161
-
162
- /** Record one served-span notification: caller slices when present, else one disk batch. */
163
- function recordServedSpans(bridge: IOBridge, notification: ServedSpanNotification): void {
164
- const content = notification.content;
165
- if (typeof content !== "string") {
166
- recordDiskReads(bridge, notification.filePath, notification);
167
- return;
168
- }
169
- for (const span of notification.spans) {
170
- recordSlicedSpan(bridge, notification.filePath, span.startLine, span.lineCount, content);
171
- }
172
- }
173
-
174
- /**
175
- * Subscribe both mirrors and return the combined unsubscribe function.
176
- *
177
- * `getCwd` is injectable because the observer path has no command context:
178
- * `process.cwd()` stands in for the session cwd (pi launches in it), while the
179
- * `/pi-better-edit` command resolves the exact `ctx.cwd`. The mode is re-read per
180
- * notification, so a mode change takes effect immediately.
181
- */
182
- export function attachIOBridgeAdapter(options?: { getCwd?: () => string }): () => void {
183
- const getCwd = options?.getCwd ?? ((): string => process.cwd());
184
- const resolveMode = (): string | undefined => {
185
- try {
186
- return resolveEffectiveLensMode(getCwd());
187
- } catch {
188
- // SAFETY: an unusable cwd means the mode cannot be resolved, so nothing
189
- // SAFETY: is mirrored and no drop is counted for a config read.
190
- return undefined;
191
- }
192
- };
193
- const detachMutated = addMutatedFileObserver((notification: MutatedFileNotification): void => {
194
- try {
195
- const mode = resolveMode();
196
- if (mode === undefined || mode === "off") return;
197
- // WHY: `auto` treats an absent bridge as disabled and `on` cannot conjure
198
- // WHY: one, so both modes share this single no-op path — the mode gate
199
- // WHY: above is the only difference between them, and an absent bridge is
200
- // WHY: still a dropped mirror.
201
- const bridge = getIOBridge();
202
- if (bridge === undefined) {
203
- dropCount += 1;
204
- return;
205
- }
206
- recordMutated(bridge, notification);
207
- } catch {
208
- // SAFETY: advisory-only — this runs inside core tool execution, so no
209
- // SAFETY: failure may propagate into the edit or write that produced the
210
- // SAFETY: mutation.
211
- }
212
- });
213
- const detachServed = addServedSpanObserver((notification: ServedSpanNotification): void => {
214
- try {
215
- if (notification.spans.length === 0 || !isAbsolute(notification.filePath)) return;
216
- const mode = resolveMode();
217
- if (mode === undefined || mode === "off") return;
218
- const bridge = getIOBridge();
219
- if (bridge === undefined) {
220
- dropCount += 1;
221
- return;
222
- }
223
- recordServedSpans(bridge, notification);
224
- } catch {
225
- // SAFETY: advisory-only — this runs inside core tool execution, so no
226
- // SAFETY: failure may propagate into the read or edit that served the
227
- // SAFETY: rows.
228
- }
229
- });
230
- return () => {
231
- detachMutated();
232
- detachServed();
233
- };
234
- }
@@ -1,112 +0,0 @@
1
- /**
2
- * Unified pi-lens File I/O Lifecycle Bridge v2 — the only place that knows the
3
- * `pi-lens:io-bridge` mount exists.
4
- *
5
- * WHY a separate module: the observer seams (`src/served-spans.ts`,
6
- * `src/mutated-files.ts`) are domain-neutral and must not reference any
7
- * extension, while this module is the integration edge that turns served and
8
- * mutated notifications into `record` calls. Detection is structural (a global
9
- * symbol key plus a version and shape check), so nothing here imports pi-lens.
10
- * When the bridge is absent, or the mode is `off`, every notification is a
11
- * no-op.
12
- *
13
- * The structural copies below are the v2-native subset this integration sends —
14
- * no `v1-compat` fields, which exist only for the frozen v1 shims. The pi-lens
15
- * contract file (`clients/io-bridge-contract.ts`) is authoritative when this
16
- * module and the migration guide disagree.
17
- *
18
- * Silence is the contract: the observers run inside core tool execution, so
19
- * nothing here logs and nothing throws — a broken bridge degrades to "no I/O
20
- * mirrored", never to a failed read, edit or write.
21
- */
22
-
23
- /** Stable symbol key — identical across module reloads in one process. */
24
- const IO_BRIDGE_KEY = "pi-lens:io-bridge";
25
-
26
- /** Bridge API version this integration speaks. A mismatch means unsupported. */
27
- export const IO_BRIDGE_VERSION = 2;
28
-
29
- /** Caller identity for logging and telemetry on the pi-lens side. */
30
- export const IO_BRIDGE_CONSUMER = "pi-better-edit";
31
-
32
- /** One 1-indexed closed line interval: `[start, end]` with `end >= start`. */
33
- export type IOBridgeRange = [start: number, end: number];
34
-
35
- /** Read facet this integration sends: caller evidence, never `disk` by default. */
36
- interface IOBridgeRead {
37
- ranges: IOBridgeRange[];
38
- content?: string;
39
- evidence?: "caller" | "disk";
40
- }
41
-
42
- /** Mutation facet this integration sends. `delete` has no producer here. */
43
- export type IOBridgeMutate = { kind: "edit"; ranges: IOBridgeRange[] } | { kind: "write" };
44
-
45
- /** One `record` entry: at most one facet of each kind. */
46
- interface IOBridgeEntry {
47
- filePath: string;
48
- consumer?: string;
49
- mutate?: IOBridgeMutate;
50
- read?: IOBridgeRead;
51
- }
52
-
53
- /** One facet outcome. `record` never throws; refusals arrive as outcomes. */
54
- type IOBridgeOutcome = { accepted: true } | { accepted: false; reason: string };
55
-
56
- /** Per-facet outcomes of one `record` call. */
57
- interface IOBridgeResult {
58
- read?: IOBridgeOutcome;
59
- mutate?: IOBridgeOutcome;
60
- }
61
-
62
- /** Structural view of the bridge published by pi-lens; only `record` is read. */
63
- export interface IOBridge {
64
- readonly version: number;
65
- record(entry: IOBridgeEntry): IOBridgeResult;
66
- }
67
-
68
- /**
69
- * SAFETY: resolved per call rather than captured at module load, so a bridge
70
- * mounted after the adapter is attached is still found, and a bridge swapped
71
- * by another extension is honoured. A version mismatch means "unsupported",
72
- * never "reset": the caller simply records nothing.
73
- */
74
- export function getIOBridge(): IOBridge | undefined {
75
- // SAFETY: `globalThis` carries no symbol index signature in the TS lib, so
76
- // SAFETY: one cast is what makes the symbol-keyed lookup expressible at all;
77
- // SAFETY: the value stays `unknown`, so the version and method checks below
78
- // SAFETY: are what prove the shape, and the I/O bridge is the only thing
79
- // SAFETY: published under this key.
80
- const maybe = (globalThis as unknown as Record<symbol, unknown>)[Symbol.for(IO_BRIDGE_KEY)];
81
- if (typeof maybe !== "object" || maybe === null) return undefined;
82
- const bridge = maybe as Partial<IOBridge>;
83
- if (bridge.version !== IO_BRIDGE_VERSION || typeof bridge.record !== "function") {
84
- return undefined;
85
- }
86
- return bridge as IOBridge;
87
- }
88
-
89
- /**
90
- * Slice the verbatim span lines out of raw file text.
91
- *
92
- * WHY a slice, not the whole text: `content` is span-relative and valid only for
93
- * a single contiguous range, so each span of a multi-span notification travels
94
- * in its own single-range `record` call. The split is CRLF-tolerant (`/\r?\n/`),
95
- * and the terminal newline's empty entry is a sentinel rather than a line, so the
96
- * returned text splits back into exactly the span's lines when the bridge hashes
97
- * it. Returns `undefined` when the text is shorter than the span claims — the
98
- * caller then drops the span rather than sending a short slice as the whole span.
99
- */
100
- export function sliceSpanContent(
101
- content: string,
102
- startLine: number,
103
- lineCount: number,
104
- ): string | undefined {
105
- if (!Number.isInteger(startLine) || !Number.isInteger(lineCount)) return undefined;
106
- const start = startLine - 1;
107
- if (start < 0 || lineCount < 1) return undefined;
108
- const lines = content.split(/\r?\n/);
109
- if (content.endsWith("\n")) lines.pop();
110
- if (start + lineCount > lines.length) return undefined;
111
- return lines.slice(start, start + lineCount).join("\n");
112
- }