@cortexkit/aft-pi 0.14.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.
- package/README.md +143 -0
- package/dist/bridge.d.ts +63 -0
- package/dist/bridge.d.ts.map +1 -0
- package/dist/commands/aft-status.d.ts +11 -0
- package/dist/commands/aft-status.d.ts.map +1 -0
- package/dist/config.d.ts +33 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/downloader.d.ts +35 -0
- package/dist/downloader.d.ts.map +1 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2294 -0
- package/dist/logger.d.ts +5 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/onnx-runtime.d.ts +35 -0
- package/dist/onnx-runtime.d.ts.map +1 -0
- package/dist/platform.d.ts +6 -0
- package/dist/platform.d.ts.map +1 -0
- package/dist/pool.d.ts +33 -0
- package/dist/pool.d.ts.map +1 -0
- package/dist/resolver.d.ts +11 -0
- package/dist/resolver.d.ts.map +1 -0
- package/dist/shared/discover-files.d.ts +5 -0
- package/dist/shared/discover-files.d.ts.map +1 -0
- package/dist/shared/status.d.ts +44 -0
- package/dist/shared/status.d.ts.map +1 -0
- package/dist/shutdown-hooks.d.ts +18 -0
- package/dist/shutdown-hooks.d.ts.map +1 -0
- package/dist/tools/_shared.d.ts +27 -0
- package/dist/tools/_shared.d.ts.map +1 -0
- package/dist/tools/ast.d.ts +12 -0
- package/dist/tools/ast.d.ts.map +1 -0
- package/dist/tools/conflicts.d.ts +7 -0
- package/dist/tools/conflicts.d.ts.map +1 -0
- package/dist/tools/fs.d.ts +12 -0
- package/dist/tools/fs.d.ts.map +1 -0
- package/dist/tools/hoisted.d.ts +15 -0
- package/dist/tools/hoisted.d.ts.map +1 -0
- package/dist/tools/imports.d.ts +8 -0
- package/dist/tools/imports.d.ts.map +1 -0
- package/dist/tools/lsp.d.ts +9 -0
- package/dist/tools/lsp.d.ts.map +1 -0
- package/dist/tools/navigate.d.ts +8 -0
- package/dist/tools/navigate.d.ts.map +1 -0
- package/dist/tools/reading.d.ts +12 -0
- package/dist/tools/reading.d.ts.map +1 -0
- package/dist/tools/refactor.d.ts +8 -0
- package/dist/tools/refactor.d.ts.map +1 -0
- package/dist/tools/safety.d.ts +7 -0
- package/dist/tools/safety.d.ts.map +1 -0
- package/dist/tools/semantic.d.ts +9 -0
- package/dist/tools/semantic.d.ts.map +1 -0
- package/dist/tools/structure.d.ts +8 -0
- package/dist/tools/structure.d.ts.map +1 -0
- package/dist/types.d.ts +13 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +53 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2294 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
3
|
+
|
|
4
|
+
// src/index.ts
|
|
5
|
+
import { createRequire as createRequire3 } from "node:module";
|
|
6
|
+
import { homedir as homedir5 } from "node:os";
|
|
7
|
+
import { join as join8 } from "node:path";
|
|
8
|
+
|
|
9
|
+
// src/shared/status.ts
|
|
10
|
+
function asRecord(value) {
|
|
11
|
+
return typeof value === "object" && value !== null ? value : {};
|
|
12
|
+
}
|
|
13
|
+
function readString(value, fallback = "") {
|
|
14
|
+
return typeof value === "string" ? value : fallback;
|
|
15
|
+
}
|
|
16
|
+
function readNullableString(value) {
|
|
17
|
+
return typeof value === "string" ? value : null;
|
|
18
|
+
}
|
|
19
|
+
function readBoolean(value, fallback = false) {
|
|
20
|
+
return typeof value === "boolean" ? value : fallback;
|
|
21
|
+
}
|
|
22
|
+
function readNumber(value, fallback = 0) {
|
|
23
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
24
|
+
}
|
|
25
|
+
function readOptionalNumber(value) {
|
|
26
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
27
|
+
}
|
|
28
|
+
function formatFlag(enabled) {
|
|
29
|
+
return enabled ? "enabled" : "disabled";
|
|
30
|
+
}
|
|
31
|
+
function formatCount(value) {
|
|
32
|
+
return value == null ? "—" : value.toLocaleString("en-US");
|
|
33
|
+
}
|
|
34
|
+
function formatBytes(bytes) {
|
|
35
|
+
if (!Number.isFinite(bytes) || bytes <= 0)
|
|
36
|
+
return "0 B";
|
|
37
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
38
|
+
let value = bytes;
|
|
39
|
+
let unitIndex = 0;
|
|
40
|
+
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
41
|
+
value /= 1024;
|
|
42
|
+
unitIndex++;
|
|
43
|
+
}
|
|
44
|
+
const decimals = value >= 10 || unitIndex === 0 ? 0 : 1;
|
|
45
|
+
return `${value.toFixed(decimals)} ${units[unitIndex]}`;
|
|
46
|
+
}
|
|
47
|
+
function coerceAftStatus(response) {
|
|
48
|
+
const features = asRecord(response.features);
|
|
49
|
+
const searchIndex = asRecord(response.search_index);
|
|
50
|
+
const semanticIndex = asRecord(response.semantic_index);
|
|
51
|
+
const semanticConfig = {
|
|
52
|
+
...asRecord(response.semantic),
|
|
53
|
+
...asRecord(response.semantic_config)
|
|
54
|
+
};
|
|
55
|
+
const disk = asRecord(response.disk);
|
|
56
|
+
const symbolCache = asRecord(response.symbol_cache);
|
|
57
|
+
return {
|
|
58
|
+
version: readString(response.version, "unknown"),
|
|
59
|
+
project_root: readNullableString(response.project_root),
|
|
60
|
+
features: {
|
|
61
|
+
format_on_edit: readBoolean(features.format_on_edit),
|
|
62
|
+
validate_on_edit: readString(features.validate_on_edit, "off"),
|
|
63
|
+
restrict_to_project_root: readBoolean(features.restrict_to_project_root),
|
|
64
|
+
experimental_search_index: readBoolean(features.experimental_search_index),
|
|
65
|
+
experimental_semantic_search: readBoolean(features.experimental_semantic_search)
|
|
66
|
+
},
|
|
67
|
+
search_index: {
|
|
68
|
+
status: readString(searchIndex.status, "unknown"),
|
|
69
|
+
files: readOptionalNumber(searchIndex.files),
|
|
70
|
+
trigrams: readOptionalNumber(searchIndex.trigrams)
|
|
71
|
+
},
|
|
72
|
+
semantic_index: {
|
|
73
|
+
status: readString(semanticIndex.status, "unknown"),
|
|
74
|
+
backend: readNullableString(semanticIndex.backend ?? semanticConfig.backend),
|
|
75
|
+
model: readNullableString(semanticIndex.model ?? semanticConfig.model),
|
|
76
|
+
stage: readNullableString(semanticIndex.stage),
|
|
77
|
+
files: readOptionalNumber(semanticIndex.files),
|
|
78
|
+
entries_done: readOptionalNumber(semanticIndex.entries_done),
|
|
79
|
+
entries_total: readOptionalNumber(semanticIndex.entries_total),
|
|
80
|
+
entries: readOptionalNumber(semanticIndex.entries),
|
|
81
|
+
dimension: readOptionalNumber(semanticIndex.dimension),
|
|
82
|
+
error: readNullableString(semanticIndex.error)
|
|
83
|
+
},
|
|
84
|
+
disk: {
|
|
85
|
+
storage_dir: readNullableString(disk.storage_dir),
|
|
86
|
+
trigram_disk_bytes: readNumber(disk.trigram_disk_bytes),
|
|
87
|
+
semantic_disk_bytes: readNumber(disk.semantic_disk_bytes)
|
|
88
|
+
},
|
|
89
|
+
lsp_servers: readNumber(response.lsp_servers),
|
|
90
|
+
symbol_cache: {
|
|
91
|
+
local_entries: readNumber(symbolCache.local_entries),
|
|
92
|
+
warm_entries: readNumber(symbolCache.warm_entries)
|
|
93
|
+
},
|
|
94
|
+
storage_dir: readNullableString(response.storage_dir)
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function formatStatusDialogMessage(status) {
|
|
98
|
+
const lines = [
|
|
99
|
+
`AFT version: ${status.version}`,
|
|
100
|
+
`Project root: ${status.project_root ?? "(not configured)"}`,
|
|
101
|
+
"",
|
|
102
|
+
"Enabled features",
|
|
103
|
+
`- format_on_edit: ${formatFlag(status.features.format_on_edit)}`,
|
|
104
|
+
`- experimental_search_index: ${formatFlag(status.features.experimental_search_index)}`,
|
|
105
|
+
`- experimental_semantic_search: ${formatFlag(status.features.experimental_semantic_search)}`,
|
|
106
|
+
"",
|
|
107
|
+
"Search index",
|
|
108
|
+
`- status: ${status.search_index.status}`,
|
|
109
|
+
`- files: ${formatCount(status.search_index.files)}`,
|
|
110
|
+
`- trigrams: ${formatCount(status.search_index.trigrams)}`,
|
|
111
|
+
"",
|
|
112
|
+
"Semantic index",
|
|
113
|
+
`- status: ${status.semantic_index.status}`,
|
|
114
|
+
`- entries: ${formatCount(status.semantic_index.entries)}`
|
|
115
|
+
];
|
|
116
|
+
if (status.semantic_index.backend) {
|
|
117
|
+
lines.push(`- backend: ${status.semantic_index.backend}`);
|
|
118
|
+
}
|
|
119
|
+
if (status.semantic_index.model) {
|
|
120
|
+
lines.push(`- model: ${status.semantic_index.model}`);
|
|
121
|
+
}
|
|
122
|
+
if (status.semantic_index.dimension != null) {
|
|
123
|
+
lines.push(`- dimension: ${formatCount(status.semantic_index.dimension)}`);
|
|
124
|
+
}
|
|
125
|
+
lines.push("", "Disk usage", `- trigram index: ${formatBytes(status.disk.trigram_disk_bytes)}`, `- semantic index: ${formatBytes(status.disk.semantic_disk_bytes)}`, "", "Runtime", `- LSP servers: ${formatCount(status.lsp_servers)}`, `- symbol cache: ${formatCount(status.symbol_cache.local_entries)} local / ${formatCount(status.symbol_cache.warm_entries)} warm`);
|
|
126
|
+
if (status.storage_dir ?? status.disk.storage_dir) {
|
|
127
|
+
lines.push(`- storage dir: ${status.storage_dir ?? status.disk.storage_dir}`);
|
|
128
|
+
}
|
|
129
|
+
if (status.semantic_index.stage) {
|
|
130
|
+
lines.push("", "Semantic stage", status.semantic_index.stage);
|
|
131
|
+
}
|
|
132
|
+
if (status.semantic_index.files != null) {
|
|
133
|
+
lines.push(`- semantic files: ${formatCount(status.semantic_index.files)}`);
|
|
134
|
+
}
|
|
135
|
+
if (status.semantic_index.entries_done != null || status.semantic_index.entries_total != null) {
|
|
136
|
+
lines.push(`- semantic progress: ${formatCount(status.semantic_index.entries_done ?? null)} / ${formatCount(status.semantic_index.entries_total ?? null)}`);
|
|
137
|
+
}
|
|
138
|
+
if (status.semantic_index.error) {
|
|
139
|
+
lines.push("", "Semantic error", status.semantic_index.error);
|
|
140
|
+
}
|
|
141
|
+
return lines.join(`
|
|
142
|
+
`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// src/tools/_shared.ts
|
|
146
|
+
function bridgeFor(ctx, cwd) {
|
|
147
|
+
return ctx.pool.getBridge(cwd);
|
|
148
|
+
}
|
|
149
|
+
async function callBridge(bridge, command, params = {}) {
|
|
150
|
+
const response = await bridge.send(command, params);
|
|
151
|
+
if (response.success === false) {
|
|
152
|
+
const message = typeof response.message === "string" && response.message.length > 0 ? response.message : `${command} failed`;
|
|
153
|
+
throw new Error(message);
|
|
154
|
+
}
|
|
155
|
+
return response;
|
|
156
|
+
}
|
|
157
|
+
function textResult(text, details) {
|
|
158
|
+
return {
|
|
159
|
+
content: [{ type: "text", text }],
|
|
160
|
+
details
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// src/commands/aft-status.ts
|
|
165
|
+
function registerStatusCommand(pi, ctx) {
|
|
166
|
+
pi.registerCommand("aft-status", {
|
|
167
|
+
description: "Show AFT plugin status (search/semantic indexes, LSP, storage)",
|
|
168
|
+
handler: async (_args, extCtx) => {
|
|
169
|
+
try {
|
|
170
|
+
const bridge = bridgeFor(ctx, extCtx.cwd);
|
|
171
|
+
const response = await callBridge(bridge, "status");
|
|
172
|
+
const snapshot = coerceAftStatus(response);
|
|
173
|
+
const text = formatStatusDialogMessage(snapshot);
|
|
174
|
+
if (extCtx.hasUI) {
|
|
175
|
+
await extCtx.ui.input("AFT Status", text);
|
|
176
|
+
} else {
|
|
177
|
+
extCtx.ui.notify(text, "info");
|
|
178
|
+
}
|
|
179
|
+
} catch (err) {
|
|
180
|
+
const message = `AFT status failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
181
|
+
if (extCtx.hasUI) {
|
|
182
|
+
extCtx.ui.notify(message, "error");
|
|
183
|
+
} else {
|
|
184
|
+
console.error(`[aft-plugin] ${message}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// src/config.ts
|
|
192
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
193
|
+
import { homedir } from "node:os";
|
|
194
|
+
import { join as join2 } from "node:path";
|
|
195
|
+
|
|
196
|
+
// src/logger.ts
|
|
197
|
+
import * as fs from "node:fs";
|
|
198
|
+
import * as os from "node:os";
|
|
199
|
+
import * as path from "node:path";
|
|
200
|
+
var TAG = "[aft-pi]";
|
|
201
|
+
var logFile = path.join(os.tmpdir(), "aft-pi.log");
|
|
202
|
+
var useStderr = process.env.AFT_LOG_STDERR === "1";
|
|
203
|
+
var buffer = [];
|
|
204
|
+
var flushTimer = null;
|
|
205
|
+
var FLUSH_INTERVAL_MS = 500;
|
|
206
|
+
var BUFFER_SIZE_LIMIT = 50;
|
|
207
|
+
function flush() {
|
|
208
|
+
if (buffer.length === 0)
|
|
209
|
+
return;
|
|
210
|
+
const data = buffer.join("");
|
|
211
|
+
buffer = [];
|
|
212
|
+
try {
|
|
213
|
+
if (useStderr) {
|
|
214
|
+
process.stderr.write(data);
|
|
215
|
+
} else {
|
|
216
|
+
fs.appendFileSync(logFile, data);
|
|
217
|
+
}
|
|
218
|
+
} catch {}
|
|
219
|
+
}
|
|
220
|
+
function scheduleFlush() {
|
|
221
|
+
if (flushTimer)
|
|
222
|
+
return;
|
|
223
|
+
flushTimer = setTimeout(() => {
|
|
224
|
+
flushTimer = null;
|
|
225
|
+
flush();
|
|
226
|
+
}, FLUSH_INTERVAL_MS);
|
|
227
|
+
if (flushTimer && typeof flushTimer === "object" && "unref" in flushTimer) {
|
|
228
|
+
flushTimer.unref();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
function write(level, message, data) {
|
|
232
|
+
try {
|
|
233
|
+
const timestamp = new Date().toISOString();
|
|
234
|
+
const serialized = data === undefined ? "" : ` ${JSON.stringify(data)}`;
|
|
235
|
+
const line = `[${timestamp}] ${level} ${TAG} ${message}${serialized}
|
|
236
|
+
`;
|
|
237
|
+
if (useStderr) {
|
|
238
|
+
process.stderr.write(line);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
buffer.push(line);
|
|
242
|
+
if (buffer.length >= BUFFER_SIZE_LIMIT) {
|
|
243
|
+
flush();
|
|
244
|
+
} else {
|
|
245
|
+
scheduleFlush();
|
|
246
|
+
}
|
|
247
|
+
} catch {}
|
|
248
|
+
}
|
|
249
|
+
function log(message, data) {
|
|
250
|
+
write("INFO", message, data);
|
|
251
|
+
}
|
|
252
|
+
function warn(message, data) {
|
|
253
|
+
write("WARN", message, data);
|
|
254
|
+
}
|
|
255
|
+
function error(message, data) {
|
|
256
|
+
write("ERROR", message, data);
|
|
257
|
+
}
|
|
258
|
+
function getLogFilePath() {
|
|
259
|
+
return logFile;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// src/config.ts
|
|
263
|
+
function stripJsonc(input) {
|
|
264
|
+
let result = "";
|
|
265
|
+
let i = 0;
|
|
266
|
+
const n = input.length;
|
|
267
|
+
let inString = false;
|
|
268
|
+
let stringChar = "";
|
|
269
|
+
while (i < n) {
|
|
270
|
+
const ch = input[i];
|
|
271
|
+
const next = input[i + 1];
|
|
272
|
+
if (inString) {
|
|
273
|
+
result += ch;
|
|
274
|
+
if (ch === "\\" && i + 1 < n) {
|
|
275
|
+
result += input[i + 1];
|
|
276
|
+
i += 2;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (ch === stringChar)
|
|
280
|
+
inString = false;
|
|
281
|
+
i++;
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (ch === '"' || ch === "'") {
|
|
285
|
+
inString = true;
|
|
286
|
+
stringChar = ch;
|
|
287
|
+
result += ch;
|
|
288
|
+
i++;
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (ch === "/" && next === "/") {
|
|
292
|
+
while (i < n && input[i] !== `
|
|
293
|
+
`)
|
|
294
|
+
i++;
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (ch === "/" && next === "*") {
|
|
298
|
+
i += 2;
|
|
299
|
+
while (i < n && !(input[i] === "*" && input[i + 1] === "/"))
|
|
300
|
+
i++;
|
|
301
|
+
i += 2;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
result += ch;
|
|
305
|
+
i++;
|
|
306
|
+
}
|
|
307
|
+
return result.replace(/,(\s*[}\]])/g, "$1");
|
|
308
|
+
}
|
|
309
|
+
function detectConfigFile(basePath) {
|
|
310
|
+
const jsoncPath = `${basePath}.jsonc`;
|
|
311
|
+
const jsonPath = `${basePath}.json`;
|
|
312
|
+
if (existsSync(jsoncPath))
|
|
313
|
+
return { format: "jsonc", path: jsoncPath };
|
|
314
|
+
if (existsSync(jsonPath))
|
|
315
|
+
return { format: "json", path: jsonPath };
|
|
316
|
+
return { format: "none", path: jsonPath };
|
|
317
|
+
}
|
|
318
|
+
function loadConfigFromPath(configPath) {
|
|
319
|
+
try {
|
|
320
|
+
if (!existsSync(configPath))
|
|
321
|
+
return null;
|
|
322
|
+
const content = readFileSync(configPath, "utf-8");
|
|
323
|
+
const parsed = JSON.parse(stripJsonc(content));
|
|
324
|
+
log(`Config loaded from ${configPath}`);
|
|
325
|
+
return parsed;
|
|
326
|
+
} catch (err) {
|
|
327
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
328
|
+
error(`Error loading config from ${configPath}: ${errorMsg}`);
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
function mergeSemanticConfig(base, override) {
|
|
333
|
+
const projectSafe = {};
|
|
334
|
+
if (override?.model !== undefined)
|
|
335
|
+
projectSafe.model = override.model;
|
|
336
|
+
if (override?.timeout_ms !== undefined)
|
|
337
|
+
projectSafe.timeout_ms = override.timeout_ms;
|
|
338
|
+
if (override?.max_batch_size !== undefined)
|
|
339
|
+
projectSafe.max_batch_size = override.max_batch_size;
|
|
340
|
+
const semantic = { ...base, ...projectSafe };
|
|
341
|
+
if (Object.values(semantic).every((v) => v === undefined))
|
|
342
|
+
return;
|
|
343
|
+
return Object.fromEntries(Object.entries(semantic).filter(([, v]) => v !== undefined));
|
|
344
|
+
}
|
|
345
|
+
function mergeConfigs(base, override) {
|
|
346
|
+
const disabledTools = [...base.disabled_tools ?? [], ...override.disabled_tools ?? []];
|
|
347
|
+
const formatter = { ...base.formatter, ...override.formatter };
|
|
348
|
+
const checker = { ...base.checker, ...override.checker };
|
|
349
|
+
const semantic = mergeSemanticConfig(base.semantic, override.semantic);
|
|
350
|
+
const { semantic: _stripSemantic, ...safeOverride } = override;
|
|
351
|
+
return {
|
|
352
|
+
...base,
|
|
353
|
+
...safeOverride,
|
|
354
|
+
...Object.keys(formatter).length > 0 ? { formatter } : {},
|
|
355
|
+
...Object.keys(checker).length > 0 ? { checker } : {},
|
|
356
|
+
semantic,
|
|
357
|
+
...disabledTools.length > 0 ? { disabled_tools: [...new Set(disabledTools)] } : {}
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
function getGlobalPiDir() {
|
|
361
|
+
return join2(homedir(), ".pi", "agent");
|
|
362
|
+
}
|
|
363
|
+
function loadAftConfig(projectDirectory) {
|
|
364
|
+
const userBasePath = join2(getGlobalPiDir(), "aft");
|
|
365
|
+
const userDetected = detectConfigFile(userBasePath);
|
|
366
|
+
const userConfigPath = userDetected.format !== "none" ? userDetected.path : `${userBasePath}.json`;
|
|
367
|
+
const projectBasePath = join2(projectDirectory, ".pi", "aft");
|
|
368
|
+
const projectDetected = detectConfigFile(projectBasePath);
|
|
369
|
+
const projectConfigPath = projectDetected.format !== "none" ? projectDetected.path : `${projectBasePath}.json`;
|
|
370
|
+
let config = loadConfigFromPath(userConfigPath) ?? {};
|
|
371
|
+
const projectConfig = loadConfigFromPath(projectConfigPath);
|
|
372
|
+
if (projectConfig) {
|
|
373
|
+
if (projectConfig.semantic?.backend !== undefined || projectConfig.semantic?.base_url !== undefined || projectConfig.semantic?.api_key_env !== undefined) {
|
|
374
|
+
warn("Ignoring semantic.backend/base_url/api_key_env from project config (security: use user config for external backends)");
|
|
375
|
+
}
|
|
376
|
+
config = mergeConfigs(config, projectConfig);
|
|
377
|
+
}
|
|
378
|
+
return config;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// src/onnx-runtime.ts
|
|
382
|
+
import { chmodSync, existsSync as existsSync2, mkdirSync, readdirSync, unlinkSync } from "node:fs";
|
|
383
|
+
import { join as join3 } from "node:path";
|
|
384
|
+
var ORT_VERSION = "1.24.4";
|
|
385
|
+
var ORT_REPO = "microsoft/onnxruntime";
|
|
386
|
+
var ORT_PLATFORM_MAP = {
|
|
387
|
+
darwin: {
|
|
388
|
+
arm64: {
|
|
389
|
+
assetName: `onnxruntime-osx-arm64-${ORT_VERSION}`,
|
|
390
|
+
libName: "libonnxruntime.dylib",
|
|
391
|
+
archiveType: "tgz"
|
|
392
|
+
}
|
|
393
|
+
},
|
|
394
|
+
linux: {
|
|
395
|
+
x64: {
|
|
396
|
+
assetName: `onnxruntime-linux-x64-${ORT_VERSION}`,
|
|
397
|
+
libName: "libonnxruntime.so",
|
|
398
|
+
archiveType: "tgz"
|
|
399
|
+
},
|
|
400
|
+
arm64: {
|
|
401
|
+
assetName: `onnxruntime-linux-aarch64-${ORT_VERSION}`,
|
|
402
|
+
libName: "libonnxruntime.so",
|
|
403
|
+
archiveType: "tgz"
|
|
404
|
+
}
|
|
405
|
+
},
|
|
406
|
+
win32: {
|
|
407
|
+
x64: {
|
|
408
|
+
assetName: `onnxruntime-win-x64-${ORT_VERSION}`,
|
|
409
|
+
libName: "onnxruntime.dll",
|
|
410
|
+
archiveType: "zip"
|
|
411
|
+
},
|
|
412
|
+
arm64: {
|
|
413
|
+
assetName: `onnxruntime-win-arm64-${ORT_VERSION}`,
|
|
414
|
+
libName: "onnxruntime.dll",
|
|
415
|
+
archiveType: "zip"
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
function getPlatformInfo() {
|
|
420
|
+
const platformMap = ORT_PLATFORM_MAP[process.platform];
|
|
421
|
+
if (!platformMap)
|
|
422
|
+
return null;
|
|
423
|
+
return platformMap[process.arch] || null;
|
|
424
|
+
}
|
|
425
|
+
function getManualInstallHint() {
|
|
426
|
+
if (process.platform === "darwin" && process.arch === "x64") {
|
|
427
|
+
return "brew install onnxruntime";
|
|
428
|
+
}
|
|
429
|
+
if (process.platform === "linux") {
|
|
430
|
+
return "apt install libonnxruntime or download from https://github.com/microsoft/onnxruntime/releases";
|
|
431
|
+
}
|
|
432
|
+
return "Download from https://github.com/microsoft/onnxruntime/releases";
|
|
433
|
+
}
|
|
434
|
+
async function ensureOnnxRuntime(storageDir) {
|
|
435
|
+
const info = getPlatformInfo();
|
|
436
|
+
const ortDir = join3(storageDir, "onnxruntime", ORT_VERSION);
|
|
437
|
+
const libPath = join3(ortDir, info?.libName ?? "libonnxruntime.dylib");
|
|
438
|
+
if (existsSync2(libPath)) {
|
|
439
|
+
log(`ONNX Runtime found at ${ortDir}`);
|
|
440
|
+
return ortDir;
|
|
441
|
+
}
|
|
442
|
+
const systemPath = findSystemOnnxRuntime(info?.libName);
|
|
443
|
+
if (systemPath) {
|
|
444
|
+
log(`ONNX Runtime found at system path: ${systemPath}`);
|
|
445
|
+
return systemPath;
|
|
446
|
+
}
|
|
447
|
+
if (!info) {
|
|
448
|
+
warn(`ONNX Runtime auto-download not available for ${process.platform}/${process.arch}. Install manually: ${getManualInstallHint()}`);
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
return downloadOnnxRuntime(info, ortDir);
|
|
452
|
+
}
|
|
453
|
+
function findSystemOnnxRuntime(libName) {
|
|
454
|
+
if (!libName)
|
|
455
|
+
return null;
|
|
456
|
+
const searchPaths = [];
|
|
457
|
+
if (process.platform === "darwin") {
|
|
458
|
+
searchPaths.push("/opt/homebrew/lib", "/usr/local/lib");
|
|
459
|
+
} else if (process.platform === "linux") {
|
|
460
|
+
searchPaths.push("/usr/lib", "/usr/lib/x86_64-linux-gnu", "/usr/lib/aarch64-linux-gnu", "/usr/local/lib");
|
|
461
|
+
}
|
|
462
|
+
for (const dir of searchPaths) {
|
|
463
|
+
if (existsSync2(join3(dir, libName))) {
|
|
464
|
+
return dir;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
async function downloadOnnxRuntime(info, targetDir) {
|
|
470
|
+
const url = `https://github.com/${ORT_REPO}/releases/download/v${ORT_VERSION}/${info.assetName}.${info.archiveType === "tgz" ? "tgz" : "zip"}`;
|
|
471
|
+
log(`Downloading ONNX Runtime v${ORT_VERSION} for ${process.platform}/${process.arch}...`);
|
|
472
|
+
try {
|
|
473
|
+
const tmpDir = `${targetDir}.tmp.${process.pid}`;
|
|
474
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
475
|
+
const archivePath = join3(tmpDir, `onnxruntime.${info.archiveType}`);
|
|
476
|
+
const { execSync: execSyncDl } = await import("node:child_process");
|
|
477
|
+
execSyncDl(`curl -fsSL "${url}" -o "${archivePath}"`, {
|
|
478
|
+
stdio: "pipe",
|
|
479
|
+
timeout: 120000
|
|
480
|
+
});
|
|
481
|
+
if (info.archiveType === "tgz") {
|
|
482
|
+
const { execSync } = await import("node:child_process");
|
|
483
|
+
execSync(`tar xzf "${archivePath}" -C "${tmpDir}"`, { stdio: "pipe" });
|
|
484
|
+
} else {
|
|
485
|
+
await extractZipArchive(archivePath, tmpDir);
|
|
486
|
+
}
|
|
487
|
+
const extractedDir = join3(tmpDir, info.assetName, "lib");
|
|
488
|
+
if (!existsSync2(extractedDir)) {
|
|
489
|
+
throw new Error(`Expected directory not found: ${extractedDir}`);
|
|
490
|
+
}
|
|
491
|
+
mkdirSync(targetDir, { recursive: true });
|
|
492
|
+
const libFiles = readdirSync(extractedDir).filter((f) => f.startsWith("libonnxruntime") || f.startsWith("onnxruntime"));
|
|
493
|
+
const { lstatSync, symlinkSync, readlinkSync, copyFileSync: cpFile } = await import("node:fs");
|
|
494
|
+
const realFiles = [];
|
|
495
|
+
const symlinks = [];
|
|
496
|
+
for (const libFile of libFiles) {
|
|
497
|
+
const src = join3(extractedDir, libFile);
|
|
498
|
+
try {
|
|
499
|
+
const stat = lstatSync(src);
|
|
500
|
+
log(`ORT extract: ${libFile} — isSymlink=${stat.isSymbolicLink()}, isFile=${stat.isFile()}, size=${stat.size}`);
|
|
501
|
+
if (stat.isSymbolicLink()) {
|
|
502
|
+
symlinks.push({ name: libFile, target: readlinkSync(src) });
|
|
503
|
+
} else {
|
|
504
|
+
realFiles.push(libFile);
|
|
505
|
+
}
|
|
506
|
+
} catch (e) {
|
|
507
|
+
log(`ORT extract: ${libFile} — stat failed: ${e}`);
|
|
508
|
+
realFiles.push(libFile);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
for (const libFile of realFiles) {
|
|
512
|
+
const src = join3(extractedDir, libFile);
|
|
513
|
+
const dst = join3(targetDir, libFile);
|
|
514
|
+
try {
|
|
515
|
+
cpFile(src, dst);
|
|
516
|
+
if (process.platform !== "win32") {
|
|
517
|
+
chmodSync(dst, 493);
|
|
518
|
+
}
|
|
519
|
+
} catch (copyErr) {
|
|
520
|
+
log(`ORT extract: failed to copy ${libFile}: ${copyErr}`);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
for (const link of symlinks) {
|
|
524
|
+
const dst = join3(targetDir, link.name);
|
|
525
|
+
try {
|
|
526
|
+
unlinkSync(dst);
|
|
527
|
+
} catch {}
|
|
528
|
+
symlinkSync(link.target, dst);
|
|
529
|
+
}
|
|
530
|
+
const { rmSync } = await import("node:fs");
|
|
531
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
532
|
+
log(`ONNX Runtime v${ORT_VERSION} installed to ${targetDir}`);
|
|
533
|
+
return targetDir;
|
|
534
|
+
} catch (err) {
|
|
535
|
+
error(`Failed to download ONNX Runtime: ${err}`);
|
|
536
|
+
try {
|
|
537
|
+
const { rmSync } = await import("node:fs");
|
|
538
|
+
rmSync(`${targetDir}.tmp.${process.pid}`, { recursive: true, force: true });
|
|
539
|
+
} catch {}
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
async function extractZipArchive(archivePath, destinationDir) {
|
|
544
|
+
const { execFileSync } = await import("node:child_process");
|
|
545
|
+
if (process.platform === "win32") {
|
|
546
|
+
let powershellError;
|
|
547
|
+
try {
|
|
548
|
+
execFileSync("powershell.exe", [
|
|
549
|
+
"-NoProfile",
|
|
550
|
+
"-NonInteractive",
|
|
551
|
+
"-ExecutionPolicy",
|
|
552
|
+
"Bypass",
|
|
553
|
+
"-Command",
|
|
554
|
+
"& { Expand-Archive -LiteralPath $args[0] -DestinationPath $args[1] -Force }",
|
|
555
|
+
archivePath,
|
|
556
|
+
destinationDir
|
|
557
|
+
], { stdio: "pipe", timeout: 120000 });
|
|
558
|
+
return;
|
|
559
|
+
} catch (err) {
|
|
560
|
+
powershellError = err;
|
|
561
|
+
warn(`PowerShell Expand-Archive failed, falling back to cmd/tar: ${String(err)}`);
|
|
562
|
+
}
|
|
563
|
+
try {
|
|
564
|
+
execFileSync("cmd.exe", ["/d", "/s", "/c", `tar -xf "${archivePath}" -C "${destinationDir}"`], { stdio: "pipe", timeout: 120000 });
|
|
565
|
+
return;
|
|
566
|
+
} catch (cmdError) {
|
|
567
|
+
throw new Error(`ZIP extraction failed via PowerShell and cmd/tar. PowerShell: ${String(powershellError)} | cmd/tar: ${String(cmdError)}`);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
execFileSync("unzip", ["-q", archivePath, "-d", destinationDir], {
|
|
571
|
+
stdio: "pipe",
|
|
572
|
+
timeout: 120000
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// src/bridge.ts
|
|
577
|
+
import { spawn } from "node:child_process";
|
|
578
|
+
import { homedir as homedir2 } from "node:os";
|
|
579
|
+
import { join as join4 } from "node:path";
|
|
580
|
+
var DEFAULT_BRIDGE_TIMEOUT_MS = 30000;
|
|
581
|
+
var SEMANTIC_TIMEOUT_SAFETY_MARGIN_MS = 5000;
|
|
582
|
+
function compareSemver(a, b) {
|
|
583
|
+
const pa = a.split(".").map(Number);
|
|
584
|
+
const pb = b.split(".").map(Number);
|
|
585
|
+
for (let i = 0;i < 3; i++) {
|
|
586
|
+
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
587
|
+
if (diff !== 0)
|
|
588
|
+
return diff;
|
|
589
|
+
}
|
|
590
|
+
return 0;
|
|
591
|
+
}
|
|
592
|
+
function clampSemanticTimeout(configOverrides, bridgeTimeoutMs) {
|
|
593
|
+
const semantic = configOverrides.semantic;
|
|
594
|
+
if (!semantic || typeof semantic !== "object" || Array.isArray(semantic)) {
|
|
595
|
+
return configOverrides;
|
|
596
|
+
}
|
|
597
|
+
const timeoutMs = semantic.timeout_ms;
|
|
598
|
+
if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs)) {
|
|
599
|
+
return configOverrides;
|
|
600
|
+
}
|
|
601
|
+
const maxSemanticTimeoutMs = bridgeTimeoutMs > SEMANTIC_TIMEOUT_SAFETY_MARGIN_MS ? bridgeTimeoutMs - SEMANTIC_TIMEOUT_SAFETY_MARGIN_MS : Math.max(1, bridgeTimeoutMs - 1);
|
|
602
|
+
if (timeoutMs <= maxSemanticTimeoutMs) {
|
|
603
|
+
return configOverrides;
|
|
604
|
+
}
|
|
605
|
+
warn(`semantic.timeout_ms=${timeoutMs} exceeds bridge timeout budget; clamping to ${maxSemanticTimeoutMs}ms (bridge timeout: ${bridgeTimeoutMs}ms)`);
|
|
606
|
+
return {
|
|
607
|
+
...configOverrides,
|
|
608
|
+
semantic: {
|
|
609
|
+
...semantic,
|
|
610
|
+
timeout_ms: maxSemanticTimeoutMs
|
|
611
|
+
}
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
class BinaryBridge {
|
|
616
|
+
static RESTART_RESET_MS = 5 * 60 * 1000;
|
|
617
|
+
static STDERR_TAIL_MAX = 20;
|
|
618
|
+
binaryPath;
|
|
619
|
+
cwd;
|
|
620
|
+
process = null;
|
|
621
|
+
pending = new Map;
|
|
622
|
+
nextId = 1;
|
|
623
|
+
stdoutBuffer = "";
|
|
624
|
+
stderrTail = [];
|
|
625
|
+
_restartCount = 0;
|
|
626
|
+
_shuttingDown = false;
|
|
627
|
+
timeoutMs;
|
|
628
|
+
maxRestarts;
|
|
629
|
+
configured = false;
|
|
630
|
+
_configurePromise = null;
|
|
631
|
+
configOverrides;
|
|
632
|
+
minVersion;
|
|
633
|
+
onVersionMismatch;
|
|
634
|
+
restartResetTimer = null;
|
|
635
|
+
constructor(binaryPath, cwd, options, configOverrides) {
|
|
636
|
+
this.binaryPath = binaryPath;
|
|
637
|
+
this.cwd = cwd;
|
|
638
|
+
this.timeoutMs = options?.timeoutMs ?? DEFAULT_BRIDGE_TIMEOUT_MS;
|
|
639
|
+
this.maxRestarts = options?.maxRestarts ?? 3;
|
|
640
|
+
this.configOverrides = clampSemanticTimeout(configOverrides ?? {}, this.timeoutMs);
|
|
641
|
+
this.minVersion = options?.minVersion;
|
|
642
|
+
this.onVersionMismatch = options?.onVersionMismatch;
|
|
643
|
+
}
|
|
644
|
+
get restartCount() {
|
|
645
|
+
return this._restartCount;
|
|
646
|
+
}
|
|
647
|
+
isAlive() {
|
|
648
|
+
return this.process !== null && this.process.exitCode === null && !this.process.killed;
|
|
649
|
+
}
|
|
650
|
+
async send(command, params = {}) {
|
|
651
|
+
if (this._shuttingDown) {
|
|
652
|
+
throw new Error(`[aft-pi] Bridge is shutting down, cannot send "${command}"`);
|
|
653
|
+
}
|
|
654
|
+
this.ensureSpawned();
|
|
655
|
+
if (!this.configured) {
|
|
656
|
+
if (command !== "configure" && command !== "version") {
|
|
657
|
+
if (!this._configurePromise) {
|
|
658
|
+
this._configurePromise = (async () => {
|
|
659
|
+
try {
|
|
660
|
+
const configResult = await this.send("configure", {
|
|
661
|
+
project_root: this.cwd,
|
|
662
|
+
...this.configOverrides
|
|
663
|
+
});
|
|
664
|
+
if (configResult.success === false) {
|
|
665
|
+
throw new Error(`[aft-pi] Configure failed: ${configResult.message ?? "unknown error"}`);
|
|
666
|
+
}
|
|
667
|
+
await this.checkVersion();
|
|
668
|
+
if (!this.isAlive()) {
|
|
669
|
+
throw new Error(`[aft-pi] Bridge died during version check. Check logs: ${getLogFilePath()}`);
|
|
670
|
+
}
|
|
671
|
+
this.configured = true;
|
|
672
|
+
} finally {
|
|
673
|
+
this._configurePromise = null;
|
|
674
|
+
}
|
|
675
|
+
})();
|
|
676
|
+
}
|
|
677
|
+
await this._configurePromise;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
const id = String(this.nextId++);
|
|
681
|
+
const request = { id, command, ...params };
|
|
682
|
+
const line = `${JSON.stringify(request)}
|
|
683
|
+
`;
|
|
684
|
+
return new Promise((resolve, reject) => {
|
|
685
|
+
const timer = setTimeout(() => {
|
|
686
|
+
this.pending.delete(id);
|
|
687
|
+
warn(`Request "${command}" (id=${id}) timed out after ${this.timeoutMs}ms — restarting bridge`);
|
|
688
|
+
reject(new Error(`[aft-pi] Request "${command}" (id=${id}) timed out after ${this.timeoutMs}ms`));
|
|
689
|
+
this.handleTimeout();
|
|
690
|
+
}, this.timeoutMs);
|
|
691
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
692
|
+
if (!this.process?.stdin?.writable) {
|
|
693
|
+
this.pending.delete(id);
|
|
694
|
+
clearTimeout(timer);
|
|
695
|
+
reject(new Error(`[aft-pi] stdin not writable for command "${command}"`));
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
this.process.stdin.write(line, (err) => {
|
|
699
|
+
if (err) {
|
|
700
|
+
const entry = this.pending.get(id);
|
|
701
|
+
if (entry) {
|
|
702
|
+
this.pending.delete(id);
|
|
703
|
+
clearTimeout(entry.timer);
|
|
704
|
+
entry.reject(new Error(`[aft-pi] Failed to write to stdin: ${err.message}`));
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
});
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
async shutdown() {
|
|
711
|
+
this._shuttingDown = true;
|
|
712
|
+
this.clearRestartResetTimer();
|
|
713
|
+
this.rejectAllPending(new Error("[aft-pi] Bridge shutting down"));
|
|
714
|
+
if (this.process) {
|
|
715
|
+
const proc = this.process;
|
|
716
|
+
this.process = null;
|
|
717
|
+
return new Promise((resolve) => {
|
|
718
|
+
const forceKillTimer = setTimeout(() => {
|
|
719
|
+
proc.kill("SIGKILL");
|
|
720
|
+
resolve();
|
|
721
|
+
}, 5000);
|
|
722
|
+
proc.once("exit", () => {
|
|
723
|
+
clearTimeout(forceKillTimer);
|
|
724
|
+
log("Process exited during shutdown");
|
|
725
|
+
resolve();
|
|
726
|
+
});
|
|
727
|
+
proc.kill("SIGTERM");
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
async checkVersion() {
|
|
732
|
+
if (!this.minVersion)
|
|
733
|
+
return;
|
|
734
|
+
try {
|
|
735
|
+
const resp = await this.send("version");
|
|
736
|
+
const binaryVersion = resp.version;
|
|
737
|
+
if (!binaryVersion) {
|
|
738
|
+
log("Binary did not report a version — skipping version check");
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
log(`Binary version: ${binaryVersion}`);
|
|
742
|
+
if (compareSemver(binaryVersion, this.minVersion) < 0) {
|
|
743
|
+
warn(`Binary version ${binaryVersion} is older than required ${this.minVersion}`);
|
|
744
|
+
this.onVersionMismatch?.(binaryVersion, this.minVersion);
|
|
745
|
+
}
|
|
746
|
+
} catch (err) {
|
|
747
|
+
warn(`Version check failed: ${err.message}`);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
ensureSpawned() {
|
|
751
|
+
if (this.isAlive())
|
|
752
|
+
return;
|
|
753
|
+
this.spawnProcess();
|
|
754
|
+
}
|
|
755
|
+
spawnProcess() {
|
|
756
|
+
log(`Spawning binary: ${this.binaryPath} (cwd: ${this.cwd})`);
|
|
757
|
+
const semantic = this.configOverrides.semantic;
|
|
758
|
+
const semanticBackend = (() => {
|
|
759
|
+
if (semantic && typeof semantic === "object" && !Array.isArray(semantic)) {
|
|
760
|
+
const candidate = semantic.backend;
|
|
761
|
+
return typeof candidate === "string" ? candidate : undefined;
|
|
762
|
+
}
|
|
763
|
+
return;
|
|
764
|
+
})();
|
|
765
|
+
const useFastembedBackend = semanticBackend === undefined || semanticBackend === "fastembed" || semanticBackend === "";
|
|
766
|
+
const ortDir = typeof this.configOverrides._ort_dylib_dir === "string" && useFastembedBackend ? this.configOverrides._ort_dylib_dir : null;
|
|
767
|
+
const ortLibraryPath = ortDir == null ? null : join4(ortDir, process.platform === "win32" ? "onnxruntime.dll" : process.platform === "darwin" ? "libonnxruntime.dylib" : "libonnxruntime.so");
|
|
768
|
+
const envPath = process.platform === "win32" && ortDir ? `${ortDir};${process.env.PATH ?? ""}` : process.env.PATH;
|
|
769
|
+
const env = {
|
|
770
|
+
...process.env,
|
|
771
|
+
...envPath ? { PATH: envPath } : {}
|
|
772
|
+
};
|
|
773
|
+
if (useFastembedBackend) {
|
|
774
|
+
env.FASTEMBED_CACHE_DIR = process.env.FASTEMBED_CACHE_DIR || (typeof this.configOverrides.storage_dir === "string" ? join4(this.configOverrides.storage_dir, "semantic", "models") : join4(homedir2() || "", ".cache", "fastembed"));
|
|
775
|
+
if (ortLibraryPath) {
|
|
776
|
+
env.ORT_DYLIB_PATH = ortLibraryPath;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
const child = spawn(this.binaryPath, [], {
|
|
780
|
+
cwd: this.cwd,
|
|
781
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
782
|
+
env
|
|
783
|
+
});
|
|
784
|
+
child.stdout?.on("data", (chunk) => {
|
|
785
|
+
this.onStdoutData(chunk.toString("utf-8"));
|
|
786
|
+
});
|
|
787
|
+
child.stderr?.on("data", (chunk) => {
|
|
788
|
+
const lines = chunk.toString("utf-8").trimEnd().split(`
|
|
789
|
+
`);
|
|
790
|
+
for (const line of lines) {
|
|
791
|
+
if (!line)
|
|
792
|
+
continue;
|
|
793
|
+
const stripped = line.replace(/^\[aft\]\s*/, "");
|
|
794
|
+
log(`[aft] ${stripped}`);
|
|
795
|
+
this.pushStderrLine(stripped);
|
|
796
|
+
}
|
|
797
|
+
});
|
|
798
|
+
child.on("error", (err) => {
|
|
799
|
+
error(`Process error: ${err.message}${this.formatStderrTail()}`);
|
|
800
|
+
this.handleCrash();
|
|
801
|
+
});
|
|
802
|
+
child.on("exit", (code, signal) => {
|
|
803
|
+
if (this._shuttingDown)
|
|
804
|
+
return;
|
|
805
|
+
log(`Process exited: code=${code}, signal=${signal}`);
|
|
806
|
+
if (signal === "SIGTERM" || signal === "SIGKILL" || signal === "SIGHUP" || signal === "SIGINT") {
|
|
807
|
+
this.process = null;
|
|
808
|
+
this.configured = false;
|
|
809
|
+
this.clearRestartResetTimer();
|
|
810
|
+
this.rejectAllPending(new Error(`[aft-pi] Binary killed by ${signal}`));
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
this.handleCrash();
|
|
814
|
+
});
|
|
815
|
+
this.process = child;
|
|
816
|
+
this.stdoutBuffer = "";
|
|
817
|
+
this.stderrTail = [];
|
|
818
|
+
}
|
|
819
|
+
pushStderrLine(line) {
|
|
820
|
+
this.stderrTail.push(line);
|
|
821
|
+
if (this.stderrTail.length > BinaryBridge.STDERR_TAIL_MAX) {
|
|
822
|
+
this.stderrTail.shift();
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
formatStderrTail() {
|
|
826
|
+
if (this.stderrTail.length === 0)
|
|
827
|
+
return "";
|
|
828
|
+
const tail = this.stderrTail.join(`
|
|
829
|
+
`);
|
|
830
|
+
return `
|
|
831
|
+
--- last ${this.stderrTail.length} stderr lines ---
|
|
832
|
+
${tail}`;
|
|
833
|
+
}
|
|
834
|
+
onStdoutData(data) {
|
|
835
|
+
this.stdoutBuffer += data;
|
|
836
|
+
let newlineIdx;
|
|
837
|
+
while ((newlineIdx = this.stdoutBuffer.indexOf(`
|
|
838
|
+
`)) !== -1) {
|
|
839
|
+
const line = this.stdoutBuffer.slice(0, newlineIdx).trim();
|
|
840
|
+
this.stdoutBuffer = this.stdoutBuffer.slice(newlineIdx + 1);
|
|
841
|
+
if (!line)
|
|
842
|
+
continue;
|
|
843
|
+
try {
|
|
844
|
+
const response = JSON.parse(line);
|
|
845
|
+
const id = response.id;
|
|
846
|
+
if (id && this.pending.has(id)) {
|
|
847
|
+
const entry = this.pending.get(id);
|
|
848
|
+
if (!entry)
|
|
849
|
+
continue;
|
|
850
|
+
this.pending.delete(id);
|
|
851
|
+
clearTimeout(entry.timer);
|
|
852
|
+
this.scheduleRestartCountReset();
|
|
853
|
+
entry.resolve(response);
|
|
854
|
+
}
|
|
855
|
+
} catch (_err) {
|
|
856
|
+
warn(`Failed to parse stdout line: ${line}`);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
handleTimeout() {
|
|
861
|
+
if (this.process) {
|
|
862
|
+
this.process.kill("SIGKILL");
|
|
863
|
+
this.process = null;
|
|
864
|
+
}
|
|
865
|
+
this.clearRestartResetTimer();
|
|
866
|
+
this.configured = false;
|
|
867
|
+
const tail = this.formatStderrTail();
|
|
868
|
+
this.stderrTail = [];
|
|
869
|
+
this.rejectAllPending(new Error(`[aft-pi] Bridge restarted after timeout${tail}`));
|
|
870
|
+
}
|
|
871
|
+
handleCrash() {
|
|
872
|
+
this.process = null;
|
|
873
|
+
this.clearRestartResetTimer();
|
|
874
|
+
this.configured = false;
|
|
875
|
+
const tail = this.formatStderrTail();
|
|
876
|
+
this.rejectAllPending(new Error(`[aft-pi] Binary crashed (restarts: ${this._restartCount})${tail}`));
|
|
877
|
+
if (this._restartCount < this.maxRestarts) {
|
|
878
|
+
const delay = 100 * 2 ** this._restartCount;
|
|
879
|
+
this._restartCount++;
|
|
880
|
+
log(`Auto-restart #${this._restartCount} in ${delay}ms`);
|
|
881
|
+
setTimeout(() => {
|
|
882
|
+
if (!this._shuttingDown && !this.isAlive()) {
|
|
883
|
+
try {
|
|
884
|
+
this.spawnProcess();
|
|
885
|
+
} catch (err) {
|
|
886
|
+
error(`Failed to restart: ${err.message}`);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
}, delay);
|
|
890
|
+
} else {
|
|
891
|
+
error(`Max restarts (${this.maxRestarts}) reached, giving up. Logs: ${getLogFilePath()}${tail}`);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
rejectAllPending(error2) {
|
|
895
|
+
for (const [_id, entry] of this.pending) {
|
|
896
|
+
clearTimeout(entry.timer);
|
|
897
|
+
entry.reject(error2);
|
|
898
|
+
}
|
|
899
|
+
this.pending.clear();
|
|
900
|
+
}
|
|
901
|
+
scheduleRestartCountReset() {
|
|
902
|
+
this.clearRestartResetTimer();
|
|
903
|
+
this.restartResetTimer = setTimeout(() => {
|
|
904
|
+
this._restartCount = 0;
|
|
905
|
+
this.restartResetTimer = null;
|
|
906
|
+
}, BinaryBridge.RESTART_RESET_MS);
|
|
907
|
+
}
|
|
908
|
+
clearRestartResetTimer() {
|
|
909
|
+
if (this.restartResetTimer) {
|
|
910
|
+
clearTimeout(this.restartResetTimer);
|
|
911
|
+
this.restartResetTimer = null;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// src/pool.ts
|
|
917
|
+
var DEFAULT_IDLE_TIMEOUT_MS = Infinity;
|
|
918
|
+
var DEFAULT_MAX_POOL_SIZE = 4;
|
|
919
|
+
var CLEANUP_INTERVAL_MS = 60 * 1000;
|
|
920
|
+
|
|
921
|
+
class BridgePool {
|
|
922
|
+
bridges = new Map;
|
|
923
|
+
binaryPath;
|
|
924
|
+
maxPoolSize;
|
|
925
|
+
idleTimeoutMs;
|
|
926
|
+
bridgeOptions;
|
|
927
|
+
configOverrides;
|
|
928
|
+
cleanupTimer = null;
|
|
929
|
+
constructor(binaryPath, options = {}, configOverrides = {}) {
|
|
930
|
+
this.binaryPath = binaryPath;
|
|
931
|
+
this.maxPoolSize = options.maxPoolSize ?? DEFAULT_MAX_POOL_SIZE;
|
|
932
|
+
this.idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
|
|
933
|
+
this.bridgeOptions = {
|
|
934
|
+
timeoutMs: options.timeoutMs,
|
|
935
|
+
maxRestarts: options.maxRestarts,
|
|
936
|
+
minVersion: options.minVersion,
|
|
937
|
+
onVersionMismatch: options.onVersionMismatch
|
|
938
|
+
};
|
|
939
|
+
this.configOverrides = configOverrides;
|
|
940
|
+
if (Number.isFinite(this.idleTimeoutMs)) {
|
|
941
|
+
this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL_MS);
|
|
942
|
+
this.cleanupTimer.unref();
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
getAnyActiveBridge(_directory) {
|
|
946
|
+
for (const [, entry] of this.bridges) {
|
|
947
|
+
if (entry.bridge.isAlive()) {
|
|
948
|
+
entry.lastUsed = Date.now();
|
|
949
|
+
return entry.bridge;
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
return null;
|
|
953
|
+
}
|
|
954
|
+
getBridge(directory) {
|
|
955
|
+
const key = directory.replace(/\/+$/, "");
|
|
956
|
+
const existing = this.bridges.get(key);
|
|
957
|
+
if (existing) {
|
|
958
|
+
existing.lastUsed = Date.now();
|
|
959
|
+
return existing.bridge;
|
|
960
|
+
}
|
|
961
|
+
if (this.bridges.size >= this.maxPoolSize) {
|
|
962
|
+
this.evictLRU();
|
|
963
|
+
}
|
|
964
|
+
const bridge = new BinaryBridge(this.binaryPath, key, this.bridgeOptions, this.configOverrides);
|
|
965
|
+
this.bridges.set(key, { bridge, lastUsed: Date.now() });
|
|
966
|
+
return bridge;
|
|
967
|
+
}
|
|
968
|
+
cleanup() {
|
|
969
|
+
const now = Date.now();
|
|
970
|
+
for (const [dir, entry] of this.bridges) {
|
|
971
|
+
if (now - entry.lastUsed > this.idleTimeoutMs) {
|
|
972
|
+
entry.bridge.shutdown().catch((err) => error("cleanup shutdown failed:", err));
|
|
973
|
+
this.bridges.delete(dir);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
evictLRU() {
|
|
978
|
+
let oldestDir = null;
|
|
979
|
+
let oldestTime = Infinity;
|
|
980
|
+
for (const [dir, entry] of this.bridges) {
|
|
981
|
+
if (entry.lastUsed < oldestTime) {
|
|
982
|
+
oldestTime = entry.lastUsed;
|
|
983
|
+
oldestDir = dir;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
if (oldestDir) {
|
|
987
|
+
const entry = this.bridges.get(oldestDir);
|
|
988
|
+
entry?.bridge.shutdown().catch((err) => error("eviction shutdown failed:", err));
|
|
989
|
+
this.bridges.delete(oldestDir);
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
async shutdown() {
|
|
993
|
+
if (this.cleanupTimer) {
|
|
994
|
+
clearInterval(this.cleanupTimer);
|
|
995
|
+
this.cleanupTimer = null;
|
|
996
|
+
}
|
|
997
|
+
const shutdowns = Array.from(this.bridges.values()).map((e) => e.bridge.shutdown());
|
|
998
|
+
this.bridges.clear();
|
|
999
|
+
await Promise.allSettled(shutdowns);
|
|
1000
|
+
}
|
|
1001
|
+
async replaceBinary(newPath) {
|
|
1002
|
+
this.binaryPath = newPath;
|
|
1003
|
+
for (const [, entry] of this.bridges) {
|
|
1004
|
+
try {
|
|
1005
|
+
entry.bridge.shutdown();
|
|
1006
|
+
} catch {}
|
|
1007
|
+
}
|
|
1008
|
+
this.bridges.clear();
|
|
1009
|
+
log(`Binary path updated to ${newPath}. All bridges cleared.`);
|
|
1010
|
+
}
|
|
1011
|
+
get size() {
|
|
1012
|
+
return this.bridges.size;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// src/resolver.ts
|
|
1017
|
+
import { execSync, spawnSync } from "node:child_process";
|
|
1018
|
+
import { chmodSync as chmodSync3, copyFileSync, existsSync as existsSync4, mkdirSync as mkdirSync3, renameSync } from "node:fs";
|
|
1019
|
+
import { createRequire as createRequire2 } from "node:module";
|
|
1020
|
+
import { homedir as homedir4 } from "node:os";
|
|
1021
|
+
import { join as join6 } from "node:path";
|
|
1022
|
+
|
|
1023
|
+
// src/downloader.ts
|
|
1024
|
+
import { chmodSync as chmodSync2, existsSync as existsSync3, mkdirSync as mkdirSync2, unlinkSync as unlinkSync2 } from "node:fs";
|
|
1025
|
+
import { homedir as homedir3 } from "node:os";
|
|
1026
|
+
import { join as join5 } from "node:path";
|
|
1027
|
+
|
|
1028
|
+
// src/platform.ts
|
|
1029
|
+
var PLATFORM_ARCH_MAP = {
|
|
1030
|
+
darwin: { arm64: "darwin-arm64", x64: "darwin-x64" },
|
|
1031
|
+
linux: { arm64: "linux-arm64", x64: "linux-x64" },
|
|
1032
|
+
win32: { x64: "win32-x64" }
|
|
1033
|
+
};
|
|
1034
|
+
var PLATFORM_ASSET_MAP = {
|
|
1035
|
+
"darwin-arm64": "aft-darwin-arm64",
|
|
1036
|
+
"darwin-x64": "aft-darwin-x64",
|
|
1037
|
+
"linux-arm64": "aft-linux-arm64",
|
|
1038
|
+
"linux-x64": "aft-linux-x64",
|
|
1039
|
+
"win32-x64": "aft-win32-x64.exe"
|
|
1040
|
+
};
|
|
1041
|
+
|
|
1042
|
+
// src/downloader.ts
|
|
1043
|
+
var REPO = "cortexkit/aft";
|
|
1044
|
+
function getCacheDir() {
|
|
1045
|
+
if (process.platform === "win32") {
|
|
1046
|
+
const localAppData = process.env.LOCALAPPDATA || process.env.APPDATA;
|
|
1047
|
+
const base2 = localAppData || join5(homedir3(), "AppData", "Local");
|
|
1048
|
+
return join5(base2, "aft", "bin");
|
|
1049
|
+
}
|
|
1050
|
+
const base = process.env.XDG_CACHE_HOME || join5(homedir3(), ".cache");
|
|
1051
|
+
return join5(base, "aft", "bin");
|
|
1052
|
+
}
|
|
1053
|
+
function getBinaryName() {
|
|
1054
|
+
return process.platform === "win32" ? "aft.exe" : "aft";
|
|
1055
|
+
}
|
|
1056
|
+
function getCachedBinaryPath(version) {
|
|
1057
|
+
if (!version)
|
|
1058
|
+
return null;
|
|
1059
|
+
const binaryPath = join5(getCacheDir(), version, getBinaryName());
|
|
1060
|
+
return existsSync3(binaryPath) ? binaryPath : null;
|
|
1061
|
+
}
|
|
1062
|
+
async function downloadBinary(version) {
|
|
1063
|
+
const platformKey = `${process.platform}-${process.arch}`;
|
|
1064
|
+
const assetName = PLATFORM_ASSET_MAP[platformKey];
|
|
1065
|
+
if (!assetName) {
|
|
1066
|
+
error(`Unsupported platform: ${platformKey}`);
|
|
1067
|
+
return null;
|
|
1068
|
+
}
|
|
1069
|
+
const tag = version ?? await fetchLatestTag();
|
|
1070
|
+
if (!tag) {
|
|
1071
|
+
error("Could not determine latest release version.");
|
|
1072
|
+
return null;
|
|
1073
|
+
}
|
|
1074
|
+
const versionedCacheDir = join5(getCacheDir(), tag);
|
|
1075
|
+
const binaryName = getBinaryName();
|
|
1076
|
+
const binaryPath = join5(versionedCacheDir, binaryName);
|
|
1077
|
+
if (existsSync3(binaryPath)) {
|
|
1078
|
+
return binaryPath;
|
|
1079
|
+
}
|
|
1080
|
+
const downloadUrl = `https://github.com/${REPO}/releases/download/${tag}/${assetName}`;
|
|
1081
|
+
const checksumUrl = `https://github.com/${REPO}/releases/download/${tag}/checksums.sha256`;
|
|
1082
|
+
log(`Downloading AFT binary (${tag}) for ${platformKey}...`);
|
|
1083
|
+
try {
|
|
1084
|
+
if (!existsSync3(versionedCacheDir)) {
|
|
1085
|
+
mkdirSync2(versionedCacheDir, { recursive: true });
|
|
1086
|
+
}
|
|
1087
|
+
const [binaryResponse, checksumResponse] = await Promise.all([
|
|
1088
|
+
fetch(downloadUrl, { redirect: "follow" }),
|
|
1089
|
+
fetch(checksumUrl, { redirect: "follow" })
|
|
1090
|
+
]);
|
|
1091
|
+
if (!binaryResponse.ok) {
|
|
1092
|
+
throw new Error(`HTTP ${binaryResponse.status}: ${binaryResponse.statusText} (${downloadUrl})`);
|
|
1093
|
+
}
|
|
1094
|
+
const arrayBuffer = await binaryResponse.arrayBuffer();
|
|
1095
|
+
if (!checksumResponse.ok) {
|
|
1096
|
+
warn(`Checksum verification failed: no checksums.sha256 found for ${tag}. ` + "Binary download aborted for security reasons.");
|
|
1097
|
+
return null;
|
|
1098
|
+
}
|
|
1099
|
+
const checksumText = await checksumResponse.text();
|
|
1100
|
+
const expectedHash = parseChecksumForAsset(checksumText, assetName);
|
|
1101
|
+
if (!expectedHash) {
|
|
1102
|
+
warn(`Checksum verification failed: checksums.sha256 found but no entry for ${assetName}. ` + "Binary download aborted for security reasons.");
|
|
1103
|
+
return null;
|
|
1104
|
+
}
|
|
1105
|
+
const { createHash } = await import("node:crypto");
|
|
1106
|
+
const actualHash = createHash("sha256").update(Buffer.from(arrayBuffer)).digest("hex");
|
|
1107
|
+
if (actualHash !== expectedHash) {
|
|
1108
|
+
throw new Error(`Checksum mismatch for ${assetName}: expected ${expectedHash}, got ${actualHash}. The binary may have been tampered with.`);
|
|
1109
|
+
}
|
|
1110
|
+
log(`Checksum verified (SHA-256: ${actualHash.slice(0, 16)}...)`);
|
|
1111
|
+
const tmpPath = `${binaryPath}.tmp`;
|
|
1112
|
+
const { writeFileSync } = await import("node:fs");
|
|
1113
|
+
writeFileSync(tmpPath, Buffer.from(arrayBuffer));
|
|
1114
|
+
if (process.platform !== "win32") {
|
|
1115
|
+
chmodSync2(tmpPath, 493);
|
|
1116
|
+
}
|
|
1117
|
+
const { renameSync } = await import("node:fs");
|
|
1118
|
+
renameSync(tmpPath, binaryPath);
|
|
1119
|
+
log(`AFT binary ready at ${binaryPath}`);
|
|
1120
|
+
return binaryPath;
|
|
1121
|
+
} catch (err) {
|
|
1122
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1123
|
+
error(`Failed to download AFT binary: ${msg}`);
|
|
1124
|
+
const tmpPath = `${binaryPath}.tmp`;
|
|
1125
|
+
if (existsSync3(tmpPath)) {
|
|
1126
|
+
try {
|
|
1127
|
+
unlinkSync2(tmpPath);
|
|
1128
|
+
} catch {}
|
|
1129
|
+
}
|
|
1130
|
+
return null;
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
async function ensureBinary(version) {
|
|
1134
|
+
if (version) {
|
|
1135
|
+
const versionCached = getCachedBinaryPath(version);
|
|
1136
|
+
if (versionCached) {
|
|
1137
|
+
log(`Found cached binary for ${version}: ${versionCached}`);
|
|
1138
|
+
return versionCached;
|
|
1139
|
+
}
|
|
1140
|
+
log(`No cached binary for ${version}, downloading...`);
|
|
1141
|
+
return downloadBinary(version);
|
|
1142
|
+
}
|
|
1143
|
+
const legacyCached = getCachedBinaryPath();
|
|
1144
|
+
if (legacyCached) {
|
|
1145
|
+
log(`Found cached binary: ${legacyCached}`);
|
|
1146
|
+
return legacyCached;
|
|
1147
|
+
}
|
|
1148
|
+
log("No cached binary found, downloading latest...");
|
|
1149
|
+
return downloadBinary();
|
|
1150
|
+
}
|
|
1151
|
+
function parseChecksumForAsset(checksumText, assetName) {
|
|
1152
|
+
for (const line of checksumText.split(`
|
|
1153
|
+
`)) {
|
|
1154
|
+
const trimmed = line.trim();
|
|
1155
|
+
if (!trimmed)
|
|
1156
|
+
continue;
|
|
1157
|
+
const match = trimmed.match(/^([0-9a-f]{64})\s+(.+)$/);
|
|
1158
|
+
if (match && match[2] === assetName) {
|
|
1159
|
+
return match[1];
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
return null;
|
|
1163
|
+
}
|
|
1164
|
+
async function fetchLatestTag() {
|
|
1165
|
+
try {
|
|
1166
|
+
const response = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, {
|
|
1167
|
+
headers: { Accept: "application/vnd.github.v3+json" }
|
|
1168
|
+
});
|
|
1169
|
+
if (!response.ok)
|
|
1170
|
+
return null;
|
|
1171
|
+
const data = await response.json();
|
|
1172
|
+
return data.tag_name ?? null;
|
|
1173
|
+
} catch {
|
|
1174
|
+
return null;
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// src/resolver.ts
|
|
1179
|
+
function copyToVersionedCache(npmBinaryPath) {
|
|
1180
|
+
try {
|
|
1181
|
+
const result = spawnSync(npmBinaryPath, ["--version"], {
|
|
1182
|
+
encoding: "utf-8",
|
|
1183
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1184
|
+
timeout: 5000
|
|
1185
|
+
});
|
|
1186
|
+
const rawVersion = result.stdout?.trim();
|
|
1187
|
+
if (!rawVersion)
|
|
1188
|
+
return null;
|
|
1189
|
+
const version = rawVersion.replace(/^aft\s+/, "");
|
|
1190
|
+
const tag = version.startsWith("v") ? version : `v${version}`;
|
|
1191
|
+
const cacheDir = getCacheDir();
|
|
1192
|
+
const versionedDir = join6(cacheDir, tag);
|
|
1193
|
+
const ext = process.platform === "win32" ? ".exe" : "";
|
|
1194
|
+
const cachedPath = join6(versionedDir, `aft${ext}`);
|
|
1195
|
+
if (existsSync4(cachedPath))
|
|
1196
|
+
return cachedPath;
|
|
1197
|
+
mkdirSync3(versionedDir, { recursive: true });
|
|
1198
|
+
const tmpPath = `${cachedPath}.tmp`;
|
|
1199
|
+
copyFileSync(npmBinaryPath, tmpPath);
|
|
1200
|
+
if (process.platform !== "win32") {
|
|
1201
|
+
chmodSync3(tmpPath, 493);
|
|
1202
|
+
}
|
|
1203
|
+
renameSync(tmpPath, cachedPath);
|
|
1204
|
+
log(`Copied npm binary to versioned cache: ${cachedPath}`);
|
|
1205
|
+
return cachedPath;
|
|
1206
|
+
} catch (err) {
|
|
1207
|
+
warn(`Failed to copy binary to cache: ${err instanceof Error ? err.message : String(err)}`);
|
|
1208
|
+
return null;
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
function platformKey(platform = process.platform, arch = process.arch) {
|
|
1212
|
+
const archMap = PLATFORM_ARCH_MAP[platform];
|
|
1213
|
+
if (!archMap) {
|
|
1214
|
+
throw new Error(`Unsupported platform: ${platform} (arch: ${arch}). ` + `Supported platforms: ${Object.keys(PLATFORM_ARCH_MAP).join(", ")}`);
|
|
1215
|
+
}
|
|
1216
|
+
const key = archMap[arch];
|
|
1217
|
+
if (!key) {
|
|
1218
|
+
throw new Error(`Unsupported architecture: ${arch} on platform ${platform}. ` + `Supported architectures for ${platform}: ${Object.keys(archMap).join(", ")}`);
|
|
1219
|
+
}
|
|
1220
|
+
return key;
|
|
1221
|
+
}
|
|
1222
|
+
function findBinarySync() {
|
|
1223
|
+
const ext = process.platform === "win32" ? ".exe" : "";
|
|
1224
|
+
const pluginVersion = (() => {
|
|
1225
|
+
try {
|
|
1226
|
+
const req = createRequire2(import.meta.url);
|
|
1227
|
+
return `v${req("../package.json").version}`;
|
|
1228
|
+
} catch {
|
|
1229
|
+
return null;
|
|
1230
|
+
}
|
|
1231
|
+
})();
|
|
1232
|
+
if (pluginVersion) {
|
|
1233
|
+
const versionCached = getCachedBinaryPath(pluginVersion);
|
|
1234
|
+
if (versionCached)
|
|
1235
|
+
return versionCached;
|
|
1236
|
+
}
|
|
1237
|
+
try {
|
|
1238
|
+
const key = platformKey();
|
|
1239
|
+
const packageBin = `@cortexkit/aft-${key}/bin/aft${ext}`;
|
|
1240
|
+
const req = createRequire2(import.meta.url);
|
|
1241
|
+
const resolved = req.resolve(packageBin);
|
|
1242
|
+
if (existsSync4(resolved)) {
|
|
1243
|
+
const copied = copyToVersionedCache(resolved);
|
|
1244
|
+
return copied ?? resolved;
|
|
1245
|
+
}
|
|
1246
|
+
} catch {}
|
|
1247
|
+
try {
|
|
1248
|
+
const whichCmd = process.platform === "win32" ? "where aft" : "which aft";
|
|
1249
|
+
const result = execSync(whichCmd, {
|
|
1250
|
+
encoding: "utf-8",
|
|
1251
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
1252
|
+
}).trim();
|
|
1253
|
+
if (result)
|
|
1254
|
+
return result;
|
|
1255
|
+
} catch {}
|
|
1256
|
+
const cargoPath = join6(homedir4(), ".cargo", "bin", `aft${ext}`);
|
|
1257
|
+
if (existsSync4(cargoPath))
|
|
1258
|
+
return cargoPath;
|
|
1259
|
+
return null;
|
|
1260
|
+
}
|
|
1261
|
+
async function findBinary() {
|
|
1262
|
+
const syncResult = findBinarySync();
|
|
1263
|
+
if (syncResult) {
|
|
1264
|
+
log(`Resolved binary: ${syncResult}`);
|
|
1265
|
+
return syncResult;
|
|
1266
|
+
}
|
|
1267
|
+
log("Binary not found locally, attempting auto-download...");
|
|
1268
|
+
const downloaded = await ensureBinary();
|
|
1269
|
+
if (downloaded)
|
|
1270
|
+
return downloaded;
|
|
1271
|
+
throw new Error([
|
|
1272
|
+
"Could not find the `aft` binary.",
|
|
1273
|
+
"",
|
|
1274
|
+
"Attempted sources:",
|
|
1275
|
+
" - Cache directory (~/.cache/aft/bin/)",
|
|
1276
|
+
" - npm platform package (@cortexkit/aft-<platform>)",
|
|
1277
|
+
" - PATH lookup (which aft)",
|
|
1278
|
+
" - ~/.cargo/bin/aft",
|
|
1279
|
+
" - Auto-download from GitHub releases (failed)",
|
|
1280
|
+
"",
|
|
1281
|
+
"Install it using one of these methods:",
|
|
1282
|
+
" npm install @cortexkit/aft-pi # installs platform-specific binary via npm",
|
|
1283
|
+
" cargo install agent-file-tools # from crates.io",
|
|
1284
|
+
"",
|
|
1285
|
+
"Or add the aft directory to your PATH."
|
|
1286
|
+
].join(`
|
|
1287
|
+
`));
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
// src/shutdown-hooks.ts
|
|
1291
|
+
var GLOBAL_KEY = "__aftPiShutdownHooks__";
|
|
1292
|
+
function getState() {
|
|
1293
|
+
const g = globalThis;
|
|
1294
|
+
if (!g[GLOBAL_KEY]) {
|
|
1295
|
+
g[GLOBAL_KEY] = { cleanups: new Set, installed: false };
|
|
1296
|
+
}
|
|
1297
|
+
return g[GLOBAL_KEY];
|
|
1298
|
+
}
|
|
1299
|
+
var shuttingDown = false;
|
|
1300
|
+
async function runCleanups(reason) {
|
|
1301
|
+
if (shuttingDown)
|
|
1302
|
+
return;
|
|
1303
|
+
shuttingDown = true;
|
|
1304
|
+
const state = getState();
|
|
1305
|
+
if (state.cleanups.size === 0)
|
|
1306
|
+
return;
|
|
1307
|
+
log(`Shutdown triggered by ${reason} — running ${state.cleanups.size} cleanup(s)`);
|
|
1308
|
+
const cleanups = Array.from(state.cleanups);
|
|
1309
|
+
state.cleanups.clear();
|
|
1310
|
+
await Promise.allSettled(cleanups.map(async (fn) => {
|
|
1311
|
+
try {
|
|
1312
|
+
await fn();
|
|
1313
|
+
} catch (err) {
|
|
1314
|
+
log(`Cleanup error: ${err.message}`);
|
|
1315
|
+
}
|
|
1316
|
+
}));
|
|
1317
|
+
}
|
|
1318
|
+
function installProcessHandlers() {
|
|
1319
|
+
const state = getState();
|
|
1320
|
+
if (state.installed)
|
|
1321
|
+
return;
|
|
1322
|
+
state.installed = true;
|
|
1323
|
+
const signals = ["SIGTERM", "SIGINT", "SIGHUP"];
|
|
1324
|
+
for (const sig of signals) {
|
|
1325
|
+
process.on(sig, () => {
|
|
1326
|
+
runCleanups(sig);
|
|
1327
|
+
});
|
|
1328
|
+
}
|
|
1329
|
+
process.on("beforeExit", () => {
|
|
1330
|
+
runCleanups("beforeExit");
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
function registerShutdownCleanup(fn) {
|
|
1334
|
+
installProcessHandlers();
|
|
1335
|
+
const state = getState();
|
|
1336
|
+
state.cleanups.add(fn);
|
|
1337
|
+
return () => {
|
|
1338
|
+
state.cleanups.delete(fn);
|
|
1339
|
+
};
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
// src/tools/ast.ts
|
|
1343
|
+
import { StringEnum } from "@mariozechner/pi-ai";
|
|
1344
|
+
import { Type } from "@sinclair/typebox";
|
|
1345
|
+
var AstLang = StringEnum(["typescript", "tsx", "javascript", "python", "rust", "go"], {
|
|
1346
|
+
description: "Target language"
|
|
1347
|
+
});
|
|
1348
|
+
var SearchParams = Type.Object({
|
|
1349
|
+
pattern: Type.String({
|
|
1350
|
+
description: "AST pattern with meta-variables (`$VAR` matches one node, `$$$` matches many). Must be a complete AST node."
|
|
1351
|
+
}),
|
|
1352
|
+
lang: AstLang,
|
|
1353
|
+
paths: Type.Optional(Type.Array(Type.String(), { description: "Paths to search (default: ['.'])" })),
|
|
1354
|
+
globs: Type.Optional(Type.Array(Type.String(), { description: "Include/exclude globs (prefix `!` to exclude)" })),
|
|
1355
|
+
contextLines: Type.Optional(Type.Number({ description: "Number of context lines around each match" }))
|
|
1356
|
+
});
|
|
1357
|
+
var ReplaceParams = Type.Object({
|
|
1358
|
+
pattern: Type.String({ description: "AST pattern with meta-variables" }),
|
|
1359
|
+
rewrite: Type.String({ description: "Replacement pattern, can reference $VAR from pattern" }),
|
|
1360
|
+
lang: AstLang,
|
|
1361
|
+
paths: Type.Optional(Type.Array(Type.String(), { description: "Paths (default: ['.'])" })),
|
|
1362
|
+
globs: Type.Optional(Type.Array(Type.String(), { description: "Include/exclude globs" })),
|
|
1363
|
+
dryRun: Type.Optional(Type.Boolean({ description: "Preview without applying (default: false)" }))
|
|
1364
|
+
});
|
|
1365
|
+
function registerAstTools(pi, ctx, surface) {
|
|
1366
|
+
if (surface.astSearch) {
|
|
1367
|
+
pi.registerTool({
|
|
1368
|
+
name: "ast_grep_search",
|
|
1369
|
+
label: "ast search",
|
|
1370
|
+
description: "Search code patterns across the filesystem using AST-aware matching. Use `$VAR` to match a single AST node, `$$$` for multiple. Pattern must be a complete, valid code fragment (include braces, params, etc.).",
|
|
1371
|
+
parameters: SearchParams,
|
|
1372
|
+
async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
|
|
1373
|
+
const bridge = bridgeFor(ctx, extCtx.cwd);
|
|
1374
|
+
const req = {
|
|
1375
|
+
pattern: params.pattern,
|
|
1376
|
+
lang: params.lang
|
|
1377
|
+
};
|
|
1378
|
+
if (params.paths !== undefined)
|
|
1379
|
+
req.paths = params.paths;
|
|
1380
|
+
if (params.globs !== undefined)
|
|
1381
|
+
req.globs = params.globs;
|
|
1382
|
+
if (params.contextLines !== undefined)
|
|
1383
|
+
req.context_lines = params.contextLines;
|
|
1384
|
+
const response = await callBridge(bridge, "ast_search", req);
|
|
1385
|
+
return textResult(response.text ?? JSON.stringify(response));
|
|
1386
|
+
}
|
|
1387
|
+
});
|
|
1388
|
+
}
|
|
1389
|
+
if (surface.astReplace) {
|
|
1390
|
+
pi.registerTool({
|
|
1391
|
+
name: "ast_grep_replace",
|
|
1392
|
+
label: "ast replace",
|
|
1393
|
+
description: "Replace code patterns across the filesystem with AST-aware rewriting. Applies by default — pass `dryRun: true` to preview. Use meta-variables in `rewrite` to preserve captured content from the pattern.",
|
|
1394
|
+
parameters: ReplaceParams,
|
|
1395
|
+
async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
|
|
1396
|
+
const bridge = bridgeFor(ctx, extCtx.cwd);
|
|
1397
|
+
const req = {
|
|
1398
|
+
pattern: params.pattern,
|
|
1399
|
+
rewrite: params.rewrite,
|
|
1400
|
+
lang: params.lang
|
|
1401
|
+
};
|
|
1402
|
+
if (params.paths !== undefined)
|
|
1403
|
+
req.paths = params.paths;
|
|
1404
|
+
if (params.globs !== undefined)
|
|
1405
|
+
req.globs = params.globs;
|
|
1406
|
+
req.dry_run = params.dryRun === true;
|
|
1407
|
+
const response = await callBridge(bridge, "ast_replace", req);
|
|
1408
|
+
return textResult(response.text ?? JSON.stringify(response));
|
|
1409
|
+
}
|
|
1410
|
+
});
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
// src/tools/conflicts.ts
|
|
1415
|
+
import { Type as Type2 } from "@sinclair/typebox";
|
|
1416
|
+
var ConflictsParams = Type2.Object({});
|
|
1417
|
+
function registerConflictsTool(pi, ctx) {
|
|
1418
|
+
pi.registerTool({
|
|
1419
|
+
name: "aft_conflicts",
|
|
1420
|
+
label: "conflicts",
|
|
1421
|
+
description: "Show all git merge conflicts across the repository — returns line-numbered conflict regions with context for every conflicted file in a single call.",
|
|
1422
|
+
parameters: ConflictsParams,
|
|
1423
|
+
async execute(_toolCallId, _params, _signal, _onUpdate, extCtx) {
|
|
1424
|
+
const bridge = bridgeFor(ctx, extCtx.cwd);
|
|
1425
|
+
const response = await callBridge(bridge, "git_conflicts");
|
|
1426
|
+
return textResult(response.text ?? JSON.stringify(response, null, 2));
|
|
1427
|
+
}
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
// src/tools/fs.ts
|
|
1432
|
+
import { Type as Type3 } from "@sinclair/typebox";
|
|
1433
|
+
var DeleteParams = Type3.Object({
|
|
1434
|
+
filePath: Type3.String({ description: "Path to file to delete" })
|
|
1435
|
+
});
|
|
1436
|
+
var MoveParams = Type3.Object({
|
|
1437
|
+
filePath: Type3.String({ description: "Source file path to move" }),
|
|
1438
|
+
destination: Type3.String({ description: "Destination file path" })
|
|
1439
|
+
});
|
|
1440
|
+
function registerFsTools(pi, ctx, surface) {
|
|
1441
|
+
if (surface.delete) {
|
|
1442
|
+
pi.registerTool({
|
|
1443
|
+
name: "aft_delete",
|
|
1444
|
+
label: "delete",
|
|
1445
|
+
description: "Delete a file with backup. The file content is backed up before deletion — use `aft_safety undo` to recover.",
|
|
1446
|
+
parameters: DeleteParams,
|
|
1447
|
+
async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
|
|
1448
|
+
const bridge = bridgeFor(ctx, extCtx.cwd);
|
|
1449
|
+
const response = await callBridge(bridge, "delete_file", { file: params.filePath });
|
|
1450
|
+
return textResult(`Deleted ${params.filePath}`, response);
|
|
1451
|
+
}
|
|
1452
|
+
});
|
|
1453
|
+
}
|
|
1454
|
+
if (surface.move) {
|
|
1455
|
+
pi.registerTool({
|
|
1456
|
+
name: "aft_move",
|
|
1457
|
+
label: "move",
|
|
1458
|
+
description: "Move or rename a file with backup. Creates parent directories for the destination automatically. This operates on files at the OS level — to relocate a code symbol between files, use `aft_refactor` with op='move' instead.",
|
|
1459
|
+
parameters: MoveParams,
|
|
1460
|
+
async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
|
|
1461
|
+
const bridge = bridgeFor(ctx, extCtx.cwd);
|
|
1462
|
+
const response = await callBridge(bridge, "move_file", {
|
|
1463
|
+
file: params.filePath,
|
|
1464
|
+
destination: params.destination
|
|
1465
|
+
});
|
|
1466
|
+
return textResult(`Moved ${params.filePath} → ${params.destination}`, response);
|
|
1467
|
+
}
|
|
1468
|
+
});
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
// src/tools/hoisted.ts
|
|
1473
|
+
import { stat } from "node:fs/promises";
|
|
1474
|
+
import { resolve } from "node:path";
|
|
1475
|
+
import { Type as Type4 } from "@sinclair/typebox";
|
|
1476
|
+
var ReadParams = Type4.Object({
|
|
1477
|
+
path: Type4.String({ description: "Path to the file to read (relative or absolute)" }),
|
|
1478
|
+
offset: Type4.Optional(Type4.Number({ description: "Line number to start reading from (1-indexed)" })),
|
|
1479
|
+
limit: Type4.Optional(Type4.Number({ description: "Maximum number of lines to read" }))
|
|
1480
|
+
});
|
|
1481
|
+
var WriteParams = Type4.Object({
|
|
1482
|
+
filePath: Type4.String({
|
|
1483
|
+
description: "Path to the file to write (absolute or project-relative)"
|
|
1484
|
+
}),
|
|
1485
|
+
content: Type4.String({ description: "Full file contents to write" })
|
|
1486
|
+
});
|
|
1487
|
+
var EditParams = Type4.Object({
|
|
1488
|
+
filePath: Type4.String({ description: "Path to the file to edit" }),
|
|
1489
|
+
oldString: Type4.Optional(Type4.String({ description: "Text to find (exact match, fuzzy fallback)" })),
|
|
1490
|
+
newString: Type4.Optional(Type4.String({ description: "Replacement text (omit to delete match)" })),
|
|
1491
|
+
replaceAll: Type4.Optional(Type4.Boolean({ description: "Replace every occurrence" })),
|
|
1492
|
+
occurrence: Type4.Optional(Type4.Number({ description: "0-indexed occurrence when multiple matches exist" }))
|
|
1493
|
+
});
|
|
1494
|
+
var GrepParams = Type4.Object({
|
|
1495
|
+
pattern: Type4.String({ description: "Regex pattern to search for" }),
|
|
1496
|
+
path: Type4.Optional(Type4.String({ description: "Path scope (file or directory)" })),
|
|
1497
|
+
include: Type4.Optional(Type4.String({ description: "Glob filter for included files (e.g. '*.ts,*.tsx')" })),
|
|
1498
|
+
caseSensitive: Type4.Optional(Type4.Boolean({ description: "Case-sensitive matching" })),
|
|
1499
|
+
contextLines: Type4.Optional(Type4.Number({ description: "Lines of context before/after each match" }))
|
|
1500
|
+
});
|
|
1501
|
+
function registerHoistedTools(pi, ctx, surface) {
|
|
1502
|
+
if (surface.hoistRead) {
|
|
1503
|
+
pi.registerTool({
|
|
1504
|
+
name: "read",
|
|
1505
|
+
label: "read",
|
|
1506
|
+
description: "Read file contents with line numbers. Backed by AFT's indexed Rust reader — faster than the built-in `read` on large repos and correctly handles images/PDFs as attachments.",
|
|
1507
|
+
parameters: ReadParams,
|
|
1508
|
+
async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
|
|
1509
|
+
const bridge = bridgeFor(ctx, extCtx.cwd);
|
|
1510
|
+
const req = { file: params.path };
|
|
1511
|
+
if (params.offset !== undefined) {
|
|
1512
|
+
req.start_line = params.offset;
|
|
1513
|
+
if (params.limit !== undefined) {
|
|
1514
|
+
req.end_line = params.offset + params.limit - 1;
|
|
1515
|
+
}
|
|
1516
|
+
} else if (params.limit !== undefined) {
|
|
1517
|
+
req.end_line = params.limit;
|
|
1518
|
+
}
|
|
1519
|
+
const response = await callBridge(bridge, "read", req);
|
|
1520
|
+
if (Array.isArray(response.entries)) {
|
|
1521
|
+
return textResult(response.entries.join(`
|
|
1522
|
+
`));
|
|
1523
|
+
}
|
|
1524
|
+
const text = response.content ?? "";
|
|
1525
|
+
return textResult(text);
|
|
1526
|
+
}
|
|
1527
|
+
});
|
|
1528
|
+
}
|
|
1529
|
+
if (surface.hoistWrite) {
|
|
1530
|
+
pi.registerTool({
|
|
1531
|
+
name: "write",
|
|
1532
|
+
label: "write",
|
|
1533
|
+
description: "Write a file atomically with per-file backup, optional auto-format, and inline LSP diagnostics. Parent directories are created automatically. Overwrites existing files.",
|
|
1534
|
+
parameters: WriteParams,
|
|
1535
|
+
async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
|
|
1536
|
+
const bridge = bridgeFor(ctx, extCtx.cwd);
|
|
1537
|
+
const response = await callBridge(bridge, "write", {
|
|
1538
|
+
file: params.filePath,
|
|
1539
|
+
content: params.content
|
|
1540
|
+
});
|
|
1541
|
+
const diffAdd = response.diff?.additions ?? 0;
|
|
1542
|
+
const diffDel = response.diff?.deletions ?? 0;
|
|
1543
|
+
const diagnostics = response.lsp_diagnostics;
|
|
1544
|
+
let summary = `Wrote ${params.filePath} (+${diffAdd}/-${diffDel})`;
|
|
1545
|
+
if (diagnostics && diagnostics.length > 0) {
|
|
1546
|
+
summary += `
|
|
1547
|
+
|
|
1548
|
+
LSP diagnostics:
|
|
1549
|
+
${JSON.stringify(diagnostics, null, 2)}`;
|
|
1550
|
+
}
|
|
1551
|
+
return textResult(summary, {
|
|
1552
|
+
filePath: params.filePath,
|
|
1553
|
+
additions: diffAdd,
|
|
1554
|
+
deletions: diffDel,
|
|
1555
|
+
diagnostics
|
|
1556
|
+
});
|
|
1557
|
+
}
|
|
1558
|
+
});
|
|
1559
|
+
}
|
|
1560
|
+
if (surface.hoistEdit) {
|
|
1561
|
+
pi.registerTool({
|
|
1562
|
+
name: "edit",
|
|
1563
|
+
label: "edit",
|
|
1564
|
+
description: "Find-and-replace edit with progressive fuzzy matching (handles whitespace and Unicode drift). Returns an error if multiple matches are found — use `occurrence` to select one, or `replaceAll: true` to replace all. Always returns inline LSP diagnostics for the edited file.",
|
|
1565
|
+
parameters: EditParams,
|
|
1566
|
+
async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
|
|
1567
|
+
const bridge = bridgeFor(ctx, extCtx.cwd);
|
|
1568
|
+
const req = {
|
|
1569
|
+
file: params.filePath,
|
|
1570
|
+
match: params.oldString ?? "",
|
|
1571
|
+
replacement: params.newString ?? ""
|
|
1572
|
+
};
|
|
1573
|
+
if (params.replaceAll === true)
|
|
1574
|
+
req.replace_all = true;
|
|
1575
|
+
if (params.occurrence !== undefined)
|
|
1576
|
+
req.occurrence = params.occurrence;
|
|
1577
|
+
const response = await callBridge(bridge, "edit_match", req);
|
|
1578
|
+
const diff = response.diff;
|
|
1579
|
+
const diffAdd = diff?.additions ?? 0;
|
|
1580
|
+
const diffDel = diff?.deletions ?? 0;
|
|
1581
|
+
const replacements = response.replacements;
|
|
1582
|
+
const diagnostics = response.lsp_diagnostics;
|
|
1583
|
+
let summary = `Edited ${params.filePath} (+${diffAdd}/-${diffDel}, ${replacements ?? 1} replacement${replacements === 1 ? "" : "s"})`;
|
|
1584
|
+
if (diagnostics && diagnostics.length > 0) {
|
|
1585
|
+
summary += `
|
|
1586
|
+
|
|
1587
|
+
LSP diagnostics:
|
|
1588
|
+
${JSON.stringify(diagnostics, null, 2)}`;
|
|
1589
|
+
}
|
|
1590
|
+
return textResult(summary, {
|
|
1591
|
+
filePath: params.filePath,
|
|
1592
|
+
additions: diffAdd,
|
|
1593
|
+
deletions: diffDel,
|
|
1594
|
+
replacements,
|
|
1595
|
+
diagnostics
|
|
1596
|
+
});
|
|
1597
|
+
}
|
|
1598
|
+
});
|
|
1599
|
+
}
|
|
1600
|
+
if (surface.hoistGrep) {
|
|
1601
|
+
pi.registerTool({
|
|
1602
|
+
name: "grep",
|
|
1603
|
+
label: "grep",
|
|
1604
|
+
description: "Search for a regex pattern across files. Uses AFT's trigram index inside the project root for fast repeated queries, and falls back to ripgrep for paths outside the project root.",
|
|
1605
|
+
parameters: GrepParams,
|
|
1606
|
+
async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
|
|
1607
|
+
const bridge = bridgeFor(ctx, extCtx.cwd);
|
|
1608
|
+
const req = { pattern: params.pattern };
|
|
1609
|
+
if (params.path)
|
|
1610
|
+
req.path = await resolvePathArg(extCtx.cwd, params.path);
|
|
1611
|
+
if (params.include)
|
|
1612
|
+
req.include = splitIncludeGlobs(params.include);
|
|
1613
|
+
if (params.caseSensitive !== undefined)
|
|
1614
|
+
req.case_sensitive = params.caseSensitive;
|
|
1615
|
+
if (params.contextLines !== undefined)
|
|
1616
|
+
req.context_lines = params.contextLines;
|
|
1617
|
+
const response = await callBridge(bridge, "grep", req);
|
|
1618
|
+
const text = response.text ?? "";
|
|
1619
|
+
return textResult(text);
|
|
1620
|
+
}
|
|
1621
|
+
});
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
async function resolvePathArg(cwd, path2) {
|
|
1625
|
+
const abs = resolve(cwd, path2);
|
|
1626
|
+
try {
|
|
1627
|
+
await stat(abs);
|
|
1628
|
+
return abs;
|
|
1629
|
+
} catch {
|
|
1630
|
+
return path2;
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
function splitIncludeGlobs(include) {
|
|
1634
|
+
return include.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
// src/tools/imports.ts
|
|
1638
|
+
import { StringEnum as StringEnum2 } from "@mariozechner/pi-ai";
|
|
1639
|
+
import { Type as Type5 } from "@sinclair/typebox";
|
|
1640
|
+
var ImportParams = Type5.Object({
|
|
1641
|
+
op: StringEnum2(["add", "remove", "organize"], { description: "Import operation" }),
|
|
1642
|
+
filePath: Type5.String({ description: "Path to the file" }),
|
|
1643
|
+
module: Type5.Optional(Type5.String({ description: "Module path (required for add/remove), e.g. 'react', './utils'" })),
|
|
1644
|
+
names: Type5.Optional(Type5.Array(Type5.String(), { description: "Named imports to add, e.g. ['useState']" })),
|
|
1645
|
+
defaultImport: Type5.Optional(Type5.String({ description: "Default import name (e.g. 'React')" })),
|
|
1646
|
+
removeName: Type5.Optional(Type5.String({ description: "Named import to remove; omit to remove entire import" })),
|
|
1647
|
+
typeOnly: Type5.Optional(Type5.Boolean({ description: "Type-only import (TS only)" })),
|
|
1648
|
+
dryRun: Type5.Optional(Type5.Boolean({ description: "Preview without writing" })),
|
|
1649
|
+
validate: Type5.Optional(StringEnum2(["syntax", "full"], {
|
|
1650
|
+
description: "Post-edit validation level (default: syntax)"
|
|
1651
|
+
}))
|
|
1652
|
+
});
|
|
1653
|
+
function registerImportTools(pi, ctx) {
|
|
1654
|
+
pi.registerTool({
|
|
1655
|
+
name: "aft_import",
|
|
1656
|
+
label: "import",
|
|
1657
|
+
description: "Language-aware import management. Supports TS, JS, TSX, Python, Rust, Go. Ops: `add` (auto-groups stdlib/external/internal, deduplicates), `remove` (pass `removeName` for single name or omit to remove entire import), `organize` (re-sort + deduplicate).",
|
|
1658
|
+
parameters: ImportParams,
|
|
1659
|
+
async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
|
|
1660
|
+
if ((params.op === "add" || params.op === "remove") && !params.module) {
|
|
1661
|
+
throw new Error(`op='${params.op}' requires 'module'`);
|
|
1662
|
+
}
|
|
1663
|
+
const bridge = bridgeFor(ctx, extCtx.cwd);
|
|
1664
|
+
const commandMap = {
|
|
1665
|
+
add: "add_import",
|
|
1666
|
+
remove: "remove_import",
|
|
1667
|
+
organize: "organize_imports"
|
|
1668
|
+
};
|
|
1669
|
+
const req = { file: params.filePath };
|
|
1670
|
+
if (params.module !== undefined)
|
|
1671
|
+
req.module = params.module;
|
|
1672
|
+
if (params.names !== undefined)
|
|
1673
|
+
req.names = params.names;
|
|
1674
|
+
if (params.defaultImport !== undefined)
|
|
1675
|
+
req.default_import = params.defaultImport;
|
|
1676
|
+
if (params.removeName !== undefined)
|
|
1677
|
+
req.name = params.removeName;
|
|
1678
|
+
if (params.typeOnly !== undefined)
|
|
1679
|
+
req.type_only = params.typeOnly;
|
|
1680
|
+
if (params.dryRun !== undefined)
|
|
1681
|
+
req.dry_run = params.dryRun;
|
|
1682
|
+
if (params.validate !== undefined)
|
|
1683
|
+
req.validate = params.validate;
|
|
1684
|
+
const response = await callBridge(bridge, commandMap[params.op], req);
|
|
1685
|
+
return textResult(JSON.stringify(response, null, 2));
|
|
1686
|
+
}
|
|
1687
|
+
});
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
// src/tools/lsp.ts
|
|
1691
|
+
import { StringEnum as StringEnum3 } from "@mariozechner/pi-ai";
|
|
1692
|
+
import { Type as Type6 } from "@sinclair/typebox";
|
|
1693
|
+
var LspDiagnosticsParams = Type6.Object({
|
|
1694
|
+
filePath: Type6.Optional(Type6.String({ description: "File to get diagnostics for (mutually exclusive with directory)" })),
|
|
1695
|
+
directory: Type6.Optional(Type6.String({
|
|
1696
|
+
description: "Directory to get diagnostics for (mutually exclusive with filePath)"
|
|
1697
|
+
})),
|
|
1698
|
+
severity: Type6.Optional(StringEnum3(["error", "warning", "information", "hint", "all"], {
|
|
1699
|
+
description: "Filter by severity (default: all)"
|
|
1700
|
+
})),
|
|
1701
|
+
waitMs: Type6.Optional(Type6.Number({
|
|
1702
|
+
description: "Wait N ms for fresh diagnostics (max 10000, default: 0)"
|
|
1703
|
+
}))
|
|
1704
|
+
});
|
|
1705
|
+
function registerLspTools(pi, ctx) {
|
|
1706
|
+
pi.registerTool({
|
|
1707
|
+
name: "lsp_diagnostics",
|
|
1708
|
+
label: "lsp diagnostics",
|
|
1709
|
+
description: "Get errors, warnings, hints from a language server. Provide `filePath` for a single file, `directory` for all files under a path, or omit both for all tracked files.",
|
|
1710
|
+
parameters: LspDiagnosticsParams,
|
|
1711
|
+
async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
|
|
1712
|
+
const hasFile = typeof params.filePath === "string" && params.filePath.length > 0;
|
|
1713
|
+
const hasDir = typeof params.directory === "string" && params.directory.length > 0;
|
|
1714
|
+
if (hasFile && hasDir) {
|
|
1715
|
+
throw new Error("'filePath' and 'directory' are mutually exclusive — provide one or neither");
|
|
1716
|
+
}
|
|
1717
|
+
const bridge = bridgeFor(ctx, extCtx.cwd);
|
|
1718
|
+
const req = {};
|
|
1719
|
+
if (hasFile)
|
|
1720
|
+
req.file = params.filePath;
|
|
1721
|
+
if (hasDir)
|
|
1722
|
+
req.directory = params.directory;
|
|
1723
|
+
if (params.severity !== undefined)
|
|
1724
|
+
req.severity = params.severity;
|
|
1725
|
+
if (params.waitMs !== undefined)
|
|
1726
|
+
req.wait_ms = params.waitMs;
|
|
1727
|
+
const response = await callBridge(bridge, "lsp_diagnostics", req);
|
|
1728
|
+
return textResult(JSON.stringify(response, null, 2));
|
|
1729
|
+
}
|
|
1730
|
+
});
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
// src/tools/navigate.ts
|
|
1734
|
+
import { StringEnum as StringEnum4 } from "@mariozechner/pi-ai";
|
|
1735
|
+
import { Type as Type7 } from "@sinclair/typebox";
|
|
1736
|
+
var NavigateParams = Type7.Object({
|
|
1737
|
+
op: StringEnum4(["call_tree", "callers", "trace_to", "impact", "trace_data"], {
|
|
1738
|
+
description: "Navigation operation"
|
|
1739
|
+
}),
|
|
1740
|
+
filePath: Type7.String({ description: "Source file containing the symbol" }),
|
|
1741
|
+
symbol: Type7.String({ description: "Name of the symbol to analyze" }),
|
|
1742
|
+
depth: Type7.Optional(Type7.Number({ description: "Max traversal depth" })),
|
|
1743
|
+
expression: Type7.Optional(Type7.String({ description: "Expression to track (required for trace_data)" }))
|
|
1744
|
+
});
|
|
1745
|
+
function registerNavigateTool(pi, ctx) {
|
|
1746
|
+
pi.registerTool({
|
|
1747
|
+
name: "aft_navigate",
|
|
1748
|
+
label: "navigate",
|
|
1749
|
+
description: "Navigate code structure across files using call graph analysis. All ops require both `filePath` and `symbol`. Use `call_tree` for what a function calls, `callers` for call sites, `trace_to` for entry points, `impact` for blast radius, `trace_data` to follow a value.",
|
|
1750
|
+
parameters: NavigateParams,
|
|
1751
|
+
async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
|
|
1752
|
+
if (params.op === "trace_data" && !params.expression) {
|
|
1753
|
+
throw new Error("op='trace_data' requires an `expression`");
|
|
1754
|
+
}
|
|
1755
|
+
const bridge = bridgeFor(ctx, extCtx.cwd);
|
|
1756
|
+
const req = {
|
|
1757
|
+
op: params.op,
|
|
1758
|
+
file: params.filePath,
|
|
1759
|
+
symbol: params.symbol
|
|
1760
|
+
};
|
|
1761
|
+
if (params.depth !== undefined)
|
|
1762
|
+
req.depth = params.depth;
|
|
1763
|
+
if (params.expression !== undefined)
|
|
1764
|
+
req.expression = params.expression;
|
|
1765
|
+
const response = await callBridge(bridge, params.op, req);
|
|
1766
|
+
return textResult(JSON.stringify(response, null, 2));
|
|
1767
|
+
}
|
|
1768
|
+
});
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
// src/tools/reading.ts
|
|
1772
|
+
import { stat as stat2 } from "node:fs/promises";
|
|
1773
|
+
import { resolve as resolve2 } from "node:path";
|
|
1774
|
+
import { Type as Type8 } from "@sinclair/typebox";
|
|
1775
|
+
|
|
1776
|
+
// src/shared/discover-files.ts
|
|
1777
|
+
import { readdir } from "node:fs/promises";
|
|
1778
|
+
import { extname, join as join7 } from "node:path";
|
|
1779
|
+
var OUTLINE_EXTENSIONS = new Set([
|
|
1780
|
+
".ts",
|
|
1781
|
+
".tsx",
|
|
1782
|
+
".js",
|
|
1783
|
+
".jsx",
|
|
1784
|
+
".mjs",
|
|
1785
|
+
".cjs",
|
|
1786
|
+
".rs",
|
|
1787
|
+
".go",
|
|
1788
|
+
".py",
|
|
1789
|
+
".rb",
|
|
1790
|
+
".c",
|
|
1791
|
+
".cpp",
|
|
1792
|
+
".h",
|
|
1793
|
+
".hpp",
|
|
1794
|
+
".cs",
|
|
1795
|
+
".java",
|
|
1796
|
+
".kt",
|
|
1797
|
+
".scala",
|
|
1798
|
+
".swift",
|
|
1799
|
+
".lua",
|
|
1800
|
+
".ex",
|
|
1801
|
+
".exs",
|
|
1802
|
+
".hs",
|
|
1803
|
+
".sol",
|
|
1804
|
+
".nix",
|
|
1805
|
+
".md",
|
|
1806
|
+
".mdx",
|
|
1807
|
+
".css",
|
|
1808
|
+
".html",
|
|
1809
|
+
".json",
|
|
1810
|
+
".yaml",
|
|
1811
|
+
".yml",
|
|
1812
|
+
".sh",
|
|
1813
|
+
".bash",
|
|
1814
|
+
".zsh"
|
|
1815
|
+
]);
|
|
1816
|
+
var SKIP_DIRS = new Set([
|
|
1817
|
+
"node_modules",
|
|
1818
|
+
".git",
|
|
1819
|
+
"dist",
|
|
1820
|
+
"build",
|
|
1821
|
+
"out",
|
|
1822
|
+
".next",
|
|
1823
|
+
".nuxt",
|
|
1824
|
+
"target",
|
|
1825
|
+
"__pycache__",
|
|
1826
|
+
".venv",
|
|
1827
|
+
"venv",
|
|
1828
|
+
"vendor",
|
|
1829
|
+
".turbo",
|
|
1830
|
+
"coverage",
|
|
1831
|
+
".nyc_output",
|
|
1832
|
+
".cache"
|
|
1833
|
+
]);
|
|
1834
|
+
async function discoverSourceFiles(dir, maxFiles = 200) {
|
|
1835
|
+
const files = [];
|
|
1836
|
+
async function walk(current) {
|
|
1837
|
+
if (files.length >= maxFiles)
|
|
1838
|
+
return;
|
|
1839
|
+
let entries;
|
|
1840
|
+
try {
|
|
1841
|
+
entries = await readdir(current, { withFileTypes: true });
|
|
1842
|
+
} catch {
|
|
1843
|
+
return;
|
|
1844
|
+
}
|
|
1845
|
+
for (const entry of entries) {
|
|
1846
|
+
if (files.length >= maxFiles)
|
|
1847
|
+
return;
|
|
1848
|
+
if (entry.isDirectory()) {
|
|
1849
|
+
if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith(".")) {
|
|
1850
|
+
await walk(join7(current, entry.name));
|
|
1851
|
+
}
|
|
1852
|
+
} else if (entry.isFile()) {
|
|
1853
|
+
const ext = extname(entry.name).toLowerCase();
|
|
1854
|
+
if (OUTLINE_EXTENSIONS.has(ext)) {
|
|
1855
|
+
files.push(join7(current, entry.name));
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
await walk(dir);
|
|
1861
|
+
files.sort();
|
|
1862
|
+
return files;
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
// src/tools/reading.ts
|
|
1866
|
+
var OutlineParams = Type8.Object({
|
|
1867
|
+
filePath: Type8.Optional(Type8.String({
|
|
1868
|
+
description: "Path to a single file to outline. Directories are auto-detected."
|
|
1869
|
+
})),
|
|
1870
|
+
files: Type8.Optional(Type8.Array(Type8.String(), { description: "Array of file paths to outline in one call" })),
|
|
1871
|
+
directory: Type8.Optional(Type8.String({ description: "Directory to outline recursively (200 file cap)" }))
|
|
1872
|
+
});
|
|
1873
|
+
var ZoomParams = Type8.Object({
|
|
1874
|
+
filePath: Type8.String({ description: "Path to file (absolute or project-relative)" }),
|
|
1875
|
+
symbol: Type8.Optional(Type8.String({ description: "Symbol name (function/class/type) or Markdown heading" })),
|
|
1876
|
+
symbols: Type8.Optional(Type8.Array(Type8.String(), { description: "Multiple symbols — returns array of matches" })),
|
|
1877
|
+
contextLines: Type8.Optional(Type8.Number({ description: "Lines of context before/after (default: 3)" }))
|
|
1878
|
+
});
|
|
1879
|
+
function registerReadingTools(pi, ctx, surface) {
|
|
1880
|
+
if (surface.outline) {
|
|
1881
|
+
pi.registerTool({
|
|
1882
|
+
name: "aft_outline",
|
|
1883
|
+
label: "outline",
|
|
1884
|
+
description: "Structural outline of source code or Markdown. For code, returns symbols (functions, classes, types) with line ranges. For Markdown/HTML, returns heading hierarchy. Use this to explore structure before reading specific sections with aft_zoom.\n\nProvide exactly ONE of: `filePath`, `files`, or `directory`.",
|
|
1885
|
+
parameters: OutlineParams,
|
|
1886
|
+
async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
|
|
1887
|
+
const bridge = bridgeFor(ctx, extCtx.cwd);
|
|
1888
|
+
const hasFilePath = typeof params.filePath === "string" && params.filePath.length > 0;
|
|
1889
|
+
const hasFiles = Array.isArray(params.files) && params.files.length > 0;
|
|
1890
|
+
const hasDirectory = typeof params.directory === "string" && params.directory.length > 0;
|
|
1891
|
+
const provided = [hasFilePath, hasFiles, hasDirectory].filter(Boolean).length;
|
|
1892
|
+
if (provided === 0) {
|
|
1893
|
+
throw new Error("Provide exactly one of 'filePath', 'files', or 'directory'");
|
|
1894
|
+
}
|
|
1895
|
+
if (provided > 1) {
|
|
1896
|
+
throw new Error("Provide exactly ONE of 'filePath', 'files', or 'directory' — not multiple");
|
|
1897
|
+
}
|
|
1898
|
+
let dirArg = hasDirectory ? params.directory : undefined;
|
|
1899
|
+
if (!dirArg && hasFilePath) {
|
|
1900
|
+
try {
|
|
1901
|
+
const resolved = resolve2(extCtx.cwd, params.filePath);
|
|
1902
|
+
const st = await stat2(resolved);
|
|
1903
|
+
if (st.isDirectory())
|
|
1904
|
+
dirArg = params.filePath;
|
|
1905
|
+
} catch {}
|
|
1906
|
+
}
|
|
1907
|
+
if (dirArg) {
|
|
1908
|
+
const dirPath = resolve2(extCtx.cwd, dirArg);
|
|
1909
|
+
const files = await discoverSourceFiles(dirPath);
|
|
1910
|
+
if (files.length === 0) {
|
|
1911
|
+
return textResult(`No source files found under ${dirArg}`);
|
|
1912
|
+
}
|
|
1913
|
+
const response2 = await callBridge(bridge, "outline", { files });
|
|
1914
|
+
return textResult(response2.text ?? "");
|
|
1915
|
+
}
|
|
1916
|
+
if (hasFiles) {
|
|
1917
|
+
const response2 = await callBridge(bridge, "outline", { files: params.files });
|
|
1918
|
+
return textResult(response2.text ?? "");
|
|
1919
|
+
}
|
|
1920
|
+
const response = await callBridge(bridge, "outline", { file: params.filePath });
|
|
1921
|
+
return textResult(response.text ?? "");
|
|
1922
|
+
}
|
|
1923
|
+
});
|
|
1924
|
+
}
|
|
1925
|
+
if (surface.zoom) {
|
|
1926
|
+
pi.registerTool({
|
|
1927
|
+
name: "aft_zoom",
|
|
1928
|
+
label: "zoom",
|
|
1929
|
+
description: "Inspect a code symbol or Markdown/HTML section. For code, returns the full source of the symbol with call-graph annotations (calls/called-by). Pass `symbols` for batched lookups.",
|
|
1930
|
+
parameters: ZoomParams,
|
|
1931
|
+
async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
|
|
1932
|
+
const bridge = bridgeFor(ctx, extCtx.cwd);
|
|
1933
|
+
if (Array.isArray(params.symbols) && params.symbols.length > 0) {
|
|
1934
|
+
const results = await Promise.all(params.symbols.map((sym) => {
|
|
1935
|
+
const req2 = { file: params.filePath, symbol: sym };
|
|
1936
|
+
if (params.contextLines !== undefined)
|
|
1937
|
+
req2.context_lines = params.contextLines;
|
|
1938
|
+
return bridge.send("zoom", req2);
|
|
1939
|
+
}));
|
|
1940
|
+
return textResult(JSON.stringify(results, null, 2));
|
|
1941
|
+
}
|
|
1942
|
+
const req = { file: params.filePath };
|
|
1943
|
+
if (params.symbol)
|
|
1944
|
+
req.symbol = params.symbol;
|
|
1945
|
+
if (params.contextLines !== undefined)
|
|
1946
|
+
req.context_lines = params.contextLines;
|
|
1947
|
+
const response = await callBridge(bridge, "zoom", req);
|
|
1948
|
+
return textResult(JSON.stringify(response, null, 2));
|
|
1949
|
+
}
|
|
1950
|
+
});
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
// src/tools/refactor.ts
|
|
1955
|
+
import { StringEnum as StringEnum5 } from "@mariozechner/pi-ai";
|
|
1956
|
+
import { Type as Type9 } from "@sinclair/typebox";
|
|
1957
|
+
var RefactorParams = Type9.Object({
|
|
1958
|
+
op: StringEnum5(["move", "extract", "inline"], { description: "Refactoring operation" }),
|
|
1959
|
+
filePath: Type9.String({ description: "Source file" }),
|
|
1960
|
+
symbol: Type9.Optional(Type9.String({ description: "Symbol name (for move, inline)" })),
|
|
1961
|
+
destination: Type9.Optional(Type9.String({ description: "Target file (for move)" })),
|
|
1962
|
+
scope: Type9.Optional(Type9.String({ description: "Disambiguation scope for move op" })),
|
|
1963
|
+
name: Type9.Optional(Type9.String({ description: "New function name (for extract)" })),
|
|
1964
|
+
startLine: Type9.Optional(Type9.Number({ description: "1-based start line (for extract)" })),
|
|
1965
|
+
endLine: Type9.Optional(Type9.Number({ description: "1-based end line, inclusive (for extract)" })),
|
|
1966
|
+
callSiteLine: Type9.Optional(Type9.Number({ description: "1-based call site line (for inline)" })),
|
|
1967
|
+
dryRun: Type9.Optional(Type9.Boolean({ description: "Preview as diff" }))
|
|
1968
|
+
});
|
|
1969
|
+
function registerRefactorTool(pi, ctx) {
|
|
1970
|
+
pi.registerTool({
|
|
1971
|
+
name: "aft_refactor",
|
|
1972
|
+
label: "refactor",
|
|
1973
|
+
description: "Workspace-wide refactoring that updates imports and references across files. `move` relocates a top-level symbol (only top-level exports); `extract` pulls a line range into a new function; `inline` replaces a call site with the function body.",
|
|
1974
|
+
parameters: RefactorParams,
|
|
1975
|
+
async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
|
|
1976
|
+
const bridge = bridgeFor(ctx, extCtx.cwd);
|
|
1977
|
+
const commandMap = {
|
|
1978
|
+
move: "move_symbol",
|
|
1979
|
+
extract: "extract_function",
|
|
1980
|
+
inline: "inline_symbol"
|
|
1981
|
+
};
|
|
1982
|
+
const req = { file: params.filePath };
|
|
1983
|
+
if (params.symbol !== undefined)
|
|
1984
|
+
req.symbol = params.symbol;
|
|
1985
|
+
if (params.destination !== undefined)
|
|
1986
|
+
req.destination = params.destination;
|
|
1987
|
+
if (params.scope !== undefined)
|
|
1988
|
+
req.scope = params.scope;
|
|
1989
|
+
if (params.name !== undefined)
|
|
1990
|
+
req.name = params.name;
|
|
1991
|
+
if (params.startLine !== undefined)
|
|
1992
|
+
req.start_line = params.startLine;
|
|
1993
|
+
if (params.endLine !== undefined) {
|
|
1994
|
+
req.end_line = params.op === "extract" ? params.endLine + 1 : params.endLine;
|
|
1995
|
+
}
|
|
1996
|
+
if (params.callSiteLine !== undefined)
|
|
1997
|
+
req.call_site_line = params.callSiteLine;
|
|
1998
|
+
if (params.dryRun !== undefined)
|
|
1999
|
+
req.dry_run = params.dryRun;
|
|
2000
|
+
const response = await callBridge(bridge, commandMap[params.op], req);
|
|
2001
|
+
return textResult(JSON.stringify(response, null, 2));
|
|
2002
|
+
}
|
|
2003
|
+
});
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
// src/tools/safety.ts
|
|
2007
|
+
import { StringEnum as StringEnum6 } from "@mariozechner/pi-ai";
|
|
2008
|
+
import { Type as Type10 } from "@sinclair/typebox";
|
|
2009
|
+
var SafetyParams = Type10.Object({
|
|
2010
|
+
op: StringEnum6(["undo", "history", "checkpoint", "restore", "list"], {
|
|
2011
|
+
description: "Safety operation"
|
|
2012
|
+
}),
|
|
2013
|
+
filePath: Type10.Optional(Type10.String({ description: "File path (required for undo, history)" })),
|
|
2014
|
+
name: Type10.Optional(Type10.String({ description: "Checkpoint name (required for checkpoint, restore)" })),
|
|
2015
|
+
files: Type10.Optional(Type10.Array(Type10.String(), {
|
|
2016
|
+
description: "Specific files for checkpoint (optional, defaults to all tracked)"
|
|
2017
|
+
}))
|
|
2018
|
+
});
|
|
2019
|
+
function registerSafetyTool(pi, ctx) {
|
|
2020
|
+
pi.registerTool({
|
|
2021
|
+
name: "aft_safety",
|
|
2022
|
+
label: "safety",
|
|
2023
|
+
description: "File safety and recovery operations. Ops: `undo` (pop latest snapshot for a file — irreversible), `history` (list snapshots for a file), `checkpoint` (save named snapshot), `restore` (restore named checkpoint), `list` (list checkpoints). Per-file undo stack is capped at 20.",
|
|
2024
|
+
parameters: SafetyParams,
|
|
2025
|
+
async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
|
|
2026
|
+
if ((params.op === "undo" || params.op === "history") && !params.filePath) {
|
|
2027
|
+
throw new Error(`op='${params.op}' requires 'filePath'`);
|
|
2028
|
+
}
|
|
2029
|
+
if ((params.op === "checkpoint" || params.op === "restore") && !params.name) {
|
|
2030
|
+
throw new Error(`op='${params.op}' requires 'name'`);
|
|
2031
|
+
}
|
|
2032
|
+
const bridge = bridgeFor(ctx, extCtx.cwd);
|
|
2033
|
+
const commandMap = {
|
|
2034
|
+
undo: "undo",
|
|
2035
|
+
history: "edit_history",
|
|
2036
|
+
checkpoint: "checkpoint",
|
|
2037
|
+
restore: "restore_checkpoint",
|
|
2038
|
+
list: "list_checkpoints"
|
|
2039
|
+
};
|
|
2040
|
+
const req = {};
|
|
2041
|
+
if (params.filePath)
|
|
2042
|
+
req.file = params.filePath;
|
|
2043
|
+
if (params.name)
|
|
2044
|
+
req.name = params.name;
|
|
2045
|
+
if (params.files)
|
|
2046
|
+
req.files = params.files;
|
|
2047
|
+
const response = await callBridge(bridge, commandMap[params.op], req);
|
|
2048
|
+
return textResult(JSON.stringify(response, null, 2));
|
|
2049
|
+
}
|
|
2050
|
+
});
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
// src/tools/semantic.ts
|
|
2054
|
+
import { Type as Type11 } from "@sinclair/typebox";
|
|
2055
|
+
var SearchParams2 = Type11.Object({
|
|
2056
|
+
query: Type11.String({ description: "Natural-language description of the code to find" }),
|
|
2057
|
+
topK: Type11.Optional(Type11.Number({ description: "Maximum number of results (default: 10, max: 100)" }))
|
|
2058
|
+
});
|
|
2059
|
+
function registerSemanticTool(pi, ctx) {
|
|
2060
|
+
pi.registerTool({
|
|
2061
|
+
name: "aft_search",
|
|
2062
|
+
label: "semantic search",
|
|
2063
|
+
description: "Search code by meaning using semantic similarity. Use when you don't know the exact name or text — describe what you're looking for in natural language and get the most relevant symbols, functions, and types.",
|
|
2064
|
+
parameters: SearchParams2,
|
|
2065
|
+
async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
|
|
2066
|
+
const bridge = bridgeFor(ctx, extCtx.cwd);
|
|
2067
|
+
const req = { query: params.query };
|
|
2068
|
+
if (params.topK !== undefined)
|
|
2069
|
+
req.top_k = params.topK;
|
|
2070
|
+
const response = await callBridge(bridge, "semantic_search", req);
|
|
2071
|
+
return textResult(response.text ?? JSON.stringify(response, null, 2));
|
|
2072
|
+
}
|
|
2073
|
+
});
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
// src/tools/structure.ts
|
|
2077
|
+
import { StringEnum as StringEnum7 } from "@mariozechner/pi-ai";
|
|
2078
|
+
import { Type as Type12 } from "@sinclair/typebox";
|
|
2079
|
+
var TransformParams = Type12.Object({
|
|
2080
|
+
op: StringEnum7(["add_member", "add_derive", "wrap_try_catch", "add_decorator", "add_struct_tags"], { description: "Transformation operation" }),
|
|
2081
|
+
filePath: Type12.String({ description: "Path to the source file" }),
|
|
2082
|
+
container: Type12.Optional(Type12.String({ description: "Class/struct/impl name for add_member" })),
|
|
2083
|
+
code: Type12.Optional(Type12.String({ description: "Member code to insert (add_member)" })),
|
|
2084
|
+
target: Type12.Optional(Type12.String({ description: "Target symbol name" })),
|
|
2085
|
+
derives: Type12.Optional(Type12.Array(Type12.String(), { description: "Derive macro names (add_derive)" })),
|
|
2086
|
+
catchBody: Type12.Optional(Type12.String({ description: "Catch block body (wrap_try_catch, default: 'throw error;')" })),
|
|
2087
|
+
decorator: Type12.Optional(Type12.String({ description: "Decorator text without @ (add_decorator)" })),
|
|
2088
|
+
field: Type12.Optional(Type12.String({ description: "Struct field name (add_struct_tags)" })),
|
|
2089
|
+
tag: Type12.Optional(Type12.String({ description: "Tag key (add_struct_tags)" })),
|
|
2090
|
+
value: Type12.Optional(Type12.String({ description: "Tag value (add_struct_tags)" })),
|
|
2091
|
+
position: Type12.Optional(Type12.String({
|
|
2092
|
+
description: "Position hint: 'first', 'last' (default), 'before:name', 'after:name' for add_member"
|
|
2093
|
+
})),
|
|
2094
|
+
dryRun: Type12.Optional(Type12.Boolean({ description: "Preview without modifying" })),
|
|
2095
|
+
validate: Type12.Optional(StringEnum7(["syntax", "full"], {
|
|
2096
|
+
description: "Validation level (default: syntax)"
|
|
2097
|
+
}))
|
|
2098
|
+
});
|
|
2099
|
+
function registerStructureTool(pi, ctx) {
|
|
2100
|
+
pi.registerTool({
|
|
2101
|
+
name: "aft_transform",
|
|
2102
|
+
label: "transform",
|
|
2103
|
+
description: "Scope-aware structural code transformations with correct indentation. See parameter descriptions for per-op requirements.",
|
|
2104
|
+
parameters: TransformParams,
|
|
2105
|
+
async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
|
|
2106
|
+
const bridge = bridgeFor(ctx, extCtx.cwd);
|
|
2107
|
+
const req = { file: params.filePath };
|
|
2108
|
+
if (params.container !== undefined)
|
|
2109
|
+
req.scope = params.container;
|
|
2110
|
+
if (params.code !== undefined)
|
|
2111
|
+
req.code = params.code;
|
|
2112
|
+
if (params.target !== undefined)
|
|
2113
|
+
req.target = params.target;
|
|
2114
|
+
if (params.derives !== undefined)
|
|
2115
|
+
req.derives = params.derives;
|
|
2116
|
+
if (params.catchBody !== undefined)
|
|
2117
|
+
req.catch_body = params.catchBody;
|
|
2118
|
+
if (params.decorator !== undefined)
|
|
2119
|
+
req.decorator = params.decorator;
|
|
2120
|
+
if (params.field !== undefined)
|
|
2121
|
+
req.field = params.field;
|
|
2122
|
+
if (params.tag !== undefined)
|
|
2123
|
+
req.tag = params.tag;
|
|
2124
|
+
if (params.value !== undefined)
|
|
2125
|
+
req.value = params.value;
|
|
2126
|
+
if (params.position !== undefined)
|
|
2127
|
+
req.position = params.position;
|
|
2128
|
+
if (params.dryRun !== undefined)
|
|
2129
|
+
req.dry_run = params.dryRun;
|
|
2130
|
+
if (params.validate !== undefined)
|
|
2131
|
+
req.validate = params.validate;
|
|
2132
|
+
const response = await callBridge(bridge, params.op, req);
|
|
2133
|
+
return textResult(JSON.stringify(response, null, 2));
|
|
2134
|
+
}
|
|
2135
|
+
});
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
// src/index.ts
|
|
2139
|
+
var PLUGIN_VERSION = (() => {
|
|
2140
|
+
try {
|
|
2141
|
+
const req = createRequire3(import.meta.url);
|
|
2142
|
+
return req("../package.json").version;
|
|
2143
|
+
} catch {
|
|
2144
|
+
return "0.0.0";
|
|
2145
|
+
}
|
|
2146
|
+
})();
|
|
2147
|
+
function resolveStorageDir() {
|
|
2148
|
+
return join8(homedir5(), ".pi", "agent", "aft");
|
|
2149
|
+
}
|
|
2150
|
+
function resolveToolSurface(config) {
|
|
2151
|
+
const surface = config.tool_surface ?? "recommended";
|
|
2152
|
+
const disabled = new Set(config.disabled_tools ?? []);
|
|
2153
|
+
const ok = (name) => !disabled.has(name);
|
|
2154
|
+
if (surface === "minimal") {
|
|
2155
|
+
return {
|
|
2156
|
+
hoistRead: false,
|
|
2157
|
+
hoistWrite: false,
|
|
2158
|
+
hoistEdit: false,
|
|
2159
|
+
hoistGrep: false,
|
|
2160
|
+
outline: ok("aft_outline"),
|
|
2161
|
+
zoom: ok("aft_zoom"),
|
|
2162
|
+
semantic: false,
|
|
2163
|
+
navigate: false,
|
|
2164
|
+
conflicts: false,
|
|
2165
|
+
importTool: false,
|
|
2166
|
+
safety: ok("aft_safety"),
|
|
2167
|
+
delete: false,
|
|
2168
|
+
move: false,
|
|
2169
|
+
astSearch: false,
|
|
2170
|
+
astReplace: false,
|
|
2171
|
+
lspDiagnostics: false,
|
|
2172
|
+
structure: false,
|
|
2173
|
+
refactor: false
|
|
2174
|
+
};
|
|
2175
|
+
}
|
|
2176
|
+
const base = {
|
|
2177
|
+
hoistRead: ok("read"),
|
|
2178
|
+
hoistWrite: ok("write"),
|
|
2179
|
+
hoistEdit: ok("edit"),
|
|
2180
|
+
hoistGrep: ok("grep") && config.experimental_search_index === true,
|
|
2181
|
+
outline: ok("aft_outline"),
|
|
2182
|
+
zoom: ok("aft_zoom"),
|
|
2183
|
+
semantic: ok("aft_search") && config.experimental_semantic_search === true,
|
|
2184
|
+
navigate: false,
|
|
2185
|
+
conflicts: ok("aft_conflicts"),
|
|
2186
|
+
importTool: ok("aft_import"),
|
|
2187
|
+
safety: ok("aft_safety"),
|
|
2188
|
+
delete: false,
|
|
2189
|
+
move: false,
|
|
2190
|
+
astSearch: ok("ast_grep_search"),
|
|
2191
|
+
astReplace: ok("ast_grep_replace"),
|
|
2192
|
+
lspDiagnostics: ok("lsp_diagnostics"),
|
|
2193
|
+
structure: false,
|
|
2194
|
+
refactor: false
|
|
2195
|
+
};
|
|
2196
|
+
if (surface === "all") {
|
|
2197
|
+
return {
|
|
2198
|
+
...base,
|
|
2199
|
+
navigate: ok("aft_navigate"),
|
|
2200
|
+
delete: ok("aft_delete"),
|
|
2201
|
+
move: ok("aft_move"),
|
|
2202
|
+
structure: ok("aft_transform"),
|
|
2203
|
+
refactor: ok("aft_refactor")
|
|
2204
|
+
};
|
|
2205
|
+
}
|
|
2206
|
+
return base;
|
|
2207
|
+
}
|
|
2208
|
+
async function src_default(pi) {
|
|
2209
|
+
log(`AFT extension loading (plugin v${PLUGIN_VERSION})`);
|
|
2210
|
+
let binaryPath;
|
|
2211
|
+
try {
|
|
2212
|
+
binaryPath = await findBinary();
|
|
2213
|
+
} catch (err) {
|
|
2214
|
+
warn(`Failed to resolve AFT binary: ${err instanceof Error ? err.message : String(err)}. ` + "Tools will not be registered.");
|
|
2215
|
+
return;
|
|
2216
|
+
}
|
|
2217
|
+
const config = loadAftConfig(process.cwd());
|
|
2218
|
+
const storageDir = resolveStorageDir();
|
|
2219
|
+
let ortDylibDir = null;
|
|
2220
|
+
if (config.experimental_semantic_search) {
|
|
2221
|
+
try {
|
|
2222
|
+
ortDylibDir = await ensureOnnxRuntime(storageDir);
|
|
2223
|
+
if (!ortDylibDir) {
|
|
2224
|
+
warn(`ONNX Runtime unavailable. Semantic search will be disabled. Install manually: ${getManualInstallHint()}`);
|
|
2225
|
+
}
|
|
2226
|
+
} catch (err) {
|
|
2227
|
+
warn(`Failed to prepare ONNX Runtime: ${err instanceof Error ? err.message : String(err)}`);
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
const configOverrides = {
|
|
2231
|
+
...config,
|
|
2232
|
+
storage_dir: storageDir
|
|
2233
|
+
};
|
|
2234
|
+
if (ortDylibDir) {
|
|
2235
|
+
configOverrides._ort_dylib_dir = ortDylibDir;
|
|
2236
|
+
}
|
|
2237
|
+
const pool = new BridgePool(binaryPath, { minVersion: PLUGIN_VERSION }, configOverrides);
|
|
2238
|
+
const ctx = { pool, config, storageDir };
|
|
2239
|
+
const surface = resolveToolSurface(config);
|
|
2240
|
+
registerHoistedTools(pi, ctx, surface);
|
|
2241
|
+
if (surface.outline || surface.zoom) {
|
|
2242
|
+
registerReadingTools(pi, ctx, surface);
|
|
2243
|
+
}
|
|
2244
|
+
if (surface.semantic) {
|
|
2245
|
+
registerSemanticTool(pi, ctx);
|
|
2246
|
+
}
|
|
2247
|
+
if (surface.navigate) {
|
|
2248
|
+
registerNavigateTool(pi, ctx);
|
|
2249
|
+
}
|
|
2250
|
+
if (surface.conflicts) {
|
|
2251
|
+
registerConflictsTool(pi, ctx);
|
|
2252
|
+
}
|
|
2253
|
+
if (surface.importTool) {
|
|
2254
|
+
registerImportTools(pi, ctx);
|
|
2255
|
+
}
|
|
2256
|
+
if (surface.safety) {
|
|
2257
|
+
registerSafetyTool(pi, ctx);
|
|
2258
|
+
}
|
|
2259
|
+
if (surface.astSearch || surface.astReplace) {
|
|
2260
|
+
registerAstTools(pi, ctx, surface);
|
|
2261
|
+
}
|
|
2262
|
+
if (surface.delete || surface.move) {
|
|
2263
|
+
registerFsTools(pi, ctx, surface);
|
|
2264
|
+
}
|
|
2265
|
+
if (surface.lspDiagnostics) {
|
|
2266
|
+
registerLspTools(pi, ctx);
|
|
2267
|
+
}
|
|
2268
|
+
if (surface.structure) {
|
|
2269
|
+
registerStructureTool(pi, ctx);
|
|
2270
|
+
}
|
|
2271
|
+
if (surface.refactor) {
|
|
2272
|
+
registerRefactorTool(pi, ctx);
|
|
2273
|
+
}
|
|
2274
|
+
registerStatusCommand(pi, ctx);
|
|
2275
|
+
pi.on("session_shutdown", async () => {
|
|
2276
|
+
try {
|
|
2277
|
+
await pool.shutdown();
|
|
2278
|
+
log("Bridge pool shut down");
|
|
2279
|
+
} catch (err) {
|
|
2280
|
+
warn(`Error during bridge shutdown: ${err instanceof Error ? err.message : String(err)}`);
|
|
2281
|
+
}
|
|
2282
|
+
});
|
|
2283
|
+
registerShutdownCleanup(async () => {
|
|
2284
|
+
try {
|
|
2285
|
+
await pool.shutdown();
|
|
2286
|
+
} catch (err) {
|
|
2287
|
+
warn(`Error during process shutdown: ${err instanceof Error ? err.message : String(err)}`);
|
|
2288
|
+
}
|
|
2289
|
+
});
|
|
2290
|
+
log(`AFT extension ready (surface=${config.tool_surface ?? "recommended"})`);
|
|
2291
|
+
}
|
|
2292
|
+
export {
|
|
2293
|
+
src_default as default
|
|
2294
|
+
};
|