@narumitw/pi-files 0.0.0-stage → 0.1.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 (64) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/LICENSE +21 -0
  3. package/README.md +102 -2
  4. package/dist/chunks/chunk-3GQ7VS22.ts +273 -0
  5. package/dist/chunks/chunk-3GQ7VS22.ts.map +7 -0
  6. package/dist/chunks/chunk-AXW73HDJ.ts +31 -0
  7. package/dist/chunks/chunk-AXW73HDJ.ts.map +7 -0
  8. package/dist/chunks/chunk-F5QFC255.ts +101 -0
  9. package/dist/chunks/chunk-F5QFC255.ts.map +7 -0
  10. package/dist/chunks/chunk-NFNGJETD.ts +77 -0
  11. package/dist/chunks/chunk-NFNGJETD.ts.map +7 -0
  12. package/dist/chunks/chunk-OPGTWNJI.ts +258 -0
  13. package/dist/chunks/chunk-OPGTWNJI.ts.map +7 -0
  14. package/dist/chunks/chunk-WOTDNRTG.ts +242 -0
  15. package/dist/chunks/chunk-WOTDNRTG.ts.map +7 -0
  16. package/dist/chunks/file-context-explorer-WR6GQZHF.ts +1793 -0
  17. package/dist/chunks/file-context-explorer-WR6GQZHF.ts.map +7 -0
  18. package/dist/chunks/file-context-menu-VPLHUUQ6.ts +302 -0
  19. package/dist/chunks/file-context-menu-VPLHUUQ6.ts.map +7 -0
  20. package/dist/chunks/files-menu-MES4MQ4F.ts +119 -0
  21. package/dist/chunks/files-menu-MES4MQ4F.ts.map +7 -0
  22. package/dist/chunks/files-settings-NYXQU2HV.ts +295 -0
  23. package/dist/chunks/files-settings-NYXQU2HV.ts.map +7 -0
  24. package/dist/chunks/menu-P6IBMROH.ts +876 -0
  25. package/dist/chunks/menu-P6IBMROH.ts.map +7 -0
  26. package/dist/index.ts +1789 -0
  27. package/dist/index.ts.map +7 -0
  28. package/docs/edit-scope-reference.md +142 -0
  29. package/docs/file-context-reference.md +91 -0
  30. package/docs/migration.md +105 -0
  31. package/docs/verification.md +99 -0
  32. package/package.json +58 -4
  33. package/src/content-search-session.ts +204 -0
  34. package/src/content-search-ui.ts +159 -0
  35. package/src/content-search.ts +170 -0
  36. package/src/context-service.ts +515 -0
  37. package/src/external-editor.ts +143 -0
  38. package/src/file-browser-ui.ts +128 -0
  39. package/src/file-browser.ts +76 -0
  40. package/src/file-context-explorer.ts +992 -0
  41. package/src/file-context-menu.ts +360 -0
  42. package/src/file-context-preview-ui.ts +354 -0
  43. package/src/file-context-settings.ts +301 -0
  44. package/src/file-context.ts +321 -0
  45. package/src/file-search.ts +246 -0
  46. package/src/files-menu.ts +125 -0
  47. package/src/files-settings.ts +314 -0
  48. package/src/files-widget.ts +65 -0
  49. package/src/files.ts +148 -0
  50. package/src/git-context.ts +485 -0
  51. package/src/index.ts +1 -0
  52. package/src/scope/contracts.ts +243 -0
  53. package/src/scope/display.ts +27 -0
  54. package/src/scope/edit-scope.ts +435 -0
  55. package/src/scope/files.ts +118 -0
  56. package/src/scope/input.ts +207 -0
  57. package/src/scope/menu.ts +277 -0
  58. package/src/scope/paths.ts +142 -0
  59. package/src/scope/picker-keys.ts +154 -0
  60. package/src/scope/picker.ts +484 -0
  61. package/src/scope/policy.ts +55 -0
  62. package/src/scope/settings.ts +138 -0
  63. package/src/scope/state.ts +91 -0
  64. package/src/scope/ui.ts +106 -0
