skyloom 1.16.2 → 1.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -3
- package/dist/cli/loom_chat.d.ts.map +1 -1
- package/dist/cli/loom_chat.js +17 -0
- package/dist/cli/loom_chat.js.map +1 -1
- package/dist/cli/main.js +37 -1
- package/dist/cli/main.js.map +1 -1
- package/dist/core/agent.d.ts +2 -0
- package/dist/core/agent.d.ts.map +1 -1
- package/dist/core/agent.js +21 -5
- package/dist/core/agent.js.map +1 -1
- package/dist/core/bgproc.d.ts +59 -0
- package/dist/core/bgproc.d.ts.map +1 -0
- package/dist/core/bgproc.js +135 -0
- package/dist/core/bgproc.js.map +1 -0
- package/dist/core/commands.d.ts.map +1 -1
- package/dist/core/commands.js +20 -0
- package/dist/core/commands.js.map +1 -1
- package/dist/core/diagnostics.d.ts +39 -0
- package/dist/core/diagnostics.d.ts.map +1 -0
- package/dist/core/diagnostics.js +206 -0
- package/dist/core/diagnostics.js.map +1 -0
- package/dist/core/diff.d.ts +31 -0
- package/dist/core/diff.d.ts.map +1 -0
- package/dist/core/diff.js +82 -0
- package/dist/core/diff.js.map +1 -0
- package/dist/core/envcontext.d.ts +25 -0
- package/dist/core/envcontext.d.ts.map +1 -0
- package/dist/core/envcontext.js +112 -0
- package/dist/core/envcontext.js.map +1 -0
- package/dist/core/factory.d.ts +2 -0
- package/dist/core/factory.d.ts.map +1 -1
- package/dist/core/factory.js +35 -2
- package/dist/core/factory.js.map +1 -1
- package/dist/core/patch.d.ts +59 -0
- package/dist/core/patch.d.ts.map +1 -0
- package/dist/core/patch.js +220 -0
- package/dist/core/patch.js.map +1 -0
- package/dist/core/protocol.d.ts +11 -0
- package/dist/core/protocol.d.ts.map +1 -0
- package/dist/core/protocol.js +39 -0
- package/dist/core/protocol.js.map +1 -0
- package/dist/core/sandbox.d.ts +1 -0
- package/dist/core/sandbox.d.ts.map +1 -1
- package/dist/core/sandbox.js +1 -0
- package/dist/core/sandbox.js.map +1 -1
- package/dist/core/search.d.ts +41 -0
- package/dist/core/search.d.ts.map +1 -0
- package/dist/core/search.js +156 -0
- package/dist/core/search.js.map +1 -0
- package/dist/core/security.d.ts +22 -2
- package/dist/core/security.d.ts.map +1 -1
- package/dist/core/security.js +55 -24
- package/dist/core/security.js.map +1 -1
- package/dist/core/skill.d.ts +4 -0
- package/dist/core/skill.d.ts.map +1 -1
- package/dist/core/skill.js +1 -0
- package/dist/core/skill.js.map +1 -1
- package/dist/core/subagent.d.ts +75 -0
- package/dist/core/subagent.d.ts.map +1 -0
- package/dist/core/subagent.js +287 -0
- package/dist/core/subagent.js.map +1 -0
- package/dist/core/tool.d.ts +23 -1
- package/dist/core/tool.d.ts.map +1 -1
- package/dist/core/tool.js +95 -30
- package/dist/core/tool.js.map +1 -1
- package/dist/plugins/loader.d.ts +49 -8
- package/dist/plugins/loader.d.ts.map +1 -1
- package/dist/plugins/loader.js +129 -16
- package/dist/plugins/loader.js.map +1 -1
- package/dist/tools/builtin.d.ts.map +1 -1
- package/dist/tools/builtin.js +183 -17
- package/dist/tools/builtin.js.map +1 -1
- package/dist/tools/spawn.d.ts +23 -0
- package/dist/tools/spawn.d.ts.map +1 -0
- package/dist/tools/spawn.js +77 -0
- package/dist/tools/spawn.js.map +1 -0
- package/docs/OPTIMIZATION_PLAN.md +21 -4
- package/package.json +1 -1
- package/src/cli/loom_chat.ts +11 -0
- package/src/cli/main.ts +31 -1
- package/src/core/agent.ts +20 -5
- package/src/core/bgproc.ts +153 -0
- package/src/core/commands.ts +20 -0
- package/src/core/diagnostics.ts +178 -0
- package/src/core/diff.ts +98 -0
- package/src/core/envcontext.ts +79 -0
- package/src/core/factory.ts +31 -2
- package/src/core/patch.ts +176 -0
- package/src/core/protocol.ts +36 -0
- package/src/core/sandbox.ts +1 -1
- package/src/core/search.ts +138 -0
- package/src/core/security.ts +63 -21
- package/src/core/skill.ts +1 -1
- package/src/core/subagent.ts +272 -0
- package/src/core/tool.ts +101 -31
- package/src/plugins/loader.ts +145 -18
- package/src/tools/builtin.ts +167 -17
- package/src/tools/spawn.ts +92 -0
- package/tests/bgproc.test.ts +65 -0
- package/tests/diagnostics.test.ts +86 -0
- package/tests/edit_diff.test.ts +102 -0
- package/tests/envcontext.test.ts +67 -0
- package/tests/patch.test.ts +128 -0
- package/tests/plugins.test.ts +84 -0
- package/tests/protocol.test.ts +27 -0
- package/tests/search.test.ts +87 -0
- package/tests/security.test.ts +87 -0
- package/tests/subagent.test.ts +211 -0
- package/tests/tool.test.ts +120 -0
package/src/core/tool.ts
CHANGED
|
@@ -46,6 +46,14 @@ export interface ToolDefinition {
|
|
|
46
46
|
*/
|
|
47
47
|
idempotent?: boolean;
|
|
48
48
|
timeout?: number;
|
|
49
|
+
/**
|
|
50
|
+
* Optional output guard: inspect the handler's result and return an error
|
|
51
|
+
* message if it's not valid (else null/undefined). A non-null return makes
|
|
52
|
+
* the call fail — routed through the same retry + circuit-breaker path as a
|
|
53
|
+
* thrown error — so a tool/plugin can reject malformed output instead of
|
|
54
|
+
* passing garbage back to the model as "success".
|
|
55
|
+
*/
|
|
56
|
+
validateOutput?: (result: string, params: Record<string, unknown>) => string | null | undefined;
|
|
49
57
|
}
|
|
50
58
|
|
|
51
59
|
/** Order-stable JSON key so {a,b} and {b,a} hash to the same cache/dedup key. */
|
|
@@ -144,15 +152,22 @@ function coerceValue(value: unknown, targetType: string): [boolean, unknown] {
|
|
|
144
152
|
return [true, value];
|
|
145
153
|
}
|
|
146
154
|
|
|
155
|
+
// string target accepts scalars (number/boolean) by stringifying them —
|
|
156
|
+
// the model sometimes sends 5 where a string id is expected.
|
|
157
|
+
if (targetType === "string" && (typeof value === "number" || typeof value === "boolean")) {
|
|
158
|
+
return [true, String(value)];
|
|
159
|
+
}
|
|
160
|
+
|
|
147
161
|
// Lenient coercion from string
|
|
148
162
|
if (typeof value === "string") {
|
|
149
163
|
const stripped = value.trim();
|
|
150
164
|
|
|
151
165
|
if (targetType === "integer" || targetType === "number") {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
166
|
+
// Number() handles ints and floats uniformly; parseInt truncated floats
|
|
167
|
+
// ("3.5" -> 3), silently corrupting numeric args.
|
|
168
|
+
if (stripped === "") return [false, value];
|
|
169
|
+
const num = Number(stripped);
|
|
170
|
+
if (!isNaN(num)) return [true, targetType === "integer" ? Math.trunc(num) : num];
|
|
156
171
|
return [false, value];
|
|
157
172
|
}
|
|
158
173
|
|
|
@@ -164,16 +179,35 @@ function coerceValue(value: unknown, targetType: string): [boolean, unknown] {
|
|
|
164
179
|
}
|
|
165
180
|
|
|
166
181
|
if (targetType === "array") {
|
|
182
|
+
// The model often sends a JSON-encoded array string for array params.
|
|
183
|
+
if (stripped.startsWith("[")) {
|
|
184
|
+
try { const parsed = JSON.parse(stripped); if (Array.isArray(parsed)) return [true, parsed]; } catch { /* fall through */ }
|
|
185
|
+
}
|
|
167
186
|
if (stripped.includes(",")) {
|
|
168
187
|
return [true, stripped.split(",").map((s) => s.trim())];
|
|
169
188
|
}
|
|
170
189
|
return [true, [value]];
|
|
171
190
|
}
|
|
191
|
+
|
|
192
|
+
if (targetType === "object") {
|
|
193
|
+
try {
|
|
194
|
+
const parsed = JSON.parse(stripped);
|
|
195
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return [true, parsed];
|
|
196
|
+
} catch { /* not JSON */ }
|
|
197
|
+
return [false, value];
|
|
198
|
+
}
|
|
172
199
|
}
|
|
173
200
|
|
|
174
201
|
return [false, value];
|
|
175
202
|
}
|
|
176
203
|
|
|
204
|
+
/** Describe a value's runtime type for an error message. */
|
|
205
|
+
function describeType(v: unknown): string {
|
|
206
|
+
if (v === null) return "null";
|
|
207
|
+
if (Array.isArray(v)) return "array";
|
|
208
|
+
return typeof v;
|
|
209
|
+
}
|
|
210
|
+
|
|
177
211
|
/**
|
|
178
212
|
* Tool registry and executor
|
|
179
213
|
*/
|
|
@@ -265,32 +299,59 @@ export class ToolRegistry extends EventEmitter {
|
|
|
265
299
|
}
|
|
266
300
|
|
|
267
301
|
/**
|
|
268
|
-
* Validate tool
|
|
302
|
+
* Validate tool inputs against the declared schema AND return coerced args:
|
|
303
|
+
* required-field presence, per-param type coercion (string<->number/bool,
|
|
304
|
+
* JSON-string -> array/object), and enum membership. Returning the coerced
|
|
305
|
+
* params means the handler receives clean, typed values (the model often
|
|
306
|
+
* sends "5" / "true" / a JSON string), and rejecting malformed input before
|
|
307
|
+
* execution gives the model a precise, actionable error to retry against.
|
|
269
308
|
*/
|
|
270
|
-
|
|
309
|
+
validateAndCoerce(
|
|
310
|
+
toolName: string,
|
|
311
|
+
params: Record<string, unknown>,
|
|
312
|
+
): { ok: boolean; error?: string; params?: Record<string, unknown> } {
|
|
271
313
|
const tool = this.tools.get(toolName);
|
|
272
|
-
if (!tool) {
|
|
273
|
-
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
if (!tool.parameters) {
|
|
277
|
-
return [true, ""];
|
|
278
|
-
}
|
|
314
|
+
if (!tool) return { ok: false, error: `Tool ${toolName} not found` };
|
|
315
|
+
if (!tool.parameters || tool.parameters.length === 0) return { ok: true, params };
|
|
279
316
|
|
|
317
|
+
const out: Record<string, unknown> = { ...params };
|
|
280
318
|
for (const param of tool.parameters) {
|
|
281
|
-
|
|
282
|
-
|
|
319
|
+
const has = param.name in params && params[param.name] !== null && params[param.name] !== undefined;
|
|
320
|
+
if (!has) {
|
|
321
|
+
if (param.required) {
|
|
322
|
+
return { ok: false, error: `Missing required parameter '${param.name}' (expected ${param.type}).` };
|
|
323
|
+
}
|
|
324
|
+
continue;
|
|
283
325
|
}
|
|
284
326
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
327
|
+
const [valid, coerced] = coerceValue(params[param.name], param.type);
|
|
328
|
+
if (!valid) {
|
|
329
|
+
return {
|
|
330
|
+
ok: false,
|
|
331
|
+
error: `Invalid type for parameter '${param.name}': expected ${param.type}, got ${describeType(params[param.name])}.`,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (param.enum && param.enum.length > 0 && !param.enum.includes(String(coerced))) {
|
|
336
|
+
return {
|
|
337
|
+
ok: false,
|
|
338
|
+
error: `Invalid value for parameter '${param.name}': '${String(coerced)}' is not allowed. Valid values: ${param.enum.join(", ")}.`,
|
|
339
|
+
};
|
|
290
340
|
}
|
|
341
|
+
|
|
342
|
+
out[param.name] = coerced;
|
|
291
343
|
}
|
|
292
344
|
|
|
293
|
-
return
|
|
345
|
+
return { ok: true, params: out };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Validate tool parameters (legacy boolean/message form; delegates to
|
|
350
|
+
* validateAndCoerce).
|
|
351
|
+
*/
|
|
352
|
+
validateParameters(toolName: string, params: Record<string, unknown>): [boolean, string] {
|
|
353
|
+
const r = this.validateAndCoerce(toolName, params);
|
|
354
|
+
return [r.ok, r.error || ""];
|
|
294
355
|
}
|
|
295
356
|
|
|
296
357
|
/**
|
|
@@ -316,6 +377,18 @@ export class ToolRegistry extends EventEmitter {
|
|
|
316
377
|
};
|
|
317
378
|
}
|
|
318
379
|
|
|
380
|
+
// Validate + coerce inputs against the schema BEFORE cache/execute, so the
|
|
381
|
+
// handler and the cache key both use clean, typed values.
|
|
382
|
+
const validated = this.validateAndCoerce(toolName, params);
|
|
383
|
+
if (!validated.ok) {
|
|
384
|
+
return {
|
|
385
|
+
success: false,
|
|
386
|
+
result: "",
|
|
387
|
+
error: validated.error,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
params = validated.params!;
|
|
391
|
+
|
|
319
392
|
// Check cache
|
|
320
393
|
if (tool.cacheable) {
|
|
321
394
|
const cacheKey = stableStringify(params);
|
|
@@ -330,16 +403,6 @@ export class ToolRegistry extends EventEmitter {
|
|
|
330
403
|
}
|
|
331
404
|
}
|
|
332
405
|
|
|
333
|
-
// Validate parameters
|
|
334
|
-
const [valid, error] = this.validateParameters(toolName, params);
|
|
335
|
-
if (!valid) {
|
|
336
|
-
return {
|
|
337
|
-
success: false,
|
|
338
|
-
result: "",
|
|
339
|
-
error,
|
|
340
|
-
};
|
|
341
|
-
}
|
|
342
|
-
|
|
343
406
|
// Execute with retries
|
|
344
407
|
const maxRetries = tool.maxRetries ?? DEFAULT_RETRIES;
|
|
345
408
|
const retryDelay = (tool.retryDelay ?? DEFAULT_RETRY_DELAY) * 1000;
|
|
@@ -375,6 +438,13 @@ export class ToolRegistry extends EventEmitter {
|
|
|
375
438
|
if (timer) clearTimeout(timer);
|
|
376
439
|
}
|
|
377
440
|
|
|
441
|
+
// Output guard: a non-null message means the result is invalid. Throw so
|
|
442
|
+
// it flows through the same retry + breaker path as any other failure.
|
|
443
|
+
if (tool.validateOutput) {
|
|
444
|
+
const outErr = tool.validateOutput(result, params);
|
|
445
|
+
if (outErr) throw new Error(`invalid tool output: ${outErr}`);
|
|
446
|
+
}
|
|
447
|
+
|
|
378
448
|
const duration = Date.now() - startTime;
|
|
379
449
|
|
|
380
450
|
// Cache result
|
package/src/plugins/loader.ts
CHANGED
|
@@ -1,14 +1,52 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Plugin loader —
|
|
2
|
+
* Plugin loader — discovers external plugins and runs them through an ordered
|
|
3
|
+
* hook lifecycle.
|
|
4
|
+
*
|
|
5
|
+
* A plugin is a directory with an `index.js` exporting either:
|
|
6
|
+
* - activate(ctx): the lifecycle form. `ctx` scopes every registration to the
|
|
7
|
+
* plugin (registerTool / on(hook, fn)), so unload(name) cleanly removes
|
|
8
|
+
* exactly what the plugin added.
|
|
9
|
+
* - register(registry): the legacy form. Still supported — tools it adds are
|
|
10
|
+
* diffed against the registry so they're tracked for unload too.
|
|
11
|
+
*
|
|
12
|
+
* Hooks fire in registration order. Core hooks: `init` (after all plugins
|
|
13
|
+
* load), `tool.register` (a tool was added), `provider.update` (model/provider
|
|
14
|
+
* config changed). Plugins may define and emit their own hook names.
|
|
3
15
|
*/
|
|
4
16
|
|
|
5
17
|
import * as fs from 'fs';
|
|
6
18
|
import * as path from 'path';
|
|
7
|
-
import { ToolRegistry } from '../core/tool';
|
|
19
|
+
import { ToolRegistry, type ToolDefinition } from '../core/tool';
|
|
8
20
|
import { getLogger } from '../core/logger';
|
|
9
21
|
|
|
10
22
|
const log = getLogger('plugin-loader');
|
|
11
23
|
|
|
24
|
+
export type PluginHook = 'init' | 'tool.register' | 'provider.update' | string;
|
|
25
|
+
export type HookHandler = (payload?: any) => void | Promise<void>;
|
|
26
|
+
|
|
27
|
+
/** Scoped API handed to a plugin's activate(); every call is tracked for unload. */
|
|
28
|
+
export interface PluginContext {
|
|
29
|
+
readonly name: string;
|
|
30
|
+
readonly config: any;
|
|
31
|
+
readonly log: ReturnType<typeof getLogger>;
|
|
32
|
+
registerTool(def: ToolDefinition): void;
|
|
33
|
+
on(hook: PluginHook, handler: HookHandler): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface Plugin {
|
|
37
|
+
name?: string;
|
|
38
|
+
activate?(ctx: PluginContext): void | Promise<void>;
|
|
39
|
+
register?(registry: ToolRegistry): void; // legacy
|
|
40
|
+
deactivate?(): void | Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface LoadedPlugin {
|
|
44
|
+
name: string;
|
|
45
|
+
module: Plugin;
|
|
46
|
+
tools: string[];
|
|
47
|
+
hooks: Array<{ hook: string; fn: HookHandler }>;
|
|
48
|
+
}
|
|
49
|
+
|
|
12
50
|
/**
|
|
13
51
|
* A plugin path is safe to `require` only if neither it nor (on POSIX) its
|
|
14
52
|
* permissions allow group/world write — otherwise a less-privileged user could
|
|
@@ -28,14 +66,17 @@ export function isSafePluginPath(target: string): boolean {
|
|
|
28
66
|
|
|
29
67
|
export class PluginLoader {
|
|
30
68
|
private toolRegistry: ToolRegistry;
|
|
69
|
+
private config: any;
|
|
70
|
+
private plugins = new Map<string, LoadedPlugin>();
|
|
71
|
+
/** hook name -> handlers in registration order, each tagged with its plugin. */
|
|
72
|
+
private hookHandlers = new Map<string, Array<{ plugin: string; fn: HookHandler }>>();
|
|
31
73
|
|
|
32
|
-
constructor(toolRegistry: ToolRegistry) {
|
|
74
|
+
constructor(toolRegistry: ToolRegistry, config?: any) {
|
|
33
75
|
this.toolRegistry = toolRegistry;
|
|
76
|
+
this.config = config ?? {};
|
|
34
77
|
}
|
|
35
78
|
|
|
36
|
-
/**
|
|
37
|
-
* Load plugins from specified directories.
|
|
38
|
-
*/
|
|
79
|
+
/** Load plugins from specified directories. Returns the number activated. */
|
|
39
80
|
loadFromDirectories(directories: string[]): number {
|
|
40
81
|
let total = 0;
|
|
41
82
|
for (const dir of directories) {
|
|
@@ -44,9 +85,6 @@ export class PluginLoader {
|
|
|
44
85
|
return total;
|
|
45
86
|
}
|
|
46
87
|
|
|
47
|
-
/**
|
|
48
|
-
* Load a single plugin directory.
|
|
49
|
-
*/
|
|
50
88
|
private loadDirectory(dir: string): number {
|
|
51
89
|
if (!fs.existsSync(dir)) {
|
|
52
90
|
log.debug('plugin_dir_not_found', { dir });
|
|
@@ -55,10 +93,9 @@ export class PluginLoader {
|
|
|
55
93
|
|
|
56
94
|
let count = 0;
|
|
57
95
|
try {
|
|
58
|
-
const
|
|
59
|
-
for (const entry of entries) {
|
|
96
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
60
97
|
const pluginPath = path.join(dir, entry);
|
|
61
|
-
if (!fs.statSync(pluginPath).isDirectory()) continue;
|
|
98
|
+
try { if (!fs.statSync(pluginPath).isDirectory()) continue; } catch { continue; }
|
|
62
99
|
|
|
63
100
|
const pluginFile = path.join(pluginPath, 'index.js');
|
|
64
101
|
if (!fs.existsSync(pluginFile)) continue;
|
|
@@ -72,12 +109,8 @@ export class PluginLoader {
|
|
|
72
109
|
}
|
|
73
110
|
|
|
74
111
|
try {
|
|
75
|
-
const
|
|
76
|
-
if (
|
|
77
|
-
plugin.register(this.toolRegistry);
|
|
78
|
-
count++;
|
|
79
|
-
log.info('plugin_loaded', { name: entry });
|
|
80
|
-
}
|
|
112
|
+
const mod = require(pluginFile) as Plugin;
|
|
113
|
+
if (this.activatePlugin(entry, mod)) count++;
|
|
81
114
|
} catch (e) {
|
|
82
115
|
log.warn('plugin_load_failed', { name: entry, error: String(e) });
|
|
83
116
|
}
|
|
@@ -88,4 +121,98 @@ export class PluginLoader {
|
|
|
88
121
|
|
|
89
122
|
return count;
|
|
90
123
|
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Activate a plugin module under a name. Reactivating an already-loaded name
|
|
127
|
+
* unloads the previous instance first. Returns true if anything registered.
|
|
128
|
+
*/
|
|
129
|
+
activatePlugin(name: string, mod: Plugin): boolean {
|
|
130
|
+
const pluginName = mod.name || name;
|
|
131
|
+
if (this.plugins.has(pluginName)) this.unload(pluginName);
|
|
132
|
+
|
|
133
|
+
const record: LoadedPlugin = { name: pluginName, module: mod, tools: [], hooks: [] };
|
|
134
|
+
const self = this;
|
|
135
|
+
|
|
136
|
+
if (typeof mod.activate === 'function') {
|
|
137
|
+
const ctx: PluginContext = {
|
|
138
|
+
name: pluginName,
|
|
139
|
+
config: this.config,
|
|
140
|
+
log: getLogger(`plugin:${pluginName}`),
|
|
141
|
+
registerTool(def: ToolDefinition) {
|
|
142
|
+
self.toolRegistry.register(def);
|
|
143
|
+
record.tools.push(def.name);
|
|
144
|
+
void self.emit('tool.register', { plugin: pluginName, tool: def.name });
|
|
145
|
+
},
|
|
146
|
+
on(hook: PluginHook, handler: HookHandler) {
|
|
147
|
+
record.hooks.push({ hook, fn: handler });
|
|
148
|
+
const arr = self.hookHandlers.get(hook) || [];
|
|
149
|
+
arr.push({ plugin: pluginName, fn: handler });
|
|
150
|
+
self.hookHandlers.set(hook, arr);
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
try {
|
|
154
|
+
void mod.activate(ctx);
|
|
155
|
+
} catch (e) {
|
|
156
|
+
log.warn('plugin_activate_failed', { name: pluginName, error: String(e) });
|
|
157
|
+
this.unload(pluginName);
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
} else if (typeof mod.register === 'function') {
|
|
161
|
+
// Legacy: diff the registry to learn which tools the plugin added.
|
|
162
|
+
const before = new Set(this.toolRegistry.listNames());
|
|
163
|
+
try {
|
|
164
|
+
mod.register(this.toolRegistry);
|
|
165
|
+
} catch (e) {
|
|
166
|
+
log.warn('plugin_register_failed', { name: pluginName, error: String(e) });
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
for (const n of this.toolRegistry.listNames()) {
|
|
170
|
+
if (!before.has(n)) record.tools.push(n);
|
|
171
|
+
}
|
|
172
|
+
} else {
|
|
173
|
+
log.warn('plugin_no_entrypoint', { name: pluginName });
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
this.plugins.set(pluginName, record);
|
|
178
|
+
log.info('plugin_loaded', { name: pluginName, tools: record.tools.length, hooks: record.hooks.length });
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Fire a hook; handlers run in registration order. Errors are isolated. */
|
|
183
|
+
async emit(hook: PluginHook, payload?: any): Promise<void> {
|
|
184
|
+
const handlers = this.hookHandlers.get(hook);
|
|
185
|
+
if (!handlers || handlers.length === 0) return;
|
|
186
|
+
for (const { plugin, fn } of [...handlers]) {
|
|
187
|
+
try {
|
|
188
|
+
await fn(payload);
|
|
189
|
+
} catch (e) {
|
|
190
|
+
log.warn('plugin_hook_failed', { hook, plugin, error: String(e) });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Unload a plugin: remove its tools and hook handlers, call deactivate. */
|
|
196
|
+
unload(name: string): boolean {
|
|
197
|
+
const record = this.plugins.get(name);
|
|
198
|
+
if (!record) return false;
|
|
199
|
+
|
|
200
|
+
for (const tool of record.tools) this.toolRegistry.unregister(tool);
|
|
201
|
+
for (const [hook, arr] of this.hookHandlers) {
|
|
202
|
+
const filtered = arr.filter((h) => h.plugin !== name);
|
|
203
|
+
if (filtered.length) this.hookHandlers.set(hook, filtered);
|
|
204
|
+
else this.hookHandlers.delete(hook);
|
|
205
|
+
}
|
|
206
|
+
try { record.module.deactivate?.(); } catch (e) { log.warn('plugin_deactivate_failed', { name, error: String(e) }); }
|
|
207
|
+
|
|
208
|
+
this.plugins.delete(name);
|
|
209
|
+
log.info('plugin_unloaded', { name });
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Names of currently loaded plugins. */
|
|
214
|
+
list(): string[] { return [...this.plugins.keys()]; }
|
|
215
|
+
|
|
216
|
+
/** Number of handlers registered for a hook (for diagnostics/tests). */
|
|
217
|
+
hookCount(hook: PluginHook): number { return this.hookHandlers.get(hook)?.length ?? 0; }
|
|
91
218
|
}
|
package/src/tools/builtin.ts
CHANGED
|
@@ -10,6 +10,10 @@ import { registerComputerTools } from './computer';
|
|
|
10
10
|
import { registerExtraTools } from './extra';
|
|
11
11
|
import { isPrivateIp, assertFetchAllowed, fenceRoot, fenceCheck } from './guards';
|
|
12
12
|
import { webSearch, formatSearchResults, readPage } from './websearch';
|
|
13
|
+
import { countOccurrences, unifiedDiff } from '../core/diff';
|
|
14
|
+
import { getDiagnostics, formatDiagnostics } from '../core/diagnostics';
|
|
15
|
+
import { searchCode, formatSearchResult } from '../core/search';
|
|
16
|
+
import { applyPatch } from '../core/patch';
|
|
13
17
|
|
|
14
18
|
// Re-exported so existing importers/tests keep resolving these from builtin.
|
|
15
19
|
export { isPrivateIp, assertFetchAllowed, fenceRoot, fenceCheck };
|
|
@@ -77,32 +81,93 @@ export function registerBuiltinTools(registry: ToolRegistry): void {
|
|
|
77
81
|
|
|
78
82
|
registry.register({
|
|
79
83
|
name: 'edit_file',
|
|
80
|
-
description: 'Edit a file by replacing old_text with new_text.
|
|
84
|
+
description: 'Edit a file by replacing an exact occurrence of old_text with new_text. old_text must match the file exactly (including whitespace and indentation) and must be UNIQUE — include enough surrounding context to disambiguate, or set replace_all to change every occurrence. Returns a unified diff of the change.',
|
|
81
85
|
parameters: [
|
|
82
86
|
{ name: 'path', type: 'string', description: 'Path to the file to edit', required: true },
|
|
83
|
-
{ name: 'old_text', type: 'string', description: '
|
|
84
|
-
{ name: 'new_text', type: 'string', description: '
|
|
87
|
+
{ name: 'old_text', type: 'string', description: 'Exact text to replace (must match the file verbatim, and be unique unless replace_all is set)', required: true },
|
|
88
|
+
{ name: 'new_text', type: 'string', description: 'Replacement text (must differ from old_text)', required: true },
|
|
89
|
+
{ name: 'replace_all', type: 'boolean', description: 'Replace every occurrence instead of requiring a unique match (default false)', required: false },
|
|
85
90
|
],
|
|
86
91
|
handler: async (params) => {
|
|
87
92
|
const filePath = path.resolve(params.path as string);
|
|
88
93
|
const fenced = fenceCheck(filePath); if (fenced) return fenced;
|
|
89
94
|
if (!fs.existsSync(filePath)) return `Error: File not found: ${filePath}`;
|
|
95
|
+
const oldText = params.old_text as string;
|
|
96
|
+
const newText = params.new_text as string;
|
|
97
|
+
const replaceAll = params.replace_all === true || params.replace_all === 'true';
|
|
98
|
+
if (oldText === newText) return 'Error: old_text and new_text are identical — nothing to change.';
|
|
90
99
|
try {
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
return `Error: old_text not found in file. Searched for: ${oldText.slice(0, 50)}...`;
|
|
100
|
+
const orig = fs.readFileSync(filePath, 'utf-8');
|
|
101
|
+
const n = countOccurrences(orig, oldText);
|
|
102
|
+
if (n === 0) {
|
|
103
|
+
return `Error: old_text not found in ${filePath}. It must match exactly (including whitespace). Searched for: ${JSON.stringify(oldText.slice(0, 80))}`;
|
|
96
104
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
105
|
+
if (n > 1 && !replaceAll) {
|
|
106
|
+
return `Error: old_text appears ${n} times in ${filePath} — the edit is ambiguous. Add more surrounding context to make it unique, or set replace_all=true to change all ${n} occurrences.`;
|
|
107
|
+
}
|
|
108
|
+
// Literal replacement: split/join (replace_all) and a function replacer
|
|
109
|
+
// (single) both avoid String.replace interpreting `$&`/`$1` in new_text.
|
|
110
|
+
const updated = replaceAll
|
|
111
|
+
? orig.split(oldText).join(newText)
|
|
112
|
+
: orig.replace(oldText, () => newText);
|
|
113
|
+
fs.writeFileSync(filePath, updated, 'utf-8');
|
|
114
|
+
const rel = path.relative(process.cwd(), filePath) || filePath;
|
|
115
|
+
const diff = unifiedDiff(orig, updated, { path: rel, context: 3 });
|
|
116
|
+
const occ = replaceAll ? ` (${n} occurrence${n > 1 ? 's' : ''})` : '';
|
|
117
|
+
const body = diff.text ? `\n${diff.text}` : '';
|
|
118
|
+
return `Successfully edited ${filePath}${occ} · +${diff.stat.added} -${diff.stat.removed}${body}`;
|
|
100
119
|
} catch (e) {
|
|
101
120
|
return `Error editing file: ${e}`;
|
|
102
121
|
}
|
|
103
122
|
},
|
|
104
123
|
});
|
|
105
124
|
|
|
125
|
+
registry.register({
|
|
126
|
+
name: 'get_diagnostics',
|
|
127
|
+
idempotent: true,
|
|
128
|
+
description: 'Get LSP-style diagnostics (type errors, lint issues with line:col) for a source file. TS/JS work out of the box via the workspace TypeScript; other languages use a configured checker (config.yaml diagnostics map). Call this after editing code to confirm it is error-free, or to locate the root cause of a type error.',
|
|
129
|
+
parameters: [
|
|
130
|
+
{ name: 'path', type: 'string', description: 'Path to the source file to check', required: true },
|
|
131
|
+
],
|
|
132
|
+
handler: async (params) => {
|
|
133
|
+
const filePath = path.resolve(params.path as string);
|
|
134
|
+
const fenced = fenceCheck(filePath); if (fenced) return fenced;
|
|
135
|
+
let config: any = {};
|
|
136
|
+
try { const { loadConfig } = require('../core/config'); config = loadConfig(); } catch { /* defaults */ }
|
|
137
|
+
try {
|
|
138
|
+
const result = getDiagnostics(filePath, config);
|
|
139
|
+
if (!Array.isArray(result)) return `[diagnostics unavailable] ${result.unavailable}`;
|
|
140
|
+
return formatDiagnostics(path.relative(process.cwd(), filePath) || filePath, result);
|
|
141
|
+
} catch (e) {
|
|
142
|
+
return `Error getting diagnostics: ${e}`;
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
registry.register({
|
|
148
|
+
name: 'apply_patch',
|
|
149
|
+
description: 'Apply an atomic, multi-file edit in one call — ideal for larger refactors touching several places/files. The whole patch is validated before anything is written, so a bad block aborts cleanly with no half-applied changes. Each SEARCH must match the file exactly and uniquely. Format:\n*** Update File: path\n<<<<<<< SEARCH\nold exact text\n=======\nnew text\n>>>>>>> REPLACE\n(repeat blocks; also *** Add File: path / full content, and *** Delete File: path)',
|
|
150
|
+
parameters: [
|
|
151
|
+
{ name: 'patch', type: 'string', description: 'The patch text in the *** Update/Add/Delete File + SEARCH/REPLACE format', required: true },
|
|
152
|
+
],
|
|
153
|
+
handler: async (params) => {
|
|
154
|
+
let snapshot: ((abs: string) => void) | undefined;
|
|
155
|
+
try {
|
|
156
|
+
const { getFileCheckpoints } = require('../core/file_checkpoint');
|
|
157
|
+
const cp = getFileCheckpoints();
|
|
158
|
+
snapshot = (abs: string) => { try { cp.snapshot(abs); } catch { /* best-effort */ } };
|
|
159
|
+
} catch { /* checkpointing optional */ }
|
|
160
|
+
try {
|
|
161
|
+
return applyPatch(String(params.patch || ''), {
|
|
162
|
+
fenceCheck: (abs: string) => fenceCheck(abs),
|
|
163
|
+
snapshot,
|
|
164
|
+
});
|
|
165
|
+
} catch (e) {
|
|
166
|
+
return `Error applying patch: ${e}`;
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
|
|
106
171
|
registry.register({
|
|
107
172
|
name: 'delete_file',
|
|
108
173
|
description: 'Delete a file at the given path.',
|
|
@@ -172,13 +237,23 @@ export function registerBuiltinTools(registry: ToolRegistry): void {
|
|
|
172
237
|
|
|
173
238
|
registry.register({
|
|
174
239
|
name: 'run_bash',
|
|
175
|
-
description: 'Execute a shell command and return its output.',
|
|
240
|
+
description: 'Execute a shell command and return its output. Set background=true for long-running processes (dev servers, watchers, builds): it returns a job id immediately — read its output later with bash_output, stop it with kill_bash.',
|
|
176
241
|
parameters: [
|
|
177
242
|
{ name: 'command', type: 'string', description: 'Command to execute', required: true },
|
|
178
|
-
{ name: 'timeout', type: 'number', description: 'Timeout in milliseconds (default: 30000)', required: false },
|
|
243
|
+
{ name: 'timeout', type: 'number', description: 'Timeout in milliseconds (default: 30000). Ignored when background=true.', required: false },
|
|
244
|
+
{ name: 'background', type: 'boolean', description: 'Run detached in the background and return a job id instead of blocking (default false)', required: false },
|
|
179
245
|
],
|
|
180
246
|
handler: async (params) => {
|
|
181
247
|
const cmd = params.command as string;
|
|
248
|
+
const background = params.background === true || params.background === 'true';
|
|
249
|
+
if (background) {
|
|
250
|
+
try {
|
|
251
|
+
const { getBackgroundManager } = require('../core/bgproc');
|
|
252
|
+
const { id, error } = getBackgroundManager().start(cmd);
|
|
253
|
+
if (error) return error;
|
|
254
|
+
return `[background job ${id} started] pid running. Use bash_output("${id}") to read output, kill_bash("${id}") to stop, list_bash to see all jobs.`;
|
|
255
|
+
} catch (e: any) { return `Error: ${e.message || e}`; }
|
|
256
|
+
}
|
|
182
257
|
const timeout = (params.timeout as number) || 30000;
|
|
183
258
|
try {
|
|
184
259
|
const { runInSandbox, formatSandboxResult } = require('../core/sandbox');
|
|
@@ -189,6 +264,51 @@ export function registerBuiltinTools(registry: ToolRegistry): void {
|
|
|
189
264
|
dangerous: true,
|
|
190
265
|
});
|
|
191
266
|
|
|
267
|
+
registry.register({
|
|
268
|
+
name: 'bash_output',
|
|
269
|
+
description: 'Read new output from a background shell job (started by run_bash with background=true) since the last read, plus its current status.',
|
|
270
|
+
parameters: [
|
|
271
|
+
{ name: 'id', type: 'string', description: 'The background job id', required: true },
|
|
272
|
+
],
|
|
273
|
+
handler: async (params) => {
|
|
274
|
+
const { getBackgroundManager } = require('../core/bgproc');
|
|
275
|
+
const r = getBackgroundManager().read(String(params.id || ''));
|
|
276
|
+
if (!r.ok) return r.error || 'Error reading background job.';
|
|
277
|
+
const statusLine = `[job ${params.id} · ${r.status}${r.exitCode != null ? ` · exit ${r.exitCode}` : ''}]`;
|
|
278
|
+
const out = r.text && r.text.length ? r.text : '(no new output)';
|
|
279
|
+
return `${statusLine}\n${out}`;
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
registry.register({
|
|
284
|
+
name: 'list_bash',
|
|
285
|
+
description: 'List background shell jobs with their status, pid, and runtime.',
|
|
286
|
+
parameters: [],
|
|
287
|
+
handler: async () => {
|
|
288
|
+
const { getBackgroundManager } = require('../core/bgproc');
|
|
289
|
+
const jobs = getBackgroundManager().list();
|
|
290
|
+
if (!jobs.length) return 'No background jobs.';
|
|
291
|
+
return jobs.map((j: any) => {
|
|
292
|
+
const dur = ((j.endedAt || Date.now()) - j.startedAt) / 1000;
|
|
293
|
+
const ex = j.exitCode != null ? ` exit ${j.exitCode}` : '';
|
|
294
|
+
return `${j.id} · ${j.status}${ex} · pid ${j.pid ?? '?'} · ${dur.toFixed(1)}s · ${j.command.slice(0, 60)}`;
|
|
295
|
+
}).join('\n');
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
registry.register({
|
|
300
|
+
name: 'kill_bash',
|
|
301
|
+
description: 'Terminate a running background shell job.',
|
|
302
|
+
parameters: [
|
|
303
|
+
{ name: 'id', type: 'string', description: 'The background job id to kill', required: true },
|
|
304
|
+
],
|
|
305
|
+
handler: async (params) => {
|
|
306
|
+
const { getBackgroundManager } = require('../core/bgproc');
|
|
307
|
+
const r = getBackgroundManager().kill(String(params.id || ''));
|
|
308
|
+
return r.ok ? `[job ${params.id} killed]` : (r.error || 'Error killing background job.');
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
|
|
192
312
|
// ── HTTP Tools ──
|
|
193
313
|
|
|
194
314
|
registry.register({
|
|
@@ -376,10 +496,39 @@ export function registerBuiltinTools(registry: ToolRegistry): void {
|
|
|
376
496
|
|
|
377
497
|
// ── Utility Tools ──
|
|
378
498
|
|
|
499
|
+
registry.register({
|
|
500
|
+
name: 'code_search',
|
|
501
|
+
idempotent: true,
|
|
502
|
+
description: 'Search source code for a regex pattern across files. Returns file:line matches with optional surrounding context. Use this to find where a symbol/string is defined or used. Restrict scope with glob (e.g. "**/*.ts") and add context lines to read around hits.',
|
|
503
|
+
parameters: [
|
|
504
|
+
{ name: 'pattern', type: 'string', description: 'Regex (or literal if regex=false) to search for', required: true },
|
|
505
|
+
{ name: 'path', type: 'string', description: 'Root directory to search (default: cwd)', required: false },
|
|
506
|
+
{ name: 'glob', type: 'string', description: 'Restrict to files matching this glob, e.g. "**/*.ts"', required: false },
|
|
507
|
+
{ name: 'context', type: 'number', description: 'Lines of context around each match (default 0)', required: false },
|
|
508
|
+
{ name: 'ignore_case', type: 'boolean', description: 'Case-insensitive match (default false)', required: false },
|
|
509
|
+
{ name: 'regex', type: 'boolean', description: 'Treat pattern as regex (default true; false = literal substring)', required: false },
|
|
510
|
+
{ name: 'max_results', type: 'number', description: 'Max matches to return (default 200)', required: false },
|
|
511
|
+
],
|
|
512
|
+
handler: async (params) => {
|
|
513
|
+
const root = params.path ? path.resolve(params.path as string) : process.cwd();
|
|
514
|
+
const fenced = fenceCheck(root); if (fenced) return fenced;
|
|
515
|
+
const res = searchCode({
|
|
516
|
+
pattern: String(params.pattern || ''),
|
|
517
|
+
root,
|
|
518
|
+
glob: params.glob ? String(params.glob) : undefined,
|
|
519
|
+
context: params.context != null ? Number(params.context) : 0,
|
|
520
|
+
ignoreCase: params.ignore_case === true,
|
|
521
|
+
regex: params.regex !== false,
|
|
522
|
+
maxResults: params.max_results != null ? Number(params.max_results) : 200,
|
|
523
|
+
});
|
|
524
|
+
return formatSearchResult(res);
|
|
525
|
+
},
|
|
526
|
+
});
|
|
527
|
+
|
|
379
528
|
registry.register({
|
|
380
529
|
name: 'grep',
|
|
381
530
|
idempotent: true,
|
|
382
|
-
description: 'Search for a pattern in files using ripgrep
|
|
531
|
+
description: 'Search for a regex pattern in files using ripgrep/grep, with a built-in fallback when neither is installed. For richer control (glob, context, ignore-case) prefer code_search.',
|
|
383
532
|
parameters: [
|
|
384
533
|
{ name: 'pattern', type: 'string', description: 'Regex pattern to search for', required: true },
|
|
385
534
|
{ name: 'path', type: 'string', description: 'Directory to search in', required: false },
|
|
@@ -402,12 +551,13 @@ export function registerBuiltinTools(registry: ToolRegistry): void {
|
|
|
402
551
|
const out = execFileSync(bin, args, { encoding: 'utf-8', maxBuffer: 1024 * 1024 });
|
|
403
552
|
return out || 'No matches found.';
|
|
404
553
|
} catch (e: any) {
|
|
405
|
-
// exit status 1 = ran successfully, zero matches
|
|
406
|
-
// (e.g. binary not installed) falls through to the next variant
|
|
554
|
+
// exit status 1 = ran successfully, zero matches. Any other failure
|
|
555
|
+
// (e.g. binary not installed) falls through to the next variant, then
|
|
556
|
+
// to the pure-JS engine so search works even with no rg/grep.
|
|
407
557
|
if (e?.status === 1) return 'No matches found.';
|
|
408
558
|
}
|
|
409
559
|
}
|
|
410
|
-
return
|
|
560
|
+
return formatSearchResult(searchCode({ pattern: pat, root: searchDir, maxResults: 200 }));
|
|
411
561
|
},
|
|
412
562
|
});
|
|
413
563
|
|