amicus 4.2.0 → 4.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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +37 -1
- package/README.md +8 -5
- package/bin/amicus.js +5 -0
- package/package.json +1 -1
- package/schemas/council-run-live.schema.json +33 -0
- package/schemas/event.schema.json +15 -0
- package/schemas/progress.schema.json +24 -0
- package/schemas/run-live.schema.json +15 -0
- package/schemas/spend.schema.json +26 -1
- package/schemas/wave-live.schema.json +15 -0
- package/src/cli-handlers-council-run.js +61 -5
- package/src/cli-handlers-run.js +26 -0
- package/src/cli-handlers-spend.js +62 -27
- package/src/cli-handlers-watch.js +89 -0
- package/src/cli.js +58 -1
- package/src/council/run-chair.js +10 -2
- package/src/council/run-debate.js +5 -1
- package/src/council/run-launch.js +14 -1
- package/src/council/run-stages.js +13 -0
- package/src/council/run.js +32 -4
- package/src/headless.js +9 -1
- package/src/mcp-council-awareness.js +46 -1
- package/src/mcp-council-run.js +28 -4
- package/src/mcp-notify.js +54 -0
- package/src/mcp-server.js +51 -1
- package/src/mcp-spend.js +125 -0
- package/src/mcp-tools.js +39 -0
- package/src/mcp-wait.js +28 -2
- package/src/observe/events.js +156 -0
- package/src/observe/follow.js +26 -0
- package/src/observe/live-doc.js +38 -0
- package/src/observe/on-complete.js +117 -0
- package/src/observe/watch-render.js +149 -0
- package/src/sidecar/continue.js +32 -0
- package/src/sidecar/fallback-chains.js +65 -0
- package/src/sidecar/fanout-leg-fallback.js +189 -0
- package/src/sidecar/fanout-leg.js +58 -26
- package/src/sidecar/fanout-retry.js +208 -0
- package/src/sidecar/fanout-validate.js +42 -4
- package/src/sidecar/fanout.js +50 -30
- package/src/sidecar/progress.js +5 -0
- package/src/sidecar/resume.js +12 -0
- package/src/sidecar/start.js +13 -1
- package/src/spend-query.js +104 -0
- package/src/utils/api-key-store.js +7 -4
- package/src/utils/env-loader.js +0 -1
- package/src/utils/env-raw-store.js +13 -4
- package/src/utils/error-classify.js +31 -0
- package/src/utils/model-tiers.js +1 -1
- package/src/utils/spend-ledger.js +24 -1
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// src/spend-query.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module spend-query
|
|
6
|
+
* Pure query/rollup helpers for `amicus spend` (spec §7.3 filters/group-by,
|
|
7
|
+
* §6.3/resolved Q6 `wasted`). Split out of src/cli-handlers-spend.js (which
|
|
8
|
+
* re-exports these) to stay under the 300-line size gate — see that file's
|
|
9
|
+
* module docblock. No I/O, no CLI concerns: everything here is rows-in,
|
|
10
|
+
* rows/rollup-out and independently testable.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Valid `--group-by`/`groupBy` dimensions — the SINGLE source of truth shared
|
|
15
|
+
* by the CLI's validity check (cli-handlers-spend.js), the MCP `amicus_spend`
|
|
16
|
+
* tool's `groupBy` Zod enum (mcp-tools.js), and rowKey()'s switch below. Do
|
|
17
|
+
* NOT hand-copy this array elsewhere: a 7th dimension added here must reach
|
|
18
|
+
* both surfaces automatically, not just the one someone remembered to edit.
|
|
19
|
+
*/
|
|
20
|
+
const GROUP_DIMS = ['model', 'wave', 'council', 'project', 'op', 'day'];
|
|
21
|
+
|
|
22
|
+
/** Cap on rows returned when a caller opts into raw rows (CLI --rows / MCP rows:true). */
|
|
23
|
+
const ROWS_CAP = 1000;
|
|
24
|
+
|
|
25
|
+
function emptyTokens() {
|
|
26
|
+
return { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function addTokens(into, tokens) {
|
|
30
|
+
if (!tokens) { return; }
|
|
31
|
+
for (const k of Object.keys(into)) { into[k] += tokens[k] || 0; }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Pure row filter over the additive attribution fields (spec 7.3). */
|
|
35
|
+
function filterRows(rows, f = {}) {
|
|
36
|
+
const cutoff = (f.since !== undefined && f.since !== null && f.now !== undefined) ? f.now - f.since * 86400000 : null;
|
|
37
|
+
return rows.filter((r) => {
|
|
38
|
+
if (f.wave && r.waveId !== f.wave) { return false; }
|
|
39
|
+
if (f.council && r.councilRunId !== f.council && r.councilName !== f.council) { return false; }
|
|
40
|
+
if (f.project && r.project !== f.project) { return false; }
|
|
41
|
+
if (f.model && !String(r.model || '').startsWith(f.model)) { return false; }
|
|
42
|
+
if (f.op && r.op !== f.op) { return false; }
|
|
43
|
+
if (f.failed && (r.status === 'complete' || !r.status)) { return false; }
|
|
44
|
+
if (cutoff !== null) { const t = Date.parse(r.ts); if (!Number.isFinite(t) || t < cutoff) { return false; } }
|
|
45
|
+
return true;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** dimension -> row key. null/absent -> '(unattributed)'. `day` = the ISO date. */
|
|
50
|
+
function rowKey(row, dimension) {
|
|
51
|
+
switch (dimension) {
|
|
52
|
+
case 'model': return row.model || '(unattributed)';
|
|
53
|
+
case 'wave': return row.waveId || '(unattributed)';
|
|
54
|
+
case 'council': return row.councilRunId || row.councilName || '(unattributed)';
|
|
55
|
+
case 'project': return row.project || '(unattributed)';
|
|
56
|
+
case 'op': return row.op || '(unattributed)';
|
|
57
|
+
case 'day': return typeof row.ts === 'string' ? row.ts.slice(0, 10) : '(unattributed)';
|
|
58
|
+
default: return '(unattributed)';
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Group rows into {key, amount, tokens, runs, sourceMix}, most-expensive first. */
|
|
63
|
+
function groupRows(rows, dimension) {
|
|
64
|
+
const map = new Map();
|
|
65
|
+
for (const r of rows) {
|
|
66
|
+
const key = rowKey(r, dimension);
|
|
67
|
+
if (!map.has(key)) { map.set(key, { key, amount: 0, tokens: emptyTokens(), runs: 0, sourceMix: { reported: 0, estimated: 0, unknown: 0 } }); }
|
|
68
|
+
const b = map.get(key);
|
|
69
|
+
b.runs += 1;
|
|
70
|
+
addTokens(b.tokens, r.tokens);
|
|
71
|
+
const cost = r.cost || {};
|
|
72
|
+
if (typeof cost.amount === 'number') { b.amount += cost.amount; }
|
|
73
|
+
const src = (cost.source === 'reported' || cost.source === 'estimated') ? cost.source : 'unknown';
|
|
74
|
+
b.sourceMix[src] += 1;
|
|
75
|
+
}
|
|
76
|
+
return [...map.values()].sort((a, b) => b.amount - a.amount);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Wasted spend = every row with an EXPLICIT non-complete status, bucketed by
|
|
81
|
+
* status (spec 6.3, resolved Q6). A row with status null/absent (pre-v4.3,
|
|
82
|
+
* or any row that never reached a terminal status write) is deliberately
|
|
83
|
+
* EXCLUDED here — not "complete" and not "wasted" — because we cannot know
|
|
84
|
+
* whether that historical run actually failed; counting it would fabricate
|
|
85
|
+
* a failure that was never recorded. Contrast with groupRows(), where a null
|
|
86
|
+
* dimension is a first-class '(unattributed)' bucket (grouping never drops
|
|
87
|
+
* a row); computeWasted intentionally drops it instead.
|
|
88
|
+
*/
|
|
89
|
+
function computeWasted(rows) {
|
|
90
|
+
const out = { amount: 0, tokens: emptyTokens(), runs: 0, byStatus: {} };
|
|
91
|
+
for (const r of rows) {
|
|
92
|
+
if (r.status === 'complete' || !r.status) { continue; }
|
|
93
|
+
out.runs += 1;
|
|
94
|
+
addTokens(out.tokens, r.tokens);
|
|
95
|
+
const amt = (r.cost && typeof r.cost.amount === 'number') ? r.cost.amount : 0;
|
|
96
|
+
out.amount += amt;
|
|
97
|
+
if (!out.byStatus[r.status]) { out.byStatus[r.status] = { amount: 0, runs: 0 }; }
|
|
98
|
+
out.byStatus[r.status].amount += amt;
|
|
99
|
+
out.byStatus[r.status].runs += 1;
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = { filterRows, groupRows, computeWasted, emptyTokens, addTokens, GROUP_DIMS, ROWS_CAP };
|
|
@@ -60,6 +60,8 @@ function migrateEnvFileKey(envPath, oldName, newName) {
|
|
|
60
60
|
const updated = content.replace(re, `${newName}=`);
|
|
61
61
|
if (updated !== content) {
|
|
62
62
|
fs.writeFileSync(envPath, updated, { mode: 0o600 });
|
|
63
|
+
// Re-assert 0600 on the existing secrets file (writeFileSync mode is create-only).
|
|
64
|
+
try { fs.chmodSync(envPath, 0o600); } catch (_err) { /* perms best-effort */ }
|
|
63
65
|
}
|
|
64
66
|
} catch (_err) {
|
|
65
67
|
// Best effort
|
|
@@ -149,10 +151,11 @@ function saveApiKey(provider, key) {
|
|
|
149
151
|
if (!envVar) {
|
|
150
152
|
return { success: false, error: `Unknown provider: ${provider}` };
|
|
151
153
|
}
|
|
152
|
-
// Shared merge helper (preserves comments/other lines, dedups, 0600, trailing NL
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
process.env
|
|
154
|
+
// Shared merge helper (preserves comments/other lines, dedups, 0600, trailing NL;
|
|
155
|
+
// strips CR/LF and returns the cleaned value).
|
|
156
|
+
const clean = upsertEnvLine(getEnvPath(), envVar, key);
|
|
157
|
+
// Also set process.env so the key is immediately available (sanitized to match disk)
|
|
158
|
+
process.env[envVar] = clean;
|
|
156
159
|
return { success: true };
|
|
157
160
|
}
|
|
158
161
|
|
package/src/utils/env-loader.js
CHANGED
|
@@ -53,7 +53,6 @@ function loadCredentials() {
|
|
|
53
53
|
// process.env (same never-overwrite rule) so the engine's {env:VAR} finds it.
|
|
54
54
|
try {
|
|
55
55
|
const { getLocalProviders } = require('./local-providers');
|
|
56
|
-
const fileEntries = loadEnvEntries();
|
|
57
56
|
for (const entry of Object.values(getLocalProviders())) {
|
|
58
57
|
if (entry.apiKeyEnv && !process.env[entry.apiKeyEnv]) {
|
|
59
58
|
const v = fileEntries.get(entry.apiKeyEnv);
|
|
@@ -23,6 +23,10 @@ const ENV_VAR_RE = /^[A-Z][A-Z0-9_]*$/;
|
|
|
23
23
|
* @param {string} value
|
|
24
24
|
*/
|
|
25
25
|
function upsertEnvLine(envPath, envVar, value) {
|
|
26
|
+
// Strip CR/LF: a newline in the value corrupts the line-based .env (splits it,
|
|
27
|
+
// or bakes a trailing CR into the persisted/served token). Bearer/API-key schemes
|
|
28
|
+
// never contain newlines, so stripping cannot damage a legitimate token.
|
|
29
|
+
const clean = String(value).replace(/[\r\n]/g, '');
|
|
26
30
|
fs.mkdirSync(path.dirname(envPath), { recursive: true });
|
|
27
31
|
|
|
28
32
|
let lines = [];
|
|
@@ -37,7 +41,7 @@ function upsertEnvLine(envPath, envVar, value) {
|
|
|
37
41
|
let found = false;
|
|
38
42
|
for (let i = 0; i < lines.length; i++) {
|
|
39
43
|
if (lines[i].trim().startsWith(envVar + '=')) {
|
|
40
|
-
lines[i] = `${envVar}=${
|
|
44
|
+
lines[i] = `${envVar}=${clean}`;
|
|
41
45
|
found = true;
|
|
42
46
|
break;
|
|
43
47
|
}
|
|
@@ -46,10 +50,14 @@ function upsertEnvLine(envPath, envVar, value) {
|
|
|
46
50
|
while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
|
|
47
51
|
lines.pop();
|
|
48
52
|
}
|
|
49
|
-
lines.push(`${envVar}=${
|
|
53
|
+
lines.push(`${envVar}=${clean}`);
|
|
50
54
|
}
|
|
51
55
|
|
|
52
56
|
fs.writeFileSync(envPath, lines.join('\n') + '\n', { mode: 0o600 });
|
|
57
|
+
// writeFileSync's mode only applies on CREATE; re-assert 0600 so an existing
|
|
58
|
+
// secrets file whose perms drifted is re-tightened. Best-effort (no-op on Windows).
|
|
59
|
+
try { fs.chmodSync(envPath, 0o600); } catch (_err) { /* perms best-effort */ }
|
|
60
|
+
return clean;
|
|
53
61
|
}
|
|
54
62
|
|
|
55
63
|
/**
|
|
@@ -68,6 +76,7 @@ function deleteEnvLine(envPath, envVar) {
|
|
|
68
76
|
lines.pop();
|
|
69
77
|
}
|
|
70
78
|
fs.writeFileSync(envPath, lines.length > 0 ? lines.join('\n') + '\n' : '', { mode: 0o600 });
|
|
79
|
+
try { fs.chmodSync(envPath, 0o600); } catch (_err) { /* perms best-effort */ }
|
|
71
80
|
}
|
|
72
81
|
} catch (_err) {
|
|
73
82
|
// Best-effort
|
|
@@ -89,8 +98,8 @@ function saveRawEnv(envVar, value) {
|
|
|
89
98
|
return { success: false, error: `Invalid env var name: ${envVar}` };
|
|
90
99
|
}
|
|
91
100
|
const { getEnvPath } = require('./api-key-store'); // lazy: avoids a load-time cycle
|
|
92
|
-
upsertEnvLine(getEnvPath(), envVar, value);
|
|
93
|
-
process.env[envVar] = value
|
|
101
|
+
const clean = upsertEnvLine(getEnvPath(), envVar, value);
|
|
102
|
+
process.env[envVar] = clean; // mirror the sanitized on-disk value
|
|
94
103
|
return { success: true };
|
|
95
104
|
}
|
|
96
105
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @module error-classify
|
|
5
|
+
* Conservative classification of an OpenCode leg-error string into a trigger
|
|
6
|
+
* class (spec 6.2). Fallback substitution fires ONLY on capacity signals
|
|
7
|
+
* (rate-limit|overload). timeout is excluded (resolved Q3: a slow model on a
|
|
8
|
+
* heavy task is not a capacity signal — --retry-failed covers it); auth /
|
|
9
|
+
* validation never substitute. Misclassification cost is bounded either way:
|
|
10
|
+
* one extra cheaper attempt, or status quo.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const RATE_LIMIT = /429|rate ?limit|too many requests|quota|resource exhausted/i;
|
|
14
|
+
const OVERLOAD = /529|503|overload|capacity|server busy|service unavailable/i;
|
|
15
|
+
const AUTH = /401|403|unauthorized|forbidden|invalid api key|authentication/i;
|
|
16
|
+
const TIMEOUT = /timed? ?out|timeout|deadline exceeded/i;
|
|
17
|
+
|
|
18
|
+
/** @param {string} message @returns {'rate-limit'|'overload'|'auth'|'timeout'|'other'} */
|
|
19
|
+
function classifyLegError(message) {
|
|
20
|
+
const m = String(message || '');
|
|
21
|
+
if (RATE_LIMIT.test(m)) { return 'rate-limit'; }
|
|
22
|
+
if (OVERLOAD.test(m)) { return 'overload'; }
|
|
23
|
+
if (AUTH.test(m)) { return 'auth'; }
|
|
24
|
+
if (TIMEOUT.test(m)) { return 'timeout'; }
|
|
25
|
+
return 'other';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Only capacity signals trigger a cheaper-model substitution. */
|
|
29
|
+
function isRetryable(cls) { return cls === 'rate-limit' || cls === 'overload'; }
|
|
30
|
+
|
|
31
|
+
module.exports = { classifyLegError, isRetryable };
|
package/src/utils/model-tiers.js
CHANGED
|
@@ -46,9 +46,20 @@ const SPEND_LEDGER_FILE = 'spend-ledger.jsonl';
|
|
|
46
46
|
* @param {string} opts.model resolved model id (or alias, if that's all the caller has)
|
|
47
47
|
* @param {'headless'|'interactive'|'leg'} opts.mode
|
|
48
48
|
* @param {{tokens:object, cost:{amount:number|null,currency:string,source:string}}|null} opts.usage
|
|
49
|
+
* @param {string} [opts.op] 'leg' | 'start' | 'continue' | 'resume'
|
|
50
|
+
* @param {string} [opts.status] terminal status
|
|
51
|
+
* @param {string} [opts.councilRunId] council run id (additive attribution)
|
|
52
|
+
* @param {string} [opts.councilName] council name (additive attribution)
|
|
53
|
+
* @param {string} [opts.project] project directory (additive attribution)
|
|
54
|
+
* @param {string} [opts.gateway] resolved gateway ('direct'|'openrouter'|'local', additive attribution)
|
|
55
|
+
* @param {number} [opts.attempt] fallback attempt count (omitted if absent)
|
|
56
|
+
* @param {string} [opts.substitutedFor] substituted model (omitted if absent)
|
|
57
|
+
* @param {string} [opts.retryOfWaveId] wave id being retried (omitted if absent)
|
|
49
58
|
* @param {{dir?:string}} [ctx] test seam — dir overrides getConfigDir()
|
|
50
59
|
*/
|
|
51
|
-
function appendSpend({ taskId, waveId, model, mode, usage
|
|
60
|
+
function appendSpend({ taskId, waveId, model, mode, usage,
|
|
61
|
+
op, status, councilRunId, councilName, project, gateway,
|
|
62
|
+
attempt, substitutedFor, retryOfWaveId }, ctx = {}) {
|
|
52
63
|
if (!usage) { return; }
|
|
53
64
|
try {
|
|
54
65
|
const dir = ctx.dir || getConfigDir();
|
|
@@ -62,7 +73,19 @@ function appendSpend({ taskId, waveId, model, mode, usage }, ctx = {}) {
|
|
|
62
73
|
mode: mode || null,
|
|
63
74
|
tokens: usage.tokens || null,
|
|
64
75
|
cost: usage.cost || null,
|
|
76
|
+
// v4.3 additive attribution (spec 7.1). Nullable dimensions default to
|
|
77
|
+
// null (so a row is always groupable); linkage fields are OMITTED unless
|
|
78
|
+
// present (they only exist on fallback/retry rows).
|
|
79
|
+
op: op || null,
|
|
80
|
+
status: status || null,
|
|
81
|
+
councilRunId: councilRunId || null,
|
|
82
|
+
councilName: councilName || null,
|
|
83
|
+
project: project || null,
|
|
84
|
+
gateway: gateway || null,
|
|
65
85
|
};
|
|
86
|
+
if (attempt !== undefined) { row.attempt = attempt; }
|
|
87
|
+
if (substitutedFor !== undefined) { row.substitutedFor = substitutedFor; }
|
|
88
|
+
if (retryOfWaveId !== undefined) { row.retryOfWaveId = retryOfWaveId; }
|
|
66
89
|
fs.appendFileSync(path.join(dir, SPEND_LEDGER_FILE), JSON.stringify(row) + '\n');
|
|
67
90
|
} catch (e) {
|
|
68
91
|
logger.debug('spend-ledger append failed (best-effort, run unaffected)', { taskId, error: e.message });
|