synabun 2.0.5 → 2.0.7
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 +40 -0
- package/README.md +2 -1
- package/THIRD-PARTY-LICENSES.md +13 -0
- package/docs/whatsapp.md +151 -16
- package/hooks/claude-code/shared.mjs +6 -3
- package/lib/child-path.js +303 -0
- package/lib/claude-hooks.js +2 -1
- package/lib/data-home-migration.js +2 -0
- package/lib/desktop-app.js +265 -0
- package/lib/external-tools.js +7 -3
- package/lib/log-redact.js +102 -0
- package/lib/packaged-runtime.js +138 -11
- package/mcp-server/dist/http.d.ts +3 -1
- package/mcp-server/dist/http.d.ts.map +1 -1
- package/mcp-server/dist/http.js +15 -4
- package/mcp-server/dist/http.js.map +1 -1
- package/mcp-server/dist/index.d.ts.map +1 -1
- package/mcp-server/dist/index.js +9 -6
- package/mcp-server/dist/index.js.map +1 -1
- package/mcp-server/dist/services/budget-judgments.d.ts +108 -0
- package/mcp-server/dist/services/budget-judgments.d.ts.map +1 -0
- package/mcp-server/dist/services/budget-judgments.js +153 -0
- package/mcp-server/dist/services/budget-judgments.js.map +1 -0
- package/mcp-server/dist/services/codex-config-heal.d.ts +5 -1
- package/mcp-server/dist/services/codex-config-heal.d.ts.map +1 -1
- package/mcp-server/dist/services/codex-config-heal.js +41 -4
- package/mcp-server/dist/services/codex-config-heal.js.map +1 -1
- package/mcp-server/dist/services/identity.d.ts +11 -0
- package/mcp-server/dist/services/identity.d.ts.map +1 -1
- package/mcp-server/dist/services/identity.js +24 -0
- package/mcp-server/dist/services/identity.js.map +1 -1
- package/mcp-server/dist/services/neural-interface.d.ts +13 -0
- package/mcp-server/dist/services/neural-interface.d.ts.map +1 -1
- package/mcp-server/dist/services/neural-interface.js +34 -2
- package/mcp-server/dist/services/neural-interface.js.map +1 -1
- package/mcp-server/dist/services/typesafe-config.d.ts +1 -1
- package/mcp-server/dist/services/typesafe-config.d.ts.map +1 -1
- package/mcp-server/dist/services/typesafe-config.js +4 -1
- package/mcp-server/dist/services/typesafe-config.js.map +1 -1
- package/mcp-server/dist/tools/agents.d.ts +10 -7
- package/mcp-server/dist/tools/agents.d.ts.map +1 -1
- package/mcp-server/dist/tools/agents.js +34 -13
- package/mcp-server/dist/tools/agents.js.map +1 -1
- package/mcp-server/dist/tools/whatsapp-send-file.d.ts +17 -0
- package/mcp-server/dist/tools/whatsapp-send-file.d.ts.map +1 -0
- package/mcp-server/dist/tools/whatsapp-send-file.js +44 -0
- package/mcp-server/dist/tools/whatsapp-send-file.js.map +1 -0
- package/mcp-server/dist/tui.js +2 -1
- package/mcp-server/dist/tui.js.map +1 -1
- package/mcp-server/dist/version.d.ts +5 -0
- package/mcp-server/dist/version.d.ts.map +1 -0
- package/mcp-server/dist/version.js +21 -0
- package/mcp-server/dist/version.js.map +1 -0
- package/mcp-server/package-lock.json +2 -2
- package/mcp-server/package.json +1 -1
- package/mcp-server/run.mjs +15 -9
- package/mcp-server/src/http.ts +18 -3
- package/mcp-server/src/index.ts +9 -6
- package/mcp-server/src/services/budget-judgments.ts +183 -0
- package/mcp-server/src/services/codex-config-heal.ts +35 -4
- package/mcp-server/src/services/identity.ts +27 -0
- package/mcp-server/src/services/neural-interface.ts +34 -2
- package/mcp-server/src/services/typesafe-config.ts +4 -1
- package/mcp-server/src/tools/agents.ts +33 -14
- package/mcp-server/src/tools/whatsapp-send-file.ts +51 -0
- package/mcp-server/src/tui.ts +2 -1
- package/mcp-server/src/version.ts +22 -0
- package/neural-interface/i18n/en.json +627 -57
- package/neural-interface/i18n/pt-BR.json +626 -56
- package/neural-interface/lib/account-usage.js +163 -0
- package/neural-interface/lib/assistant-api.js +255 -52
- package/neural-interface/lib/assistant-brains/claude.js +15 -2
- package/neural-interface/lib/assistant-brains/codex.js +3 -0
- package/neural-interface/lib/assistant-brains/opencode.js +17 -4
- package/neural-interface/lib/assistant-budget-planner.js +390 -0
- package/neural-interface/lib/assistant-budget-split.js +408 -0
- package/neural-interface/lib/assistant-budget.js +94 -95
- package/neural-interface/lib/assistant-config.js +62 -14
- package/neural-interface/lib/assistant-dispatch.js +432 -125
- package/neural-interface/lib/assistant-jev.js +26 -3
- package/neural-interface/lib/assistant-persona.js +68 -11
- package/neural-interface/lib/assistant-plan-permissions.js +8 -1
- package/neural-interface/lib/assistant-route-accounts.js +14 -0
- package/neural-interface/lib/assistant-route-gate.js +19 -3
- package/neural-interface/lib/assistant-router.js +373 -42
- package/neural-interface/lib/assistant-runtime.js +307 -47
- package/neural-interface/lib/assistant-user-writer.js +4 -2
- package/neural-interface/lib/augmented-path.js +317 -53
- package/neural-interface/lib/claude-accounts.js +342 -31
- package/neural-interface/lib/claude-agent-bridge.js +90 -3
- package/neural-interface/lib/claude-executable.js +6 -1
- package/neural-interface/lib/claude-model-catalog.js +3 -3
- package/neural-interface/lib/claude-panel-auth.js +221 -0
- package/neural-interface/lib/claude-profile-api-key.js +62 -0
- package/neural-interface/lib/cli-install-api.js +137 -0
- package/neural-interface/lib/cli-install.js +797 -0
- package/neural-interface/lib/cli-path.js +130 -0
- package/neural-interface/lib/cli-resolver.js +744 -0
- package/neural-interface/lib/cli-update-planner.js +143 -7
- package/neural-interface/lib/codex-account-auth.js +178 -0
- package/neural-interface/lib/codex-accounts.js +489 -0
- package/neural-interface/lib/codex-capabilities.js +2 -1
- package/neural-interface/lib/codex-compat-rpc.js +8 -2
- package/neural-interface/lib/codex-mcp-approvals.js +5 -1
- package/neural-interface/lib/codex-panel-auth.js +62 -0
- package/neural-interface/lib/codex-session-settings.js +1 -1
- package/neural-interface/lib/codex-settings-schema.js +120 -0
- package/neural-interface/lib/desktop/config.js +3 -0
- package/neural-interface/lib/git-bash/bzip2.js +339 -0
- package/neural-interface/lib/git-bash/claude-settings.js +73 -0
- package/neural-interface/lib/git-bash/extract-worker.js +34 -0
- package/neural-interface/lib/git-bash/install.js +529 -0
- package/neural-interface/lib/git-bash/licenses/COPYING +360 -0
- package/neural-interface/lib/git-bash/licenses/LGPL-2.1 +511 -0
- package/neural-interface/lib/git-bash/manager.js +268 -0
- package/neural-interface/lib/git-bash/pins.js +206 -0
- package/neural-interface/lib/git-bash/pins.json +34 -0
- package/neural-interface/lib/git-bash/resolver.js +194 -0
- package/neural-interface/lib/git-read.js +34 -2
- package/neural-interface/lib/install-details.js +145 -0
- package/neural-interface/lib/mcp-installer.js +14 -2
- package/neural-interface/lib/native-loop-providers.js +16 -3
- package/neural-interface/lib/native-loop-runtime.js +9 -4
- package/neural-interface/lib/os-path.js +319 -0
- package/neural-interface/lib/provider-panel-api-key-auth.js +92 -0
- package/neural-interface/lib/provider-panel-terminal-auth.js +95 -0
- package/neural-interface/lib/pty-host/core.js +2 -1
- package/neural-interface/lib/pty-host/manager.js +27 -2
- package/neural-interface/lib/remote-policy.js +20 -11
- package/neural-interface/lib/session-title-generator.js +4 -3
- package/neural-interface/lib/system-about.js +340 -0
- package/neural-interface/lib/system-activity.js +66 -0
- package/neural-interface/lib/terminal-env.js +11 -3
- package/neural-interface/lib/terminal-path.js +55 -0
- package/neural-interface/lib/virtual-socket.js +12 -1
- package/neural-interface/lib/whatsapp/baileys-adapter.js +165 -45
- package/neural-interface/lib/whatsapp/bridge.js +78 -34
- package/neural-interface/lib/whatsapp/commands.js +13 -14
- package/neural-interface/lib/whatsapp/config.js +134 -20
- package/neural-interface/lib/whatsapp/fake-baileys.js +99 -14
- package/neural-interface/lib/whatsapp/host-core.js +168 -35
- package/neural-interface/lib/whatsapp/inbound.js +5 -3
- package/neural-interface/lib/whatsapp/local-file.js +524 -0
- package/neural-interface/lib/whatsapp/manager.js +92 -32
- package/neural-interface/lib/whatsapp/paths.js +11 -1
- package/neural-interface/lib/whatsapp/protocol.js +65 -5
- package/neural-interface/lib/whatsapp/redact.js +63 -0
- package/neural-interface/lib/whatsapp/service.js +343 -91
- package/neural-interface/lib/whatsapp/transcription.js +463 -0
- package/neural-interface/package-lock.json +2 -2
- package/neural-interface/package.json +1 -1
- package/neural-interface/public/index.html +2 -0
- package/neural-interface/public/index2d.html +2 -0
- package/neural-interface/public/offline.html +16 -1
- package/neural-interface/public/onboarding.html +96 -2
- package/neural-interface/public/shared/account-i18n.js +7 -0
- package/neural-interface/public/shared/account-menu.js +539 -0
- package/neural-interface/public/shared/account-usage.js +19 -0
- package/neural-interface/public/shared/account-view.js +335 -0
- package/neural-interface/public/shared/agent-runtime-options.js +28 -8
- package/neural-interface/public/shared/assistant/asst-accounts.js +182 -184
- package/neural-interface/public/shared/assistant/asst-api.js +32 -2
- package/neural-interface/public/shared/assistant/asst-brain-picker.js +124 -25
- package/neural-interface/public/shared/assistant/asst-budget.js +259 -303
- package/neural-interface/public/shared/assistant/asst-mascot.js +126 -7
- package/neural-interface/public/shared/assistant/asst-menu.js +15 -3
- package/neural-interface/public/shared/assistant/asst-panel.js +48 -14
- package/neural-interface/public/shared/assistant/asst-render.js +38 -7
- package/neural-interface/public/shared/assistant/asst-route.js +72 -14
- package/neural-interface/public/shared/assistant/asst-routes-editor.js +45 -6
- package/neural-interface/public/shared/assistant/asst-slash.js +1 -1
- package/neural-interface/public/shared/assistant/asst-state.js +1 -1
- package/neural-interface/public/shared/assistant/asst-styles.js +99 -17
- package/neural-interface/public/shared/cdx/cdx-commands.js +4 -1
- package/neural-interface/public/shared/cdx/cdx-features.js +22 -14
- package/neural-interface/public/shared/cdx/cdx-icons.js +2 -0
- package/neural-interface/public/shared/cdx/cdx-panel.js +48 -107
- package/neural-interface/public/shared/cdx/cdx-settings.js +231 -0
- package/neural-interface/public/shared/cdx/cdx-styles.js +128 -107
- package/neural-interface/public/shared/cdx/cdx-tabs.js +261 -664
- package/neural-interface/public/shared/cdx/cdx-ws.js +2 -0
- package/neural-interface/public/shared/claude-account-view.js +3 -0
- package/neural-interface/public/shared/cli-install-view.js +392 -0
- package/neural-interface/public/shared/cli-status.js +282 -22
- package/neural-interface/public/shared/cp/cp-events.js +2 -1
- package/neural-interface/public/shared/cp/cp-styles.js +46 -0
- package/neural-interface/public/shared/desktop-bridge.js +39 -0
- package/neural-interface/public/shared/desktop-titlebar-page.js +59 -0
- package/neural-interface/public/shared/desktop-titlebar-view.js +122 -0
- package/neural-interface/public/shared/desktop-titlebar.js +153 -0
- package/neural-interface/public/shared/desktop-update-view.js +462 -0
- package/neural-interface/public/shared/editor/editor-panel.js +1 -1
- package/neural-interface/public/shared/explorer/fx-main.js +1 -1
- package/neural-interface/public/shared/git-bash-view.js +145 -0
- package/neural-interface/public/shared/html-shell.js +1 -0
- package/neural-interface/public/shared/i18n.js +19 -1
- package/neural-interface/public/shared/install-details-view.js +86 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-panel.js +32 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-send.js +17 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-styles.js +118 -0
- package/neural-interface/public/shared/pwa-install-page.js +3 -1
- package/neural-interface/public/shared/settings/settings-about-pane.js +271 -0
- package/neural-interface/public/shared/settings/settings-about.js +254 -0
- package/neural-interface/public/shared/settings/settings-ia.js +15 -0
- package/neural-interface/public/shared/showcase-privacy.js +489 -0
- package/neural-interface/public/shared/storage.js +30 -3
- package/neural-interface/public/shared/styles.css +278 -31
- package/neural-interface/public/shared/synabun-mascot.js +48 -2
- package/neural-interface/public/shared/ui-claude-panel.js +212 -213
- package/neural-interface/public/shared/ui-cli-banner.js +355 -0
- package/neural-interface/public/shared/ui-git-bash.js +124 -0
- package/neural-interface/public/shared/ui-judgments.js +10 -1
- package/neural-interface/public/shared/ui-loading.js +30 -6
- package/neural-interface/public/shared/ui-menubar.js +13 -3
- package/neural-interface/public/shared/ui-navbar.js +3 -1
- package/neural-interface/public/shared/ui-provider-auth.js +104 -0
- package/neural-interface/public/shared/ui-provider-terminal-auth.js +223 -0
- package/neural-interface/public/shared/ui-sessions.js +9 -3
- package/neural-interface/public/shared/ui-settings.js +55 -3
- package/neural-interface/public/shared/ui-sidepanel-sessions.js +3 -1
- package/neural-interface/public/shared/ui-sync.js +6 -0
- package/neural-interface/public/shared/ui-terminal.js +17 -0
- package/neural-interface/public/shared/ui-tool-updates.js +5 -0
- package/neural-interface/public/shared/ui-update.js +594 -5
- package/neural-interface/public/shared/ui-whatsapp.js +21 -6
- package/neural-interface/public/shared/whatsapp/wa-view.js +51 -8
- package/neural-interface/public/shared/worker-end.js +2 -0
- package/neural-interface/public/sw.js +1 -1
- package/neural-interface/public/variant/2d/main.js +10 -1
- package/neural-interface/public/variant/3d/main.js +10 -1
- package/neural-interface/server.js +1219 -608
- package/package.json +4 -1
- package/setup.js +9 -8
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════
|
|
2
|
+
// SynaBun — Assistant budget planner (per-session split, checkpoints, Jev)
|
|
3
|
+
// ═══════════════════════════════════════════
|
|
4
|
+
//
|
|
5
|
+
// The state behind the budget split (the math is assistant-budget-split.js):
|
|
6
|
+
//
|
|
7
|
+
// - The split of each session, cached on it: made at the session's first
|
|
8
|
+
// actionable route or dispatch (ensureSplit), made again only at a
|
|
9
|
+
// checkpoint (resplit): a route, dispatch or brain turn its provider's
|
|
10
|
+
// share cannot cover (ensureCovered), a change of the budget
|
|
11
|
+
// (onBudgetChanged), a run ending (onRunEnded). Never per tick or per
|
|
12
|
+
// metered step: coverage() and covers() only read.
|
|
13
|
+
// - Jev (surface budget-split) weighs the providers; when it is off,
|
|
14
|
+
// unreachable or answers nothing usable, the deterministic weights are
|
|
15
|
+
// used. The split records which (`source`: jev | fallback) and when.
|
|
16
|
+
// - Re-splits of one session run one at a time; calls that arrive while one
|
|
17
|
+
// waits to start are merged into it.
|
|
18
|
+
//
|
|
19
|
+
// Under No limit nothing is split and every check passes.
|
|
20
|
+
//
|
|
21
|
+
// Every dependency is injected (server.js wires the dispatcher, the runtime,
|
|
22
|
+
// the catalog, the accounts, the config store and Jev); nothing here throws
|
|
23
|
+
// at its callers.
|
|
24
|
+
|
|
25
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
26
|
+
import { dirname } from 'node:path';
|
|
27
|
+
import { BUDGET_DEFAULT_USD, BUDGET_WARN_RATIO, roundUsd } from './assistant-budget.js';
|
|
28
|
+
import {
|
|
29
|
+
BUDGET_PROVIDERS, MIN_SPEND_USD, billingOf, bucketEligibility, budgetView, coverageOf, coversWork, divideLeft,
|
|
30
|
+
dryProviders, fallbackWeights, jevWeights, listPriceOf, priceLabel, providerSpendOf, rowOf, totalSpentOf,
|
|
31
|
+
} from './assistant-budget-split.js';
|
|
32
|
+
|
|
33
|
+
export const SPLIT_REASONS = Object.freeze(['first_route', 'share_used_up', 'brain_turn', 'budget_changed', 'run_ended']);
|
|
34
|
+
const MAX_SESSIONS = 200;
|
|
35
|
+
const MAX_TASKS = 20;
|
|
36
|
+
// A provider found short again this soon after a re-split for it is not re-split again: the split stands
|
|
37
|
+
// (its runs stop, its tasks move). The brain's own turns re-split again as soon as it spent since.
|
|
38
|
+
const NEED_RETRY_MS = 5_000;
|
|
39
|
+
const RUN_END_DEBOUNCE_MS = 1500;
|
|
40
|
+
|
|
41
|
+
const arr = (value) => (Array.isArray(value) ? value : []);
|
|
42
|
+
const clip = (value, max = 200) => { const text = String(value ?? '').replace(/\s+/g, ' ').trim(); return text.length > max ? `${text.slice(0, max - 1)}…` : text; };
|
|
43
|
+
const iso = (ms) => new Date(ms).toISOString();
|
|
44
|
+
|
|
45
|
+
export function createBudgetPlanner({
|
|
46
|
+
setting = () => ({ unlimited: false, amountUsd: BUDGET_DEFAULT_USD, version: 0 }),
|
|
47
|
+
// (sessionId) => { providers: { [p]: { brainUsd, runsUsd, liveRuns, runningRuns } }, unpricedRuns, liveRuns: [{ provider, model, taskClass }] }
|
|
48
|
+
spendOf = () => ({ providers: {}, unpricedRuns: 0, liveRuns: [] }),
|
|
49
|
+
// (sessionId) => { provider, model, accountId, project } | null
|
|
50
|
+
brainOf = () => null,
|
|
51
|
+
// (sessionId) => is the session still open (a split of an ended one is not redone)
|
|
52
|
+
isOpen = () => true,
|
|
53
|
+
catalog = () => null,
|
|
54
|
+
refreshCatalog = null,
|
|
55
|
+
accounts = () => null,
|
|
56
|
+
routing = () => ({ preferences: {} }),
|
|
57
|
+
routeMode = () => null,
|
|
58
|
+
workers = () => ({}),
|
|
59
|
+
pricing = null,
|
|
60
|
+
// (sessionId) => { [provider]: usd } of this session from the usage ledger, or null
|
|
61
|
+
recentSpend = () => null,
|
|
62
|
+
// assistant-jev.js: { available(), budgetSplit(input, ctx), annotate(logId, outcome) } or null
|
|
63
|
+
judge = null,
|
|
64
|
+
emit = () => {},
|
|
65
|
+
statePath = null,
|
|
66
|
+
now = Date.now,
|
|
67
|
+
log = () => {},
|
|
68
|
+
} = {}) {
|
|
69
|
+
const sessions = new Map(); // id → { split, tasks: [], updatedAt }
|
|
70
|
+
const chains = new Map(); // id → { promise, queued, waiting }
|
|
71
|
+
const needTries = new Map(); // `${id}:${provider}` → { at, version }
|
|
72
|
+
const runEndTimers = new Map();
|
|
73
|
+
let persistTimer = null;
|
|
74
|
+
|
|
75
|
+
// ── persistence ────────────────────────────────────────────────────────
|
|
76
|
+
function load() {
|
|
77
|
+
if (!statePath || !existsSync(statePath)) return;
|
|
78
|
+
try {
|
|
79
|
+
const raw = JSON.parse(readFileSync(statePath, 'utf8'));
|
|
80
|
+
for (const [id, row] of Object.entries(raw?.sessions || {})) {
|
|
81
|
+
if (!row || typeof row !== 'object') continue;
|
|
82
|
+
sessions.set(id, { split: row.split && typeof row.split === 'object' ? row.split : null, tasks: arr(row.tasks).slice(-MAX_TASKS), updatedAt: row.updatedAt || null });
|
|
83
|
+
}
|
|
84
|
+
} catch (error) { log('budget:state-unreadable', error?.message || String(error)); }
|
|
85
|
+
}
|
|
86
|
+
function writeState() {
|
|
87
|
+
if (!statePath) return;
|
|
88
|
+
try {
|
|
89
|
+
const rows = [...sessions.entries()].sort((a, b) => String(b[1].updatedAt || '').localeCompare(String(a[1].updatedAt || ''))).slice(0, MAX_SESSIONS);
|
|
90
|
+
for (const [id] of [...sessions.entries()].filter(([id]) => !rows.some(([kept]) => kept === id))) sessions.delete(id);
|
|
91
|
+
mkdirSync(dirname(statePath), { recursive: true });
|
|
92
|
+
const tmp = `${statePath}.${process.pid}.tmp`;
|
|
93
|
+
writeFileSync(tmp, JSON.stringify({ version: 1, sessions: Object.fromEntries(rows) }, null, 2) + '\n', 'utf8');
|
|
94
|
+
renameSync(tmp, statePath);
|
|
95
|
+
} catch (error) { log('budget:state-error', error?.message || String(error)); }
|
|
96
|
+
}
|
|
97
|
+
function persist() {
|
|
98
|
+
if (!statePath || persistTimer) return;
|
|
99
|
+
persistTimer = setTimeout(() => { persistTimer = null; writeState(); }, 300);
|
|
100
|
+
persistTimer.unref?.();
|
|
101
|
+
}
|
|
102
|
+
function rowOfSession(id, { create = false } = {}) {
|
|
103
|
+
let row = sessions.get(id) || null;
|
|
104
|
+
if (!row && create) { row = { split: null, tasks: [], updatedAt: iso(now()) }; sessions.set(id, row); }
|
|
105
|
+
return row;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── reads ──────────────────────────────────────────────────────────────
|
|
109
|
+
function readSetting() {
|
|
110
|
+
try {
|
|
111
|
+
const value = setting() || {};
|
|
112
|
+
return { unlimited: value.unlimited === true, amountUsd: Number(value.amountUsd) > 0 ? Number(value.amountUsd) : BUDGET_DEFAULT_USD, version: Number(value.version) || 0 };
|
|
113
|
+
} catch { return { unlimited: false, amountUsd: BUDGET_DEFAULT_USD, version: 0 }; }
|
|
114
|
+
}
|
|
115
|
+
function limited() { return !readSetting().unlimited; }
|
|
116
|
+
function safe(fn, fallback = null) { try { const value = fn(); return value === undefined ? fallback : value; } catch { return fallback; } }
|
|
117
|
+
function spend(id) { return safe(() => spendOf(id), null) || { providers: {}, unpricedRuns: 0, liveRuns: [] }; }
|
|
118
|
+
function brain(id) { return safe(() => brainOf(id), null); }
|
|
119
|
+
function splitOf(id) { return sessions.get(String(id || ''))?.split || null; }
|
|
120
|
+
function coverage(id) {
|
|
121
|
+
const value = readSetting();
|
|
122
|
+
if (value.unlimited) return null;
|
|
123
|
+
const sessionId = String(id || '');
|
|
124
|
+
return coverageOf({ amountUsd: value.amountUsd, split: splitOf(sessionId), spend: spend(sessionId), brainProvider: brain(sessionId)?.provider || null });
|
|
125
|
+
}
|
|
126
|
+
/** Can `provider` pay for more work in the session now (No limit: always). */
|
|
127
|
+
function covers(id, { provider, forBrain = false, free = false } = {}) {
|
|
128
|
+
if (!id) return true;
|
|
129
|
+
const cov = coverage(id);
|
|
130
|
+
return cov ? coversWork(cov, { provider, forBrain, free }) : true;
|
|
131
|
+
}
|
|
132
|
+
function view(id) {
|
|
133
|
+
const sessionId = String(id || '');
|
|
134
|
+
const value = readSetting();
|
|
135
|
+
const used = spend(sessionId);
|
|
136
|
+
const brainProvider = brain(sessionId)?.provider || null;
|
|
137
|
+
return budgetView({
|
|
138
|
+
sessionId, setting: value, split: value.unlimited ? null : splitOf(sessionId), spend: used, unpricedRuns: used.unpricedRuns || 0, brainProvider,
|
|
139
|
+
coverage: value.unlimited ? null : coverageOf({ amountUsd: value.amountUsd, split: splitOf(sessionId), spend: used, brainProvider }),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── Jev's state ────────────────────────────────────────────────────────
|
|
144
|
+
function accountFor(provider, rows, brainInfo) {
|
|
145
|
+
const list = arr(rows?.[provider]);
|
|
146
|
+
if (!list.length) return null;
|
|
147
|
+
const wanted = provider === brainInfo?.provider ? (brainInfo.accountId || 'default')
|
|
148
|
+
: (safe(() => workers()?.[provider === 'codex' ? 'codexAccount' : 'claudeAccount']) || 'default');
|
|
149
|
+
return list.find((row) => row?.id === wanted) || list.find((row) => row?.id === 'default') || list[0];
|
|
150
|
+
}
|
|
151
|
+
function modelInfo(cat, provider, model, account = null) {
|
|
152
|
+
const row = rowOf(cat, provider, model);
|
|
153
|
+
const price = listPriceOf(row, { pricing, provider, model });
|
|
154
|
+
return { id: model || row?.id || null, price: priceLabel(row, price), billing: billingOf(provider, price, account), tier: row?.tier || null };
|
|
155
|
+
}
|
|
156
|
+
function jevInput(id, { value, used, cov, eligible, brainInfo, cat, needs, task, reason, tasks }) {
|
|
157
|
+
const rows = safe(() => accounts(), null);
|
|
158
|
+
const prefs = safe(() => routing()?.preferences, {}) || {};
|
|
159
|
+
const savedRoutes = Object.entries(prefs).filter(([, pref]) => pref?.provider).map(([taskClass, pref]) => {
|
|
160
|
+
const info = modelInfo(cat, pref.provider, pref.model || null, accountFor(pref.provider, rows, brainInfo));
|
|
161
|
+
return { taskClass, provider: pref.provider, model: pref.model || null, effort: pref.effort || null, price: info.price, billing: info.billing };
|
|
162
|
+
});
|
|
163
|
+
const recent = safe(() => recentSpend(id), null) || {};
|
|
164
|
+
const providers = {};
|
|
165
|
+
for (const provider of BUDGET_PROVIDERS) {
|
|
166
|
+
const own = providerSpendOf(used, provider);
|
|
167
|
+
const account = accountFor(provider, rows, brainInfo);
|
|
168
|
+
const routed = savedRoutes.filter((r) => r.provider === provider);
|
|
169
|
+
const models = [];
|
|
170
|
+
for (const r of routed) if (!models.some((m) => m.id === r.model)) models.push({ id: r.model, price: r.price, billing: r.billing, tier: rowOf(cat, provider, r.model)?.tier || null });
|
|
171
|
+
providers[provider] = {
|
|
172
|
+
eligible: eligible[provider]?.eligible !== false, why: eligible[provider]?.reason || null,
|
|
173
|
+
spentUsd: own.spentUsd, brainUsd: own.brainUsd, runsUsd: own.runsUsd,
|
|
174
|
+
shareUsd: cov?.hasSplit ? cov.providers[provider].shareUsd : null, leftUsd: cov?.hasSplit ? cov.providers[provider].leftUsd : null,
|
|
175
|
+
liveRuns: own.liveRuns, runningRuns: own.runningRuns, recentUsd: Number.isFinite(Number(recent[provider])) ? Number(recent[provider]) : null,
|
|
176
|
+
account: account ? { mode: account.mode === 'apiKey' ? 'api key' : account.mode || null, usageLeftPercent: account.usage?.remainingPercent ?? null, limited: account.usage?.limited ?? null } : null,
|
|
177
|
+
models,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
const brainModel = brainInfo?.provider ? modelInfo(cat, brainInfo.provider, brainInfo.model || null, accountFor(brainInfo.provider, rows, brainInfo)) : null;
|
|
181
|
+
const spentUsd = totalSpentOf(used);
|
|
182
|
+
return {
|
|
183
|
+
ask: BUDGET_PROVIDERS.filter((p) => eligible[p]?.eligible !== false),
|
|
184
|
+
budget: { amountUsd: value.amountUsd, spentUsd, leftUsd: Math.max(0, value.amountUsd - spentUsd), warnAtUsd: roundUsd(value.amountUsd * BUDGET_WARN_RATIO) },
|
|
185
|
+
checkpoint: { reason, needs },
|
|
186
|
+
brain: brainInfo?.provider ? { provider: brainInfo.provider, model: brainInfo.model || null, price: brainModel?.price || null, billing: brainModel?.billing || null } : null,
|
|
187
|
+
routeMode: safe(() => routeMode(id), null),
|
|
188
|
+
savedRoutes, providers,
|
|
189
|
+
tasks: arr(tasks).map((t) => ({ taskClass: t.taskClass || null, summary: t.summary || null, provider: t.provider || null, model: t.model || null })),
|
|
190
|
+
task: task ? { taskClass: task.taskClass || null, summary: task.summary || null, provider: task.provider || null, model: task.model || null } : null,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── making a split ─────────────────────────────────────────────────────
|
|
195
|
+
async function catalogNow() {
|
|
196
|
+
const cached = safe(() => catalog(), null);
|
|
197
|
+
if (cached || typeof refreshCatalog !== 'function') return cached;
|
|
198
|
+
try { return (await refreshCatalog()) || null; } catch { return null; }
|
|
199
|
+
}
|
|
200
|
+
async function compute(id, want = {}) {
|
|
201
|
+
const value = readSetting();
|
|
202
|
+
if (value.unlimited) return null;
|
|
203
|
+
const reason = SPLIT_REASONS.includes(want.reason) ? want.reason : 'first_route';
|
|
204
|
+
const needs = [...new Set(arr(want.needs).filter((p) => BUDGET_PROVIDERS.includes(p)))];
|
|
205
|
+
const row = rowOfSession(id, { create: true });
|
|
206
|
+
if (want.task) noteTaskRow(row, want.task, { current: true });
|
|
207
|
+
const cat = await catalogNow();
|
|
208
|
+
const used = spend(id);
|
|
209
|
+
const brainInfo = brain(id);
|
|
210
|
+
const brainProvider = BUDGET_PROVIDERS.includes(brainInfo?.provider) ? brainInfo.provider : null;
|
|
211
|
+
const eligible = bucketEligibility({ catalog: cat, accounts: safe(() => accounts(), null), brainProvider });
|
|
212
|
+
const cov = coverageOf({ amountUsd: value.amountUsd, split: row.split, spend: used, brainProvider });
|
|
213
|
+
const leftUsd = Math.max(0, value.amountUsd - totalSpentOf(used));
|
|
214
|
+
const tasks = row.tasks.filter((t) => !t.current);
|
|
215
|
+
const task = want.task || row.tasks.find((t) => t.current) || null;
|
|
216
|
+
let weights = null;
|
|
217
|
+
let jev = null;
|
|
218
|
+
let fallbackWhy = null;
|
|
219
|
+
if (leftUsd < MIN_SPEND_USD) fallbackWhy = 'nothing_left';
|
|
220
|
+
else if (!judge?.budgetSplit) fallbackWhy = 'jev_off';
|
|
221
|
+
else if (typeof judge.available === 'function' && !safe(() => judge.available(), false)) fallbackWhy = 'jev_off';
|
|
222
|
+
else {
|
|
223
|
+
let judged = null;
|
|
224
|
+
try {
|
|
225
|
+
judged = await judge.budgetSplit(jevInput(id, { value, used, cov, eligible, brainInfo, cat, needs, task, reason, tasks }), { sessionId: id, project: brainInfo?.project || null });
|
|
226
|
+
} catch { judged = null; }
|
|
227
|
+
weights = judged ? jevWeights(judged, eligible) : null;
|
|
228
|
+
if (weights) jev = { logId: judged.logId ?? null, scores: judged.scores };
|
|
229
|
+
else fallbackWhy = judged ? 'jev_incomplete' : 'jev_unavailable';
|
|
230
|
+
// A setting changed while Jev answered: this split is for an old budget.
|
|
231
|
+
if (readSetting().version !== value.version) return compute(id, want);
|
|
232
|
+
}
|
|
233
|
+
if (!weights) {
|
|
234
|
+
weights = fallbackWeights({
|
|
235
|
+
eligible, routing: safe(() => routing(), {}) || {}, brain: brainInfo, catalog: cat, pricing,
|
|
236
|
+
tasks, task, liveRuns: arr(used.liveRuns), needs,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
// What was spent is read again: Jev took up to its timeout, and money may have moved meanwhile.
|
|
240
|
+
const fresh = spend(id);
|
|
241
|
+
const freshLeft = Math.max(0, value.amountUsd - totalSpentOf(fresh));
|
|
242
|
+
const divided = divideLeft({ leftUsd: freshLeft, weights, eligible, brainProvider });
|
|
243
|
+
const spentAtSplit = Object.fromEntries(BUDGET_PROVIDERS.map((p) => [p, providerSpendOf(fresh, p).spentUsd]));
|
|
244
|
+
const shares = Object.fromEntries(BUDGET_PROVIDERS.map((p) => [p, Number((spentAtSplit[p] + divided.allocCents[p] / 100).toFixed(8))]));
|
|
245
|
+
const split = {
|
|
246
|
+
source: jev ? 'jev' : 'fallback', fallbackWhy: jev ? null : fallbackWhy, reason, at: iso(now()),
|
|
247
|
+
settingVersion: value.version, amountUsd: value.amountUsd,
|
|
248
|
+
leftUsd: divided.leftCents / 100,
|
|
249
|
+
shares, alloc: Object.fromEntries(BUDGET_PROVIDERS.map((p) => [p, divided.allocCents[p] / 100])), spentAtSplit,
|
|
250
|
+
brainProvider, brainFloorUsd: divided.brainFloorCents / 100,
|
|
251
|
+
brainSpentAtSplitUsd: brainProvider ? providerSpendOf(fresh, brainProvider).brainUsd : 0,
|
|
252
|
+
eligible, weights, needs, jev,
|
|
253
|
+
};
|
|
254
|
+
row.split = split;
|
|
255
|
+
row.updatedAt = split.at;
|
|
256
|
+
for (const t of row.tasks) delete t.current;
|
|
257
|
+
persist();
|
|
258
|
+
if (jev?.logId && typeof judge?.annotate === 'function') {
|
|
259
|
+
try { judge.annotate(jev.logId, { applied: true, reason, needs, alloc: split.alloc, brainFloorUsd: split.brainFloorUsd, leftUsd: split.leftUsd }); } catch { /* diagnostics */ }
|
|
260
|
+
}
|
|
261
|
+
log('budget:split', `${id}: ${split.source}${fallbackWhy && !jev ? ` (${fallbackWhy})` : ''} · ${reason} · left $${split.leftUsd.toFixed(2)} · ${BUDGET_PROVIDERS.map((p) => `${p} $${split.alloc[p].toFixed(2)}`).join(', ')}`);
|
|
262
|
+
try { emit(id, view(id), { reason }); } catch { /* the live packet is best effort */ }
|
|
263
|
+
return split;
|
|
264
|
+
}
|
|
265
|
+
function mergeWant(base, next = {}) {
|
|
266
|
+
const order = (reason) => ['share_used_up', 'brain_turn', 'first_route', 'budget_changed', 'run_ended'].indexOf(reason);
|
|
267
|
+
if (!base) return { reason: next.reason || 'first_route', needs: [...new Set(arr(next.needs))], task: next.task || null };
|
|
268
|
+
const reason = order(next.reason) !== -1 && (order(base.reason) === -1 || order(next.reason) < order(base.reason)) ? next.reason : base.reason;
|
|
269
|
+
return { reason, needs: [...new Set([...arr(base.needs), ...arr(next.needs)])], task: next.task || base.task || null };
|
|
270
|
+
}
|
|
271
|
+
/** A checkpoint: divide what is left again. Calls made while one waits to start join it. Never rejects. */
|
|
272
|
+
function resplit(sessionId, want = {}) {
|
|
273
|
+
const id = String(sessionId || '');
|
|
274
|
+
if (!id || !limited()) return Promise.resolve(null);
|
|
275
|
+
let chain = chains.get(id);
|
|
276
|
+
if (!chain) { chain = { promise: Promise.resolve(), queued: null, waiting: null }; chains.set(id, chain); }
|
|
277
|
+
if (chain.queued) { chain.queued = mergeWant(chain.queued, want); return chain.waiting; }
|
|
278
|
+
chain.queued = mergeWant(null, want);
|
|
279
|
+
const waiting = chain.promise.then(() => {
|
|
280
|
+
const next = chain.queued;
|
|
281
|
+
chain.queued = null;
|
|
282
|
+
return compute(id, next);
|
|
283
|
+
}).catch((error) => { log('budget:split-error', `${id}: ${error?.message || String(error)}`); return splitOf(id); });
|
|
284
|
+
chain.waiting = waiting;
|
|
285
|
+
chain.promise = waiting;
|
|
286
|
+
return waiting;
|
|
287
|
+
}
|
|
288
|
+
/** The session's split for the budget as it is now, made when there is none (the first route or dispatch). */
|
|
289
|
+
async function ensureSplit(sessionId, { task = null, reason = null } = {}) {
|
|
290
|
+
const id = String(sessionId || '');
|
|
291
|
+
const value = readSetting();
|
|
292
|
+
if (!id || value.unlimited) return null;
|
|
293
|
+
const current = splitOf(id);
|
|
294
|
+
if (current && current.settingVersion === value.version && current.amountUsd === value.amountUsd) {
|
|
295
|
+
if (task) noteTask(id, task);
|
|
296
|
+
return current;
|
|
297
|
+
}
|
|
298
|
+
return resplit(id, { reason: reason || (current ? 'budget_changed' : 'first_route'), task });
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Can `provider` pay for this work? When its share is used up but the session has money left, the
|
|
302
|
+
* unspent budget is divided again first (a checkpoint: Jev, else the fallback, decides whether the
|
|
303
|
+
* provider gets more; at most once per provider every few seconds). `forBrain`: a brain turn (it may
|
|
304
|
+
* use its reserve). `free`: a free model (only the session limit counts).
|
|
305
|
+
*/
|
|
306
|
+
async function ensureCovered(sessionId, { provider, forBrain = false, free = false, task = null, reason = null } = {}) {
|
|
307
|
+
const id = String(sessionId || '');
|
|
308
|
+
if (!id || !limited()) return true;
|
|
309
|
+
let cov = coverage(id);
|
|
310
|
+
if (coversWork(cov, { provider, forBrain, free })) return true;
|
|
311
|
+
if (cov.leftUsd < MIN_SPEND_USD || free) return false;
|
|
312
|
+
const key = `${id}:${provider}:${forBrain ? 'brain' : 'runs'}`;
|
|
313
|
+
const tried = needTries.get(key);
|
|
314
|
+
const value = readSetting();
|
|
315
|
+
const spentNow = cov.spentUsd;
|
|
316
|
+
// Never a re-split per metered step: a provider the split left short a moment ago stays short.
|
|
317
|
+
if (tried && now() - tried.at < NEED_RETRY_MS && tried.version === value.version && cov.hasSplit && (!forBrain || tried.spent === spentNow)) return false;
|
|
318
|
+
needTries.set(key, { at: now(), version: value.version, spent: spentNow });
|
|
319
|
+
await resplit(id, { reason: reason || (forBrain ? 'brain_turn' : 'share_used_up'), needs: provider ? [provider] : [], task });
|
|
320
|
+
cov = coverage(id);
|
|
321
|
+
return coversWork(cov, { provider, forBrain, free });
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ── tasks ──────────────────────────────────────────────────────────────
|
|
325
|
+
function noteTaskRow(row, task, { current = false } = {}) {
|
|
326
|
+
if (!task || typeof task !== 'object') return;
|
|
327
|
+
const entry = {
|
|
328
|
+
taskClass: task.taskClass ? String(task.taskClass).slice(0, 20) : null, summary: clip(task.summary || '', 160) || null,
|
|
329
|
+
provider: BUDGET_PROVIDERS.includes(task.provider) ? task.provider : null, model: task.model ? String(task.model).slice(0, 120) : null,
|
|
330
|
+
at: iso(now()), ...(current ? { current: true } : {}),
|
|
331
|
+
};
|
|
332
|
+
const last = row.tasks.at(-1);
|
|
333
|
+
if (last && last.summary === entry.summary && last.taskClass === entry.taskClass && last.provider === entry.provider) { Object.assign(last, entry); return; }
|
|
334
|
+
row.tasks.push(entry);
|
|
335
|
+
if (row.tasks.length > MAX_TASKS) row.tasks.splice(0, row.tasks.length - MAX_TASKS);
|
|
336
|
+
}
|
|
337
|
+
/** A task the session routed (agent_route approved it, or a dispatch started): Jev's next state lists it. */
|
|
338
|
+
function noteTask(sessionId, task) {
|
|
339
|
+
const id = String(sessionId || '');
|
|
340
|
+
if (!id || !task) return;
|
|
341
|
+
const row = rowOfSession(id, { create: true });
|
|
342
|
+
noteTaskRow(row, task);
|
|
343
|
+
row.updatedAt = iso(now());
|
|
344
|
+
persist();
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ── checkpoints from outside ───────────────────────────────────────────
|
|
348
|
+
/** The budget setting changed: every open session with a split divides what is left again. */
|
|
349
|
+
function onBudgetChanged() {
|
|
350
|
+
needTries.clear();
|
|
351
|
+
if (!limited()) return;
|
|
352
|
+
for (const [id, row] of sessions) {
|
|
353
|
+
if (!row.split || !safe(() => isOpen(id), true)) continue;
|
|
354
|
+
resplit(id, { reason: 'budget_changed' }).catch(() => {});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
/** A run of the session ended: what it did not spend goes back into the split (debounced). */
|
|
358
|
+
function onRunEnded(sessionId) {
|
|
359
|
+
const id = String(sessionId || '');
|
|
360
|
+
if (!id || !limited() || !splitOf(id)) return;
|
|
361
|
+
if (runEndTimers.has(id)) clearTimeout(runEndTimers.get(id));
|
|
362
|
+
const timer = setTimeout(() => {
|
|
363
|
+
runEndTimers.delete(id);
|
|
364
|
+
if (safe(() => isOpen(id), true)) resplit(id, { reason: 'run_ended' }).catch(() => {});
|
|
365
|
+
}, RUN_END_DEBOUNCE_MS);
|
|
366
|
+
timer.unref?.();
|
|
367
|
+
runEndTimers.set(id, timer);
|
|
368
|
+
}
|
|
369
|
+
function forget(sessionId) {
|
|
370
|
+
const id = String(sessionId || '');
|
|
371
|
+
sessions.delete(id);
|
|
372
|
+
chains.delete(id);
|
|
373
|
+
persist();
|
|
374
|
+
}
|
|
375
|
+
/** Settled for a test or a shutdown: pending timers cleared, state written now. */
|
|
376
|
+
function flush() {
|
|
377
|
+
for (const timer of runEndTimers.values()) clearTimeout(timer);
|
|
378
|
+
runEndTimers.clear();
|
|
379
|
+
if (persistTimer) { clearTimeout(persistTimer); persistTimer = null; }
|
|
380
|
+
writeState();
|
|
381
|
+
}
|
|
382
|
+
/** What every queued re-split resolves to (tests). */
|
|
383
|
+
function settled(sessionId) { return chains.get(String(sessionId || ''))?.promise || Promise.resolve(splitOf(sessionId)); }
|
|
384
|
+
|
|
385
|
+
load();
|
|
386
|
+
return {
|
|
387
|
+
setting: readSetting, limited, coverage, covers, view, splitOf, dry: (id) => dryProviders(coverage(id)),
|
|
388
|
+
ensureSplit, resplit, ensureCovered, noteTask, onBudgetChanged, onRunEnded, forget, flush, settled,
|
|
389
|
+
};
|
|
390
|
+
}
|