dsh-hashline-edit 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -55
- package/cordis.patch.yml +1 -1
- package/lib/index.js +7225 -6445
- package/lib/types/dsh/contracts.d.ts +3 -12
- package/lib/types/dsh/fs.d.ts +0 -3
- package/lib/types/dsh/guidance.d.ts +5 -5
- package/lib/types/dsh/host-compat.d.ts +78 -7
- package/lib/types/dsh/index.d.ts +7 -17
- 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/edit.d.ts +10 -0
- package/lib/types/dsh/tools/read.d.ts +3 -3
- package/lib/types/src/domain-errors.d.ts +2 -16
- package/lib/types/src/edit-tool.d.ts +41 -0
- package/lib/types/src/edit-undo.d.ts +2 -11
- 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/hash-store.d.ts +2 -2
- package/lib/types/src/hashline/alphabet.d.ts +16 -1
- package/lib/types/src/hashline/hash-identity.d.ts +10 -4
- package/lib/types/src/mutation-engine/engine.d.ts +31 -0
- package/lib/types/src/mutation-engine/pipeline.d.ts +1 -8
- package/lib/types/src/mutation-engine/types.d.ts +50 -40
- package/lib/types/src/payload-contract.d.ts +135 -0
- package/lib/types/src/served-session/serve-render.d.ts +0 -6
- package/lib/types/src/undo-store.d.ts +1 -17
- package/package.json +21 -10
- package/prompts/edit-guidelines.md +10 -0
- package/prompts/edit-snippet.md +1 -0
- package/prompts/edit.md +1 -0
- package/prompts/read-guidelines.md +4 -4
- 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/bash-classifier.ts +1626 -0
- package/src/bash-conversion/decide.ts +812 -0
- package/src/bash-display-transform.ts +50 -0
- package/src/bash-multifile-config.ts +239 -0
- package/src/bash-view-config.ts +158 -0
- package/src/cut-repair.ts +11 -77
- package/src/domain-errors.ts +3 -35
- package/src/edit-normalize.ts +8 -0
- package/src/edit-render.ts +161 -0
- package/src/edit-tool.ts +118 -0
- package/src/edit-undo.ts +25 -379
- package/src/edit.ts +136 -0
- 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/hash-store.ts +1 -18
- package/src/hashline/alphabet.ts +42 -1
- package/src/hashline/hash-identity.ts +12 -3
- package/src/integrations/pi-lens/command.ts +712 -0
- package/src/integrations/pi-lens/config.ts +148 -0
- package/src/integrations/pi-lens/io-bridge-adapter.ts +234 -0
- package/src/integrations/pi-lens/io-bridge.ts +112 -0
- package/src/integrations/pi-lens/settings-overlay.ts +272 -0
- package/src/lifecycle-hooks/index.ts +1992 -0
- package/src/lifecycle-hooks/types.ts +64 -0
- package/src/mutated-files.ts +75 -0
- package/src/mutation-engine/engine.ts +239 -0
- package/src/mutation-engine/index.ts +17 -0
- package/src/mutation-engine/pipeline.ts +27 -198
- package/src/mutation-engine/types.ts +57 -39
- package/src/mutation-queue.ts +1 -1
- package/src/payload-contract.ts +685 -0
- package/src/preview-controller.ts +84 -0
- package/src/prompts.ts +43 -10
- package/src/read.ts +865 -0
- package/src/search-hook.ts +41 -0
- package/src/served-session/serve-render.ts +2 -2
- package/src/store-config.ts +73 -0
- package/src/store-janitor-warning.ts +169 -0
- package/src/store-janitor.ts +539 -0
- package/src/store-kernel/settings.ts +233 -0
- package/src/store.ts +154 -0
- package/src/tui-presenter.ts +134 -0
- package/src/undo-store.ts +5 -74
- package/src/write-hook.ts +130 -0
- package/lib/types/dsh/hide.d.ts +0 -78
- package/lib/types/dsh/tools/apply-patch.d.ts +0 -38
- package/lib/types/src/apply-patch/commit.d.ts +0 -49
- package/lib/types/src/apply-patch/grammar.d.ts +0 -29
- package/lib/types/src/apply-patch/lifecycle.d.ts +0 -32
- package/lib/types/src/apply-patch/plan.d.ts +0 -50
- package/lib/types/src/apply-patch/render.d.ts +0 -51
- package/lib/types/src/apply-patch/resolve.d.ts +0 -129
- package/lib/types/src/apply-patch/tool.d.ts +0 -54
- package/lib/types/src/apply-patch/types.d.ts +0 -362
- package/lib/types/src/fs-lifecycle.d.ts +0 -44
- package/lib/types/src/host-utils.d.ts +0 -63
- package/lib/types/src/mutation-engine/edit-request.d.ts +0 -34
- package/prompts/apply-patch-guidelines.md +0 -10
- package/prompts/apply-patch.md +0 -1
- package/src/apply-patch/commit.ts +0 -190
- package/src/apply-patch/grammar.ts +0 -620
- package/src/apply-patch/index.ts +0 -17
- package/src/apply-patch/lifecycle.ts +0 -195
- package/src/apply-patch/plan.ts +0 -800
- package/src/apply-patch/render.ts +0 -137
- package/src/apply-patch/resolve.ts +0 -470
- package/src/apply-patch/tool.ts +0 -257
- package/src/apply-patch/types.ts +0 -387
- package/src/fs-lifecycle.ts +0 -201
- package/src/host-utils.ts +0 -224
- package/src/mutation-engine/edit-request.ts +0 -39
|
@@ -0,0 +1,233 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
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/undo-store.ts
CHANGED
|
@@ -29,25 +29,8 @@ export interface UndoRecord {
|
|
|
29
29
|
* `null` is a row from before this column, or an ordinary single-file edit.
|
|
30
30
|
*/
|
|
31
31
|
rawPre?: string | null;
|
|
32
|
-
/**
|
|
33
|
-
* The lifecycle action this row reverses. Absent/null is an ordinary edit (back-compatible).
|
|
34
|
-
* add = the file did not exist before; delete = the file does not exist after; move = a pure
|
|
35
|
-
* rename with an optional content edit.
|
|
36
|
-
*/
|
|
37
|
-
lifecycle?: UndoLifecycle | null;
|
|
38
|
-
/** Move: the absolute path the file had before the transaction. */
|
|
39
|
-
fromPath?: string | null;
|
|
40
|
-
/** Move: the absolute path the file has after the transaction. */
|
|
41
|
-
toPath?: string | null;
|
|
42
|
-
/** The RAW post-transaction text when the commit wrote non-canonical bytes (pure move). */
|
|
43
|
-
rawPost?: string | null;
|
|
44
|
-
/** Add: the directories the add created, outermost first, JSON-encoded in the column. */
|
|
45
|
-
createdDirs?: readonly string[] | null;
|
|
46
32
|
}
|
|
47
33
|
|
|
48
|
-
/** The lifecycle action one undo row reverses. */
|
|
49
|
-
export type UndoLifecycle = "edit" | "add" | "delete" | "move";
|
|
50
|
-
|
|
51
34
|
export type CutIntentDirection = "revert" | null;
|
|
52
35
|
|
|
53
36
|
export interface CutIntent {
|
|
@@ -67,11 +50,6 @@ export interface UndoStmts {
|
|
|
67
50
|
snapshotHash: string | null,
|
|
68
51
|
transactionId: string | null,
|
|
69
52
|
rawPre: string | null,
|
|
70
|
-
lifecycle: string | null,
|
|
71
|
-
fromPath: string | null,
|
|
72
|
-
toPath: string | null,
|
|
73
|
-
rawPost: string | null,
|
|
74
|
-
createdDirs: string | null,
|
|
75
53
|
anchorGeneration: number,
|
|
76
54
|
updatedAt: number,
|
|
77
55
|
) => void;
|
|
@@ -97,15 +75,15 @@ export function undoStmts(db: DatabaseSync): UndoStmts {
|
|
|
97
75
|
|
|
98
76
|
function buildStmts(db: DatabaseSync): UndoStmts {
|
|
99
77
|
const undoUpsertStmt = db.prepare(
|
|
100
|
-
"INSERT INTO file_undo (path, content, bom, ending, hashes, result_content, snapshot_hash, transaction_id, raw_pre,
|
|
101
|
-
"ON CONFLICT(path) DO UPDATE SET content = excluded.content, bom = excluded.bom, ending = excluded.ending, hashes = excluded.hashes, result_content = excluded.result_content, snapshot_hash = excluded.snapshot_hash, transaction_id = excluded.transaction_id, raw_pre = excluded.raw_pre,
|
|
78
|
+
"INSERT INTO file_undo (path, content, bom, ending, hashes, result_content, snapshot_hash, transaction_id, raw_pre, anchor_generation, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " +
|
|
79
|
+
"ON CONFLICT(path) DO UPDATE SET content = excluded.content, bom = excluded.bom, ending = excluded.ending, hashes = excluded.hashes, result_content = excluded.result_content, snapshot_hash = excluded.snapshot_hash, transaction_id = excluded.transaction_id, raw_pre = excluded.raw_pre, anchor_generation = excluded.anchor_generation, updated_at = excluded.updated_at",
|
|
102
80
|
);
|
|
103
81
|
const undoGetStmt = db.prepare(
|
|
104
|
-
"SELECT content, bom, ending, hashes, result_content, snapshot_hash, transaction_id, raw_pre,
|
|
82
|
+
"SELECT content, bom, ending, hashes, result_content, snapshot_hash, transaction_id, raw_pre, anchor_generation FROM file_undo WHERE path = ?",
|
|
105
83
|
);
|
|
106
84
|
const undoDelStmt = db.prepare("DELETE FROM file_undo WHERE path = ?");
|
|
107
85
|
const undoGetTransactionStmt = db.prepare(
|
|
108
|
-
"SELECT path, content, bom, ending, hashes, result_content, snapshot_hash, transaction_id, raw_pre,
|
|
86
|
+
"SELECT path, content, bom, ending, hashes, result_content, snapshot_hash, transaction_id, raw_pre, anchor_generation FROM file_undo WHERE transaction_id = ? ORDER BY path",
|
|
109
87
|
);
|
|
110
88
|
const undoDelTransactionStmt = db.prepare("DELETE FROM file_undo WHERE transaction_id = ?");
|
|
111
89
|
const intentUpsertStmt = db.prepare(
|
|
@@ -127,11 +105,6 @@ function buildStmts(db: DatabaseSync): UndoStmts {
|
|
|
127
105
|
snapshotHash,
|
|
128
106
|
transactionId,
|
|
129
107
|
rawPre,
|
|
130
|
-
lifecycle,
|
|
131
|
-
fromPath,
|
|
132
|
-
toPath,
|
|
133
|
-
rawPost,
|
|
134
|
-
createdDirs,
|
|
135
108
|
anchorGeneration,
|
|
136
109
|
updatedAt,
|
|
137
110
|
) => {
|
|
@@ -146,11 +119,6 @@ function buildStmts(db: DatabaseSync): UndoStmts {
|
|
|
146
119
|
snapshotHash,
|
|
147
120
|
transactionId,
|
|
148
121
|
rawPre,
|
|
149
|
-
lifecycle,
|
|
150
|
-
fromPath,
|
|
151
|
-
toPath,
|
|
152
|
-
rawPost,
|
|
153
|
-
createdDirs,
|
|
154
122
|
anchorGeneration,
|
|
155
123
|
updatedAt,
|
|
156
124
|
);
|
|
@@ -196,25 +164,12 @@ onStoreOpen((db) => {
|
|
|
196
164
|
ensureFileUndoSchema(db);
|
|
197
165
|
});
|
|
198
166
|
|
|
199
|
-
/** Parse the JSON-encoded created-directory list; a corrupt value degrades to an empty list. */
|
|
200
|
-
function parseCreatedDirs(value: unknown): string[] | null {
|
|
201
|
-
if (typeof value !== "string" || value.length === 0) return null;
|
|
202
|
-
try {
|
|
203
|
-
const parsed: unknown = JSON.parse(value);
|
|
204
|
-
return Array.isArray(parsed) && parsed.every((entry) => typeof entry === "string")
|
|
205
|
-
? (parsed as string[])
|
|
206
|
-
: null;
|
|
207
|
-
} catch {
|
|
208
|
-
return null;
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
167
|
function parseUndoRow(row: Record<string, unknown> | undefined): UndoRecord | undefined {
|
|
213
168
|
if (!row) return undefined;
|
|
214
169
|
try {
|
|
215
170
|
const parsed = JSON.parse(row.hashes as string);
|
|
216
171
|
if (!isValidHashList(parsed)) return undefined;
|
|
217
|
-
|
|
172
|
+
return {
|
|
218
173
|
content: row.content as string,
|
|
219
174
|
bom: row.bom as string,
|
|
220
175
|
ending: row.ending as string,
|
|
@@ -225,23 +180,6 @@ function parseUndoRow(row: Record<string, unknown> | undefined): UndoRecord | un
|
|
|
225
180
|
rawPre: (row.raw_pre as string | null) ?? null,
|
|
226
181
|
anchorGeneration: (row.anchor_generation as number | null) ?? 0,
|
|
227
182
|
};
|
|
228
|
-
// WHY: the lifecycle fields are additive; an ordinary row omits them entirely so its
|
|
229
|
-
// WHY: pre-extension shape stays byte-for-byte what existing consumers assert.
|
|
230
|
-
if (row.lifecycle !== null && row.lifecycle !== undefined) {
|
|
231
|
-
record.lifecycle = row.lifecycle as UndoLifecycle;
|
|
232
|
-
}
|
|
233
|
-
if (row.from_path !== null && row.from_path !== undefined) {
|
|
234
|
-
record.fromPath = row.from_path as string;
|
|
235
|
-
}
|
|
236
|
-
if (row.to_path !== null && row.to_path !== undefined) {
|
|
237
|
-
record.toPath = row.to_path as string;
|
|
238
|
-
}
|
|
239
|
-
if (row.raw_post !== null && row.raw_post !== undefined) {
|
|
240
|
-
record.rawPost = row.raw_post as string;
|
|
241
|
-
}
|
|
242
|
-
const createdDirs = parseCreatedDirs(row.created_dirs);
|
|
243
|
-
if (createdDirs !== null) record.createdDirs = createdDirs;
|
|
244
|
-
return record;
|
|
245
183
|
} catch {
|
|
246
184
|
return undefined;
|
|
247
185
|
}
|
|
@@ -258,13 +196,6 @@ export function upsertUndo(store: HashStore, path: string, entry: UndoRecord): v
|
|
|
258
196
|
entry.snapshotHash ?? null,
|
|
259
197
|
entry.transactionId ?? null,
|
|
260
198
|
entry.rawPre ?? null,
|
|
261
|
-
entry.lifecycle ?? null,
|
|
262
|
-
entry.fromPath ?? null,
|
|
263
|
-
entry.toPath ?? null,
|
|
264
|
-
entry.rawPost ?? null,
|
|
265
|
-
entry.createdDirs === null || entry.createdDirs === undefined
|
|
266
|
-
? null
|
|
267
|
-
: JSON.stringify(entry.createdDirs),
|
|
268
199
|
// WHY: ADR-0031 §4 (refuse, do not launder) — the stamp comes from the payload, never
|
|
269
200
|
// WHY: hard-coded: a replay of a pre-generation row preserves its generation so the
|
|
270
201
|
// WHY: restore gate re-derives, while a fresh write (absent generation) defaults current.
|