opencode-swarm-plugin 0.34.0 → 0.35.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/CHANGELOG.md +35 -0
- package/bin/swarm.test.ts +70 -0
- package/bin/swarm.ts +139 -14
- package/package.json +1 -1
- package/src/compaction-hook.test.ts +234 -212
- package/src/compaction-hook.ts +197 -0
- package/src/swarm-decompose.ts +0 -7
- package/src/swarm-prompts.ts +1 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
# opencode-swarm-plugin
|
|
2
2
|
|
|
3
|
+
## 0.35.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [`084f888`](https://github.com/joelhooks/swarm-tools/commit/084f888fcac4912f594428b1ac7148c8a8aaa422) Thanks [@joelhooks](https://github.com/joelhooks)! - ## 👁️ Watch Your Swarm in Real-Time
|
|
8
|
+
|
|
9
|
+
`swarm log` now has a `--watch` mode for continuous log monitoring. No more running the command repeatedly - just sit back and watch the bees work.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# Watch all logs
|
|
13
|
+
swarm log --watch
|
|
14
|
+
|
|
15
|
+
# Watch with filters
|
|
16
|
+
swarm log compaction -w --level error
|
|
17
|
+
|
|
18
|
+
# Faster polling (500ms instead of default 1s)
|
|
19
|
+
swarm log --watch --interval 500
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
**New flags:**
|
|
23
|
+
|
|
24
|
+
- `--watch`, `-w` - Enable continuous monitoring mode
|
|
25
|
+
- `--interval <ms>` - Poll interval in milliseconds (default: 1000, min: 100)
|
|
26
|
+
|
|
27
|
+
**How it works:**
|
|
28
|
+
|
|
29
|
+
- Shows initial logs (last N lines based on `--limit`)
|
|
30
|
+
- Polls log files for new entries at the specified interval
|
|
31
|
+
- Tracks file positions for efficient incremental reads
|
|
32
|
+
- Handles log rotation gracefully (detects file truncation)
|
|
33
|
+
- All existing filters work: `--level`, `--since`, module name
|
|
34
|
+
- Clean shutdown on Ctrl+C
|
|
35
|
+
|
|
36
|
+
_"The hive that watches itself, debugs itself."_
|
|
37
|
+
|
|
3
38
|
## 0.34.0
|
|
4
39
|
|
|
5
40
|
### Minor Changes
|
package/bin/swarm.test.ts
CHANGED
|
@@ -462,4 +462,74 @@ describe("Log command helpers", () => {
|
|
|
462
462
|
expect(lines).toHaveLength(0);
|
|
463
463
|
});
|
|
464
464
|
});
|
|
465
|
+
|
|
466
|
+
describe("watchLogs", () => {
|
|
467
|
+
test("detects new log lines appended to file", async () => {
|
|
468
|
+
const logFile = join(testDir, "swarm.1log");
|
|
469
|
+
const collectedLines: string[] = [];
|
|
470
|
+
|
|
471
|
+
// Create initial log file
|
|
472
|
+
writeFileSync(logFile, '{"level":30,"time":"2024-12-24T16:00:00.000Z","msg":"initial"}\n');
|
|
473
|
+
|
|
474
|
+
// Import watch utilities
|
|
475
|
+
const { watch } = await import("fs");
|
|
476
|
+
const { appendFileSync } = await import("fs");
|
|
477
|
+
|
|
478
|
+
// Track file position for incremental reads
|
|
479
|
+
let lastSize = 0;
|
|
480
|
+
|
|
481
|
+
function readNewLines(filePath: string): string[] {
|
|
482
|
+
const content = readFileSync(filePath, "utf-8");
|
|
483
|
+
const newContent = content.slice(lastSize);
|
|
484
|
+
lastSize = content.length;
|
|
485
|
+
return newContent.split("\n").filter((line) => line.trim());
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Simulate watch behavior
|
|
489
|
+
const watcher = watch(testDir, (eventType, filename) => {
|
|
490
|
+
if (filename && /\.\d+log$/.test(filename)) {
|
|
491
|
+
const newLines = readNewLines(join(testDir, filename));
|
|
492
|
+
collectedLines.push(...newLines);
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
// Wait for watcher to be ready
|
|
497
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
498
|
+
|
|
499
|
+
// Append new log line
|
|
500
|
+
appendFileSync(logFile, '{"level":30,"time":"2024-12-24T16:00:01.000Z","msg":"appended"}\n');
|
|
501
|
+
|
|
502
|
+
// Wait for event to fire
|
|
503
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
504
|
+
|
|
505
|
+
watcher.close();
|
|
506
|
+
|
|
507
|
+
// Should have detected the new line
|
|
508
|
+
expect(collectedLines.some((l) => l.includes("appended"))).toBe(true);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test("parseWatchArgs extracts --watch flag", () => {
|
|
512
|
+
function parseWatchArgs(args: string[]): { watch: boolean; interval: number } {
|
|
513
|
+
let watch = false;
|
|
514
|
+
let interval = 1000; // default 1 second
|
|
515
|
+
|
|
516
|
+
for (let i = 0; i < args.length; i++) {
|
|
517
|
+
const arg = args[i];
|
|
518
|
+
if (arg === "--watch" || arg === "-w") {
|
|
519
|
+
watch = true;
|
|
520
|
+
} else if (arg === "--interval" && i + 1 < args.length) {
|
|
521
|
+
interval = parseInt(args[++i], 10);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
return { watch, interval };
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
expect(parseWatchArgs(["--watch"])).toEqual({ watch: true, interval: 1000 });
|
|
529
|
+
expect(parseWatchArgs(["-w"])).toEqual({ watch: true, interval: 1000 });
|
|
530
|
+
expect(parseWatchArgs(["--watch", "--interval", "500"])).toEqual({ watch: true, interval: 500 });
|
|
531
|
+
expect(parseWatchArgs(["compaction", "--watch"])).toEqual({ watch: true, interval: 1000 });
|
|
532
|
+
expect(parseWatchArgs(["--level", "error"])).toEqual({ watch: false, interval: 1000 });
|
|
533
|
+
});
|
|
534
|
+
});
|
|
465
535
|
});
|
package/bin/swarm.ts
CHANGED
|
@@ -2732,6 +2732,8 @@ ${cyan("Log Viewing:")}
|
|
|
2732
2732
|
swarm log --since <duration> Time filter (30s, 5m, 2h, 1d)
|
|
2733
2733
|
swarm log --json Raw JSON output for jq
|
|
2734
2734
|
swarm log --limit <n> Limit output to n lines (default: 50)
|
|
2735
|
+
swarm log --watch, -w Watch mode - continuously monitor for new logs
|
|
2736
|
+
swarm log --interval <ms> Poll interval in ms (default: 1000, min: 100)
|
|
2735
2737
|
|
|
2736
2738
|
${cyan("Usage in OpenCode:")}
|
|
2737
2739
|
/swarm "Add user authentication with OAuth"
|
|
@@ -3203,6 +3205,8 @@ async function logs() {
|
|
|
3203
3205
|
let sinceMs: number | null = null;
|
|
3204
3206
|
let jsonOutput = false;
|
|
3205
3207
|
let limit = 50;
|
|
3208
|
+
let watchMode = false;
|
|
3209
|
+
let pollInterval = 1000; // 1 second default
|
|
3206
3210
|
|
|
3207
3211
|
for (let i = 0; i < args.length; i++) {
|
|
3208
3212
|
const arg = args[i];
|
|
@@ -3225,7 +3229,15 @@ async function logs() {
|
|
|
3225
3229
|
p.log.error(`Invalid limit: ${args[i]}`);
|
|
3226
3230
|
process.exit(1);
|
|
3227
3231
|
}
|
|
3228
|
-
} else if (
|
|
3232
|
+
} else if (arg === "--watch" || arg === "-w") {
|
|
3233
|
+
watchMode = true;
|
|
3234
|
+
} else if (arg === "--interval" && i + 1 < args.length) {
|
|
3235
|
+
pollInterval = parseInt(args[++i], 10);
|
|
3236
|
+
if (isNaN(pollInterval) || pollInterval < 100) {
|
|
3237
|
+
p.log.error(`Invalid interval: ${args[i]} (minimum 100ms)`);
|
|
3238
|
+
process.exit(1);
|
|
3239
|
+
}
|
|
3240
|
+
} else if (!arg.startsWith("--") && !arg.startsWith("-")) {
|
|
3229
3241
|
// Positional arg = module filter
|
|
3230
3242
|
moduleFilter = arg;
|
|
3231
3243
|
}
|
|
@@ -3244,6 +3256,131 @@ async function logs() {
|
|
|
3244
3256
|
return;
|
|
3245
3257
|
}
|
|
3246
3258
|
|
|
3259
|
+
// Helper to filter logs
|
|
3260
|
+
const filterLogs = (rawLogs: LogLine[]): LogLine[] => {
|
|
3261
|
+
let filtered = rawLogs;
|
|
3262
|
+
|
|
3263
|
+
if (moduleFilter) {
|
|
3264
|
+
filtered = filtered.filter((log) => log.module === moduleFilter);
|
|
3265
|
+
}
|
|
3266
|
+
|
|
3267
|
+
if (levelFilter !== null) {
|
|
3268
|
+
filtered = filtered.filter((log) => log.level >= levelFilter);
|
|
3269
|
+
}
|
|
3270
|
+
|
|
3271
|
+
if (sinceMs !== null) {
|
|
3272
|
+
const cutoffTime = Date.now() - sinceMs;
|
|
3273
|
+
filtered = filtered.filter((log) => new Date(log.time).getTime() >= cutoffTime);
|
|
3274
|
+
}
|
|
3275
|
+
|
|
3276
|
+
return filtered;
|
|
3277
|
+
};
|
|
3278
|
+
|
|
3279
|
+
// Watch mode - continuous monitoring
|
|
3280
|
+
if (watchMode) {
|
|
3281
|
+
console.log(yellow(BANNER));
|
|
3282
|
+
console.log(dim(` Watching logs... (Ctrl+C to stop)`));
|
|
3283
|
+
if (moduleFilter) console.log(dim(` Module: ${moduleFilter}`));
|
|
3284
|
+
if (levelFilter !== null) console.log(dim(` Level: >=${levelToName(levelFilter)}`));
|
|
3285
|
+
console.log();
|
|
3286
|
+
|
|
3287
|
+
// Track file positions for incremental reads
|
|
3288
|
+
const filePositions: Map<string, number> = new Map();
|
|
3289
|
+
|
|
3290
|
+
// Initialize positions from current file sizes
|
|
3291
|
+
const initializePositions = () => {
|
|
3292
|
+
if (!existsSync(logsDir)) return;
|
|
3293
|
+
const files = readdirSync(logsDir).filter((f: string) => /\.\d+log$/.test(f));
|
|
3294
|
+
for (const file of files) {
|
|
3295
|
+
const filePath = join(logsDir, file);
|
|
3296
|
+
try {
|
|
3297
|
+
const stats = statSync(filePath);
|
|
3298
|
+
filePositions.set(filePath, stats.size);
|
|
3299
|
+
} catch {
|
|
3300
|
+
// Skip unreadable files
|
|
3301
|
+
}
|
|
3302
|
+
}
|
|
3303
|
+
};
|
|
3304
|
+
|
|
3305
|
+
// Read new lines from a file since last position
|
|
3306
|
+
const readNewLines = (filePath: string): string[] => {
|
|
3307
|
+
try {
|
|
3308
|
+
const stats = statSync(filePath);
|
|
3309
|
+
const lastPos = filePositions.get(filePath) || 0;
|
|
3310
|
+
|
|
3311
|
+
if (stats.size <= lastPos) {
|
|
3312
|
+
// File was truncated or no new content
|
|
3313
|
+
if (stats.size < lastPos) {
|
|
3314
|
+
filePositions.set(filePath, stats.size);
|
|
3315
|
+
}
|
|
3316
|
+
return [];
|
|
3317
|
+
}
|
|
3318
|
+
|
|
3319
|
+
const content = readFileSync(filePath, "utf-8");
|
|
3320
|
+
const newContent = content.slice(lastPos);
|
|
3321
|
+
filePositions.set(filePath, stats.size);
|
|
3322
|
+
|
|
3323
|
+
return newContent.split("\n").filter((line: string) => line.trim());
|
|
3324
|
+
} catch {
|
|
3325
|
+
return [];
|
|
3326
|
+
}
|
|
3327
|
+
};
|
|
3328
|
+
|
|
3329
|
+
// Print initial logs (last N lines)
|
|
3330
|
+
const rawLines = readLogFiles(logsDir);
|
|
3331
|
+
let logs: LogLine[] = rawLines
|
|
3332
|
+
.map(parseLogLine)
|
|
3333
|
+
.filter((log): log is LogLine => log !== null);
|
|
3334
|
+
logs = filterLogs(logs).slice(-limit);
|
|
3335
|
+
|
|
3336
|
+
for (const log of logs) {
|
|
3337
|
+
console.log(formatLogLine(log));
|
|
3338
|
+
}
|
|
3339
|
+
|
|
3340
|
+
// Initialize positions after printing initial logs
|
|
3341
|
+
initializePositions();
|
|
3342
|
+
|
|
3343
|
+
// Poll for new logs
|
|
3344
|
+
const pollForNewLogs = () => {
|
|
3345
|
+
if (!existsSync(logsDir)) return;
|
|
3346
|
+
|
|
3347
|
+
const files = readdirSync(logsDir).filter((f: string) => /\.\d+log$/.test(f));
|
|
3348
|
+
|
|
3349
|
+
for (const file of files) {
|
|
3350
|
+
const filePath = join(logsDir, file);
|
|
3351
|
+
const newLines = readNewLines(filePath);
|
|
3352
|
+
|
|
3353
|
+
for (const line of newLines) {
|
|
3354
|
+
const parsed = parseLogLine(line);
|
|
3355
|
+
if (parsed) {
|
|
3356
|
+
const filtered = filterLogs([parsed]);
|
|
3357
|
+
if (filtered.length > 0) {
|
|
3358
|
+
console.log(formatLogLine(filtered[0]));
|
|
3359
|
+
}
|
|
3360
|
+
}
|
|
3361
|
+
}
|
|
3362
|
+
}
|
|
3363
|
+
};
|
|
3364
|
+
|
|
3365
|
+
// Set up polling interval
|
|
3366
|
+
const intervalId = setInterval(pollForNewLogs, pollInterval);
|
|
3367
|
+
|
|
3368
|
+
// Handle graceful shutdown
|
|
3369
|
+
const cleanup = () => {
|
|
3370
|
+
clearInterval(intervalId);
|
|
3371
|
+
console.log(dim("\n Stopped watching."));
|
|
3372
|
+
process.exit(0);
|
|
3373
|
+
};
|
|
3374
|
+
|
|
3375
|
+
process.on("SIGINT", cleanup);
|
|
3376
|
+
process.on("SIGTERM", cleanup);
|
|
3377
|
+
|
|
3378
|
+
// Keep process alive
|
|
3379
|
+
await new Promise(() => {});
|
|
3380
|
+
return;
|
|
3381
|
+
}
|
|
3382
|
+
|
|
3383
|
+
// Non-watch mode - one-shot output
|
|
3247
3384
|
const rawLines = readLogFiles(logsDir);
|
|
3248
3385
|
|
|
3249
3386
|
// Parse and filter
|
|
@@ -3251,19 +3388,7 @@ async function logs() {
|
|
|
3251
3388
|
.map(parseLogLine)
|
|
3252
3389
|
.filter((log): log is LogLine => log !== null);
|
|
3253
3390
|
|
|
3254
|
-
|
|
3255
|
-
if (moduleFilter) {
|
|
3256
|
-
logs = logs.filter((log) => log.module === moduleFilter);
|
|
3257
|
-
}
|
|
3258
|
-
|
|
3259
|
-
if (levelFilter !== null) {
|
|
3260
|
-
logs = logs.filter((log) => log.level >= levelFilter);
|
|
3261
|
-
}
|
|
3262
|
-
|
|
3263
|
-
if (sinceMs !== null) {
|
|
3264
|
-
const cutoffTime = Date.now() - sinceMs;
|
|
3265
|
-
logs = logs.filter((log) => new Date(log.time).getTime() >= cutoffTime);
|
|
3266
|
-
}
|
|
3391
|
+
logs = filterLogs(logs);
|
|
3267
3392
|
|
|
3268
3393
|
// Apply limit (keep most recent)
|
|
3269
3394
|
logs = logs.slice(-limit);
|
package/package.json
CHANGED
|
@@ -330,254 +330,276 @@ describe("Compaction Hook", () => {
|
|
|
330
330
|
});
|
|
331
331
|
});
|
|
332
332
|
|
|
333
|
-
describe("scanSessionMessages
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
* - client.session.messages({ sessionID, limit }) → { info: Message, parts: Part[] }[]
|
|
346
|
-
*/
|
|
347
|
-
|
|
348
|
-
// Mock SDK client factory
|
|
349
|
-
const createMockClient = (messages: Array<{ info: any; parts: any[] }>) => ({
|
|
350
|
-
session: {
|
|
351
|
-
messages: async ({ sessionID, limit }: { sessionID: string; limit?: number }) => ({
|
|
352
|
-
data: messages,
|
|
353
|
-
error: undefined,
|
|
354
|
-
}),
|
|
355
|
-
},
|
|
333
|
+
describe("scanSessionMessages", () => {
|
|
334
|
+
it("returns empty state when client is undefined", async () => {
|
|
335
|
+
const { scanSessionMessages } = await import("./compaction-hook");
|
|
336
|
+
|
|
337
|
+
const result = await scanSessionMessages(undefined, "session-123");
|
|
338
|
+
|
|
339
|
+
expect(result.epicId).toBeUndefined();
|
|
340
|
+
expect(result.epicTitle).toBeUndefined();
|
|
341
|
+
expect(result.projectPath).toBeUndefined();
|
|
342
|
+
expect(result.agentName).toBeUndefined();
|
|
343
|
+
expect(result.subtasks.size).toBe(0);
|
|
344
|
+
expect(result.lastAction).toBeUndefined();
|
|
356
345
|
});
|
|
357
346
|
|
|
358
|
-
it("extracts epic
|
|
359
|
-
const
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
347
|
+
it("extracts epic data from hive_create_epic tool call", async () => {
|
|
348
|
+
const { scanSessionMessages } = await import("./compaction-hook");
|
|
349
|
+
|
|
350
|
+
// Mock SDK client
|
|
351
|
+
const mockClient = {
|
|
352
|
+
session: {
|
|
353
|
+
messages: async ({ sessionID, limit }: { sessionID: string; limit?: number }) => {
|
|
354
|
+
return [
|
|
355
|
+
{
|
|
356
|
+
info: { id: "msg-1", sessionID: "session-123" },
|
|
357
|
+
parts: [
|
|
358
|
+
{
|
|
359
|
+
id: "part-1",
|
|
360
|
+
sessionID: "session-123",
|
|
361
|
+
messageID: "msg-1",
|
|
362
|
+
type: "tool" as const,
|
|
363
|
+
callID: "call-1",
|
|
364
|
+
tool: "hive_create_epic",
|
|
365
|
+
state: {
|
|
366
|
+
status: "completed" as const,
|
|
367
|
+
input: {
|
|
368
|
+
epic_title: "Add authentication system",
|
|
369
|
+
epic_description: "Implement OAuth flow",
|
|
370
|
+
},
|
|
371
|
+
output: JSON.stringify({
|
|
372
|
+
success: true,
|
|
373
|
+
epic: { id: "bd-epic-123" },
|
|
374
|
+
}),
|
|
375
|
+
title: "Create Epic",
|
|
376
|
+
metadata: {},
|
|
377
|
+
time: { start: Date.now(), end: Date.now() },
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
],
|
|
378
381
|
},
|
|
379
|
-
|
|
380
|
-
|
|
382
|
+
];
|
|
383
|
+
},
|
|
381
384
|
},
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
const client = createMockClient(mockMessages);
|
|
385
|
+
} as any;
|
|
385
386
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
expect(result.epicId).toBe("bd-epic-auth-123");
|
|
391
|
-
expect(result.subtasks.size).toBeGreaterThan(0);
|
|
387
|
+
const result = await scanSessionMessages(mockClient, "session-123");
|
|
388
|
+
|
|
389
|
+
expect(result.epicId).toBe("bd-epic-123");
|
|
390
|
+
expect(result.epicTitle).toBe("Add authentication system");
|
|
392
391
|
});
|
|
393
392
|
|
|
394
|
-
it("extracts agent name from swarmmail_init tool
|
|
395
|
-
const
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
393
|
+
it("extracts agent name from swarmmail_init tool call", async () => {
|
|
394
|
+
const { scanSessionMessages } = await import("./compaction-hook");
|
|
395
|
+
|
|
396
|
+
const mockClient = {
|
|
397
|
+
session: {
|
|
398
|
+
messages: async () => [
|
|
399
399
|
{
|
|
400
|
-
id: "
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
400
|
+
info: { id: "msg-1", sessionID: "session-123" },
|
|
401
|
+
parts: [
|
|
402
|
+
{
|
|
403
|
+
id: "part-1",
|
|
404
|
+
sessionID: "session-123",
|
|
405
|
+
messageID: "msg-1",
|
|
406
|
+
type: "tool" as const,
|
|
407
|
+
callID: "call-1",
|
|
408
|
+
tool: "swarmmail_init",
|
|
409
|
+
state: {
|
|
410
|
+
status: "completed" as const,
|
|
411
|
+
input: {
|
|
412
|
+
project_path: "/test/project",
|
|
413
|
+
task_description: "Working on auth",
|
|
414
|
+
},
|
|
415
|
+
output: JSON.stringify({
|
|
416
|
+
agent_name: "BlueLake",
|
|
417
|
+
project_key: "/test/project",
|
|
418
|
+
}),
|
|
419
|
+
title: "Init Swarm Mail",
|
|
420
|
+
metadata: {},
|
|
421
|
+
time: { start: Date.now(), end: Date.now() },
|
|
422
|
+
},
|
|
408
423
|
},
|
|
409
|
-
|
|
410
|
-
agent_name: "DarkWind",
|
|
411
|
-
project_key: "/Users/joel/project",
|
|
412
|
-
}),
|
|
413
|
-
title: "Initialized swarm mail",
|
|
414
|
-
metadata: {},
|
|
415
|
-
time: { start: 1000, end: 2000 },
|
|
416
|
-
},
|
|
424
|
+
],
|
|
417
425
|
},
|
|
418
426
|
],
|
|
419
427
|
},
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
expect(result.agentName).toBe("DarkWind");
|
|
427
|
-
expect(result.projectPath).toBe("/Users/joel/project");
|
|
428
|
+
} as any;
|
|
429
|
+
|
|
430
|
+
const result = await scanSessionMessages(mockClient, "session-123");
|
|
431
|
+
|
|
432
|
+
expect(result.agentName).toBe("BlueLake");
|
|
433
|
+
expect(result.projectPath).toBe("/test/project");
|
|
428
434
|
});
|
|
429
435
|
|
|
430
|
-
it("extracts
|
|
431
|
-
const
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
436
|
+
it("extracts subtask data from swarm_spawn_subtask tool call", async () => {
|
|
437
|
+
const { scanSessionMessages } = await import("./compaction-hook");
|
|
438
|
+
|
|
439
|
+
const mockClient = {
|
|
440
|
+
session: {
|
|
441
|
+
messages: async () => [
|
|
435
442
|
{
|
|
436
|
-
id: "
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
443
|
+
info: { id: "msg-1", sessionID: "session-123" },
|
|
444
|
+
parts: [
|
|
445
|
+
{
|
|
446
|
+
id: "part-1",
|
|
447
|
+
sessionID: "session-123",
|
|
448
|
+
messageID: "msg-1",
|
|
449
|
+
type: "tool" as const,
|
|
450
|
+
callID: "call-1",
|
|
451
|
+
tool: "swarm_spawn_subtask",
|
|
452
|
+
state: {
|
|
453
|
+
status: "completed" as const,
|
|
454
|
+
input: {
|
|
455
|
+
bead_id: "bd-task-1",
|
|
456
|
+
epic_id: "bd-epic-123",
|
|
457
|
+
subtask_title: "Implement OAuth service",
|
|
458
|
+
files: ["src/auth/oauth.ts"],
|
|
459
|
+
},
|
|
460
|
+
output: JSON.stringify({
|
|
461
|
+
worker: "RedMountain",
|
|
462
|
+
bead_id: "bd-task-1",
|
|
463
|
+
}),
|
|
464
|
+
title: "Spawn Subtask",
|
|
465
|
+
metadata: {},
|
|
466
|
+
time: { start: Date.now(), end: Date.now() },
|
|
467
|
+
},
|
|
448
468
|
},
|
|
449
|
-
|
|
450
|
-
epic_id: "bd-epic-oauth-456",
|
|
451
|
-
subtask_ids: ["bd-epic-oauth-456.1", "bd-epic-oauth-456.2"],
|
|
452
|
-
}),
|
|
453
|
-
title: "Created epic",
|
|
454
|
-
metadata: {},
|
|
455
|
-
time: { start: 1000, end: 2000 },
|
|
456
|
-
},
|
|
469
|
+
],
|
|
457
470
|
},
|
|
458
471
|
],
|
|
459
472
|
},
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
const
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
473
|
+
} as any;
|
|
474
|
+
|
|
475
|
+
const result = await scanSessionMessages(mockClient, "session-123");
|
|
476
|
+
|
|
477
|
+
expect(result.subtasks.size).toBe(1);
|
|
478
|
+
expect(result.subtasks.get("bd-task-1")).toEqual({
|
|
479
|
+
title: "Implement OAuth service",
|
|
480
|
+
status: "spawned",
|
|
481
|
+
worker: "RedMountain",
|
|
482
|
+
files: ["src/auth/oauth.ts"],
|
|
483
|
+
});
|
|
469
484
|
});
|
|
470
485
|
|
|
471
|
-
it("
|
|
472
|
-
const
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
486
|
+
it("marks subtask as completed from swarm_complete tool call", async () => {
|
|
487
|
+
const { scanSessionMessages } = await import("./compaction-hook");
|
|
488
|
+
|
|
489
|
+
const mockClient = {
|
|
490
|
+
session: {
|
|
491
|
+
messages: async () => [
|
|
476
492
|
{
|
|
477
|
-
id: "
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
493
|
+
info: { id: "msg-1", sessionID: "session-123" },
|
|
494
|
+
parts: [
|
|
495
|
+
{
|
|
496
|
+
id: "part-1",
|
|
497
|
+
sessionID: "session-123",
|
|
498
|
+
messageID: "msg-1",
|
|
499
|
+
type: "tool" as const,
|
|
500
|
+
callID: "call-1",
|
|
501
|
+
tool: "swarm_spawn_subtask",
|
|
502
|
+
state: {
|
|
503
|
+
status: "completed" as const,
|
|
504
|
+
input: {
|
|
505
|
+
bead_id: "bd-task-1",
|
|
506
|
+
epic_id: "bd-epic-123",
|
|
507
|
+
subtask_title: "Fix bug",
|
|
508
|
+
files: [],
|
|
509
|
+
},
|
|
510
|
+
output: "{}",
|
|
511
|
+
title: "Spawn",
|
|
512
|
+
metadata: {},
|
|
513
|
+
time: { start: 100, end: 200 },
|
|
514
|
+
},
|
|
515
|
+
},
|
|
516
|
+
{
|
|
517
|
+
id: "part-2",
|
|
518
|
+
sessionID: "session-123",
|
|
519
|
+
messageID: "msg-1",
|
|
520
|
+
type: "tool" as const,
|
|
521
|
+
callID: "call-2",
|
|
522
|
+
tool: "swarm_complete",
|
|
523
|
+
state: {
|
|
524
|
+
status: "completed" as const,
|
|
525
|
+
input: {
|
|
526
|
+
bead_id: "bd-task-1",
|
|
527
|
+
summary: "Fixed the bug",
|
|
528
|
+
},
|
|
529
|
+
output: JSON.stringify({ success: true, closed: true }),
|
|
530
|
+
title: "Complete",
|
|
531
|
+
metadata: {},
|
|
532
|
+
time: { start: 300, end: 400 },
|
|
533
|
+
},
|
|
534
|
+
},
|
|
535
|
+
],
|
|
488
536
|
},
|
|
489
537
|
],
|
|
490
538
|
},
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
const
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
expect(result.lastAction).toBeDefined();
|
|
498
|
-
expect(result.lastAction?.tool).toBe("swarm_status");
|
|
499
|
-
expect(result.lastAction?.timestamp).toBe(6000);
|
|
539
|
+
} as any;
|
|
540
|
+
|
|
541
|
+
const result = await scanSessionMessages(mockClient, "session-123");
|
|
542
|
+
|
|
543
|
+
expect(result.subtasks.get("bd-task-1")?.status).toBe("completed");
|
|
500
544
|
});
|
|
501
545
|
|
|
502
|
-
it("
|
|
503
|
-
const mockMessages = [
|
|
504
|
-
{
|
|
505
|
-
info: { id: "msg-1", sessionID: "sess-1", role: "assistant" },
|
|
506
|
-
parts: [
|
|
507
|
-
{ id: "part-1", type: "text", text: "Hello" },
|
|
508
|
-
{ id: "part-2", type: "reasoning", text: "Thinking..." },
|
|
509
|
-
],
|
|
510
|
-
},
|
|
511
|
-
];
|
|
512
|
-
|
|
513
|
-
const client = createMockClient(mockMessages);
|
|
546
|
+
it("captures last action timestamp", async () => {
|
|
514
547
|
const { scanSessionMessages } = await import("./compaction-hook");
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
});
|
|
520
|
-
|
|
521
|
-
it("ignores pending/running tool states", async () => {
|
|
522
|
-
const mockMessages = [
|
|
523
|
-
{
|
|
524
|
-
info: { id: "msg-1", sessionID: "sess-1", role: "assistant" },
|
|
525
|
-
parts: [
|
|
526
|
-
{
|
|
527
|
-
id: "part-1",
|
|
528
|
-
type: "tool",
|
|
529
|
-
tool: "swarm_spawn_subtask",
|
|
530
|
-
state: {
|
|
531
|
-
status: "pending",
|
|
532
|
-
input: { epic_id: "bd-epic-123" },
|
|
533
|
-
raw: "{}",
|
|
534
|
-
},
|
|
535
|
-
},
|
|
548
|
+
|
|
549
|
+
const mockClient = {
|
|
550
|
+
session: {
|
|
551
|
+
messages: async () => [
|
|
536
552
|
{
|
|
537
|
-
id: "
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
553
|
+
info: { id: "msg-1", sessionID: "session-123" },
|
|
554
|
+
parts: [
|
|
555
|
+
{
|
|
556
|
+
id: "part-1",
|
|
557
|
+
sessionID: "session-123",
|
|
558
|
+
messageID: "msg-1",
|
|
559
|
+
type: "tool" as const,
|
|
560
|
+
callID: "call-1",
|
|
561
|
+
tool: "swarm_status",
|
|
562
|
+
state: {
|
|
563
|
+
status: "completed" as const,
|
|
564
|
+
input: {
|
|
565
|
+
epic_id: "bd-epic-123",
|
|
566
|
+
project_key: "/test",
|
|
567
|
+
},
|
|
568
|
+
output: "{}",
|
|
569
|
+
title: "Check Status",
|
|
570
|
+
metadata: {},
|
|
571
|
+
time: { start: 1000, end: 2000 },
|
|
572
|
+
},
|
|
573
|
+
},
|
|
574
|
+
],
|
|
545
575
|
},
|
|
546
576
|
],
|
|
547
577
|
},
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
const
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
expect(result.epicId).toBeUndefined();
|
|
578
|
+
} as any;
|
|
579
|
+
|
|
580
|
+
const result = await scanSessionMessages(mockClient, "session-123");
|
|
581
|
+
|
|
582
|
+
expect(result.lastAction).toBeDefined();
|
|
583
|
+
expect(result.lastAction?.tool).toBe("swarm_status");
|
|
584
|
+
expect(result.lastAction?.timestamp).toBe(2000);
|
|
556
585
|
});
|
|
557
586
|
|
|
558
|
-
it("
|
|
559
|
-
const
|
|
587
|
+
it("respects limit parameter", async () => {
|
|
588
|
+
const { scanSessionMessages } = await import("./compaction-hook");
|
|
589
|
+
|
|
590
|
+
let capturedLimit: number | undefined;
|
|
591
|
+
const mockClient = {
|
|
560
592
|
session: {
|
|
561
|
-
messages: async () =>
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
}
|
|
593
|
+
messages: async ({ limit }: { limit?: number }) => {
|
|
594
|
+
capturedLimit = limit;
|
|
595
|
+
return [];
|
|
596
|
+
},
|
|
565
597
|
},
|
|
566
|
-
};
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
// Should return empty state, not throw
|
|
572
|
-
expect(result.subtasks.size).toBe(0);
|
|
573
|
-
expect(result.epicId).toBeUndefined();
|
|
574
|
-
});
|
|
575
|
-
|
|
576
|
-
it("returns empty state when client is undefined", async () => {
|
|
577
|
-
const { scanSessionMessages } = await import("./compaction-hook");
|
|
578
|
-
const result = await scanSessionMessages(undefined as any, "sess-1");
|
|
579
|
-
|
|
580
|
-
expect(result.subtasks.size).toBe(0);
|
|
598
|
+
} as any;
|
|
599
|
+
|
|
600
|
+
await scanSessionMessages(mockClient, "session-123", 50);
|
|
601
|
+
|
|
602
|
+
expect(capturedLimit).toBe(50);
|
|
581
603
|
});
|
|
582
604
|
});
|
|
583
605
|
|
package/src/compaction-hook.ts
CHANGED
|
@@ -216,6 +216,203 @@ function buildDynamicSwarmState(state: SwarmState): string {
|
|
|
216
216
|
return parts.join("\n");
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
+
// ============================================================================
|
|
220
|
+
// SDK Message Scanning
|
|
221
|
+
// ============================================================================
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Tool part with completed state containing input/output
|
|
225
|
+
*/
|
|
226
|
+
interface ToolPart {
|
|
227
|
+
id: string;
|
|
228
|
+
sessionID: string;
|
|
229
|
+
messageID: string;
|
|
230
|
+
type: "tool";
|
|
231
|
+
callID: string;
|
|
232
|
+
tool: string;
|
|
233
|
+
state: ToolState;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Tool state (completed tools have input/output we need)
|
|
238
|
+
*/
|
|
239
|
+
type ToolState = {
|
|
240
|
+
status: "completed";
|
|
241
|
+
input: { [key: string]: unknown };
|
|
242
|
+
output: string;
|
|
243
|
+
title: string;
|
|
244
|
+
metadata: { [key: string]: unknown };
|
|
245
|
+
time: { start: number; end: number };
|
|
246
|
+
} | {
|
|
247
|
+
status: string;
|
|
248
|
+
[key: string]: unknown;
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* SDK Client type (minimal interface for scanSessionMessages)
|
|
253
|
+
*/
|
|
254
|
+
interface OpencodeClient {
|
|
255
|
+
session: {
|
|
256
|
+
messages: (opts: { sessionID: string; limit?: number }) => Promise<{
|
|
257
|
+
info: { id: string; sessionID: string };
|
|
258
|
+
parts: ToolPart[];
|
|
259
|
+
}[]>;
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Scanned swarm state extracted from session messages
|
|
265
|
+
*/
|
|
266
|
+
export interface ScannedSwarmState {
|
|
267
|
+
epicId?: string;
|
|
268
|
+
epicTitle?: string;
|
|
269
|
+
projectPath?: string;
|
|
270
|
+
agentName?: string;
|
|
271
|
+
subtasks: Map<string, { title: string; status: string; worker?: string; files?: string[] }>;
|
|
272
|
+
lastAction?: { tool: string; args: unknown; timestamp: number };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Scan session messages for swarm state using SDK client
|
|
277
|
+
*
|
|
278
|
+
* Extracts swarm coordination state from actual tool calls:
|
|
279
|
+
* - swarm_spawn_subtask → subtask tracking
|
|
280
|
+
* - swarmmail_init → agent name, project path
|
|
281
|
+
* - hive_create_epic → epic ID and title
|
|
282
|
+
* - swarm_status → epic reference
|
|
283
|
+
* - swarm_complete → subtask completion
|
|
284
|
+
*
|
|
285
|
+
* @param client - OpenCode SDK client (undefined if not available)
|
|
286
|
+
* @param sessionID - Session to scan
|
|
287
|
+
* @param limit - Max messages to fetch (default 100)
|
|
288
|
+
* @returns Extracted swarm state
|
|
289
|
+
*/
|
|
290
|
+
export async function scanSessionMessages(
|
|
291
|
+
client: OpencodeClient | undefined,
|
|
292
|
+
sessionID: string,
|
|
293
|
+
limit: number = 100
|
|
294
|
+
): Promise<ScannedSwarmState> {
|
|
295
|
+
const state: ScannedSwarmState = {
|
|
296
|
+
subtasks: new Map(),
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
if (!client) {
|
|
300
|
+
return state;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
const messages = await client.session.messages({ sessionID, limit });
|
|
305
|
+
|
|
306
|
+
for (const message of messages) {
|
|
307
|
+
for (const part of message.parts) {
|
|
308
|
+
if (part.type !== "tool" || part.state.status !== "completed") {
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const { tool, state: toolState } = part;
|
|
313
|
+
const { input, output, time } = toolState as Extract<ToolState, { status: "completed" }>;
|
|
314
|
+
|
|
315
|
+
// Track last action
|
|
316
|
+
state.lastAction = {
|
|
317
|
+
tool,
|
|
318
|
+
args: input,
|
|
319
|
+
timestamp: time.end,
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
// Extract swarm state based on tool type
|
|
323
|
+
switch (tool) {
|
|
324
|
+
case "hive_create_epic": {
|
|
325
|
+
try {
|
|
326
|
+
const parsed = JSON.parse(output);
|
|
327
|
+
if (parsed.epic?.id) {
|
|
328
|
+
state.epicId = parsed.epic.id;
|
|
329
|
+
}
|
|
330
|
+
if (input.epic_title && typeof input.epic_title === "string") {
|
|
331
|
+
state.epicTitle = input.epic_title;
|
|
332
|
+
}
|
|
333
|
+
} catch {
|
|
334
|
+
// Invalid JSON, skip
|
|
335
|
+
}
|
|
336
|
+
break;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
case "swarmmail_init": {
|
|
340
|
+
try {
|
|
341
|
+
const parsed = JSON.parse(output);
|
|
342
|
+
if (parsed.agent_name) {
|
|
343
|
+
state.agentName = parsed.agent_name;
|
|
344
|
+
}
|
|
345
|
+
if (parsed.project_key) {
|
|
346
|
+
state.projectPath = parsed.project_key;
|
|
347
|
+
}
|
|
348
|
+
} catch {
|
|
349
|
+
// Invalid JSON, skip
|
|
350
|
+
}
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
case "swarm_spawn_subtask": {
|
|
355
|
+
const beadId = input.bead_id as string | undefined;
|
|
356
|
+
const epicId = input.epic_id as string | undefined;
|
|
357
|
+
const title = input.subtask_title as string | undefined;
|
|
358
|
+
const files = input.files as string[] | undefined;
|
|
359
|
+
|
|
360
|
+
if (beadId && title) {
|
|
361
|
+
let worker: string | undefined;
|
|
362
|
+
try {
|
|
363
|
+
const parsed = JSON.parse(output);
|
|
364
|
+
worker = parsed.worker;
|
|
365
|
+
} catch {
|
|
366
|
+
// No worker in output
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
state.subtasks.set(beadId, {
|
|
370
|
+
title,
|
|
371
|
+
status: "spawned",
|
|
372
|
+
worker,
|
|
373
|
+
files,
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
if (epicId && !state.epicId) {
|
|
377
|
+
state.epicId = epicId;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
break;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
case "swarm_complete": {
|
|
384
|
+
const beadId = input.bead_id as string | undefined;
|
|
385
|
+
if (beadId && state.subtasks.has(beadId)) {
|
|
386
|
+
const existing = state.subtasks.get(beadId)!;
|
|
387
|
+
state.subtasks.set(beadId, {
|
|
388
|
+
...existing,
|
|
389
|
+
status: "completed",
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
break;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
case "swarm_status": {
|
|
396
|
+
const epicId = input.epic_id as string | undefined;
|
|
397
|
+
if (epicId && !state.epicId) {
|
|
398
|
+
state.epicId = epicId;
|
|
399
|
+
}
|
|
400
|
+
const projectKey = input.project_key as string | undefined;
|
|
401
|
+
if (projectKey && !state.projectPath) {
|
|
402
|
+
state.projectPath = projectKey;
|
|
403
|
+
}
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
} catch (error) {
|
|
410
|
+
// SDK not available or error fetching messages - return what we have
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return state;
|
|
414
|
+
}
|
|
415
|
+
|
|
219
416
|
// ============================================================================
|
|
220
417
|
// Swarm Detection
|
|
221
418
|
// ============================================================================
|
package/src/swarm-decompose.ts
CHANGED
|
@@ -434,12 +434,6 @@ export const swarm_decompose = tool({
|
|
|
434
434
|
"Generate decomposition prompt for breaking task into parallelizable subtasks. Optionally queries CASS for similar past tasks.",
|
|
435
435
|
args: {
|
|
436
436
|
task: tool.schema.string().min(1).describe("Task description to decompose"),
|
|
437
|
-
max_subtasks: tool.schema
|
|
438
|
-
.number()
|
|
439
|
-
.int()
|
|
440
|
-
.min(1)
|
|
441
|
-
.optional()
|
|
442
|
-
.describe("Suggested max subtasks (optional - LLM decides if not specified)"),
|
|
443
437
|
context: tool.schema
|
|
444
438
|
.string()
|
|
445
439
|
.optional()
|
|
@@ -503,7 +497,6 @@ export const swarm_decompose = tool({
|
|
|
503
497
|
: "## Additional Context\n(none provided)";
|
|
504
498
|
|
|
505
499
|
const prompt = DECOMPOSITION_PROMPT.replace("{task}", args.task)
|
|
506
|
-
.replace("{max_subtasks}", (args.max_subtasks ?? 5).toString())
|
|
507
500
|
.replace("{context_section}", contextSection);
|
|
508
501
|
|
|
509
502
|
// Return the prompt and schema info for the caller
|
package/src/swarm-prompts.ts
CHANGED
|
@@ -1393,12 +1393,6 @@ export const swarm_plan_prompt = tool({
|
|
|
1393
1393
|
.enum(["file-based", "feature-based", "risk-based", "auto"])
|
|
1394
1394
|
.optional()
|
|
1395
1395
|
.describe("Decomposition strategy (default: auto-detect)"),
|
|
1396
|
-
max_subtasks: tool.schema
|
|
1397
|
-
.number()
|
|
1398
|
-
.int()
|
|
1399
|
-
.min(1)
|
|
1400
|
-
.optional()
|
|
1401
|
-
.describe("Suggested max subtasks (optional - LLM decides if not specified)"),
|
|
1402
1396
|
context: tool.schema
|
|
1403
1397
|
.string()
|
|
1404
1398
|
.optional()
|
|
@@ -1482,8 +1476,7 @@ export const swarm_plan_prompt = tool({
|
|
|
1482
1476
|
.replace("{strategy_guidelines}", strategyGuidelines)
|
|
1483
1477
|
.replace("{context_section}", contextSection)
|
|
1484
1478
|
.replace("{cass_history}", "") // Empty for now
|
|
1485
|
-
.replace("{skills_context}", skillsContext || "")
|
|
1486
|
-
.replace("{max_subtasks}", (args.max_subtasks ?? 5).toString());
|
|
1479
|
+
.replace("{skills_context}", skillsContext || "");
|
|
1487
1480
|
|
|
1488
1481
|
return JSON.stringify(
|
|
1489
1482
|
{
|