@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.
Files changed (165) hide show
  1. package/AGENTS.md +249 -0
  2. package/CHANGELOG.md +260 -0
  3. package/LICENSE +21 -0
  4. package/README.md +172 -0
  5. package/agents/balthasar.md +32 -0
  6. package/agents/casper.md +33 -0
  7. package/agents/evidence-verifier.md +23 -0
  8. package/agents/melchior.md +32 -0
  9. package/agents/operator.md +63 -0
  10. package/agents/research.md +58 -0
  11. package/agents/reviewer.md +49 -0
  12. package/agents/scout.md +23 -0
  13. package/agents/verifier.md +21 -0
  14. package/assets/banner.png +0 -0
  15. package/assets/demo1.gif +0 -0
  16. package/assets/demo1.mp4 +0 -0
  17. package/assets/workflow.png +0 -0
  18. package/contracts/finding.contract.json +9 -0
  19. package/docs/ARCHITECTURE.md +727 -0
  20. package/docs/EXPERIENCE.md +96 -0
  21. package/docs/MONITORS.md +84 -0
  22. package/docs/README.md +53 -0
  23. package/docs/REFERENCE.md +408 -0
  24. package/docs/SPINE.md +152 -0
  25. package/docs/STRATEGIES.md +215 -0
  26. package/docs/TELEMETRY.md +9 -0
  27. package/flows/deep-review.flow.json +8 -0
  28. package/flows/gated-build.flow.json +9 -0
  29. package/package.json +106 -0
  30. package/personas/audit.md +29 -0
  31. package/personas/dev.md +52 -0
  32. package/personas/elite.md +256 -0
  33. package/personas/judge.md +32 -0
  34. package/personas/magi.md +41 -0
  35. package/personas/planner.md +37 -0
  36. package/personas/researcher.md +37 -0
  37. package/personas/swarm.md +38 -0
  38. package/personas/verify.md +31 -0
  39. package/presets/magi-rounds.preset.json +5 -0
  40. package/prompts/spine.md +11 -0
  41. package/prompts/spine.worker.md +9 -0
  42. package/src/agents/agent.ts +78 -0
  43. package/src/bridge.ts +209 -0
  44. package/src/bus/broker/client.ts +379 -0
  45. package/src/bus/broker/framing.ts +43 -0
  46. package/src/bus/broker/host.ts +407 -0
  47. package/src/bus/broker/messages.ts +87 -0
  48. package/src/bus/broker/paths.ts +31 -0
  49. package/src/bus/contact.ts +145 -0
  50. package/src/bus/inproc.ts +279 -0
  51. package/src/bus/peers.ts +114 -0
  52. package/src/core/brief.ts +356 -0
  53. package/src/core/capabilities.ts +114 -0
  54. package/src/core/config.ts +228 -0
  55. package/src/core/contract.ts +391 -0
  56. package/src/core/data-root.ts +260 -0
  57. package/src/core/discovery.ts +33 -0
  58. package/src/core/display-label.ts +47 -0
  59. package/src/core/fence.ts +151 -0
  60. package/src/core/format.ts +13 -0
  61. package/src/core/frontmatter.ts +202 -0
  62. package/src/core/models.ts +192 -0
  63. package/src/core/monitor.ts +415 -0
  64. package/src/core/naming.ts +42 -0
  65. package/src/core/nudge.ts +238 -0
  66. package/src/core/ownership.ts +102 -0
  67. package/src/core/permissions.ts +56 -0
  68. package/src/core/pi-compat.ts +50 -0
  69. package/src/core/seed.ts +770 -0
  70. package/src/core/session-identity.ts +89 -0
  71. package/src/core/time.ts +202 -0
  72. package/src/core/timer.ts +221 -0
  73. package/src/core/types.ts +28 -0
  74. package/src/engine/adapter.ts +257 -0
  75. package/src/engine/async.ts +898 -0
  76. package/src/engine/change-report.ts +143 -0
  77. package/src/engine/child.ts +532 -0
  78. package/src/engine/errors.ts +30 -0
  79. package/src/engine/fallback.ts +57 -0
  80. package/src/engine/handles.ts +21 -0
  81. package/src/engine/inproc.ts +857 -0
  82. package/src/engine/signals.ts +11 -0
  83. package/src/engine/spec-preflight.ts +33 -0
  84. package/src/engine/stream.ts +369 -0
  85. package/src/engine/worktree.ts +144 -0
  86. package/src/exocom/activation.ts +31 -0
  87. package/src/exocom/codes.ts +261 -0
  88. package/src/exocom/envelope.ts +180 -0
  89. package/src/exocom/gate.ts +6 -0
  90. package/src/exocom/guards.ts +45 -0
  91. package/src/exocom/inbound.ts +141 -0
  92. package/src/exocom/install.ts +970 -0
  93. package/src/exocom/ledger.ts +603 -0
  94. package/src/exocom/limits.ts +20 -0
  95. package/src/exocom/paths.ts +35 -0
  96. package/src/exocom/plane.ts +1116 -0
  97. package/src/exocom/registry.ts +307 -0
  98. package/src/exocom/scope.ts +42 -0
  99. package/src/exocom/status.ts +86 -0
  100. package/src/exocom/untrusted.ts +40 -0
  101. package/src/exocom/wait.ts +23 -0
  102. package/src/exocom/write-guard.ts +89 -0
  103. package/src/extension/broker-host.ts +181 -0
  104. package/src/extension/clock.ts +14 -0
  105. package/src/extension/engine.ts +321 -0
  106. package/src/extension/hooks.ts +796 -0
  107. package/src/extension/identity.ts +244 -0
  108. package/src/extension/shared.ts +175 -0
  109. package/src/extension.ts +2221 -0
  110. package/src/loader.ts +218 -0
  111. package/src/monitor/process.ts +116 -0
  112. package/src/monitor/session.ts +61 -0
  113. package/src/orchestration/flow-journal.ts +86 -0
  114. package/src/orchestration/flow-run.ts +301 -0
  115. package/src/orchestration/flow.ts +125 -0
  116. package/src/orchestration/judge.ts +78 -0
  117. package/src/orchestration/model-retry.ts +85 -0
  118. package/src/orchestration/parallel.ts +107 -0
  119. package/src/orchestration/params.ts +23 -0
  120. package/src/orchestration/reducers.ts +123 -0
  121. package/src/orchestration/render.ts +127 -0
  122. package/src/orchestration/roster.ts +133 -0
  123. package/src/orchestration/sdk.ts +326 -0
  124. package/src/orchestration/strategies/compete.ts +182 -0
  125. package/src/orchestration/strategies/council-rounds.ts +124 -0
  126. package/src/orchestration/strategies/critic-loop.ts +168 -0
  127. package/src/orchestration/strategies/debate.ts +126 -0
  128. package/src/orchestration/strategies/fanout.ts +20 -0
  129. package/src/orchestration/strategies/judge.ts +117 -0
  130. package/src/orchestration/strategies/magi.ts +239 -0
  131. package/src/orchestration/strategies/map.ts +327 -0
  132. package/src/orchestration/strategies/pair.ts +72 -0
  133. package/src/orchestration/strategies/pipeline.ts +91 -0
  134. package/src/orchestration/strategies/synthesize.ts +99 -0
  135. package/src/orchestration/strategy.ts +49 -0
  136. package/src/orchestration/types.ts +31 -0
  137. package/src/orchestration/voting.ts +126 -0
  138. package/src/persona/config-store.ts +215 -0
  139. package/src/persona/controller.ts +173 -0
  140. package/src/persona/gating.ts +82 -0
  141. package/src/persona/orchestrate.ts +83 -0
  142. package/src/persona/persona.ts +294 -0
  143. package/src/persona/spine.ts +221 -0
  144. package/src/persona/state.ts +28 -0
  145. package/src/telemetry/contract.ts +244 -0
  146. package/src/telemetry/paths.ts +14 -0
  147. package/src/telemetry/producer.ts +390 -0
  148. package/src/tools/council.ts +200 -0
  149. package/src/tools/delegate-tool.ts +637 -0
  150. package/src/tools/delegate.ts +661 -0
  151. package/src/tools/exocom-work.ts +328 -0
  152. package/src/tools/exocom.ts +476 -0
  153. package/src/tools/flow.ts +117 -0
  154. package/src/tools/intercom-tool.ts +292 -0
  155. package/src/tools/intercom.ts +181 -0
  156. package/src/tools/models.ts +39 -0
  157. package/src/tools/monitor.ts +66 -0
  158. package/src/tools/timer.ts +105 -0
  159. package/src/ui/agent-overlay.ts +526 -0
  160. package/src/ui/agent-tree.ts +307 -0
  161. package/src/ui/live-clock.ts +42 -0
  162. package/src/ui/model-picker.ts +160 -0
  163. package/src/ui/presentation.ts +170 -0
  164. package/src/ui/usage.ts +133 -0
  165. package/teams.yaml +27 -0
