dsh-hashline-edit 0.1.0 → 0.2.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 +72 -70
- package/cordis.patch.yml +1 -1
- package/lib/index.js +4494 -4813
- package/lib/types/dsh/contracts.d.ts +69 -6
- package/lib/types/dsh/fs.d.ts +38 -3
- package/lib/types/dsh/guidance.d.ts +5 -5
- package/lib/types/dsh/hide.d.ts +78 -0
- package/lib/types/dsh/host-compat.d.ts +7 -78
- package/lib/types/dsh/index.d.ts +17 -7
- package/lib/types/dsh/metrics/events.d.ts +8 -1
- package/lib/types/dsh/metrics/instrument.d.ts +20 -14
- package/lib/types/dsh/metrics/observer.d.ts +28 -0
- package/lib/types/dsh/sandbox.d.ts +2 -2
- package/lib/types/dsh/shell-hook.d.ts +17 -8
- package/lib/types/dsh/shell-view.d.ts +65 -3
- package/lib/types/dsh/tools/apply-patch.d.ts +38 -0
- package/lib/types/dsh/tools/context.d.ts +19 -1
- package/lib/types/dsh/tools/output.d.ts +32 -4
- package/lib/types/dsh/tools/read.d.ts +12 -15
- package/lib/types/dsh/tools/undo.d.ts +5 -0
- package/lib/types/dsh/write-hook.d.ts +8 -22
- 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/bash-view-banner.d.ts +20 -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/detection.d.ts +17 -0
- package/lib/types/src/file-content/index.d.ts +2 -2
- 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/resolve.d.ts +6 -0
- package/lib/types/src/host-utils.d.ts +63 -0
- package/lib/types/src/mutation-engine/batch-span-gate.d.ts +6 -0
- package/lib/types/src/mutation-engine/edit-request.d.ts +34 -0
- package/lib/types/src/mutation-engine/pipeline.d.ts +18 -3
- package/lib/types/src/mutation-engine/types.d.ts +50 -58
- package/lib/types/src/read-expansion/grammar-manifest.d.ts +4 -2
- package/lib/types/src/read-expansion/java-decoration.d.ts +21 -0
- package/lib/types/src/read-expansion/span-expansion.d.ts +6 -2
- package/lib/types/src/read-expansion/span-kind.d.ts +3 -2
- package/lib/types/src/read-expansion/vue-decoration.d.ts +14 -0
- package/lib/types/src/served-session/serve-render.d.ts +9 -3
- package/lib/types/src/undo-store.d.ts +17 -1
- package/package.json +10 -22
- package/prompts/apply-patch-guidelines.md +10 -0
- package/prompts/apply-patch.md +1 -0
- package/prompts/read-guidelines.md +5 -5
- 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/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 +58 -10
- package/src/edit-undo.ts +379 -25
- package/src/file-content/detection.ts +34 -0
- package/src/file-content/index.ts +2 -2
- 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/lease-resolve.ts +14 -8
- package/src/hashline/resolve.ts +30 -27
- package/src/host-utils.ts +224 -0
- package/src/mutation-engine/batch-span-gate.ts +17 -4
- package/src/mutation-engine/edit-request.ts +39 -0
- package/src/mutation-engine/pipeline.ts +535 -63
- package/src/mutation-engine/types.ts +49 -65
- package/src/mutation-queue.ts +1 -1
- package/src/prompts.ts +0 -28
- package/src/read-expansion/ast-provider.ts +4 -0
- package/src/read-expansion/grammar-manifest.ts +21 -2
- package/src/read-expansion/java-decoration.ts +34 -0
- package/src/read-expansion/span-expansion.ts +6 -2
- package/src/read-expansion/span-kind.ts +3 -1
- package/src/read-expansion/vue-decoration.ts +43 -0
- package/src/served-session/serve-render.ts +5 -5
- package/src/undo-store.ts +74 -5
- package/lib/types/dsh/encoding-bridge.d.ts +0 -25
- package/lib/types/dsh/encoding.d.ts +0 -144
- 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 -124
- package/prompts/edit-guidelines.md +0 -10
- package/prompts/edit-snippet.md +0 -1
- package/prompts/edit.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 -116
- 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 -173
- package/src/mutation-engine/index.ts +0 -17
- package/src/payload-contract.ts +0 -637
- 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,233 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SAFETY: store-kernel/settings — the janitor settings ladder: read, resolve per key, name the rung.
|
|
3
|
-
*
|
|
4
|
-
* WHY: this lives in the kernel rather than beside the writer because the resolved settings are what the
|
|
5
|
-
* WHY: maintenance pass runs on: cadence, batch width and enable/disable are open-gate policy, and a
|
|
6
|
-
* WHY: resolver that drifts from the store's own knobs would throttle the wrong sweep. The file's shape
|
|
7
|
-
* WHY: (keys, scopes, the atomic preserving write) stays in `store-config.ts`; this module only reads.
|
|
8
|
-
* WHY: Precedence is PER KEY — env, then project file, then global file, then the default — and a file
|
|
9
|
-
* WHY: that exists but cannot be used warns through `onWarn` while the values fall through.
|
|
10
|
-
*/
|
|
11
|
-
import {
|
|
12
|
-
isRecord,
|
|
13
|
-
readScopeConfig,
|
|
14
|
-
type ConfigOnWarn,
|
|
15
|
-
type ConfigRung,
|
|
16
|
-
type ConfigScope,
|
|
17
|
-
} from "../json-config.js";
|
|
18
|
-
|
|
19
|
-
export type StoreJanitorMode = "on" | "off";
|
|
20
|
-
|
|
21
|
-
/** Environment overrides; each wins over both files for its OWN key. */
|
|
22
|
-
export const STORE_JANITOR_ENV_VAR = "PI_BETTER_EDIT_STORE_JANITOR";
|
|
23
|
-
export const STORE_JANITOR_INTERVAL_ENV_VAR = "PI_BETTER_EDIT_STORE_JANITOR_INTERVAL_MS";
|
|
24
|
-
export const STORE_JANITOR_BATCH_ENV_VAR = "PI_BETTER_EDIT_STORE_JANITOR_BATCH_SIZE";
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* WHY a day: the committed design in `src/hash-store.ts` bounds the sweep at about once a day, and
|
|
28
|
-
* this interval is the only thing that decides how often an open may pay for it.
|
|
29
|
-
*/
|
|
30
|
-
export const STORE_JANITOR_INTERVAL_DEFAULT_MS = 24 * 60 * 60 * 1000;
|
|
31
|
-
|
|
32
|
-
/** WHY 64: the snapshot store already deletes in statement batches of that width (`STAT_BATCH`). */
|
|
33
|
-
export const STORE_JANITOR_BATCH_DEFAULT = 64;
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* One resolved setting: the value the store will use, and the rung of the ladder that supplied it.
|
|
37
|
-
* NOT exported on its own — the keyed shape below is the interface a caller reads, and a bare pair
|
|
38
|
-
* of fields has nothing to say without the key it belongs to.
|
|
39
|
-
*/
|
|
40
|
-
interface ResolvedStoreJanitorSetting<T> {
|
|
41
|
-
readonly value: T;
|
|
42
|
-
readonly source: ConfigRung;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Every janitor setting with its rung, keyed by the same three names `StoreJanitorSettings` uses, so
|
|
47
|
-
* a caller that needs to say WHERE a value came from reads the ladder's answer instead of guessing
|
|
48
|
-
* it from the two files (“the project file does not have the key, so the global one must have won”
|
|
49
|
-
* is a second ladder, and a wrong one as soon as a value is outside its accepted shape).
|
|
50
|
-
*/
|
|
51
|
-
export interface ResolvedStoreJanitorSettings {
|
|
52
|
-
readonly mode: ResolvedStoreJanitorSetting<StoreJanitorMode>;
|
|
53
|
-
readonly intervalMs: ResolvedStoreJanitorSetting<number>;
|
|
54
|
-
readonly batchSize: ResolvedStoreJanitorSetting<number>;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export interface StoreJanitorSettings {
|
|
58
|
-
readonly mode: StoreJanitorMode;
|
|
59
|
-
readonly intervalMs: number;
|
|
60
|
-
readonly batchSize: number;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** The settings ONE scope's file carries — every key optional, because the ladder resolves per key. */
|
|
64
|
-
export type StoreJanitorScopeSettings = Partial<StoreJanitorSettings>;
|
|
65
|
-
|
|
66
|
-
/** The values an unconfigured store gets. */
|
|
67
|
-
export const STORE_JANITOR_DEFAULTS: StoreJanitorSettings = {
|
|
68
|
-
mode: "on",
|
|
69
|
-
intervalMs: STORE_JANITOR_INTERVAL_DEFAULT_MS,
|
|
70
|
-
batchSize: STORE_JANITOR_BATCH_DEFAULT,
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
// WHY: one place maps a setting to its file key, so the read path and the write path cannot spell a
|
|
74
|
-
// WHY: key differently — the failure a second hand-written literal would introduce.
|
|
75
|
-
const STORE_JANITOR_KEYS = {
|
|
76
|
-
mode: "janitor",
|
|
77
|
-
intervalMs: "janitorIntervalMs",
|
|
78
|
-
batchSize: "janitorBatchSize",
|
|
79
|
-
} as const satisfies Record<keyof StoreJanitorSettings, string>;
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* The key each setting is read from and written to under `store`, for a caller that has to NAME one
|
|
83
|
-
* — the `/pi-better-edit` status text labels a key with the same spelling the ladder reads it by,
|
|
84
|
-
* instead of a second copy of the literal that can drift from this one.
|
|
85
|
-
*/
|
|
86
|
-
export const STORE_JANITOR_KEY_NAMES: Readonly<Record<keyof StoreJanitorSettings, string>> =
|
|
87
|
-
STORE_JANITOR_KEYS;
|
|
88
|
-
|
|
89
|
-
export function isStoreJanitorMode(value: unknown): value is StoreJanitorMode {
|
|
90
|
-
return value === "on" || value === "off";
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* A whole number of milliseconds at least 1: a fractional, non-finite, zero or negative interval
|
|
95
|
-
* cannot express a cadence, and admitting one would silently turn every open into a sweep.
|
|
96
|
-
*/
|
|
97
|
-
export function isIntervalMs(value: unknown): value is number {
|
|
98
|
-
return typeof value === "number" && Number.isInteger(value) && value >= 1;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/** A whole number of rows at least 1: a batch of zero rows deletes nothing and reports success. */
|
|
102
|
-
export function isBatchSize(value: unknown): value is number {
|
|
103
|
-
return typeof value === "number" && Number.isInteger(value) && value >= 1;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** A trimmed environment value, or `undefined` when the variable is unset. */
|
|
107
|
-
function envText(name: string): string | undefined {
|
|
108
|
-
const raw = process.env[name];
|
|
109
|
-
return raw === undefined ? undefined : raw.trim();
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function readEnvMode(): StoreJanitorMode | undefined {
|
|
113
|
-
const mode = envText(STORE_JANITOR_ENV_VAR)?.toLowerCase();
|
|
114
|
-
return mode !== undefined && isStoreJanitorMode(mode) ? mode : undefined;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function readEnvIntervalMs(): number | undefined {
|
|
118
|
-
const raw = envText(STORE_JANITOR_INTERVAL_ENV_VAR);
|
|
119
|
-
if (raw === undefined) return undefined;
|
|
120
|
-
const value = Number(raw);
|
|
121
|
-
return isIntervalMs(value) ? value : undefined;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function readEnvBatchSize(): number | undefined {
|
|
125
|
-
const raw = envText(STORE_JANITOR_BATCH_ENV_VAR);
|
|
126
|
-
if (raw === undefined) return undefined;
|
|
127
|
-
const value = Number(raw);
|
|
128
|
-
return isBatchSize(value) ? value : undefined;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/** The keys one scope's file actually carries, read once so a bad file warns once per scope. */
|
|
132
|
-
function readScopeSettings(
|
|
133
|
-
scope: ConfigScope,
|
|
134
|
-
cwd: string,
|
|
135
|
-
onWarn?: ConfigOnWarn,
|
|
136
|
-
): StoreJanitorScopeSettings {
|
|
137
|
-
const config = readScopeConfig(scope, cwd, onWarn);
|
|
138
|
-
if (config === undefined) return {};
|
|
139
|
-
const store = config.store;
|
|
140
|
-
if (!isRecord(store)) return {};
|
|
141
|
-
const mode = store[STORE_JANITOR_KEYS.mode];
|
|
142
|
-
const intervalMs = store[STORE_JANITOR_KEYS.intervalMs];
|
|
143
|
-
const batchSize = store[STORE_JANITOR_KEYS.batchSize];
|
|
144
|
-
// WHY: with `exactOptionalPropertyTypes` an unusable key is OMITTED rather than present as
|
|
145
|
-
// WHY: `undefined`, so "this scope said nothing about it" is one state, not two.
|
|
146
|
-
return {
|
|
147
|
-
...(isStoreJanitorMode(mode) ? { mode } : {}),
|
|
148
|
-
...(isIntervalMs(intervalMs) ? { intervalMs } : {}),
|
|
149
|
-
...(isBatchSize(batchSize) ? { batchSize } : {}),
|
|
150
|
-
};
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* Both scopes' raw settings, each scope present only when its file carries at least one accepted
|
|
155
|
-
* key. Pure read: never creates the directory or the file, never throws. A warning names each file
|
|
156
|
-
* that exists but could not be used.
|
|
157
|
-
*/
|
|
158
|
-
export function readStoreJanitorConfig(
|
|
159
|
-
cwd: string,
|
|
160
|
-
onWarn?: ConfigOnWarn,
|
|
161
|
-
): { project?: StoreJanitorScopeSettings; global?: StoreJanitorScopeSettings } {
|
|
162
|
-
const project = readScopeSettings("project", cwd, onWarn);
|
|
163
|
-
const global = readScopeSettings("global", cwd, onWarn);
|
|
164
|
-
return {
|
|
165
|
-
...(Object.keys(project).length > 0 ? { project } : {}),
|
|
166
|
-
...(Object.keys(global).length > 0 ? { global } : {}),
|
|
167
|
-
};
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* The first rung that answered, with its own name: the ladder's three ORDERS are one comparison made
|
|
172
|
-
* three times, so the rung labels and the precedence order cannot drift apart as two literals.
|
|
173
|
-
*/
|
|
174
|
-
function fromRung<T>(
|
|
175
|
-
env: T | undefined,
|
|
176
|
-
project: T | undefined,
|
|
177
|
-
global: T | undefined,
|
|
178
|
-
fallback: T,
|
|
179
|
-
): ResolvedStoreJanitorSetting<T> {
|
|
180
|
-
if (env !== undefined) return { value: env, source: "env" };
|
|
181
|
-
if (project !== undefined) return { value: project, source: "project" };
|
|
182
|
-
if (global !== undefined) return { value: global, source: "global" };
|
|
183
|
-
return { value: fallback, source: "default" };
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* The effective janitor settings WITH the rung each one came from. Precedence is PER KEY — env, then
|
|
188
|
-
* project, then global, then the default — so a file that tunes one knob does not have to restate the
|
|
189
|
-
* others, and a caller that reports the settings can name the rung it should blame for each one.
|
|
190
|
-
* Never throws. This is the ladder itself; `resolveStoreJanitorSettings` is its value half.
|
|
191
|
-
*/
|
|
192
|
-
export function resolveStoreJanitorSettingsWithSources(
|
|
193
|
-
cwd: string,
|
|
194
|
-
onWarn?: ConfigOnWarn,
|
|
195
|
-
): ResolvedStoreJanitorSettings {
|
|
196
|
-
const config = readStoreJanitorConfig(cwd, onWarn);
|
|
197
|
-
return {
|
|
198
|
-
mode: fromRung(
|
|
199
|
-
readEnvMode(),
|
|
200
|
-
config.project?.mode,
|
|
201
|
-
config.global?.mode,
|
|
202
|
-
STORE_JANITOR_DEFAULTS.mode,
|
|
203
|
-
),
|
|
204
|
-
intervalMs: fromRung(
|
|
205
|
-
readEnvIntervalMs(),
|
|
206
|
-
config.project?.intervalMs,
|
|
207
|
-
config.global?.intervalMs,
|
|
208
|
-
STORE_JANITOR_DEFAULTS.intervalMs,
|
|
209
|
-
),
|
|
210
|
-
batchSize: fromRung(
|
|
211
|
-
readEnvBatchSize(),
|
|
212
|
-
config.project?.batchSize,
|
|
213
|
-
config.global?.batchSize,
|
|
214
|
-
STORE_JANITOR_DEFAULTS.batchSize,
|
|
215
|
-
),
|
|
216
|
-
};
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* The effective janitor settings, without their provenance. Precedence is PER KEY — env, then
|
|
221
|
-
* project, then global, then the default. Never throws.
|
|
222
|
-
*/
|
|
223
|
-
export function resolveStoreJanitorSettings(
|
|
224
|
-
cwd: string,
|
|
225
|
-
onWarn?: ConfigOnWarn,
|
|
226
|
-
): StoreJanitorSettings {
|
|
227
|
-
const resolved = resolveStoreJanitorSettingsWithSources(cwd, onWarn);
|
|
228
|
-
return {
|
|
229
|
-
mode: resolved.mode.value,
|
|
230
|
-
intervalMs: resolved.intervalMs.value,
|
|
231
|
-
batchSize: resolved.batchSize.value,
|
|
232
|
-
};
|
|
233
|
-
}
|
package/src/store.ts
DELETED
|
@@ -1,154 +0,0 @@
|
|
|
1
|
-
import { DatabaseSync } from "node:sqlite";
|
|
2
|
-
import { withBusyRetry as hashWithBusyRetry } from "./store-kernel/index.js";
|
|
3
|
-
|
|
4
|
-
interface SnapshotStore {
|
|
5
|
-
get(path: string, checksum: string, lineCount: number): string[] | undefined;
|
|
6
|
-
put(
|
|
7
|
-
path: string,
|
|
8
|
-
checksum: string,
|
|
9
|
-
lineCount: number,
|
|
10
|
-
hashes: string[],
|
|
11
|
-
updatedAt?: number,
|
|
12
|
-
): void;
|
|
13
|
-
delete(path: string): void;
|
|
14
|
-
allHashes(): Array<{ path: string; hashes: string }>;
|
|
15
|
-
allPaths(): Array<{ path: string }>;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
// SAFETY: large-class — cohesive store owns DB and cache as single owner; split would scatter invariants.
|
|
19
|
-
export class SQLiteSnapshotStore implements SnapshotStore {
|
|
20
|
-
constructor(private readonly db: DatabaseSync) {}
|
|
21
|
-
|
|
22
|
-
get(path: string, checksum: string, lineCount: number): string[] | undefined {
|
|
23
|
-
const row = this.db
|
|
24
|
-
.prepare("SELECT hashes FROM snapshots WHERE path = ? AND checksum = ? AND line_count = ?")
|
|
25
|
-
.get(path, checksum, lineCount) as { hashes?: string } | undefined;
|
|
26
|
-
if (!row?.hashes) return undefined;
|
|
27
|
-
try {
|
|
28
|
-
const parsed = JSON.parse(row.hashes) as unknown;
|
|
29
|
-
if (Array.isArray(parsed) && parsed.every((h) => typeof h === "string")) {
|
|
30
|
-
return parsed as string[];
|
|
31
|
-
}
|
|
32
|
-
return undefined;
|
|
33
|
-
} catch {
|
|
34
|
-
return undefined;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
put(
|
|
39
|
-
path: string,
|
|
40
|
-
checksum: string,
|
|
41
|
-
lineCount: number,
|
|
42
|
-
hashes: string[],
|
|
43
|
-
updatedAt: number = Date.now(),
|
|
44
|
-
): void {
|
|
45
|
-
const stmt = this.db.prepare(
|
|
46
|
-
"INSERT INTO snapshots (path, checksum, line_count, hashes, updated_at) VALUES (?, ?, ?, ?, ?) " +
|
|
47
|
-
"ON CONFLICT(path) DO UPDATE SET checksum = excluded.checksum, line_count = excluded.line_count, hashes = excluded.hashes, updated_at = excluded.updated_at",
|
|
48
|
-
);
|
|
49
|
-
hashWithBusyRetry(() => stmt.run(path, checksum, lineCount, JSON.stringify(hashes), updatedAt));
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
delete(path: string): void {
|
|
53
|
-
const stmt = this.db.prepare("DELETE FROM snapshots WHERE path = ?");
|
|
54
|
-
hashWithBusyRetry(() => stmt.run(path));
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
allHashes(): Array<{ path: string; hashes: string }> {
|
|
58
|
-
const rows = this.db.prepare("SELECT path, hashes FROM snapshots").all() as Array<{
|
|
59
|
-
path: string;
|
|
60
|
-
hashes: string;
|
|
61
|
-
}>;
|
|
62
|
-
return rows;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
allPaths(): Array<{ path: string }> {
|
|
66
|
-
const rows = this.db
|
|
67
|
-
.prepare(
|
|
68
|
-
"SELECT path FROM snapshots UNION SELECT path FROM undo UNION SELECT path FROM file_undo UNION SELECT path FROM served",
|
|
69
|
-
)
|
|
70
|
-
.all() as Array<{ path: string }>;
|
|
71
|
-
return rows;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
type SnapshotRow = {
|
|
76
|
-
checksum: string;
|
|
77
|
-
lineCount: number;
|
|
78
|
-
hashes: string;
|
|
79
|
-
updatedAt: number;
|
|
80
|
-
};
|
|
81
|
-
|
|
82
|
-
// SAFETY: large-class — in-memory store mirrors SQLite shape for test isolation; cohesive and not split.
|
|
83
|
-
export class MemorySnapshotStore implements SnapshotStore {
|
|
84
|
-
private readonly data = new Map<string, SnapshotRow>();
|
|
85
|
-
private readonly extraPaths = new Set<string>();
|
|
86
|
-
|
|
87
|
-
get(path: string, checksum: string, lineCount: number): string[] | undefined {
|
|
88
|
-
const row = this.data.get(path);
|
|
89
|
-
if (!row) return undefined;
|
|
90
|
-
if (row.checksum !== checksum || row.lineCount !== lineCount) return undefined;
|
|
91
|
-
try {
|
|
92
|
-
const parsed = JSON.parse(row.hashes) as unknown;
|
|
93
|
-
if (Array.isArray(parsed) && parsed.every((h) => typeof h === "string")) {
|
|
94
|
-
return parsed as string[];
|
|
95
|
-
}
|
|
96
|
-
return undefined;
|
|
97
|
-
} catch {
|
|
98
|
-
return undefined;
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
put(
|
|
103
|
-
path: string,
|
|
104
|
-
checksum: string,
|
|
105
|
-
lineCount: number,
|
|
106
|
-
hashes: string[],
|
|
107
|
-
updatedAt: number = Date.now(),
|
|
108
|
-
): void {
|
|
109
|
-
this.data.set(path, {
|
|
110
|
-
checksum,
|
|
111
|
-
lineCount,
|
|
112
|
-
hashes: JSON.stringify(hashes),
|
|
113
|
-
updatedAt,
|
|
114
|
-
});
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
delete(path: string): void {
|
|
118
|
-
this.data.delete(path);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
allHashes(): Array<{ path: string; hashes: string }> {
|
|
122
|
-
const out: Array<{ path: string; hashes: string }> = [];
|
|
123
|
-
for (const [path, row] of this.data) {
|
|
124
|
-
out.push({ path, hashes: row.hashes });
|
|
125
|
-
}
|
|
126
|
-
return out;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
allPaths(): Array<{ path: string }> {
|
|
130
|
-
const seen = new Set<string>();
|
|
131
|
-
const out: Array<{ path: string }> = [];
|
|
132
|
-
for (const p of this.data.keys()) {
|
|
133
|
-
seen.add(p);
|
|
134
|
-
out.push({ path: p });
|
|
135
|
-
}
|
|
136
|
-
for (const p of this.extraPaths) {
|
|
137
|
-
if (!seen.has(p)) out.push({ path: p });
|
|
138
|
-
}
|
|
139
|
-
return out;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
__seedPath(path: string): void {
|
|
143
|
-
this.extraPaths.add(path);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
__getRow(path: string): SnapshotRow | undefined {
|
|
147
|
-
return this.data.get(path);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
clear(): void {
|
|
151
|
-
this.data.clear();
|
|
152
|
-
this.extraPaths.clear();
|
|
153
|
-
}
|
|
154
|
-
}
|
package/src/tui-presenter.ts
DELETED
|
@@ -1,134 +0,0 @@
|
|
|
1
|
-
/** SAFETY: TuiPresenter — Framework adapter owning the pi TUI boundary. Deep module EditTool never imports pi-tui; all Text/Markdown, DebouncedPreview wiring, and SAFETY casts live here. Two adapters justify the seam: TuiPresenter in prod, Headless in tests. Graded surface: public createTuiPresenter(preview) returns renderCall/renderResult ready for pi's ToolDefinition. */
|
|
2
|
-
|
|
3
|
-
import { Markdown, Text } from "@earendil-works/pi-tui";
|
|
4
|
-
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import type { TSchema } from "typebox";
|
|
6
|
-
import { DebouncedPreview, PREVIEW_DEBOUNCE_MS } from "./preview-controller.js";
|
|
7
|
-
import {
|
|
8
|
-
buildAppliedText,
|
|
9
|
-
mkMdTheme,
|
|
10
|
-
fmtCall,
|
|
11
|
-
fmtResultMd,
|
|
12
|
-
getPreviewInput,
|
|
13
|
-
getResultText,
|
|
14
|
-
isApplied,
|
|
15
|
-
type RPreview,
|
|
16
|
-
type RRState,
|
|
17
|
-
} from "./edit-render.js";
|
|
18
|
-
import type { EditDetails } from "./edit-response.js";
|
|
19
|
-
|
|
20
|
-
type TuiToolDef = ToolDefinition<TSchema, EditDetails, RRState> & {
|
|
21
|
-
renderShell?: "default" | "self";
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
export function reuseText(context: unknown, content: string): Text {
|
|
25
|
-
const ctx = context as { lastComponent: unknown };
|
|
26
|
-
const t = ctx.lastComponent instanceof Text ? ctx.lastComponent : new Text("", 0, 0);
|
|
27
|
-
t.setText(content);
|
|
28
|
-
return t;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function reuseMarkdown(context: unknown, content: string, theme: unknown): Markdown {
|
|
32
|
-
const ctx = context as { lastComponent: unknown };
|
|
33
|
-
const m =
|
|
34
|
-
ctx.lastComponent instanceof Markdown
|
|
35
|
-
? ctx.lastComponent
|
|
36
|
-
: new Markdown("", 0, 0, mkMdTheme(theme as never));
|
|
37
|
-
m.setText(content);
|
|
38
|
-
return m;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function isErrorContext(ctx: unknown): boolean {
|
|
42
|
-
// SAFETY: pi TUI context isError is untyped at boundary — cast isolated to adapter, validated by render error path
|
|
43
|
-
return (ctx as unknown as { isError: boolean }).isError;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function makeRenderCall(preview: DebouncedPreview) {
|
|
47
|
-
return (args: unknown, theme: unknown, context: unknown) => {
|
|
48
|
-
const ctx = context as {
|
|
49
|
-
lastComponent: unknown;
|
|
50
|
-
state: unknown;
|
|
51
|
-
expanded: unknown;
|
|
52
|
-
};
|
|
53
|
-
// SAFETY: pi TUI renderCall expects untyped context — cast isolates to adapter, validated by pi's runtime context shape
|
|
54
|
-
preview.renderCall(ctx as unknown as Parameters<DebouncedPreview["renderCall"]>[0], args);
|
|
55
|
-
const text = (ctx.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
56
|
-
// SAFETY: pi context carries untyped state/expanded — cast to RRState/boolean narrowed by getPreviewInput and theme contract
|
|
57
|
-
text.setText(
|
|
58
|
-
fmtCall(
|
|
59
|
-
getPreviewInput(args),
|
|
60
|
-
(ctx as unknown as { state: RRState }).state as RRState,
|
|
61
|
-
(ctx as unknown as { expanded: boolean }).expanded,
|
|
62
|
-
theme as never,
|
|
63
|
-
),
|
|
64
|
-
);
|
|
65
|
-
return text;
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function makeRenderResult(preview: DebouncedPreview) {
|
|
70
|
-
return (result: unknown, opts: { isPartial: boolean }, theme: unknown, context: unknown) => {
|
|
71
|
-
const ctx = context as {
|
|
72
|
-
lastComponent: unknown;
|
|
73
|
-
state: unknown;
|
|
74
|
-
isError: boolean;
|
|
75
|
-
};
|
|
76
|
-
if (opts.isPartial)
|
|
77
|
-
return reuseText(
|
|
78
|
-
ctx,
|
|
79
|
-
(theme as { fg: (a: string, b: string) => string }).fg("warning", "Editing..."),
|
|
80
|
-
);
|
|
81
|
-
const typedResult = result as {
|
|
82
|
-
content?: Array<{ type: string; text?: string }>;
|
|
83
|
-
details?: EditDetails;
|
|
84
|
-
};
|
|
85
|
-
const renderedText = getResultText(typedResult);
|
|
86
|
-
// SAFETY: pi TUI context state is untyped at boundary — cast to RRState narrowed by preview lifecycle (covers ctx and state casts)
|
|
87
|
-
const renderState = (ctx as unknown as { state: RRState | undefined }).state as
|
|
88
|
-
| RRState
|
|
89
|
-
| undefined;
|
|
90
|
-
if (renderState) preview.clearResult(renderState);
|
|
91
|
-
if (isErrorContext(ctx))
|
|
92
|
-
return renderedText
|
|
93
|
-
? reuseText(
|
|
94
|
-
ctx,
|
|
95
|
-
`\n${(theme as { fg: (a: string, b: string) => string }).fg("error", renderedText)}`,
|
|
96
|
-
)
|
|
97
|
-
: new Text("", 0, 0);
|
|
98
|
-
if (isApplied(typedResult.details)) {
|
|
99
|
-
const appliedText = buildAppliedText(typedResult.details, theme as never);
|
|
100
|
-
return appliedText ? reuseText(ctx, appliedText) : new Text("", 0, 0);
|
|
101
|
-
}
|
|
102
|
-
if (!renderedText) return new Text("", 0, 0);
|
|
103
|
-
return reuseMarkdown(ctx, fmtResultMd(renderedText), theme as never);
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
export interface TuiPresenter {
|
|
108
|
-
renderCall: ReturnType<typeof makeRenderCall>;
|
|
109
|
-
renderResult: ReturnType<typeof makeRenderResult>;
|
|
110
|
-
preview: DebouncedPreview;
|
|
111
|
-
/** SAFETY: Returns pi ToolDefinition-typed renders — adapter owns the SAFETY casts */
|
|
112
|
-
asToolDef(): Pick<TuiToolDef, "renderCall" | "renderResult">;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
export function createTuiPresenter(
|
|
116
|
-
previewFn: (request: unknown, cwd: string) => Promise<RPreview>,
|
|
117
|
-
getSessionId?: () => string | undefined,
|
|
118
|
-
): TuiPresenter {
|
|
119
|
-
const preview = new DebouncedPreview(previewFn, PREVIEW_DEBOUNCE_MS, getSessionId);
|
|
120
|
-
const renderCall = makeRenderCall(preview);
|
|
121
|
-
const renderResult = makeRenderResult(preview);
|
|
122
|
-
return {
|
|
123
|
-
preview,
|
|
124
|
-
renderCall,
|
|
125
|
-
renderResult,
|
|
126
|
-
asToolDef() {
|
|
127
|
-
// SAFETY: ToolDef renderCall/renderResult typed strictly by pi — casts validated by ToolDef contract, owned by framework adapter seam. `NonNullable` names the always-defined renders this adapter owns (`ToolDefinition` declares them optional).
|
|
128
|
-
return {
|
|
129
|
-
renderCall: renderCall as unknown as NonNullable<TuiToolDef["renderCall"]>,
|
|
130
|
-
renderResult: renderResult as unknown as NonNullable<TuiToolDef["renderResult"]>,
|
|
131
|
-
};
|
|
132
|
-
},
|
|
133
|
-
};
|
|
134
|
-
}
|
package/src/write-hook.ts
DELETED
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SAFETY: Guard + auto-read seam around `write`: the `tool_call` listener refuses
|
|
3
|
-
* reproduced served rows before they can reach disk.
|
|
4
|
-
* Ported from dsh@0.4.1 `src/write-hook.ts` adapted to pi's FileIO/session view.
|
|
5
|
-
* @module pi-better-edit/write-hook
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
|
-
import { abortIf, splitLines } from "./utils.js";
|
|
10
|
-
import { resolveTarget } from "./fs-write.js";
|
|
11
|
-
import { toCwd } from "./paths.js";
|
|
12
|
-
import { createSessionHandle, sessionKeyFor } from "./served-session/session.js";
|
|
13
|
-
import {
|
|
14
|
-
findServedHashEcho,
|
|
15
|
-
buildServedWriteMessage,
|
|
16
|
-
trackServedWriteRefusal,
|
|
17
|
-
} from "./served-session/serve-render.js";
|
|
18
|
-
import { reportDiagnostic } from "./diagnostics.js";
|
|
19
|
-
|
|
20
|
-
export { findServedHashEcho };
|
|
21
|
-
|
|
22
|
-
export interface WriteHookIO {
|
|
23
|
-
resolve(path: string, cwd: string, signal?: AbortSignal): Promise<string>;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* SAFETY: Inspect one validated-looking built-in write request against session-scoped
|
|
28
|
-
* served state. Returns a pre-dispatch denial reason only with evidence: the
|
|
29
|
-
* candidate begins with a served anchor at any position AND reproduces the
|
|
30
|
-
* served content for that anchor (GLOSSARY.md served hash echo). No canon data
|
|
31
|
-
* means no evidence, so the check stays silent — never a shape refusal.
|
|
32
|
-
*/
|
|
33
|
-
export async function servedHashEchoDenial(
|
|
34
|
-
io: WriteHookIO | null | undefined,
|
|
35
|
-
rawPath: string,
|
|
36
|
-
content: string,
|
|
37
|
-
cwd: string,
|
|
38
|
-
sessionKey: string,
|
|
39
|
-
signal?: AbortSignal,
|
|
40
|
-
mode: "general" | "literal" = "general",
|
|
41
|
-
): Promise<string | undefined> {
|
|
42
|
-
abortIf(signal);
|
|
43
|
-
if (mode === "literal") return undefined;
|
|
44
|
-
let absolutePath: string;
|
|
45
|
-
if (io && typeof (io as WriteHookIO).resolve === "function") {
|
|
46
|
-
absolutePath = await (io as WriteHookIO).resolve(rawPath, cwd, signal);
|
|
47
|
-
} else {
|
|
48
|
-
absolutePath = await resolveTarget(toCwd(rawPath, cwd));
|
|
49
|
-
}
|
|
50
|
-
abortIf(signal);
|
|
51
|
-
const handle = createSessionHandle(sessionKey, absolutePath);
|
|
52
|
-
const served = await handle.load();
|
|
53
|
-
let canonDigests: (string | null)[] = [];
|
|
54
|
-
try {
|
|
55
|
-
canonDigests = await handle.loadCanonDigests();
|
|
56
|
-
} catch (error) {
|
|
57
|
-
reportDiagnostic("write-hook", "Failed to load served canon digests for write:", error);
|
|
58
|
-
canonDigests = [];
|
|
59
|
-
}
|
|
60
|
-
const lines = splitLines(content);
|
|
61
|
-
const reproduction = findServedHashEcho(lines, served, canonDigests, 1);
|
|
62
|
-
if (!reproduction) return undefined;
|
|
63
|
-
const offendingLine = lines[reproduction.k - 1] ?? "";
|
|
64
|
-
// WHY: verification side of the tally: this refusal runs pre-dispatch, so no bytes move
|
|
65
|
-
// WHY: and no clear is due — the clear belongs to the committed write, which re-serves
|
|
66
|
-
// WHY: the file (`lifecycle-hooks` post-write), never to this gate.
|
|
67
|
-
const count = trackServedWriteRefusal(sessionKey, absolutePath, offendingLine);
|
|
68
|
-
return buildServedWriteMessage({
|
|
69
|
-
path: rawPath,
|
|
70
|
-
line: reproduction.line,
|
|
71
|
-
hash: reproduction.hash,
|
|
72
|
-
servedLine: reproduction.servedLine,
|
|
73
|
-
count,
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* SAFETY: Register the pre-write served hash echo guard on the calling extension's scope.
|
|
79
|
-
* The guard denies before dispatch; infrastructure failures fail open so the
|
|
80
|
-
* plugin never breaks an otherwise valid write.
|
|
81
|
-
*/
|
|
82
|
-
export function registerWriteHook(pi: ExtensionAPI): void {
|
|
83
|
-
pi.on("tool_call", async (event, ctx) => {
|
|
84
|
-
if (event.toolName !== "write") return;
|
|
85
|
-
const input = event.input as Record<string, unknown> | undefined;
|
|
86
|
-
const rawPath = input?.path as unknown;
|
|
87
|
-
const content = input?.content as unknown;
|
|
88
|
-
if (typeof rawPath !== "string" || typeof content !== "string") return;
|
|
89
|
-
// WHY: pi's builtin `write` schema tolerates the extra top-level `mode` field
|
|
90
|
-
// WHY: (verified with TypeBox `Compile`: an additional `mode?: "general" | "literal"`
|
|
91
|
-
// WHY: property passes `additionalProperties` handling on the builtin schema, so the
|
|
92
|
-
// WHY: literal declaration rides the same input object with no schema change).
|
|
93
|
-
const modeValue = (input as Record<string, unknown>)?.mode;
|
|
94
|
-
const mode: "general" | "literal" = modeValue === "literal" ? "literal" : "general";
|
|
95
|
-
|
|
96
|
-
const cwd = ctx.cwd;
|
|
97
|
-
// SAFETY: ExtensionAPI ctx carries sessionManager at runtime; cast narrows to sessionKeyFor's expected shape which is validated by sessionKeyFor's internal guards.
|
|
98
|
-
const sessionKey = sessionKeyFor(
|
|
99
|
-
ctx as unknown as { sessionManager?: { getSessionId(): string } },
|
|
100
|
-
);
|
|
101
|
-
const signal = ctx.signal;
|
|
102
|
-
try {
|
|
103
|
-
const io: WriteHookIO = {
|
|
104
|
-
resolve: async (p: string, c: string, sig?: AbortSignal) => {
|
|
105
|
-
abortIf(sig);
|
|
106
|
-
return resolveTarget(toCwd(p, c));
|
|
107
|
-
},
|
|
108
|
-
};
|
|
109
|
-
const reason = await servedHashEchoDenial(
|
|
110
|
-
io,
|
|
111
|
-
rawPath,
|
|
112
|
-
content,
|
|
113
|
-
cwd,
|
|
114
|
-
sessionKey,
|
|
115
|
-
signal,
|
|
116
|
-
mode,
|
|
117
|
-
);
|
|
118
|
-
if (reason !== undefined) {
|
|
119
|
-
return { block: true, reason };
|
|
120
|
-
}
|
|
121
|
-
} catch (error) {
|
|
122
|
-
if (signal?.aborted) throw error;
|
|
123
|
-
reportDiagnostic(
|
|
124
|
-
"write-hook",
|
|
125
|
-
`pi-better-edit: pre-write served hash guard failed open: ${error instanceof Error ? error.message : String(error)}`,
|
|
126
|
-
);
|
|
127
|
-
}
|
|
128
|
-
return;
|
|
129
|
-
});
|
|
130
|
-
}
|