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.
Files changed (131) hide show
  1. package/README.md +72 -70
  2. package/cordis.patch.yml +1 -1
  3. package/lib/index.js +4494 -4813
  4. package/lib/types/dsh/contracts.d.ts +69 -6
  5. package/lib/types/dsh/fs.d.ts +38 -3
  6. package/lib/types/dsh/guidance.d.ts +5 -5
  7. package/lib/types/dsh/hide.d.ts +78 -0
  8. package/lib/types/dsh/host-compat.d.ts +7 -78
  9. package/lib/types/dsh/index.d.ts +17 -7
  10. package/lib/types/dsh/metrics/events.d.ts +8 -1
  11. package/lib/types/dsh/metrics/instrument.d.ts +20 -14
  12. package/lib/types/dsh/metrics/observer.d.ts +28 -0
  13. package/lib/types/dsh/sandbox.d.ts +2 -2
  14. package/lib/types/dsh/shell-hook.d.ts +17 -8
  15. package/lib/types/dsh/shell-view.d.ts +65 -3
  16. package/lib/types/dsh/tools/apply-patch.d.ts +38 -0
  17. package/lib/types/dsh/tools/context.d.ts +19 -1
  18. package/lib/types/dsh/tools/output.d.ts +32 -4
  19. package/lib/types/dsh/tools/read.d.ts +12 -15
  20. package/lib/types/dsh/tools/undo.d.ts +5 -0
  21. package/lib/types/dsh/write-hook.d.ts +8 -22
  22. package/lib/types/src/apply-patch/commit.d.ts +49 -0
  23. package/lib/types/src/apply-patch/grammar.d.ts +29 -0
  24. package/lib/types/src/apply-patch/lifecycle.d.ts +32 -0
  25. package/lib/types/src/apply-patch/plan.d.ts +50 -0
  26. package/lib/types/src/apply-patch/render.d.ts +51 -0
  27. package/lib/types/src/apply-patch/resolve.d.ts +129 -0
  28. package/lib/types/src/apply-patch/tool.d.ts +54 -0
  29. package/lib/types/src/apply-patch/types.d.ts +362 -0
  30. package/lib/types/src/bash-view-banner.d.ts +20 -0
  31. package/lib/types/src/domain-errors.d.ts +16 -2
  32. package/lib/types/src/edit-undo.d.ts +11 -2
  33. package/lib/types/src/file-content/detection.d.ts +17 -0
  34. package/lib/types/src/file-content/index.d.ts +2 -2
  35. package/lib/types/src/file-content/preview.d.ts +1 -1
  36. package/lib/types/src/fs-lifecycle.d.ts +44 -0
  37. package/lib/types/src/hashline/resolve.d.ts +6 -0
  38. package/lib/types/src/host-utils.d.ts +63 -0
  39. package/lib/types/src/mutation-engine/batch-span-gate.d.ts +6 -0
  40. package/lib/types/src/mutation-engine/edit-request.d.ts +34 -0
  41. package/lib/types/src/mutation-engine/pipeline.d.ts +18 -3
  42. package/lib/types/src/mutation-engine/types.d.ts +50 -58
  43. package/lib/types/src/read-expansion/grammar-manifest.d.ts +4 -2
  44. package/lib/types/src/read-expansion/java-decoration.d.ts +21 -0
  45. package/lib/types/src/read-expansion/span-expansion.d.ts +6 -2
  46. package/lib/types/src/read-expansion/span-kind.d.ts +3 -2
  47. package/lib/types/src/read-expansion/vue-decoration.d.ts +14 -0
  48. package/lib/types/src/served-session/serve-render.d.ts +9 -3
  49. package/lib/types/src/undo-store.d.ts +17 -1
  50. package/package.json +10 -22
  51. package/prompts/apply-patch-guidelines.md +10 -0
  52. package/prompts/apply-patch.md +1 -0
  53. package/prompts/read-guidelines.md +5 -5
  54. package/prompts/read.md +1 -1
  55. package/prompts/undo-last-edit-guidelines.md +2 -2
  56. package/prompts/undo-last-edit-snippet.md +1 -1
  57. package/prompts/undo-last-edit.md +1 -1
  58. package/src/apply-patch/commit.ts +190 -0
  59. package/src/apply-patch/grammar.ts +620 -0
  60. package/src/apply-patch/index.ts +17 -0
  61. package/src/apply-patch/lifecycle.ts +195 -0
  62. package/src/apply-patch/plan.ts +800 -0
  63. package/src/apply-patch/render.ts +137 -0
  64. package/src/apply-patch/resolve.ts +470 -0
  65. package/src/apply-patch/tool.ts +257 -0
  66. package/src/apply-patch/types.ts +387 -0
  67. package/src/cut-repair.ts +77 -11
  68. package/src/domain-errors.ts +58 -10
  69. package/src/edit-undo.ts +379 -25
  70. package/src/file-content/detection.ts +34 -0
  71. package/src/file-content/index.ts +2 -2
  72. package/src/file-content/preview.ts +1 -1
  73. package/src/file-content/served-header.ts +1 -1
  74. package/src/fs-lifecycle.ts +201 -0
  75. package/src/hash-store.ts +18 -1
  76. package/src/hashline/lease-resolve.ts +14 -8
  77. package/src/hashline/resolve.ts +30 -27
  78. package/src/host-utils.ts +224 -0
  79. package/src/mutation-engine/batch-span-gate.ts +17 -4
  80. package/src/mutation-engine/edit-request.ts +39 -0
  81. package/src/mutation-engine/pipeline.ts +535 -63
  82. package/src/mutation-engine/types.ts +49 -65
  83. package/src/mutation-queue.ts +1 -1
  84. package/src/prompts.ts +0 -28
  85. package/src/read-expansion/ast-provider.ts +4 -0
  86. package/src/read-expansion/grammar-manifest.ts +21 -2
  87. package/src/read-expansion/java-decoration.ts +34 -0
  88. package/src/read-expansion/span-expansion.ts +6 -2
  89. package/src/read-expansion/span-kind.ts +3 -1
  90. package/src/read-expansion/vue-decoration.ts +43 -0
  91. package/src/served-session/serve-render.ts +5 -5
  92. package/src/undo-store.ts +74 -5
  93. package/lib/types/dsh/encoding-bridge.d.ts +0 -25
  94. package/lib/types/dsh/encoding.d.ts +0 -144
  95. package/lib/types/dsh/tools/edit.d.ts +0 -10
  96. package/lib/types/src/edit-tool.d.ts +0 -41
  97. package/lib/types/src/mutation-engine/engine.d.ts +0 -31
  98. package/lib/types/src/payload-contract.d.ts +0 -124
  99. package/prompts/edit-guidelines.md +0 -10
  100. package/prompts/edit-snippet.md +0 -1
  101. package/prompts/edit.md +0 -1
  102. package/src/bash-classifier.ts +0 -1626
  103. package/src/bash-conversion/decide.ts +0 -812
  104. package/src/bash-display-transform.ts +0 -50
  105. package/src/bash-multifile-config.ts +0 -239
  106. package/src/bash-view-config.ts +0 -158
  107. package/src/edit-normalize.ts +0 -8
  108. package/src/edit-render.ts +0 -161
  109. package/src/edit-tool.ts +0 -116
  110. package/src/edit.ts +0 -136
  111. package/src/integrations/pi-lens/command.ts +0 -712
  112. package/src/integrations/pi-lens/config.ts +0 -148
  113. package/src/integrations/pi-lens/io-bridge-adapter.ts +0 -234
  114. package/src/integrations/pi-lens/io-bridge.ts +0 -112
  115. package/src/integrations/pi-lens/settings-overlay.ts +0 -272
  116. package/src/lifecycle-hooks/index.ts +0 -1992
  117. package/src/lifecycle-hooks/types.ts +0 -64
  118. package/src/mutated-files.ts +0 -75
  119. package/src/mutation-engine/engine.ts +0 -173
  120. package/src/mutation-engine/index.ts +0 -17
  121. package/src/payload-contract.ts +0 -637
  122. package/src/preview-controller.ts +0 -84
  123. package/src/read.ts +0 -865
  124. package/src/search-hook.ts +0 -41
  125. package/src/store-config.ts +0 -73
  126. package/src/store-janitor-warning.ts +0 -169
  127. package/src/store-janitor.ts +0 -539
  128. package/src/store-kernel/settings.ts +0 -233
  129. package/src/store.ts +0 -154
  130. package/src/tui-presenter.ts +0 -134
  131. 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
- }
@@ -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
- }