@bdsqqq/pi 0.0.1

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 (99) hide show
  1. package/dist/chunk-DsIazq42.js +26 -0
  2. package/dist/command-palette-B97yKqne.js +406 -0
  3. package/dist/core/agents-md.d.ts +22 -0
  4. package/dist/core/agents-md.js +174 -0
  5. package/dist/core/box-chrome.d.ts +56 -0
  6. package/dist/core/box-chrome.js +207 -0
  7. package/dist/core/box-format.d.ts +82 -0
  8. package/dist/core/box-format.js +381 -0
  9. package/dist/core/config.d.ts +14 -0
  10. package/dist/core/config.js +201 -0
  11. package/dist/core/file-tracker.d.ts +65 -0
  12. package/dist/core/file-tracker.js +439 -0
  13. package/dist/core/github-api.d.ts +45 -0
  14. package/dist/core/github-api.js +101 -0
  15. package/dist/core/html-to-md.d.ts +5 -0
  16. package/dist/core/html-to-md.js +289 -0
  17. package/dist/core/interpolate.d.ts +44 -0
  18. package/dist/core/interpolate.js +475 -0
  19. package/dist/core/mutex.d.ts +8 -0
  20. package/dist/core/mutex.js +142 -0
  21. package/dist/core/output-buffer.d.ts +80 -0
  22. package/dist/core/output-buffer.js +517 -0
  23. package/dist/core/permissions.d.ts +19 -0
  24. package/dist/core/permissions.js +140 -0
  25. package/dist/core/pi-spawn.d.ts +62 -0
  26. package/dist/core/pi-spawn.js +244 -0
  27. package/dist/core/prompt-patch.d.ts +11 -0
  28. package/dist/core/prompt-patch.js +16 -0
  29. package/dist/core/show-renderer.d.ts +10 -0
  30. package/dist/core/show-renderer.js +39 -0
  31. package/dist/core/show.d.ts +29 -0
  32. package/dist/core/show.js +288 -0
  33. package/dist/core/sub-agent-render.d.ts +56 -0
  34. package/dist/core/sub-agent-render.js +579 -0
  35. package/dist/core/tool-cost.d.ts +17 -0
  36. package/dist/core/tool-cost.js +58 -0
  37. package/dist/core/tui.d.ts +9 -0
  38. package/dist/core/tui.js +33 -0
  39. package/dist/editor-BljciPdx.js +537 -0
  40. package/dist/extensions/bash.d.ts +12 -0
  41. package/dist/extensions/bash.js +261 -0
  42. package/dist/extensions/code-review.d.ts +14 -0
  43. package/dist/extensions/code-review.js +175 -0
  44. package/dist/extensions/command-palette.d.ts +6 -0
  45. package/dist/extensions/command-palette.js +2 -0
  46. package/dist/extensions/create-file.d.ts +7 -0
  47. package/dist/extensions/create-file.js +88 -0
  48. package/dist/extensions/edit-file.d.ts +7 -0
  49. package/dist/extensions/edit-file.js +395 -0
  50. package/dist/extensions/editor.d.ts +6 -0
  51. package/dist/extensions/editor.js +2 -0
  52. package/dist/extensions/finder.d.ts +13 -0
  53. package/dist/extensions/finder.js +108 -0
  54. package/dist/extensions/format-file.d.ts +11 -0
  55. package/dist/extensions/format-file.js +145 -0
  56. package/dist/extensions/github.d.ts +13 -0
  57. package/dist/extensions/github.js +527 -0
  58. package/dist/extensions/glob.d.ts +10 -0
  59. package/dist/extensions/glob.js +163 -0
  60. package/dist/extensions/grep.d.ts +13 -0
  61. package/dist/extensions/grep.js +356 -0
  62. package/dist/extensions/handoff.d.ts +6 -0
  63. package/dist/extensions/handoff.js +277 -0
  64. package/dist/extensions/librarian.d.ts +13 -0
  65. package/dist/extensions/librarian.js +113 -0
  66. package/dist/extensions/look-at.d.ts +13 -0
  67. package/dist/extensions/look-at.js +121 -0
  68. package/dist/extensions/ls.d.ts +8 -0
  69. package/dist/extensions/ls.js +87 -0
  70. package/dist/extensions/mermaid.d.ts +20 -0
  71. package/dist/extensions/mermaid.js +2 -0
  72. package/dist/extensions/oracle.d.ts +13 -0
  73. package/dist/extensions/oracle.js +143 -0
  74. package/dist/extensions/read-session.d.ts +13 -0
  75. package/dist/extensions/read-session.js +265 -0
  76. package/dist/extensions/read-web-page.d.ts +11 -0
  77. package/dist/extensions/read-web-page.js +234 -0
  78. package/dist/extensions/read.d.ts +23 -0
  79. package/dist/extensions/read.js +323 -0
  80. package/dist/extensions/search-sessions.d.ts +29 -0
  81. package/dist/extensions/search-sessions.js +426 -0
  82. package/dist/extensions/session-name.d.ts +6 -0
  83. package/dist/extensions/session-name.js +54 -0
  84. package/dist/extensions/skill.d.ts +7 -0
  85. package/dist/extensions/skill.js +232 -0
  86. package/dist/extensions/system-prompt.d.ts +6 -0
  87. package/dist/extensions/system-prompt.js +31 -0
  88. package/dist/extensions/task.d.ts +11 -0
  89. package/dist/extensions/task.js +114 -0
  90. package/dist/extensions/tool-harness.d.ts +6 -0
  91. package/dist/extensions/tool-harness.js +37 -0
  92. package/dist/extensions/undo-edit.d.ts +7 -0
  93. package/dist/extensions/undo-edit.js +127 -0
  94. package/dist/extensions/web-search.d.ts +12 -0
  95. package/dist/extensions/web-search.js +254 -0
  96. package/dist/extensions.d.ts +29 -0
  97. package/dist/extensions.js +29 -0
  98. package/dist/mermaid-C7xCVBrw.js +95296 -0
  99. package/package.json +67 -0
