@underactive/pi-topping-moa-fusion 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/LICENSE +21 -0
- package/README.md +437 -0
- package/agents/mf-plan.md +43 -0
- package/agents/moa-debater.md +37 -0
- package/agents/moa-explore.md +56 -0
- package/agents/moa-opinion.md +29 -0
- package/agents/moa-proposer.md +49 -0
- package/agents/moa-synthesizer.md +124 -0
- package/agents/moa-verifier.md +67 -0
- package/index.ts +3 -0
- package/package.json +61 -0
- package/src/activityMeter.ts +193 -0
- package/src/agents/authoritative.ts +91 -0
- package/src/agents/defaults.ts +123 -0
- package/src/agents/discovery.ts +119 -0
- package/src/config/modelCatalogue.ts +54 -0
- package/src/config/planName.ts +74 -0
- package/src/config/rosters.ts +118 -0
- package/src/config/settings.ts +161 -0
- package/src/debate/debateContract.ts +89 -0
- package/src/debate/debateFanout.ts +285 -0
- package/src/debate/debateFile.ts +38 -0
- package/src/debate/debateResults.ts +115 -0
- package/src/debate/debateRounds.ts +61 -0
- package/src/debate/runDebate.ts +143 -0
- package/src/index.ts +283 -0
- package/src/moa/conflictContract.ts +49 -0
- package/src/moa/conflicts.ts +153 -0
- package/src/moa/contextContract.ts +52 -0
- package/src/moa/fanout.ts +152 -0
- package/src/moa/fanoutWiring.ts +88 -0
- package/src/moa/implementationRetry.ts +292 -0
- package/src/moa/modelRuntime.ts +87 -0
- package/src/moa/orchestration.ts +105 -0
- package/src/moa/planInfo.ts +57 -0
- package/src/moa/planlessRetry.ts +72 -0
- package/src/moa/reviewLoop.ts +170 -0
- package/src/moa/runContext.ts +118 -0
- package/src/moa/synthesis.ts +420 -0
- package/src/moa/verdicts.ts +81 -0
- package/src/moa/verification.ts +791 -0
- package/src/moa/verificationCriteria.ts +127 -0
- package/src/moa/verifyGate.ts +137 -0
- package/src/opinion/opinionContract.ts +21 -0
- package/src/opinion/opinionFanout.ts +135 -0
- package/src/opinion/opinionFile.ts +38 -0
- package/src/opinion/opinionResults.ts +73 -0
- package/src/opinion/runOpinion.ts +156 -0
- package/src/planning/askUserQuestion.ts +83 -0
- package/src/planning/instructions.ts +146 -0
- package/src/planning/modeState.ts +61 -0
- package/src/planning/planFile.ts +273 -0
- package/src/planning/planMode.ts +673 -0
- package/src/planning/tools/enterPlanMode.ts +165 -0
- package/src/planning/tools/exitPlanMode.ts +159 -0
- package/src/planning/tools/mfPlanSubagent.ts +311 -0
- package/src/planning/tools/shared.ts +19 -0
- package/src/planning/tools/writePlan.ts +33 -0
- package/src/runtime/activityTracking.ts +141 -0
- package/src/runtime/cancelRun.ts +134 -0
- package/src/runtime/mutationTripwire.ts +251 -0
- package/src/runtime/processPool.ts +55 -0
- package/src/runtime/results.ts +103 -0
- package/src/runtime/runner.ts +538 -0
- package/src/runtime/wire.ts +177 -0
- package/src/shared/functionKeys.ts +30 -0
- package/src/shared/modelRefs.ts +91 -0
- package/src/ui/agentStatus.ts +84 -0
- package/src/ui/agentTranscript.ts +112 -0
- package/src/ui/cancelOverlay.ts +191 -0
- package/src/ui/chrome.ts +151 -0
- package/src/ui/conflictOverlay.ts +363 -0
- package/src/ui/debateModelPicker.ts +273 -0
- package/src/ui/menu.ts +679 -0
- package/src/ui/moaModelPicker.ts +900 -0
- package/src/ui/moaProgressWidget.ts +910 -0
- package/src/ui/moaSetupOverlay.ts +368 -0
- package/src/ui/modelLabel.ts +61 -0
- package/src/ui/observeOverlay.ts +206 -0
- package/src/ui/opinionModelPicker.ts +246 -0
- package/src/ui/planReviewOverlay.ts +315 -0
- package/src/ui/promptEditor.ts +87 -0
- package/src/ui/rosterEditor.ts +310 -0
- package/src/ui/shimmer.ts +77 -0
- package/src/ui/toolActivity.ts +35 -0
- package/src/ui/twoPaneModelThinking.ts +272 -0
- package/src/ui/verificationFindingsOverlay.ts +137 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default model/thinking selections for the user-customizable planning
|
|
3
|
+
* subagents, stored in the frontmatter of the installed agent files.
|
|
4
|
+
*
|
|
5
|
+
* The installed copies under `~/.pi/agent/agents/` are authoritative at
|
|
6
|
+
* runtime, so writes target those rather than this package's shipped `agents/`
|
|
7
|
+
* directory (which only seeds them, no-clobber, via installShippedAgents).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import { isThinkingLevel, type ThinkingLevel } from "../shared/modelRefs.ts";
|
|
14
|
+
|
|
15
|
+
export interface ConfigurableAgent {
|
|
16
|
+
name: string;
|
|
17
|
+
menuLabel: string;
|
|
18
|
+
title: string;
|
|
19
|
+
description: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The shipped agents whose model the user may choose. `moa-proposer` and
|
|
24
|
+
* `moa-synthesizer` are excluded: their model is assigned per slot at runtime,
|
|
25
|
+
* and withAuthoritativeMoaAgents replaces their on-disk copies wholesale, so
|
|
26
|
+
* any frontmatter written to them would be ignored. `mf-plan` is excluded
|
|
27
|
+
* too: it follows the session model chosen when entering plan mode, so a
|
|
28
|
+
* configured default would never be read.
|
|
29
|
+
*/
|
|
30
|
+
export const CONFIGURABLE_AGENTS: readonly ConfigurableAgent[] = [
|
|
31
|
+
{
|
|
32
|
+
name: "moa-explore",
|
|
33
|
+
menuLabel: "explore agent",
|
|
34
|
+
title: "Explore agent — fast codebase recon",
|
|
35
|
+
description: "Runs high-volume file reads and greps to gather context before planning. Favor a fast, inexpensive model: this role needs breadth and speed, not deep reasoning.",
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
export interface AgentFrontmatterDefault {
|
|
40
|
+
/** Raw frontmatter value — shipped defaults are bare ids (`claude-haiku-4-5`), not `provider/id`. */
|
|
41
|
+
model?: string;
|
|
42
|
+
thinking?: ThinkingLevel;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function installedAgentPath(name: string): string {
|
|
46
|
+
return path.join(getAgentDir(), "agents", `${name}.md`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Index of the closing `---` of a leading frontmatter block, or -1 if there is none. */
|
|
50
|
+
function frontmatterEnd(lines: string[]): number {
|
|
51
|
+
if (lines[0]?.trim() !== "---") return -1;
|
|
52
|
+
for (let index = 1; index < lines.length; index++) {
|
|
53
|
+
if (lines[index]?.trim() === "---") return index;
|
|
54
|
+
}
|
|
55
|
+
return -1;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The value of `line` if it assigns `key`, else undefined. */
|
|
59
|
+
function readField(line: string, key: string): string | undefined {
|
|
60
|
+
const match = line.match(/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/);
|
|
61
|
+
if (!match || match[1] !== key) return undefined;
|
|
62
|
+
return match[2]!.trim();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function setField(frontmatterLines: string[], key: string, value: string): void {
|
|
66
|
+
const index = frontmatterLines.findIndex((line) => readField(line, key) !== undefined);
|
|
67
|
+
if (index >= 0) frontmatterLines[index] = `${key}: ${value}`;
|
|
68
|
+
else frontmatterLines.push(`${key}: ${value}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function readAgentDefault(name: string): AgentFrontmatterDefault {
|
|
72
|
+
let content: string;
|
|
73
|
+
try {
|
|
74
|
+
content = fs.readFileSync(installedAgentPath(name), "utf-8");
|
|
75
|
+
} catch {
|
|
76
|
+
return {};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const lines = content.split("\n");
|
|
80
|
+
const end = frontmatterEnd(lines);
|
|
81
|
+
if (end < 0) return {};
|
|
82
|
+
|
|
83
|
+
const result: AgentFrontmatterDefault = {};
|
|
84
|
+
for (let index = 1; index < end; index++) {
|
|
85
|
+
const line = lines[index]!;
|
|
86
|
+
const model = readField(line, "model");
|
|
87
|
+
if (model) result.model = model;
|
|
88
|
+
const thinking = readField(line, "thinking");
|
|
89
|
+
if (thinking && isThinkingLevel(thinking)) result.thinking = thinking;
|
|
90
|
+
}
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Rewrite just the `model:`/`thinking:` lines of an installed agent's
|
|
96
|
+
* frontmatter, leaving every other line — including the prompt body and any
|
|
97
|
+
* hand-added keys — byte-for-byte intact. Returns false when the file is
|
|
98
|
+
* missing or carries no frontmatter block.
|
|
99
|
+
*/
|
|
100
|
+
export function writeAgentDefault(name: string, model: string, thinking: ThinkingLevel): boolean {
|
|
101
|
+
const filePath = installedAgentPath(name);
|
|
102
|
+
let content: string;
|
|
103
|
+
try {
|
|
104
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
105
|
+
} catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const lines = content.split("\n");
|
|
110
|
+
const end = frontmatterEnd(lines);
|
|
111
|
+
if (end < 0) return false;
|
|
112
|
+
|
|
113
|
+
const frontmatterLines = lines.slice(1, end);
|
|
114
|
+
setField(frontmatterLines, "model", model);
|
|
115
|
+
setField(frontmatterLines, "thinking", thinking);
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
fs.writeFileSync(filePath, [lines[0]!, ...frontmatterLines, ...lines.slice(end)].join("\n"), "utf-8");
|
|
119
|
+
} catch {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent discovery and configuration.
|
|
3
|
+
* Ported from pi's subagent example agents.ts.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as fs from "node:fs";
|
|
7
|
+
import * as path from "node:path";
|
|
8
|
+
import { CONFIG_DIR_NAME, getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { isThinkingLevel, type ThinkingLevel } from "../shared/modelRefs.ts";
|
|
10
|
+
|
|
11
|
+
export type AgentScope = "user" | "project" | "both";
|
|
12
|
+
|
|
13
|
+
export interface AgentConfig {
|
|
14
|
+
name: string;
|
|
15
|
+
description: string;
|
|
16
|
+
tools?: string[];
|
|
17
|
+
model?: string;
|
|
18
|
+
thinking?: ThinkingLevel;
|
|
19
|
+
systemPrompt: string;
|
|
20
|
+
source: "user" | "project";
|
|
21
|
+
filePath: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AgentDiscoveryResult {
|
|
25
|
+
agents: AgentConfig[];
|
|
26
|
+
projectAgentsDir: string | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Parse a single agent markdown file into an `AgentConfig`, or `undefined` if malformed/unreadable. */
|
|
30
|
+
export function parseAgentFile(filePath: string, source: "user" | "project"): AgentConfig | undefined {
|
|
31
|
+
let content: string;
|
|
32
|
+
try {
|
|
33
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
34
|
+
} catch {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const { frontmatter, body } = parseFrontmatter<Record<string, string>>(content);
|
|
39
|
+
|
|
40
|
+
if (!frontmatter.name || !frontmatter.description) return undefined;
|
|
41
|
+
|
|
42
|
+
const tools = frontmatter.tools
|
|
43
|
+
?.split(",")
|
|
44
|
+
.map((t: string) => t.trim())
|
|
45
|
+
.filter(Boolean);
|
|
46
|
+
|
|
47
|
+
// An unrecognized level is dropped rather than forwarded: `--thinking` on the
|
|
48
|
+
// child process would reject it and fail the whole agent run.
|
|
49
|
+
const thinking = frontmatter.thinking?.trim();
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
name: frontmatter.name,
|
|
53
|
+
description: frontmatter.description,
|
|
54
|
+
tools: tools && tools.length > 0 ? tools : undefined,
|
|
55
|
+
model: frontmatter.model,
|
|
56
|
+
thinking: thinking && isThinkingLevel(thinking) ? thinking : undefined,
|
|
57
|
+
systemPrompt: body,
|
|
58
|
+
source,
|
|
59
|
+
filePath,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig[] {
|
|
64
|
+
const agents: AgentConfig[] = [];
|
|
65
|
+
|
|
66
|
+
if (!fs.existsSync(dir)) return agents;
|
|
67
|
+
|
|
68
|
+
let entries: fs.Dirent[];
|
|
69
|
+
try {
|
|
70
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
71
|
+
} catch {
|
|
72
|
+
return agents;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (const entry of entries) {
|
|
76
|
+
if (!entry.name.endsWith(".md")) continue;
|
|
77
|
+
if (!entry.isFile() && !entry.isSymbolicLink()) continue;
|
|
78
|
+
|
|
79
|
+
const agent = parseAgentFile(path.join(dir, entry.name), source);
|
|
80
|
+
if (agent) agents.push(agent);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return agents;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isDirectory(p: string): boolean {
|
|
87
|
+
try {
|
|
88
|
+
return fs.statSync(p).isDirectory();
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function findNearestProjectAgentsDir(cwd: string): string | null {
|
|
95
|
+
let currentDir = cwd;
|
|
96
|
+
while (true) {
|
|
97
|
+
const candidate = path.join(currentDir, CONFIG_DIR_NAME, "agents");
|
|
98
|
+
if (isDirectory(candidate)) return candidate;
|
|
99
|
+
|
|
100
|
+
const parentDir = path.dirname(currentDir);
|
|
101
|
+
if (parentDir === currentDir) return null;
|
|
102
|
+
currentDir = parentDir;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult {
|
|
107
|
+
const userDir = path.join(getAgentDir(), "agents");
|
|
108
|
+
const projectAgentsDir = findNearestProjectAgentsDir(cwd);
|
|
109
|
+
|
|
110
|
+
const userAgents = scope === "project" ? [] : loadAgentsFromDir(userDir, "user");
|
|
111
|
+
const projectAgents = scope === "user" || !projectAgentsDir ? [] : loadAgentsFromDir(projectAgentsDir, "project");
|
|
112
|
+
|
|
113
|
+
const agentMap = new Map<string, AgentConfig>();
|
|
114
|
+
|
|
115
|
+
for (const agent of userAgents) agentMap.set(agent.name, agent);
|
|
116
|
+
for (const agent of projectAgents) agentMap.set(agent.name, agent);
|
|
117
|
+
|
|
118
|
+
return { agents: Array.from(agentMap.values()), projectAgentsDir };
|
|
119
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cached view of the models pi reports as usable, with the thinking levels the
|
|
3
|
+
* registry declares for each.
|
|
4
|
+
*
|
|
5
|
+
* The registry is the only authority the pickers consult: it decides both which
|
|
6
|
+
* models may be selected and which thinking levels each one supports, so a
|
|
7
|
+
* confirmed selection needs no further verification. Whether a model actually
|
|
8
|
+
* answers is decided when it runs, not here.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { getSupportedThinkingLevels, type Api, type Model } from "@earendil-works/pi-ai";
|
|
12
|
+
import { isThinkingLevel, modelRefLabel, parseRef, type ModelRef, type ThinkingLevel } from "../shared/modelRefs.ts";
|
|
13
|
+
|
|
14
|
+
export interface ModelCatalogue {
|
|
15
|
+
/** Selectable models, sorted by `provider/id`. */
|
|
16
|
+
availableRefs(): ModelRef[];
|
|
17
|
+
/** Levels the registry declares for a model, in pi's canonical order. */
|
|
18
|
+
thinkingLevelsFor(ref: ModelRef): ThinkingLevel[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The slice of pi's `ModelRegistry` a catalogue is built from. */
|
|
22
|
+
export interface CatalogueRegistry {
|
|
23
|
+
getAvailable(): Model<Api>[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Lives for as long as the registry object itself: pi hands extensions a stable
|
|
27
|
+
// registry per session, and dropping the registry drops its catalogue with it.
|
|
28
|
+
const CATALOGUES = new WeakMap<CatalogueRegistry, ModelCatalogue>();
|
|
29
|
+
|
|
30
|
+
export function getModelCatalogue(registry: CatalogueRegistry): ModelCatalogue {
|
|
31
|
+
const cached = CATALOGUES.get(registry);
|
|
32
|
+
if (cached) return cached;
|
|
33
|
+
const built = buildCatalogue(registry);
|
|
34
|
+
CATALOGUES.set(registry, built);
|
|
35
|
+
return built;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function buildCatalogue(registry: CatalogueRegistry): ModelCatalogue {
|
|
39
|
+
const levels = new Map<string, ThinkingLevel[]>();
|
|
40
|
+
for (const model of registry.getAvailable()) {
|
|
41
|
+
const key = `${model.provider}/${model.id}`;
|
|
42
|
+
if (levels.has(key)) continue;
|
|
43
|
+
levels.set(key, getSupportedThinkingLevels(model).filter(isThinkingLevel));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const refs = [...levels.keys()]
|
|
47
|
+
.sort((a, b) => a.localeCompare(b))
|
|
48
|
+
.map((key) => parseRef(key));
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
availableRefs: () => [...refs],
|
|
52
|
+
thinkingLevelsFor: (ref) => levels.get(modelRefLabel(ref)) ?? [],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM-based 4-word plan name generation for repo-local plan files.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { completeSimple } from "@earendil-works/pi-ai/compat";
|
|
6
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { loadMoaConfig } from "./settings.ts";
|
|
8
|
+
import { fallbackPlanName, generateWordSlug, trySlugifyPlanName } from "../planning/planFile.ts";
|
|
9
|
+
|
|
10
|
+
function buildSummarizePrompt(prompt: string): string {
|
|
11
|
+
return [
|
|
12
|
+
"Summarize the following planning request in exactly 4 words.",
|
|
13
|
+
"Reply with ONLY those 4 words separated by spaces, no punctuation, all lowercase.",
|
|
14
|
+
"",
|
|
15
|
+
"<request>",
|
|
16
|
+
prompt,
|
|
17
|
+
"</request>",
|
|
18
|
+
].join("\n");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Call the active model to summarize a plan prompt into a 4-word slug. */
|
|
22
|
+
export async function summarizePlanPromptName(ctx: ExtensionContext, prompt: string): Promise<string> {
|
|
23
|
+
const promptSlug = trySlugifyPlanName(prompt);
|
|
24
|
+
const fallback = () => promptSlug ?? fallbackPlanName();
|
|
25
|
+
|
|
26
|
+
const config = loadMoaConfig();
|
|
27
|
+
|
|
28
|
+
// When summary names are disabled, use a random adjective-adjective-noun phrase directly.
|
|
29
|
+
if (!config.useSummaryName) return generateWordSlug();
|
|
30
|
+
|
|
31
|
+
const cheapRef = config.cheap;
|
|
32
|
+
const model = cheapRef
|
|
33
|
+
? ctx.modelRegistry.find(cheapRef.provider, cheapRef.id) ?? ctx.model
|
|
34
|
+
: ctx.model;
|
|
35
|
+
if (!model) return fallback();
|
|
36
|
+
|
|
37
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
38
|
+
if (!auth?.ok || !auth.apiKey) return fallback();
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
// Naming a plan file is not a reasoning task, and the cheap/fast model is
|
|
42
|
+
// chosen to be cheap. Omitting `reasoning` is how pi's simple-stream API
|
|
43
|
+
// spells "thinking off" (its ThinkingLevel type has no "off" member), so
|
|
44
|
+
// providers send their disabled-reasoning mapping rather than a default.
|
|
45
|
+
const response = await completeSimple(
|
|
46
|
+
model,
|
|
47
|
+
{
|
|
48
|
+
messages: [
|
|
49
|
+
{
|
|
50
|
+
role: "user",
|
|
51
|
+
content: [{ type: "text", text: buildSummarizePrompt(prompt) }],
|
|
52
|
+
timestamp: Date.now(),
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
apiKey: auth.apiKey,
|
|
58
|
+
headers: auth.headers,
|
|
59
|
+
env: auth.env,
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const text = response.content
|
|
64
|
+
.filter((part): part is { type: "text"; text: string } => part.type === "text")
|
|
65
|
+
.map((part) => part.text)
|
|
66
|
+
.join("")
|
|
67
|
+
.trim();
|
|
68
|
+
|
|
69
|
+
const slug = trySlugifyPlanName(text);
|
|
70
|
+
return slug ?? fallback();
|
|
71
|
+
} catch {
|
|
72
|
+
return fallback();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named agent rosters for MoA plan runs.
|
|
3
|
+
*
|
|
4
|
+
* A roster bundles one model + thinking level for every fan-out role — up to
|
|
5
|
+
* five proposers plus synthesizer, implementer, and verifier — so a whole
|
|
6
|
+
* assignment set can be reused across runs and loaded wholesale from the
|
|
7
|
+
* picker's "Load Roster" row. Rosters are defined in `/mf-plan-settings` and
|
|
8
|
+
* persisted in settings.json; they are pure data here: nothing in this module
|
|
9
|
+
* consults the model registry, so an unavailable provider must never erase a
|
|
10
|
+
* saved definition. Availability is filtered where rosters are applied.
|
|
11
|
+
*
|
|
12
|
+
* Pure module — no I/O — so tests need none.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { isModelRef, isThinkingLevel, shortModelName, type ModelRef, type ThinkingLevel } from "../shared/modelRefs.ts";
|
|
16
|
+
|
|
17
|
+
export const MIN_ROSTER_PROPOSERS = 2;
|
|
18
|
+
export const MAX_ROSTER_PROPOSERS = 5;
|
|
19
|
+
export const MAX_ROSTER_NAME_LENGTH = 24;
|
|
20
|
+
export const MAX_ROSTER_COUNT = 20;
|
|
21
|
+
export const ROSTER_NAME_PATTERN = /^[A-Za-z0-9]+$/;
|
|
22
|
+
|
|
23
|
+
/** One model + thinking level assignment for a single role slot. */
|
|
24
|
+
export interface RosterSlot {
|
|
25
|
+
ref: ModelRef;
|
|
26
|
+
thinking: ThinkingLevel;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A complete saved team: a dense slot-ordered proposer list plus the three required roles. */
|
|
30
|
+
export interface PlanRoster {
|
|
31
|
+
name: string;
|
|
32
|
+
/** Dense in slot order, `MIN_ROSTER_PROPOSERS..MAX_ROSTER_PROPOSERS` entries. */
|
|
33
|
+
proposers: RosterSlot[];
|
|
34
|
+
synthesizer: RosterSlot;
|
|
35
|
+
implementer: RosterSlot;
|
|
36
|
+
verifier: RosterSlot;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** An in-progress roster in the editor: proposer slots may be empty and required roles unset. */
|
|
40
|
+
export interface DraftRoster {
|
|
41
|
+
name: string;
|
|
42
|
+
/** Fixed `MAX_ROSTER_PROPOSERS` length; `undefined` entries are unassigned. */
|
|
43
|
+
proposers: (RosterSlot | undefined)[];
|
|
44
|
+
synthesizer?: RosterSlot;
|
|
45
|
+
implementer?: RosterSlot;
|
|
46
|
+
verifier?: RosterSlot;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function isRosterSlot(value: unknown): value is RosterSlot {
|
|
50
|
+
return !!value
|
|
51
|
+
&& typeof value === "object"
|
|
52
|
+
&& isModelRef((value as RosterSlot).ref)
|
|
53
|
+
&& typeof (value as RosterSlot).thinking === "string"
|
|
54
|
+
&& isThinkingLevel((value as RosterSlot).thinking);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Sanitise a parsed `rosters` array the way persona-audit does its rosters. */
|
|
58
|
+
export function parseRosters(value: unknown): PlanRoster[] {
|
|
59
|
+
const rosters: PlanRoster[] = [];
|
|
60
|
+
const names = new Set<string>();
|
|
61
|
+
if (!Array.isArray(value)) return rosters;
|
|
62
|
+
for (const entry of value) {
|
|
63
|
+
if (rosters.length >= MAX_ROSTER_COUNT) break;
|
|
64
|
+
if (!entry || typeof entry !== "object") continue;
|
|
65
|
+
const candidate = entry as Partial<PlanRoster>;
|
|
66
|
+
if (typeof candidate.name !== "string" || !Array.isArray(candidate.proposers)) continue;
|
|
67
|
+
const name = candidate.name.trim();
|
|
68
|
+
const normalizedName = name.toLowerCase();
|
|
69
|
+
if (
|
|
70
|
+
name.length < 1
|
|
71
|
+
|| name.length > MAX_ROSTER_NAME_LENGTH
|
|
72
|
+
|| !ROSTER_NAME_PATTERN.test(name)
|
|
73
|
+
|| names.has(normalizedName)
|
|
74
|
+
) continue;
|
|
75
|
+
if (!isRosterSlot(candidate.synthesizer) || !isRosterSlot(candidate.implementer) || !isRosterSlot(candidate.verifier)) continue;
|
|
76
|
+
|
|
77
|
+
const proposers: RosterSlot[] = [];
|
|
78
|
+
for (const slot of candidate.proposers) {
|
|
79
|
+
if (isRosterSlot(slot)) proposers.push(slot);
|
|
80
|
+
if (proposers.length >= MAX_ROSTER_PROPOSERS) break;
|
|
81
|
+
}
|
|
82
|
+
if (proposers.length < MIN_ROSTER_PROPOSERS) continue;
|
|
83
|
+
names.add(normalizedName);
|
|
84
|
+
rosters.push({ name, proposers, synthesizer: candidate.synthesizer, implementer: candidate.implementer, verifier: candidate.verifier });
|
|
85
|
+
}
|
|
86
|
+
return rosters;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Validation message for a prospective roster name, or undefined when acceptable. */
|
|
90
|
+
export function rosterNameError(name: string, rosters: { name: string }[], currentIndex?: number): string | undefined {
|
|
91
|
+
if (name.length < 1 || name.length > MAX_ROSTER_NAME_LENGTH || !ROSTER_NAME_PATTERN.test(name)) {
|
|
92
|
+
return `Roster names must be 1–${MAX_ROSTER_NAME_LENGTH} alphanumeric characters.`;
|
|
93
|
+
}
|
|
94
|
+
if (rosters.some((roster, index) => index !== currentIndex && roster.name.toLowerCase() === name.toLowerCase())) {
|
|
95
|
+
return `A roster named ${name} already exists.`;
|
|
96
|
+
}
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Compact one-line summary of a roster's models, e.g. "P: opus, haiku · S: opus · I: opus · V: haiku". */
|
|
101
|
+
export function rosterSummary(roster: PlanRoster): string {
|
|
102
|
+
const proposers = roster.proposers.map((slot) => shortModelName(slot.ref)).join(", ");
|
|
103
|
+
return `P: ${proposers} · S: ${shortModelName(roster.synthesizer.ref)} · I: ${shortModelName(roster.implementer.ref)} · V: ${shortModelName(roster.verifier.ref)}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** What a draft is still missing before it can be saved, or undefined when complete. */
|
|
107
|
+
export function rosterReadinessError(draft: DraftRoster): string | undefined {
|
|
108
|
+
const assigned = draft.proposers.filter((slot) => slot !== undefined).length;
|
|
109
|
+
const missing: string[] = [];
|
|
110
|
+
const shortfall = MIN_ROSTER_PROPOSERS - assigned;
|
|
111
|
+
if (shortfall > 0) missing.push(shortfall === 1 ? "1 more proposer" : `${shortfall} proposers`);
|
|
112
|
+
if (!draft.synthesizer) missing.push("a synthesizer");
|
|
113
|
+
if (!draft.implementer) missing.push("an implementer");
|
|
114
|
+
if (!draft.verifier) missing.push("a verifier");
|
|
115
|
+
if (missing.length === 0) return undefined;
|
|
116
|
+
if (missing.length === 1) return `A roster needs ${missing[0]}.`;
|
|
117
|
+
return `A roster needs ${missing.slice(0, -1).join(", ")} and ${missing[missing.length - 1]}.`;
|
|
118
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MoA (Mixture-of-Agents) configuration for /mf-plan.
|
|
3
|
+
*
|
|
4
|
+
* Persists the last-used mode + model selections so the picker can
|
|
5
|
+
* pre-fill/remember choices across sessions. Purely a UI convenience — the
|
|
6
|
+
* picker is always shown after a plan prompt is submitted, this file never
|
|
7
|
+
* causes it to be skipped.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import {
|
|
14
|
+
THINKING_LEVELS,
|
|
15
|
+
isModelRef,
|
|
16
|
+
isThinkingLevel,
|
|
17
|
+
type ModelRef,
|
|
18
|
+
type ThinkingLevel,
|
|
19
|
+
} from "../shared/modelRefs.ts";
|
|
20
|
+
import { parseRosters, type PlanRoster } from "./rosters.ts";
|
|
21
|
+
|
|
22
|
+
export type MoaMode = "single" | "moa";
|
|
23
|
+
|
|
24
|
+
export interface MoaConfig {
|
|
25
|
+
mode: MoaMode;
|
|
26
|
+
proposers: ModelRef[];
|
|
27
|
+
opinionModels: ModelRef[];
|
|
28
|
+
debateModels: ModelRef[];
|
|
29
|
+
/** Round ceiling for /mf-debate, clamped to the picker's 2–5 range. */
|
|
30
|
+
debateRounds: number;
|
|
31
|
+
synthesizer?: ModelRef;
|
|
32
|
+
implementer?: ModelRef;
|
|
33
|
+
verifier?: ModelRef;
|
|
34
|
+
cheap?: ModelRef;
|
|
35
|
+
/** Last thinking level chosen for a model in the picker, keyed by `modelRefLabel(ref)`. */
|
|
36
|
+
thinkingOverrides: Record<string, ThinkingLevel>;
|
|
37
|
+
/** Named model+thinking rosters loadable wholesale from the picker's Load Roster row. */
|
|
38
|
+
rosters: PlanRoster[];
|
|
39
|
+
/** Auto-accept recommended conflict resolutions without showing the TUI conflict overlay. */
|
|
40
|
+
autoResolveConflicts: boolean;
|
|
41
|
+
/** Use LLM-summarized plan names (true) or random adjective-adjective-noun phrases (false). */
|
|
42
|
+
useSummaryName: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Whether the user has been through the setup overlay at least once. Gates the
|
|
45
|
+
* first `/mf-plan` into setup. Kept here rather than inferred from the agent
|
|
46
|
+
* files because those always carry a shipped `model:`, so their contents cannot
|
|
47
|
+
* distinguish a shipped default from a deliberate choice.
|
|
48
|
+
*/
|
|
49
|
+
agentDefaultsConfigured: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function emptyConfig(): MoaConfig {
|
|
53
|
+
return {
|
|
54
|
+
mode: "single",
|
|
55
|
+
proposers: [],
|
|
56
|
+
opinionModels: [],
|
|
57
|
+
debateModels: [],
|
|
58
|
+
debateRounds: 3,
|
|
59
|
+
synthesizer: undefined,
|
|
60
|
+
implementer: undefined,
|
|
61
|
+
verifier: undefined,
|
|
62
|
+
cheap: undefined,
|
|
63
|
+
thinkingOverrides: {},
|
|
64
|
+
rosters: [],
|
|
65
|
+
autoResolveConflicts: false,
|
|
66
|
+
useSummaryName: true,
|
|
67
|
+
agentDefaultsConfigured: false,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Persistent MoA settings, colocated with pi's user-level settings rather than generated plans. */
|
|
72
|
+
export function moaSettingsPath(): string {
|
|
73
|
+
return join(getAgentDir(), "mf-plan", "settings.json");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function parseSettingsFile(path: string): MoaConfig {
|
|
77
|
+
try {
|
|
78
|
+
const raw = readFileSync(path, "utf-8");
|
|
79
|
+
const parsed = JSON.parse(raw) as Partial<MoaConfig>;
|
|
80
|
+
const proposers = Array.isArray(parsed.proposers)
|
|
81
|
+
? parsed.proposers.filter(isModelRef)
|
|
82
|
+
: [];
|
|
83
|
+
const opinionModels = Array.isArray(parsed.opinionModels)
|
|
84
|
+
? parsed.opinionModels.filter(isModelRef)
|
|
85
|
+
: [];
|
|
86
|
+
const debateModels = Array.isArray(parsed.debateModels)
|
|
87
|
+
? parsed.debateModels.filter(isModelRef)
|
|
88
|
+
: [];
|
|
89
|
+
const debateRounds = typeof parsed.debateRounds === "number" && Number.isFinite(parsed.debateRounds)
|
|
90
|
+
? Math.min(5, Math.max(2, Math.round(parsed.debateRounds)))
|
|
91
|
+
: 3;
|
|
92
|
+
const synthesizer = isModelRef(parsed.synthesizer) ? parsed.synthesizer : undefined;
|
|
93
|
+
const implementer = isModelRef(parsed.implementer) ? parsed.implementer : undefined;
|
|
94
|
+
const verifier = isModelRef(parsed.verifier) ? parsed.verifier : undefined;
|
|
95
|
+
const cheap = isModelRef(parsed.cheap) ? parsed.cheap : undefined;
|
|
96
|
+
const mode: MoaMode = parsed.mode === "moa" ? "moa" : "single";
|
|
97
|
+
|
|
98
|
+
const thinkingOverrides: Record<string, ThinkingLevel> = {};
|
|
99
|
+
if (parsed.thinkingOverrides && typeof parsed.thinkingOverrides === "object") {
|
|
100
|
+
for (const [key, value] of Object.entries(parsed.thinkingOverrides)) {
|
|
101
|
+
if (typeof value === "string" && isThinkingLevel(value)) thinkingOverrides[key] = value;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const autoResolveConflicts = typeof parsed.autoResolveConflicts === "boolean"
|
|
106
|
+
? parsed.autoResolveConflicts
|
|
107
|
+
: false;
|
|
108
|
+
|
|
109
|
+
const useSummaryName = typeof parsed.useSummaryName === "boolean"
|
|
110
|
+
? parsed.useSummaryName
|
|
111
|
+
: true;
|
|
112
|
+
|
|
113
|
+
const agentDefaultsConfigured = parsed.agentDefaultsConfigured === true;
|
|
114
|
+
const rosters = parseRosters(parsed.rosters);
|
|
115
|
+
|
|
116
|
+
return { mode, proposers, opinionModels, debateModels, debateRounds, synthesizer, implementer, verifier, cheap, thinkingOverrides, rosters, autoResolveConflicts, useSummaryName, agentDefaultsConfigured };
|
|
117
|
+
} catch (err) {
|
|
118
|
+
console.error("mf-plan: failed to parse settings, using defaults:", err);
|
|
119
|
+
return emptyConfig();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function loadMoaConfig(): MoaConfig {
|
|
124
|
+
const settingsPath = moaSettingsPath();
|
|
125
|
+
if (existsSync(settingsPath)) return parseSettingsFile(settingsPath);
|
|
126
|
+
return emptyConfig();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function saveMoaConfig(config: MoaConfig): void {
|
|
130
|
+
const path = moaSettingsPath();
|
|
131
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
132
|
+
writeFileSync(path, `${JSON.stringify(config, null, "\t")}\n`, "utf-8");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function moaSettingsExist(): boolean {
|
|
136
|
+
return existsSync(moaSettingsPath());
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The levels to offer for a model, in pi's canonical thinking-level order.
|
|
141
|
+
* The registry's declared range is the whole answer — a level it does not list
|
|
142
|
+
* is one the backend will refuse.
|
|
143
|
+
*/
|
|
144
|
+
export function thinkingOptionsForModel(registryLevels: ThinkingLevel[]): ThinkingLevel[] {
|
|
145
|
+
return THINKING_LEVELS.filter((level) => registryLevels.includes(level));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Selects a model's saved level, the current level, or a sensible default in that order. */
|
|
149
|
+
export function defaultThinkingForModel(
|
|
150
|
+
key: string,
|
|
151
|
+
config: MoaConfig,
|
|
152
|
+
currentLevel: ThinkingLevel,
|
|
153
|
+
registryLevels: ThinkingLevel[],
|
|
154
|
+
): ThinkingLevel {
|
|
155
|
+
const options = thinkingOptionsForModel(registryLevels);
|
|
156
|
+
const saved = config.thinkingOverrides[key];
|
|
157
|
+
if (saved && options.includes(saved)) return saved;
|
|
158
|
+
if (options.includes(currentLevel)) return currentLevel;
|
|
159
|
+
if (options.includes("medium")) return "medium";
|
|
160
|
+
return options[0] ?? "medium";
|
|
161
|
+
}
|