wispy-cli 1.1.1 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,531 @@
1
+ /**
2
+ * core/harness.mjs — Execution Harness for Wispy
3
+ *
4
+ * Mediates ALL tool execution with:
5
+ * - Permission checks (auto/notify/approve)
6
+ * - Dry-run simulation
7
+ * - Pre/post snapshots + unified diffs
8
+ * - Execution receipts
9
+ * - Audit logging
10
+ * - Event emission for TUI/channel approval UX
11
+ *
12
+ * v1.2.0
13
+ */
14
+
15
+ import { EventEmitter } from "node:events";
16
+ import { readFile } from "node:fs/promises";
17
+ import path from "node:path";
18
+ import os from "node:os";
19
+
20
+ import { EVENT_TYPES } from "./audit.mjs";
21
+
22
+ // ── Receipt ────────────────────────────────────────────────────────────────────
23
+
24
+ export class Receipt {
25
+ constructor() {
26
+ this.id = generateId();
27
+ this.timestamp = new Date().toISOString();
28
+ this.sessionId = null;
29
+ this.toolName = null;
30
+ this.args = {};
31
+ this.permissionLevel = "auto";
32
+ this.approved = null; // null = not needed, true/false = approval result
33
+ this.dryRun = false;
34
+ this.duration = 0;
35
+ this.success = false;
36
+ this.result = null;
37
+ this.diff = null; // { before, after, unified } for file ops
38
+ this.error = null;
39
+ }
40
+
41
+ toMarkdown() {
42
+ const icon = this.success ? "✅" : "❌";
43
+ const dryTag = this.dryRun ? " [DRY RUN]" : "";
44
+ const lines = [
45
+ `${icon} **${this.toolName}**${dryTag}`,
46
+ `- ID: \`${this.id}\``,
47
+ `- Time: ${new Date(this.timestamp).toLocaleTimeString()}`,
48
+ `- Duration: ${this.duration}ms`,
49
+ `- Permission: ${this.permissionLevel}`,
50
+ ];
51
+
52
+ if (this.approved !== null) {
53
+ lines.push(`- Approved: ${this.approved ? "✅ yes" : "❌ no"}`);
54
+ }
55
+
56
+ if (this.error) {
57
+ lines.push(`- Error: ${this.error}`);
58
+ }
59
+
60
+ if (this.diff?.unified) {
61
+ const stats = diffStats(this.diff.unified);
62
+ lines.push(`- Changes: +${stats.added} lines, -${stats.removed} lines`);
63
+ lines.push("", "```diff", this.diff.unified.slice(0, 2000), "```");
64
+ }
65
+
66
+ return lines.join("\n");
67
+ }
68
+
69
+ toJSON() {
70
+ return {
71
+ id: this.id,
72
+ timestamp: this.timestamp,
73
+ sessionId: this.sessionId,
74
+ toolName: this.toolName,
75
+ args: this.args,
76
+ permissionLevel: this.permissionLevel,
77
+ approved: this.approved,
78
+ dryRun: this.dryRun,
79
+ duration: this.duration,
80
+ success: this.success,
81
+ result: this.result,
82
+ diff: this.diff,
83
+ error: this.error,
84
+ };
85
+ }
86
+ }
87
+
88
+ // ── HarnessResult ──────────────────────────────────────────────────────────────
89
+
90
+ export class HarnessResult {
91
+ constructor({ result, receipt, denied = false, dryRun = false }) {
92
+ this.result = result;
93
+ this.receipt = receipt;
94
+ this.denied = denied;
95
+ this.dryRun = dryRun;
96
+ this.success = receipt?.success ?? (!denied);
97
+ }
98
+ }
99
+
100
+ // ── Sandbox modes ─────────────────────────────────────────────────────────────
101
+
102
+ // Which tools get file snapshots (pre/post diff)
103
+ const FILE_SNAPSHOT_TOOLS = new Set(["write_file", "file_edit"]);
104
+
105
+ // File-path arg for each tool (to know what to snapshot)
106
+ function getFilePath(toolName, args) {
107
+ if (toolName === "write_file" || toolName === "file_edit" || toolName === "read_file") {
108
+ return args.path;
109
+ }
110
+ return null;
111
+ }
112
+
113
+ // Resolve a file path the same way tools.mjs does
114
+ function resolvePath(p) {
115
+ if (!p) return null;
116
+ let resolved = p.replace(/^~/, os.homedir());
117
+ if (!path.isAbsolute(resolved)) resolved = path.resolve(process.cwd(), resolved);
118
+ return resolved;
119
+ }
120
+
121
+ // ── Unified diff (no external deps) ──────────────────────────────────────────
122
+
123
+ /**
124
+ * Compute a simple unified diff between two strings.
125
+ * Returns unified diff string.
126
+ */
127
+ export function computeUnifiedDiff(before, after, filePath = "file") {
128
+ if (before === after) return "";
129
+
130
+ const beforeLines = before ? before.split("\n") : [];
131
+ const afterLines = after ? after.split("\n") : [];
132
+
133
+ // LCS-based diff — simple Myers-like algorithm
134
+ const hunks = computeHunks(beforeLines, afterLines, 3);
135
+
136
+ if (hunks.length === 0) return "";
137
+
138
+ const lines = [
139
+ `--- a/${filePath}`,
140
+ `+++ b/${filePath}`,
141
+ ];
142
+
143
+ for (const hunk of hunks) {
144
+ lines.push(hunk.header);
145
+ lines.push(...hunk.lines);
146
+ }
147
+
148
+ return lines.join("\n");
149
+ }
150
+
151
+ function computeHunks(oldLines, newLines, context = 3) {
152
+ // Build edit script using simple LCS
153
+ const edits = shortestEditScript(oldLines, newLines);
154
+
155
+ if (edits.length === 0) return [];
156
+
157
+ // Group edits into hunks with context
158
+ const hunks = [];
159
+ let i = 0;
160
+
161
+ while (i < edits.length) {
162
+ if (edits[i].type === "equal") { i++; continue; }
163
+
164
+ // Found a change — build a hunk
165
+ const hunkStart = i;
166
+ const hunkEdits = [edits[i]];
167
+ i++;
168
+
169
+ // Extend hunk while changes are within 2*context of each other
170
+ while (i < edits.length) {
171
+ if (edits[i].type !== "equal") {
172
+ hunkEdits.push(edits[i]);
173
+ i++;
174
+ } else {
175
+ // Count consecutive equal lines
176
+ let equalCount = 0;
177
+ let j = i;
178
+ while (j < edits.length && edits[j].type === "equal") { equalCount++; j++; }
179
+ if (equalCount <= 2 * context && j < edits.length && edits[j].type !== "equal") {
180
+ // Merge into current hunk
181
+ hunkEdits.push(...edits.slice(i, j));
182
+ i = j;
183
+ } else {
184
+ break;
185
+ }
186
+ }
187
+ }
188
+
189
+ // Compute old/new line ranges
190
+ let oldStart = null; let oldCount = 0;
191
+ let newStart = null; let newCount = 0;
192
+ const hunkLines = [];
193
+
194
+ // Add leading context
195
+ const firstEdit = hunkEdits[0];
196
+ const ctxStart = Math.max(0, firstEdit.oldIdx - context);
197
+ oldStart = ctxStart + 1; // 1-indexed
198
+ newStart = firstEdit.newIdx - (firstEdit.oldIdx - ctxStart) + 1; // 1-indexed
199
+ for (let k = ctxStart; k < firstEdit.oldIdx; k++) {
200
+ hunkLines.push(` ${oldLines[k]}`);
201
+ oldCount++; newCount++;
202
+ }
203
+
204
+ // Add the hunk edits
205
+ for (const edit of hunkEdits) {
206
+ if (edit.type === "equal") {
207
+ hunkLines.push(` ${oldLines[edit.oldIdx]}`);
208
+ oldCount++; newCount++;
209
+ } else if (edit.type === "delete") {
210
+ hunkLines.push(`-${oldLines[edit.oldIdx]}`);
211
+ oldCount++;
212
+ } else if (edit.type === "insert") {
213
+ hunkLines.push(`+${newLines[edit.newIdx]}`);
214
+ newCount++;
215
+ }
216
+ }
217
+
218
+ // Add trailing context
219
+ const lastEdit = hunkEdits[hunkEdits.length - 1];
220
+ const lastOldIdx = lastEdit.type === "insert" ? lastEdit.oldIdx : lastEdit.oldIdx + 1;
221
+ const ctxEnd = Math.min(oldLines.length, lastOldIdx + context);
222
+ for (let k = lastOldIdx; k < ctxEnd; k++) {
223
+ hunkLines.push(` ${oldLines[k]}`);
224
+ oldCount++; newCount++;
225
+ }
226
+
227
+ hunks.push({
228
+ header: `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`,
229
+ lines: hunkLines,
230
+ });
231
+ }
232
+
233
+ return hunks;
234
+ }
235
+
236
+ function shortestEditScript(oldLines, newLines) {
237
+ // Simple O(nd) diff using dynamic programming LCS
238
+ const m = oldLines.length;
239
+ const n = newLines.length;
240
+
241
+ // Build LCS table
242
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
243
+ for (let i = m - 1; i >= 0; i--) {
244
+ for (let j = n - 1; j >= 0; j--) {
245
+ if (oldLines[i] === newLines[j]) {
246
+ dp[i][j] = dp[i + 1][j + 1] + 1;
247
+ } else {
248
+ dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]);
249
+ }
250
+ }
251
+ }
252
+
253
+ // Trace back
254
+ const edits = [];
255
+ let i = 0; let j = 0;
256
+ while (i < m || j < n) {
257
+ if (i < m && j < n && oldLines[i] === newLines[j]) {
258
+ edits.push({ type: "equal", oldIdx: i, newIdx: j });
259
+ i++; j++;
260
+ } else if (j < n && (i >= m || dp[i][j + 1] >= dp[i + 1][j])) {
261
+ edits.push({ type: "insert", oldIdx: i, newIdx: j });
262
+ j++;
263
+ } else {
264
+ edits.push({ type: "delete", oldIdx: i, newIdx: j });
265
+ i++;
266
+ }
267
+ }
268
+
269
+ return edits;
270
+ }
271
+
272
+ function diffStats(unifiedDiff) {
273
+ let added = 0; let removed = 0;
274
+ for (const line of unifiedDiff.split("\n")) {
275
+ if (line.startsWith("+") && !line.startsWith("+++")) added++;
276
+ else if (line.startsWith("-") && !line.startsWith("---")) removed++;
277
+ }
278
+ return { added, removed };
279
+ }
280
+
281
+ // ── ID generation (no uuid dep needed) ───────────────────────────────────────
282
+
283
+ function generateId() {
284
+ const ts = Date.now().toString(36);
285
+ const rand = Math.random().toString(36).slice(2, 8);
286
+ return `rcpt-${ts}-${rand}`;
287
+ }
288
+
289
+ // ── Dry-run simulation ────────────────────────────────────────────────────────
290
+
291
+ function simulateDryRun(toolName, args) {
292
+ switch (toolName) {
293
+ case "write_file":
294
+ return {
295
+ success: true,
296
+ dryRun: true,
297
+ preview: `Would write ${(args.content ?? "").length} chars to: ${args.path}`,
298
+ content: args.content,
299
+ path: args.path,
300
+ };
301
+
302
+ case "file_edit":
303
+ return {
304
+ success: true,
305
+ dryRun: true,
306
+ preview: `Would replace text in: ${args.path}`,
307
+ old_text: args.old_text,
308
+ new_text: args.new_text,
309
+ path: args.path,
310
+ };
311
+
312
+ case "run_command":
313
+ return {
314
+ success: true,
315
+ dryRun: true,
316
+ preview: `Would execute: ${args.command}`,
317
+ command: args.command,
318
+ };
319
+
320
+ case "git":
321
+ return {
322
+ success: true,
323
+ dryRun: true,
324
+ preview: `Would run: git ${args.command}`,
325
+ command: args.command,
326
+ };
327
+
328
+ default:
329
+ return {
330
+ success: true,
331
+ dryRun: true,
332
+ preview: `Would call ${toolName} with ${JSON.stringify(args).slice(0, 100)}`,
333
+ };
334
+ }
335
+ }
336
+
337
+ // ── Harness class ──────────────────────────────────────────────────────────────
338
+
339
+ export class Harness extends EventEmitter {
340
+ /**
341
+ * @param {import('./tools.mjs').ToolRegistry} toolRegistry
342
+ * @param {import('./permissions.mjs').PermissionManager} permissions
343
+ * @param {import('./audit.mjs').AuditLog} audit
344
+ * @param {object} config
345
+ */
346
+ constructor(toolRegistry, permissions, audit, config = {}) {
347
+ super();
348
+ this.tools = toolRegistry;
349
+ this.permissions = permissions;
350
+ this.audit = audit;
351
+ this.config = config;
352
+
353
+ // Sandbox config per-tool: "preview" | "diff" | null
354
+ this._sandboxModes = {
355
+ run_command: "preview",
356
+ write_file: "diff",
357
+ file_edit: "diff",
358
+ git: "preview",
359
+ };
360
+ }
361
+
362
+ /**
363
+ * Main entry point — replaces direct tool.execute() calls.
364
+ *
365
+ * @param {string} toolName
366
+ * @param {object} args
367
+ * @param {object} context - { sessionId, userId, channel, dryRun?, executeToolFn }
368
+ * @returns {HarnessResult}
369
+ */
370
+ async execute(toolName, args, context = {}) {
371
+ const receipt = new Receipt();
372
+ receipt.toolName = toolName;
373
+ receipt.args = args;
374
+ receipt.sessionId = context.sessionId ?? null;
375
+ receipt.dryRun = context.dryRun ?? false;
376
+
377
+ const callStart = Date.now();
378
+
379
+ // ── 1. Permission check ──────────────────────────────────────────────────
380
+ const permResult = await this.permissions.check(toolName, args, context);
381
+ receipt.permissionLevel = permResult.level ?? "auto";
382
+
383
+ if (!permResult.allowed) {
384
+ receipt.approved = false;
385
+ receipt.success = false;
386
+ receipt.error = permResult.reason ?? "Permission denied";
387
+ receipt.duration = Date.now() - callStart;
388
+
389
+ this.audit.log({
390
+ type: EVENT_TYPES.APPROVAL_DENIED,
391
+ sessionId: context.sessionId,
392
+ tool: toolName,
393
+ args,
394
+ }).catch(() => {});
395
+
396
+ this.emit("tool:denied", { toolName, args, receipt, context });
397
+
398
+ return new HarnessResult({ result: { success: false, error: receipt.error, denied: true }, receipt, denied: true });
399
+ }
400
+
401
+ if (permResult.needsApproval) {
402
+ receipt.approved = permResult.approved;
403
+ }
404
+
405
+ // ── 2. Dry-run mode ──────────────────────────────────────────────────────
406
+ if (receipt.dryRun) {
407
+ const preview = simulateDryRun(toolName, args);
408
+
409
+ // For file edits in dry-run, compute the diff
410
+ if (FILE_SNAPSHOT_TOOLS.has(toolName)) {
411
+ const filePath = getFilePath(toolName, args);
412
+ if (filePath) {
413
+ const resolved = resolvePath(filePath);
414
+ let before = "";
415
+ try { before = await readFile(resolved, "utf8"); } catch {}
416
+
417
+ let after = before;
418
+ if (toolName === "write_file") {
419
+ after = args.content ?? "";
420
+ } else if (toolName === "file_edit") {
421
+ after = before.replace(args.old_text ?? "", args.new_text ?? "");
422
+ }
423
+
424
+ const unified = computeUnifiedDiff(before, after, filePath);
425
+ receipt.diff = { before, after, unified };
426
+ preview.diff = receipt.diff;
427
+ }
428
+ }
429
+
430
+ receipt.success = true;
431
+ receipt.result = preview;
432
+ receipt.duration = Date.now() - callStart;
433
+
434
+ this.emit("tool:dryrun", { toolName, args, preview, receipt, context });
435
+
436
+ return new HarnessResult({ result: preview, receipt, dryRun: true });
437
+ }
438
+
439
+ // ── 3. Pre-snapshot ──────────────────────────────────────────────────────
440
+ let beforeContent = null;
441
+ if (FILE_SNAPSHOT_TOOLS.has(toolName)) {
442
+ const filePath = getFilePath(toolName, args);
443
+ if (filePath) {
444
+ const resolved = resolvePath(filePath);
445
+ try { beforeContent = await readFile(resolved, "utf8"); } catch { beforeContent = ""; }
446
+ }
447
+ }
448
+
449
+ // ── 4. Emit tool:start ───────────────────────────────────────────────────
450
+ this.emit("tool:start", { toolName, args, context });
451
+
452
+ this.audit.log({
453
+ type: EVENT_TYPES.TOOL_CALL,
454
+ sessionId: context.sessionId,
455
+ tool: toolName,
456
+ args,
457
+ permissionLevel: receipt.permissionLevel,
458
+ }).catch(() => {});
459
+
460
+ // ── 5. Execute ───────────────────────────────────────────────────────────
461
+ let result;
462
+ try {
463
+ if (context.executeToolFn) {
464
+ result = await context.executeToolFn(toolName, args);
465
+ } else {
466
+ result = await this.tools.execute(toolName, args);
467
+ }
468
+ receipt.success = result?.success !== false;
469
+ receipt.result = result;
470
+ } catch (err) {
471
+ receipt.success = false;
472
+ receipt.error = err.message;
473
+ receipt.duration = Date.now() - callStart;
474
+
475
+ this.audit.log({
476
+ type: EVENT_TYPES.ERROR,
477
+ sessionId: context.sessionId,
478
+ tool: toolName,
479
+ message: err.message,
480
+ duration: receipt.duration,
481
+ }).catch(() => {});
482
+
483
+ this.emit("tool:error", { toolName, args, error: err, receipt, context });
484
+
485
+ return new HarnessResult({ result: { success: false, error: err.message }, receipt });
486
+ }
487
+
488
+ // ── 6. Post-snapshot ─────────────────────────────────────────────────────
489
+ if (FILE_SNAPSHOT_TOOLS.has(toolName) && receipt.success) {
490
+ const filePath = getFilePath(toolName, args);
491
+ if (filePath) {
492
+ const resolved = resolvePath(filePath);
493
+ let afterContent = "";
494
+ try { afterContent = await readFile(resolved, "utf8"); } catch {}
495
+
496
+ const unified = computeUnifiedDiff(beforeContent ?? "", afterContent, filePath);
497
+ receipt.diff = { before: beforeContent ?? "", after: afterContent, unified };
498
+ }
499
+ }
500
+
501
+ // ── 7. Duration ──────────────────────────────────────────────────────────
502
+ receipt.duration = Date.now() - callStart;
503
+
504
+ // ── 8. Audit ─────────────────────────────────────────────────────────────
505
+ this.audit.log({
506
+ type: EVENT_TYPES.TOOL_RESULT,
507
+ sessionId: context.sessionId,
508
+ tool: toolName,
509
+ result: JSON.stringify(result).slice(0, 500),
510
+ duration: receipt.duration,
511
+ }).catch(() => {});
512
+
513
+ // ── 9. Emit tool:complete ────────────────────────────────────────────────
514
+ this.emit("tool:complete", { toolName, args, result, receipt, context });
515
+
516
+ return new HarnessResult({ result, receipt });
517
+ }
518
+
519
+ /**
520
+ * Set sandbox mode for a tool.
521
+ * @param {string} toolName
522
+ * @param {"preview"|"diff"|null} mode
523
+ */
524
+ setSandboxMode(toolName, mode) {
525
+ this._sandboxModes[toolName] = mode;
526
+ }
527
+
528
+ getSandboxMode(toolName) {
529
+ return this._sandboxModes[toolName] ?? null;
530
+ }
531
+ }
package/core/index.mjs CHANGED
@@ -17,3 +17,5 @@ export { PermissionManager, DEFAULT_POLICIES, BUILT_IN_SCOPES } from "./permissi
17
17
  export { AuditLog, EVENT_TYPES, getAuditLog } from "./audit.mjs";
