@aeondave/pi-persona 1.15.2 → 1.16.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/AGENTS.md +32 -6
- package/CHANGELOG.md +97 -0
- package/README.md +27 -8
- package/SECURITY.md +42 -0
- package/docs/ARCHITECTURE.md +52 -7
- package/docs/EXPERIENCE.md +21 -5
- package/docs/README.md +2 -0
- package/docs/REFERENCE.md +84 -14
- package/docs/STRATEGIES.md +58 -31
- package/docs/TELEMETRY.md +14 -0
- package/package.json +18 -11
- package/src/bus/broker/paths.ts +3 -2
- package/src/core/brief.ts +1 -1
- package/src/core/nudge.ts +3 -4
- package/src/core/pi-compat.ts +3 -3
- package/src/engine/adapter.ts +11 -6
- package/src/engine/async.ts +12 -6
- package/src/engine/inproc.ts +14 -6
- package/src/extension/engine.ts +25 -4
- package/src/extension/hooks.ts +68 -21
- package/src/extension.ts +233 -57
- package/src/orchestration/model-retry.ts +32 -61
- package/src/orchestration/params.ts +27 -1
- package/src/orchestration/roster.ts +80 -3
- package/src/orchestration/sdk.ts +150 -36
- package/src/orchestration/strategies/compete.ts +14 -8
- package/src/orchestration/strategies/council-rounds.ts +8 -1
- package/src/orchestration/strategies/critic-loop.ts +25 -11
- package/src/orchestration/strategies/judge.ts +18 -9
- package/src/orchestration/strategies/magi.ts +42 -93
- package/src/orchestration/strategies/map.ts +41 -19
- package/src/orchestration/strategies/pipeline.ts +2 -2
- package/src/orchestration/strategies/synthesize.ts +18 -11
- package/src/orchestration/types.ts +15 -0
- package/src/persona/model-participants.ts +240 -0
- package/src/persona/orchestrate.ts +44 -2
- package/src/persona/persona.ts +86 -14
- package/src/telemetry/contract.ts +1 -1
- package/src/telemetry/producer.ts +241 -8
- package/src/tools/council.ts +36 -8
- package/src/tools/delegate-tool.ts +162 -24
- package/src/tools/intercom-tool.ts +119 -32
- package/src/ui/agent-overlay.ts +15 -6
- package/src/ui/agent-tree.ts +39 -2
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { appendFile, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
2
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
1
|
+
import { appendFile, lstat, mkdir, opendir, readFile, readdir, realpath, rename, rmdir, stat, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { appendFileSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmdirSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
4
5
|
|
|
5
6
|
import {
|
|
6
7
|
TELEMETRY_PRODUCER_ID,
|
|
@@ -27,6 +28,7 @@ export interface TelemetryProducerOptions {
|
|
|
27
28
|
heartbeat?: () => Partial<InstanceDescriptor>;
|
|
28
29
|
onError?: (error: unknown) => void;
|
|
29
30
|
maxFileBytes?: number;
|
|
31
|
+
retentionMs?: number;
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
/** Richer caller input. Prompt/activity/output fields are accepted only so the sink can drop them. */
|
|
@@ -37,6 +39,15 @@ const STRING_LIMIT = 512;
|
|
|
37
39
|
const ARRAY_LIMIT = 256;
|
|
38
40
|
const MAX_DEPTH = 8;
|
|
39
41
|
const DEFAULT_MAX_FILE_BYTES = 4 * 1024 * 1024;
|
|
42
|
+
export const DEFAULT_TELEMETRY_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
43
|
+
|
|
44
|
+
export class TelemetryAdmissionError extends Error {
|
|
45
|
+
constructor(message: string) { super(message); this.name = "TelemetryAdmissionError"; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const markerName = (kind: "writer" | "prune") => `${kind}-${process.pid}-${randomUUID()}`;
|
|
49
|
+
const markerPattern = /^(writer|prune)-(\d+)-([0-9a-f-]{36})$/;
|
|
50
|
+
const workspacePattern = /^[0-9a-f]{24}$/;
|
|
40
51
|
|
|
41
52
|
const TERMINAL_AGENT_STATUSES = new Set(["done", "failed", "stopped"]);
|
|
42
53
|
|
|
@@ -279,9 +290,194 @@ function sanitizeAgentPatch(value: Partial<TelemetryAgentInput>): Partial<AgentD
|
|
|
279
290
|
};
|
|
280
291
|
}
|
|
281
292
|
|
|
293
|
+
function validProducerId(value: string): boolean {
|
|
294
|
+
return value !== "." && value !== ".." && /^[A-Za-z0-9._-]{1,96}$/.test(value);
|
|
295
|
+
}
|
|
296
|
+
|
|
282
297
|
function producerSegment(value: string | undefined, fallback: string): string {
|
|
283
298
|
const safe = value?.trim().replace(/[^A-Za-z0-9._-]/g, "-").slice(0, 96);
|
|
284
|
-
return safe
|
|
299
|
+
return safe && validProducerId(safe) ? safe : fallback;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function pidState(pid: number): "alive" | "dead" | "unknown" {
|
|
303
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return "unknown";
|
|
304
|
+
if (pid === process.pid) return "alive";
|
|
305
|
+
try { process.kill(pid, 0); return "alive"; }
|
|
306
|
+
catch (error) { return (error as NodeJS.ErrnoException).code === "ESRCH" ? "dead" : "unknown"; }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function isMissing(error: unknown): boolean {
|
|
310
|
+
return (error as NodeJS.ErrnoException).code === "ENOENT";
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** The supplied agent directory is trusted; linked descendants of its canonical root are not. */
|
|
314
|
+
function namespaceParts(root: string, path: string): string[] {
|
|
315
|
+
const parts: string[] = [];
|
|
316
|
+
for (let current = path; current !== root; current = dirname(current)) {
|
|
317
|
+
if (dirname(current) === current) throw new TelemetryAdmissionError("Unsafe telemetry namespace");
|
|
318
|
+
parts.unshift(current);
|
|
319
|
+
}
|
|
320
|
+
return parts;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function ensureDirectoriesSync(root: string, path: string, created: string[]): void {
|
|
324
|
+
for (const part of namespaceParts(root, path)) {
|
|
325
|
+
try { mkdirSync(part, { mode: 0o700 }); created.push(part); }
|
|
326
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
|
|
327
|
+
if (!lstatSync(part).isDirectory()) throw new TelemetryAdmissionError("Unsafe linked telemetry directory");
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Roll back only directories created by this admission attempt, from leaf to root. */
|
|
332
|
+
function removeCreatedDirectoriesSync(created: readonly string[], onError?: (error: unknown) => void): void {
|
|
333
|
+
for (let index = created.length - 1; index >= 0; index -= 1) {
|
|
334
|
+
try { rmdirSync(created[index]!); }
|
|
335
|
+
catch (error) {
|
|
336
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
337
|
+
if (code !== "ENOENT" && code !== "ENOTEMPTY" && code !== "EEXIST") reportRetentionError(onError, error);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function safeDirectories(root: string, path: string): Promise<boolean> {
|
|
343
|
+
for (const part of namespaceParts(root, path)) {
|
|
344
|
+
try { if (!(await lstat(part)).isDirectory()) return false; }
|
|
345
|
+
catch (error) { if (isMissing(error)) return false; throw error; }
|
|
346
|
+
}
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function assertSafeArtifactSync(file: string): void {
|
|
351
|
+
try {
|
|
352
|
+
const info = lstatSync(file);
|
|
353
|
+
if (!info.isFile() || info.nlink !== 1) throw new TelemetryAdmissionError("Unsafe linked telemetry artifact");
|
|
354
|
+
} catch (error) { if (!isMissing(error)) throw error; }
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function assertNoPruneClaimsSync(dir: string): void {
|
|
358
|
+
for (const name of readdirSync(dir)) {
|
|
359
|
+
const match = markerPattern.exec(name);
|
|
360
|
+
if (!match) throw new TelemetryAdmissionError("Unknown telemetry lease marker");
|
|
361
|
+
const marker = join(dir, name);
|
|
362
|
+
let info;
|
|
363
|
+
try { info = lstatSync(marker); } catch (error) { if (isMissing(error)) continue; throw error; }
|
|
364
|
+
if (!info.isFile() || info.nlink !== 1 || info.size !== 0) throw new TelemetryAdmissionError("Unsafe telemetry lease marker");
|
|
365
|
+
if (match[1] === "prune") {
|
|
366
|
+
if (pidState(Number(match[2])) !== "dead") throw new TelemetryAdmissionError("Active telemetry prune claim");
|
|
367
|
+
try { unlinkSync(marker); } catch (error) { if (!isMissing(error)) throw error; }
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
type RetentionErrorSink = ((error: unknown) => void) | undefined;
|
|
373
|
+
function reportRetentionError(sink: RetentionErrorSink, error: unknown): void {
|
|
374
|
+
try { sink?.(error); } catch { /* diagnostics must not break the host session */ }
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const retentionJobs = new Map<string, Promise<void>>();
|
|
378
|
+
function scheduleTelemetryRetention(agentDir: string, producerId: string, retentionMs: number, onError: RetentionErrorSink): Promise<void> {
|
|
379
|
+
const key = `${resolve(agentDir)}\0${producerId}`;
|
|
380
|
+
const existing = retentionJobs.get(key);
|
|
381
|
+
if (existing) return existing;
|
|
382
|
+
const job = flushTelemetryRetention(agentDir, producerId, retentionMs, Date.now(), onError)
|
|
383
|
+
.catch((error) => reportRetentionError(onError, error))
|
|
384
|
+
.finally(() => retentionJobs.delete(key));
|
|
385
|
+
retentionJobs.set(key, job);
|
|
386
|
+
return job;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async function removeEmptyDir(dir: string): Promise<void> {
|
|
390
|
+
try { await rmdir(dir); }
|
|
391
|
+
catch (error) {
|
|
392
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
393
|
+
if (code !== "ENOENT" && code !== "ENOTEMPTY" && code !== "EEXIST") throw error;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const artifactPattern = /^([0-9a-f]{16})\.jsonl(?:\.previous|\.trim-\d+-\d+)?$/;
|
|
398
|
+
const leaseDirPattern = /^([0-9a-f]{16})\.jsonl\.leases$/;
|
|
399
|
+
|
|
400
|
+
async function oldArtifacts(dir: string, session: string, cutoff: number): Promise<string[] | undefined> {
|
|
401
|
+
const group = (await readdir(dir)).filter((name) => artifactPattern.exec(name)?.[1] === session);
|
|
402
|
+
for (const name of group) {
|
|
403
|
+
let info;
|
|
404
|
+
try { info = await lstat(join(dir, name)); }
|
|
405
|
+
catch (error) { if (isMissing(error)) return undefined; throw error; }
|
|
406
|
+
if (!info.isFile() || info.nlink !== 1 || info.mtimeMs >= cutoff) return undefined;
|
|
407
|
+
}
|
|
408
|
+
return group;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function hasProtectedWriter(leaseDir: string, ownClaim: string): Promise<boolean> {
|
|
412
|
+
for (const name of await readdir(leaseDir)) {
|
|
413
|
+
if (name === basename(ownClaim)) continue;
|
|
414
|
+
const match = markerPattern.exec(name);
|
|
415
|
+
if (!match) return true;
|
|
416
|
+
const path = join(leaseDir, name);
|
|
417
|
+
let info;
|
|
418
|
+
try { info = await lstat(path); } catch (error) { if (isMissing(error)) continue; throw error; }
|
|
419
|
+
if (!info.isFile() || info.nlink !== 1 || info.size !== 0) return true;
|
|
420
|
+
const state = pidState(Number(match[2]));
|
|
421
|
+
if (state === "unknown" || (match[1] === "writer" && state === "alive")) return true;
|
|
422
|
+
if (state === "dead") {
|
|
423
|
+
try { await unlink(path); } catch (error) { if (!isMissing(error)) throw error; }
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Cooperative same-host retention; unknown/linked objects and live writer leases are retained.
|
|
430
|
+
* Pre-protocol processes are protected only by recent writes, not by these leases. */
|
|
431
|
+
export async function flushTelemetryRetention(
|
|
432
|
+
agentDir: string, producerId: string, retentionMs: number, now = Date.now(), onError?: (error: unknown) => void,
|
|
433
|
+
): Promise<void> {
|
|
434
|
+
if (!validProducerId(producerId)) throw new RangeError("Unsafe telemetry producerId");
|
|
435
|
+
if (!Number.isSafeInteger(retentionMs) || retentionMs < 0) throw new RangeError("retentionMs must be a safe integer >= 0");
|
|
436
|
+
if (retentionMs === 0) return;
|
|
437
|
+
let base: string;
|
|
438
|
+
try { base = await realpath(agentDir); } catch (error) { if (isMissing(error)) return; throw error; }
|
|
439
|
+
const root = join(base, "telemetry", "v2");
|
|
440
|
+
if (!(await safeDirectories(base, root))) return;
|
|
441
|
+
for await (const entry of await opendir(root)) {
|
|
442
|
+
if (!workspacePattern.test(entry.name) || !entry.isDirectory()) continue;
|
|
443
|
+
const workspaceDir = join(root, entry.name);
|
|
444
|
+
const producerDir = join(workspaceDir, producerId);
|
|
445
|
+
try {
|
|
446
|
+
if (!(await safeDirectories(base, producerDir))) continue;
|
|
447
|
+
const sessions = new Set((await readdir(producerDir)).flatMap((name) => {
|
|
448
|
+
const session = artifactPattern.exec(name)?.[1] ?? leaseDirPattern.exec(name)?.[1];
|
|
449
|
+
return session ? [session] : [];
|
|
450
|
+
}));
|
|
451
|
+
for (const session of sessions) {
|
|
452
|
+
const leaseDir = join(producerDir, `${session}.jsonl.leases`);
|
|
453
|
+
let claim: string | undefined;
|
|
454
|
+
try {
|
|
455
|
+
if (await oldArtifacts(producerDir, session, now - retentionMs) === undefined) continue;
|
|
456
|
+
try { await mkdir(leaseDir, { mode: 0o700 }); }
|
|
457
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
|
|
458
|
+
if (!(await safeDirectories(base, leaseDir))) continue;
|
|
459
|
+
claim = join(leaseDir, markerName("prune"));
|
|
460
|
+
await writeFile(claim, "", { flag: "wx", mode: 0o600 });
|
|
461
|
+
if (await hasProtectedWriter(leaseDir, claim)) continue;
|
|
462
|
+
// Re-enumerate AFTER the claim: a fresh backup/scratch retains the whole group.
|
|
463
|
+
const group = await oldArtifacts(producerDir, session, now - retentionMs);
|
|
464
|
+
if (!group || !(await safeDirectories(base, producerDir))) continue;
|
|
465
|
+
for (const name of group) {
|
|
466
|
+
try { await unlink(join(producerDir, name)); }
|
|
467
|
+
catch (error) { if (!isMissing(error)) throw error; }
|
|
468
|
+
}
|
|
469
|
+
} catch (error) { if (!isMissing(error)) reportRetentionError(onError, error); }
|
|
470
|
+
finally {
|
|
471
|
+
if (claim) {
|
|
472
|
+
try { await unlink(claim); } catch (error) { if (!isMissing(error)) reportRetentionError(onError, error); }
|
|
473
|
+
try { await removeEmptyDir(leaseDir); } catch (error) { reportRetentionError(onError, error); }
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
await removeEmptyDir(producerDir);
|
|
478
|
+
await removeEmptyDir(workspaceDir);
|
|
479
|
+
} catch (error) { if (!isMissing(error)) reportRetentionError(onError, error); }
|
|
480
|
+
}
|
|
285
481
|
}
|
|
286
482
|
|
|
287
483
|
export class TelemetryProducer {
|
|
@@ -293,6 +489,8 @@ export class TelemetryProducer {
|
|
|
293
489
|
private heartbeatTimer: ReturnType<typeof setInterval> | undefined;
|
|
294
490
|
private instance: InstanceDescriptor | undefined;
|
|
295
491
|
private stopped = false;
|
|
492
|
+
private leasePath: string;
|
|
493
|
+
private retentionSweep: Promise<void> = Promise.resolve();
|
|
296
494
|
readonly producerId: string;
|
|
297
495
|
readonly producerVersion: string;
|
|
298
496
|
|
|
@@ -301,11 +499,35 @@ export class TelemetryProducer {
|
|
|
301
499
|
this.producerId = producerSegment(options.producerId, TELEMETRY_PRODUCER_ID);
|
|
302
500
|
this.producerVersion = producerSegment(options.producerVersion, TELEMETRY_PRODUCER_VERSION);
|
|
303
501
|
if (options.maxFileBytes !== undefined && (!Number.isSafeInteger(options.maxFileBytes) || options.maxFileBytes < 512)) throw new RangeError("maxFileBytes must be at least 512");
|
|
502
|
+
const retentionMs = options.retentionMs ?? DEFAULT_TELEMETRY_RETENTION_MS;
|
|
503
|
+
if (!Number.isSafeInteger(retentionMs) || retentionMs < 0) throw new RangeError("retentionMs must be a safe integer >= 0");
|
|
304
504
|
this.workspaceId = telemetryWorkspaceId(options.cwd);
|
|
305
|
-
|
|
306
|
-
|
|
505
|
+
mkdirSync(options.agentDir, { recursive: true, mode: 0o700 });
|
|
506
|
+
const base = realpathSync(options.agentDir);
|
|
507
|
+
const dir = join(base, "telemetry", "v2", this.workspaceId, this.producerId);
|
|
307
508
|
const fileKey = telemetrySessionFileKey(options.sessionId);
|
|
308
509
|
this.filePath = join(dir, `${fileKey}.jsonl`);
|
|
510
|
+
const leaseDir = `${this.filePath}.leases`;
|
|
511
|
+
this.leasePath = join(leaseDir, markerName("writer"));
|
|
512
|
+
const createdDirectories: string[] = [];
|
|
513
|
+
let leaseCreated = false;
|
|
514
|
+
try {
|
|
515
|
+
ensureDirectoriesSync(base, dir, createdDirectories);
|
|
516
|
+
ensureDirectoriesSync(base, leaseDir, createdDirectories);
|
|
517
|
+
const fd = openSync(this.leasePath, "wx", 0o600);
|
|
518
|
+
leaseCreated = true;
|
|
519
|
+
closeSync(fd);
|
|
520
|
+
assertNoPruneClaimsSync(leaseDir);
|
|
521
|
+
for (const name of readdirSync(dir)) {
|
|
522
|
+
if (artifactPattern.exec(name)?.[1] === fileKey) assertSafeArtifactSync(join(dir, name));
|
|
523
|
+
}
|
|
524
|
+
} catch (error) {
|
|
525
|
+
if (leaseCreated) {
|
|
526
|
+
try { unlinkSync(this.leasePath); } catch { /* remove only our unique marker */ }
|
|
527
|
+
}
|
|
528
|
+
removeCreatedDirectoriesSync(createdDirectories, options.onError);
|
|
529
|
+
throw new TelemetryAdmissionError(`Cannot acquire telemetry writer lease: ${error instanceof Error ? error.message : String(error)}`);
|
|
530
|
+
}
|
|
309
531
|
const backup = `${this.filePath}.previous`;
|
|
310
532
|
if (!existsSync(this.filePath) && existsSync(backup)) {
|
|
311
533
|
try { renameSync(backup, this.filePath); } catch { /* a concurrent activation will retry on its own */ }
|
|
@@ -313,7 +535,7 @@ export class TelemetryProducer {
|
|
|
313
535
|
try { unlinkSync(backup); } catch { /* best effort */ }
|
|
314
536
|
}
|
|
315
537
|
try {
|
|
316
|
-
for (const name of readdirSync(dir)) if (
|
|
538
|
+
for (const name of readdirSync(dir)) if (artifactPattern.exec(name)?.[1] === fileKey && name.includes(".trim-")) unlinkSync(join(dir, name));
|
|
317
539
|
} catch { /* best effort cleanup of interrupted compaction scratch files */ }
|
|
318
540
|
const { seq, needsNewline } = lastSequence(this.filePath);
|
|
319
541
|
this.seq = seq;
|
|
@@ -323,6 +545,7 @@ export class TelemetryProducer {
|
|
|
323
545
|
if (needsNewline) {
|
|
324
546
|
try { appendFileSync(this.filePath, "\n", { encoding: "utf8", mode: 0o600 }); } catch { /* the write chain reports a real append failure */ }
|
|
325
547
|
}
|
|
548
|
+
if (retentionMs > 0) this.retentionSweep = scheduleTelemetryRetention(base, this.producerId, retentionMs, options.onError);
|
|
326
549
|
}
|
|
327
550
|
|
|
328
551
|
start(instance: InstanceDescriptor): void {
|
|
@@ -379,12 +602,22 @@ export class TelemetryProducer {
|
|
|
379
602
|
await this.writeChain;
|
|
380
603
|
}
|
|
381
604
|
|
|
605
|
+
async flushRetention(): Promise<void> { await this.retentionSweep; }
|
|
606
|
+
|
|
382
607
|
async stop(reason?: string): Promise<void> {
|
|
383
608
|
if (this.stopped) return;
|
|
384
609
|
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
|
385
610
|
this.heartbeatTimer = undefined;
|
|
386
611
|
this.publish("instance.stopped", { reason: reason ? boundedText(reason, 120) : "shutdown" });
|
|
387
612
|
this.stopped = true;
|
|
388
|
-
|
|
613
|
+
try {
|
|
614
|
+
await this.flush();
|
|
615
|
+
// Retention is shared background maintenance, not part of this writer's lifetime.
|
|
616
|
+
// Keep the lease through terminal writes, but do not delay host shutdown/reload
|
|
617
|
+
// on a sweep of unrelated sessions. flushRetention() remains an explicit join.
|
|
618
|
+
} finally {
|
|
619
|
+
try { await unlink(this.leasePath); } catch (error) { if (!isMissing(error)) reportRetentionError(this.options.onError, error); }
|
|
620
|
+
try { await removeEmptyDir(dirname(this.leasePath)); } catch (error) { reportRetentionError(this.options.onError, error); }
|
|
621
|
+
}
|
|
389
622
|
}
|
|
390
623
|
}
|
package/src/tools/council.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { Type } from "typebox";
|
|
|
4
4
|
import { fenceUntrusted } from "../core/fence.ts";
|
|
5
5
|
import { expandDetailHint, failureDetails, formatCouncilCallLabel } from "../extension/shared.ts";
|
|
6
6
|
import { knownParams, strategyNames } from "../orchestration/strategy.ts";
|
|
7
|
+
import { memberBaseLabel, type RosterMember } from "../orchestration/roster.ts";
|
|
7
8
|
import { formatCouncilResult, humanizeAggregateResult } from "../orchestration/render.ts";
|
|
8
9
|
import type { FailureKind } from "../orchestration/types.ts";
|
|
9
10
|
import type { AgentResult } from "../orchestration/types.ts";
|
|
@@ -34,6 +35,13 @@ export interface CouncilToolDeps {
|
|
|
34
35
|
publishPersonaCost(): void;
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
/** What the card names as the panel: the team, or the ad-hoc members themselves — an
|
|
39
|
+
* ephemeral call has no team name, so the resolver leaves `roster` empty for it. */
|
|
40
|
+
function panelLabel(value: { roster: string; members?: RosterMember[] | undefined }): string {
|
|
41
|
+
if (!value.members || value.members.length === 0) return value.roster;
|
|
42
|
+
return value.members.map((m) => memberBaseLabel(m)).join(", ");
|
|
43
|
+
}
|
|
44
|
+
|
|
37
45
|
export function registerCouncilTool(pi: ExtensionAPI, d: CouncilToolDeps): void {
|
|
38
46
|
// ── council tool (deliberate → vote → ruling; the executor then applies it) ───
|
|
39
47
|
const CouncilParams = Type.Object({
|
|
@@ -48,6 +56,12 @@ export function registerCouncilTool(pi: ExtensionAPI, d: CouncilToolDeps): void
|
|
|
48
56
|
Type.String({ description: 'Per-call strategy override (default: the selected or active council profile\'s strategy, or built-in "magi")' }),
|
|
49
57
|
),
|
|
50
58
|
roster: Type.Optional(Type.String({ description: 'Per-call roster override (default: the selected or active profile\'s roster, then its orchestration roster, or built-in "magi")' })),
|
|
59
|
+
members: Type.Optional(
|
|
60
|
+
Type.Array(Type.Unknown(), {
|
|
61
|
+
description:
|
|
62
|
+
'Ephemeral panel members for THIS call — each an installed agent name ("scout") or an inline { agent, role?, model?, skills? } specialisation. They win `roster` and the profile\'s own members for this call only: no team file, persona file, or inherited model/tool is touched. Omit to use the named roster.',
|
|
63
|
+
}),
|
|
64
|
+
),
|
|
51
65
|
params: Type.Optional(
|
|
52
66
|
Type.Record(Type.String(), Type.Unknown(), {
|
|
53
67
|
description:
|
|
@@ -61,9 +75,15 @@ export function registerCouncilTool(pi: ExtensionAPI, d: CouncilToolDeps): void
|
|
|
61
75
|
description: [
|
|
62
76
|
"Convene a council of specialists with controlled, complementary biases to deliberate a",
|
|
63
77
|
"decision — returns the selected strategy's result, preserving vote tally and dissent when produced.",
|
|
64
|
-
"
|
|
65
|
-
"
|
|
66
|
-
"
|
|
78
|
+
"Scale it to the decision: convene for a genuine fork, a contested or high-stakes call, or a",
|
|
79
|
+
"pick between rival answers — NOT before every decision, and a routine task does not",
|
|
80
|
+
"deserve a whole panel. Then EXECUTE the ruling yourself and re-convene only when execution",
|
|
81
|
+
"surfaces a NEW decision. Do NOT use it to get work done: `delegate` (and your own tools)",
|
|
82
|
+
"produce the work; the council decides, the executor acts.",
|
|
83
|
+
"Patterns: adversarial vote with dissent (magi, council-rounds); a reasoned pick between rival",
|
|
84
|
+
"COMPLETE answers by an impartial arbiter (judge); adversarial critique then revise",
|
|
85
|
+
"(critic-loop); head-to-head argument (debate); many findings merged into ONE deliverable",
|
|
86
|
+
"(synthesize, map); rival implementations judged blind (compete); a single pair (pair).",
|
|
67
87
|
'Use `persona: "magi"` to invoke an installed persona\'s declared council without switching away',
|
|
68
88
|
"from the active caller; its prompt, model, tools, and permissions are never inherited.",
|
|
69
89
|
`Strategies: ${strategyNames()
|
|
@@ -73,7 +93,8 @@ export function registerCouncilTool(pi: ExtensionAPI, d: CouncilToolDeps): void
|
|
|
73
93
|
return keys.length > 0 ? `${n}(${keys.join(", ")})` : n;
|
|
74
94
|
})
|
|
75
95
|
.join(" · ")}.`,
|
|
76
|
-
|
|
96
|
+
"Pass `members` to seat an ad-hoc panel for one call, or `params` to vary the profile's default",
|
|
97
|
+
'council — e.g. { "reflect": false }.',
|
|
77
98
|
].join(" "),
|
|
78
99
|
parameters: CouncilParams,
|
|
79
100
|
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
@@ -82,6 +103,7 @@ export function registerCouncilTool(pi: ExtensionAPI, d: CouncilToolDeps): void
|
|
|
82
103
|
persona: params.persona,
|
|
83
104
|
strategy: params.strategy,
|
|
84
105
|
roster: params.roster,
|
|
106
|
+
members: params.members,
|
|
85
107
|
params: params.params as Record<string, unknown> | undefined,
|
|
86
108
|
});
|
|
87
109
|
if (!resolved.ok) {
|
|
@@ -91,7 +113,8 @@ export function registerCouncilTool(pi: ExtensionAPI, d: CouncilToolDeps): void
|
|
|
91
113
|
isError: true,
|
|
92
114
|
};
|
|
93
115
|
}
|
|
94
|
-
const { strategy, roster, params: mergedParams, persona } = resolved.value;
|
|
116
|
+
const { strategy, roster, members, params: mergedParams, persona } = resolved.value;
|
|
117
|
+
const panel = panelLabel({ roster, members });
|
|
95
118
|
try {
|
|
96
119
|
// Fully persona-driven: a persona's `council:` block picks the strategy, roster,
|
|
97
120
|
// and params — a new ensemble (more members, supermajority, multi-round) needs no
|
|
@@ -111,6 +134,9 @@ export function registerCouncilTool(pi: ExtensionAPI, d: CouncilToolDeps): void
|
|
|
111
134
|
}
|
|
112
135
|
}
|
|
113
136
|
const orch: OrchestrationGrammar = { mode: "strategy", strategy, roster, params: mergedParams };
|
|
137
|
+
// Ephemeral members ride the SAME grammar path as a named team (see
|
|
138
|
+
// `resolveOrchestrationMembers`) — nothing about the profile is inherited.
|
|
139
|
+
if (members) orch.members = members;
|
|
114
140
|
const result = await d.runStrategyVisible(ctx, orch, params.question, `council:${_id}`, signal);
|
|
115
141
|
const s = (result?.structured ?? {}) as {
|
|
116
142
|
headline?: string;
|
|
@@ -133,7 +159,7 @@ export function registerCouncilTool(pi: ExtensionAPI, d: CouncilToolDeps): void
|
|
|
133
159
|
items: s.items,
|
|
134
160
|
body: uiBody,
|
|
135
161
|
strategy,
|
|
136
|
-
roster,
|
|
162
|
+
roster: panel,
|
|
137
163
|
persona,
|
|
138
164
|
...(result?.error ? { error: result.error } : {}),
|
|
139
165
|
...(result?.failureKind ? { failureKind: result.failureKind } : {}),
|
|
@@ -151,7 +177,7 @@ export function registerCouncilTool(pi: ExtensionAPI, d: CouncilToolDeps): void
|
|
|
151
177
|
};
|
|
152
178
|
} catch (err) {
|
|
153
179
|
const message = err instanceof Error ? err.message : String(err);
|
|
154
|
-
return { content: [{ type: "text", text: `council failed: ${message}` }], details: failureDetails({ error: message, strategy, roster }), isError: true };
|
|
180
|
+
return { content: [{ type: "text", text: `council failed: ${message}` }], details: failureDetails({ error: message, strategy, roster: panel }), isError: true };
|
|
155
181
|
}
|
|
156
182
|
},
|
|
157
183
|
renderCall(args, theme) {
|
|
@@ -159,10 +185,12 @@ export function registerCouncilTool(pi: ExtensionAPI, d: CouncilToolDeps): void
|
|
|
159
185
|
persona: args.persona,
|
|
160
186
|
strategy: args.strategy,
|
|
161
187
|
roster: args.roster,
|
|
188
|
+
members: args.members,
|
|
162
189
|
params: args.params as Record<string, unknown> | undefined,
|
|
163
190
|
});
|
|
164
191
|
const strategy = resolved.ok ? resolved.value.strategy : (args.strategy ?? "?");
|
|
165
|
-
|
|
192
|
+
// With inline members there is no named team to name — label the panel itself.
|
|
193
|
+
const roster = resolved.ok ? panelLabel(resolved.value) : (args.roster ?? args.persona ?? "?");
|
|
166
194
|
return new Text(theme.fg("toolTitle", theme.bold(formatCouncilCallLabel(strategy, roster))), 0, 0);
|
|
167
195
|
},
|
|
168
196
|
renderResult(result, { expanded }, theme) {
|