openplanter 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/README.md +210 -0
  2. package/dist/builder.d.ts +11 -0
  3. package/dist/builder.d.ts.map +1 -0
  4. package/dist/builder.js +179 -0
  5. package/dist/builder.js.map +1 -0
  6. package/dist/cli.d.ts +9 -0
  7. package/dist/cli.d.ts.map +1 -0
  8. package/dist/cli.js +548 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/config.d.ts +51 -0
  11. package/dist/config.d.ts.map +1 -0
  12. package/dist/config.js +114 -0
  13. package/dist/config.js.map +1 -0
  14. package/dist/credentials.d.ts +52 -0
  15. package/dist/credentials.d.ts.map +1 -0
  16. package/dist/credentials.js +371 -0
  17. package/dist/credentials.js.map +1 -0
  18. package/dist/demo.d.ts +26 -0
  19. package/dist/demo.d.ts.map +1 -0
  20. package/dist/demo.js +95 -0
  21. package/dist/demo.js.map +1 -0
  22. package/dist/engine.d.ts +91 -0
  23. package/dist/engine.d.ts.map +1 -0
  24. package/dist/engine.js +1036 -0
  25. package/dist/engine.js.map +1 -0
  26. package/dist/index.d.ts +30 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +39 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/investigation-tools/aph-holdings.d.ts +61 -0
  31. package/dist/investigation-tools/aph-holdings.d.ts.map +1 -0
  32. package/dist/investigation-tools/aph-holdings.js +459 -0
  33. package/dist/investigation-tools/aph-holdings.js.map +1 -0
  34. package/dist/investigation-tools/asic-officer-lookup.d.ts +42 -0
  35. package/dist/investigation-tools/asic-officer-lookup.d.ts.map +1 -0
  36. package/dist/investigation-tools/asic-officer-lookup.js +197 -0
  37. package/dist/investigation-tools/asic-officer-lookup.js.map +1 -0
  38. package/dist/investigation-tools/asx-calendar-fetcher.d.ts +42 -0
  39. package/dist/investigation-tools/asx-calendar-fetcher.d.ts.map +1 -0
  40. package/dist/investigation-tools/asx-calendar-fetcher.js +271 -0
  41. package/dist/investigation-tools/asx-calendar-fetcher.js.map +1 -0
  42. package/dist/investigation-tools/asx-parser.d.ts +66 -0
  43. package/dist/investigation-tools/asx-parser.d.ts.map +1 -0
  44. package/dist/investigation-tools/asx-parser.js +314 -0
  45. package/dist/investigation-tools/asx-parser.js.map +1 -0
  46. package/dist/investigation-tools/bulk-asx-announcements.d.ts +53 -0
  47. package/dist/investigation-tools/bulk-asx-announcements.d.ts.map +1 -0
  48. package/dist/investigation-tools/bulk-asx-announcements.js +204 -0
  49. package/dist/investigation-tools/bulk-asx-announcements.js.map +1 -0
  50. package/dist/investigation-tools/entity-resolver.d.ts +77 -0
  51. package/dist/investigation-tools/entity-resolver.d.ts.map +1 -0
  52. package/dist/investigation-tools/entity-resolver.js +346 -0
  53. package/dist/investigation-tools/entity-resolver.js.map +1 -0
  54. package/dist/investigation-tools/hotcopper-scraper.d.ts +73 -0
  55. package/dist/investigation-tools/hotcopper-scraper.d.ts.map +1 -0
  56. package/dist/investigation-tools/hotcopper-scraper.js +318 -0
  57. package/dist/investigation-tools/hotcopper-scraper.js.map +1 -0
  58. package/dist/investigation-tools/index.d.ts +15 -0
  59. package/dist/investigation-tools/index.d.ts.map +1 -0
  60. package/dist/investigation-tools/index.js +15 -0
  61. package/dist/investigation-tools/index.js.map +1 -0
  62. package/dist/investigation-tools/insider-graph.d.ts +173 -0
  63. package/dist/investigation-tools/insider-graph.d.ts.map +1 -0
  64. package/dist/investigation-tools/insider-graph.js +732 -0
  65. package/dist/investigation-tools/insider-graph.js.map +1 -0
  66. package/dist/investigation-tools/insider-suspicion-scorer.d.ts +97 -0
  67. package/dist/investigation-tools/insider-suspicion-scorer.d.ts.map +1 -0
  68. package/dist/investigation-tools/insider-suspicion-scorer.js +327 -0
  69. package/dist/investigation-tools/insider-suspicion-scorer.js.map +1 -0
  70. package/dist/investigation-tools/multi-forum-scraper.d.ts +104 -0
  71. package/dist/investigation-tools/multi-forum-scraper.d.ts.map +1 -0
  72. package/dist/investigation-tools/multi-forum-scraper.js +415 -0
  73. package/dist/investigation-tools/multi-forum-scraper.js.map +1 -0
  74. package/dist/investigation-tools/price-fetcher.d.ts +81 -0
  75. package/dist/investigation-tools/price-fetcher.d.ts.map +1 -0
  76. package/dist/investigation-tools/price-fetcher.js +268 -0
  77. package/dist/investigation-tools/price-fetcher.js.map +1 -0
  78. package/dist/investigation-tools/shared.d.ts +39 -0
  79. package/dist/investigation-tools/shared.d.ts.map +1 -0
  80. package/dist/investigation-tools/shared.js +203 -0
  81. package/dist/investigation-tools/shared.js.map +1 -0
  82. package/dist/investigation-tools/timeline-linker.d.ts +90 -0
  83. package/dist/investigation-tools/timeline-linker.d.ts.map +1 -0
  84. package/dist/investigation-tools/timeline-linker.js +219 -0
  85. package/dist/investigation-tools/timeline-linker.js.map +1 -0
  86. package/dist/investigation-tools/volume-scanner.d.ts +70 -0
  87. package/dist/investigation-tools/volume-scanner.d.ts.map +1 -0
  88. package/dist/investigation-tools/volume-scanner.js +227 -0
  89. package/dist/investigation-tools/volume-scanner.js.map +1 -0
  90. package/dist/model.d.ts +136 -0
  91. package/dist/model.d.ts.map +1 -0
  92. package/dist/model.js +1071 -0
  93. package/dist/model.js.map +1 -0
  94. package/dist/patching.d.ts +45 -0
  95. package/dist/patching.d.ts.map +1 -0
  96. package/dist/patching.js +317 -0
  97. package/dist/patching.js.map +1 -0
  98. package/dist/prompts.d.ts +15 -0
  99. package/dist/prompts.d.ts.map +1 -0
  100. package/dist/prompts.js +351 -0
  101. package/dist/prompts.js.map +1 -0
  102. package/dist/replay-log.d.ts +54 -0
  103. package/dist/replay-log.d.ts.map +1 -0
  104. package/dist/replay-log.js +94 -0
  105. package/dist/replay-log.js.map +1 -0
  106. package/dist/runtime.d.ts +53 -0
  107. package/dist/runtime.d.ts.map +1 -0
  108. package/dist/runtime.js +259 -0
  109. package/dist/runtime.js.map +1 -0
  110. package/dist/settings.d.ts +39 -0
  111. package/dist/settings.d.ts.map +1 -0
  112. package/dist/settings.js +146 -0
  113. package/dist/settings.js.map +1 -0
  114. package/dist/tool-defs.d.ts +58 -0
  115. package/dist/tool-defs.d.ts.map +1 -0
  116. package/dist/tool-defs.js +1029 -0
  117. package/dist/tool-defs.js.map +1 -0
  118. package/dist/tools.d.ts +72 -0
  119. package/dist/tools.d.ts.map +1 -0
  120. package/dist/tools.js +1454 -0
  121. package/dist/tools.js.map +1 -0
  122. package/dist/tui.d.ts +49 -0
  123. package/dist/tui.d.ts.map +1 -0
  124. package/dist/tui.js +699 -0
  125. package/dist/tui.js.map +1 -0
  126. package/package.json +126 -0
