amicus 1.2.1 → 1.4.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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +38 -0
- package/README.md +12 -1
- package/electron/ipc-setup.js +18 -1
- package/electron/preload-setup.js +2 -1
- package/electron/setup-ui-council.js +77 -0
- package/electron/setup-ui.js +9 -3
- package/package.json +1 -1
- package/skills/second-opinion/MODEL-NOTES.md +6 -0
- package/skills/second-opinion/SKILL.md +23 -0
- package/src/cli-handlers-council.js +37 -2
- package/src/cli-handlers-run.js +24 -2
- package/src/cli.js +4 -0
- package/src/council/report-html.js +71 -0
- package/src/council/report.js +113 -0
- package/src/mcp-server.js +66 -6
- package/src/mcp-tools.js +61 -2
- package/src/sidecar/fanout-output.js +4 -1
- package/src/sidecar/fanout.js +8 -2
- package/src/sidecar/progress.js +16 -1
- package/src/sidecar/setup.js +129 -9
- package/src/sidecar/wave-progress.js +80 -0
- package/src/utils/config.js +54 -0
- package/src/utils/free-models.js +50 -0
- package/src/utils/pricing.js +16 -1
package/src/mcp-server.js
CHANGED
|
@@ -8,7 +8,7 @@ const os = require('os');
|
|
|
8
8
|
const { logger } = require('./utils/logger');
|
|
9
9
|
const { safeSessionDir } = require('./utils/validators');
|
|
10
10
|
const { getSessionDir, SESSIONS_DIR, LEGACY_SESSIONS_DIR } = require('./session-manager');
|
|
11
|
-
const { readProgress } = require('./sidecar/progress');
|
|
11
|
+
const { readProgress, isStalled } = require('./sidecar/progress');
|
|
12
12
|
const { SharedServerManager } = require('./utils/shared-server');
|
|
13
13
|
|
|
14
14
|
const sharedServer = new SharedServerManager({ logger });
|
|
@@ -264,7 +264,14 @@ const handlers = {
|
|
|
264
264
|
if (metadata.type === 'wave') {
|
|
265
265
|
const legs = (metadata.legs || []).map((legId) => {
|
|
266
266
|
const m = readMetadata(legId, cwd);
|
|
267
|
-
|
|
267
|
+
const leg = { taskId: legId, model: (m && m.model) || null, status: (m && m.status) || 'unknown' };
|
|
268
|
+
try {
|
|
269
|
+
const p = readProgress(getSessionDir(cwd, legId));
|
|
270
|
+
leg.messages = p.messages;
|
|
271
|
+
leg.latestActivity = p.latest;
|
|
272
|
+
leg.stalled = leg.status === 'running' && isStalled(p.lastActivityMs);
|
|
273
|
+
} catch { /* no progress yet — leave base fields only */ }
|
|
274
|
+
return leg;
|
|
268
275
|
});
|
|
269
276
|
const { TERMINAL_STATUSES } = require('./utils/result-schema');
|
|
270
277
|
const done = legs.filter(l => TERMINAL_STATUSES.includes(l.status)).length;
|
|
@@ -524,9 +531,38 @@ const handlers = {
|
|
|
524
531
|
async amicus_fanout(input, project) {
|
|
525
532
|
const cwd = project || getProjectDir(input.project);
|
|
526
533
|
const { generateTaskId } = require('./sidecar/start');
|
|
527
|
-
const { deriveLegIds } = require('./sidecar/fanout');
|
|
534
|
+
const { deriveLegIds, DEFAULT_MAX_LEGS } = require('./sidecar/fanout');
|
|
535
|
+
|
|
536
|
+
// Resolve a single effective models list (council OR models), validated
|
|
537
|
+
// BEFORE any wave dir / metadata is written so a bad request never strands
|
|
538
|
+
// a pid-less 'running' orphan wave.
|
|
539
|
+
const inputModels = Array.isArray(input.models) ? input.models : [];
|
|
540
|
+
const hasModels = inputModels.length > 0;
|
|
541
|
+
const hasCouncil = typeof input.council === 'string' && input.council.trim();
|
|
542
|
+
if (hasModels && hasCouncil) {
|
|
543
|
+
return textResult("Pass exactly one of 'models' / 'council', not both.", true);
|
|
544
|
+
}
|
|
545
|
+
let effectiveModels;
|
|
546
|
+
if (hasCouncil) {
|
|
547
|
+
const { resolveCouncilMembers } = require('./utils/config');
|
|
548
|
+
const { readCache } = require('./utils/model-catalog');
|
|
549
|
+
const catalog = (readCache() || {}).models || [];
|
|
550
|
+
const expanded = resolveCouncilMembers(input.council.trim(), catalog);
|
|
551
|
+
if (expanded.error) { return textResult(expanded.error, true); }
|
|
552
|
+
effectiveModels = expanded.models;
|
|
553
|
+
} else if (hasModels) {
|
|
554
|
+
effectiveModels = inputModels;
|
|
555
|
+
} else {
|
|
556
|
+
return textResult("Provide 'models' or 'council'.", true);
|
|
557
|
+
}
|
|
558
|
+
const envCap = Number(process.env.AMICUS_FANOUT_MAX_LEGS);
|
|
559
|
+
const maxLegs = (Number.isInteger(envCap) && envCap > 0) ? envCap : DEFAULT_MAX_LEGS;
|
|
560
|
+
if (effectiveModels.length > maxLegs) {
|
|
561
|
+
return textResult(`Council/model list exceeds the fan-out cap of ${maxLegs} legs.`, true);
|
|
562
|
+
}
|
|
563
|
+
|
|
528
564
|
const waveId = generateTaskId();
|
|
529
|
-
const legIds = deriveLegIds(waveId,
|
|
565
|
+
const legIds = deriveLegIds(waveId, effectiveModels.length);
|
|
530
566
|
const waveDir = getSessionDir(cwd, waveId);
|
|
531
567
|
|
|
532
568
|
let briefingPath;
|
|
@@ -538,14 +574,14 @@ const handlers = {
|
|
|
538
574
|
fs.writeFileSync(briefingPath, input.prompt, { mode: 0o600 });
|
|
539
575
|
fs.writeFileSync(path.join(waveDir, 'metadata.json'), JSON.stringify({
|
|
540
576
|
taskId: waveId, type: 'wave', status: 'running', legs: legIds,
|
|
541
|
-
models:
|
|
577
|
+
models: effectiveModels, headless: true, createdAt: new Date().toISOString(),
|
|
542
578
|
}, null, 2), { mode: 0o600 });
|
|
543
579
|
} catch (err) {
|
|
544
580
|
return textResult(`Failed to prepare fan-out wave: ${err.message}`, true);
|
|
545
581
|
}
|
|
546
582
|
|
|
547
583
|
const args = [
|
|
548
|
-
'fanout', '--models',
|
|
584
|
+
'fanout', '--models', effectiveModels.join(','),
|
|
549
585
|
'--prompt-file', briefingPath, '--wave-id', waveId,
|
|
550
586
|
'--json', '--client', 'cowork', '--cwd', cwd,
|
|
551
587
|
];
|
|
@@ -574,6 +610,27 @@ const handlers = {
|
|
|
574
610
|
return { content: [{ type: 'text', text: body }, { type: 'text', text: HEADLESS_START_REMINDER }] };
|
|
575
611
|
},
|
|
576
612
|
|
|
613
|
+
async amicus_council_tally(input) {
|
|
614
|
+
try {
|
|
615
|
+
const { tally } = require('./council/tally');
|
|
616
|
+
return textResult(JSON.stringify(tally(input)));
|
|
617
|
+
} catch (err) { return textResult(`council tally failed: ${err.message}`, true); }
|
|
618
|
+
},
|
|
619
|
+
|
|
620
|
+
async amicus_council_stats() {
|
|
621
|
+
try {
|
|
622
|
+
const { deriveReliability } = require('./council/ledger');
|
|
623
|
+
return textResult(JSON.stringify(deriveReliability()));
|
|
624
|
+
} catch (err) { return textResult(`council stats failed: ${err.message}`, true); }
|
|
625
|
+
},
|
|
626
|
+
|
|
627
|
+
async amicus_verdict(input) {
|
|
628
|
+
try {
|
|
629
|
+
const { buildVerdict } = require('./council/verdict');
|
|
630
|
+
return textResult(JSON.stringify(buildVerdict(input.record, input.decisions || [])));
|
|
631
|
+
} catch (err) { return textResult(`verdict build failed: ${err.message}`, true); }
|
|
632
|
+
},
|
|
633
|
+
|
|
577
634
|
async amicus_setup() {
|
|
578
635
|
try { spawnSidecarProcess(['setup']); } catch (err) {
|
|
579
636
|
return textResult(`Failed to launch setup: ${err.message}`, true);
|
|
@@ -592,6 +649,9 @@ const LEGACY_TOOL_ALIASES = {
|
|
|
592
649
|
amicus_setup: 'sidecar_setup', amicus_abort: 'sidecar_abort',
|
|
593
650
|
amicus_fanout: 'sidecar_fanout',
|
|
594
651
|
amicus_guide: 'sidecar_guide',
|
|
652
|
+
amicus_council_tally: 'sidecar_council_tally',
|
|
653
|
+
amicus_council_stats: 'sidecar_council_stats',
|
|
654
|
+
amicus_verdict: 'sidecar_verdict',
|
|
595
655
|
};
|
|
596
656
|
|
|
597
657
|
/** Start the MCP server on stdio transport */
|
package/src/mcp-tools.js
CHANGED
|
@@ -255,8 +255,11 @@ function getTools() {
|
|
|
255
255
|
'document (per-leg summaries inside). Each leg is also an ordinary ' +
|
|
256
256
|
'session readable by taskId.',
|
|
257
257
|
inputSchema: {
|
|
258
|
-
models: z.array(safeModel).min(1).max(10).describe(
|
|
259
|
-
`1-10 models (2+ for genuine fan-out). Short aliases (${aliasNames}) or full provider/model IDs. Duplicates allowed.`
|
|
258
|
+
models: z.array(safeModel).min(1).max(10).optional().describe(
|
|
259
|
+
`1-10 models (2+ for genuine fan-out). Short aliases (${aliasNames}) or full provider/model IDs. Duplicates allowed. Omit when using 'council'.`
|
|
260
|
+
),
|
|
261
|
+
council: z.string().optional().describe(
|
|
262
|
+
"Run a saved council by name (e.g. 'free') instead of 'models'. Expands to the council's members. Mutually exclusive with 'models'."
|
|
260
263
|
),
|
|
261
264
|
prompt: z.string().describe(
|
|
262
265
|
'The briefing sent to every model. Self-contained briefings work best (set includeContext false).'
|
|
@@ -281,6 +284,62 @@ function getTools() {
|
|
|
281
284
|
),
|
|
282
285
|
},
|
|
283
286
|
},
|
|
287
|
+
{
|
|
288
|
+
name: 'amicus_council_tally',
|
|
289
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
290
|
+
description:
|
|
291
|
+
'Deterministic council tally over an ASSEMBLED, de-anonymized input ' +
|
|
292
|
+
'(meta + findings + adjudications + rankings). Peers-only tier cascade ' +
|
|
293
|
+
'(Confirmed/Contested/Disputed/Singleton) + street-cred. Pure + synchronous: ' +
|
|
294
|
+
'returns the tally record immediately. No subprocess, no polling. Claude ' +
|
|
295
|
+
'assembles the input and may override margin tiers afterward.',
|
|
296
|
+
inputSchema: {
|
|
297
|
+
meta: z.object({
|
|
298
|
+
runId: z.string(), runType: z.string().optional(), date: z.string().optional(),
|
|
299
|
+
models: z.array(z.string()).min(1), chair: z.string().optional(),
|
|
300
|
+
claudeInCouncil: z.boolean().optional(),
|
|
301
|
+
}).describe('Run metadata; meta.models lists every reviewed model.'),
|
|
302
|
+
findings: z.array(z.object({
|
|
303
|
+
id: z.string(), raiser: z.string(), severity: z.string(), claim: z.string().optional(),
|
|
304
|
+
})).describe('Run-global findings (ids already A1/B2/C3-prefixed by Claude).'),
|
|
305
|
+
adjudications: z.array(z.object({
|
|
306
|
+
judge: z.string(), findingId: z.string(), verdict: z.enum(['agree', 'dispute', 'neutral']),
|
|
307
|
+
})).describe('One row per (judge × finding).'),
|
|
308
|
+
rankings: z.array(z.object({
|
|
309
|
+
judge: z.string(), order: z.array(z.union([z.string(), z.array(z.string())])),
|
|
310
|
+
})).describe("Each judge's preference order over the reviews (ties = nested array)."),
|
|
311
|
+
runStats: z.array(z.record(z.any())).optional().describe('Optional per-model run stats (status/duration/usage).'),
|
|
312
|
+
project: z.string().optional().describe('Optional project directory path.'),
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
name: 'amicus_council_stats',
|
|
317
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
318
|
+
description:
|
|
319
|
+
'Per-model reviewer reliability derived from the append-only council ledger ' +
|
|
320
|
+
'(avg peers-only street-cred, lifetime confirm/fact-error rates). Read-only; ' +
|
|
321
|
+
'no inputs required.',
|
|
322
|
+
inputSchema: {
|
|
323
|
+
project: z.string().optional().describe('Optional project directory path.'),
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
name: 'amicus_verdict',
|
|
328
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
329
|
+
description:
|
|
330
|
+
"Merge a tally record with Claude's Stage-4 decisions into the verdict " +
|
|
331
|
+
'object (final tiers after overrides, decisions, applied flags). Pure + ' +
|
|
332
|
+
'synchronous; returns the verdict — does NOT write it to disk.',
|
|
333
|
+
inputSchema: {
|
|
334
|
+
record: z.record(z.any()).describe('A tally() output record (from amicus_council_tally).'),
|
|
335
|
+
decisions: z.array(z.object({
|
|
336
|
+
id: z.string(), decision: z.string().optional(), applied: z.boolean().optional(),
|
|
337
|
+
duplicateOf: z.string().nullable().optional(),
|
|
338
|
+
tierOverride: z.object({ from: z.string(), to: z.string(), reason: z.string() }).nullable().optional(),
|
|
339
|
+
})).optional().describe('Stage-4 per-finding decisions (default []).'),
|
|
340
|
+
project: z.string().optional().describe('Optional project directory path.'),
|
|
341
|
+
},
|
|
342
|
+
},
|
|
284
343
|
{
|
|
285
344
|
name: 'amicus_guide',
|
|
286
345
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// src/sidecar/fanout-output.js
|
|
2
2
|
'use strict';
|
|
3
|
+
const { formatCost } = require('../utils/pricing');
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* @module fanout-output
|
|
@@ -36,9 +37,11 @@ function formatWaveHuman(wave) {
|
|
|
36
37
|
lines.push('─'.repeat(40));
|
|
37
38
|
const counts = wave.counts || { complete: '?', total: '?' };
|
|
38
39
|
lines.push(`Wave ${wave.waveId}: ${wave.status} — ${counts.complete}/${counts.total} complete in ${fmtDuration(wave.durationMs)}`);
|
|
40
|
+
lines.push(` Wave cost: ${formatCost(wave.usage && wave.usage.cost)}`);
|
|
39
41
|
for (const leg of wave.legs) {
|
|
40
42
|
const label = leg.modelInput || leg.model || leg.taskId;
|
|
41
|
-
lines.push(` ${leg.taskId} ${String(label).padEnd(12)} ${String(leg.status).padEnd(9)}
|
|
43
|
+
lines.push(` ${leg.taskId} ${String(label).padEnd(12)} ${String(leg.status).padEnd(9)} ` +
|
|
44
|
+
`${String(fmtDuration(leg.durationMs)).padEnd(7)} ${formatCost(leg.usage && leg.usage.cost)}`);
|
|
42
45
|
}
|
|
43
46
|
return lines.join('\n');
|
|
44
47
|
}
|
package/src/sidecar/fanout.js
CHANGED
|
@@ -109,7 +109,8 @@ function writeWaveMetadata(waveDir, patch) {
|
|
|
109
109
|
async function runFanout(options) {
|
|
110
110
|
const { buildWaveResult, waveExitCode } = require('../utils/result-schema');
|
|
111
111
|
const { generateTaskId, buildMcpConfig } = require('./start');
|
|
112
|
-
const { startOpenCodeServer,
|
|
112
|
+
const { startOpenCodeServer, HEARTBEAT_INTERVAL } = require('./session-utils');
|
|
113
|
+
const { createWaveHeartbeat } = require('./wave-progress');
|
|
113
114
|
const { buildContext } = require('./context-builder');
|
|
114
115
|
const { buildPrompts } = require('../prompt-builder');
|
|
115
116
|
const { installSignalAbort, markAborted } = require('../utils/session-abort');
|
|
@@ -223,7 +224,12 @@ async function runFanout(options) {
|
|
|
223
224
|
});
|
|
224
225
|
|
|
225
226
|
// 6. Launch all legs concurrently (runLeg never rejects)
|
|
226
|
-
const heartbeat = options.quiet
|
|
227
|
+
const heartbeat = options.quiet
|
|
228
|
+
? { stop() {} }
|
|
229
|
+
: createWaveHeartbeat(
|
|
230
|
+
legs.map((leg, i) => ({ label: leg.modelInput || leg.model, dir: legDirs[i] })),
|
|
231
|
+
HEARTBEAT_INTERVAL
|
|
232
|
+
);
|
|
227
233
|
const timeoutMs = (options.timeout || 15) * 60 * 1000;
|
|
228
234
|
const reasoning = options.thinking ? { effort: options.thinking } : undefined;
|
|
229
235
|
let legDocs;
|
package/src/sidecar/progress.js
CHANGED
|
@@ -87,6 +87,19 @@ function computeLastActivity(mtime) {
|
|
|
87
87
|
return `${diffHr}h ago`;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/** A leg with no new activity for longer than this (ms) is flagged stalled in rollups. */
|
|
91
|
+
const STALL_MS = 60000;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Is a leg stalled? True only when we have a real idle measurement that exceeds
|
|
95
|
+
* the threshold; an unknown / just-started leg (null) is never "stalled".
|
|
96
|
+
* @param {number|null|undefined} lastActivityMs ms since last activity
|
|
97
|
+
* @returns {boolean}
|
|
98
|
+
*/
|
|
99
|
+
function isStalled(lastActivityMs) {
|
|
100
|
+
return typeof lastActivityMs === 'number' && lastActivityMs > STALL_MS;
|
|
101
|
+
}
|
|
102
|
+
|
|
90
103
|
/**
|
|
91
104
|
* Write a progress update to progress.json.
|
|
92
105
|
*
|
|
@@ -214,5 +227,7 @@ module.exports = {
|
|
|
214
227
|
writeProgress,
|
|
215
228
|
extractLatest,
|
|
216
229
|
computeLastActivity,
|
|
217
|
-
STAGE_LABELS
|
|
230
|
+
STAGE_LABELS,
|
|
231
|
+
STALL_MS,
|
|
232
|
+
isStalled
|
|
218
233
|
};
|
package/src/sidecar/setup.js
CHANGED
|
@@ -40,19 +40,23 @@ function addAlias(name, modelString) {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
|
-
*
|
|
43
|
+
* Ensure a config exists with the chosen default model. Read-modify-write:
|
|
44
|
+
* preserves every pre-existing top-level key (aliases, councils, …) and only
|
|
45
|
+
* fills in the default + any missing default aliases. Never clobbers.
|
|
44
46
|
* @param {string} defaultModel - Default model alias or full model string
|
|
45
|
-
* @returns {object} The
|
|
47
|
+
* @returns {object} The resulting config object
|
|
46
48
|
*/
|
|
47
49
|
function createDefaultConfig(defaultModel) {
|
|
50
|
+
const existing = loadConfig() || {};
|
|
48
51
|
const cfg = {
|
|
49
|
-
|
|
50
|
-
|
|
52
|
+
...existing,
|
|
53
|
+
default: existing.default || defaultModel,
|
|
54
|
+
aliases: { ...getDefaultAliases(), ...(existing.aliases || {}) },
|
|
51
55
|
};
|
|
52
56
|
saveConfig(cfg);
|
|
53
|
-
logger.info('Default config
|
|
54
|
-
default:
|
|
55
|
-
aliasCount: Object.keys(cfg.aliases).length
|
|
57
|
+
logger.info('Default config ensured', {
|
|
58
|
+
default: cfg.default,
|
|
59
|
+
aliasCount: Object.keys(cfg.aliases).length,
|
|
56
60
|
});
|
|
57
61
|
return cfg;
|
|
58
62
|
}
|
|
@@ -152,13 +156,70 @@ async function seedCatalog(print) {
|
|
|
152
156
|
log('Model catalog unavailable (offline?) — it will refresh on first start.');
|
|
153
157
|
}
|
|
154
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Free OpenRouter council branch of the readline wizard. Requires
|
|
161
|
+
* OPENROUTER_API_KEY; lists free catalog models, lets the user multi-pick
|
|
162
|
+
* (Enter = the vendor-diverse default), seeds aliases + councils.free, and
|
|
163
|
+
* never touches config.default.
|
|
164
|
+
* @param {readline.Interface} rl
|
|
165
|
+
*/
|
|
166
|
+
async function runFreeCouncilBranch(rl) {
|
|
167
|
+
const keys = detectApiKeys();
|
|
168
|
+
if (!keys.openrouter) {
|
|
169
|
+
console.log('');
|
|
170
|
+
console.log('A free council needs OPENROUTER_API_KEY (free models route only through OpenRouter).');
|
|
171
|
+
console.log('Set OPENROUTER_API_KEY and re-run: amicus setup. No changes made.');
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const { getCatalog } = require('../utils/model-catalog');
|
|
175
|
+
const { listFreeModels, suggestFreeCouncil, PINNED_FREE_MODELS } = require('../utils/free-models');
|
|
176
|
+
let catalog = [];
|
|
177
|
+
try { catalog = await getCatalog(); } catch (_e) { /* offline */ }
|
|
178
|
+
let free = listFreeModels(catalog);
|
|
179
|
+
if (free.length === 0) {
|
|
180
|
+
console.log('Live free-model list unavailable (offline?) — using a small pinned set.');
|
|
181
|
+
free = PINNED_FREE_MODELS.map(id => ({ id }));
|
|
182
|
+
}
|
|
183
|
+
const defaults = new Set(suggestFreeCouncil(free, 3).map(r => r.id));
|
|
184
|
+
console.log('');
|
|
185
|
+
console.log('Free OpenRouter models (★ = default council):');
|
|
186
|
+
free.forEach((r, i) => {
|
|
187
|
+
const star = defaults.has(r.id) ? '★' : ' ';
|
|
188
|
+
console.log(` ${star} ${i + 1}) ${r.id}`);
|
|
189
|
+
});
|
|
190
|
+
console.log('');
|
|
191
|
+
const answer = await askQuestion(rl,
|
|
192
|
+
'Pick members (comma-separated numbers, or Enter for the ★ default): ');
|
|
193
|
+
let pickIds;
|
|
194
|
+
if (!answer) {
|
|
195
|
+
pickIds = free.filter(r => defaults.has(r.id)).map(r => r.id);
|
|
196
|
+
} else {
|
|
197
|
+
pickIds = answer.split(',').map(s => parseInt(s.trim(), 10))
|
|
198
|
+
.filter(n => n >= 1 && n <= free.length).map(n => free[n - 1].id);
|
|
199
|
+
}
|
|
200
|
+
if (pickIds.length < 2) {
|
|
201
|
+
console.log('A council needs at least 2 models. No changes made.');
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const { council } = seedFreeCouncil(pickIds);
|
|
205
|
+
await seedCatalog();
|
|
206
|
+
console.log('');
|
|
207
|
+
console.log(`Free council saved: councils.free = [${council.join(', ')}]`);
|
|
208
|
+
console.log('Run it: amicus fanout --council free --prompt "..."');
|
|
209
|
+
console.log('config.default left unchanged.');
|
|
210
|
+
console.log('');
|
|
211
|
+
console.log('Heads up (free tier): rate-limited & quality-variable; some models 404');
|
|
212
|
+
console.log('unless you enable data-sharing at openrouter.ai/settings/privacy.');
|
|
213
|
+
}
|
|
214
|
+
|
|
155
215
|
/**
|
|
156
216
|
* Run the readline-based setup wizard (headless fallback)
|
|
157
217
|
*
|
|
158
218
|
* Guides the user through:
|
|
159
219
|
* 1. API key detection
|
|
160
|
-
* 2.
|
|
161
|
-
* 3.
|
|
220
|
+
* 2. Mode selection (standard or free council)
|
|
221
|
+
* 3. Default model selection from live quick-picks (read-modify-write, no clobber)
|
|
222
|
+
* 4. Config file save
|
|
162
223
|
*/
|
|
163
224
|
async function runReadlineSetup() {
|
|
164
225
|
const rl = readline.createInterface({
|
|
@@ -185,6 +246,13 @@ async function runReadlineSetup() {
|
|
|
185
246
|
}
|
|
186
247
|
console.log('');
|
|
187
248
|
|
|
249
|
+
const mode = await askQuestion(rl,
|
|
250
|
+
'Setup mode — 1) Standard (pick a default model) 2) Free OpenRouter council: ');
|
|
251
|
+
if (mode === '2') {
|
|
252
|
+
await runFreeCouncilBranch(rl);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
188
256
|
const { getCatalog } = require('../utils/model-catalog');
|
|
189
257
|
const { resolveQuickPicks, toLiveSeedAliases } = require('../utils/quick-picks');
|
|
190
258
|
let catalog = [];
|
|
@@ -282,12 +350,64 @@ async function runInteractiveSetup() {
|
|
|
282
350
|
|
|
283
351
|
/* eslint-enable no-console */
|
|
284
352
|
|
|
353
|
+
/**
|
|
354
|
+
* Collision-safe alias name from a free model id. Strips the openrouter/
|
|
355
|
+
* prefix and trailing :free, sanitizes '/'/':' to '-', prefixes 'free-',
|
|
356
|
+
* and disambiguates against `taken` with a numeric suffix.
|
|
357
|
+
* @param {string} id e.g. openrouter/deepseek/deepseek-r1:free
|
|
358
|
+
* @param {Set<string>} taken alias names already in use
|
|
359
|
+
* @returns {string} e.g. free-deepseek-deepseek-r1
|
|
360
|
+
*/
|
|
361
|
+
function deriveFreeAlias(id, taken) {
|
|
362
|
+
const base = 'free-' + id
|
|
363
|
+
.replace(/^openrouter\//, '')
|
|
364
|
+
.replace(/:free$/, '')
|
|
365
|
+
.replace(/[/:]/g, '-')
|
|
366
|
+
.replace(/-+/g, '-');
|
|
367
|
+
let name = base;
|
|
368
|
+
let n = 2;
|
|
369
|
+
while (taken.has(name)) { name = `${base}-${n++}`; }
|
|
370
|
+
taken.add(name);
|
|
371
|
+
return name;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Seed free-model aliases + councils.free from chosen catalog ids.
|
|
376
|
+
* Single atomic read-modify-write. Reuses an existing alias that already
|
|
377
|
+
* maps to the same id (idempotent re-runs); never touches config.default.
|
|
378
|
+
* @param {string[]} pickIds full openrouter/.../...:free ids
|
|
379
|
+
* @returns {{added: Array<{alias:string, model:string}>, council: string[]}}
|
|
380
|
+
*/
|
|
381
|
+
function seedFreeCouncil(pickIds) {
|
|
382
|
+
const cfg = loadConfig() || { aliases: {} };
|
|
383
|
+
if (!cfg.aliases) { cfg.aliases = {}; }
|
|
384
|
+
const taken = new Set(Object.keys(cfg.aliases));
|
|
385
|
+
const council = [];
|
|
386
|
+
const added = [];
|
|
387
|
+
for (const id of pickIds) {
|
|
388
|
+
const existing = Object.entries(cfg.aliases).find(([, m]) => m === id);
|
|
389
|
+
if (existing) { if (!council.includes(existing[0])) { council.push(existing[0]); } continue; }
|
|
390
|
+
const alias = deriveFreeAlias(id, taken);
|
|
391
|
+
cfg.aliases[alias] = id;
|
|
392
|
+
added.push({ alias, model: id });
|
|
393
|
+
council.push(alias);
|
|
394
|
+
}
|
|
395
|
+
if (!cfg.councils) { cfg.councils = {}; }
|
|
396
|
+
cfg.councils.free = Array.from(new Set(council));
|
|
397
|
+
saveConfig(cfg);
|
|
398
|
+
logger.info('Free council seeded', { count: cfg.councils.free.length });
|
|
399
|
+
return { added, council: cfg.councils.free };
|
|
400
|
+
}
|
|
401
|
+
|
|
285
402
|
module.exports = {
|
|
286
403
|
addAlias,
|
|
287
404
|
createDefaultConfig,
|
|
405
|
+
deriveFreeAlias,
|
|
288
406
|
detectApiKeys,
|
|
407
|
+
runFreeCouncilBranch,
|
|
289
408
|
runInteractiveSetup,
|
|
290
409
|
runReadlineSetup,
|
|
291
410
|
runApiKeySetup,
|
|
292
411
|
seedCatalog,
|
|
412
|
+
seedFreeCouncil,
|
|
293
413
|
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// src/sidecar/wave-progress.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module wave-progress
|
|
6
|
+
* Per-leg live progress rollup for a fan-out wave. Each headless leg already
|
|
7
|
+
* writes progress.json + conversation.jsonl to its own session dir; this reads
|
|
8
|
+
* them on a timer and prints ONE terse line per leg to stderr — milestones,
|
|
9
|
+
* never a token firehose (all three council models flagged firehose noise).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const { readProgress, isStalled } = require('./progress');
|
|
15
|
+
|
|
16
|
+
const WAVE_HEARTBEAT_INTERVAL = 15000;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Render one compact status line per leg. Pure: takes already-read leg states.
|
|
20
|
+
* @param {Array<{label:string, messages:number, latest:string, stage?:string, stalled:boolean}>} legStates
|
|
21
|
+
* @returns {string}
|
|
22
|
+
*/
|
|
23
|
+
function formatWaveProgress(legStates) {
|
|
24
|
+
return legStates.map((s) => {
|
|
25
|
+
const stage = s.stage || 'starting';
|
|
26
|
+
const flag = s.stalled ? ' ⏳stalled' : '';
|
|
27
|
+
return `[amicus] ${String(s.label).padEnd(16)} ${String(stage).padEnd(10)} ` +
|
|
28
|
+
`${s.messages} msg | ${s.latest}${flag}`;
|
|
29
|
+
}).join('\n');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Read a single leg's live state from its session dir. Degrades gracefully when
|
|
34
|
+
* progress.json is absent (a leg that has not started writing yet).
|
|
35
|
+
* @param {{label:string, dir:string}} leg
|
|
36
|
+
* @returns {{label:string, messages:number, latest:string, stage?:string, stalled:boolean}}
|
|
37
|
+
*/
|
|
38
|
+
function readLegState(leg) {
|
|
39
|
+
const progressPath = path.join(leg.dir, 'progress.json');
|
|
40
|
+
const convPath = path.join(leg.dir, 'conversation.jsonl');
|
|
41
|
+
|
|
42
|
+
// Degrade gracefully when neither file has been written yet
|
|
43
|
+
if (!fs.existsSync(progressPath) && !fs.existsSync(convPath)) {
|
|
44
|
+
return { label: leg.label, messages: 0, latest: 'starting…', stalled: false };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let p;
|
|
48
|
+
try { p = readProgress(leg.dir); } catch { p = null; }
|
|
49
|
+
if (!p) { return { label: leg.label, messages: 0, latest: 'starting…', stalled: false }; }
|
|
50
|
+
|
|
51
|
+
const state = {
|
|
52
|
+
label: leg.label,
|
|
53
|
+
messages: p.messages,
|
|
54
|
+
latest: p.latest,
|
|
55
|
+
stalled: isStalled(p.lastActivityMs),
|
|
56
|
+
};
|
|
57
|
+
if (p.stage !== undefined) {
|
|
58
|
+
state.stage = p.stage;
|
|
59
|
+
}
|
|
60
|
+
return state;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Start a wave heartbeat that prints a per-leg rollup each tick. Mirrors the
|
|
65
|
+
* createHeartbeat contract: returns { stop() }.
|
|
66
|
+
* @param {Array<{label:string, dir:string}>} legs
|
|
67
|
+
* @param {number} [interval]
|
|
68
|
+
* @returns {{stop: () => void}}
|
|
69
|
+
*/
|
|
70
|
+
function createWaveHeartbeat(legs, interval = WAVE_HEARTBEAT_INTERVAL) {
|
|
71
|
+
const startTime = Date.now();
|
|
72
|
+
const intervalId = setInterval(() => {
|
|
73
|
+
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
|
74
|
+
const states = legs.map(readLegState);
|
|
75
|
+
process.stderr.write(`[amicus] wave ${elapsed}s — ${states.length} legs\n${formatWaveProgress(states)}\n`);
|
|
76
|
+
}, interval);
|
|
77
|
+
return { stop() { clearInterval(intervalId); } };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { formatWaveProgress, readLegState, createWaveHeartbeat, WAVE_HEARTBEAT_INTERVAL };
|
package/src/utils/config.js
CHANGED
|
@@ -273,6 +273,57 @@ function detectFallback(alias, resolvedModel) {
|
|
|
273
273
|
return !!(val && val.startsWith('openrouter/') && !resolvedModel.startsWith('openrouter/'));
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
+
/** @returns {Object<string,string[]>} the councils map (empty if none) */
|
|
277
|
+
function getCouncils() {
|
|
278
|
+
const config = loadConfig();
|
|
279
|
+
return (config && config.councils) || {};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** @param {string} name @returns {string[]|null} council members, or null if absent */
|
|
283
|
+
function getCouncil(name) {
|
|
284
|
+
return getCouncils()[name] || null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Expand a saved council into a runnable members list, degrading gracefully.
|
|
289
|
+
* Each member is resolved to its full model id (alias → id via effective
|
|
290
|
+
* aliases; a member containing '/' is taken as-is) and that id checked against
|
|
291
|
+
* the cached catalog. Unresolvable aliases and delisted ids are dropped with a
|
|
292
|
+
* warning rather than fail-fast-aborting the whole wave. The catalog check is
|
|
293
|
+
* skipped when the catalog is empty (offline). Returns members RAW (alias or
|
|
294
|
+
* id) — leg-time validation resolves them again.
|
|
295
|
+
* @param {string} name
|
|
296
|
+
* @param {Array<{id:string}>} [catalog]
|
|
297
|
+
* @returns {{models:string[], dropped:string[]} | {error:string}}
|
|
298
|
+
*/
|
|
299
|
+
function resolveCouncilMembers(name, catalog = []) {
|
|
300
|
+
const members = getCouncil(name);
|
|
301
|
+
if (!members) {
|
|
302
|
+
return { error: `Unknown council '${name}'. Run 'amicus setup' to create one.` };
|
|
303
|
+
}
|
|
304
|
+
if (!Array.isArray(members) || members.length === 0) {
|
|
305
|
+
return { error: `Council '${name}' is empty. Run 'amicus setup' to populate it.` };
|
|
306
|
+
}
|
|
307
|
+
const aliases = getEffectiveAliases();
|
|
308
|
+
const known = new Set((Array.isArray(catalog) ? catalog : []).map(m => m && m.id).filter(Boolean));
|
|
309
|
+
const models = [];
|
|
310
|
+
const dropped = [];
|
|
311
|
+
for (const member of members) {
|
|
312
|
+
const id = member.includes('/') ? member : aliases[member];
|
|
313
|
+
if (!id) { dropped.push(member); continue; } // alias no longer resolves
|
|
314
|
+
if (known.size > 0 && !known.has(id)) { dropped.push(member); continue; } // delisted model
|
|
315
|
+
models.push(member);
|
|
316
|
+
}
|
|
317
|
+
if (models.length < 2) {
|
|
318
|
+
return {
|
|
319
|
+
error: `Council '${name}' has fewer than 2 usable members` +
|
|
320
|
+
(dropped.length ? ` (dropped: ${dropped.join(', ')})` : '') +
|
|
321
|
+
'. Run \'amicus setup\' to refresh it.',
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
return { models, dropped };
|
|
325
|
+
}
|
|
326
|
+
|
|
276
327
|
module.exports = {
|
|
277
328
|
getConfigDir,
|
|
278
329
|
getConfigPath,
|
|
@@ -288,4 +339,7 @@ module.exports = {
|
|
|
288
339
|
formatAliasNames,
|
|
289
340
|
tryResolveModel,
|
|
290
341
|
buildProviderModels,
|
|
342
|
+
getCouncils,
|
|
343
|
+
getCouncil,
|
|
344
|
+
resolveCouncilMembers,
|
|
291
345
|
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Free OpenRouter model detection (Unit A).
|
|
3
|
+
*
|
|
4
|
+
* A free model is an openrouter/* catalog id whose slug ends in ':free'.
|
|
5
|
+
* The ':free' suffix is OpenRouter's authoritative free-tier marker. A
|
|
6
|
+
* zero prompt/completion price is deliberately NOT used: the catalog
|
|
7
|
+
* normalizer keeps only {prompt, completion} and discards request/image
|
|
8
|
+
* pricing, so a per-request-charged model with prompt:'0' would be
|
|
9
|
+
* mislabeled. Pure + network-free.
|
|
10
|
+
*/
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
/** Offline last-resort free ids (used only when the live catalog is empty). */
|
|
14
|
+
const PINNED_FREE_MODELS = [
|
|
15
|
+
'openrouter/deepseek/deepseek-r1:free',
|
|
16
|
+
'openrouter/google/gemini-2.0-flash-exp:free',
|
|
17
|
+
'openrouter/qwen/qwen3-coder:free',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
/** @param {{id?:string}} row @returns {boolean} */
|
|
21
|
+
function isFreeModel(row) {
|
|
22
|
+
const id = row && typeof row.id === 'string' ? row.id : '';
|
|
23
|
+
return id.startsWith('openrouter/') && id.endsWith(':free');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** @param {Array} catalog @returns {Array} free rows, sorted by vendor then id */
|
|
27
|
+
function listFreeModels(catalog) {
|
|
28
|
+
const rows = (Array.isArray(catalog) ? catalog : []).filter(isFreeModel);
|
|
29
|
+
return rows.sort((a, b) => {
|
|
30
|
+
const va = a.id.split('/')[1] || '';
|
|
31
|
+
const vb = b.id.split('/')[1] || '';
|
|
32
|
+
return va === vb ? a.id.localeCompare(b.id) : va.localeCompare(vb);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** @param {Array} catalog @param {number} n @returns {Array} ≤n free rows, one per vendor */
|
|
37
|
+
function suggestFreeCouncil(catalog, n = 3) {
|
|
38
|
+
const out = [];
|
|
39
|
+
const seenVendors = new Set();
|
|
40
|
+
for (const row of listFreeModels(catalog)) {
|
|
41
|
+
const vendor = row.id.split('/')[1] || '';
|
|
42
|
+
if (seenVendors.has(vendor)) { continue; }
|
|
43
|
+
seenVendors.add(vendor);
|
|
44
|
+
out.push(row);
|
|
45
|
+
if (out.length >= n) { break; }
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { isFreeModel, listFreeModels, suggestFreeCouncil, PINNED_FREE_MODELS };
|
package/src/utils/pricing.js
CHANGED
|
@@ -90,4 +90,19 @@ function sumWaveUsage(legs) {
|
|
|
90
90
|
return { tokens, cost: { amount: anyAmount ? amount : null, currency: 'USD', source, reportedLegs, estimatedLegs, unpricedLegs } };
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
|
|
93
|
+
/**
|
|
94
|
+
* Render a resolved cost object for humans. Never invents precision: a null
|
|
95
|
+
* amount is '—' (or '?' when the source is explicitly 'unknown'); estimated /
|
|
96
|
+
* mixed costs are marked with '~' so they can't be read as authoritative.
|
|
97
|
+
* @param {{amount:number|null, source:string}|null|undefined} cost
|
|
98
|
+
* @returns {string}
|
|
99
|
+
*/
|
|
100
|
+
function formatCost(cost) {
|
|
101
|
+
if (!cost || cost.amount === null || cost.amount === undefined) {
|
|
102
|
+
return cost && cost.source === 'unknown' ? '?' : '—';
|
|
103
|
+
}
|
|
104
|
+
const dollars = cost.amount < 1 ? `$${cost.amount.toFixed(4)}` : `$${cost.amount.toFixed(2)}`;
|
|
105
|
+
return (cost.source === 'estimated' || cost.source === 'mixed') ? `~${dollars}` : dollars;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = { emptyUsageTotals, sumPerMessageUsage, lookupPricing, resolveLegCost, resolveUsage, sumWaveUsage, formatCost };
|