@@ -0,0 +1,439 @@
1
+ import { createRequire } from "node:module";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import * as os from "node:os";
5
+ //#region packages/core/file-tracker/index.ts
6
+ /**
7
+ * file change tracker — persists before/after content to disk for undo_edit.
8
+ *
9
+ * each edit writes a JSON file to
10
+ * ~/.pi/file-changes/{sessionId}/{toolCallId}.json containing
11
+ * the full before/after content and a unified diff.
12
+ *
13
+ * branch awareness comes from the conversation tree, not from
14
+ * this module. tool call IDs live in assistant messages — when
15
+ * the user navigates branches, only tool calls on the active
16
+ * branch are visible. the undo_edit tool filters by active
17
+ * tool call IDs before consulting the disk.
18
+ */
19
+ const getFileChangesDir = () => globalThis.__PI_FILE_CHANGES_DIR__ ?? path.join(os.homedir(), ".pi", "file-changes");
20
+ function sessionDir(sessionId) {
21
+ return path.join(getFileChangesDir(), sessionId);
22
+ }
23
+ function changePath(sessionId, toolCallId, changeId) {
24
+ return path.join(sessionDir(sessionId), `${toolCallId}.${changeId}`);
25
+ }
26
+ /** ensure the session's file-changes directory exists. */
27
+ function ensureDir(sessionId) {
28
+ const dir = sessionDir(sessionId);
29
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
30
+ }
31
+ /**
32
+ * record a file change to disk. call after performing the edit.
33
+ * the toolCallId comes from the execute() function's first argument.
34
+ * returns the change ID (UUID) for the written record.
35
+ *
36
+ * one tool call can produce multiple changes (e.g., Task sub-agent
37
+ * creating several files). each gets a unique UUID, stored as
38
+ * {toolCallId}.{uuid}.
39
+ */
40
+ function saveChange(sessionId, toolCallId, change) {
41
+ ensureDir(sessionId);
42
+ const id = crypto.randomUUID();
43
+ const record = {
44
+ ...change,
45
+ id,
46
+ reverted: false
47
+ };
48
+ fs.writeFileSync(changePath(sessionId, toolCallId, id), JSON.stringify(record, null, 2), "utf-8");
49
+ return id;
50
+ }
51
+ /**
52
+ * load all change records for a tool call. one tool call can produce
53
+ * multiple changes (different files), each with its own UUID.
54
+ */
55
+ function loadChanges(sessionId, toolCallId) {
56
+ const dir = sessionDir(sessionId);
57
+ if (!fs.existsSync(dir)) return [];
58
+ const prefix = `${toolCallId}.`;
59
+ try {
60
+ return fs.readdirSync(dir).filter((f) => f.startsWith(prefix)).map((f) => {
61
+ try {
62
+ return JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8"));
63
+ } catch {
64
+ return null;
65
+ }
66
+ }).filter((c) => c !== null);
67
+ } catch {
68
+ return [];
69
+ }
70
+ }
71
+ /**
72
+ * mark a specific change as reverted and restore the file.
73
+ * returns the change record, or null if not found / already reverted.
74
+ */
75
+ function revertChange(sessionId, toolCallId, changeId) {
76
+ const p = changePath(sessionId, toolCallId, changeId);
77
+ if (!fs.existsSync(p)) return null;
78
+ let change;
79
+ try {
80
+ change = JSON.parse(fs.readFileSync(p, "utf-8"));
81
+ } catch {
82
+ return null;
83
+ }
84
+ if (change.reverted) return null;
85
+ const filePath = change.uri.replace(/^file:\/\//, "");
86
+ fs.writeFileSync(filePath, change.before, "utf-8");
87
+ change.reverted = true;
88
+ fs.writeFileSync(p, JSON.stringify(change, null, 2), "utf-8");
89
+ return change;
90
+ }
91
+ /**
92
+ * find the most recent non-reverted change for a file path,
93
+ * filtered to only the given tool call IDs (branch awareness).
94
+ *
95
+ * the caller gets activeToolCallIds by scanning the current
96
+ * session branch for edit_file/create_file tool calls.
97
+ */
98
+ function findLatestChange(sessionId, filePath, activeToolCallIds) {
99
+ const uri = `file://${path.resolve(filePath)}`;
100
+ for (let i = activeToolCallIds.length - 1; i >= 0; i--) {
101
+ const toolCallId = activeToolCallIds[i];
102
+ if (!toolCallId) continue;
103
+ const match = loadChanges(sessionId, toolCallId).filter((c) => !c.reverted && c.uri === uri).sort((a, b) => b.timestamp - a.timestamp)[0];
104
+ if (match) return {
105
+ toolCallId,
106
+ change: match
107
+ };
108
+ }
109
+ return null;
110
+ }
111
+ /**
112
+ * graceful require for the `diff` package — falls back to a naive
113
+ * line-by-line diff when the package isn't resolvable (same pattern
114
+ * as cheerio in html-to-md.ts).
115
+ */
116
+ let createPatchFn = null;
117
+ try {
118
+ createPatchFn = createRequire(import.meta.url)("diff").createPatch;
119
+ } catch {}
120
+ /**
121
+ * generate a unified diff between two strings.
122
+ *
123
+ * uses the `diff` npm package (Myers algorithm) when available for
124
+ * proper hunk-based output with context lines. context=3 matches
125
+ * git's default, producing gaps between distant changes that show()
126
+ * can elide in collapsed display.
127
+ *
128
+ * falls back to a naive line-by-line comparison when `diff` isn't
129
+ * installed (produces correct but less optimal output — every line
130
+ * is either +, -, or context with no hunk headers).
131
+ */
132
+ function simpleDiff(filePath, before, after) {
133
+ if (createPatchFn) {
134
+ const lines = createPatchFn(path.basename(filePath), before, after, "original", "modified", { context: 3 }).split("\n");
135
+ const startIdx = lines.findIndex((l) => l.startsWith("---"));
136
+ return (startIdx > 0 ? lines.slice(startIdx) : lines).join("\n");
137
+ }
138
+ const beforeLines = before.split("\n");
139
+ const afterLines = after.split("\n");
140
+ const lines = [`--- ${path.basename(filePath)}\toriginal`, `+++ ${path.basename(filePath)}\tmodified`];
141
+ let i = 0;
142
+ let j = 0;
143
+ while (i < beforeLines.length || j < afterLines.length) if (i < beforeLines.length && j < afterLines.length && beforeLines[i] === afterLines[j]) {
144
+ lines.push(` ${beforeLines[i]}`);
145
+ i++;
146
+ j++;
147
+ } else if (i < beforeLines.length && (j >= afterLines.length || beforeLines[i] !== afterLines[j])) {
148
+ lines.push(`-${beforeLines[i]}`);
149
+ i++;
150
+ } else {
151
+ lines.push(`+${afterLines[j]}`);
152
+ j++;
153
+ }
154
+ return lines.join("\n");
155
+ }
156
+ if (import.meta.vitest) {
157
+ const { describe, it, expect, beforeEach, afterEach } = import.meta.vitest;
158
+ let tmpDir;
159
+ let sessionId;
160
+ beforeEach(() => {
161
+ tmpDir = path.join(os.tmpdir(), `pi-file-tracker-test-${Date.now()}`);
162
+ fs.mkdirSync(tmpDir, { recursive: true });
163
+ sessionId = `test-session-${Date.now()}`;
164
+ globalThis.__PI_FILE_CHANGES_DIR__ = tmpDir;
165
+ });
166
+ afterEach(() => {
167
+ try {
168
+ fs.rmSync(tmpDir, {
169
+ recursive: true,
170
+ force: true
171
+ });
172
+ } catch {}
173
+ });
174
+ describe("simpleDiff", () => {
175
+ it("generates unified diff for added lines", () => {
176
+ const diff = simpleDiff("test.txt", "line1\nline2", "line1\nline2\nline3");
177
+ expect(diff).toContain("--- test.txt");
178
+ expect(diff).toContain("+++ test.txt");
179
+ expect(diff).toContain("+line3");
180
+ });
181
+ it("generates unified diff for removed lines", () => {
182
+ const diff = simpleDiff("test.txt", "line1\nline2\nline3", "line1\nline3");
183
+ expect(diff).toContain("-line2");
184
+ expect(diff).not.toContain("+line2");
185
+ });
186
+ it("generates unified diff for changed lines", () => {
187
+ const diff = simpleDiff("file.txt", "old content", "new content");
188
+ expect(diff).toContain("-old content");
189
+ expect(diff).toContain("+new content");
190
+ });
191
+ it("handles identical content (no changes)", () => {
192
+ const content = "same\nlines\nhere";
193
+ const diff = simpleDiff("same.txt", content, content);
194
+ expect(diff).toContain("--- same.txt");
195
+ expect(diff).toContain("+++ same.txt");
196
+ const lines = diff.split("\n");
197
+ const changedLines = lines.filter((l) => l.startsWith("-") && !l.startsWith("---"));
198
+ const addedLines = lines.filter((l) => l.startsWith("+") && !l.startsWith("+++"));
199
+ expect(changedLines).toHaveLength(0);
200
+ expect(addedLines).toHaveLength(0);
201
+ });
202
+ it("handles empty before content", () => {
203
+ expect(simpleDiff("new.txt", "", "new file content")).toContain("+new file content");
204
+ });
205
+ it("handles empty after content", () => {
206
+ expect(simpleDiff("del.txt", "deleted content", "")).toContain("-deleted content");
207
+ });
208
+ it("includes file basename in diff header", () => {
209
+ const diff = simpleDiff("/path/to/some/file.ts", "a", "b");
210
+ expect(diff).toContain("--- file.ts");
211
+ expect(diff).toContain("+++ file.ts");
212
+ });
213
+ });
214
+ describe("saveChange and loadChanges", () => {
215
+ it("saves a change record to disk and loads it back", () => {
216
+ const toolCallId = "tc-123";
217
+ const filePath = path.join(tmpDir, "test-file.txt");
218
+ const content = "file content here";
219
+ const changeId = saveChange(sessionId, toolCallId, {
220
+ uri: `file://${filePath}`,
221
+ before: "",
222
+ after: content,
223
+ diff: simpleDiff(filePath, "", content),
224
+ isNewFile: true,
225
+ timestamp: Date.now()
226
+ });
227
+ expect(changeId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
228
+ const changes = loadChanges(sessionId, toolCallId);
229
+ expect(changes).toHaveLength(1);
230
+ expect(changes[0].id).toBe(changeId);
231
+ expect(changes[0].uri).toBe(`file://${filePath}`);
232
+ expect(changes[0].before).toBe("");
233
+ expect(changes[0].after).toBe(content);
234
+ expect(changes[0].isNewFile).toBe(true);
235
+ expect(changes[0].reverted).toBe(false);
236
+ });
237
+ it("supports multiple changes per tool call", () => {
238
+ const toolCallId = "tc-multi";
239
+ const file1 = path.join(tmpDir, "file1.txt");
240
+ const file2 = path.join(tmpDir, "file2.txt");
241
+ const id1 = saveChange(sessionId, toolCallId, {
242
+ uri: `file://${file1}`,
243
+ before: "",
244
+ after: "content1",
245
+ diff: "",
246
+ isNewFile: true,
247
+ timestamp: Date.now()
248
+ });
249
+ const id2 = saveChange(sessionId, toolCallId, {
250
+ uri: `file://${file2}`,
251
+ before: "",
252
+ after: "content2",
253
+ diff: "",
254
+ isNewFile: true,
255
+ timestamp: Date.now()
256
+ });
257
+ expect(id1).not.toBe(id2);
258
+ const changes = loadChanges(sessionId, toolCallId);
259
+ expect(changes).toHaveLength(2);
260
+ const uris = changes.map((c) => c.uri);
261
+ expect(uris).toContain(`file://${file1}`);
262
+ expect(uris).toContain(`file://${file2}`);
263
+ });
264
+ it("returns empty array when no changes exist", () => {
265
+ expect(loadChanges(sessionId, "nonexistent-toolcall")).toEqual([]);
266
+ });
267
+ it("persists changes across calls (real disk)", () => {
268
+ const toolCallId = "tc-persist";
269
+ const filePath = path.join(tmpDir, "persist.txt");
270
+ saveChange(sessionId, toolCallId, {
271
+ uri: `file://${filePath}`,
272
+ before: "old",
273
+ after: "new",
274
+ diff: simpleDiff(filePath, "old", "new"),
275
+ isNewFile: false,
276
+ timestamp: Date.now()
277
+ });
278
+ const changes = loadChanges(sessionId, toolCallId);
279
+ expect(changes).toHaveLength(1);
280
+ expect(changes[0].before).toBe("old");
281
+ expect(changes[0].after).toBe("new");
282
+ });
283
+ });
284
+ describe("revertChange", () => {
285
+ it("restores file to before state and marks reverted", () => {
286
+ const toolCallId = "tc-revert";
287
+ const filePath = path.join(tmpDir, "to-revert.txt");
288
+ fs.writeFileSync(filePath, "original content", "utf-8");
289
+ const changeId = saveChange(sessionId, toolCallId, {
290
+ uri: `file://${filePath}`,
291
+ before: "original content",
292
+ after: "modified content",
293
+ diff: "",
294
+ isNewFile: false,
295
+ timestamp: Date.now()
296
+ });
297
+ fs.writeFileSync(filePath, "modified content", "utf-8");
298
+ expect(fs.readFileSync(filePath, "utf-8")).toBe("modified content");
299
+ const result = revertChange(sessionId, toolCallId, changeId);
300
+ expect(result).not.toBeNull();
301
+ expect(result?.reverted).toBe(true);
302
+ expect(fs.readFileSync(filePath, "utf-8")).toBe("original content");
303
+ expect(loadChanges(sessionId, toolCallId)[0].reverted).toBe(true);
304
+ });
305
+ it("returns null for nonexistent change", () => {
306
+ expect(revertChange(sessionId, "tc-xxx", "nonexistent-id")).toBeNull();
307
+ });
308
+ it("returns null for already reverted change", () => {
309
+ const toolCallId = "tc-revert-twice";
310
+ const filePath = path.join(tmpDir, "revert-once.txt");
311
+ fs.writeFileSync(filePath, "before", "utf-8");
312
+ const changeId = saveChange(sessionId, toolCallId, {
313
+ uri: `file://${filePath}`,
314
+ before: "before",
315
+ after: "after",
316
+ diff: "",
317
+ isNewFile: false,
318
+ timestamp: Date.now()
319
+ });
320
+ expect(revertChange(sessionId, toolCallId, changeId)).not.toBeNull();
321
+ expect(revertChange(sessionId, toolCallId, changeId)).toBeNull();
322
+ });
323
+ it("works for newly created files (isNewFile: true)", () => {
324
+ const toolCallId = "tc-newfile";
325
+ const filePath = path.join(tmpDir, "brand-new.txt");
326
+ const changeId = saveChange(sessionId, toolCallId, {
327
+ uri: `file://${filePath}`,
328
+ before: "",
329
+ after: "new file content",
330
+ diff: "",
331
+ isNewFile: true,
332
+ timestamp: Date.now()
333
+ });
334
+ fs.writeFileSync(filePath, "new file content", "utf-8");
335
+ expect(revertChange(sessionId, toolCallId, changeId)).not.toBeNull();
336
+ expect(fs.readFileSync(filePath, "utf-8")).toBe("");
337
+ });
338
+ });
339
+ describe("findLatestChange", () => {
340
+ it("finds the most recent change for a file", () => {
341
+ const tc1 = "tc-first";
342
+ const tc2 = "tc-second";
343
+ const filePath = path.join(tmpDir, "chain.txt");
344
+ saveChange(sessionId, tc1, {
345
+ uri: `file://${filePath}`,
346
+ before: "v1",
347
+ after: "v2",
348
+ diff: "",
349
+ isNewFile: false,
350
+ timestamp: Date.now() - 2e3
351
+ });
352
+ saveChange(sessionId, tc2, {
353
+ uri: `file://${filePath}`,
354
+ before: "v2",
355
+ after: "v3",
356
+ diff: "",
357
+ isNewFile: false,
358
+ timestamp: Date.now() - 1e3
359
+ });
360
+ const result = findLatestChange(sessionId, filePath, [tc1, tc2]);
361
+ expect(result).not.toBeNull();
362
+ expect(result?.change.before).toBe("v2");
363
+ expect(result?.change.after).toBe("v3");
364
+ expect(result?.toolCallId).toBe(tc2);
365
+ });
366
+ it("skips reverted changes", () => {
367
+ const tc1 = "tc-revert-skip";
368
+ const filePath = path.join(tmpDir, "skip-reverted.txt");
369
+ const changeId = saveChange(sessionId, tc1, {
370
+ uri: `file://${filePath}`,
371
+ before: "old",
372
+ after: "new",
373
+ diff: "",
374
+ isNewFile: false,
375
+ timestamp: Date.now()
376
+ });
377
+ const change = {
378
+ ...loadChanges(sessionId, tc1)[0],
379
+ reverted: true
380
+ };
381
+ const changeFilePath = path.join(tmpDir, sessionId, `${tc1}.${changeId}`);
382
+ fs.writeFileSync(changeFilePath, JSON.stringify(change, null, 2), "utf-8");
383
+ expect(findLatestChange(sessionId, filePath, [tc1])).toBeNull();
384
+ });
385
+ it("respects branch order (activeToolCallIds order)", () => {
386
+ const tc1 = "tc-branch-1";
387
+ const tc2 = "tc-branch-2";
388
+ const filePath = path.join(tmpDir, "branch-order.txt");
389
+ saveChange(sessionId, tc1, {
390
+ uri: `file://${filePath}`,
391
+ before: "a",
392
+ after: "b",
393
+ diff: "",
394
+ isNewFile: false,
395
+ timestamp: Date.now() - 1e3
396
+ });
397
+ saveChange(sessionId, tc2, {
398
+ uri: `file://${filePath}`,
399
+ before: "c",
400
+ after: "d",
401
+ diff: "",
402
+ isNewFile: false,
403
+ timestamp: Date.now()
404
+ });
405
+ expect(findLatestChange(sessionId, filePath, [tc1, tc2])?.change.after).toBe("d");
406
+ expect(findLatestChange(sessionId, filePath, [tc1])?.change.after).toBe("b");
407
+ });
408
+ it("returns null when file has no changes", () => {
409
+ expect(findLatestChange(sessionId, "/nonexistent/file.txt", ["tc-x"])).toBeNull();
410
+ });
411
+ it("handles multiple changes to different files in same tool call", () => {
412
+ const tc = "tc-multi-file";
413
+ const file1 = path.join(tmpDir, "multi1.txt");
414
+ const file2 = path.join(tmpDir, "multi2.txt");
415
+ saveChange(sessionId, tc, {
416
+ uri: `file://${file1}`,
417
+ before: "",
418
+ after: "f1",
419
+ diff: "",
420
+ isNewFile: true,
421
+ timestamp: Date.now() - 1e3
422
+ });
423
+ saveChange(sessionId, tc, {
424
+ uri: `file://${file2}`,
425
+ before: "",
426
+ after: "f2",
427
+ diff: "",
428
+ isNewFile: true,
429
+ timestamp: Date.now()
430
+ });
431
+ const result1 = findLatestChange(sessionId, file1, [tc]);
432
+ const result2 = findLatestChange(sessionId, file2, [tc]);
433
+ expect(result1?.change.after).toBe("f1");
434
+ expect(result2?.change.after).toBe("f2");
435
+ });
436
+ });
437
+ }
438
+ //#endregion
439
+ export { findLatestChange, loadChanges, revertChange, saveChange, simpleDiff };
@@ -0,0 +1,45 @@
1
+ //#region packages/core/github-api/index.d.ts
2
+ interface RepoRef {
3
+ owner: string;
4
+ repo: string;
5
+ }
6
+ /**
7
+ * parse a repository URL or shorthand into owner/repo.
8
+ * accepts:
9
+ * - https://github.com/owner/repo
10
+ * - github.com/owner/repo
11
+ * - owner/repo
12
+ */
13
+ declare function parseRepoUrl(input: string): RepoRef;
14
+ /** build the "owner/repo" string for gh api paths. */
15
+ declare function repoSlug(ref: RepoRef): string;
16
+ /**
17
+ * call `gh api` and return parsed JSON.
18
+ * throws on non-zero exit or JSON parse failure.
19
+ *
20
+ * params are appended as query string (GET-safe). gh api's `-f` flag
21
+ * forces POST, which breaks search/list endpoints.
22
+ */
23
+ declare function ghApi<T = any>(endpoint: string, opts?: {
24
+ method?: string;
25
+ params?: Record<string, string | number>;
26
+ rawOutput?: boolean;
27
+ accept?: string;
28
+ }): T;
29
+ /**
30
+ * paginated gh api call. collects items across pages.
31
+ * github REST APIs return arrays for list endpoints.
32
+ */
33
+ declare function ghApiPaginated<T = any>(endpoint: string, opts?: {
34
+ params?: Record<string, string | number>;
35
+ limit?: number;
36
+ offset?: number;
37
+ }): T[];
38
+ /** decode base64 content from github contents API. */
39
+ declare function decodeBase64Content(encoded: string): string;
40
+ /** add line numbers to file content. */
41
+ declare function addLineNumbers(content: string, startLine?: number): string;
42
+ /** truncate string to max length with indicator. */
43
+ declare function truncate(text: string, maxLen: number): string;
44
+ //#endregion
45
+ export { RepoRef, addLineNumbers, decodeBase64Content, ghApi, ghApiPaginated, parseRepoUrl, repoSlug, truncate };
@@ -0,0 +1,101 @@
1
+ import { spawnSync } from "node:child_process";
2
+ //#region packages/core/github-api/index.ts
3
+ /**
4
+ * shared helpers for github tools.
5
+ *
6
+ * wraps `gh api` CLI calls. requires gh CLI authenticated
7
+ * (GH_TOKEN or `gh auth login`). all tools parse repository
8
+ * URLs in the format "https://github.com/owner/repo" or
9
+ * shorthand "owner/repo".
10
+ */
11
+ /**
12
+ * parse a repository URL or shorthand into owner/repo.
13
+ * accepts:
14
+ * - https://github.com/owner/repo
15
+ * - github.com/owner/repo
16
+ * - owner/repo
17
+ */
18
+ function parseRepoUrl(input) {
19
+ const parts = input.replace(/^https?:\/\//, "").replace(/^github\.com\//, "").replace(/\.git$/, "").replace(/\/$/, "").split("/");
20
+ if (parts.length < 2) throw new Error(`invalid repository: "${input}" — expected "owner/repo" or full URL`);
21
+ return {
22
+ owner: parts[0],
23
+ repo: parts[1]
24
+ };
25
+ }
26
+ /** build the "owner/repo" string for gh api paths. */
27
+ function repoSlug(ref) {
28
+ return `${ref.owner}/${ref.repo}`;
29
+ }
30
+ /**
31
+ * call `gh api` and return parsed JSON.
32
+ * throws on non-zero exit or JSON parse failure.
33
+ *
34
+ * params are appended as query string (GET-safe). gh api's `-f` flag
35
+ * forces POST, which breaks search/list endpoints.
36
+ */
37
+ function ghApi(endpoint, opts) {
38
+ let url = endpoint;
39
+ if (opts?.params) {
40
+ const qs = Object.entries(opts.params).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`).join("&");
41
+ url += (url.includes("?") ? "&" : "?") + qs;
42
+ }
43
+ const args = [
44
+ "gh",
45
+ "api",
46
+ url
47
+ ];
48
+ if (opts?.method) args.push("--method", opts.method);
49
+ if (opts?.accept) args.push("-H", `Accept: ${opts.accept}`);
50
+ const result = spawnSync("gh", args.slice(1), {
51
+ stdio: [
52
+ "ignore",
53
+ "pipe",
54
+ "pipe"
55
+ ],
56
+ encoding: "utf-8",
57
+ maxBuffer: 10 * 1024 * 1024
58
+ });
59
+ if (result.status !== 0) {
60
+ const stderr = result.stderr?.trim() || "unknown error";
61
+ throw new Error(`gh api ${endpoint} failed: ${stderr}`);
62
+ }
63
+ const stdout = result.stdout?.trim() || "";
64
+ if (opts?.rawOutput) return stdout;
65
+ try {
66
+ return JSON.parse(stdout);
67
+ } catch {
68
+ throw new Error(`gh api ${endpoint}: invalid JSON response`);
69
+ }
70
+ }
71
+ /**
72
+ * paginated gh api call. collects items across pages.
73
+ * github REST APIs return arrays for list endpoints.
74
+ */
75
+ function ghApiPaginated(endpoint, opts) {
76
+ const limit = opts?.limit ?? 30;
77
+ const offset = opts?.offset ?? 0;
78
+ const perPage = Math.min(limit, 100);
79
+ const page = Math.floor(offset / perPage) + 1;
80
+ const result = ghApi(endpoint, { params: {
81
+ ...opts?.params,
82
+ per_page: perPage,
83
+ page
84
+ } });
85
+ return Array.isArray(result) ? result.slice(0, limit) : [];
86
+ }
87
+ /** decode base64 content from github contents API. */
88
+ function decodeBase64Content(encoded) {
89
+ return Buffer.from(encoded.replace(/\n/g, ""), "base64").toString("utf-8");
90
+ }
91
+ /** add line numbers to file content. */
92
+ function addLineNumbers(content, startLine = 1) {
93
+ return content.split("\n").map((line, i) => `${startLine + i}: ${line}`).join("\n");
94
+ }
95
+ /** truncate string to max length with indicator. */
96
+ function truncate(text, maxLen) {
97
+ if (text.length <= maxLen) return text;
98
+ return `${text.slice(0, maxLen)}\n... (truncated, ${text.length} total characters)`;
99
+ }
100
+ //#endregion
101
+ export { addLineNumbers, decodeBase64Content, ghApi, ghApiPaginated, parseRepoUrl, repoSlug, truncate };
@@ -0,0 +1,5 @@
1
+ //#region packages/core/html-to-md/index.d.ts
2
+ declare function isHtml(text: string): boolean;
3
+ declare function htmlToMarkdown(html: string): string | null;
4
+ //#endregion
5
+ export { htmlToMarkdown, isHtml };