package/dist/tools.js ADDED
@@ -0,0 +1,1454 @@
1
+ import * as fs from "node:fs";
2
+ import * as fsp from "node:fs/promises";
3
+ import * as path from "node:path";
4
+ import * as os from "node:os";
5
+ import * as zlib from "node:zlib";
6
+ import { spawn, execSync } from "node:child_process";
7
+ import * as http from "node:http";
8
+ import * as https from "node:https";
9
+ import { PatchApplyError, apply_agent_patch, parse_agent_patch, } from "./patching.js";
10
+ // ---------------------------------------------------------------------------
11
+ // Constants
12
+ // ---------------------------------------------------------------------------
13
+ const _MAX_WALK_ENTRIES = 50_000;
14
+ const _WS_RE = /\s+/g;
15
+ const _HASHLINE_PREFIX_RE = /^\d+:[0-9a-f]{2}\|/;
16
+ const _HEREDOC_RE = /<<-?\s*['"]?\w+['"]?/;
17
+ const _INTERACTIVE_RE = /(^|[;&|]\s*)(vim|nano|less|more|top|htop|man)\b/;
18
+ // ---------------------------------------------------------------------------
19
+ // Helpers
20
+ // ---------------------------------------------------------------------------
21
+ /** 2-char hex hash, whitespace-invariant. */
22
+ function _line_hash(line) {
23
+ const normalized = line.replace(_WS_RE, "");
24
+ const crc = zlib.crc32(Buffer.from(normalized, "utf-8"));
25
+ return (crc & 0xff).toString(16).padStart(2, "0");
26
+ }
27
+ // ---------------------------------------------------------------------------
28
+ // ToolError
29
+ // ---------------------------------------------------------------------------
30
+ export class ToolError extends Error {
31
+ constructor(message) {
32
+ super(message);
33
+ this.name = "ToolError";
34
+ }
35
+ }
36
+ // ---------------------------------------------------------------------------
37
+ // WorkspaceTools
38
+ // ---------------------------------------------------------------------------
39
+ export class WorkspaceTools {
40
+ root;
41
+ shell;
42
+ commandTimeoutSec;
43
+ maxShellOutputChars;
44
+ maxFileChars;
45
+ maxFilesListed;
46
+ maxSearchHits;
47
+ exaApiKey;
48
+ exaBaseUrl;
49
+ _bgJobs = new Map();
50
+ _bgNextId = 1;
51
+ // Runtime policy state
52
+ _filesRead = new Set();
53
+ _parallelWriteClaims = new Map();
54
+ // Scope-local state (simulated via simple fields; Node is single-threaded)
55
+ _scopeGroupId = null;
56
+ _scopeOwnerId = null;
57
+ // ------------------------------------------------------------------
58
+ // Domain tools (tools/ directory CLI scripts)
59
+ // ------------------------------------------------------------------
60
+ static _DOMAIN_TOOL_SCRIPTS = {
61
+ aph_holdings: "aph_holdings.py",
62
+ asx_parser: "asx_parser.py",
63
+ asx_calendar_fetcher: "asx_calendar_fetcher.py",
64
+ bulk_asx_announcements: "bulk_asx_announcements.py",
65
+ asic_officer_lookup: "asic_officer_lookup.py",
66
+ entity_resolver: "entity_resolver.py",
67
+ hotcopper_scraper: "hotcopper_scraper.py",
68
+ insider_graph: "insider_graph.py",
69
+ insider_suspicion_scorer: "insider_suspicion_scorer.py",
70
+ multi_forum_scraper: "multi_forum_scraper.py",
71
+ price_fetcher: "price_fetcher.py",
72
+ timeline_linker: "timeline_linker.py",
73
+ volume_scanner: "volume_scanner.py",
74
+ };
75
+ static _DOMAIN_ARG_MAP = {
76
+ asx_calendar_fetcher: {
77
+ tickers: "--tickers",
78
+ period: "--period",
79
+ format: "--format",
80
+ test: "--test",
81
+ },
82
+ bulk_asx_announcements: {
83
+ tickers: "--tickers",
84
+ types: "--types",
85
+ days_back: "--days-back",
86
+ output_dir: "--output-dir",
87
+ format: "--format",
88
+ test: "--test",
89
+ },
90
+ asic_officer_lookup: {
91
+ abn_or_ticker: "--abn-or-ticker",
92
+ max_results: "--max-results",
93
+ format: "--format",
94
+ test: "--test",
95
+ },
96
+ multi_forum_scraper: {
97
+ ticker: "--ticker",
98
+ sites: "--sites",
99
+ days: "--days",
100
+ keywords: "--keywords",
101
+ format: "--format",
102
+ test: "--test",
103
+ },
104
+ insider_suspicion_scorer: {
105
+ trades: "--trades",
106
+ anomalies: "--anomalies",
107
+ rumors: "--rumors",
108
+ holdings: "--holdings",
109
+ output: "--output",
110
+ min_score: "--min-score",
111
+ format: "--format",
112
+ test: "--test",
113
+ },
114
+ aph_holdings: {
115
+ member: "--member",
116
+ chamber: "--chamber",
117
+ cache_dir: "--cache-dir",
118
+ test: "--test",
119
+ },
120
+ asx_parser: {
121
+ input: "--input",
122
+ type: "--type",
123
+ test: "--test",
124
+ },
125
+ entity_resolver: {
126
+ mode: "--mode",
127
+ input: "--input",
128
+ reference: "--reference",
129
+ threshold: "--threshold",
130
+ test: "--test",
131
+ },
132
+ hotcopper_scraper: {
133
+ ticker: "--ticker",
134
+ days: "--days",
135
+ pages: "--pages",
136
+ format: "--format",
137
+ test: "--test",
138
+ },
139
+ insider_graph: {
140
+ input: "--input",
141
+ mode: "--mode",
142
+ find_path: "--find-path",
143
+ connections: "--connections",
144
+ depth: "--depth",
145
+ clusters: "--clusters",
146
+ suspicion: "--suspicion",
147
+ export_format: "--export-format",
148
+ stats: "--stats",
149
+ test: "--test",
150
+ },
151
+ price_fetcher: {
152
+ tickers: "--tickers",
153
+ period: "--period",
154
+ interval: "--interval",
155
+ format: "--format",
156
+ anomalies_only: "--anomalies-only",
157
+ summary: "--summary",
158
+ test: "--test",
159
+ },
160
+ timeline_linker: {
161
+ trades: "--trades",
162
+ events: "--events",
163
+ window: "--window",
164
+ min_score: "--min-score",
165
+ date_from: "--from",
166
+ date_to: "--to",
167
+ summary: "--summary",
168
+ test: "--test",
169
+ },
170
+ volume_scanner: {
171
+ tickers: "--tickers",
172
+ watchlist: "--watchlist",
173
+ days: "--days",
174
+ threshold: "--threshold",
175
+ format: "--format",
176
+ report_dates: "--report-dates",
177
+ test: "--test",
178
+ },
179
+ };
180
+ constructor(opts) {
181
+ const resolved = path.resolve(opts.root);
182
+ if (!fs.existsSync(resolved)) {
183
+ throw new ToolError(`Workspace does not exist: ${resolved}`);
184
+ }
185
+ const stat = fs.statSync(resolved);
186
+ if (!stat.isDirectory()) {
187
+ throw new ToolError(`Workspace is not a directory: ${resolved}`);
188
+ }
189
+ this.root = resolved;
190
+ this.shell = opts.shell ?? "/bin/sh";
191
+ this.commandTimeoutSec = opts.commandTimeoutSec ?? 45;
192
+ this.maxShellOutputChars = opts.maxShellOutputChars ?? 16000;
193
+ this.maxFileChars = opts.maxFileChars ?? 20000;
194
+ this.maxFilesListed = opts.maxFilesListed ?? 400;
195
+ this.maxSearchHits = opts.maxSearchHits ?? 200;
196
+ this.exaApiKey = opts.exaApiKey ?? null;
197
+ this.exaBaseUrl = opts.exaBaseUrl ?? "https://api.exa.ai";
198
+ }
199
+ // ------------------------------------------------------------------
200
+ // Private helpers
201
+ // ------------------------------------------------------------------
202
+ _clip(text, maxChars) {
203
+ if (text.length <= maxChars) {
204
+ return text;
205
+ }
206
+ const omitted = text.length - maxChars;
207
+ return `${text.slice(0, maxChars)}\n\n...[truncated ${omitted} chars]...`;
208
+ }
209
+ _resolvePath(rawPath) {
210
+ let candidate;
211
+ if (path.isAbsolute(rawPath)) {
212
+ candidate = rawPath;
213
+ }
214
+ else {
215
+ candidate = path.join(this.root, rawPath);
216
+ }
217
+ const resolved = path.resolve(candidate);
218
+ if (resolved === this.root) {
219
+ return resolved;
220
+ }
221
+ const relative = path.relative(this.root, resolved);
222
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
223
+ throw new ToolError(`Path escapes workspace: ${rawPath}`);
224
+ }
225
+ return resolved;
226
+ }
227
+ _checkShellPolicy(command) {
228
+ if (_HEREDOC_RE.test(command)) {
229
+ return ("BLOCKED: Heredoc syntax (<< EOF) is not allowed by runtime policy. " +
230
+ "Use write_file/apply_patch for multi-line content.");
231
+ }
232
+ if (_INTERACTIVE_RE.test(command)) {
233
+ return ("BLOCKED: Interactive terminal programs are not allowed by runtime policy " +
234
+ "(vim/nano/less/more/top/htop/man).");
235
+ }
236
+ return null;
237
+ }
238
+ _registerWriteTarget(resolved) {
239
+ const groupId = this._scopeGroupId;
240
+ const ownerId = this._scopeOwnerId;
241
+ if (!groupId || !ownerId) {
242
+ return;
243
+ }
244
+ let claims = this._parallelWriteClaims.get(groupId);
245
+ if (!claims) {
246
+ claims = new Map();
247
+ this._parallelWriteClaims.set(groupId, claims);
248
+ }
249
+ const owner = claims.get(resolved);
250
+ if (owner === undefined) {
251
+ claims.set(resolved, ownerId);
252
+ return;
253
+ }
254
+ if (owner !== ownerId) {
255
+ const rel = path.relative(this.root, resolved).split(path.sep).join("/");
256
+ throw new ToolError(`Parallel write conflict: '${rel}' is already claimed by sibling task ${owner}.`);
257
+ }
258
+ }
259
+ // ------------------------------------------------------------------
260
+ // Parallel write group management
261
+ // ------------------------------------------------------------------
262
+ beginParallelWriteGroup(groupId) {
263
+ this._parallelWriteClaims.set(groupId, new Map());
264
+ }
265
+ endParallelWriteGroup(groupId) {
266
+ this._parallelWriteClaims.delete(groupId);
267
+ }
268
+ executionScope(groupId, ownerId, fn) {
269
+ const prevGroup = this._scopeGroupId;
270
+ const prevOwner = this._scopeOwnerId;
271
+ this._scopeGroupId = groupId;
272
+ this._scopeOwnerId = ownerId;
273
+ try {
274
+ return fn();
275
+ }
276
+ finally {
277
+ this._scopeGroupId = prevGroup;
278
+ this._scopeOwnerId = prevOwner;
279
+ }
280
+ }
281
+ async executionScopeAsync(groupId, ownerId, fn) {
282
+ const prevGroup = this._scopeGroupId;
283
+ const prevOwner = this._scopeOwnerId;
284
+ this._scopeGroupId = groupId;
285
+ this._scopeOwnerId = ownerId;
286
+ try {
287
+ return await fn();
288
+ }
289
+ finally {
290
+ this._scopeGroupId = prevGroup;
291
+ this._scopeOwnerId = prevOwner;
292
+ }
293
+ }
294
+ // ------------------------------------------------------------------
295
+ // Shell execution
296
+ // ------------------------------------------------------------------
297
+ async runShell(command, timeout) {
298
+ const policyError = this._checkShellPolicy(command);
299
+ if (policyError) {
300
+ return policyError;
301
+ }
302
+ const effectiveTimeout = Math.max(1, Math.min(timeout ?? this.commandTimeoutSec, 600));
303
+ return new Promise((resolve) => {
304
+ let proc;
305
+ try {
306
+ proc = spawn(command, {
307
+ shell: this.shell,
308
+ cwd: this.root,
309
+ stdio: ["ignore", "pipe", "pipe"],
310
+ detached: true,
311
+ });
312
+ }
313
+ catch (exc) {
314
+ resolve(`$ ${command}\n[failed to start: ${exc}]`);
315
+ return;
316
+ }
317
+ let stdout = "";
318
+ let stderr = "";
319
+ let finished = false;
320
+ proc.stdout?.on("data", (chunk) => {
321
+ stdout += chunk.toString("utf-8");
322
+ });
323
+ proc.stderr?.on("data", (chunk) => {
324
+ stderr += chunk.toString("utf-8");
325
+ });
326
+ const timer = setTimeout(() => {
327
+ if (!finished) {
328
+ finished = true;
329
+ try {
330
+ if (proc.pid !== undefined) {
331
+ process.kill(-proc.pid, "SIGKILL");
332
+ }
333
+ }
334
+ catch {
335
+ try {
336
+ proc.kill("SIGKILL");
337
+ }
338
+ catch {
339
+ // ignore
340
+ }
341
+ }
342
+ resolve(`$ ${command}\n[timeout after ${effectiveTimeout}s — processes killed]`);
343
+ }
344
+ }, effectiveTimeout * 1000);
345
+ proc.on("close", (code) => {
346
+ if (!finished) {
347
+ finished = true;
348
+ clearTimeout(timer);
349
+ const merged = `$ ${command}\n` +
350
+ `[exit_code=${code ?? -1}]\n` +
351
+ `[stdout]\n${stdout}\n` +
352
+ `[stderr]\n${stderr}`;
353
+ resolve(this._clip(merged, this.maxShellOutputChars));
354
+ }
355
+ });
356
+ proc.on("error", (err) => {
357
+ if (!finished) {
358
+ finished = true;
359
+ clearTimeout(timer);
360
+ resolve(`$ ${command}\n[failed to start: ${err}]`);
361
+ }
362
+ });
363
+ });
364
+ }
365
+ runShellBg(command) {
366
+ const policyError = this._checkShellPolicy(command);
367
+ if (policyError) {
368
+ return policyError;
369
+ }
370
+ const outPath = path.join(os.tmpdir(), `.rlm_bg_${this._bgNextId}.out`);
371
+ const fd = fs.openSync(outPath, "w+");
372
+ let proc;
373
+ try {
374
+ proc = spawn(command, {
375
+ shell: this.shell,
376
+ cwd: this.root,
377
+ stdio: ["ignore", fd, fd],
378
+ detached: true,
379
+ });
380
+ }
381
+ catch (exc) {
382
+ fs.closeSync(fd);
383
+ try {
384
+ fs.unlinkSync(outPath);
385
+ }
386
+ catch {
387
+ // ignore
388
+ }
389
+ return `Failed to start background command: ${exc}`;
390
+ }
391
+ const jobId = this._bgNextId;
392
+ this._bgNextId += 1;
393
+ this._bgJobs.set(jobId, { proc, outPath, fd });
394
+ return `Background job started: job_id=${jobId}, pid=${proc.pid}`;
395
+ }
396
+ checkShellBg(jobId) {
397
+ const entry = this._bgJobs.get(jobId);
398
+ if (!entry) {
399
+ return `No background job with id ${jobId}`;
400
+ }
401
+ const { proc, outPath, fd } = entry;
402
+ const exitCode = proc.exitCode;
403
+ let output = "";
404
+ try {
405
+ output = fs.readFileSync(outPath, "utf-8");
406
+ }
407
+ catch {
408
+ // ignore
409
+ }
410
+ output = this._clip(output, this.maxShellOutputChars);
411
+ if (exitCode !== null) {
412
+ fs.closeSync(fd);
413
+ try {
414
+ fs.unlinkSync(outPath);
415
+ }
416
+ catch {
417
+ // ignore
418
+ }
419
+ this._bgJobs.delete(jobId);
420
+ return `[job ${jobId} finished, exit_code=${exitCode}]\n${output}`;
421
+ }
422
+ return `[job ${jobId} still running, pid=${proc.pid}]\n${output}`;
423
+ }
424
+ killShellBg(jobId) {
425
+ const entry = this._bgJobs.get(jobId);
426
+ if (!entry) {
427
+ return `No background job with id ${jobId}`;
428
+ }
429
+ const { proc, outPath, fd } = entry;
430
+ try {
431
+ if (proc.pid !== undefined) {
432
+ process.kill(-proc.pid, "SIGKILL");
433
+ }
434
+ }
435
+ catch {
436
+ try {
437
+ proc.kill("SIGKILL");
438
+ }
439
+ catch {
440
+ // ignore
441
+ }
442
+ }
443
+ fs.closeSync(fd);
444
+ try {
445
+ fs.unlinkSync(outPath);
446
+ }
447
+ catch {
448
+ // ignore
449
+ }
450
+ this._bgJobs.delete(jobId);
451
+ return `Background job ${jobId} killed.`;
452
+ }
453
+ cleanupBgJobs() {
454
+ for (const [jobId, { proc, outPath, fd }] of this._bgJobs) {
455
+ try {
456
+ if (proc.pid !== undefined) {
457
+ process.kill(-proc.pid, "SIGKILL");
458
+ }
459
+ }
460
+ catch {
461
+ try {
462
+ proc.kill("SIGKILL");
463
+ }
464
+ catch {
465
+ // ignore
466
+ }
467
+ }
468
+ try {
469
+ fs.closeSync(fd);
470
+ }
471
+ catch {
472
+ // ignore
473
+ }
474
+ try {
475
+ fs.unlinkSync(outPath);
476
+ }
477
+ catch {
478
+ // ignore
479
+ }
480
+ }
481
+ this._bgJobs.clear();
482
+ }
483
+ // ------------------------------------------------------------------
484
+ // File listing & search
485
+ // ------------------------------------------------------------------
486
+ async listFiles(glob) {
487
+ let lines;
488
+ if (this._hasRg()) {
489
+ const cmd = ["rg", "--files", "--hidden", "-g", "!.git"];
490
+ if (glob) {
491
+ cmd.push("-g", glob);
492
+ }
493
+ const result = await this._spawnCapture(cmd, this.root, this.commandTimeoutSec);
494
+ if (result === null) {
495
+ return "(list_files timed out)";
496
+ }
497
+ lines = result.stdout
498
+ .split("\n")
499
+ .filter((ln) => ln.trim() !== "");
500
+ }
501
+ else {
502
+ const allPaths = [];
503
+ await this._walkDir(this.root, allPaths, _MAX_WALK_ENTRIES);
504
+ lines = allPaths.sort();
505
+ }
506
+ if (lines.length === 0) {
507
+ return "(no files)";
508
+ }
509
+ const clipped = lines.slice(0, this.maxFilesListed);
510
+ let suffix = "";
511
+ if (lines.length > clipped.length) {
512
+ suffix = `\n...[omitted ${lines.length - clipped.length} files]...`;
513
+ }
514
+ return clipped.join("\n") + suffix;
515
+ }
516
+ async searchFiles(query, glob) {
517
+ if (!query.trim()) {
518
+ return "query cannot be empty";
519
+ }
520
+ if (this._hasRg()) {
521
+ const cmd = ["rg", "-n", "--hidden", "-S", query, "."];
522
+ if (glob) {
523
+ cmd.push("-g", glob);
524
+ }
525
+ const result = await this._spawnCapture(cmd, this.root, this.commandTimeoutSec);
526
+ if (result === null) {
527
+ return "(search_files timed out)";
528
+ }
529
+ const outLines = result.stdout
530
+ .split("\n")
531
+ .filter((ln) => ln.trim() !== "");
532
+ if (outLines.length === 0) {
533
+ return "(no matches)";
534
+ }
535
+ const clipped = outLines.slice(0, this.maxSearchHits);
536
+ let suffix = "";
537
+ if (outLines.length > clipped.length) {
538
+ suffix = `\n...[omitted ${outLines.length - clipped.length} matches]...`;
539
+ }
540
+ return clipped.join("\n") + suffix;
541
+ }
542
+ // Fallback: manual walk + search
543
+ const matches = [];
544
+ const lowerQuery = query.toLowerCase();
545
+ const allPaths = [];
546
+ await this._walkDir(this.root, allPaths, _MAX_WALK_ENTRIES);
547
+ for (const rel of allPaths) {
548
+ const full = path.join(this.root, rel);
549
+ let text;
550
+ try {
551
+ text = await fsp.readFile(full, "utf-8");
552
+ }
553
+ catch {
554
+ continue;
555
+ }
556
+ const fileLines = text.split("\n");
557
+ for (let idx = 0; idx < fileLines.length; idx++) {
558
+ if (fileLines[idx].toLowerCase().includes(lowerQuery)) {
559
+ matches.push(`${rel}:${idx + 1}:${fileLines[idx]}`);
560
+ if (matches.length >= this.maxSearchHits) {
561
+ return matches.join("\n") + "\n...[match limit reached]...";
562
+ }
563
+ }
564
+ }
565
+ }
566
+ return matches.length > 0 ? matches.join("\n") : "(no matches)";
567
+ }
568
+ // ------------------------------------------------------------------
569
+ // Repo map
570
+ // ------------------------------------------------------------------
571
+ async repoMap(glob, maxFiles = 200) {
572
+ const clamped = Math.max(1, Math.min(Math.floor(maxFiles), 500));
573
+ const candidates = await this._repoFiles(glob, clamped);
574
+ if (candidates.length === 0) {
575
+ return "(no files)";
576
+ }
577
+ const languageBySuffix = {
578
+ ".py": "python",
579
+ ".js": "javascript",
580
+ ".jsx": "javascript",
581
+ ".ts": "typescript",
582
+ ".tsx": "typescript",
583
+ ".go": "go",
584
+ ".rs": "rust",
585
+ ".java": "java",
586
+ ".c": "c",
587
+ ".h": "c",
588
+ ".cpp": "cpp",
589
+ ".hpp": "cpp",
590
+ ".cs": "csharp",
591
+ ".rb": "ruby",
592
+ ".php": "php",
593
+ ".swift": "swift",
594
+ ".kt": "kotlin",
595
+ ".scala": "scala",
596
+ ".sh": "shell",
597
+ };
598
+ const files = [];
599
+ for (const rel of candidates) {
600
+ const suffix = path.extname(rel).toLowerCase();
601
+ const language = languageBySuffix[suffix];
602
+ if (!language)
603
+ continue;
604
+ let resolved;
605
+ try {
606
+ resolved = this._resolvePath(rel);
607
+ }
608
+ catch {
609
+ continue;
610
+ }
611
+ let stat;
612
+ try {
613
+ stat = await fsp.stat(resolved);
614
+ }
615
+ catch {
616
+ continue;
617
+ }
618
+ if (!stat.isFile())
619
+ continue;
620
+ let text;
621
+ try {
622
+ text = await fsp.readFile(resolved, "utf-8");
623
+ }
624
+ catch {
625
+ continue;
626
+ }
627
+ const symbols = this._genericSymbols(text);
628
+ files.push({
629
+ path: rel,
630
+ language,
631
+ lines: text.split("\n").length,
632
+ symbols: symbols.slice(0, 200),
633
+ });
634
+ }
635
+ const output = {
636
+ root: this.root,
637
+ files,
638
+ total: files.length,
639
+ };
640
+ return this._clip(JSON.stringify(output, null, 2), this.maxFileChars);
641
+ }
642
+ // ------------------------------------------------------------------
643
+ // File read/write/edit
644
+ // ------------------------------------------------------------------
645
+ async readFile(filePath, hashline = true) {
646
+ const resolved = this._resolvePath(filePath);
647
+ let stat;
648
+ try {
649
+ stat = await fsp.stat(resolved);
650
+ }
651
+ catch {
652
+ return `File not found: ${filePath}`;
653
+ }
654
+ if (stat.isDirectory()) {
655
+ return `Path is a directory, not a file: ${filePath}`;
656
+ }
657
+ let text;
658
+ try {
659
+ text = await fsp.readFile(resolved, "utf-8");
660
+ }
661
+ catch (exc) {
662
+ return `Failed to read file ${filePath}: ${exc}`;
663
+ }
664
+ this._filesRead.add(resolved);
665
+ const clipped = this._clip(text, this.maxFileChars);
666
+ const rel = path.relative(this.root, resolved).split(path.sep).join("/");
667
+ const lines = clipped.split("\n");
668
+ let numbered;
669
+ if (hashline) {
670
+ numbered = lines
671
+ .map((line, i) => `${i + 1}:${_line_hash(line)}|${line}`)
672
+ .join("\n");
673
+ }
674
+ else {
675
+ numbered = lines.map((line, i) => `${i + 1}|${line}`).join("\n");
676
+ }
677
+ return `# ${rel}\n${numbered}`;
678
+ }
679
+ async writeFile(filePath, content) {
680
+ const resolved = this._resolvePath(filePath);
681
+ let exists = false;
682
+ let isFile = false;
683
+ try {
684
+ const stat = await fsp.stat(resolved);
685
+ exists = true;
686
+ isFile = stat.isFile();
687
+ }
688
+ catch {
689
+ // does not exist
690
+ }
691
+ if (exists && isFile && !this._filesRead.has(resolved)) {
692
+ return (`BLOCKED: ${filePath} already exists but has not been read. ` +
693
+ `Use read_file('${filePath}') first, then edit via apply_patch or write_file.`);
694
+ }
695
+ try {
696
+ this._registerWriteTarget(resolved);
697
+ }
698
+ catch (exc) {
699
+ return `Blocked by policy: ${exc}`;
700
+ }
701
+ try {
702
+ await fsp.mkdir(path.dirname(resolved), { recursive: true });
703
+ await fsp.writeFile(resolved, content, "utf-8");
704
+ }
705
+ catch (exc) {
706
+ return `Failed to write ${filePath}: ${exc}`;
707
+ }
708
+ this._filesRead.add(resolved);
709
+ const rel = path.relative(this.root, resolved).split(path.sep).join("/");
710
+ return `Wrote ${content.length} chars to ${rel}`;
711
+ }
712
+ async editFile(filePath, oldText, newText) {
713
+ const resolved = this._resolvePath(filePath);
714
+ let stat;
715
+ try {
716
+ stat = await fsp.stat(resolved);
717
+ }
718
+ catch {
719
+ return `File not found: ${filePath}`;
720
+ }
721
+ if (stat.isDirectory()) {
722
+ return `Path is a directory, not a file: ${filePath}`;
723
+ }
724
+ let content;
725
+ try {
726
+ content = await fsp.readFile(resolved, "utf-8");
727
+ }
728
+ catch (exc) {
729
+ return `Failed to read file ${filePath}: ${exc}`;
730
+ }
731
+ this._filesRead.add(resolved);
732
+ if (!content.includes(oldText)) {
733
+ // Fuzzy fallback: whitespace-normalized match
734
+ const normOld = oldText.split(/\s+/).join(" ");
735
+ const oldLines = oldText.split(/\n/);
736
+ const contentLines = content.split(/\n/);
737
+ let found = false;
738
+ for (let i = 0; i <= contentLines.length - oldLines.length; i++) {
739
+ const candidate = contentLines.slice(i, i + oldLines.length).join("\n");
740
+ if (candidate.split(/\s+/).join(" ") === normOld) {
741
+ const before = contentLines.slice(0, i).join("\n");
742
+ const after = contentLines.slice(i + oldLines.length).join("\n");
743
+ content =
744
+ (before ? before + "\n" : "") +
745
+ newText +
746
+ (after ? "\n" + after : "");
747
+ found = true;
748
+ break;
749
+ }
750
+ }
751
+ if (!found) {
752
+ return `edit_file failed: old_text not found in ${filePath}`;
753
+ }
754
+ }
755
+ else {
756
+ const count = content.split(oldText).length - 1;
757
+ if (count > 1) {
758
+ return `edit_file failed: old_text appears ${count} times in ${filePath}. Provide more context to make it unique.`;
759
+ }
760
+ content = content.replace(oldText, newText);
761
+ }
762
+ try {
763
+ this._registerWriteTarget(resolved);
764
+ }
765
+ catch (exc) {
766
+ return `Blocked by policy: ${exc}`;
767
+ }
768
+ try {
769
+ await fsp.writeFile(resolved, content, "utf-8");
770
+ }
771
+ catch (exc) {
772
+ return `Failed to write ${filePath}: ${exc}`;
773
+ }
774
+ this._filesRead.add(resolved);
775
+ const rel = path.relative(this.root, resolved).split(path.sep).join("/");
776
+ return `Edited ${rel}`;
777
+ }
778
+ // ------------------------------------------------------------------
779
+ // Hashline edit
780
+ // ------------------------------------------------------------------
781
+ _validateAnchor(anchor, lineHashes, lines) {
782
+ const parts = anchor.split(":");
783
+ if (parts.length !== 2 ||
784
+ !/^\d+$/.test(parts[0]) ||
785
+ parts[1].length !== 2) {
786
+ return [-1, `Invalid anchor format: '${anchor}' (expected N:HH)`];
787
+ }
788
+ const lineno = parseInt(parts[0], 10);
789
+ const expectedHash = parts[1];
790
+ if (lineno < 1 || lineno > lines.length) {
791
+ return [
792
+ -1,
793
+ `Line ${lineno} out of range (file has ${lines.length} lines)`,
794
+ ];
795
+ }
796
+ const actualHash = lineHashes.get(lineno);
797
+ if (actualHash !== expectedHash) {
798
+ const ctxStart = Math.max(1, lineno - 2);
799
+ const ctxEnd = Math.min(lines.length, lineno + 2);
800
+ const ctxLines = [];
801
+ for (let i = ctxStart; i <= ctxEnd; i++) {
802
+ ctxLines.push(` ${i}:${lineHashes.get(i)}|${lines[i - 1]}`);
803
+ }
804
+ return [
805
+ -1,
806
+ `Hash mismatch at line ${lineno}: expected ${expectedHash}, ` +
807
+ `got ${actualHash}. Current context:\n` +
808
+ ctxLines.join("\n"),
809
+ ];
810
+ }
811
+ return [lineno, null];
812
+ }
813
+ async hashlineEdit(filePath, edits) {
814
+ const resolved = this._resolvePath(filePath);
815
+ let stat;
816
+ try {
817
+ stat = await fsp.stat(resolved);
818
+ }
819
+ catch {
820
+ return `File not found: ${filePath}`;
821
+ }
822
+ if (stat.isDirectory()) {
823
+ return `Path is a directory, not a file: ${filePath}`;
824
+ }
825
+ let content;
826
+ try {
827
+ content = await fsp.readFile(resolved, "utf-8");
828
+ }
829
+ catch (exc) {
830
+ return `Failed to read file ${filePath}: ${exc}`;
831
+ }
832
+ this._filesRead.add(resolved);
833
+ const lines = content.split("\n");
834
+ const lineHashes = new Map();
835
+ for (let i = 0; i < lines.length; i++) {
836
+ lineHashes.set(i + 1, _line_hash(lines[i]));
837
+ }
838
+ const parsed = [];
839
+ for (const edit of edits) {
840
+ if ("set_line" in edit) {
841
+ const anchor = String(edit.set_line);
842
+ const [lineno, err] = this._validateAnchor(anchor, lineHashes, lines);
843
+ if (err)
844
+ return err;
845
+ const raw = String(edit.content ?? "");
846
+ const newLine = raw.replace(_HASHLINE_PREFIX_RE, "");
847
+ parsed.push({ op: "set", start: lineno, end: lineno, newLines: [newLine] });
848
+ }
849
+ else if ("replace_lines" in edit) {
850
+ const rng = edit.replace_lines;
851
+ const startAnchor = String(rng.start ?? "");
852
+ const endAnchor = String(rng.end ?? "");
853
+ const [start, errStart] = this._validateAnchor(startAnchor, lineHashes, lines);
854
+ if (errStart)
855
+ return errStart;
856
+ const [end, errEnd] = this._validateAnchor(endAnchor, lineHashes, lines);
857
+ if (errEnd)
858
+ return errEnd;
859
+ if (end < start) {
860
+ return `End line ${end} is before start line ${start}`;
861
+ }
862
+ const rawContent = String(edit.content ?? "");
863
+ const newLines = rawContent
864
+ .split("\n")
865
+ .map((ln) => ln.replace(_HASHLINE_PREFIX_RE, ""));
866
+ parsed.push({ op: "replace", start, end, newLines });
867
+ }
868
+ else if ("insert_after" in edit) {
869
+ const anchor = String(edit.insert_after);
870
+ const [lineno, err] = this._validateAnchor(anchor, lineHashes, lines);
871
+ if (err)
872
+ return err;
873
+ const rawContent = String(edit.content ?? "");
874
+ const newLines = rawContent
875
+ .split("\n")
876
+ .map((ln) => ln.replace(_HASHLINE_PREFIX_RE, ""));
877
+ parsed.push({ op: "insert", start: lineno, end: lineno, newLines });
878
+ }
879
+ else {
880
+ return `Unknown edit operation: ${JSON.stringify(edit)}. Use set_line, replace_lines, or insert_after.`;
881
+ }
882
+ }
883
+ // Sort by line number descending so bottom-up application doesn't shift indices
884
+ parsed.sort((a, b) => b.start - a.start);
885
+ // Apply edits
886
+ let changed = 0;
887
+ for (const { op, start, end, newLines } of parsed) {
888
+ if (op === "set") {
889
+ if (lines[start - 1] !== newLines[0]) {
890
+ lines[start - 1] = newLines[0];
891
+ changed += 1;
892
+ }
893
+ }
894
+ else if (op === "replace") {
895
+ const oldSlice = lines.slice(start - 1, end);
896
+ if (oldSlice.length !== newLines.length ||
897
+ oldSlice.some((l, i) => l !== newLines[i])) {
898
+ lines.splice(start - 1, end - start + 1, ...newLines);
899
+ changed += 1;
900
+ }
901
+ }
902
+ else if (op === "insert") {
903
+ lines.splice(start, 0, ...newLines);
904
+ changed += 1;
905
+ }
906
+ }
907
+ if (changed === 0) {
908
+ return `No changes needed in ${filePath}`;
909
+ }
910
+ let newContent = lines.join("\n");
911
+ if (content.endsWith("\n")) {
912
+ newContent += "\n";
913
+ }
914
+ try {
915
+ this._registerWriteTarget(resolved);
916
+ }
917
+ catch (exc) {
918
+ return `Blocked by policy: ${exc}`;
919
+ }
920
+ try {
921
+ await fsp.writeFile(resolved, newContent, "utf-8");
922
+ }
923
+ catch (exc) {
924
+ return `Failed to write ${filePath}: ${exc}`;
925
+ }
926
+ this._filesRead.add(resolved);
927
+ const rel = path.relative(this.root, resolved).split(path.sep).join("/");
928
+ return `Edited ${rel} (${changed} edit(s) applied)`;
929
+ }
930
+ // ------------------------------------------------------------------
931
+ // Patch
932
+ // ------------------------------------------------------------------
933
+ async applyPatch(patchText) {
934
+ if (!patchText.trim()) {
935
+ return "apply_patch requires non-empty patch text";
936
+ }
937
+ let ops;
938
+ try {
939
+ ops = parse_agent_patch(patchText);
940
+ }
941
+ catch (exc) {
942
+ if (exc instanceof PatchApplyError) {
943
+ return `Patch failed: ${exc.message}`;
944
+ }
945
+ throw exc;
946
+ }
947
+ try {
948
+ for (const op of ops) {
949
+ if ("plus_lines" in op) {
950
+ // AddFileOp
951
+ this._registerWriteTarget(this._resolvePath(op.path));
952
+ }
953
+ else if ("raw_lines" in op) {
954
+ // UpdateFileOp
955
+ this._registerWriteTarget(this._resolvePath(op.path));
956
+ if (op.move_to) {
957
+ this._registerWriteTarget(this._resolvePath(op.move_to));
958
+ }
959
+ }
960
+ else {
961
+ // DeleteFileOp
962
+ this._registerWriteTarget(this._resolvePath(op.path));
963
+ }
964
+ }
965
+ }
966
+ catch (exc) {
967
+ if (exc instanceof ToolError) {
968
+ return `Blocked by policy: ${exc.message}`;
969
+ }
970
+ return `Blocked by policy: ${String(exc)}`;
971
+ }
972
+ try {
973
+ const report = apply_agent_patch(patchText, (rawPath) => this._resolvePath(rawPath));
974
+ for (const relPath of [...report.added, ...report.updated]) {
975
+ try {
976
+ this._filesRead.add(this._resolvePath(relPath));
977
+ }
978
+ catch {
979
+ // ignore
980
+ }
981
+ }
982
+ return report.render();
983
+ }
984
+ catch (exc) {
985
+ if (exc instanceof PatchApplyError) {
986
+ return `Patch failed: ${exc.message}`;
987
+ }
988
+ return `Patch failed: ${String(exc)}`;
989
+ }
990
+ }
991
+ // ------------------------------------------------------------------
992
+ // Exa API: web_search and fetch_url
993
+ // ------------------------------------------------------------------
994
+ static _EXA_MAX_RETRIES = 3;
995
+ static _EXA_BACKOFF_BASE = 1.0;
996
+ _exaDiagnoseNetwork() {
997
+ // Synchronous quick-check diagnostics
998
+ const hints = [];
999
+ for (const envVar of [
1000
+ "HTTPS_PROXY",
1001
+ "https_proxy",
1002
+ "HTTP_PROXY",
1003
+ "http_proxy",
1004
+ ]) {
1005
+ const val = process.env[envVar];
1006
+ if (val) {
1007
+ hints.push(`proxy ${envVar}=${val} is set`);
1008
+ break;
1009
+ }
1010
+ }
1011
+ return hints.length > 0
1012
+ ? hints.join("; ")
1013
+ : "no obvious network issue detected";
1014
+ }
1015
+ async _exaRequest(endpoint, payload) {
1016
+ if (!this.exaApiKey?.trim()) {
1017
+ throw new ToolError("EXA_API_KEY not configured");
1018
+ }
1019
+ const url = new URL(endpoint, this.exaBaseUrl.replace(/\/+$/, "") + "/");
1020
+ const data = JSON.stringify(payload);
1021
+ const headers = {
1022
+ "x-api-key": this.exaApiKey,
1023
+ "Content-Type": "application/json",
1024
+ "User-Agent": "exa-py 1.0.18",
1025
+ };
1026
+ let lastExc = null;
1027
+ for (let attempt = 0; attempt < WorkspaceTools._EXA_MAX_RETRIES; attempt++) {
1028
+ try {
1029
+ const raw = await this._httpPost(url.href, data, headers);
1030
+ const parsed = JSON.parse(raw);
1031
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1032
+ throw new ToolError(`Exa API returned non-object response: ${typeof parsed}`);
1033
+ }
1034
+ return parsed;
1035
+ }
1036
+ catch (exc) {
1037
+ if (exc instanceof ToolError) {
1038
+ throw exc;
1039
+ }
1040
+ if (exc instanceof HttpError &&
1041
+ exc.statusCode < 500 &&
1042
+ exc.statusCode !== 429) {
1043
+ throw new ToolError(`Exa API HTTP ${exc.statusCode}: ${exc.body}`);
1044
+ }
1045
+ lastExc = exc;
1046
+ if (attempt < WorkspaceTools._EXA_MAX_RETRIES - 1) {
1047
+ await this._sleep(WorkspaceTools._EXA_BACKOFF_BASE * 2 ** attempt);
1048
+ }
1049
+ }
1050
+ }
1051
+ const diag = this._exaDiagnoseNetwork();
1052
+ throw new ToolError(`Exa API error after ${WorkspaceTools._EXA_MAX_RETRIES} attempts: ${lastExc} (diagnostics: ${diag})`);
1053
+ }
1054
+ async webSearch(query, numResults = 10, includeText = false) {
1055
+ const trimmed = query.trim();
1056
+ if (!trimmed) {
1057
+ return "web_search requires non-empty query";
1058
+ }
1059
+ const clampedResults = Math.max(1, Math.min(Math.floor(numResults), 20));
1060
+ const payload = {
1061
+ query: trimmed,
1062
+ numResults: clampedResults,
1063
+ };
1064
+ if (includeText) {
1065
+ payload.contents = { text: { maxCharacters: 4000 } };
1066
+ }
1067
+ let parsed;
1068
+ try {
1069
+ parsed = await this._exaRequest("/search", payload);
1070
+ }
1071
+ catch (exc) {
1072
+ return `Web search failed: ${exc}`;
1073
+ }
1074
+ const outResults = [];
1075
+ const results = Array.isArray(parsed.results) ? parsed.results : [];
1076
+ for (const row of results) {
1077
+ if (typeof row !== "object" || row === null)
1078
+ continue;
1079
+ const r = row;
1080
+ const item = {
1081
+ url: String(r.url ?? ""),
1082
+ title: String(r.title ?? ""),
1083
+ snippet: String(r.highlight ?? r.snippet ?? ""),
1084
+ };
1085
+ if (includeText && typeof r.text === "string") {
1086
+ item.text = this._clip(r.text, 4000);
1087
+ }
1088
+ outResults.push(item);
1089
+ }
1090
+ const output = {
1091
+ query: trimmed,
1092
+ results: outResults,
1093
+ total: outResults.length,
1094
+ };
1095
+ return this._clip(JSON.stringify(output, null, 2), this.maxFileChars);
1096
+ }
1097
+ async fetchUrl(urls) {
1098
+ if (!Array.isArray(urls)) {
1099
+ return "fetch_url requires a list of URL strings";
1100
+ }
1101
+ const normalized = [];
1102
+ for (const raw of urls) {
1103
+ if (typeof raw !== "string")
1104
+ continue;
1105
+ const text = raw.trim();
1106
+ if (text)
1107
+ normalized.push(text);
1108
+ }
1109
+ if (normalized.length === 0) {
1110
+ return "fetch_url requires at least one valid URL";
1111
+ }
1112
+ const capped = normalized.slice(0, 10);
1113
+ const payload = {
1114
+ ids: capped,
1115
+ text: { maxCharacters: 8000 },
1116
+ };
1117
+ let parsed;
1118
+ try {
1119
+ parsed = await this._exaRequest("/contents", payload);
1120
+ }
1121
+ catch (exc) {
1122
+ return `Fetch URL failed: ${exc}`;
1123
+ }
1124
+ const pages = [];
1125
+ const results = Array.isArray(parsed.results) ? parsed.results : [];
1126
+ for (const row of results) {
1127
+ if (typeof row !== "object" || row === null)
1128
+ continue;
1129
+ const r = row;
1130
+ pages.push({
1131
+ url: String(r.url ?? ""),
1132
+ title: String(r.title ?? ""),
1133
+ text: this._clip(String(r.text ?? ""), 8000),
1134
+ });
1135
+ }
1136
+ const output = {
1137
+ pages,
1138
+ total: pages.length,
1139
+ };
1140
+ return this._clip(JSON.stringify(output, null, 2), this.maxFileChars);
1141
+ }
1142
+ // ------------------------------------------------------------------
1143
+ // Domain tools
1144
+ // ------------------------------------------------------------------
1145
+ async runDomainTool(toolName, args) {
1146
+ const script = WorkspaceTools._DOMAIN_TOOL_SCRIPTS[toolName];
1147
+ if (!script) {
1148
+ return `Unknown domain tool: ${toolName}`;
1149
+ }
1150
+ // Navigate up from this file's location to the project root, then into tools/
1151
+ const packageDir = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
1152
+ const scriptPath = path.join(packageDir, "tools", script);
1153
+ if (!fs.existsSync(scriptPath)) {
1154
+ return `Domain tool script not found: ${scriptPath}`;
1155
+ }
1156
+ const argMap = WorkspaceTools._DOMAIN_ARG_MAP[toolName] ?? {};
1157
+ const cmdParts = ["python3", scriptPath];
1158
+ for (const [paramName, cliFlag] of Object.entries(argMap)) {
1159
+ const value = args[paramName];
1160
+ if (value === undefined || value === null)
1161
+ continue;
1162
+ if (typeof value === "boolean") {
1163
+ if (value)
1164
+ cmdParts.push(cliFlag);
1165
+ }
1166
+ else if (Array.isArray(value)) {
1167
+ cmdParts.push(cliFlag);
1168
+ for (const item of value) {
1169
+ cmdParts.push(String(item));
1170
+ }
1171
+ }
1172
+ else {
1173
+ cmdParts.push(cliFlag, String(value));
1174
+ }
1175
+ }
1176
+ const timeout = Math.max(this.commandTimeoutSec, 120);
1177
+ return new Promise((resolve) => {
1178
+ let proc;
1179
+ try {
1180
+ proc = spawn(cmdParts[0], cmdParts.slice(1), {
1181
+ cwd: this.root,
1182
+ stdio: ["ignore", "pipe", "pipe"],
1183
+ detached: true,
1184
+ });
1185
+ }
1186
+ catch (exc) {
1187
+ resolve(`[${toolName}] failed to start: ${exc}`);
1188
+ return;
1189
+ }
1190
+ let stdout = "";
1191
+ let stderr = "";
1192
+ let finished = false;
1193
+ proc.stdout?.on("data", (chunk) => {
1194
+ stdout += chunk.toString("utf-8");
1195
+ });
1196
+ proc.stderr?.on("data", (chunk) => {
1197
+ stderr += chunk.toString("utf-8");
1198
+ });
1199
+ const timer = setTimeout(() => {
1200
+ if (!finished) {
1201
+ finished = true;
1202
+ try {
1203
+ if (proc.pid !== undefined) {
1204
+ process.kill(-proc.pid, "SIGKILL");
1205
+ }
1206
+ }
1207
+ catch {
1208
+ try {
1209
+ proc.kill("SIGKILL");
1210
+ }
1211
+ catch {
1212
+ // ignore
1213
+ }
1214
+ }
1215
+ resolve(`[${toolName}] timeout after ${timeout}s`);
1216
+ }
1217
+ }, timeout * 1000);
1218
+ proc.on("close", (code) => {
1219
+ if (!finished) {
1220
+ finished = true;
1221
+ clearTimeout(timer);
1222
+ let merged = "";
1223
+ if (stdout.trim())
1224
+ merged += stdout;
1225
+ if (stderr.trim())
1226
+ merged += `\n[stderr]\n${stderr}`;
1227
+ if (code !== 0) {
1228
+ merged = `[${toolName} exited with code ${code}]\n${merged}`;
1229
+ }
1230
+ resolve(this._clip(merged.trim(), this.maxShellOutputChars));
1231
+ }
1232
+ });
1233
+ proc.on("error", (err) => {
1234
+ if (!finished) {
1235
+ finished = true;
1236
+ clearTimeout(timer);
1237
+ resolve(`[${toolName}] failed to start: ${err}`);
1238
+ }
1239
+ });
1240
+ });
1241
+ }
1242
+ // ------------------------------------------------------------------
1243
+ // Internal utilities
1244
+ // ------------------------------------------------------------------
1245
+ _hasRg() {
1246
+ try {
1247
+ const result = execSync("which rg", {
1248
+ stdio: "pipe",
1249
+ timeout: 5000,
1250
+ });
1251
+ return result.toString().trim().length > 0;
1252
+ }
1253
+ catch {
1254
+ return false;
1255
+ }
1256
+ }
1257
+ _spawnCapture(cmd, cwd, timeoutSec) {
1258
+ return new Promise((resolve) => {
1259
+ let proc;
1260
+ try {
1261
+ proc = spawn(cmd[0], cmd.slice(1), {
1262
+ cwd,
1263
+ stdio: ["ignore", "pipe", "pipe"],
1264
+ detached: true,
1265
+ });
1266
+ }
1267
+ catch {
1268
+ resolve({ stdout: "", stderr: "", code: -1 });
1269
+ return;
1270
+ }
1271
+ let stdout = "";
1272
+ let stderr = "";
1273
+ let finished = false;
1274
+ proc.stdout?.on("data", (chunk) => {
1275
+ stdout += chunk.toString("utf-8");
1276
+ });
1277
+ proc.stderr?.on("data", (chunk) => {
1278
+ stderr += chunk.toString("utf-8");
1279
+ });
1280
+ const timer = setTimeout(() => {
1281
+ if (!finished) {
1282
+ finished = true;
1283
+ try {
1284
+ if (proc.pid !== undefined) {
1285
+ process.kill(-proc.pid, "SIGKILL");
1286
+ }
1287
+ }
1288
+ catch {
1289
+ try {
1290
+ proc.kill("SIGKILL");
1291
+ }
1292
+ catch {
1293
+ // ignore
1294
+ }
1295
+ }
1296
+ resolve(null);
1297
+ }
1298
+ }, timeoutSec * 1000);
1299
+ proc.on("close", (code) => {
1300
+ if (!finished) {
1301
+ finished = true;
1302
+ clearTimeout(timer);
1303
+ resolve({ stdout, stderr, code: code ?? -1 });
1304
+ }
1305
+ });
1306
+ proc.on("error", () => {
1307
+ if (!finished) {
1308
+ finished = true;
1309
+ clearTimeout(timer);
1310
+ resolve({ stdout: "", stderr: "", code: -1 });
1311
+ }
1312
+ });
1313
+ });
1314
+ }
1315
+ async _walkDir(dir, allPaths, maxEntries) {
1316
+ if (allPaths.length >= maxEntries)
1317
+ return;
1318
+ let entries;
1319
+ try {
1320
+ entries = await fsp.readdir(dir, { withFileTypes: true });
1321
+ }
1322
+ catch {
1323
+ return;
1324
+ }
1325
+ for (const entry of entries) {
1326
+ if (allPaths.length >= maxEntries)
1327
+ return;
1328
+ if (entry.name === ".git")
1329
+ continue;
1330
+ const full = path.join(dir, entry.name);
1331
+ if (entry.isDirectory()) {
1332
+ await this._walkDir(full, allPaths, maxEntries);
1333
+ }
1334
+ else if (entry.isFile()) {
1335
+ const rel = path.relative(this.root, full).split(path.sep).join("/");
1336
+ allPaths.push(rel);
1337
+ }
1338
+ }
1339
+ }
1340
+ async _repoFiles(glob, maxFiles) {
1341
+ let lines;
1342
+ if (this._hasRg()) {
1343
+ const cmd = ["rg", "--files", "--hidden", "-g", "!.git"];
1344
+ if (glob) {
1345
+ cmd.push("-g", glob);
1346
+ }
1347
+ const result = await this._spawnCapture(cmd, this.root, this.commandTimeoutSec);
1348
+ if (!result)
1349
+ return [];
1350
+ lines = result.stdout.split("\n").filter((ln) => ln.trim() !== "");
1351
+ }
1352
+ else {
1353
+ const allPaths = [];
1354
+ await this._walkDir(this.root, allPaths, _MAX_WALK_ENTRIES);
1355
+ lines = allPaths;
1356
+ if (glob) {
1357
+ const globRe = this._globToRegex(glob);
1358
+ lines = lines.filter((l) => globRe.test(l));
1359
+ }
1360
+ }
1361
+ return lines.slice(0, maxFiles);
1362
+ }
1363
+ _globToRegex(glob) {
1364
+ // Simple glob-to-regex: * -> [^/]*, ** -> .*, ? -> .
1365
+ let pattern = glob
1366
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
1367
+ .replace(/\*\*/g, "\0")
1368
+ .replace(/\*/g, "[^/]*")
1369
+ .replace(/\0/g, ".*")
1370
+ .replace(/\?/g, ".");
1371
+ return new RegExp(`^${pattern}$`);
1372
+ }
1373
+ _genericSymbols(text) {
1374
+ const patterns = [
1375
+ [
1376
+ /^\s*function\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/gm,
1377
+ "function",
1378
+ ],
1379
+ [/^\s*class\s+([A-Za-z_][A-Za-z0-9_]*)\b/gm, "class"],
1380
+ [
1381
+ /^\s*(?:const|let|var)\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*\(/gm,
1382
+ "function",
1383
+ ],
1384
+ ];
1385
+ const symbols = [];
1386
+ for (const [regex, kind] of patterns) {
1387
+ regex.lastIndex = 0;
1388
+ let match;
1389
+ while ((match = regex.exec(text)) !== null) {
1390
+ const line = text.slice(0, match.index).split("\n").length;
1391
+ symbols.push({ kind, name: match[1], line });
1392
+ }
1393
+ }
1394
+ symbols.sort((a, b) => a.line - b.line);
1395
+ return symbols;
1396
+ }
1397
+ _sleep(seconds) {
1398
+ return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
1399
+ }
1400
+ _httpPost(url, body, headers) {
1401
+ return new Promise((resolve, reject) => {
1402
+ const parsed = new URL(url);
1403
+ const isHttps = parsed.protocol === "https:";
1404
+ const lib = isHttps ? https : http;
1405
+ const options = {
1406
+ method: "POST",
1407
+ hostname: parsed.hostname,
1408
+ port: parsed.port || (isHttps ? 443 : 80),
1409
+ path: parsed.pathname + parsed.search,
1410
+ headers: {
1411
+ ...headers,
1412
+ "Content-Length": Buffer.byteLength(body, "utf-8").toString(),
1413
+ },
1414
+ timeout: this.commandTimeoutSec * 1000,
1415
+ };
1416
+ const req = lib.request(options, (res) => {
1417
+ let data = "";
1418
+ res.setEncoding("utf-8");
1419
+ res.on("data", (chunk) => {
1420
+ data += chunk;
1421
+ });
1422
+ res.on("end", () => {
1423
+ const statusCode = res.statusCode ?? 0;
1424
+ if (statusCode >= 200 && statusCode < 300) {
1425
+ resolve(data);
1426
+ }
1427
+ else {
1428
+ reject(new HttpError(statusCode, data));
1429
+ }
1430
+ });
1431
+ });
1432
+ req.on("error", reject);
1433
+ req.on("timeout", () => {
1434
+ req.destroy(new Error("Request timeout"));
1435
+ });
1436
+ req.write(body);
1437
+ req.end();
1438
+ });
1439
+ }
1440
+ }
1441
+ // ---------------------------------------------------------------------------
1442
+ // Internal HTTP error for retry logic
1443
+ // ---------------------------------------------------------------------------
1444
+ class HttpError extends Error {
1445
+ statusCode;
1446
+ body;
1447
+ constructor(statusCode, body) {
1448
+ super(`HTTP ${statusCode}`);
1449
+ this.name = "HttpError";
1450
+ this.statusCode = statusCode;
1451
+ this.body = body;
1452
+ }
1453
+ }
1454
+ //# sourceMappingURL=tools.js.map