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.
- package/README.md +57 -45
- package/cordis.patch.yml +9 -3
- package/lib/client.js +231 -0
- package/lib/index.js +3298 -3957
- package/lib/types/dsh/contracts.d.ts +12 -3
- package/lib/types/dsh/fs.d.ts +3 -0
- package/lib/types/dsh/guidance.d.ts +12 -6
- package/lib/types/dsh/hide.d.ts +79 -0
- package/lib/types/dsh/host-compat.d.ts +7 -78
- package/lib/types/dsh/index.d.ts +53 -10
- package/lib/types/dsh/metrics/events.d.ts +1 -1
- package/lib/types/dsh/metrics/observer.d.ts +1 -1
- package/lib/types/dsh/sandbox.d.ts +2 -2
- package/lib/types/dsh/shell-hook.d.ts +1 -1
- package/lib/types/dsh/tools/apply-patch.d.ts +38 -0
- package/lib/types/dsh/tools/read.d.ts +3 -3
- package/lib/types/src/apply-patch/commit.d.ts +49 -0
- package/lib/types/src/apply-patch/grammar.d.ts +29 -0
- package/lib/types/src/apply-patch/lifecycle.d.ts +32 -0
- package/lib/types/src/apply-patch/plan.d.ts +50 -0
- package/lib/types/src/apply-patch/render.d.ts +51 -0
- package/lib/types/src/apply-patch/resolve.d.ts +129 -0
- package/lib/types/src/apply-patch/tool.d.ts +54 -0
- package/lib/types/src/apply-patch/types.d.ts +362 -0
- package/lib/types/src/domain-errors.d.ts +16 -2
- package/lib/types/src/edit-undo.d.ts +11 -2
- package/lib/types/src/file-content/index.d.ts +1 -1
- package/lib/types/src/file-content/preview.d.ts +1 -1
- package/lib/types/src/fs-lifecycle.d.ts +44 -0
- package/lib/types/src/hashline/alphabet.d.ts +4 -3
- package/lib/types/src/hashline/hash-identity.d.ts +12 -7
- package/lib/types/src/host-utils.d.ts +63 -0
- package/lib/types/src/mutation-engine/edit-request.d.ts +34 -0
- package/lib/types/src/mutation-engine/pipeline.d.ts +8 -1
- package/lib/types/src/mutation-engine/types.d.ts +40 -50
- package/lib/types/src/served-session/serve-render.d.ts +6 -0
- package/lib/types/src/undo-store.d.ts +17 -1
- package/package.json +37 -21
- package/prompts/apply-patch-guidelines.md +4 -0
- package/prompts/apply-patch.md +1 -0
- package/prompts/read-guidelines.md +6 -6
- package/prompts/read.md +1 -1
- package/prompts/undo-last-edit-guidelines.md +2 -2
- package/prompts/undo-last-edit-snippet.md +1 -1
- package/prompts/undo-last-edit.md +1 -1
- package/prompts/zh/apply-patch-guidelines.md +4 -0
- package/prompts/zh/apply-patch.md +1 -0
- package/prompts/zh/read-guidelines.md +6 -0
- package/prompts/zh/read-snippet.md +1 -0
- package/prompts/zh/read.md +1 -0
- package/prompts/zh/undo-last-edit-guidelines.md +2 -0
- package/prompts/zh/undo-last-edit-snippet.md +1 -0
- package/prompts/zh/undo-last-edit.md +1 -0
- package/src/apply-patch/commit.ts +190 -0
- package/src/apply-patch/grammar.ts +620 -0
- package/src/apply-patch/index.ts +17 -0
- package/src/apply-patch/lifecycle.ts +195 -0
- package/src/apply-patch/plan.ts +800 -0
- package/src/apply-patch/render.ts +137 -0
- package/src/apply-patch/resolve.ts +470 -0
- package/src/apply-patch/tool.ts +257 -0
- package/src/apply-patch/types.ts +387 -0
- package/src/cut-repair.ts +77 -11
- package/src/domain-errors.ts +35 -3
- package/src/edit-undo.ts +379 -25
- package/src/file-content/index.ts +1 -1
- package/src/file-content/preview.ts +1 -1
- package/src/file-content/served-header.ts +1 -1
- package/src/fs-lifecycle.ts +201 -0
- package/src/hash-store.ts +18 -1
- package/src/hashline/alphabet.ts +7 -7
- package/src/hashline/hash-identity.ts +12 -7
- package/src/host-utils.ts +224 -0
- package/src/mutation-engine/edit-request.ts +39 -0
- package/src/mutation-engine/pipeline.ts +198 -27
- package/src/mutation-engine/types.ts +39 -57
- package/src/mutation-queue.ts +1 -1
- package/src/prompts.ts +3 -30
- package/src/served-session/serve-render.ts +2 -2
- package/src/undo-store.ts +74 -5
- package/lib/types/dsh/tools/edit.d.ts +0 -10
- package/lib/types/src/edit-tool.d.ts +0 -41
- package/lib/types/src/mutation-engine/engine.d.ts +0 -31
- package/lib/types/src/payload-contract.d.ts +0 -135
- package/prompts/edit-guidelines.md +0 -10
- package/prompts/edit-snippet.md +0 -1
- package/prompts/edit.md +0 -1
- package/prompts/read-snippet.md +0 -1
- package/src/bash-classifier.ts +0 -1626
- package/src/bash-conversion/decide.ts +0 -812
- package/src/bash-display-transform.ts +0 -50
- package/src/bash-multifile-config.ts +0 -239
- package/src/bash-view-config.ts +0 -158
- package/src/edit-normalize.ts +0 -8
- package/src/edit-render.ts +0 -161
- package/src/edit-tool.ts +0 -118
- package/src/edit.ts +0 -136
- package/src/integrations/pi-lens/command.ts +0 -712
- package/src/integrations/pi-lens/config.ts +0 -148
- package/src/integrations/pi-lens/io-bridge-adapter.ts +0 -234
- package/src/integrations/pi-lens/io-bridge.ts +0 -112
- package/src/integrations/pi-lens/settings-overlay.ts +0 -272
- package/src/lifecycle-hooks/index.ts +0 -1992
- package/src/lifecycle-hooks/types.ts +0 -64
- package/src/mutated-files.ts +0 -75
- package/src/mutation-engine/engine.ts +0 -239
- package/src/mutation-engine/index.ts +0 -17
- package/src/payload-contract.ts +0 -685
- package/src/preview-controller.ts +0 -84
- package/src/read.ts +0 -865
- package/src/search-hook.ts +0 -41
- package/src/store-config.ts +0 -73
- package/src/store-janitor-warning.ts +0 -169
- package/src/store-janitor.ts +0 -539
- package/src/store-kernel/settings.ts +0 -233
- package/src/store.ts +0 -154
- package/src/tui-presenter.ts +0 -134
- 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
|
-
}
|