@buildwithtrace/sdk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -0
- package/dist/index.d.mts +171 -0
- package/dist/index.d.ts +171 -0
- package/dist/index.js +786 -0
- package/dist/index.mjs +768 -0
- package/package.json +46 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,786 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
Trace: () => Trace,
|
|
24
|
+
TraceError: () => TraceError,
|
|
25
|
+
TraceToolExecutionError: () => TraceToolExecutionError,
|
|
26
|
+
default: () => index_default
|
|
27
|
+
});
|
|
28
|
+
module.exports = __toCommonJS(index_exports);
|
|
29
|
+
var import_fs2 = require("fs");
|
|
30
|
+
var import_path2 = require("path");
|
|
31
|
+
|
|
32
|
+
// src/executor.ts
|
|
33
|
+
var import_fs = require("fs");
|
|
34
|
+
var import_path = require("path");
|
|
35
|
+
var ALLOWED_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
36
|
+
".trace_sch",
|
|
37
|
+
".trace_pcb",
|
|
38
|
+
".kicad_sch",
|
|
39
|
+
".kicad_pcb",
|
|
40
|
+
".kicad_sym",
|
|
41
|
+
".kicad_mod",
|
|
42
|
+
".net",
|
|
43
|
+
".schdoc",
|
|
44
|
+
".pcbdoc",
|
|
45
|
+
".schlib",
|
|
46
|
+
".pcblib",
|
|
47
|
+
".prjpcb",
|
|
48
|
+
".svg",
|
|
49
|
+
".request",
|
|
50
|
+
".response",
|
|
51
|
+
".backup",
|
|
52
|
+
".zip",
|
|
53
|
+
".gbr",
|
|
54
|
+
".drl",
|
|
55
|
+
".md",
|
|
56
|
+
".json",
|
|
57
|
+
".csv",
|
|
58
|
+
".pos"
|
|
59
|
+
]);
|
|
60
|
+
var ALLOWED_FILENAMES = /* @__PURE__ */ new Set([
|
|
61
|
+
"fp-lib-table",
|
|
62
|
+
"sym-lib-table"
|
|
63
|
+
]);
|
|
64
|
+
var TRACE_FILE_EXTENSIONS = /* @__PURE__ */ new Set([".trace_sch", ".trace_pcb"]);
|
|
65
|
+
var SEARCHABLE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
66
|
+
".trace_sch",
|
|
67
|
+
".trace_pcb",
|
|
68
|
+
".kicad_sch",
|
|
69
|
+
".kicad_pcb",
|
|
70
|
+
".kicad_sym",
|
|
71
|
+
".kicad_mod",
|
|
72
|
+
".md"
|
|
73
|
+
]);
|
|
74
|
+
var FILE_TOOLS = /* @__PURE__ */ new Set([
|
|
75
|
+
"read_file",
|
|
76
|
+
"write",
|
|
77
|
+
"search_replace",
|
|
78
|
+
"list_dir",
|
|
79
|
+
"grep",
|
|
80
|
+
"delete_trace_file",
|
|
81
|
+
// Trivial no-ops the backend agent may emit during a normal run; handling
|
|
82
|
+
// them (instead of throwing) keeps multi-step agent loops from dead-locking.
|
|
83
|
+
"todo_write",
|
|
84
|
+
"todo_read"
|
|
85
|
+
]);
|
|
86
|
+
function isFileTool(toolName) {
|
|
87
|
+
return FILE_TOOLS.has(toolName);
|
|
88
|
+
}
|
|
89
|
+
function ok(result, fileModified = false) {
|
|
90
|
+
return { result, success: true, fileModified };
|
|
91
|
+
}
|
|
92
|
+
function fail(result, fileModified = false) {
|
|
93
|
+
return { result, success: false, fileModified };
|
|
94
|
+
}
|
|
95
|
+
function argStr(args, ...keys) {
|
|
96
|
+
for (const k of keys) {
|
|
97
|
+
const v = args[k];
|
|
98
|
+
if (typeof v === "string" && v.length > 0) return v;
|
|
99
|
+
}
|
|
100
|
+
return "";
|
|
101
|
+
}
|
|
102
|
+
function argInt(args, key, dflt) {
|
|
103
|
+
const v = args[key];
|
|
104
|
+
if (typeof v === "number" && Number.isFinite(v)) return Math.trunc(v);
|
|
105
|
+
if (typeof v === "string" && v.trim() !== "" && !Number.isNaN(Number(v))) return Math.trunc(Number(v));
|
|
106
|
+
return dflt;
|
|
107
|
+
}
|
|
108
|
+
function argBool(args, key) {
|
|
109
|
+
return args[key] === true || args[key] === "true";
|
|
110
|
+
}
|
|
111
|
+
function canonicalize(p) {
|
|
112
|
+
const abs = (0, import_path.resolve)(p);
|
|
113
|
+
let current = abs;
|
|
114
|
+
const tail = [];
|
|
115
|
+
for (; ; ) {
|
|
116
|
+
try {
|
|
117
|
+
const real = (0, import_fs.realpathSync)(current);
|
|
118
|
+
return tail.length ? (0, import_path.join)(real, ...tail.reverse()) : real;
|
|
119
|
+
} catch {
|
|
120
|
+
const parent = (0, import_path.dirname)(current);
|
|
121
|
+
if (parent === current) {
|
|
122
|
+
return abs;
|
|
123
|
+
}
|
|
124
|
+
tail.push((0, import_path.basename)(current));
|
|
125
|
+
current = parent;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function isInside(parent, child) {
|
|
130
|
+
if (child === parent) return true;
|
|
131
|
+
const rel = (0, import_path.relative)(parent, child);
|
|
132
|
+
return rel !== "" && !rel.startsWith(".." + import_path.sep) && rel !== ".." && !(0, import_path.isAbsolute)(rel);
|
|
133
|
+
}
|
|
134
|
+
function validateFilePath(filePath, projectDir, operation = "access") {
|
|
135
|
+
if (!filePath) return { ok: false, error: "Security: Empty file path" };
|
|
136
|
+
let target = filePath;
|
|
137
|
+
if (!(0, import_path.isAbsolute)(target)) target = (0, import_path.join)(projectDir, target);
|
|
138
|
+
let canonical;
|
|
139
|
+
try {
|
|
140
|
+
canonical = canonicalize(target);
|
|
141
|
+
} catch {
|
|
142
|
+
return { ok: false, error: `Security: Could not resolve path: ${filePath}` };
|
|
143
|
+
}
|
|
144
|
+
const ext = (0, import_path.extname)(canonical).toLowerCase();
|
|
145
|
+
const name = (0, import_path.basename)(canonical);
|
|
146
|
+
if (!ALLOWED_EXTENSIONS.has(ext) && !ALLOWED_FILENAMES.has(name)) {
|
|
147
|
+
return {
|
|
148
|
+
ok: false,
|
|
149
|
+
error: `Security: Blocked ${operation} to disallowed file type: ${ext} (file: ${name})`
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
const projectResolved = canonicalize(projectDir);
|
|
153
|
+
if (!isInside(projectResolved, canonical)) {
|
|
154
|
+
return { ok: false, error: `Security: Path escapes project directory: ${filePath}` };
|
|
155
|
+
}
|
|
156
|
+
return { ok: true, path: canonical };
|
|
157
|
+
}
|
|
158
|
+
function resolveInProject(p, projectDir) {
|
|
159
|
+
return (0, import_path.isAbsolute)(p) ? p : (0, import_path.join)(projectDir, p);
|
|
160
|
+
}
|
|
161
|
+
function isTraceFile(p) {
|
|
162
|
+
return TRACE_FILE_EXTENSIONS.has((0, import_path.extname)(p).toLowerCase());
|
|
163
|
+
}
|
|
164
|
+
function traceConversionNote(p) {
|
|
165
|
+
if (!isTraceFile(p)) return "";
|
|
166
|
+
return " NOTE: this SDK does not run the trace\u2192KiCad converter, so the matching .kicad_* file was NOT regenerated. Use the `buildwithtrace` CLI or the desktop app to produce the native KiCad file.";
|
|
167
|
+
}
|
|
168
|
+
function executeReadFile(args, projectDir, defaultFile) {
|
|
169
|
+
const filePath = argStr(args, "target_file", "file_path") || defaultFile;
|
|
170
|
+
if (!filePath) return fail("Error: No file path specified");
|
|
171
|
+
const v = validateFilePath(filePath, projectDir, "read");
|
|
172
|
+
if (!v.ok) return fail(v.error);
|
|
173
|
+
if (!(0, import_fs.existsSync)(v.path)) return fail(`Error: File not found: ${filePath}`);
|
|
174
|
+
let content;
|
|
175
|
+
try {
|
|
176
|
+
content = (0, import_fs.readFileSync)(v.path, "utf-8");
|
|
177
|
+
} catch (e) {
|
|
178
|
+
return fail(`Error reading file: ${e.message}`);
|
|
179
|
+
}
|
|
180
|
+
if (content.length === 0) return ok("File is empty.");
|
|
181
|
+
const lines = content.split("\n");
|
|
182
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
183
|
+
const offset = Math.max(1, argInt(args, "offset", 1));
|
|
184
|
+
const limit = argInt(args, "limit", -1);
|
|
185
|
+
if (offset > lines.length) {
|
|
186
|
+
return fail(`Error: offset ${offset} exceeds file length (${lines.length} lines)`);
|
|
187
|
+
}
|
|
188
|
+
const end = limit === -1 ? lines.length : Math.min(offset + limit - 1, lines.length);
|
|
189
|
+
const out = [];
|
|
190
|
+
for (let i = offset; i <= end; i++) {
|
|
191
|
+
out.push(`${String(i).padStart(6)}|${lines[i - 1]}`);
|
|
192
|
+
}
|
|
193
|
+
return ok(out.join("\n"));
|
|
194
|
+
}
|
|
195
|
+
function executeWrite(args, projectDir) {
|
|
196
|
+
const filePath = argStr(args, "file_path");
|
|
197
|
+
const contents = typeof args.contents === "string" ? args.contents : "";
|
|
198
|
+
if (!filePath) return fail("Error: file_path is required");
|
|
199
|
+
const v = validateFilePath(filePath, projectDir, "write");
|
|
200
|
+
if (!v.ok) return fail(v.error);
|
|
201
|
+
try {
|
|
202
|
+
(0, import_fs.mkdirSync)((0, import_path.dirname)(v.path), { recursive: true });
|
|
203
|
+
(0, import_fs.writeFileSync)(v.path, contents, "utf-8");
|
|
204
|
+
} catch (e) {
|
|
205
|
+
return fail(`Error writing file: ${e.message}`);
|
|
206
|
+
}
|
|
207
|
+
return ok(`File written successfully.${traceConversionNote(v.path)}`, true);
|
|
208
|
+
}
|
|
209
|
+
function executeSearchReplace(args, projectDir, defaultFile) {
|
|
210
|
+
const filePath = argStr(args, "file_path") || defaultFile;
|
|
211
|
+
const oldString = typeof args.old_string === "string" ? args.old_string : "";
|
|
212
|
+
const newString = typeof args.new_string === "string" ? args.new_string : "";
|
|
213
|
+
const replaceAll = argBool(args, "replace_all");
|
|
214
|
+
if (!oldString) return fail("Error: old_string cannot be empty");
|
|
215
|
+
if (oldString === newString) {
|
|
216
|
+
return fail("Error: old_string and new_string are identical - no change needed");
|
|
217
|
+
}
|
|
218
|
+
if (!filePath) return fail("Error: No file path specified");
|
|
219
|
+
const v = validateFilePath(filePath, projectDir, "write");
|
|
220
|
+
if (!v.ok) return fail(v.error);
|
|
221
|
+
if (!(0, import_fs.existsSync)(v.path)) return fail(`Error: File not found: ${filePath}`);
|
|
222
|
+
let content;
|
|
223
|
+
try {
|
|
224
|
+
content = (0, import_fs.readFileSync)(v.path, "utf-8");
|
|
225
|
+
} catch (e) {
|
|
226
|
+
return fail(`Error reading file: ${e.message}`);
|
|
227
|
+
}
|
|
228
|
+
const count = content.split(oldString).length - 1;
|
|
229
|
+
if (count === 0) {
|
|
230
|
+
return fail("Error: old_string not found in file. Re-read the file to see current content.");
|
|
231
|
+
}
|
|
232
|
+
if (!replaceAll && count > 1) {
|
|
233
|
+
return fail(
|
|
234
|
+
`Error: old_string found ${count} times in file - must be unique. Provide more surrounding context to make it unique, or set replace_all=true.`
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
const newContent = replaceAll ? content.split(oldString).join(newString) : content.replace(oldString, newString);
|
|
238
|
+
try {
|
|
239
|
+
(0, import_fs.writeFileSync)(v.path, newContent, "utf-8");
|
|
240
|
+
} catch (e) {
|
|
241
|
+
return fail(`Error writing file: ${e.message}`);
|
|
242
|
+
}
|
|
243
|
+
const replacements = replaceAll ? count : 1;
|
|
244
|
+
const base = `Successfully replaced ${replacements} occurrence${replacements > 1 ? "s" : ""}.`;
|
|
245
|
+
return ok(`${base}${traceConversionNote(v.path)}`, true);
|
|
246
|
+
}
|
|
247
|
+
function executeListDir(args, projectDir) {
|
|
248
|
+
const dirPath = argStr(args, "path") || projectDir;
|
|
249
|
+
const path = resolveInProject(dirPath, projectDir);
|
|
250
|
+
const projectResolved = canonicalize(projectDir);
|
|
251
|
+
if (!isInside(projectResolved, canonicalize(path))) {
|
|
252
|
+
return fail("Security error: Path outside project directory");
|
|
253
|
+
}
|
|
254
|
+
let stat;
|
|
255
|
+
try {
|
|
256
|
+
stat = (0, import_fs.statSync)(path);
|
|
257
|
+
} catch {
|
|
258
|
+
return fail(`Error: Not a directory: ${dirPath}`);
|
|
259
|
+
}
|
|
260
|
+
if (!stat.isDirectory()) return fail(`Error: Not a directory: ${dirPath}`);
|
|
261
|
+
const entries = [];
|
|
262
|
+
let names;
|
|
263
|
+
try {
|
|
264
|
+
names = (0, import_fs.readdirSync)(path).sort();
|
|
265
|
+
} catch (e) {
|
|
266
|
+
return fail(`Error listing directory: ${e.message}`);
|
|
267
|
+
}
|
|
268
|
+
for (const itemName of names) {
|
|
269
|
+
if (itemName.startsWith(".") && itemName !== ".trace") continue;
|
|
270
|
+
const full = (0, import_path.join)(path, itemName);
|
|
271
|
+
let s;
|
|
272
|
+
try {
|
|
273
|
+
s = (0, import_fs.statSync)(full);
|
|
274
|
+
} catch {
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (s.isDirectory()) {
|
|
278
|
+
if ((0, import_path.extname)(itemName) === ".pretty" || itemName === ".trace") entries.push(`${itemName}/`);
|
|
279
|
+
} else if (ALLOWED_EXTENSIONS.has((0, import_path.extname)(itemName).toLowerCase()) || ALLOWED_FILENAMES.has(itemName)) {
|
|
280
|
+
entries.push(itemName);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
const traceDir = (0, import_path.join)(path, ".trace");
|
|
284
|
+
try {
|
|
285
|
+
if ((0, import_fs.statSync)(traceDir).isDirectory()) {
|
|
286
|
+
for (const sub of (0, import_fs.readdirSync)(traceDir).sort()) {
|
|
287
|
+
if ((0, import_path.extname)(sub).toLowerCase() === ".md") entries.push(`.trace/${sub}`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
} catch {
|
|
291
|
+
}
|
|
292
|
+
return ok(JSON.stringify(entries));
|
|
293
|
+
}
|
|
294
|
+
function walkFiles(root) {
|
|
295
|
+
const out = [];
|
|
296
|
+
const stack = [root];
|
|
297
|
+
while (stack.length) {
|
|
298
|
+
const dir = stack.pop();
|
|
299
|
+
let names;
|
|
300
|
+
try {
|
|
301
|
+
names = (0, import_fs.readdirSync)(dir);
|
|
302
|
+
} catch {
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
for (const n of names) {
|
|
306
|
+
const full = (0, import_path.join)(dir, n);
|
|
307
|
+
let s;
|
|
308
|
+
try {
|
|
309
|
+
s = (0, import_fs.statSync)(full);
|
|
310
|
+
} catch {
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (s.isDirectory()) stack.push(full);
|
|
314
|
+
else if (SEARCHABLE_EXTENSIONS.has((0, import_path.extname)(full).toLowerCase())) out.push(full);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return out;
|
|
318
|
+
}
|
|
319
|
+
function executeGrep(args, projectDir) {
|
|
320
|
+
const pattern = argStr(args, "pattern");
|
|
321
|
+
if (!pattern) return fail("Error: pattern is required");
|
|
322
|
+
const searchPath = argStr(args, "path") || projectDir;
|
|
323
|
+
const path = resolveInProject(searchPath, projectDir);
|
|
324
|
+
const projectResolved = canonicalize(projectDir);
|
|
325
|
+
if (!isInside(projectResolved, canonicalize(path))) {
|
|
326
|
+
return fail("Security error: Path outside project directory");
|
|
327
|
+
}
|
|
328
|
+
const caseInsensitive = argBool(args, "i");
|
|
329
|
+
const outputMode = argStr(args, "output_mode") || "content";
|
|
330
|
+
let contextBefore = argInt(args, "B", 0);
|
|
331
|
+
let contextAfter = argInt(args, "A", 0);
|
|
332
|
+
const contextBoth = argInt(args, "C", 0);
|
|
333
|
+
const headLimit = argInt(args, "head_limit", -1);
|
|
334
|
+
if (contextBoth > 0) {
|
|
335
|
+
contextBefore = contextBoth;
|
|
336
|
+
contextAfter = contextBoth;
|
|
337
|
+
}
|
|
338
|
+
let regex;
|
|
339
|
+
try {
|
|
340
|
+
regex = new RegExp(pattern, caseInsensitive ? "i" : "");
|
|
341
|
+
} catch (e) {
|
|
342
|
+
return fail(`Error: Invalid regex pattern: ${e.message}`);
|
|
343
|
+
}
|
|
344
|
+
let filesToSearch;
|
|
345
|
+
let isFile = false;
|
|
346
|
+
try {
|
|
347
|
+
isFile = (0, import_fs.statSync)(path).isFile();
|
|
348
|
+
} catch {
|
|
349
|
+
isFile = false;
|
|
350
|
+
}
|
|
351
|
+
filesToSearch = isFile ? [path] : walkFiles(path).sort();
|
|
352
|
+
let totalMatches = 0;
|
|
353
|
+
let filesMatched = 0;
|
|
354
|
+
const outputParts = [];
|
|
355
|
+
for (const file of filesToSearch) {
|
|
356
|
+
let lines;
|
|
357
|
+
try {
|
|
358
|
+
lines = (0, import_fs.readFileSync)(file, "utf-8").split("\n");
|
|
359
|
+
} catch {
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
if (lines.length && lines[lines.length - 1] === "") lines.pop();
|
|
363
|
+
const matchIdxs = [];
|
|
364
|
+
for (let i = 0; i < lines.length; i++) {
|
|
365
|
+
if (regex.test(lines[i])) matchIdxs.push(i);
|
|
366
|
+
regex.lastIndex = 0;
|
|
367
|
+
}
|
|
368
|
+
if (matchIdxs.length === 0) continue;
|
|
369
|
+
filesMatched++;
|
|
370
|
+
let rel = (0, import_path.relative)(projectDir, file);
|
|
371
|
+
if (rel.startsWith("..")) rel = file;
|
|
372
|
+
if (outputMode === "files_with_matches") {
|
|
373
|
+
outputParts.push(rel);
|
|
374
|
+
} else if (outputMode === "count") {
|
|
375
|
+
outputParts.push(`${rel}:${matchIdxs.length}`);
|
|
376
|
+
} else {
|
|
377
|
+
outputParts.push(rel);
|
|
378
|
+
for (const mi of matchIdxs) {
|
|
379
|
+
const start = Math.max(0, mi - contextBefore);
|
|
380
|
+
const end = Math.min(lines.length, mi + 1 + contextAfter);
|
|
381
|
+
for (let ci = start; ci < end; ci++) {
|
|
382
|
+
const prefix = ci === mi ? ":" : "-";
|
|
383
|
+
outputParts.push(`${ci + 1}${prefix}${lines[ci]}`);
|
|
384
|
+
}
|
|
385
|
+
if (contextBefore > 0 || contextAfter > 0) outputParts.push("--");
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
totalMatches += matchIdxs.length;
|
|
389
|
+
if (headLimit > 0 && totalMatches >= headLimit) break;
|
|
390
|
+
}
|
|
391
|
+
if (outputParts.length === 0) return ok("No matches found.");
|
|
392
|
+
const header = `Found ${totalMatches} matches in ${filesMatched} files`;
|
|
393
|
+
return ok(`${header}
|
|
394
|
+
${outputParts.join("\n")}`);
|
|
395
|
+
}
|
|
396
|
+
function executeDeleteTraceFile(args, projectDir) {
|
|
397
|
+
const filename = argStr(args, "filename", "file_name");
|
|
398
|
+
if (!filename) return fail("Error: filename is required");
|
|
399
|
+
const v = validateFilePath(filename, projectDir, "delete");
|
|
400
|
+
if (!v.ok) return fail(v.error);
|
|
401
|
+
if (!(0, import_fs.existsSync)(v.path)) return fail(`Error: File not found: ${filename}`);
|
|
402
|
+
try {
|
|
403
|
+
(0, import_fs.unlinkSync)(v.path);
|
|
404
|
+
} catch (e) {
|
|
405
|
+
return fail(`Error deleting file: ${e.message}`);
|
|
406
|
+
}
|
|
407
|
+
if ((0, import_path.extname)(v.path).toLowerCase() === ".trace_sch") {
|
|
408
|
+
const kicad = v.path.slice(0, -".trace_sch".length) + ".kicad_sch";
|
|
409
|
+
if ((0, import_fs.existsSync)(kicad)) {
|
|
410
|
+
try {
|
|
411
|
+
(0, import_fs.unlinkSync)(kicad);
|
|
412
|
+
} catch {
|
|
413
|
+
return ok(`Deleted ${filename} but failed to delete corresponding KiCad file`, true);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return ok(`Successfully deleted ${filename}`, true);
|
|
418
|
+
}
|
|
419
|
+
function executeFileTool(toolName, toolArgs, projectDir, defaultFile = "") {
|
|
420
|
+
switch (toolName) {
|
|
421
|
+
case "read_file":
|
|
422
|
+
return executeReadFile(toolArgs, projectDir, defaultFile);
|
|
423
|
+
case "write":
|
|
424
|
+
return executeWrite(toolArgs, projectDir);
|
|
425
|
+
case "search_replace":
|
|
426
|
+
return executeSearchReplace(toolArgs, projectDir, defaultFile);
|
|
427
|
+
case "list_dir":
|
|
428
|
+
return executeListDir(toolArgs, projectDir);
|
|
429
|
+
case "grep":
|
|
430
|
+
return executeGrep(toolArgs, projectDir);
|
|
431
|
+
case "delete_trace_file":
|
|
432
|
+
return executeDeleteTraceFile(toolArgs, projectDir);
|
|
433
|
+
case "todo_write":
|
|
434
|
+
return ok(JSON.stringify({ status: "ok" }));
|
|
435
|
+
case "todo_read":
|
|
436
|
+
return ok(JSON.stringify({ todos: [] }));
|
|
437
|
+
default:
|
|
438
|
+
return fail(`Unknown file tool '${toolName}'.`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// src/index.ts
|
|
443
|
+
var DEFAULT_API_URL = "https://api.buildwithtrace.com";
|
|
444
|
+
var API_VERSION = "latest";
|
|
445
|
+
var TraceError = class extends Error {
|
|
446
|
+
constructor(message) {
|
|
447
|
+
super(message);
|
|
448
|
+
this.name = "TraceError";
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
var TraceToolExecutionError = class extends TraceError {
|
|
452
|
+
constructor(message) {
|
|
453
|
+
super(message);
|
|
454
|
+
this.name = "TraceToolExecutionError";
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
var Trace = class _Trace {
|
|
458
|
+
constructor(config) {
|
|
459
|
+
this.apiKey = config.apiKey || process.env.TRACE_API_KEY || "";
|
|
460
|
+
if (!this.apiKey) {
|
|
461
|
+
throw new Error(
|
|
462
|
+
"API key required. Pass apiKey in config or set TRACE_API_KEY env var. Get your key at buildwithtrace.com/dashboard/settings > Developer, or via CLI: buildwithtrace auth token"
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
this.baseUrl = (config.baseUrl || process.env.TRACE_BASE_URL || DEFAULT_API_URL).replace(/\/$/, "");
|
|
466
|
+
this.timeout = config.timeout || 3e5;
|
|
467
|
+
}
|
|
468
|
+
async generateSymbol(description, options) {
|
|
469
|
+
const body = { description };
|
|
470
|
+
if (options?.datasheetUrl) body.datasheet_url = options.datasheetUrl;
|
|
471
|
+
if (options?.additionalInstructions) body.additional_instructions = options.additionalInstructions;
|
|
472
|
+
const data = await this._post(`/api/${API_VERSION}/components/generate/symbol`, body);
|
|
473
|
+
return this._makeGenerateResult(data, "symbol");
|
|
474
|
+
}
|
|
475
|
+
async generateFootprint(description, options) {
|
|
476
|
+
const body = { description };
|
|
477
|
+
if (options?.packageType) body.package_type = options.packageType;
|
|
478
|
+
if (options?.datasheetUrl) body.datasheet_url = options.datasheetUrl;
|
|
479
|
+
const data = await this._post(`/api/${API_VERSION}/components/generate/footprint`, body);
|
|
480
|
+
return this._makeGenerateResult(data, "footprint");
|
|
481
|
+
}
|
|
482
|
+
async search(query, options) {
|
|
483
|
+
const params = new URLSearchParams({ q: query, limit: String(options?.limit || 20) });
|
|
484
|
+
if (options?.type) params.set("type", options.type);
|
|
485
|
+
const data = await this._get(`/api/${API_VERSION}/components/search?${params}`);
|
|
486
|
+
return (data.results || []).map((r) => ({
|
|
487
|
+
name: r.name || "",
|
|
488
|
+
library: r.library || "",
|
|
489
|
+
description: r.description || "",
|
|
490
|
+
pinCount: r.pin_count || 0,
|
|
491
|
+
padCount: r.pad_count || 0,
|
|
492
|
+
category: r.category || "",
|
|
493
|
+
source: r.source || "",
|
|
494
|
+
score: r.score || 0
|
|
495
|
+
}));
|
|
496
|
+
}
|
|
497
|
+
async ask(question, options) {
|
|
498
|
+
return this._streamChat(question, {
|
|
499
|
+
mode: "ask",
|
|
500
|
+
appType: options?.appType,
|
|
501
|
+
projectDir: options?.projectDir,
|
|
502
|
+
fileContent: options?.fileContent,
|
|
503
|
+
filePath: options?.filePath,
|
|
504
|
+
conversationId: options?.conversationId
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
async review(options) {
|
|
508
|
+
let prompt = "Review this design for issues, best practices, and improvements.";
|
|
509
|
+
if (options?.focus) prompt += ` Focus on: ${options.focus}`;
|
|
510
|
+
return this._streamChat(prompt, {
|
|
511
|
+
mode: "ask",
|
|
512
|
+
appType: options?.appType,
|
|
513
|
+
projectDir: options?.projectDir,
|
|
514
|
+
fileContent: options?.fileContent,
|
|
515
|
+
filePath: options?.filePath
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Stream a chat turn and collect the full response. Exposes the full backend
|
|
520
|
+
* chat contract: pcbnew vs eeschema (`appType`), multi-turn (`conversationId` —
|
|
521
|
+
* read it back off the result), team workspaces (`teamId`), multimodal
|
|
522
|
+
* `attachments`, `filePath` + `fileContent`, and BYOK
|
|
523
|
+
* (`llmProvider`/`llmApiKey`/`llmModelId`).
|
|
524
|
+
*
|
|
525
|
+
* NOTE: mode 'agent'/'plan' produce tool calls that run on the caller's
|
|
526
|
+
* machine. The SDK runs a client-side tool loop for FILE tools (read_file,
|
|
527
|
+
* write, search_replace, list_dir, grep, delete_trace_file), executing them
|
|
528
|
+
* under `projectDir` (defaults to process.cwd()) with the same path +
|
|
529
|
+
* extension allowlist as the CLI/desktop, and POSTing each result back to the
|
|
530
|
+
* backend so multi-step agent runs continue. Engine/GUI tools (ERC/DRC,
|
|
531
|
+
* gerber/export, take_snapshot, run_*, autoroute) are out of scope and throw
|
|
532
|
+
* TraceToolExecutionError — use the `buildwithtrace` CLI for those.
|
|
533
|
+
*/
|
|
534
|
+
async chat(message, options) {
|
|
535
|
+
return this._streamChat(message, options || {});
|
|
536
|
+
}
|
|
537
|
+
async _post(path, body) {
|
|
538
|
+
const controller = new AbortController();
|
|
539
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
540
|
+
try {
|
|
541
|
+
const resp = await fetch(`${this.baseUrl}${path}`, {
|
|
542
|
+
method: "POST",
|
|
543
|
+
headers: {
|
|
544
|
+
"Authorization": `Bearer ${this.apiKey}`,
|
|
545
|
+
"Content-Type": "application/json",
|
|
546
|
+
"User-Agent": "buildwithtrace-node-sdk/0.1.0"
|
|
547
|
+
},
|
|
548
|
+
body: JSON.stringify(body),
|
|
549
|
+
signal: controller.signal
|
|
550
|
+
});
|
|
551
|
+
if (!resp.ok) {
|
|
552
|
+
const text = await resp.text().catch(() => "");
|
|
553
|
+
throw _Trace._httpError(resp.status, text);
|
|
554
|
+
}
|
|
555
|
+
return await resp.json();
|
|
556
|
+
} finally {
|
|
557
|
+
clearTimeout(timeoutId);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
static _httpError(status, text) {
|
|
561
|
+
if (status === 401) return new TraceError("Invalid or expired API key. Get a new one at buildwithtrace.com/dashboard/settings > Developer");
|
|
562
|
+
if (status === 402) return new TraceError("Quota exceeded. Upgrade at buildwithtrace.com/dashboard/billing");
|
|
563
|
+
if (status === 403) return new TraceError("Access denied. This feature requires a paid plan.");
|
|
564
|
+
return new Error(`Trace API error ${status}: ${text.slice(0, 200)}`);
|
|
565
|
+
}
|
|
566
|
+
async _get(path) {
|
|
567
|
+
const resp = await fetch(`${this.baseUrl}${path}`, {
|
|
568
|
+
headers: {
|
|
569
|
+
"Authorization": `Bearer ${this.apiKey}`,
|
|
570
|
+
"User-Agent": "buildwithtrace-node-sdk/0.1.0"
|
|
571
|
+
}
|
|
572
|
+
});
|
|
573
|
+
if (!resp.ok) {
|
|
574
|
+
const text = await resp.text().catch(() => "");
|
|
575
|
+
throw _Trace._httpError(resp.status, text);
|
|
576
|
+
}
|
|
577
|
+
return await resp.json();
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Single source of truth for the /chat/stream request body. Mirrors the
|
|
581
|
+
* backend ChatRequest contract so the SDK stays in lockstep with the
|
|
582
|
+
* CLI/desktop instead of sending a stale minimal payload.
|
|
583
|
+
*/
|
|
584
|
+
_buildChatBody(message, opts, sessionId) {
|
|
585
|
+
const appType = opts.appType || "eeschema";
|
|
586
|
+
const body = {
|
|
587
|
+
message,
|
|
588
|
+
mode: opts.mode || "ask",
|
|
589
|
+
session_id: sessionId,
|
|
590
|
+
app_type: appType,
|
|
591
|
+
api_version: API_VERSION,
|
|
592
|
+
client: "sdk"
|
|
593
|
+
};
|
|
594
|
+
if (opts.conversationId) body.conversation_id = opts.conversationId;
|
|
595
|
+
if (opts.fileContent) {
|
|
596
|
+
if (appType === "pcbnew") body.pcb_content = opts.fileContent;
|
|
597
|
+
else body.schematic_content = opts.fileContent;
|
|
598
|
+
body.total_lines = opts.fileContent.split("\n").length;
|
|
599
|
+
}
|
|
600
|
+
if (opts.filePath) body.file_path = opts.filePath;
|
|
601
|
+
if (opts.projectDir) body.project_dir = opts.projectDir;
|
|
602
|
+
if (opts.teamId) body.team_id = opts.teamId;
|
|
603
|
+
if (opts.attachments) body.attachments = opts.attachments;
|
|
604
|
+
if (opts.llmProvider && opts.llmApiKey) {
|
|
605
|
+
body.llm_provider = opts.llmProvider;
|
|
606
|
+
body.llm_api_key = opts.llmApiKey;
|
|
607
|
+
if (opts.llmModelId) body.llm_model_id = opts.llmModelId;
|
|
608
|
+
}
|
|
609
|
+
return body;
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Stream /chat/stream and drive a client-side tool loop.
|
|
613
|
+
*
|
|
614
|
+
* The backend pauses the SSE stream at each `tool_call` until the client POSTs
|
|
615
|
+
* the result to /tools/result, so we MUST read the body incrementally (not
|
|
616
|
+
* buffer it whole — that would dead-lock waiting for `done`). FILE tools are
|
|
617
|
+
* executed locally under `projectDir`; their result is posted back and the
|
|
618
|
+
* SAME stream keeps yielding events (the backend drives the multi-step loop)
|
|
619
|
+
* until `done`. Engine/GUI tools are out of scope and throw.
|
|
620
|
+
*/
|
|
621
|
+
async _streamChat(message, opts) {
|
|
622
|
+
const sessionId = `sdk-${Date.now().toString(36)}`;
|
|
623
|
+
const body = this._buildChatBody(message, opts, sessionId);
|
|
624
|
+
const resp = await fetch(`${this.baseUrl}/api/${API_VERSION}/chat/stream`, {
|
|
625
|
+
method: "POST",
|
|
626
|
+
headers: {
|
|
627
|
+
"Authorization": `Bearer ${this.apiKey}`,
|
|
628
|
+
"Content-Type": "application/json",
|
|
629
|
+
"Accept": "text/event-stream",
|
|
630
|
+
"User-Agent": "buildwithtrace-node-sdk/0.1.0"
|
|
631
|
+
},
|
|
632
|
+
body: JSON.stringify(body)
|
|
633
|
+
});
|
|
634
|
+
if (!resp.ok) {
|
|
635
|
+
const errText = await resp.text().catch(() => "");
|
|
636
|
+
throw _Trace._httpError(resp.status, errText);
|
|
637
|
+
}
|
|
638
|
+
if (!resp.body) {
|
|
639
|
+
throw new TraceError("The backend returned an empty response stream.");
|
|
640
|
+
}
|
|
641
|
+
const projectDir = opts.projectDir ? (0, import_path2.resolve)(opts.projectDir) : process.cwd();
|
|
642
|
+
const defaultFile = opts.filePath || "";
|
|
643
|
+
const textParts = [];
|
|
644
|
+
let conversationId = opts.conversationId || null;
|
|
645
|
+
let usage = null;
|
|
646
|
+
const handleEvent = async (event) => {
|
|
647
|
+
switch (event.type) {
|
|
648
|
+
case "text_delta":
|
|
649
|
+
textParts.push(event.content || "");
|
|
650
|
+
break;
|
|
651
|
+
case "done":
|
|
652
|
+
conversationId = event.conversation_id || conversationId;
|
|
653
|
+
break;
|
|
654
|
+
case "metrics":
|
|
655
|
+
usage = event;
|
|
656
|
+
break;
|
|
657
|
+
case "error":
|
|
658
|
+
throw new TraceError(event.content || "The backend returned an error.");
|
|
659
|
+
case "tool_call":
|
|
660
|
+
await this._handleToolCall(event, sessionId, projectDir, defaultFile);
|
|
661
|
+
break;
|
|
662
|
+
default:
|
|
663
|
+
break;
|
|
664
|
+
}
|
|
665
|
+
};
|
|
666
|
+
const reader = resp.body.getReader();
|
|
667
|
+
const decoder = new TextDecoder();
|
|
668
|
+
let buffer = "";
|
|
669
|
+
try {
|
|
670
|
+
for (; ; ) {
|
|
671
|
+
const { done, value } = await reader.read();
|
|
672
|
+
if (done) break;
|
|
673
|
+
buffer += decoder.decode(value, { stream: true });
|
|
674
|
+
let nl;
|
|
675
|
+
while ((nl = buffer.indexOf("\n")) >= 0) {
|
|
676
|
+
const line = buffer.slice(0, nl);
|
|
677
|
+
buffer = buffer.slice(nl + 1);
|
|
678
|
+
const ev = _Trace._parseSSELine(line);
|
|
679
|
+
if (ev) await handleEvent(ev);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
buffer += decoder.decode();
|
|
683
|
+
if (buffer.length) {
|
|
684
|
+
const ev = _Trace._parseSSELine(buffer);
|
|
685
|
+
if (ev) await handleEvent(ev);
|
|
686
|
+
}
|
|
687
|
+
} finally {
|
|
688
|
+
reader.cancel().catch(() => {
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
return { text: textParts.join(""), conversationId, usage };
|
|
692
|
+
}
|
|
693
|
+
static _parseSSELine(line) {
|
|
694
|
+
if (!line.startsWith("data:")) return null;
|
|
695
|
+
const payload = line.slice(5).trim();
|
|
696
|
+
if (!payload) return null;
|
|
697
|
+
try {
|
|
698
|
+
return JSON.parse(payload);
|
|
699
|
+
} catch {
|
|
700
|
+
return null;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Execute one tool call. FILE tools run locally (sandboxed to projectDir) and
|
|
705
|
+
* their result is POSTed to /tools/result so the backend continues the stream.
|
|
706
|
+
* Engine/GUI tools are out of scope and throw TraceToolExecutionError.
|
|
707
|
+
*/
|
|
708
|
+
async _handleToolCall(event, sessionId, projectDir, defaultFile) {
|
|
709
|
+
const toolName = event.tool_name || "unknown";
|
|
710
|
+
const toolCallId = event.tool_call_id || "";
|
|
711
|
+
const toolArgs = event.tool_args || {};
|
|
712
|
+
if (!isFileTool(toolName)) {
|
|
713
|
+
throw new TraceToolExecutionError(
|
|
714
|
+
`The agent requested '${toolName}', which the SDK does not execute. The SDK runs FILE tools only (read_file, write, search_replace, list_dir, grep, delete_trace_file); engine/GUI tools (ERC/DRC, gerber/export, take_snapshot, run_*, autoroute) require the \`buildwithtrace\` CLI or the desktop app.`
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
const result = executeFileTool(toolName, toolArgs, projectDir, defaultFile);
|
|
718
|
+
await this._postToolResult(sessionId, toolCallId, result.result, !result.success);
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* POST a tool result back to /api/<version>/tools/result (same base URL +
|
|
722
|
+
* version as /chat/stream). Non-fatal on failure — like the CLI, we warn and
|
|
723
|
+
* move on rather than crashing the loop (the backend turn will time out).
|
|
724
|
+
*/
|
|
725
|
+
async _postToolResult(sessionId, toolCallId, result, isError) {
|
|
726
|
+
const payload = {
|
|
727
|
+
session_id: sessionId,
|
|
728
|
+
tool_call_id: toolCallId,
|
|
729
|
+
result
|
|
730
|
+
};
|
|
731
|
+
if (isError) payload.is_error = true;
|
|
732
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
733
|
+
try {
|
|
734
|
+
const resp = await fetch(`${this.baseUrl}/api/${API_VERSION}/tools/result`, {
|
|
735
|
+
method: "POST",
|
|
736
|
+
headers: {
|
|
737
|
+
"Authorization": `Bearer ${this.apiKey}`,
|
|
738
|
+
"Content-Type": "application/json",
|
|
739
|
+
"User-Agent": "buildwithtrace-node-sdk/0.1.0"
|
|
740
|
+
},
|
|
741
|
+
body: JSON.stringify(payload)
|
|
742
|
+
});
|
|
743
|
+
if (resp.ok) return;
|
|
744
|
+
} catch {
|
|
745
|
+
}
|
|
746
|
+
if (attempt < 2) await new Promise((r) => setTimeout(r, 250 * (attempt + 1)));
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
_makeGenerateResult(data, type) {
|
|
750
|
+
const name = data.name || "";
|
|
751
|
+
const kicadSym = data.kicad_sym || null;
|
|
752
|
+
const kicadMod = data.kicad_mod || null;
|
|
753
|
+
return {
|
|
754
|
+
name,
|
|
755
|
+
type,
|
|
756
|
+
description: data.description || "",
|
|
757
|
+
pinCount: data.pin_count || 0,
|
|
758
|
+
padCount: data.pad_count || 0,
|
|
759
|
+
kicadSym,
|
|
760
|
+
kicadMod,
|
|
761
|
+
traceJson: data.trace_json || null,
|
|
762
|
+
warning: data.warning || null,
|
|
763
|
+
steps: data.steps || [],
|
|
764
|
+
save(directory = ".") {
|
|
765
|
+
(0, import_fs2.mkdirSync)(directory, { recursive: true });
|
|
766
|
+
if (type === "symbol" && kicadSym) {
|
|
767
|
+
const path = (0, import_path2.join)(directory, `${name}.kicad_sym`);
|
|
768
|
+
(0, import_fs2.writeFileSync)(path, kicadSym, "utf-8");
|
|
769
|
+
return path;
|
|
770
|
+
} else if (type === "footprint" && kicadMod) {
|
|
771
|
+
const path = (0, import_path2.join)(directory, `${name}.kicad_mod`);
|
|
772
|
+
(0, import_fs2.writeFileSync)(path, kicadMod, "utf-8");
|
|
773
|
+
return path;
|
|
774
|
+
}
|
|
775
|
+
throw new Error(`No content to save for ${type} '${name}'`);
|
|
776
|
+
}
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
};
|
|
780
|
+
var index_default = Trace;
|
|
781
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
782
|
+
0 && (module.exports = {
|
|
783
|
+
Trace,
|
|
784
|
+
TraceError,
|
|
785
|
+
TraceToolExecutionError
|
|
786
|
+
});
|