@@ -0,0 +1,91 @@
1
+ /**
2
+ * pipeline — run the roster agents in SEQUENCE, each building on the previous one's
3
+ * output (a chain / refinement / debate shape, vs `fanout`'s parallel and `magi`'s vote).
4
+ * The built-in behind L1 `mode: pipeline`. The pipeline's answer is the LAST step's output
5
+ * (the culmination); usage is summed across the chain and a failed step stops it.
6
+ */
7
+
8
+ import { emptyUsage } from "../../engine/stream.ts";
9
+ import { fenceUntrusted } from "../../core/fence.ts";
10
+ import { sumUsage } from "../reducers.ts";
11
+ import { rosterSpec } from "../roster.ts";
12
+ import type { Strategy } from "../sdk.ts";
13
+ import type { AgentResult } from "../types.ts";
14
+
15
+ /** A chain the run cancelled — distinct from one a step failed, so a journal or supervisor
16
+ * records it as cancelled rather than as a broken step. The work already produced rides
17
+ * along: it cost real tokens and is still the best thing to show. */
18
+ function cancelled(results: AgentResult[], upstream: string): AgentResult {
19
+ return {
20
+ agent: "pipeline",
21
+ output: upstream || "(no output)",
22
+ structured: { steps: results.length, cancelled: true },
23
+ usage: results.length > 0 ? sumUsage(results.map((r) => r.usage)) : emptyUsage(),
24
+ ok: false,
25
+ error: "the run was aborted",
26
+ failureKind: "abort",
27
+ };
28
+ }
29
+
30
+ function failed(results: AgentResult[], upstream: string, step: AgentResult): AgentResult {
31
+ const cause = step.error ?? step.output ?? "failed";
32
+ const failedStep = `--- failed step (${step.agent}): ${cause} ---`;
33
+ const result: AgentResult = {
34
+ agent: "pipeline",
35
+ output: upstream ? `${upstream}\n\n${failedStep}` : (step.output || failedStep),
36
+ usage: results.length > 0 ? sumUsage(results.map((r) => r.usage)) : emptyUsage(),
37
+ ok: false,
38
+ };
39
+ if (step.error) result.error = step.error;
40
+ if (step.failureKind) result.failureKind = step.failureKind;
41
+ if (step.structured) result.structured = step.structured;
42
+ return result;
43
+ }
44
+
45
+ export const pipeline: Strategy = {
46
+ name: "pipeline",
47
+ async run(input, sdk) {
48
+ const team = input.roster ? sdk.roster.team(input.roster) : [];
49
+ if (team.length === 0) throw new Error("pipeline: a non-empty roster is required");
50
+ sdk.log(`pipeline: ${team.length} agents in sequence`);
51
+
52
+ const results: AgentResult[] = [];
53
+ let upstream = "";
54
+ for (const member of team) {
55
+ // An abort settles a step as ok:false/'abort' instead of throwing, and one that lands
56
+ // between steps marks nothing at all — either way the chain must stop here rather than
57
+ // walk the rest of the roster.
58
+ if (sdk.signal?.aborted) return cancelled(results, upstream);
59
+ const task = upstream
60
+ ? `${input.task}\n\n--- previous step's output (build on it) ---\n${fenceUntrusted(upstream)}`
61
+ : input.task;
62
+ const r = await sdk.agent({ ...rosterSpec(member), task });
63
+ results.push(r);
64
+ if (!r.ok) {
65
+ // A stop that lands WHILE this step runs settles it here, never at the loop guard
66
+ // above (the chain already broke): that guard reads `sdk.signal`, which is wired but
67
+ // only consulted BETWEEN steps. Report it as a cancelled chain that keeps the
68
+ // upstream work, not as a failed step whose empty output replaces it.
69
+ if (r.failureKind === "abort") return cancelled(results, upstream);
70
+ return failed(results, upstream, r);
71
+ }
72
+ if (r.output) upstream = r.output;
73
+ }
74
+
75
+ const last = results[results.length - 1];
76
+ const result: AgentResult = {
77
+ agent: "pipeline",
78
+ output: last?.output ?? "(no output)",
79
+ usage: results.length > 0 ? sumUsage(results.map((r) => r.usage)) : emptyUsage(),
80
+ ok: results.length === team.length && results.every((r) => r.ok),
81
+ };
82
+ if (last?.structured) result.structured = last.structured;
83
+ // The chain breaks AT the failing step, so `last` carries the cause — without it the phase
84
+ // renders as failed with an empty output and nothing to act on.
85
+ if (!result.ok && last && !last.ok) {
86
+ if (last.error) result.error = last.error;
87
+ if (last.failureKind) result.failureKind = last.failureKind;
88
+ }
89
+ return result;
90
+ },
91
+ };
@@ -0,0 +1,99 @@
1
+ /**
2
+ * synthesize — fan the roster out on the task in parallel, then ONE synthesiser agent
3
+ * merges the labeled findings into a single coherent answer. This is the "reduce" that
4
+ * `fanout` lacks (its aggregate is only concatenated sections): use it whenever the
5
+ * ensemble's value is a unified deliverable — research sweeps, multi-angle reviews,
6
+ * gather-then-write — rather than a vote (`magi`) or a pick (`judge`).
7
+ *
8
+ * roster = the gatherers · params = { synthesizer?: "<agent>" (default: the first roster agent),
9
+ * peers?: boolean (gatherers share contradictions/corroborations live — default off) }
10
+ */
11
+
12
+ import { rosterSpec } from "../roster.ts";
13
+ import { fenceUntrusted } from "../../core/fence.ts";
14
+ import { sumUsage, summarizeFailedResults } from "../reducers.ts";
15
+ import type { Strategy } from "../sdk.ts";
16
+ import type { AgentResult } from "../types.ts";
17
+
18
+ // Cooperative cross-talk (params.peers): gatherers surface contradictions early instead of
19
+ // leaving them all to the synthesizer. Task-text injection keeps UI tree keys stable.
20
+ const CROSS_TALK = [
21
+ "You have sibling gatherers working OTHER angles of this same task. If you find something that",
22
+ "contradicts or strongly corroborates what another angle would see, share it once via",
23
+ "`contact_peer` (action `list`, then `send`) — short and factual. Incorporate any",
24
+ '"[message from peer …]" notes you receive. No chatter: only load-bearing findings.',
25
+ ].join(" ");
26
+
27
+ export const synthesize: Strategy = {
28
+ name: "synthesize",
29
+ params: {
30
+ synthesizer: { type: "string", doc: "default: the first roster agent" },
31
+ peers: { type: "boolean", default: false, doc: "gatherers share contradictions/corroborations live" },
32
+ },
33
+ async run(input, sdk) {
34
+ const team = input.roster ? sdk.roster.team(input.roster) : [];
35
+ if (team.length === 0) throw new Error("synthesize: a non-empty roster (the gatherers) is required");
36
+ const synthesizer =
37
+ typeof input.params.synthesizer === "string" && input.params.synthesizer.trim()
38
+ ? input.params.synthesizer.trim()
39
+ : rosterSpec(team[0]!).agent;
40
+ const peers = input.params.peers === true;
41
+ sdk.log(`synthesize: ${team.length} gatherers → ${synthesizer}${peers ? " (cross-talk on)" : ""}`);
42
+ if (peers && team.length > sdk.limits.maxConcurrency) {
43
+ sdk.log(
44
+ `synthesize: ${team.length} gatherers exceeds maxConcurrency (${sdk.limits.maxConcurrency}) — the live exchange will be batched (gatherers beyond the concurrency window join late)`,
45
+ );
46
+ }
47
+
48
+ const results = await sdk.parallel(
49
+ team.map((m) => () =>
50
+ sdk.agent({
51
+ ...rosterSpec(m),
52
+ task: peers ? `${input.task}\n\n--- gatherer cross-talk ---\n${CROSS_TALK}` : input.task,
53
+ ...(peers ? { peers: true } : {}),
54
+ }),
55
+ ),
56
+ );
57
+ const usable = results.filter((r) => r.ok && r.output.trim());
58
+ if (usable.length === 0) {
59
+ const cause = summarizeFailedResults(results, "no gatherer produced output");
60
+ const reasons = results.map((r) => `[${r.agent}] ${r.error ?? "(no output)"}`).join("; ");
61
+ return {
62
+ agent: "synthesize",
63
+ output: `(no gatherer produced output: ${reasons})`,
64
+ usage: sumUsage(results.map((r) => r.usage)),
65
+ ok: false,
66
+ error: cause.error,
67
+ failureKind: cause.failureKind,
68
+ };
69
+ }
70
+
71
+ const sections = usable.map((r) => `--- [${r.agent}] ---\n${fenceUntrusted(r.output.trim())}`).join("\n\n");
72
+ const final = await sdk.agent({
73
+ agent: synthesizer,
74
+ task:
75
+ `Synthesise the findings below into ONE coherent, de-duplicated answer to the task. ` +
76
+ `Resolve contradictions explicitly (say which finding you trusted and why) and keep every load-bearing detail.\n\n` +
77
+ `Task: ${input.task}\n\nFindings:\n${sections}`,
78
+ });
79
+
80
+ // A one-line headline for the collapsed council card (the tool reads `headline`):
81
+ // the synthesiser's `result` field if it emitted one, else the first non-empty line.
82
+ const firstLine = final.output.split("\n").map((l) => l.trim()).find((l) => l.length > 0) ?? "";
83
+ const structuredResult = final.structured?.result;
84
+ const headline = (typeof structuredResult === "string" && structuredResult.trim() ? structuredResult : firstLine).slice(0, 120);
85
+
86
+ const result: AgentResult = {
87
+ agent: "synthesize",
88
+ output: final.output,
89
+ structured: { ...(final.structured ?? {}), gatherers: usable.length, headline },
90
+ usage: sumUsage([...results, final].map((r) => r.usage)),
91
+ ok: final.ok,
92
+ };
93
+ if (!final.ok) {
94
+ result.error = final.error ?? "the synthesizer failed";
95
+ result.failureKind = final.failureKind ?? "agent";
96
+ }
97
+ return result;
98
+ },
99
+ };
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Built-in strategy registry — TS modules conforming to the `Strategy` interface.
3
+ * A future loader for user-authored `strategies/*.js` (same SDK, trusted/project-
4
+ * gated code) would register through the same map.
5
+ */
6
+
7
+ import type { Strategy, StrategyParam } from "./sdk.ts";
8
+ import { compete } from "./strategies/compete.ts";
9
+ import { councilRounds } from "./strategies/council-rounds.ts";
10
+ import { criticLoop } from "./strategies/critic-loop.ts";
11
+ import { debate } from "./strategies/debate.ts";
12
+ import { fanout } from "./strategies/fanout.ts";
13
+ import { judge } from "./strategies/judge.ts";
14
+ import { magi } from "./strategies/magi.ts";
15
+ import { map } from "./strategies/map.ts";
16
+ import { pair } from "./strategies/pair.ts";
17
+ import { pipeline } from "./strategies/pipeline.ts";
18
+ import { synthesize } from "./strategies/synthesize.ts";
19
+
20
+ const BUILTINS: Record<string, Strategy> = {
21
+ fanout,
22
+ pipeline,
23
+ map,
24
+ "critic-loop": criticLoop,
25
+ "council-rounds": councilRounds,
26
+ debate,
27
+ magi,
28
+ judge,
29
+ pair,
30
+ synthesize,
31
+ compete,
32
+ };
33
+
34
+ export function getStrategy(name: string): Strategy | undefined {
35
+ return BUILTINS[name];
36
+ }
37
+
38
+ /** The registered built-in strategy names, sorted — for error messages and discovery. */
39
+ export function strategyNames(): string[] {
40
+ return Object.keys(BUILTINS).sort();
41
+ }
42
+
43
+ /** A strategy's declared param schema, for discovery (`/doctor`) and the council tool's
44
+ * lenient unknown-key warning. Known param-less strategies return an empty schema;
45
+ * `undefined` is reserved for an unknown strategy. */
46
+ export function knownParams(name: string): Record<string, StrategyParam> | undefined {
47
+ const strategy = BUILTINS[name];
48
+ return strategy ? (strategy.params ?? {}) : undefined;
49
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Orchestration-layer types shared by the SDK, strategies, and reducers.
3
+ */
4
+
5
+ import type { ChildUsage } from "../engine/stream.ts";
6
+
7
+ export type { ChildUsage };
8
+
9
+ /** Machine-readable cause of an `ok:false` run — so callers can react by CAUSE, not by
10
+ * string-matching the error. Only `"provider"` (the model's provider rejected/broke:
11
+ * auth, outage, 5xx, model-not-supported) is retryable by switching provider; the rest
12
+ * are terminal for that model. `"verification"` is the one value that never appears on an
13
+ * `AgentResult` itself — it appears only on `map`'s per-item ledger entry (`ItemLedgerEntry`,
14
+ * `orchestration/reducers.ts`), where it means the worker LEG succeeded but a separate
15
+ * `params.verify` reviewer rejected that item's work after the fact. */
16
+ export type FailureKind = "provider" | "abort" | "timeout" | "contract" | "unknown-agent" | "unknown-model" | "agent" | "verification";
17
+
18
+ /** The result of running one agent through the engine, as strategies see it. */
19
+ export interface AgentResult {
20
+ agent: string;
21
+ output: string;
22
+ /** Validated structured fields (when the agent ran against a contract). */
23
+ structured?: Record<string, unknown>;
24
+ usage: ChildUsage;
25
+ ok: boolean;
26
+ error?: string;
27
+ /** The canonical `provider/id` the run actually used (drives provider fallback + UI). */
28
+ modelUsed?: string;
29
+ /** Why it failed (set only when `ok` is false) — drives the model-fallback decision. */
30
+ failureKind?: FailureKind;
31
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Voting reducers (v0.2). Fold candidate results into a decision with an honest
3
+ * status union — never just a `winner`. Built-in bias guards: invalid outputs
4
+ * are quarantined, dissent is always preserved, and ties/no-consensus are
5
+ * surfaced (with an optional keep-best-by-confidence fallback). Anonymisation /
6
+ * order-shuffling apply to judge-based aggregation (added with the judge reducer).
7
+ */
8
+
9
+ import type { AgentResult } from "./types.ts";
10
+
11
+ export type ReducerStatus = "winner" | "tie" | "no_consensus" | "invalid_outputs";
12
+
13
+ export interface ReducerResult {
14
+ status: ReducerStatus;
15
+ winner?: AgentResult;
16
+ dissent?: AgentResult[];
17
+ invalid?: AgentResult[];
18
+ tally: Record<string, number>;
19
+ usedFallback: boolean;
20
+ }
21
+
22
+ export interface VoteOpts {
23
+ aggregate: "majority" | "unanimity";
24
+ keepBestFallback?: boolean;
25
+ /** Best-of-X: a plurality winner must reach at least this many votes, else no_consensus. */
26
+ threshold?: number;
27
+ }
28
+
29
+ /** Normalise a vote so case/separator variants tally together
30
+ * (`json-first`, `JSON_First`, `json first` → `json-first`). */
31
+ function normalizeVote(value: string): string {
32
+ return value
33
+ .trim()
34
+ .toLowerCase()
35
+ .replace(/[\s_-]+/g, "-")
36
+ .replace(/^-+|-+$/g, "");
37
+ }
38
+
39
+ /** The canonical (normalised) vote key for a candidate, or undefined if it cannot vote. */
40
+ function voteKey(r: AgentResult): string | undefined {
41
+ if (!r.ok) return undefined;
42
+ const s = r.structured;
43
+ if (s && typeof s.vote === "string" && s.vote.trim()) return normalizeVote(s.vote);
44
+ if (s && typeof s.result === "string" && s.result.trim()) return normalizeVote(s.result);
45
+ return undefined;
46
+ }
47
+
48
+ function confidence(r: AgentResult): number {
49
+ const c = r.structured?.confidence;
50
+ return typeof c === "number" ? c : 0;
51
+ }
52
+
53
+ export function voteReduce(candidates: AgentResult[], opts: VoteOpts): ReducerResult {
54
+ const valid: Array<{ result: AgentResult; key: string }> = [];
55
+ const invalid: AgentResult[] = [];
56
+ for (const candidate of candidates) {
57
+ const key = voteKey(candidate);
58
+ if (key === undefined) invalid.push(candidate);
59
+ else valid.push({ result: candidate, key });
60
+ }
61
+
62
+ const tally: Record<string, number> = {};
63
+ for (const v of valid) tally[v.key] = (tally[v.key] ?? 0) + 1;
64
+
65
+ if (valid.length === 0) {
66
+ // Nobody emitted a parseable vote. With keepBestFallback, don't strand the caller with an
67
+ // empty ruling (small models often drop the vote JSON) — surface the strongest single PROSE
68
+ // answer among the candidates that actually ANSWERED. That means ok candidates AND
69
+ // contract-only failures: an engine marks a member that answered in prose instead of the
70
+ // vote JSON as `ok:false, failureKind:"contract"`, which is precisely the case this rescue
71
+ // exists for (requiring `ok` here would make it unreachable — live-drive verified: a debate
72
+ // over generic agents returned an empty ruling). Hard failures (timeout/abort/provider/
73
+ // agent) stay excluded. No usable prose ⇒ genuinely nothing to show.
74
+ const res: ReducerResult = { status: "invalid_outputs", invalid, tally, usedFallback: false };
75
+ if (opts.keepBestFallback) {
76
+ const prose = candidates.filter((c) => c.output.trim() && (c.ok || c.failureKind === "contract"));
77
+ const winner = [...prose].sort((a, b) => confidence(b) - confidence(a))[0];
78
+ if (winner) {
79
+ res.winner = winner;
80
+ res.dissent = prose.filter((c) => c !== winner);
81
+ res.usedFallback = true;
82
+ // The rescued prose (winner + dissent) is SURFACED, not excluded — keep only the
83
+ // candidates that produced nothing usable in `invalid`, so an "N invalid excluded"
84
+ // footer counts what was actually dropped, not the ruling it sits above.
85
+ res.invalid = invalid.filter((c) => c !== winner && !res.dissent!.includes(c));
86
+ }
87
+ }
88
+ return res;
89
+ }
90
+
91
+ const bestByConfidence = (): AgentResult =>
92
+ [...valid].sort((a, b) => confidence(b.result) - confidence(a.result))[0]!.result;
93
+
94
+ const withFallback = (status: ReducerStatus): ReducerResult => {
95
+ const res: ReducerResult = { status, invalid, tally, usedFallback: false };
96
+ if (opts.keepBestFallback) {
97
+ const winner = bestByConfidence();
98
+ res.winner = winner;
99
+ // Preserve the minority report even when we fall back — this is exactly the
100
+ // case (tie / no_consensus) where the dissent matters most.
101
+ res.dissent = valid.filter((v) => v.result !== winner).map((v) => v.result);
102
+ res.usedFallback = true;
103
+ }
104
+ return res;
105
+ };
106
+
107
+ if (opts.aggregate === "unanimity") {
108
+ if (Object.keys(tally).length === 1) {
109
+ return { status: "winner", winner: valid[0]!.result, dissent: [], invalid, tally, usedFallback: false };
110
+ }
111
+ return withFallback("no_consensus");
112
+ }
113
+
114
+ // majority
115
+ const max = Math.max(...Object.values(tally));
116
+ const top = Object.keys(tally).filter((k) => tally[k] === max);
117
+ if (top.length === 1) {
118
+ // Best-of-X: a clear plurality still needs `threshold` votes to win outright.
119
+ if (opts.threshold !== undefined && max < opts.threshold) return withFallback("no_consensus");
120
+ const winnerKey = top[0];
121
+ const winner = valid.find((v) => v.key === winnerKey)!.result;
122
+ const dissent = valid.filter((v) => v.key !== winnerKey).map((v) => v.result);
123
+ return { status: "winner", winner, dissent, invalid, tally, usedFallback: false };
124
+ }
125
+ return withFallback("tie");
126
+ }
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Per-persona configuration, indexed by persona name and persisted as one JSON
3
+ * file in the global persona folder. Intentionally open-ended: a persona that
4
+ * needs settings gets an entry, others don't. Two personas that share a strategy
5
+ * keep independent configs. Today it carries per-agent model assignments (so an
6
+ * ensemble like magi can run its cores on *different* models); more keys later.
7
+ */
8
+
9
+ import { closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
10
+ import { dirname } from "node:path";
11
+
12
+ export interface PersonaConfig {
13
+ /** Roster agent name → provider-qualified model id (e.g. "claude-pro-max-native/claude-opus-4-8"). */
14
+ models?: Record<string, string>;
15
+ }
16
+
17
+ export type PersonaConfigStore = Record<string, PersonaConfig>;
18
+
19
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
20
+ return typeof value === "object" && value !== null && !Array.isArray(value);
21
+ }
22
+
23
+ /**
24
+ * Read the store. NOTE: this reader WRITES on one path — an unusable file is copied to
25
+ * `${file}.bak` before the next save replaces it (once; an existing backup is never touched).
26
+ */
27
+ export function readPersonaConfigs(file: string): PersonaConfigStore {
28
+ let text: string;
29
+ try {
30
+ text = readFileSync(file, "utf8");
31
+ } catch {
32
+ return {}; // no config yet (the common case) or unreadable → start empty
33
+ }
34
+ try {
35
+ const parsed: unknown = JSON.parse(text);
36
+ // Valid JSON that isn't a config object is as unusable as unparseable text — and the next
37
+ // save replaces it just the same, so it gets the same backup treatment.
38
+ if (!isPlainObject(parsed)) throw new Error("not a config object");
39
+ const store: PersonaConfigStore = {};
40
+ for (const [name, cfg] of Object.entries(parsed)) {
41
+ if (!isPlainObject(cfg)) continue;
42
+ const entry: PersonaConfig = {};
43
+ if (isPlainObject(cfg.models)) {
44
+ const models: Record<string, string> = {};
45
+ for (const [agent, model] of Object.entries(cfg.models)) {
46
+ if (typeof model === "string") models[agent] = model;
47
+ }
48
+ entry.models = models;
49
+ }
50
+ store[name] = entry;
51
+ }
52
+ return store;
53
+ } catch {
54
+ // An unusable file is about to be overwritten by the next save; keep it beside the
55
+ // config so the assignments stay recoverable instead of vanishing silently. `wx` makes
56
+ // this the FIRST copy only: the underlying fault (a sync client, a failing disk) tends to
57
+ // recur, and a second bad parse would otherwise overwrite the one copy that still had the
58
+ // user's assignments in it.
59
+ try {
60
+ writeFileSync(`${file}.bak`, text, { encoding: "utf8", flag: "wx" });
61
+ } catch {
62
+ /* best effort — an existing backup, or an unwritable dir, must not stop the session */
63
+ }
64
+ return {};
65
+ }
66
+ }
67
+
68
+ /** Injection seams for the rename retry below (tests drive it on any OS). */
69
+ export interface ConfigWriteIO {
70
+ rename?: (from: string, to: string) => void;
71
+ sleep?: (ms: number) => void;
72
+ /** Test seam for the cross-process lock around the read/merge/write transaction. */
73
+ acquireLock?: (file: string, sleep: (ms: number) => void) => () => void;
74
+ }
75
+
76
+ // A handle held by a virus scanner, a sync client or another pi instance mid-read is transient,
77
+ // so a few short waits usually win the race — 25+50+75 ms of backoff at worst, on a save.
78
+ const RENAME_ATTEMPTS = 4;
79
+ const RENAME_BACKOFF_MS = 25;
80
+ const LOCK_ATTEMPTS = 80;
81
+ const LOCK_BACKOFF_MS = 25;
82
+ const STALE_LOCK_MS = 5 * 60 * 1000;
83
+
84
+ function sleepSync(ms: number): void {
85
+ // The whole store API is synchronous (this runs on a model assignment, not on a hot path),
86
+ // so the backoff has to block; Atomics.wait is the standard synchronous sleep.
87
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
88
+ }
89
+
90
+ /** Acquire a small sidecar lock so two independent Pi processes cannot both read the same old
91
+ * config and race their read-modify-write transactions. `wx` is the portable atomic primitive on
92
+ * POSIX and Windows. A very old lock is treated as abandoned after a crashed process; active
93
+ * writers renew no state because the critical section is synchronous and short. */
94
+ function acquireFileLock(file: string, sleep: (ms: number) => void): () => void {
95
+ const lock = `${file}.lock`;
96
+ for (let attempt = 1; attempt <= LOCK_ATTEMPTS; attempt++) {
97
+ try {
98
+ const fd = openSync(lock, "wx");
99
+ try {
100
+ writeSync(fd, `${process.pid}\n`, null, "utf8");
101
+ } catch (error) {
102
+ try {
103
+ closeSync(fd);
104
+ } finally {
105
+ try {
106
+ unlinkSync(lock);
107
+ } catch {
108
+ /* best effort; retain the original write error */
109
+ }
110
+ }
111
+ throw error;
112
+ }
113
+ return () => {
114
+ try {
115
+ closeSync(fd);
116
+ } finally {
117
+ try {
118
+ unlinkSync(lock);
119
+ } catch {
120
+ /* another cleanup path or an external remover already won */
121
+ }
122
+ }
123
+ };
124
+ } catch (error) {
125
+ const code = (error as NodeJS.ErrnoException).code;
126
+ if (code !== "EEXIST") throw error;
127
+ try {
128
+ if (Date.now() - statSync(lock).mtimeMs > STALE_LOCK_MS) unlinkSync(lock);
129
+ } catch {
130
+ /* It disappeared between stat/unlink; retry normally. */
131
+ }
132
+ if (attempt < LOCK_ATTEMPTS) {
133
+ sleep(LOCK_BACKOFF_MS);
134
+ continue;
135
+ }
136
+ throw new Error(`timed out acquiring persona config lock: ${lock}`);
137
+ }
138
+ }
139
+ throw new Error(`timed out acquiring persona config lock: ${lock}`);
140
+ }
141
+
142
+ /**
143
+ * MERGE `store` into the file (read-modify-write), then save it.
144
+ *
145
+ * A removal is NOT expressible: an assignment missing from `store` is preserved, not deleted.
146
+ * The merge exists so a concurrent pi instance's save survives ours, and it cannot tell "the
147
+ * user cleared this" from "this store was read before that was written" — a clearing gesture
148
+ * would need its own delete-aware call.
149
+ */
150
+ export function writePersonaConfigs(file: string, store: PersonaConfigStore, io: ConfigWriteIO = {}): void {
151
+ mkdirSync(dirname(file), { recursive: true });
152
+ const sleep = io.sleep ?? sleepSync;
153
+ const release = (io.acquireLock ?? acquireFileLock)(file, sleep);
154
+ try {
155
+ // The caller holds a store read at session start; another pi instance in the same workspace
156
+ // may have saved since. Re-read and merge so its work survives our whole-store write — down
157
+ // to the agent, since two instances of the *same* persona assign different roster members.
158
+ const merged: PersonaConfigStore = { ...readPersonaConfigs(file) };
159
+ for (const [persona, cfg] of Object.entries(store)) {
160
+ const prev = merged[persona];
161
+ merged[persona] = prev ? { ...prev, ...cfg, models: { ...prev.models, ...cfg.models } } : cfg;
162
+ }
163
+ const payload = `${JSON.stringify(merged, null, 2)}\n`;
164
+ // Temp file + same-volume rename, so a crash mid-write can never leave a truncated
165
+ // config.json behind. That is atomic on POSIX; Windows is NOT the same — rename fails with
166
+ // EPERM/EBUSY while ANY other handle holds the target open (Defender, a sync client, a second
167
+ // pi instance mid-read), and the persona folder is an ordinary user directory where that is
168
+ // routine. So a contended rename is retried, and then written in place: a non-atomic save
169
+ // that lands beats an atomic one that loses the user's assignments every session.
170
+ const tmp = `${file}.${process.pid}.tmp`;
171
+ const rename = io.rename ?? renameSync;
172
+ writeFileSync(tmp, payload, "utf8");
173
+ for (let attempt = 1; ; attempt++) {
174
+ try {
175
+ rename(tmp, file);
176
+ return;
177
+ } catch (err) {
178
+ const code = (err as NodeJS.ErrnoException).code;
179
+ if (attempt < RENAME_ATTEMPTS && (code === "EPERM" || code === "EBUSY" || code === "EACCES")) {
180
+ sleep(RENAME_BACKOFF_MS * attempt);
181
+ continue;
182
+ }
183
+ try {
184
+ // The scratch file must not be left behind for every pid that ever tried, whether
185
+ // the in-place write succeeds or throws (the caller still sees a real failure).
186
+ writeFileSync(file, payload, "utf8");
187
+ } finally {
188
+ try {
189
+ rmSync(tmp, { force: true });
190
+ } catch {
191
+ /* best effort */
192
+ }
193
+ }
194
+ return;
195
+ }
196
+ }
197
+ } finally {
198
+ release();
199
+ }
200
+ }
201
+
202
+ /** The model assignments for a persona (empty object when none configured). */
203
+ export function personaModels(store: PersonaConfigStore, persona: string): Record<string, string> {
204
+ return store[persona]?.models ?? {};
205
+ }
206
+
207
+ /** Merge model assignments into a persona's config, returning a new store. */
208
+ export function withPersonaModels(
209
+ store: PersonaConfigStore,
210
+ persona: string,
211
+ models: Record<string, string>,
212
+ ): PersonaConfigStore {
213
+ const prev = store[persona] ?? {};
214
+ return { ...store, [persona]: { ...prev, models: { ...prev.models, ...models } } };
215
+ }