@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.
- package/dist/chunk-DsIazq42.js +26 -0
- package/dist/command-palette-B97yKqne.js +406 -0
- package/dist/core/agents-md.d.ts +22 -0
- package/dist/core/agents-md.js +174 -0
- package/dist/core/box-chrome.d.ts +56 -0
- package/dist/core/box-chrome.js +207 -0
- package/dist/core/box-format.d.ts +82 -0
- package/dist/core/box-format.js +381 -0
- package/dist/core/config.d.ts +14 -0
- package/dist/core/config.js +201 -0
- package/dist/core/file-tracker.d.ts +65 -0
- package/dist/core/file-tracker.js +439 -0
- package/dist/core/github-api.d.ts +45 -0
- package/dist/core/github-api.js +101 -0
- package/dist/core/html-to-md.d.ts +5 -0
- package/dist/core/html-to-md.js +289 -0
- package/dist/core/interpolate.d.ts +44 -0
- package/dist/core/interpolate.js +475 -0
- package/dist/core/mutex.d.ts +8 -0
- package/dist/core/mutex.js +142 -0
- package/dist/core/output-buffer.d.ts +80 -0
- package/dist/core/output-buffer.js +517 -0
- package/dist/core/permissions.d.ts +19 -0
- package/dist/core/permissions.js +140 -0
- package/dist/core/pi-spawn.d.ts +62 -0
- package/dist/core/pi-spawn.js +244 -0
- package/dist/core/prompt-patch.d.ts +11 -0
- package/dist/core/prompt-patch.js +16 -0
- package/dist/core/show-renderer.d.ts +10 -0
- package/dist/core/show-renderer.js +39 -0
- package/dist/core/show.d.ts +29 -0
- package/dist/core/show.js +288 -0
- package/dist/core/sub-agent-render.d.ts +56 -0
- package/dist/core/sub-agent-render.js +579 -0
- package/dist/core/tool-cost.d.ts +17 -0
- package/dist/core/tool-cost.js +58 -0
- package/dist/core/tui.d.ts +9 -0
- package/dist/core/tui.js +33 -0
- package/dist/editor-BljciPdx.js +537 -0
- package/dist/extensions/bash.d.ts +12 -0
- package/dist/extensions/bash.js +261 -0
- package/dist/extensions/code-review.d.ts +14 -0
- package/dist/extensions/code-review.js +175 -0
- package/dist/extensions/command-palette.d.ts +6 -0
- package/dist/extensions/command-palette.js +2 -0
- package/dist/extensions/create-file.d.ts +7 -0
- package/dist/extensions/create-file.js +88 -0
- package/dist/extensions/edit-file.d.ts +7 -0
- package/dist/extensions/edit-file.js +395 -0
- package/dist/extensions/editor.d.ts +6 -0
- package/dist/extensions/editor.js +2 -0
- package/dist/extensions/finder.d.ts +13 -0
- package/dist/extensions/finder.js +108 -0
- package/dist/extensions/format-file.d.ts +11 -0
- package/dist/extensions/format-file.js +145 -0
- package/dist/extensions/github.d.ts +13 -0
- package/dist/extensions/github.js +527 -0
- package/dist/extensions/glob.d.ts +10 -0
- package/dist/extensions/glob.js +163 -0
- package/dist/extensions/grep.d.ts +13 -0
- package/dist/extensions/grep.js +356 -0
- package/dist/extensions/handoff.d.ts +6 -0
- package/dist/extensions/handoff.js +277 -0
- package/dist/extensions/librarian.d.ts +13 -0
- package/dist/extensions/librarian.js +113 -0
- package/dist/extensions/look-at.d.ts +13 -0
- package/dist/extensions/look-at.js +121 -0
- package/dist/extensions/ls.d.ts +8 -0
- package/dist/extensions/ls.js +87 -0
- package/dist/extensions/mermaid.d.ts +20 -0
- package/dist/extensions/mermaid.js +2 -0
- package/dist/extensions/oracle.d.ts +13 -0
- package/dist/extensions/oracle.js +143 -0
- package/dist/extensions/read-session.d.ts +13 -0
- package/dist/extensions/read-session.js +265 -0
- package/dist/extensions/read-web-page.d.ts +11 -0
- package/dist/extensions/read-web-page.js +234 -0
- package/dist/extensions/read.d.ts +23 -0
- package/dist/extensions/read.js +323 -0
- package/dist/extensions/search-sessions.d.ts +29 -0
- package/dist/extensions/search-sessions.js +426 -0
- package/dist/extensions/session-name.d.ts +6 -0
- package/dist/extensions/session-name.js +54 -0
- package/dist/extensions/skill.d.ts +7 -0
- package/dist/extensions/skill.js +232 -0
- package/dist/extensions/system-prompt.d.ts +6 -0
- package/dist/extensions/system-prompt.js +31 -0
- package/dist/extensions/task.d.ts +11 -0
- package/dist/extensions/task.js +114 -0
- package/dist/extensions/tool-harness.d.ts +6 -0
- package/dist/extensions/tool-harness.js +37 -0
- package/dist/extensions/undo-edit.d.ts +7 -0
- package/dist/extensions/undo-edit.js +127 -0
- package/dist/extensions/web-search.d.ts +12 -0
- package/dist/extensions/web-search.js +254 -0
- package/dist/extensions.d.ts +29 -0
- package/dist/extensions.js +29 -0
- package/dist/mermaid-C7xCVBrw.js +95296 -0
- 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 };
|