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.
- package/bin/wispy.mjs +225 -0
- package/core/deploy.mjs +292 -0
- package/core/engine.mjs +115 -58
- package/core/harness.mjs +531 -0
- package/core/index.mjs +2 -0
- package/core/providers.mjs +53 -12
- package/core/tools.mjs +24 -4
- package/lib/channels/index.mjs +229 -4
- package/lib/wispy-repl.mjs +3 -0
- package/lib/wispy-tui.mjs +430 -30
- package/package.json +2 -2
package/core/harness.mjs
ADDED
|
@@ -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";
|
package/core/providers.mjs
CHANGED
|
@@ -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
|
|
368
|
-
const
|
|
369
|
-
|
|
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
|
-
|
|
372
|
-
|
|
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.
|
|
375
|
-
args: JSON.parse(tc.
|
|
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
|
-
|
|
382
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}` };
|