@aeondave/pi-persona 1.15.2
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 +249 -0
- package/CHANGELOG.md +260 -0
- package/LICENSE +21 -0
- package/README.md +172 -0
- package/agents/balthasar.md +32 -0
- package/agents/casper.md +33 -0
- package/agents/evidence-verifier.md +23 -0
- package/agents/melchior.md +32 -0
- package/agents/operator.md +63 -0
- package/agents/research.md +58 -0
- package/agents/reviewer.md +49 -0
- package/agents/scout.md +23 -0
- package/agents/verifier.md +21 -0
- package/assets/banner.png +0 -0
- package/assets/demo1.gif +0 -0
- package/assets/demo1.mp4 +0 -0
- package/assets/workflow.png +0 -0
- package/contracts/finding.contract.json +9 -0
- package/docs/ARCHITECTURE.md +727 -0
- package/docs/EXPERIENCE.md +96 -0
- package/docs/MONITORS.md +84 -0
- package/docs/README.md +53 -0
- package/docs/REFERENCE.md +408 -0
- package/docs/SPINE.md +152 -0
- package/docs/STRATEGIES.md +215 -0
- package/docs/TELEMETRY.md +9 -0
- package/flows/deep-review.flow.json +8 -0
- package/flows/gated-build.flow.json +9 -0
- package/package.json +106 -0
- package/personas/audit.md +29 -0
- package/personas/dev.md +52 -0
- package/personas/elite.md +256 -0
- package/personas/judge.md +32 -0
- package/personas/magi.md +41 -0
- package/personas/planner.md +37 -0
- package/personas/researcher.md +37 -0
- package/personas/swarm.md +38 -0
- package/personas/verify.md +31 -0
- package/presets/magi-rounds.preset.json +5 -0
- package/prompts/spine.md +11 -0
- package/prompts/spine.worker.md +9 -0
- package/src/agents/agent.ts +78 -0
- package/src/bridge.ts +209 -0
- package/src/bus/broker/client.ts +379 -0
- package/src/bus/broker/framing.ts +43 -0
- package/src/bus/broker/host.ts +407 -0
- package/src/bus/broker/messages.ts +87 -0
- package/src/bus/broker/paths.ts +31 -0
- package/src/bus/contact.ts +145 -0
- package/src/bus/inproc.ts +279 -0
- package/src/bus/peers.ts +114 -0
- package/src/core/brief.ts +356 -0
- package/src/core/capabilities.ts +114 -0
- package/src/core/config.ts +228 -0
- package/src/core/contract.ts +391 -0
- package/src/core/data-root.ts +260 -0
- package/src/core/discovery.ts +33 -0
- package/src/core/display-label.ts +47 -0
- package/src/core/fence.ts +151 -0
- package/src/core/format.ts +13 -0
- package/src/core/frontmatter.ts +202 -0
- package/src/core/models.ts +192 -0
- package/src/core/monitor.ts +415 -0
- package/src/core/naming.ts +42 -0
- package/src/core/nudge.ts +238 -0
- package/src/core/ownership.ts +102 -0
- package/src/core/permissions.ts +56 -0
- package/src/core/pi-compat.ts +50 -0
- package/src/core/seed.ts +770 -0
- package/src/core/session-identity.ts +89 -0
- package/src/core/time.ts +202 -0
- package/src/core/timer.ts +221 -0
- package/src/core/types.ts +28 -0
- package/src/engine/adapter.ts +257 -0
- package/src/engine/async.ts +898 -0
- package/src/engine/change-report.ts +143 -0
- package/src/engine/child.ts +532 -0
- package/src/engine/errors.ts +30 -0
- package/src/engine/fallback.ts +57 -0
- package/src/engine/handles.ts +21 -0
- package/src/engine/inproc.ts +857 -0
- package/src/engine/signals.ts +11 -0
- package/src/engine/spec-preflight.ts +33 -0
- package/src/engine/stream.ts +369 -0
- package/src/engine/worktree.ts +144 -0
- package/src/exocom/activation.ts +31 -0
- package/src/exocom/codes.ts +261 -0
- package/src/exocom/envelope.ts +180 -0
- package/src/exocom/gate.ts +6 -0
- package/src/exocom/guards.ts +45 -0
- package/src/exocom/inbound.ts +141 -0
- package/src/exocom/install.ts +970 -0
- package/src/exocom/ledger.ts +603 -0
- package/src/exocom/limits.ts +20 -0
- package/src/exocom/paths.ts +35 -0
- package/src/exocom/plane.ts +1116 -0
- package/src/exocom/registry.ts +307 -0
- package/src/exocom/scope.ts +42 -0
- package/src/exocom/status.ts +86 -0
- package/src/exocom/untrusted.ts +40 -0
- package/src/exocom/wait.ts +23 -0
- package/src/exocom/write-guard.ts +89 -0
- package/src/extension/broker-host.ts +181 -0
- package/src/extension/clock.ts +14 -0
- package/src/extension/engine.ts +321 -0
- package/src/extension/hooks.ts +796 -0
- package/src/extension/identity.ts +244 -0
- package/src/extension/shared.ts +175 -0
- package/src/extension.ts +2221 -0
- package/src/loader.ts +218 -0
- package/src/monitor/process.ts +116 -0
- package/src/monitor/session.ts +61 -0
- package/src/orchestration/flow-journal.ts +86 -0
- package/src/orchestration/flow-run.ts +301 -0
- package/src/orchestration/flow.ts +125 -0
- package/src/orchestration/judge.ts +78 -0
- package/src/orchestration/model-retry.ts +85 -0
- package/src/orchestration/parallel.ts +107 -0
- package/src/orchestration/params.ts +23 -0
- package/src/orchestration/reducers.ts +123 -0
- package/src/orchestration/render.ts +127 -0
- package/src/orchestration/roster.ts +133 -0
- package/src/orchestration/sdk.ts +326 -0
- package/src/orchestration/strategies/compete.ts +182 -0
- package/src/orchestration/strategies/council-rounds.ts +124 -0
- package/src/orchestration/strategies/critic-loop.ts +168 -0
- package/src/orchestration/strategies/debate.ts +126 -0
- package/src/orchestration/strategies/fanout.ts +20 -0
- package/src/orchestration/strategies/judge.ts +117 -0
- package/src/orchestration/strategies/magi.ts +239 -0
- package/src/orchestration/strategies/map.ts +327 -0
- package/src/orchestration/strategies/pair.ts +72 -0
- package/src/orchestration/strategies/pipeline.ts +91 -0
- package/src/orchestration/strategies/synthesize.ts +99 -0
- package/src/orchestration/strategy.ts +49 -0
- package/src/orchestration/types.ts +31 -0
- package/src/orchestration/voting.ts +126 -0
- package/src/persona/config-store.ts +215 -0
- package/src/persona/controller.ts +173 -0
- package/src/persona/gating.ts +82 -0
- package/src/persona/orchestrate.ts +83 -0
- package/src/persona/persona.ts +294 -0
- package/src/persona/spine.ts +221 -0
- package/src/persona/state.ts +28 -0
- package/src/telemetry/contract.ts +244 -0
- package/src/telemetry/paths.ts +14 -0
- package/src/telemetry/producer.ts +390 -0
- package/src/tools/council.ts +200 -0
- package/src/tools/delegate-tool.ts +637 -0
- package/src/tools/delegate.ts +661 -0
- package/src/tools/exocom-work.ts +328 -0
- package/src/tools/exocom.ts +476 -0
- package/src/tools/flow.ts +117 -0
- package/src/tools/intercom-tool.ts +292 -0
- package/src/tools/intercom.ts +181 -0
- package/src/tools/models.ts +39 -0
- package/src/tools/monitor.ts +66 -0
- package/src/tools/timer.ts +105 -0
- package/src/ui/agent-overlay.ts +526 -0
- package/src/ui/agent-tree.ts +307 -0
- package/src/ui/live-clock.ts +42 -0
- package/src/ui/model-picker.ts +160 -0
- package/src/ui/presentation.ts +170 -0
- package/src/ui/usage.ts +133 -0
- package/teams.yaml +27 -0
|
@@ -0,0 +1,390 @@
|
|
|
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";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
TELEMETRY_PRODUCER_ID,
|
|
7
|
+
TELEMETRY_PRODUCER_VERSION,
|
|
8
|
+
TELEMETRY_VERSION,
|
|
9
|
+
type KnownTelemetryEventType,
|
|
10
|
+
projectTelemetryPayload,
|
|
11
|
+
type AgentDescriptor,
|
|
12
|
+
type InstanceDescriptor,
|
|
13
|
+
type TelemetryEvent,
|
|
14
|
+
type TelemetryPayload,
|
|
15
|
+
} from "./contract.ts";
|
|
16
|
+
import { telemetrySessionFileKey, telemetryWorkspaceId } from "./paths.ts";
|
|
17
|
+
|
|
18
|
+
export interface TelemetryProducerOptions {
|
|
19
|
+
agentDir: string;
|
|
20
|
+
cwd: string;
|
|
21
|
+
sessionId: string;
|
|
22
|
+
producerId?: string;
|
|
23
|
+
producerVersion?: string;
|
|
24
|
+
emit: (event: TelemetryEvent) => void;
|
|
25
|
+
now?: () => number;
|
|
26
|
+
heartbeatMs?: number;
|
|
27
|
+
heartbeat?: () => Partial<InstanceDescriptor>;
|
|
28
|
+
onError?: (error: unknown) => void;
|
|
29
|
+
maxFileBytes?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Richer caller input. Prompt/activity/output fields are accepted only so the sink can drop them. */
|
|
33
|
+
export type TelemetryAgentInput = AgentDescriptor & { task?: string; detail?: string; output?: string };
|
|
34
|
+
|
|
35
|
+
const DEFAULT_HEARTBEAT_MS = 5_000;
|
|
36
|
+
const STRING_LIMIT = 512;
|
|
37
|
+
const ARRAY_LIMIT = 256;
|
|
38
|
+
const MAX_DEPTH = 8;
|
|
39
|
+
const DEFAULT_MAX_FILE_BYTES = 4 * 1024 * 1024;
|
|
40
|
+
|
|
41
|
+
const TERMINAL_AGENT_STATUSES = new Set(["done", "failed", "stopped"]);
|
|
42
|
+
|
|
43
|
+
/** The replay seeds, split by what compaction is allowed to shed. `droppable` holds the seeds that
|
|
44
|
+
* scale with LIVE WORK rather than with the cap — one group per live agent (its `added` plus the
|
|
45
|
+
* updates that still describe it) and one per unfinished tool — ordered oldest first. Everything
|
|
46
|
+
* else is a fixed handful of instance anchors and is never shed. */
|
|
47
|
+
interface ReplaySeeds {
|
|
48
|
+
keep: Set<number>;
|
|
49
|
+
droppable: readonly (readonly number[])[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Identify the minimum original records needed to rebuild current live state after log compaction.
|
|
53
|
+
* Recent history alone is insufficient when a long-running agent/tool began before the retained tail. */
|
|
54
|
+
function replaySeedIndexes(lines: readonly string[]): ReplaySeeds {
|
|
55
|
+
type LiveAgent = { added: number; fields: Map<string, number> };
|
|
56
|
+
const keep = new Set<number>();
|
|
57
|
+
const instanceFields = new Map<string, number>();
|
|
58
|
+
const agents = new Map<string, LiveAgent>();
|
|
59
|
+
const tools = new Map<string, number>();
|
|
60
|
+
let firstInstance = -1;
|
|
61
|
+
let latestInstance = -1;
|
|
62
|
+
let latestStop = -1;
|
|
63
|
+
let latestPeers = -1;
|
|
64
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
65
|
+
let event: { type?: unknown; payload?: unknown };
|
|
66
|
+
try { event = JSON.parse(lines[index] ?? "") as { type?: unknown; payload?: unknown }; }
|
|
67
|
+
catch { continue; }
|
|
68
|
+
const payload = event.payload && typeof event.payload === "object" && !Array.isArray(event.payload)
|
|
69
|
+
? event.payload as Record<string, unknown>
|
|
70
|
+
: {};
|
|
71
|
+
if (event.type === "instance.started") {
|
|
72
|
+
if (firstInstance < 0) firstInstance = index;
|
|
73
|
+
latestInstance = index;
|
|
74
|
+
instanceFields.clear();
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (event.type === "instance.updated" || event.type === "instance.heartbeat") {
|
|
78
|
+
for (const field of Object.keys(payload)) instanceFields.set(field, index);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (event.type === "instance.stopped") { latestStop = index; continue; }
|
|
82
|
+
if (event.type === "peers.snapshot") { latestPeers = index; continue; }
|
|
83
|
+
if (event.type === "agent.added") {
|
|
84
|
+
const id = typeof payload.id === "string" ? payload.id : undefined;
|
|
85
|
+
if (!id) continue;
|
|
86
|
+
if (typeof payload.status === "string" && TERMINAL_AGENT_STATUSES.has(payload.status)) agents.delete(id);
|
|
87
|
+
else agents.set(id, { added: index, fields: new Map() });
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (event.type === "agent.updated") {
|
|
91
|
+
const id = typeof payload.id === "string" ? payload.id : undefined;
|
|
92
|
+
const patch = payload.patch && typeof payload.patch === "object" && !Array.isArray(payload.patch)
|
|
93
|
+
? payload.patch as Record<string, unknown>
|
|
94
|
+
: undefined;
|
|
95
|
+
const active = id ? agents.get(id) : undefined;
|
|
96
|
+
if (!id || !patch || !active) continue;
|
|
97
|
+
if (typeof patch.status === "string" && TERMINAL_AGENT_STATUSES.has(patch.status)) { agents.delete(id); continue; }
|
|
98
|
+
for (const field of Object.keys(patch)) active.fields.set(field, index);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (event.type === "agent.removed") {
|
|
102
|
+
if (typeof payload.id === "string") agents.delete(payload.id);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (event.type === "agent.cleared") { agents.clear(); continue; }
|
|
106
|
+
if (event.type === "tool.started") {
|
|
107
|
+
if (typeof payload.callId === "string") tools.set(payload.callId, index);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (event.type === "tool.finished" && typeof payload.callId === "string") tools.delete(payload.callId);
|
|
111
|
+
}
|
|
112
|
+
if (firstInstance >= 0) keep.add(firstInstance);
|
|
113
|
+
else if (lines.length > 0) keep.add(0);
|
|
114
|
+
if (latestInstance >= 0) keep.add(latestInstance);
|
|
115
|
+
if (latestStop >= 0) keep.add(latestStop);
|
|
116
|
+
if (latestPeers >= 0) keep.add(latestPeers);
|
|
117
|
+
for (const index of instanceFields.values()) keep.add(index);
|
|
118
|
+
// Snapshot the anchors before the live seeds join them: a log with no instance.started falls back
|
|
119
|
+
// to line 0, which may itself be a live tool or agent, and shedding it would take the anchor too.
|
|
120
|
+
const pinned = new Set(keep);
|
|
121
|
+
const droppable: number[][] = [];
|
|
122
|
+
const shedGroup = (indexes: number[]) => {
|
|
123
|
+
// Dedupe first: an agent's `fields` map is keyed by FIELD NAME, so one `agent.updated`
|
|
124
|
+
// patching N fields contributes N references to the SAME line. Sized once per reference the
|
|
125
|
+
// budget below over-estimates the group and sheds live agents whose seeds actually fit.
|
|
126
|
+
const group = [...new Set(indexes)].filter((index) => !pinned.has(index));
|
|
127
|
+
if (group.length > 0) droppable.push(group);
|
|
128
|
+
};
|
|
129
|
+
for (const agent of agents.values()) {
|
|
130
|
+
keep.add(agent.added);
|
|
131
|
+
for (const index of agent.fields.values()) keep.add(index);
|
|
132
|
+
// One group per agent: its field updates are worthless without the `added` that introduced
|
|
133
|
+
// it, so the budget below sheds the whole agent or none of it — never orphan patches.
|
|
134
|
+
shedGroup([agent.added, ...agent.fields.values()]);
|
|
135
|
+
}
|
|
136
|
+
for (const index of tools.values()) { keep.add(index); shedGroup([index]); }
|
|
137
|
+
// Oldest live work first: the newest agents and tools are the ones a consumer is still watching.
|
|
138
|
+
// The instance anchors and field seeds stay out of this list — there is one per instance field, a
|
|
139
|
+
// count fixed by the descriptor's shape, so they never grow the seed set past the cap on their own.
|
|
140
|
+
droppable.sort((left, right) => (left[0] ?? 0) - (right[0] ?? 0));
|
|
141
|
+
return { keep, droppable };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function appendBounded(file: string, line: string, maxBytes: number): Promise<void> {
|
|
145
|
+
await appendFile(file, line, { encoding: "utf8", mode: 0o600 });
|
|
146
|
+
const size = (await stat(file)).size;
|
|
147
|
+
if (size <= maxBytes) return;
|
|
148
|
+
const contents = await readFile(file);
|
|
149
|
+
const targetBytes = Math.max(128, Math.floor(maxBytes / 2));
|
|
150
|
+
// Keep the immutable instance anchor so replay after compaction still has stream identity.
|
|
151
|
+
// The cap is soft for one complete event (and the anchor) rather than truncating JSONL records.
|
|
152
|
+
const lines = contents.toString("utf8").split("\n").filter((item) => item.length > 0);
|
|
153
|
+
const { keep: kept, droppable } = replaySeedIndexes(lines);
|
|
154
|
+
const lineBytes = (index: number) => Buffer.byteLength(lines[index] ?? "") + 1;
|
|
155
|
+
let keptBytes = [...kept].reduce((total, index) => total + lineBytes(index), 0);
|
|
156
|
+
// Bound the live-work seeds against the target BEFORE writing them. They scale with the number of
|
|
157
|
+
// live agents and unfinished tools, not with the cap, so left alone they outgrow maxBytes on their
|
|
158
|
+
// own: the file then never comes back under the cap, every later append pays another full read +
|
|
159
|
+
// rewrite, and the loop below can retain nothing but the newest record. Shedding the stalest seed
|
|
160
|
+
// costs one agent's reconstruction, which is far cheaper than erasing the log. The instance
|
|
161
|
+
// anchors sit outside this budget so a live agent still replays under a cap a few records wide.
|
|
162
|
+
let liveSeedBytes = droppable.reduce((total, group) => total + group.reduce((sum, index) => sum + lineBytes(index), 0), 0);
|
|
163
|
+
for (const group of droppable) {
|
|
164
|
+
if (liveSeedBytes <= targetBytes) break;
|
|
165
|
+
for (const index of group) {
|
|
166
|
+
if (!kept.delete(index)) continue;
|
|
167
|
+
keptBytes -= lineBytes(index);
|
|
168
|
+
liveSeedBytes -= lineBytes(index);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
172
|
+
if (kept.has(index)) continue;
|
|
173
|
+
const candidateBytes = lineBytes(index);
|
|
174
|
+
if (keptBytes + candidateBytes > targetBytes) {
|
|
175
|
+
// Always retain the newest complete event even when one record is larger than the soft cap.
|
|
176
|
+
if (index === lines.length - 1) { kept.add(index); keptBytes += candidateBytes; }
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
kept.add(index);
|
|
180
|
+
keptBytes += candidateBytes;
|
|
181
|
+
}
|
|
182
|
+
const retained = [...kept].sort((left, right) => left - right).map((index) => lines[index]).join("\n") + "\n";
|
|
183
|
+
const temp = `${file}.trim-${process.pid}-${Date.now()}`;
|
|
184
|
+
const backup = `${file}.previous`;
|
|
185
|
+
await writeFile(temp, retained, { encoding: "utf8", mode: 0o600 });
|
|
186
|
+
try {
|
|
187
|
+
await unlink(backup).catch(() => undefined);
|
|
188
|
+
await rename(file, backup);
|
|
189
|
+
await rename(temp, file);
|
|
190
|
+
await unlink(backup);
|
|
191
|
+
} catch (error) {
|
|
192
|
+
await unlink(temp).catch(() => undefined);
|
|
193
|
+
await rename(backup, file).catch(() => undefined);
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function boundedText(value: string, max = STRING_LIMIT): string {
|
|
199
|
+
return Array.from(value.normalize("NFKC").replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029<>]/g, " ").replace(/\s+/g, " ").trim())
|
|
200
|
+
.slice(0, max)
|
|
201
|
+
.join("");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function sanitizeValue(value: unknown, depth = 0): unknown {
|
|
205
|
+
if (depth > MAX_DEPTH) return "[depth bounded]";
|
|
206
|
+
if (typeof value === "string") return boundedText(value);
|
|
207
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : 0;
|
|
208
|
+
if (typeof value === "boolean" || value === null) return value;
|
|
209
|
+
if (Array.isArray(value)) return value.slice(0, ARRAY_LIMIT).map((item) => sanitizeValue(item, depth + 1));
|
|
210
|
+
if (value && typeof value === "object") {
|
|
211
|
+
const out: Record<string, unknown> = {};
|
|
212
|
+
for (const [key, item] of Object.entries(value).slice(0, ARRAY_LIMIT)) {
|
|
213
|
+
if (item !== undefined) out[boundedText(key, 80)] = sanitizeValue(item, depth + 1);
|
|
214
|
+
}
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The last persisted sequence, plus whether the log ends mid-record — a crash between an append's
|
|
221
|
+
* open and its write leaves the final line unterminated, and the next append fuses onto it. */
|
|
222
|
+
function lastSequence(file: string): { seq: number; needsNewline: boolean } {
|
|
223
|
+
try {
|
|
224
|
+
const contents = readFileSync(file, "utf8");
|
|
225
|
+
const needsNewline = contents.length > 0 && !contents.endsWith("\n");
|
|
226
|
+
const lines = contents.trimEnd().split("\n");
|
|
227
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
228
|
+
const line = lines[i];
|
|
229
|
+
if (!line) continue;
|
|
230
|
+
try {
|
|
231
|
+
const parsed = JSON.parse(line) as { seq?: unknown };
|
|
232
|
+
if (Number.isSafeInteger(parsed.seq) && (parsed.seq as number) >= 1) return { seq: parsed.seq as number, needsNewline };
|
|
233
|
+
} catch {
|
|
234
|
+
/* a torn final line is ignored; scan backwards to the last valid event */
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return { seq: 0, needsNewline };
|
|
238
|
+
} catch {
|
|
239
|
+
/* first activation for this session */
|
|
240
|
+
}
|
|
241
|
+
return { seq: 0, needsNewline: false };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function sanitizeInstance(value: InstanceDescriptor): InstanceDescriptor {
|
|
245
|
+
return {
|
|
246
|
+
displayName: boundedText(value.displayName, 80) || "pi",
|
|
247
|
+
status: boundedText(value.status, 64) || "active",
|
|
248
|
+
...(value.persona !== undefined ? { persona: boundedText(value.persona, 64) } : {}),
|
|
249
|
+
...(value.model !== undefined ? { model: boundedText(value.model, 160) } : {}),
|
|
250
|
+
...(value.pid !== undefined ? { pid: Number.isInteger(value.pid) && value.pid > 0 ? value.pid : 0 } : {}),
|
|
251
|
+
...(value.contextPercent !== undefined ? { contextPercent: Math.max(0, Math.min(100, Number(value.contextPercent) || 0)) } : {}),
|
|
252
|
+
...(value.exocomEnabled !== undefined ? { exocomEnabled: value.exocomEnabled } : {}),
|
|
253
|
+
...(value.color && /^#[0-9A-Fa-f]{3,8}$/.test(value.color) ? { color: value.color } : {}),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function sanitizeAgent(value: TelemetryAgentInput): AgentDescriptor {
|
|
258
|
+
return {
|
|
259
|
+
id: boundedText(value.id, 160),
|
|
260
|
+
label: boundedText(value.label, 160) || "agent",
|
|
261
|
+
kind: boundedText(value.kind, 64) || "subagent",
|
|
262
|
+
status: boundedText(value.status, 64) || "running",
|
|
263
|
+
...(value.parentId ? { parentId: boundedText(value.parentId, 160) } : {}),
|
|
264
|
+
...(value.agent ? { agent: boundedText(value.agent, 80) } : {}),
|
|
265
|
+
...(value.persona ? { persona: boundedText(value.persona, 80) } : {}),
|
|
266
|
+
...(value.model ? { model: boundedText(value.model, 160) } : {}),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function sanitizeAgentPatch(value: Partial<TelemetryAgentInput>): Partial<AgentDescriptor> {
|
|
271
|
+
return {
|
|
272
|
+
...(value.label !== undefined ? { label: boundedText(value.label, 160) || "agent" } : {}),
|
|
273
|
+
...(value.kind !== undefined ? { kind: boundedText(value.kind, 64) || "subagent" } : {}),
|
|
274
|
+
...(value.status !== undefined ? { status: boundedText(value.status, 64) || "running" } : {}),
|
|
275
|
+
...(value.parentId !== undefined ? { parentId: boundedText(value.parentId, 160) } : {}),
|
|
276
|
+
...(value.agent !== undefined ? { agent: boundedText(value.agent, 80) } : {}),
|
|
277
|
+
...(value.persona !== undefined ? { persona: boundedText(value.persona, 80) } : {}),
|
|
278
|
+
...(value.model !== undefined ? { model: boundedText(value.model, 160) } : {}),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function producerSegment(value: string | undefined, fallback: string): string {
|
|
283
|
+
const safe = value?.trim().replace(/[^A-Za-z0-9._-]/g, "-").slice(0, 96);
|
|
284
|
+
return safe || fallback;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export class TelemetryProducer {
|
|
288
|
+
readonly workspaceId: string;
|
|
289
|
+
readonly filePath: string;
|
|
290
|
+
private readonly options: TelemetryProducerOptions;
|
|
291
|
+
private seq: number;
|
|
292
|
+
private writeChain: Promise<void> = Promise.resolve();
|
|
293
|
+
private heartbeatTimer: ReturnType<typeof setInterval> | undefined;
|
|
294
|
+
private instance: InstanceDescriptor | undefined;
|
|
295
|
+
private stopped = false;
|
|
296
|
+
readonly producerId: string;
|
|
297
|
+
readonly producerVersion: string;
|
|
298
|
+
|
|
299
|
+
constructor(options: TelemetryProducerOptions) {
|
|
300
|
+
this.options = options;
|
|
301
|
+
this.producerId = producerSegment(options.producerId, TELEMETRY_PRODUCER_ID);
|
|
302
|
+
this.producerVersion = producerSegment(options.producerVersion, TELEMETRY_PRODUCER_VERSION);
|
|
303
|
+
if (options.maxFileBytes !== undefined && (!Number.isSafeInteger(options.maxFileBytes) || options.maxFileBytes < 512)) throw new RangeError("maxFileBytes must be at least 512");
|
|
304
|
+
this.workspaceId = telemetryWorkspaceId(options.cwd);
|
|
305
|
+
const dir = join(options.agentDir, "telemetry", "v2", this.workspaceId, this.producerId);
|
|
306
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
307
|
+
const fileKey = telemetrySessionFileKey(options.sessionId);
|
|
308
|
+
this.filePath = join(dir, `${fileKey}.jsonl`);
|
|
309
|
+
const backup = `${this.filePath}.previous`;
|
|
310
|
+
if (!existsSync(this.filePath) && existsSync(backup)) {
|
|
311
|
+
try { renameSync(backup, this.filePath); } catch { /* a concurrent activation will retry on its own */ }
|
|
312
|
+
} else if (existsSync(this.filePath) && existsSync(backup)) {
|
|
313
|
+
try { unlinkSync(backup); } catch { /* best effort */ }
|
|
314
|
+
}
|
|
315
|
+
try {
|
|
316
|
+
for (const name of readdirSync(dir)) if (name.startsWith(`${fileKey}.jsonl.trim-`)) unlinkSync(join(dir, name));
|
|
317
|
+
} catch { /* best effort cleanup of interrupted compaction scratch files */ }
|
|
318
|
+
const { seq, needsNewline } = lastSequence(this.filePath);
|
|
319
|
+
this.seq = seq;
|
|
320
|
+
// Close a torn tail ONCE, before the first append: otherwise the next record is concatenated
|
|
321
|
+
// onto the partial bytes and BOTH are unreadable, so a consumer replaying the file sees only
|
|
322
|
+
// a sequence gap — a tool started before the crash stays "running" forever.
|
|
323
|
+
if (needsNewline) {
|
|
324
|
+
try { appendFileSync(this.filePath, "\n", { encoding: "utf8", mode: 0o600 }); } catch { /* the write chain reports a real append failure */ }
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
start(instance: InstanceDescriptor): void {
|
|
329
|
+
if (this.instance || this.stopped) return;
|
|
330
|
+
this.instance = sanitizeInstance(instance);
|
|
331
|
+
this.publish("instance.started", this.instance);
|
|
332
|
+
const heartbeatMs = this.options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
|
|
333
|
+
if (heartbeatMs > 0) {
|
|
334
|
+
this.heartbeatTimer = setInterval(() => {
|
|
335
|
+
const dynamic = this.options.heartbeat?.() ?? {};
|
|
336
|
+
this.publish("instance.heartbeat", dynamic);
|
|
337
|
+
}, heartbeatMs);
|
|
338
|
+
this.heartbeatTimer.unref?.();
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
publish<T extends KnownTelemetryEventType>(type: T, payload: TelemetryPayload<T>): TelemetryEvent<T> | undefined {
|
|
343
|
+
if (this.stopped) return undefined;
|
|
344
|
+
this.seq += 1;
|
|
345
|
+
const event = {
|
|
346
|
+
version: TELEMETRY_VERSION,
|
|
347
|
+
producerId: this.producerId,
|
|
348
|
+
producerVersion: this.producerVersion,
|
|
349
|
+
id: `${this.producerId}:${this.options.sessionId}:${this.seq}`,
|
|
350
|
+
seq: this.seq,
|
|
351
|
+
ts: this.options.now?.() ?? Date.now(),
|
|
352
|
+
sessionId: this.options.sessionId,
|
|
353
|
+
workspaceId: this.workspaceId,
|
|
354
|
+
type,
|
|
355
|
+
payload: projectTelemetryPayload(type, sanitizeValue(payload)) as TelemetryPayload<T>,
|
|
356
|
+
} as TelemetryEvent<T>;
|
|
357
|
+
try {
|
|
358
|
+
this.options.emit(event as TelemetryEvent);
|
|
359
|
+
} catch (error) {
|
|
360
|
+
this.options.onError?.(error);
|
|
361
|
+
}
|
|
362
|
+
const line = `${JSON.stringify(event)}\n`;
|
|
363
|
+
this.writeChain = this.writeChain
|
|
364
|
+
.catch(() => undefined)
|
|
365
|
+
.then(() => appendBounded(this.filePath, line, this.options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES))
|
|
366
|
+
.catch((error) => { this.options.onError?.(error); });
|
|
367
|
+
return event;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
publishAgentAdded(agent: TelemetryAgentInput): TelemetryEvent<"agent.added"> | undefined {
|
|
371
|
+
return this.publish("agent.added", sanitizeAgent(agent));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
publishAgentUpdated(id: string, patch: Partial<TelemetryAgentInput>): TelemetryEvent<"agent.updated"> | undefined {
|
|
375
|
+
return this.publish("agent.updated", { id: boundedText(id, 160), patch: sanitizeAgentPatch(patch) });
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async flush(): Promise<void> {
|
|
379
|
+
await this.writeChain;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async stop(reason?: string): Promise<void> {
|
|
383
|
+
if (this.stopped) return;
|
|
384
|
+
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
|
385
|
+
this.heartbeatTimer = undefined;
|
|
386
|
+
this.publish("instance.stopped", { reason: reason ? boundedText(reason, 120) : "shutdown" });
|
|
387
|
+
this.stopped = true;
|
|
388
|
+
await this.flush();
|
|
389
|
+
}
|
|
390
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/** `council` tool — deliberate, vote, ruling. */
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
import { fenceUntrusted } from "../core/fence.ts";
|
|
5
|
+
import { expandDetailHint, failureDetails, formatCouncilCallLabel } from "../extension/shared.ts";
|
|
6
|
+
import { knownParams, strategyNames } from "../orchestration/strategy.ts";
|
|
7
|
+
import { formatCouncilResult, humanizeAggregateResult } from "../orchestration/render.ts";
|
|
8
|
+
import type { FailureKind } from "../orchestration/types.ts";
|
|
9
|
+
import type { AgentResult } from "../orchestration/types.ts";
|
|
10
|
+
import { resolveCouncilInvocation } from "../persona/persona.ts";
|
|
11
|
+
import type { OrchestrationGrammar, Persona } from "../persona/persona.ts";
|
|
12
|
+
import type { PersonaController } from "../persona/controller.ts";
|
|
13
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
14
|
+
import { compactInlineText, sanitizeTerminalText } from "../ui/presentation.ts";
|
|
15
|
+
import { compactVisibleText } from "../ui/presentation.ts";
|
|
16
|
+
import { toolUsageField, type ChildUsageLedger } from "../ui/usage.ts";
|
|
17
|
+
import { emptyUsage } from "../engine/stream.ts";
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
export interface CouncilToolDeps {
|
|
21
|
+
get lastCtx(): ExtensionContext | undefined;
|
|
22
|
+
set lastCtx(value: ExtensionContext | undefined);
|
|
23
|
+
controller: PersonaController;
|
|
24
|
+
personas: Persona[];
|
|
25
|
+
runStrategyVisible(
|
|
26
|
+
ctx: ExtensionContext,
|
|
27
|
+
orch: OrchestrationGrammar,
|
|
28
|
+
task: string,
|
|
29
|
+
label: string,
|
|
30
|
+
signal?: AbortSignal,
|
|
31
|
+
): Promise<AgentResult | undefined>;
|
|
32
|
+
drainBusBlock(): string;
|
|
33
|
+
childUsage: ChildUsageLedger;
|
|
34
|
+
publishPersonaCost(): void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function registerCouncilTool(pi: ExtensionAPI, d: CouncilToolDeps): void {
|
|
38
|
+
// ── council tool (deliberate → vote → ruling; the executor then applies it) ───
|
|
39
|
+
const CouncilParams = Type.Object({
|
|
40
|
+
question: Type.String({ description: "The decision or problem to deliberate — specific and self-contained" }),
|
|
41
|
+
persona: Type.Optional(
|
|
42
|
+
Type.String({
|
|
43
|
+
description:
|
|
44
|
+
'Installed persona whose declared council profile to use (for example "magi"). This borrows only its council strategy/roster/params; the active caller remains in control.',
|
|
45
|
+
}),
|
|
46
|
+
),
|
|
47
|
+
strategy: Type.Optional(
|
|
48
|
+
Type.String({ description: 'Per-call strategy override (default: the selected or active council profile\'s strategy, or built-in "magi")' }),
|
|
49
|
+
),
|
|
50
|
+
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")' })),
|
|
51
|
+
params: Type.Optional(
|
|
52
|
+
Type.Record(Type.String(), Type.Unknown(), {
|
|
53
|
+
description:
|
|
54
|
+
'Strategy params, merged over the persona\'s (e.g. { "reflect": false } to skip magi\'s reflection round, { "aggregate": "unanimity" }, { "rounds": 3 }). Reach for it when the user asks for a variant of the persona\'s default council this one time.',
|
|
55
|
+
}),
|
|
56
|
+
),
|
|
57
|
+
});
|
|
58
|
+
pi.registerTool({
|
|
59
|
+
name: "council",
|
|
60
|
+
label: "Council",
|
|
61
|
+
description: [
|
|
62
|
+
"Convene a council of specialists with controlled, complementary biases to deliberate a",
|
|
63
|
+
"decision — returns the selected strategy's result, preserving vote tally and dissent when produced.",
|
|
64
|
+
"Use it before any significant choice; then EXECUTE the ruling yourself and re-convene when",
|
|
65
|
+
"execution surfaces a new decision. Patterns: adversarial vote (magi, council-rounds), best-of-N",
|
|
66
|
+
"with an impartial arbiter (judge, compete), batch map, merged synthesis (synthesize).",
|
|
67
|
+
'Use `persona: "magi"` to invoke an installed persona\'s declared council without switching away',
|
|
68
|
+
"from the active caller; its prompt, model, tools, and permissions are never inherited.",
|
|
69
|
+
`Strategies: ${strategyNames()
|
|
70
|
+
.map((n) => {
|
|
71
|
+
const p = knownParams(n);
|
|
72
|
+
const keys = p ? Object.keys(p) : [];
|
|
73
|
+
return keys.length > 0 ? `${n}(${keys.join(", ")})` : n;
|
|
74
|
+
})
|
|
75
|
+
.join(" · ")}.`,
|
|
76
|
+
'Pass `params` to vary the persona\'s default council for one call — e.g. { "reflect": false }.',
|
|
77
|
+
].join(" "),
|
|
78
|
+
parameters: CouncilParams,
|
|
79
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
80
|
+
d.lastCtx = ctx;
|
|
81
|
+
const resolved = resolveCouncilInvocation(d.personas, d.controller.activePersona, {
|
|
82
|
+
persona: params.persona,
|
|
83
|
+
strategy: params.strategy,
|
|
84
|
+
roster: params.roster,
|
|
85
|
+
params: params.params as Record<string, unknown> | undefined,
|
|
86
|
+
});
|
|
87
|
+
if (!resolved.ok) {
|
|
88
|
+
return {
|
|
89
|
+
content: [{ type: "text", text: `council failed: ${resolved.error}` }],
|
|
90
|
+
details: failureDetails({ error: resolved.error, persona: params.persona }),
|
|
91
|
+
isError: true,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const { strategy, roster, params: mergedParams, persona } = resolved.value;
|
|
95
|
+
try {
|
|
96
|
+
// Fully persona-driven: a persona's `council:` block picks the strategy, roster,
|
|
97
|
+
// and params — a new ensemble (more members, supermajority, multi-round) needs no
|
|
98
|
+
// code, just a team + (optional) strategy file + a council block. Params override.
|
|
99
|
+
// Per-call params override the selected council profile (e.g. reflect:false this once).
|
|
100
|
+
// Lenient by design (I2: strategies are trusted project code) — an unknown param key
|
|
101
|
+
// only warns, it never blocks or alters the run. A correct call is untouched.
|
|
102
|
+
let paramNote = "";
|
|
103
|
+
const schema = knownParams(strategy);
|
|
104
|
+
if (schema) {
|
|
105
|
+
const unknown = Object.keys(mergedParams).filter((k) => !(k in schema));
|
|
106
|
+
if (unknown.length > 0) {
|
|
107
|
+
const note = `council: ignoring unknown param(s) [${unknown.join(", ")}] for "${strategy}" — known: ${Object.keys(schema).join(", ") || "(none)"}`;
|
|
108
|
+
if (process.env.PI_PERSONA_DEBUG) process.stderr.write(`[pi-persona] ${note}\n`);
|
|
109
|
+
ctx.ui.notify(note, "warning");
|
|
110
|
+
paramNote = `\n\n(${note})`;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const orch: OrchestrationGrammar = { mode: "strategy", strategy, roster, params: mergedParams };
|
|
114
|
+
const result = await d.runStrategyVisible(ctx, orch, params.question, `council:${_id}`, signal);
|
|
115
|
+
const s = (result?.structured ?? {}) as {
|
|
116
|
+
headline?: string;
|
|
117
|
+
status?: string;
|
|
118
|
+
tally?: Record<string, number>;
|
|
119
|
+
usedFallback?: boolean;
|
|
120
|
+
count?: number;
|
|
121
|
+
items?: unknown;
|
|
122
|
+
};
|
|
123
|
+
const ruling = result?.output ?? "(the council returned no ruling)";
|
|
124
|
+
const uiBody = result ? (humanizeAggregateResult(result) ?? result.output) : "";
|
|
125
|
+
const headline = s.headline ?? (typeof s.count === "number" ? `${s.count} member results` : s.status ?? "");
|
|
126
|
+
const ok = result?.ok ?? false;
|
|
127
|
+
const details = {
|
|
128
|
+
ok,
|
|
129
|
+
headline,
|
|
130
|
+
status: s.status,
|
|
131
|
+
tally: s.tally,
|
|
132
|
+
usedFallback: s.usedFallback,
|
|
133
|
+
items: s.items,
|
|
134
|
+
body: uiBody,
|
|
135
|
+
strategy,
|
|
136
|
+
roster,
|
|
137
|
+
persona,
|
|
138
|
+
...(result?.error ? { error: result.error } : {}),
|
|
139
|
+
...(result?.failureKind ? { failureKind: result.failureKind } : {}),
|
|
140
|
+
};
|
|
141
|
+
const usage = result?.usage ?? emptyUsage();
|
|
142
|
+
d.childUsage.account(usage);
|
|
143
|
+
d.publishPersonaCost();
|
|
144
|
+
return {
|
|
145
|
+
// The ruling is sub-agent (council member) text — fence it like every other
|
|
146
|
+
// path that hands sub-agent output to the supervisor.
|
|
147
|
+
content: [{ type: "text", text: `${fenceUntrusted(ruling)}${paramNote}${d.drainBusBlock()}` }],
|
|
148
|
+
details: ok ? details : failureDetails(details),
|
|
149
|
+
isError: !ok,
|
|
150
|
+
...toolUsageField(usage),
|
|
151
|
+
};
|
|
152
|
+
} catch (err) {
|
|
153
|
+
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 };
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
renderCall(args, theme) {
|
|
158
|
+
const resolved = resolveCouncilInvocation(d.personas, d.controller.activePersona, {
|
|
159
|
+
persona: args.persona,
|
|
160
|
+
strategy: args.strategy,
|
|
161
|
+
roster: args.roster,
|
|
162
|
+
params: args.params as Record<string, unknown> | undefined,
|
|
163
|
+
});
|
|
164
|
+
const strategy = resolved.ok ? resolved.value.strategy : (args.strategy ?? "?");
|
|
165
|
+
const roster = resolved.ok ? resolved.value.roster : (args.roster ?? args.persona ?? "?");
|
|
166
|
+
return new Text(theme.fg("toolTitle", theme.bold(formatCouncilCallLabel(strategy, roster))), 0, 0);
|
|
167
|
+
},
|
|
168
|
+
renderResult(result, { expanded }, theme) {
|
|
169
|
+
const d = (result.details ?? {}) as {
|
|
170
|
+
ok?: boolean;
|
|
171
|
+
headline?: string;
|
|
172
|
+
status?: string;
|
|
173
|
+
tally?: Record<string, number>;
|
|
174
|
+
usedFallback?: boolean;
|
|
175
|
+
body?: string;
|
|
176
|
+
strategy?: string;
|
|
177
|
+
roster?: string;
|
|
178
|
+
error?: string;
|
|
179
|
+
failureKind?: FailureKind;
|
|
180
|
+
};
|
|
181
|
+
const first = result.content[0];
|
|
182
|
+
const body = sanitizeTerminalText(d.body || (first && first.type === "text" ? first.text : ""));
|
|
183
|
+
const failed = d.ok === false || !!d.error;
|
|
184
|
+
if (failed) {
|
|
185
|
+
const cause = compactInlineText([d.failureKind, d.error].filter((part): part is string => !!part).join(" · "), { maxChars: 160 });
|
|
186
|
+
const title = theme.fg("error", theme.bold(`council failed${cause ? ` · ${cause}` : ""}`));
|
|
187
|
+
if (expanded) return new Text(`${title}\n${theme.fg("toolOutput", body || "(no ruling)")}`, 0, 0);
|
|
188
|
+
const preview = compactVisibleText(body || "(no ruling)", { maxLines: 3, maxLineChars: 100 });
|
|
189
|
+
const hint = preview.truncated ? `\n${theme.fg("dim", expandDetailHint())}` : "";
|
|
190
|
+
return new Text(`${title}\n${theme.fg("toolOutput", preview.text)}${hint}`, 0, 0);
|
|
191
|
+
}
|
|
192
|
+
const text = formatCouncilResult(
|
|
193
|
+
{ headline: d.headline, status: d.status, tally: d.tally, usedFallback: d.usedFallback, body },
|
|
194
|
+
expanded,
|
|
195
|
+
expandDetailHint(),
|
|
196
|
+
);
|
|
197
|
+
return new Text(theme.fg(expanded ? "toolOutput" : "accent", text), 0, 0);
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
}
|