18
18
  export { WispyServer } from "./server.mjs";
19
19
  export { NodeManager, CAPABILITIES } from "./nodes.mjs";
20
+ export { Harness, Receipt, HarnessResult, computeUnifiedDiff } from "./harness.mjs";
21
+ export { DeployManager } from "./deploy.mjs";
@@ -345,7 +345,7 @@ export class ProviderRegistry {
345
345
  if (this._provider === "openrouter") headers["HTTP-Referer"] = "https://wispy.dev";
346
346
 
347
347
  const supportsTools = !["ollama"].includes(this._provider);
348
- const body = { model, messages: openaiMessages, temperature: 0.7, max_tokens: 4096 };
348
+ const body = { model, messages: openaiMessages, temperature: 0.7, max_tokens: 4096, stream: true };
349
349
  if (supportsTools && tools.length > 0) {
350
350
  body.tools = tools.map(t => ({
351
351
  type: "function",
@@ -364,24 +364,65 @@ export class ProviderRegistry {
364
364
  throw new Error(`OpenAI API error ${response.status}: ${err.slice(0, 300)}`);
365
365
  }
366
366
 
367
- const data = await response.json();
368
- const choice = data.choices?.[0];
369
- if (!choice) throw new Error("No response from OpenAI");
367
+ const reader = response.body.getReader();
368
+ const decoder = new TextDecoder();
369
+ let buffer = "";
370
+ let fullText = "";
371
+ const toolCallsMap = {};
372
+
373
+ while (true) {
374
+ const { done, value } = await reader.read();
375
+ if (done) break;
376
+
377
+ buffer += decoder.decode(value, { stream: true });
378
+ const lines = buffer.split("\n");
379
+ buffer = lines.pop() ?? "";
380
+
381
+ for (const line of lines) {
382
+ if (!line.startsWith("data: ")) continue;
383
+ const data = line.slice(6).trim();
384
+ if (!data || data === "[DONE]") continue;
385
+
386
+ try {
387
+ const event = JSON.parse(data);
388
+ const delta = event.choices?.[0]?.delta;
389
+ if (!delta) continue;
390
+
391
+ // Text content
392
+ if (delta.content) {
393
+ fullText += delta.content;
394
+ opts.onChunk?.(delta.content);
395
+ }
396
+
397
+ // Tool calls (streamed as incremental chunks)
398
+ if (delta.tool_calls) {
399
+ for (const tc of delta.tool_calls) {
400
+ const idx = tc.index ?? 0;
401
+ if (!toolCallsMap[idx]) {
402
+ toolCallsMap[idx] = { id: tc.id ?? `call_${idx}`, name: "", arguments: "" };
403
+ }
404
+ if (tc.id) toolCallsMap[idx].id = tc.id;
405
+ if (tc.function?.name) toolCallsMap[idx].name += tc.function.name;
406
+ if (tc.function?.arguments) toolCallsMap[idx].arguments += tc.function.arguments;
407
+ }
408
+ }
409
+ } catch { /* skip malformed chunks */ }
410
+ }
411
+ }
370
412
 
371
- if (choice.message?.tool_calls?.length > 0) {
372
- const calls = choice.message.tool_calls.map(tc => ({
413
+ const toolCalls = Object.values(toolCallsMap);
414
+ if (toolCalls.length > 0) {
415
+ const calls = toolCalls.map(tc => ({
373
416
  id: tc.id,
374
- name: tc.function.name,
375
- args: JSON.parse(tc.function.arguments),
417
+ name: tc.name,
418
+ args: (() => { try { return JSON.parse(tc.arguments); } catch { return {}; } })(),
376
419
  }));
377
420
  this._sessionTokens.output += this._estimateTokens(JSON.stringify(calls));
378
421
  return { type: "tool_calls", calls };
379
422
  }
380
423
 
381
- const text = choice.message?.content ?? "";
382
- this._sessionTokens.output += this._estimateTokens(text);
383
- opts.onChunk?.(text);
384
- return { type: "text", text };
424
+ this._sessionTokens.output += this._estimateTokens(fullText);
425
+ return { type: "text", text: fullText };
385
426
  }
386
427
 
387
428
  formatCost() {
package/core/tools.mjs CHANGED
@@ -337,10 +337,18 @@ export class ToolRegistry {
337
337
  try {
338
338
  const serverUrl = `http://127.0.0.1:${DEFAULT_SERVER_PORT}`;
339
339
  if (name === "read_file") {
340
+ // Resolve path relative to process.cwd() before sending to server
341
+ let resolvedPath = args.path.replace(/^~/, os.homedir());
342
+ if (!path.isAbsolute(resolvedPath)) {
343
+ resolvedPath = path.resolve(process.cwd(), resolvedPath);
344
+ }
345
+ if (process.env.WISPY_DEBUG) {
346
+ process.stderr.write(`[wispy] read_file via server: "${args.path}" → "${resolvedPath}"\n`);
347
+ }
340
348
  const resp = await fetch(`${serverUrl}/api/node-filesystem-actions`, {
341
349
  method: "POST",
342
350
  headers: { "Content-Type": "application/json" },
343
- body: JSON.stringify({ subAction: "read_file", path: args.path }),
351
+ body: JSON.stringify({ subAction: "read_file", path: resolvedPath }),
344
352
  signal: AbortSignal.timeout(10_000),
345
353
  });
346
354
  const data = await resp.json();
@@ -386,7 +394,13 @@ export class ToolRegistry {
386
394
  try {
387
395
  switch (name) {
388
396
  case "read_file": {
389
- const filePath = args.path.replace(/^~/, os.homedir());
397
+ let filePath = args.path.replace(/^~/, os.homedir());
398
+ if (!path.isAbsolute(filePath)) {
399
+ filePath = path.resolve(process.cwd(), filePath);
400
+ }
401
+ if (process.env.WISPY_DEBUG) {
402
+ process.stderr.write(`[wispy] read_file: "${args.path}" → "${filePath}"\n`);
403
+ }
390
404
  const content = await readFile(filePath, "utf8");
391
405
  const truncated = content.length > 10_000
392
406
  ? content.slice(0, 10_000) + `\n\n... (truncated, ${content.length} chars total)`
@@ -395,7 +409,10 @@ export class ToolRegistry {
395
409
  }
396
410
 
397
411
  case "write_file": {
398
- const filePath = args.path.replace(/^~/, os.homedir());
412
+ let filePath = args.path.replace(/^~/, os.homedir());
413
+ if (!path.isAbsolute(filePath)) {
414
+ filePath = path.resolve(process.cwd(), filePath);
415
+ }
399
416
  const dir = path.dirname(filePath);
400
417
  await mkdir(dir, { recursive: true });
401
418
  await writeFile(filePath, args.content, "utf8");
@@ -453,7 +470,10 @@ export class ToolRegistry {
453
470
  }
454
471
 
455
472
  case "file_edit": {
456
- const filePath = args.path.replace(/^~/, os.homedir());
473
+ let filePath = args.path.replace(/^~/, os.homedir());
474
+ if (!path.isAbsolute(filePath)) {
475
+ filePath = path.resolve(process.cwd(), filePath);
476
+ }
457
477
  const content = await readFile(filePath, "utf8");
458
478
  if (!content.includes(args.old_text)) {
459
479
  return { success: false, error: `Text not found in ${filePath}` };