@alexeiled/pi-fusion 0.2.2 → 0.3.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/docs/user-guide.md +4 -3
- package/package.json +5 -1
- package/skills/fusion-review/SKILL.md +30 -0
- package/src/claude-aliases.ts +255 -0
- package/src/config.ts +6 -2
- package/src/index.ts +31 -0
package/docs/user-guide.md
CHANGED
|
@@ -110,7 +110,8 @@ Panel member:
|
|
|
110
110
|
- `id`: stable machine name
|
|
111
111
|
- `label`: human-readable report label
|
|
112
112
|
- `agent`: subagent name
|
|
113
|
-
- `model`: optional model override; often the main source of panel diversity
|
|
113
|
+
- `model`: optional model override; often the main source of panel diversity. Supports normal Pi model ids, and if `pi-claude-alias` is configured, Claude alias shorthand like `claude-work/opus-4.8`
|
|
114
|
+
- Claude alias handles must be unique across global and project alias files; duplicate handles are rejected.
|
|
114
115
|
- `thinking`: optional `off`, `minimal`, `low`, `medium`, `high`, or `xhigh`
|
|
115
116
|
- `role`: optional perspective hint layered on top of the model
|
|
116
117
|
|
|
@@ -162,7 +163,7 @@ Deliberate review:
|
|
|
162
163
|
"id": "architect",
|
|
163
164
|
"label": "Architect",
|
|
164
165
|
"agent": "pi-fusion.fusion-panelist",
|
|
165
|
-
"model": "
|
|
166
|
+
"model": "claude-work/sonnet-4.6",
|
|
166
167
|
"thinking": "high",
|
|
167
168
|
"role": "architecture and failure modes"
|
|
168
169
|
},
|
|
@@ -177,7 +178,7 @@ Deliberate review:
|
|
|
177
178
|
],
|
|
178
179
|
"judge": {
|
|
179
180
|
"agent": "pi-fusion.fusion-judge",
|
|
180
|
-
"model": "
|
|
181
|
+
"model": "claude-work/sonnet-4.6",
|
|
181
182
|
"thinking": "high"
|
|
182
183
|
},
|
|
183
184
|
"concurrency": 2,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alexeiled/pi-fusion",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Stronger answers for hard Pi questions via a parallel model panel + judge, built on pi-subagents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
],
|
|
22
22
|
"files": [
|
|
23
23
|
"agents/*.md",
|
|
24
|
+
"skills/**/*.md",
|
|
24
25
|
"src/*.ts",
|
|
25
26
|
"docs/user-guide.md"
|
|
26
27
|
],
|
|
@@ -39,6 +40,9 @@
|
|
|
39
40
|
"extensions": [
|
|
40
41
|
"./src/index.ts"
|
|
41
42
|
],
|
|
43
|
+
"skills": [
|
|
44
|
+
"./skills"
|
|
45
|
+
],
|
|
42
46
|
"subagents": {
|
|
43
47
|
"agents": [
|
|
44
48
|
"./agents"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: fusion-review
|
|
3
|
+
description: >
|
|
4
|
+
Trigger a pi-fusion multi-model panel review implicitly. Use when the user asks to
|
|
5
|
+
"invoke fusion", "run a fusion panel", "discuss this through fusion", "get multi-model
|
|
6
|
+
opinions", "panel review", or any phrasing that implies running a parallel model review.
|
|
7
|
+
Call the start_fusion_review tool with the user's topic or prompt.
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Fusion Review
|
|
11
|
+
|
|
12
|
+
Use the `start_fusion_review` tool to launch a fusion panel without the user needing to type `/fusion`.
|
|
13
|
+
|
|
14
|
+
## When to use
|
|
15
|
+
|
|
16
|
+
- "invoke fusion panel to discuss this"
|
|
17
|
+
- "run fusion on this"
|
|
18
|
+
- "get a panel review of …"
|
|
19
|
+
- "use multi-model review for …"
|
|
20
|
+
- "discuss this through fusion"
|
|
21
|
+
- Any prompt that implies parallel model review or multi-perspective analysis
|
|
22
|
+
|
|
23
|
+
## How to use
|
|
24
|
+
|
|
25
|
+
Call `start_fusion_review` with:
|
|
26
|
+
|
|
27
|
+
- `prompt` — the topic, question, or code excerpt to review (required)
|
|
28
|
+
- `profile` — a named fusion profile (optional; omit to use the default)
|
|
29
|
+
|
|
30
|
+
The tool queues a `/fusion` command as a follow-up, so the panel starts immediately after the current turn.
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { FusionConfig } from "./types.js";
|
|
5
|
+
import { FusionConfigError } from "./errors.js";
|
|
6
|
+
import { isNodeErrorCode, isNonEmptyString, isRecord } from "./utils.js";
|
|
7
|
+
import type { FusionConfigLoadContext } from "./config.js";
|
|
8
|
+
|
|
9
|
+
const CLAUDE_ALIAS_CONFIG_FILE = "claude-alias.json";
|
|
10
|
+
|
|
11
|
+
interface ClaudeAliasDefinition {
|
|
12
|
+
slug: string;
|
|
13
|
+
providerId: string;
|
|
14
|
+
handle: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface AliasFileDeps {
|
|
18
|
+
readTextFile?: (path: string) => Promise<string>;
|
|
19
|
+
agentDir?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface RawAliasConfig {
|
|
23
|
+
aliases?: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface RawAliasEntry {
|
|
27
|
+
slug?: unknown;
|
|
28
|
+
handle?: unknown;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function applyClaudeAliasShorthand(
|
|
32
|
+
config: FusionConfig,
|
|
33
|
+
ctx: FusionConfigLoadContext,
|
|
34
|
+
deps: AliasFileDeps = {},
|
|
35
|
+
): Promise<FusionConfig> {
|
|
36
|
+
const aliases = await loadClaudeAliases(ctx, deps);
|
|
37
|
+
if (aliases.length === 0) return config;
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
...config,
|
|
41
|
+
profiles: Object.fromEntries(
|
|
42
|
+
Object.entries(config.profiles).map(([name, profile]) => [
|
|
43
|
+
name,
|
|
44
|
+
{
|
|
45
|
+
...profile,
|
|
46
|
+
panel: profile.panel.map((member) => ({
|
|
47
|
+
...member,
|
|
48
|
+
...(member.model
|
|
49
|
+
? { model: resolveClaudeAliasModelSpec(member.model, aliases) }
|
|
50
|
+
: {}),
|
|
51
|
+
})),
|
|
52
|
+
judge: {
|
|
53
|
+
...profile.judge,
|
|
54
|
+
...(profile.judge.model
|
|
55
|
+
? { model: resolveClaudeAliasModelSpec(profile.judge.model, aliases) }
|
|
56
|
+
: {}),
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
]),
|
|
60
|
+
),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function resolveClaudeAliasModelSpec(
|
|
65
|
+
model: string,
|
|
66
|
+
aliases: readonly ClaudeAliasDefinition[],
|
|
67
|
+
): string {
|
|
68
|
+
const trimmed = model.trim();
|
|
69
|
+
const slashIndex = trimmed.indexOf("/");
|
|
70
|
+
if (slashIndex <= 0 || slashIndex === trimmed.length - 1) return trimmed;
|
|
71
|
+
|
|
72
|
+
const handle = trimmed.slice(0, slashIndex).trim().toLowerCase();
|
|
73
|
+
const modelRef = trimmed.slice(slashIndex + 1).trim();
|
|
74
|
+
const alias = aliases.find((item) => item.handle === handle);
|
|
75
|
+
if (!alias) return trimmed;
|
|
76
|
+
|
|
77
|
+
return `${alias.providerId}/${normalizeAnthropicModelRef(modelRef)}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function normalizeAnthropicModelRef(modelRef: string): string {
|
|
81
|
+
const normalized = modelRef
|
|
82
|
+
.trim()
|
|
83
|
+
.toLowerCase()
|
|
84
|
+
.replace(/[._\s]+/g, "-")
|
|
85
|
+
.replace(/-+/g, "-")
|
|
86
|
+
.replace(/^-+|-+$/g, "");
|
|
87
|
+
|
|
88
|
+
if (!normalized) return modelRef.trim();
|
|
89
|
+
return normalized.startsWith("claude-") ? normalized : `claude-${normalized}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function loadClaudeAliases(
|
|
93
|
+
ctx: FusionConfigLoadContext,
|
|
94
|
+
deps: AliasFileDeps,
|
|
95
|
+
): Promise<ClaudeAliasDefinition[]> {
|
|
96
|
+
const readTextFile = deps.readTextFile ?? readUtf8File;
|
|
97
|
+
const global = await readOptionalAliasFile(
|
|
98
|
+
getGlobalClaudeAliasConfigPath(deps.agentDir),
|
|
99
|
+
readTextFile,
|
|
100
|
+
);
|
|
101
|
+
const project = ctx.isProjectTrusted()
|
|
102
|
+
? await readOptionalAliasFile(
|
|
103
|
+
getProjectClaudeAliasConfigPath(ctx.cwd),
|
|
104
|
+
readTextFile,
|
|
105
|
+
)
|
|
106
|
+
: undefined;
|
|
107
|
+
|
|
108
|
+
const merged = new Map<string, ClaudeAliasDefinition>();
|
|
109
|
+
for (const alias of global ?? []) {
|
|
110
|
+
merged.set(alias.slug, alias);
|
|
111
|
+
}
|
|
112
|
+
for (const alias of project ?? []) {
|
|
113
|
+
merged.set(alias.slug, alias);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const aliases = [...merged.values()];
|
|
117
|
+
validateUniqueHandles(aliases);
|
|
118
|
+
return aliases;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function readOptionalAliasFile(
|
|
122
|
+
path: string,
|
|
123
|
+
readTextFile: (path: string) => Promise<string>,
|
|
124
|
+
): Promise<ClaudeAliasDefinition[] | undefined> {
|
|
125
|
+
let raw: string;
|
|
126
|
+
try {
|
|
127
|
+
raw = await readTextFile(path);
|
|
128
|
+
} catch (error: unknown) {
|
|
129
|
+
if (isNodeErrorCode(error, "ENOENT")) return undefined;
|
|
130
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
131
|
+
throw new FusionConfigError(
|
|
132
|
+
`Could not read Claude alias config at ${path}: ${message}`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return parseAliasFile(raw, path);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function parseAliasFile(raw: string, source: string): ClaudeAliasDefinition[] {
|
|
140
|
+
let value: unknown;
|
|
141
|
+
try {
|
|
142
|
+
value = JSON.parse(raw);
|
|
143
|
+
} catch (error: unknown) {
|
|
144
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
145
|
+
throw new FusionConfigError(
|
|
146
|
+
`Invalid JSON in Claude alias config at ${source}: ${message}`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (!isRecord(value) || !Array.isArray((value as RawAliasConfig).aliases)) {
|
|
151
|
+
throw new FusionConfigError(
|
|
152
|
+
`Invalid Claude alias config at ${source}. Expected aliases array.`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const aliasValues = (value as { aliases: unknown[] }).aliases;
|
|
157
|
+
const aliases: ClaudeAliasDefinition[] = [];
|
|
158
|
+
for (const [index, entry] of aliasValues.entries()) {
|
|
159
|
+
const parsed = parseAliasEntry(entry);
|
|
160
|
+
if (!parsed) {
|
|
161
|
+
throw new FusionConfigError(
|
|
162
|
+
`Invalid Claude alias entry at ${source} aliases[${index}].`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
aliases.push(parsed);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return dedupeAliases(aliases, source);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function parseAliasEntry(value: unknown): ClaudeAliasDefinition | undefined {
|
|
172
|
+
if (!isRecord(value)) return undefined;
|
|
173
|
+
const entry = value as RawAliasEntry;
|
|
174
|
+
|
|
175
|
+
const slug = normalizeSlug(entry.slug);
|
|
176
|
+
if (!slug) return undefined;
|
|
177
|
+
|
|
178
|
+
const handle = normalizeHandle(entry.handle) ?? `claude-${slug}`;
|
|
179
|
+
if (!handle) return undefined;
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
slug,
|
|
183
|
+
providerId: `anthropic-${slug}`,
|
|
184
|
+
handle,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function dedupeAliases(
|
|
189
|
+
aliases: readonly ClaudeAliasDefinition[],
|
|
190
|
+
source: string,
|
|
191
|
+
): ClaudeAliasDefinition[] {
|
|
192
|
+
const byHandle = new Set<string>();
|
|
193
|
+
const deduped: ClaudeAliasDefinition[] = [];
|
|
194
|
+
|
|
195
|
+
for (const alias of aliases) {
|
|
196
|
+
if (byHandle.has(alias.handle)) {
|
|
197
|
+
throw new FusionConfigError(
|
|
198
|
+
`Duplicate Claude alias handle "${alias.handle}" in ${source}.`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
byHandle.add(alias.handle);
|
|
202
|
+
deduped.push(alias);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return deduped;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function validateUniqueHandles(
|
|
209
|
+
aliases: readonly ClaudeAliasDefinition[],
|
|
210
|
+
): void {
|
|
211
|
+
const seen = new Map<string, string>();
|
|
212
|
+
|
|
213
|
+
for (const alias of aliases) {
|
|
214
|
+
const existingSlug = seen.get(alias.handle);
|
|
215
|
+
if (existingSlug && existingSlug !== alias.slug) {
|
|
216
|
+
throw new FusionConfigError(
|
|
217
|
+
`Duplicate Claude alias handle "${alias.handle}" across merged config. Use a unique handle for each alias.`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
seen.set(alias.handle, alias.slug);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function normalizeSlug(value: unknown): string | undefined {
|
|
225
|
+
if (!isNonEmptyString(value)) return undefined;
|
|
226
|
+
const slug = value
|
|
227
|
+
.trim()
|
|
228
|
+
.toLowerCase()
|
|
229
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
230
|
+
.replace(/^-+|-+$/g, "");
|
|
231
|
+
return slug || undefined;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function normalizeHandle(value: unknown): string | undefined {
|
|
235
|
+
if (value === undefined) return undefined;
|
|
236
|
+
if (!isNonEmptyString(value)) return undefined;
|
|
237
|
+
const handle = value
|
|
238
|
+
.trim()
|
|
239
|
+
.toLowerCase()
|
|
240
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
241
|
+
.replace(/^-+|-+$/g, "");
|
|
242
|
+
return handle || undefined;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function getGlobalClaudeAliasConfigPath(agentDir = getAgentDir()): string {
|
|
246
|
+
return join(agentDir, CLAUDE_ALIAS_CONFIG_FILE);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function getProjectClaudeAliasConfigPath(cwd: string): string {
|
|
250
|
+
return join(cwd, CONFIG_DIR_NAME, CLAUDE_ALIAS_CONFIG_FILE);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function readUtf8File(path: string): Promise<string> {
|
|
254
|
+
return readFile(path, "utf8");
|
|
255
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
|
+
import { applyClaudeAliasShorthand } from "./claude-aliases.js";
|
|
4
5
|
import { FusionConfigError } from "./errors.js";
|
|
5
6
|
import {
|
|
6
7
|
THINKING_LEVELS,
|
|
@@ -104,12 +105,15 @@ export async function loadFusionConfig(
|
|
|
104
105
|
if (ctx.isProjectTrusted()) {
|
|
105
106
|
const projectPath = getProjectFusionConfigPath(ctx.cwd);
|
|
106
107
|
const projectConfig = await readOptionalConfig(projectPath, readTextFile);
|
|
107
|
-
if (projectConfig)
|
|
108
|
+
if (projectConfig) {
|
|
109
|
+
return applyClaudeAliasShorthand(projectConfig, ctx, deps);
|
|
110
|
+
}
|
|
108
111
|
}
|
|
109
112
|
|
|
110
113
|
const globalPath = getGlobalFusionConfigPath(deps.agentDir);
|
|
111
114
|
const globalConfig = await readOptionalConfig(globalPath, readTextFile);
|
|
112
|
-
|
|
115
|
+
const config = globalConfig ?? createDefaultFusionConfig();
|
|
116
|
+
return applyClaudeAliasShorthand(config, ctx, deps);
|
|
113
117
|
}
|
|
114
118
|
|
|
115
119
|
export function resolveProfile(
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
2
3
|
import { registerFusionCommands } from "./commands.js";
|
|
3
4
|
import {
|
|
4
5
|
FusionOrchestrator,
|
|
@@ -7,6 +8,35 @@ import {
|
|
|
7
8
|
import { FusionRunStore } from "./run-store.js";
|
|
8
9
|
import { SubagentsRpcClient } from "./subagents-rpc.js";
|
|
9
10
|
|
|
11
|
+
function registerFusionTool(pi: ExtensionAPI): void {
|
|
12
|
+
pi.registerTool({
|
|
13
|
+
name: "start_fusion_review",
|
|
14
|
+
label: "Fusion Review",
|
|
15
|
+
description:
|
|
16
|
+
"Start a pi-fusion multi-model panel review. Use when the user asks to invoke fusion, run a panel review, get multi-model opinions, or discuss something through the fusion panel.",
|
|
17
|
+
promptSnippet: "Start a fusion panel review for a topic or code",
|
|
18
|
+
promptGuidelines: [
|
|
19
|
+
"Use start_fusion_review when the user says 'invoke fusion', 'run fusion', 'fusion panel', 'multi-model review', 'panel review', or similar.",
|
|
20
|
+
],
|
|
21
|
+
parameters: Type.Object({
|
|
22
|
+
prompt: Type.String({ description: "What to review or discuss" }),
|
|
23
|
+
profile: Type.Optional(
|
|
24
|
+
Type.String({ description: "Fusion profile name (optional)" }),
|
|
25
|
+
),
|
|
26
|
+
}),
|
|
27
|
+
execute(_toolCallId, params) {
|
|
28
|
+
const cmd = params.profile
|
|
29
|
+
? `/fusion --profile ${params.profile} ${params.prompt}`
|
|
30
|
+
: `/fusion ${params.prompt}`;
|
|
31
|
+
pi.sendUserMessage(cmd, { deliverAs: "followUp" });
|
|
32
|
+
return Promise.resolve({
|
|
33
|
+
content: [{ type: "text", text: "Starting fusion panel review…" }],
|
|
34
|
+
details: { prompt: params.prompt, profile: params.profile },
|
|
35
|
+
});
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
10
40
|
export default function fusionExtension(pi: ExtensionAPI): void {
|
|
11
41
|
const orchestrator = new FusionOrchestrator({
|
|
12
42
|
rpc: new SubagentsRpcClient({ events: pi.events }),
|
|
@@ -15,6 +45,7 @@ export default function fusionExtension(pi: ExtensionAPI): void {
|
|
|
15
45
|
});
|
|
16
46
|
|
|
17
47
|
registerFusionCommands(pi, orchestrator);
|
|
48
|
+
registerFusionTool(pi);
|
|
18
49
|
|
|
19
50
|
const unsubscribeComplete = pi.events.on(
|
|
20
51
|
SUBAGENT_ASYNC_COMPLETE_EVENT,
|