@@ -0,0 +1,243 @@
1
+ import { createHash } from "node:crypto";
2
+ import { pathToFileURL } from "node:url";
3
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
4
+ import { convertToLlm, type SessionEntry } from "@earendil-works/pi-coding-agent";
5
+ import { displayPath } from "./display.js";
6
+ import { parseSnapshot, type ScopeState, STATE_TYPE } from "./state.js";
7
+
8
+ // Keep legacy identities and notice text stable: saved anchors hash the model-visible prefix.
9
+ export const NOTICE_TYPE = "scope-contract";
10
+ export const ANCHOR_TYPE = "scope-context-anchor";
11
+ export const MAX_NOTICE_BYTES = 48_000;
12
+
13
+ export interface ContractAnchor {
14
+ version: 1;
15
+ stateId: string;
16
+ state: ScopeState["state"];
17
+ count: number;
18
+ digest: string;
19
+ sentAt: number;
20
+ /** A new instance of a contract that already exists earlier in the projected history. */
21
+ recovery?: number;
22
+ }
23
+
24
+ type ScopeNotice = Extract<AgentMessage, { role: "custom" }>;
25
+ interface PlacedAnchor {
26
+ anchor: ContractAnchor;
27
+ notice: ScopeNotice;
28
+ }
29
+
30
+ const NON_MODEL_FIELDS = new Set([
31
+ "timestamp",
32
+ "durationMs",
33
+ "usage",
34
+ "stopReason",
35
+ "errorMessage",
36
+ "api",
37
+ "provider",
38
+ "model",
39
+ "details",
40
+ "nestedCalls",
41
+ ]);
42
+
43
+ export function createScopeNotice(state: ScopeState, timestamp = 0): ScopeNotice | undefined {
44
+ if (!state.entryId) return undefined;
45
+ let content: string;
46
+ if (state.state === "blocked") {
47
+ content =
48
+ "Scope state is unavailable. Do not edit files or use writing commands. The user must repair it with /scope or exit with /scope off. Reading remains available.";
49
+ } else if (state.state === "inactive") {
50
+ content =
51
+ "The user ended the Scope editing restriction. Earlier Scope notices no longer apply. All other existing instructions and tool restrictions remain unchanged.";
52
+ } else {
53
+ content = [
54
+ "Scope editing restriction is active. This notice replaces earlier Scope notices.",
55
+ "Only write or edit these exact selected file targets. Use their file URLs as tool paths to preserve literal Unicode and control characters:",
56
+ ...(state.snapshot?.targets ?? []).map(
57
+ (target) =>
58
+ `- ${JSON.stringify(pathToFileURL(target.path).href)}${target.path === target.canonical ? "" : ` (canonical target ${displayPath(target.canonical)})`}`,
59
+ ),
60
+ "Other files may be read. Shell commands and unsupported tools cannot bypass this editing boundary. Only the user can change or end the scope with /scope.",
61
+ ].join("\n");
62
+ }
63
+ if (Buffer.byteLength(content) > MAX_NOTICE_BYTES)
64
+ throw new Error(`Scope notice exceeds ${MAX_NOTICE_BYTES} bytes; select fewer files`);
65
+ return {
66
+ role: "custom",
67
+ customType: NOTICE_TYPE,
68
+ content,
69
+ display: false,
70
+ details: { version: 1, stateId: state.entryId },
71
+ timestamp,
72
+ };
73
+ }
74
+
75
+ function contractKey(message: AgentMessage): string | undefined {
76
+ if (message.role !== "custom" || message.customType !== NOTICE_TYPE || typeof message.content !== "string") return;
77
+ const details = message.details as { version?: unknown; stateId?: unknown } | undefined;
78
+ if (details?.version !== 1 || typeof details.stateId !== "string") return;
79
+ return JSON.stringify([details.stateId, message.content]);
80
+ }
81
+
82
+ function noticeKey(message: AgentMessage): string | undefined {
83
+ const key = contractKey(message);
84
+ if (!key) return;
85
+ const recovery = (message as ScopeNotice).details as { recovery?: unknown } | undefined;
86
+ return JSON.stringify([key, typeof recovery?.recovery === "number" ? recovery.recovery : 0]);
87
+ }
88
+
89
+ function anchoredNotice(notice: ScopeNotice, anchor: ContractAnchor): ScopeNotice {
90
+ return anchor.recovery === undefined
91
+ ? notice
92
+ : { ...notice, details: { version: 1, stateId: anchor.stateId, recovery: anchor.recovery } };
93
+ }
94
+
95
+ function prefixDigests(messages: readonly AgentMessage[], counts: ReadonlySet<number>): Map<number, string> {
96
+ const hash = createHash("sha256");
97
+ const results = new Map<number, string>();
98
+ if (counts.has(0)) results.set(0, hash.copy().digest("hex"));
99
+ for (let index = 0; index < messages.length; index += 1) {
100
+ const normalized = convertToLlm([messages[index]]).map((message) =>
101
+ Object.fromEntries(Object.entries(message).filter(([key]) => !NON_MODEL_FIELDS.has(key))),
102
+ );
103
+ hash.update(JSON.stringify(normalized));
104
+ hash.update("\n");
105
+ if (counts.has(index + 1)) results.set(index + 1, hash.copy().digest("hex"));
106
+ }
107
+ return results;
108
+ }
109
+
110
+ function readAnchors(branch: readonly SessionEntry[]): PlacedAnchor[] {
111
+ const states = new Map(
112
+ branch
113
+ .filter((entry) => entry.type === "custom" && entry.customType === STATE_TYPE)
114
+ .map((entry) => [entry.id, entry]),
115
+ );
116
+ const found = new Map<string, PlacedAnchor>();
117
+ for (const entry of branch) {
118
+ if (entry.type !== "custom" || entry.customType !== ANCHOR_TYPE || !entry.data || typeof entry.data !== "object")
119
+ continue;
120
+ const value = entry.data as Partial<ContractAnchor>;
121
+ if (
122
+ value.version !== 1 ||
123
+ typeof value.stateId !== "string" ||
124
+ !["active", "inactive", "blocked"].includes(value.state ?? "") ||
125
+ !Number.isSafeInteger(value.count) ||
126
+ (value.count as number) < 0 ||
127
+ typeof value.digest !== "string" ||
128
+ !/^[a-f0-9]{64}$/.test(value.digest) ||
129
+ typeof value.sentAt !== "number" ||
130
+ !Number.isFinite(value.sentAt) ||
131
+ (value.recovery !== undefined && (!Number.isSafeInteger(value.recovery) || value.recovery <= 0))
132
+ )
133
+ continue;
134
+ const source = states.get(value.stateId);
135
+ if (source?.type !== "custom") continue;
136
+ try {
137
+ const state: ScopeState =
138
+ value.state === "blocked"
139
+ ? { state: "blocked", entryId: source.id }
140
+ : { state: value.state as "active" | "inactive", entryId: source.id, snapshot: parseSnapshot(source.data) };
141
+ if (state.state !== "blocked" && (state.snapshot?.targets === null) !== (state.state === "inactive")) continue;
142
+ const anchor = value as ContractAnchor;
143
+ const notice = createScopeNotice(state, anchor.sentAt);
144
+ if (!notice) continue;
145
+ const key = JSON.stringify([anchor.stateId, anchor.state, anchor.count, anchor.digest, anchor.recovery ?? 0]);
146
+ if (!found.has(key)) found.set(key, { anchor, notice: anchoredNotice(notice, anchor) });
147
+ } catch {
148
+ // An invalid historical anchor cannot become model instructions.
149
+ }
150
+ }
151
+ return [...found.values()];
152
+ }
153
+
154
+ function baseFor(
155
+ messages: readonly AgentMessage[],
156
+ anchors: readonly PlacedAnchor[],
157
+ exclude: readonly ScopeNotice[] = [],
158
+ ): AgentMessage[] {
159
+ const keys = new Set([...anchors.map(({ notice }) => noticeKey(notice)), ...exclude.map(noticeKey)]);
160
+ return messages.filter((message) => !keys.has(noticeKey(message)));
161
+ }
162
+
163
+ /** Keep only anchors whose original message prefix still exists after compaction or tree changes. */
164
+ function project(
165
+ messages: readonly AgentMessage[],
166
+ candidates: readonly PlacedAnchor[],
167
+ exclude: readonly ScopeNotice[] = [],
168
+ ) {
169
+ const anchors = [...candidates];
170
+ let base: AgentMessage[];
171
+ for (;;) {
172
+ base = baseFor(messages, anchors, exclude);
173
+ const hashes = prefixDigests(base, new Set(anchors.map(({ anchor }) => anchor.count)));
174
+ const firstSlot = base[0]?.role === "system" ? 1 : 0;
175
+ const stale = anchors.findIndex(
176
+ ({ anchor }) => anchor.count < firstSlot || hashes.get(anchor.count) !== anchor.digest,
177
+ );
178
+ if (stale < 0) break;
179
+ // Removing an older anchor restores its native notice to the base. A newer recovery
180
+ // digest can include that notice, so recompute before rejecting the next candidate.
181
+ anchors.splice(stale, 1);
182
+ }
183
+ const at = new Map<number, ScopeNotice[]>();
184
+ for (const { anchor, notice } of anchors) {
185
+ const notices = at.get(anchor.count) ?? [];
186
+ notices.push(notice);
187
+ at.set(anchor.count, notices);
188
+ }
189
+ const placed: AgentMessage[] = [];
190
+ for (let index = 0; index <= base.length; index += 1) {
191
+ placed.push(...(at.get(index) ?? []));
192
+ if (index < base.length) placed.push(base[index]);
193
+ }
194
+ return { messages: placed, base, anchors };
195
+ }
196
+
197
+ export function hasCurrentScopeNotice(messages: readonly AgentMessage[], state: ScopeState): boolean {
198
+ const expected = createScopeNotice(state);
199
+ if (!expected) return true;
200
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
201
+ if (messages[index].role === "custom" && (messages[index] as ScopeNotice).customType === NOTICE_TYPE) {
202
+ return contractKey(messages[index]) === contractKey(expected);
203
+ }
204
+ }
205
+ return false;
206
+ }
207
+
208
+ /**
209
+ * A missing contract is stored as a non-model session entry before it is returned from context.
210
+ * Its fixed prefix anchor, rather than wall-clock ordering or a moving tail, also places a late
211
+ * sendMessage publication at the boundary where the model first saw it. Untouched messages retain
212
+ * their content and relative order. Anchors are retained across off/change notices, not erased.
213
+ */
214
+ export function reconcileScopeContext(
215
+ messages: readonly AgentMessage[],
216
+ branch: readonly SessionEntry[],
217
+ state: ScopeState,
218
+ sentAt = Date.now(),
219
+ ): { messages: AgentMessage[]; anchor?: ContractAnchor } {
220
+ const candidates = readAnchors(branch);
221
+ const placed = project(messages, candidates);
222
+ if (hasCurrentScopeNotice(placed.messages, state)) return { messages: placed.messages };
223
+ const notice = createScopeNotice(state, sentAt);
224
+ if (!notice || !state.entryId) return { messages: placed.messages };
225
+ // A repeated contract starts a new instance. Removing its earlier native notice can
226
+ // invalidate historical anchors whose digests include it, for example after blocked -> active.
227
+ const repeated = placed.messages.some((message) => contractKey(message) === contractKey(notice));
228
+ const prepared = project(messages, placed.anchors, repeated ? [] : [notice]);
229
+ const count = prepared.base.length;
230
+ const digest = prefixDigests(prepared.base, new Set([count])).get(count) as string;
231
+ const anchor: ContractAnchor = { version: 1, stateId: state.entryId, state: state.state, count, digest, sentAt };
232
+ if (repeated) {
233
+ anchor.recovery =
234
+ candidates.reduce(
235
+ (latest, candidate) =>
236
+ candidate.anchor.stateId === state.entryId ? Math.max(latest, candidate.anchor.recovery ?? 0) : latest,
237
+ 0,
238
+ ) + 1;
239
+ if (!Number.isSafeInteger(anchor.recovery)) throw new Error("Scope recovery sequence is exhausted");
240
+ }
241
+ const next = project(messages, [...prepared.anchors, { anchor, notice: anchoredNotice(notice, anchor) }]);
242
+ return { messages: next.messages, anchor };
243
+ }
@@ -0,0 +1,27 @@
1
+ import { sanitizeTerminalText } from "@narumitw/pi-tui-kit/terminal-text";
2
+
3
+ // Escape controls rather than deleting bytes from a path's display identity.
4
+ // JSON quotes also expose leading/trailing spaces and distinguish literal backslashes.
5
+ export function displayPath(path: string): string {
6
+ return JSON.stringify(path).replace(
7
+ /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\u00a0\u1680\u2000-\u200a\u202f\u205f\u3000]/gu,
8
+ escapeCharacter,
9
+ );
10
+ }
11
+
12
+ /** Keep ordinary picker labels readable without hiding ambiguous or unsafe bytes. */
13
+ export function displayFilePath(path: string): string {
14
+ const quoted = displayPath(path);
15
+ return quoted === `"${path}"` && !/^\s|\s$| {2}/u.test(path) ? path : quoted;
16
+ }
17
+
18
+ function escapeCharacter(character: string): string {
19
+ return Array.from(character, (part) => {
20
+ const point = part.codePointAt(0) ?? 0;
21
+ return point > 0xffff ? `\\u{${point.toString(16)}}` : `\\u${point.toString(16).padStart(4, "0")}`;
22
+ }).join("");
23
+ }
24
+
25
+ export function displayError(error: unknown): string {
26
+ return sanitizeTerminalText(error instanceof Error ? error.message : String(error));
27
+ }