@milpa/comal 0.2.1 → 0.2.3
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/README.md +1 -1
- package/backend/contracts/remote-commands-v1.json +0 -12
- package/backend/contracts/remote-relay-answers-v1.json +209 -0
- package/package.json +3 -1
- package/skills/mv3/SKILL.md +44 -0
- package/skills/mv3/skill.json +4 -0
- package/src/agent.mjs +299 -32
- package/src/auto-guard.mjs +40 -3
- package/src/autocompact.mjs +233 -0
- package/src/bash-timeouts.mjs +29 -0
- package/src/child-env.mjs +23 -0
- package/src/cli.mjs +232 -63
- package/src/code-run-artifact.mjs +30 -0
- package/src/code-run-journal.mjs +135 -0
- package/src/code-run-session-proof.mjs +50 -0
- package/src/code-run-session.mjs +97 -0
- package/src/code-run-worker.mjs +20 -4
- package/src/code-run.mjs +49 -14
- package/src/codex-provider.mjs +4 -4
- package/src/compaction-notice.mjs +22 -0
- package/src/compaction.mjs +162 -23
- package/src/connection-settings.mjs +36 -4
- package/src/context-pressure.mjs +5 -4
- package/src/context.mjs +27 -22
- package/src/execution-metrics.mjs +36 -5
- package/src/file-recovery-proof.mjs +9 -1
- package/src/file-recovery.mjs +36 -3
- package/src/gemini-provider.mjs +5 -3
- package/src/git-integration-operations.mjs +2 -1
- package/src/git-plumbing.mjs +2 -1
- package/src/git-source.mjs +2 -1
- package/src/git-workspaces.mjs +2 -1
- package/src/house.mjs +13 -1
- package/src/i18n/cli-es.mjs +12 -1
- package/src/i18n/es.mjs +133 -2
- package/src/judges.mjs +2 -1
- package/src/mcp.mjs +2 -1
- package/src/model-controls.mjs +16 -2
- package/src/model-deadline.mjs +2 -2
- package/src/openai-provider.mjs +8 -4
- package/src/operations.mjs +17 -2
- package/src/package-identity.mjs +68 -0
- package/src/peer-probe.mjs +16 -0
- package/src/permissions.mjs +99 -6
- package/src/plugin-catalog.mjs +19 -4
- package/src/plugin-config.mjs +18 -1
- package/src/plugin-controls.mjs +31 -8
- package/src/plugin-trust.mjs +15 -6
- package/src/plugins/chrome-lab/bridge.mjs +19 -6
- package/src/plugins/chrome-lab/client.mjs +242 -12
- package/src/plugins/chrome-lab/index.mjs +106 -24
- package/src/plugins/chrome-lab/legacy-fill.mjs +25 -0
- package/src/plugins/chrome-lab/native-reply.mjs +31 -0
- package/src/plugins/omarchy/catalog.mjs +96 -0
- package/src/plugins/omarchy/environment.mjs +78 -0
- package/src/plugins/omarchy/guide.mjs +54 -0
- package/src/plugins/omarchy/index.mjs +213 -0
- package/src/plugins/omarchy/runner.mjs +62 -0
- package/src/plugins/omarchy/text.mjs +26 -0
- package/src/plugins/omarchy/tiers.mjs +157 -0
- package/src/plugins/open-relay/actions.mjs +212 -16
- package/src/plugins/open-relay/client.mjs +93 -35
- package/src/plugins/open-relay/decision.mjs +89 -0
- package/src/plugins/open-relay/dossier.mjs +23 -5
- package/src/plugins/open-relay/index.mjs +68 -28
- package/src/plugins/open-relay/lookup.mjs +41 -4
- package/src/plugins/open-relay/notices.mjs +48 -4
- package/src/plugins/open-relay/panel.mjs +59 -0
- package/src/plugins/open-relay/watch.mjs +198 -85
- package/src/plugins.mjs +12 -1
- package/src/process-lock.mjs +20 -12
- package/src/provider-auth.mjs +6 -3
- package/src/provider-profiles.mjs +13 -3
- package/src/provider.mjs +175 -30
- package/src/read-only-check.mjs +12 -4
- package/src/relay/broker.mjs +160 -0
- package/src/relay/cards.mjs +702 -0
- package/src/relay/flow.mjs +886 -0
- package/src/relay/follow.mjs +119 -0
- package/src/relay/git.mjs +23 -0
- package/src/relay/hub-client.mjs +163 -0
- package/src/relay/state.mjs +55 -0
- package/src/remote-control.mjs +115 -12
- package/src/remote-relay.mjs +468 -0
- package/src/session-bus.mjs +13 -3
- package/src/session-provider-settings.mjs +2 -2
- package/src/skill-runtime.mjs +8 -6
- package/src/source-ownership.mjs +3 -1
- package/src/swarm-view.mjs +53 -0
- package/src/tool-manifest.mjs +71 -0
- package/src/tool-packs.mjs +22 -6
- package/src/tool-schema.mjs +14 -2
- package/src/tools.mjs +96 -27
- package/src/tui/connection-transition.mjs +10 -0
- package/src/tui/controller.mjs +35 -9
- package/src/tui/discovery.mjs +13 -8
- package/src/tui/inbound-blocks.mjs +5 -1
- package/src/tui/screen.mjs +78 -15
- package/src/tui/swarm-panel.mjs +217 -0
- package/src/turn-timing.mjs +82 -0
- package/src/usage-accounting.mjs +12 -1
- package/src/vision.mjs +6 -1
- package/src/visual/chrome.mjs +379 -0
- package/src/visual/fingerprint.mjs +30 -0
- package/src/visual/fixtures.mjs +201 -0
- package/src/visual/gate.mjs +92 -0
- package/src/visual/judge.mjs +114 -0
- package/src/visual/ledger.mjs +160 -0
- package/src/visual/measure.mjs +145 -0
- package/src/visual/names.mjs +7 -0
- package/src/visual/operations.mjs +211 -0
- package/src/visual/pixel-diff.mjs +96 -0
- package/src/visual/png.mjs +103 -0
- package/src/visual/registry.mjs +265 -0
- package/src/visual/report.mjs +64 -0
- package/src/visual/verify.mjs +292 -0
- package/src/work-assertions.mjs +15 -4
- package/src/work-plan.mjs +166 -30
- package/src/work-state.mjs +4 -4
- package/src/workflows/chrome-task-adoption.mjs +7 -3
- package/src/workspace-serve.mjs +59 -0
|
@@ -1,37 +1,138 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { deliveryNotice, planNotice, taskAlert, jobAlert } from './notices.mjs';
|
|
1
|
+
import { needsHuman } from './client.mjs';
|
|
2
|
+
import { deliveryNotice, planNotice, taskAlert, jobAlert, reviewAlert, correctionNotice } from './notices.mjs';
|
|
3
|
+
import { clean, excerpt } from './bound.mjs';
|
|
4
|
+
import { decisionBlock } from './decision.mjs';
|
|
3
5
|
|
|
4
|
-
// Vigilancia del enjambre,
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
6
|
+
// Vigilancia del enjambre sin sondeo (fase 3, open-relay ≥ 2.8): una conexión dedicada
|
|
7
|
+
// hace `events.wait {after, timeout_ms}` en bucle sobre el log de relayd. Al conectar
|
|
8
|
+
// por primera vez se toma el `head_seq` y se revisa lo que ya estaba pendiente (buzón
|
|
9
|
+
// del operador, tareas, planes y jobs); después sólo se mira lo que tocan los eventos:
|
|
10
|
+
// `message_sent` al operador → buzón de ese swarm; `task:*` → foto del swarm (alertas y
|
|
11
|
+
// estados); `plan:*` → planes pendientes; `job:*` → jobs. Si la espera vence
|
|
12
|
+
// (`timed_out`), se repite con el mismo `after`. Ante un corte (relayd reiniciado,
|
|
13
|
+
// UNAVAILABLE) reconecta con espera creciente y sigue desde su cursor: lo que pasó
|
|
14
|
+
// mientras tanto llega en la siguiente espera. Deduplica por ref.
|
|
15
|
+
// Fase 4: con lo que ya lee, guarda además una foto ACOTADA y limpia para el panel /swarm
|
|
16
|
+
// (tareas, roles, preguntas y planes pendientes) y avisa con onChange; no hace llamadas
|
|
17
|
+
// extra salvo releer el buzón cuando relayd registra un ack o vencimiento de una entrega del
|
|
18
|
+
// operador, y la foto del swarm cuando cambian sus roles o equipos.
|
|
8
19
|
const sleep = (ms, signal) => new Promise(resolve => { const timer = setTimeout(done, ms); function done() { clearTimeout(timer); signal?.removeEventListener('abort', done); resolve(); } signal?.addEventListener('abort', done, { once: true }); });
|
|
9
|
-
const SEEN_LIMIT = 4000, PAGE = 200;
|
|
20
|
+
const SEEN_LIMIT = 4000, PAGE = 200, TASKS_SHOWN = 40;
|
|
21
|
+
// Topes de la foto del panel (texto de agentes: limpio y acotado ya aquí).
|
|
22
|
+
const VIEW = Object.freeze({ swarms: 16, tasks: 80, roles: 40, pending: 30, plans: 20, title: 160, objective: 300, excerpt: 240, id: 120 });
|
|
23
|
+
const short = (value, limit = VIEW.id) => { const text = clean(value).replace(/\s+/g, ' ').trim(); return text.length > limit ? text.slice(0, limit) + '…' : text; };
|
|
24
|
+
const stamp = value => { const number = Number(value); return Number.isFinite(number) && number > 0 ? number : null; };
|
|
25
|
+
const splitAggregate = value => { const text = String(value || ''), at = text.indexOf(':'); return at < 0 ? [text, ''] : [text.slice(0, at), text.slice(at + 1)]; };
|
|
10
26
|
|
|
11
27
|
export class RelayWatcher {
|
|
12
|
-
constructor({ client, notify, swarms = null, waitMs = 30000,
|
|
13
|
-
Object.assign(this, { client, notify, filter: swarms && swarms.length ? new Set(swarms) : null, waitMs,
|
|
14
|
-
|
|
15
|
-
|
|
28
|
+
constructor({ client, notify, swarms = null, waitMs = 30000, questionGraceMs = 1200, maxBackoffMs = 5000, humanBackoffMs = 30000, batch = 200, onError = () => {}, onChange = () => {}, now = Date.now }) {
|
|
29
|
+
Object.assign(this, { client, notify, filter: swarms && swarms.length ? new Set(swarms) : null, waitMs, questionGraceMs, maxBackoffMs, humanBackoffMs, batch, onError, onChange, now });
|
|
30
|
+
// Foto del panel: por swarm, resumen, tareas (con desde cuándo se ve su estado), roles,
|
|
31
|
+
// entregas pendientes del operador y planes pendientes. Todo limpio y acotado.
|
|
32
|
+
this.info = new Map(); this.board = new Map(); this.since = new Map(); this.pending = new Map(); this.proposals = new Map(); this.criteria = new Map();
|
|
33
|
+
this.questionTask = new Map(); this.messageSwarm = new Map(); this.roleSwarm = new Map(); this.updatedAt = null;
|
|
34
|
+
this.controller = new AbortController(); this.known = new Set(); this.taskStates = new Map(); this.taskSwarm = new Map();
|
|
35
|
+
this.seen = new Set(); this.retry = []; this.connection = null; this.sent = 0; this.lastError = null; this.started = false;
|
|
36
|
+
this.cursor = null; this.events = 0; this.waits = 0; this.opened = 0; this.lastEvent = null; this.state = 'idle'; this.loop = null;
|
|
16
37
|
}
|
|
17
38
|
get signal() { return this.controller.signal; }
|
|
18
39
|
status() {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
(
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
40
|
+
const tasks = {};
|
|
41
|
+
for (const [swarm, states] of this.taskStates) tasks[swarm] = Object.fromEntries([...states].slice(-TASKS_SHOWN));
|
|
42
|
+
return { state: this.state, watching: [...this.known], filter: this.filter ? [...this.filter] : null, cursor: this.cursor, events_seen: this.events, waits: this.waits, last_event: this.lastEvent, tasks, notices_sent: this.sent, pending_retry: this.retry.length, reconnects: Math.max(0, this.opened - 1), last_error: this.lastError };
|
|
43
|
+
}
|
|
44
|
+
start() { if (this.started) return; this.started = true; this.loop = this.#run(); }
|
|
45
|
+
#state(state) { if (this.state === state) return; this.state = state; this.#changed(); }
|
|
46
|
+
#changed() { this.updatedAt = this.now(); try { this.onChange(); } catch {} }
|
|
47
|
+
/** Foto para el panel /swarm: datos ya limpios y acotados, sin tokens ni rutas del token. */
|
|
48
|
+
view() {
|
|
49
|
+
const swarms = [];
|
|
50
|
+
for (const id of [...this.known].slice(0, VIEW.swarms)) {
|
|
51
|
+
const board = this.board.get(id) || { tasks: [], roles: [], truncated: false };
|
|
52
|
+
swarms.push({ id: short(id), ...(this.info.get(id) || {}), tasks: board.tasks.map(task => ({ ...task, ...(this.since.get(`${id}:${task.id}`) || { since: null, since_exact: false }) })), roles: board.roles, pending: this.pending.get(id) || [], plans: this.proposals.get(id) || [], criteria: this.criteria.get(id) || null, truncated: board.truncated || false });
|
|
53
|
+
}
|
|
54
|
+
return { swarms, swarms_total: this.known.size, updated_at: this.updatedAt };
|
|
55
|
+
}
|
|
56
|
+
/** Corta la espera en curso (cerrar la conexión cancela events.wait en relayd) y espera al bucle. */
|
|
57
|
+
async stop() { this.controller.abort(new Error('Watch stopped.')); this.connection?.close(); await this.loop?.catch(() => {}); this.state = 'stopped'; this.#changed(); }
|
|
58
|
+
#watched(swarm) { return typeof swarm === 'string' && (!this.filter || this.filter.has(swarm)); }
|
|
59
|
+
#error(where, error) { if (this.signal.aborted) return; this.lastError = { where, code: error.code || 'ERROR', message: String(error.message).slice(0, 600), at: new Date().toISOString() }; this.onError(this.lastError); this.#changed(); }
|
|
60
|
+
async #run() {
|
|
61
|
+
let backoff = 250;
|
|
62
|
+
while (!this.signal.aborted) {
|
|
63
|
+
let connection;
|
|
64
|
+
try {
|
|
65
|
+
this.#state('connecting');
|
|
66
|
+
connection = this.connection = await this.client.dedicated(this.signal); this.opened++;
|
|
67
|
+
backoff = 250;
|
|
68
|
+
if (this.cursor === null) { this.#state('catching_up'); await this.#catchUp(connection); }
|
|
69
|
+
this.lastError = null; if (this.state === 'waiting') this.#changed(); else this.#state('waiting');
|
|
70
|
+
await this.#follow(connection);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (this.signal.aborted) return;
|
|
73
|
+
this.#error(this.state, error);
|
|
74
|
+
// Sin la capacidad read no hay nada que vigilar: hace falta otra delegación.
|
|
75
|
+
if (error.code === 'NOT_DELEGATED') { this.#state('stopped'); return; }
|
|
76
|
+
// Token ausente, revocado o vencido: espera larga (el humano lo reemite) sin martillar.
|
|
77
|
+
const wait = needsHuman(error) ? this.humanBackoffMs : backoff;
|
|
78
|
+
this.#state(needsHuman(error) ? 'needs_human' : 'backoff');
|
|
79
|
+
await sleep(wait, this.signal); backoff = Math.min(this.maxBackoffMs, backoff * 2);
|
|
80
|
+
} finally { connection?.close(); if (this.connection === connection) this.connection = null; }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** Primera conexión: cursor = head_seq y revisión de lo que ya estaba pendiente. */
|
|
84
|
+
async #catchUp(connection) {
|
|
85
|
+
const { head_seq } = await connection.call('daemon.status', {}, { signal: this.signal });
|
|
86
|
+
const { swarms = [] } = await connection.call('swarm.list', {}, { signal: this.signal });
|
|
87
|
+
for (const swarm of swarms) if (this.#watched(swarm.id)) { this.known.add(swarm.id); this.#summary(swarm.id, swarm); }
|
|
88
|
+
for (const swarm of this.known) { await this.#status(connection, swarm); await this.#plans(connection, swarm); await this.#inbox(connection, swarm); }
|
|
89
|
+
await this.#jobs(connection);
|
|
90
|
+
this.cursor = String(head_seq ?? '0');
|
|
91
|
+
}
|
|
92
|
+
async #follow(connection) {
|
|
93
|
+
while (!this.signal.aborted) {
|
|
94
|
+
for (const notice of this.retry.splice(0)) await this.#send(notice);
|
|
95
|
+
const result = await connection.call('events.wait', { after: this.cursor, timeout_ms: String(this.waitMs), limit: this.batch }, { signal: this.signal, timeoutMs: this.waitMs + 15000 });
|
|
96
|
+
this.waits++;
|
|
97
|
+
const events = result?.events || [];
|
|
98
|
+
if (result?.timed_out || !events.length) continue;
|
|
99
|
+
await this.#apply(connection, events);
|
|
100
|
+
this.cursor = result.next_after || events.at(-1).seq || this.cursor;
|
|
101
|
+
this.#changed();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
async #apply(connection, events) {
|
|
105
|
+
const statuses = new Set(), inboxes = new Set(), plans = new Set(), criteria = new Set(); let jobs = false;
|
|
106
|
+
for (const event of events) {
|
|
107
|
+
this.events++;
|
|
108
|
+
const [kind, id] = splitAggregate(event.aggregate_id);
|
|
109
|
+
this.lastEvent = { seq: event.seq, type: event.type, aggregate_id: event.aggregate_id, actor: event.actor, ...(event.via ? { via: event.via } : {}) };
|
|
110
|
+
if (event.type === 'swarm_created' && this.#watched(id)) { this.known.add(id); statuses.add(id); }
|
|
111
|
+
else if (event.type === 'message_sent') { const message = event.payload?.message; if (message?.to?.kind === 'operator' && this.#watched(message.swarm)) { this.known.add(message.swarm); inboxes.add(message.swarm); } }
|
|
112
|
+
else if (kind === 'task') { const swarm = this.taskSwarm.get(id); if (swarm) statuses.add(swarm); else for (const item of this.known) statuses.add(item); }
|
|
113
|
+
// Roles y equipos: ocupación y equipos del panel.
|
|
114
|
+
else if (kind === 'role' || kind === 'team') { const swarm = this.roleSwarm.get(id); if (swarm) statuses.add(swarm); else for (const item of this.known) statuses.add(item); }
|
|
115
|
+
// Ack o vencimiento de una entrega del operador: deja de estar pendiente en el panel.
|
|
116
|
+
else if (kind === 'delivery' && id.endsWith('@operator')) { const swarm = this.messageSwarm.get(id.slice(0, id.lastIndexOf('.'))); if (swarm) inboxes.add(swarm); else for (const item of this.known) inboxes.add(item); }
|
|
117
|
+
else if (kind === 'plan') plans.add(id);
|
|
118
|
+
// Revisor (open-relay F7): los criterios de éxito son eventos del swarm.
|
|
119
|
+
else if (kind === 'swarm' && /^criteria_/.test(event.type || '') && this.known.has(id)) criteria.add(id);
|
|
120
|
+
else if (kind === 'job') jobs = true;
|
|
121
|
+
}
|
|
122
|
+
for (const swarm of statuses) await this.#status(connection, swarm);
|
|
123
|
+
for (const swarm of criteria) if (!statuses.has(swarm)) await this.#criteria(connection, swarm);
|
|
124
|
+
for (const id of plans) {
|
|
125
|
+
try {
|
|
126
|
+
const got = await connection.call('plan.get', { proposal_id: id }, { signal: this.signal }), swarm = got?.proposal?.swarm_id;
|
|
127
|
+
if (!this.#watched(swarm)) continue;
|
|
128
|
+
// Revisor (F6): su plan de corrección se aprueba solo (nunca queda pendiente): se avisa al aplicarse.
|
|
129
|
+
const correction = correctionNotice(swarm, got.proposal); if (correction) await this.#send(correction);
|
|
130
|
+
await this.#plans(connection, swarm);
|
|
33
131
|
}
|
|
34
|
-
|
|
132
|
+
catch (error) { if (!error.rpc) throw error; this.#error(`plan:${id}`, error); }
|
|
133
|
+
}
|
|
134
|
+
for (const swarm of inboxes) await this.#inbox(connection, swarm);
|
|
135
|
+
if (jobs) await this.#jobs(connection);
|
|
35
136
|
}
|
|
36
137
|
/** Envía un aviso una sola vez por ref. Si el host no lo admite por ahora, queda para reintento. */
|
|
37
138
|
async #send(notice) {
|
|
@@ -44,83 +145,95 @@ export class RelayWatcher {
|
|
|
44
145
|
} else if (!this.retry.some(item => item.ref === notice.ref)) this.retry.push(notice);
|
|
45
146
|
if (this.retry.length > 64) this.retry.shift();
|
|
46
147
|
}
|
|
47
|
-
async #
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
148
|
+
async #inbox(connection, swarm) {
|
|
149
|
+
let after = null; const pending = [];
|
|
150
|
+
for (let page = 0; page < 20; page++) {
|
|
151
|
+
const result = await connection.call('msg.inbox', { operator: true, swarm_id: swarm, pending_only: true, limit: PAGE, ...(after ? { after } : {}) }, { signal: this.signal });
|
|
152
|
+
for (const delivery of result.deliveries || []) if (delivery.state === 'pending') { await this.#delivery(connection, swarm, delivery); pending.push(delivery); }
|
|
153
|
+
if (!result.deliveries?.length || !result.next_after || result.next_after === after) break;
|
|
154
|
+
after = result.next_after;
|
|
53
155
|
}
|
|
156
|
+
this.pending.set(swarm, pending.slice(-VIEW.pending).map(delivery => this.#pendingItem(swarm, delivery)));
|
|
54
157
|
}
|
|
55
|
-
/** Una
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if (error.rpc && !reconnectable(error) && error.code === 'INVALID_PARAMS') return; // swarm inexistente
|
|
67
|
-
await sleep(backoff, this.signal); backoff = Math.min(this.maxBackoffMs, backoff * 2);
|
|
68
|
-
} finally { connection?.close(); this.connections.delete(connection); }
|
|
69
|
-
}
|
|
158
|
+
/** Una entrega pendiente para el panel: quién, qué, desde cuándo y el bloque decision, limpios. */
|
|
159
|
+
#pendingItem(swarm, delivery) {
|
|
160
|
+
const message = delivery.message || {}, structured = decisionBlock(message);
|
|
161
|
+
if (typeof message.id === 'string') { this.messageSwarm.set(message.id, swarm); if (this.messageSwarm.size > SEEN_LIMIT) this.messageSwarm.delete(this.messageSwarm.keys().next().value); }
|
|
162
|
+
return {
|
|
163
|
+
delivery_id: short(delivery.delivery_id), message_id: short(message.id), kind: short(message.kind || 'message', 40),
|
|
164
|
+
from: message.from?.role ? `role:${short(message.from.role)}` : short(message.from?.kind || 'operator', 40),
|
|
165
|
+
sent_at: stamp(message.sent_at), excerpt: message.pruned ? null : excerpt(message.body || '', VIEW.excerpt),
|
|
166
|
+
task: this.questionTask.get(message.id) || null,
|
|
167
|
+
decision: structured ? { question: structured.question, options: structured.options.map(item => item.label), recommended: structured.recommendation?.option || null, scope: structured.scope, reversible: structured.reversible } : null,
|
|
168
|
+
};
|
|
70
169
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
let after = 0n;
|
|
75
|
-
for (;;) {
|
|
76
|
-
const page = await connection.call('msg.inbox', { operator: true, swarm_id: swarm, after: String(after), limit: PAGE }, { signal: this.signal });
|
|
77
|
-
for (const delivery of page.deliveries || []) if (delivery.state === 'pending') await this.#delivery(swarm, delivery);
|
|
78
|
-
const next = page.next_after ? BigInt(page.next_after) : after;
|
|
79
|
-
if (!page.deliveries?.length || next === after) break;
|
|
80
|
-
after = next;
|
|
81
|
-
}
|
|
82
|
-
this.cursors.set(swarm, String(after));
|
|
83
|
-
}
|
|
84
|
-
while (!this.signal.aborted) {
|
|
85
|
-
const result = await connection.call('msg.wait', { operator: true, swarm_id: swarm, after: this.cursors.get(swarm), timeout_ms: String(this.waitMs) }, { signal: this.signal, timeoutMs: this.waitMs + 15000 });
|
|
86
|
-
for (const delivery of result.deliveries || []) if (delivery.state === 'pending') await this.#delivery(swarm, delivery);
|
|
87
|
-
if (result.next_after) this.cursors.set(swarm, result.next_after);
|
|
88
|
-
}
|
|
170
|
+
#summary(swarm, data = {}) {
|
|
171
|
+
const old = this.info.get(swarm) || {};
|
|
172
|
+
this.info.set(swarm, { objective: data.objective !== undefined ? excerpt(data.objective, VIEW.objective) : old.objective ?? null, root_team: data.root_team !== undefined ? short(data.root_team) : old.root_team ?? null });
|
|
89
173
|
}
|
|
90
|
-
async #delivery(swarm, delivery) {
|
|
174
|
+
async #delivery(connection, swarm, delivery) {
|
|
91
175
|
const ref = `delivery:${swarm}:${delivery.delivery_id}`;
|
|
92
176
|
if (this.seen.has(ref)) return;
|
|
93
177
|
let task = null;
|
|
94
178
|
if (delivery.message?.kind === 'question' && delivery.message.from?.role) {
|
|
95
179
|
// msg.ask bloquea la tarea en un commit posterior al envío: un margen breve.
|
|
96
|
-
task = await this.#blockedBy(swarm, delivery.message.id);
|
|
97
|
-
if (!task) { await sleep(this.questionGraceMs, this.signal); task = await this.#blockedBy(swarm, delivery.message.id); }
|
|
180
|
+
task = await this.#blockedBy(connection, swarm, delivery.message.id);
|
|
181
|
+
if (!task) { await sleep(this.questionGraceMs, this.signal); task = await this.#blockedBy(connection, swarm, delivery.message.id); }
|
|
98
182
|
}
|
|
99
183
|
await this.#send(deliveryNotice(delivery, { task }));
|
|
100
184
|
}
|
|
101
|
-
async #blockedBy(swarm, messageId) {
|
|
185
|
+
async #blockedBy(connection, swarm, messageId) {
|
|
102
186
|
try {
|
|
103
|
-
const status = await
|
|
187
|
+
const status = await connection.call('swarm.status', { swarm_id: swarm }, { signal: this.signal });
|
|
104
188
|
for (const task of (status.tasks || []).filter(item => item.state === 'blocked:question').slice(0, 20)) {
|
|
105
|
-
const detail = await
|
|
106
|
-
if (detail.park?.reason?.kind === 'question' && detail.park.reason.message_id === messageId) return { id: task.id, state: task.state, title: task.title };
|
|
189
|
+
const detail = await connection.call('task.get', { task_id: task.id }, { signal: this.signal });
|
|
190
|
+
if (detail.park?.reason?.kind === 'question' && detail.park.reason.message_id === messageId) { this.questionTask.set(messageId, short(task.id)); return { id: task.id, state: task.state, title: task.title }; }
|
|
107
191
|
}
|
|
108
|
-
} catch (error) { this.#error(`question:${swarm}`, error); }
|
|
192
|
+
} catch (error) { if (!error.rpc) throw error; this.#error(`question:${swarm}`, error); }
|
|
109
193
|
return null;
|
|
110
194
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
195
|
+
/** Foto del swarm: estados de tareas (para status()) y alertas de infraestructura. */
|
|
196
|
+
async #status(connection, swarm) {
|
|
197
|
+
let status;
|
|
198
|
+
try { status = await connection.call('swarm.status', { swarm_id: swarm }, { signal: this.signal }); }
|
|
199
|
+
catch (error) { if (!error.rpc) throw error; this.#error(`status:${swarm}`, error); return; }
|
|
200
|
+
const states = new Map(), previous = this.taskStates.get(swarm), first = previous === undefined, at = this.now();
|
|
201
|
+
for (const task of status.tasks || []) { if (typeof task.id !== 'string') continue; states.set(task.id, task.state); this.taskSwarm.set(task.id, swarm); }
|
|
202
|
+
// Desde cuándo se ve cada estado: exacto si el cambio llegó por un evento; en la primera
|
|
203
|
+
// foto sólo se sabe que ya estaba así («al menos»).
|
|
204
|
+
for (const [task, state] of states) if (previous?.get(task) !== state || !this.since.has(`${swarm}:${task}`)) this.since.set(`${swarm}:${task}`, { since: at, since_exact: !first });
|
|
205
|
+
for (const task of previous?.keys() || []) if (!states.has(task)) this.since.delete(`${swarm}:${task}`);
|
|
206
|
+
this.taskStates.set(swarm, states);
|
|
207
|
+
if (status.swarm) this.#summary(swarm, status.swarm);
|
|
208
|
+
const roles = Array.isArray(status.roles) ? status.roles : [], tasks = Array.isArray(status.tasks) ? status.tasks : [];
|
|
209
|
+
for (const role of roles) if (typeof role?.id === 'string') this.roleSwarm.set(role.id, swarm);
|
|
210
|
+
for (const role of roles) if (typeof role?.team === 'string') this.roleSwarm.set(role.team, swarm);
|
|
211
|
+
const names = new Map(roles.map(role => [role?.id, role?.name]));
|
|
212
|
+
this.board.set(swarm, {
|
|
213
|
+
truncated: tasks.length > VIEW.tasks || roles.length > VIEW.roles,
|
|
214
|
+
tasks: tasks.filter(task => typeof task?.id === 'string').slice(0, VIEW.tasks).map(task => ({ id: short(task.id), title: task.title ? excerpt(task.title, VIEW.title) : null, state: short(task.state, 40), team: short(task.team), role: short(task.required_role), role_name: names.get(task.required_role) ? short(names.get(task.required_role), 60) : null, attempts_used: Number(task.attempts_used) || 0, counted_attempts: task.counted_attempts == null ? Number(task.attempts_used) || 0 : Number(task.counted_attempts) || 0, max_attempts: Number(task.max_attempts) || 0, ...(task.infra?.exhausted ? { infra_exhausted: true } : {}), ...(task.wake_cap === true ? { wake_cap: true } : {}), ...(Number(task.review_rounds) > 0 ? { review_rounds: Number(task.review_rounds) } : {}), ...(['task', 'integration', 'final'].includes(task.review_kind) ? { review_kind: task.review_kind } : {}) })),
|
|
215
|
+
roles: roles.slice(0, VIEW.roles).map(role => ({ id: short(role.id), name: short(role.name || role.id, 60), team: short(role.team), occupied: Boolean(role.occupant), load: Number(role.load) || 0, capacity: Number(role.capacity) || 0, model: role.model ? short(role.model, 60) : null })),
|
|
216
|
+
});
|
|
217
|
+
for (const task of status.tasks || []) { const alert = taskAlert(swarm, task) || reviewAlert(swarm, task); if (alert) await this.#send(alert); }
|
|
218
|
+
// Revisor (open-relay F7): un swarm con revisor tiene criterios de éxito que acepta el humano.
|
|
219
|
+
if (tasks.some(task => /^t-review/.test(task?.id || ''))) await this.#criteria(connection, swarm);
|
|
220
|
+
}
|
|
221
|
+
async #criteria(connection, swarm) {
|
|
222
|
+
try {
|
|
223
|
+
const got = await connection.call('criteria.get', { swarm_id: swarm }, { signal: this.signal });
|
|
224
|
+
this.criteria.set(swarm, { status: short(got?.status || 'none', 20), version: Number(got?.current?.version) || 0, digest: /^[a-f0-9]{64}$/.test(String(got?.current?.digest || '')) ? got.current.digest : null, rounds: Number(got?.rounds) || 0, renegotiating: Boolean(got?.accepted), in_force: Number(got?.accepted?.version) || 0 });
|
|
225
|
+
} catch (error) { if (!error.rpc) throw error; this.#error(`criteria:${swarm}`, error); }
|
|
226
|
+
}
|
|
227
|
+
async #plans(connection, swarm) {
|
|
228
|
+
const plans = await connection.call('plan.list', { swarm_id: swarm, status: 'pending' }, { signal: this.signal });
|
|
229
|
+
// Revisor (open-relay F3/F8): un plan que espera al revisor todavía no es decidible por el
|
|
230
|
+
// humano (relayd respondería REVIEW_PENDING): no hay aviso ni tarjeta hasta que lo revise.
|
|
231
|
+
const pending = (plans.proposals || []).filter(proposal => proposal.status === 'pending' && proposal.review?.status !== 'awaiting');
|
|
232
|
+
for (const proposal of pending) await this.#send(planNotice(swarm, proposal));
|
|
233
|
+
this.proposals.set(swarm, pending.slice(0, VIEW.plans).map(proposal => ({ proposal_id: short(proposal.proposal_id), proposer_role: short(proposal.proposer_role), parent_task: proposal.parent_task ? short(proposal.parent_task) : null, summary: excerpt(proposal.summary || '', VIEW.excerpt), proposed_at: stamp(proposal.proposed_at), tasks: Number(proposal.tasks) || 0, roles: Number(proposal.roles) || 0 })));
|
|
121
234
|
}
|
|
122
|
-
async #jobs() {
|
|
123
|
-
const result = await
|
|
235
|
+
async #jobs(connection) {
|
|
236
|
+
const result = await connection.call('job.list', {}, { signal: this.signal });
|
|
124
237
|
if (!result.available) return;
|
|
125
238
|
for (const job of result.jobs || []) { const alert = jobAlert(job); if (alert) await this.#send(alert); }
|
|
126
239
|
}
|
package/src/plugins.mjs
CHANGED
|
@@ -33,6 +33,7 @@ export class PluginRuntime {
|
|
|
33
33
|
#services = new Map();
|
|
34
34
|
#queue = Promise.resolve();
|
|
35
35
|
#closing = false;
|
|
36
|
+
#serviceWatchers = new Set();
|
|
36
37
|
// notify(notice): el host encola avisos de plugins (plugin-notices.mjs); sin él, ctx.notify responde unavailable.
|
|
37
38
|
constructor({ cwd, house, services = {}, configPath, onEvent = () => {}, notify }) {
|
|
38
39
|
Object.assign(this, { cwd, house, onEvent, notify });
|
|
@@ -41,7 +42,16 @@ export class PluginRuntime {
|
|
|
41
42
|
}
|
|
42
43
|
async #notify(notice) { if (typeof this.notify !== 'function') return { status: 'unavailable' }; try { return await this.notify(notice); } catch (error) { return { status: 'failed', error: String(error.message).slice(0, 200) }; } }
|
|
43
44
|
#exclusive(task) { const next = this.#queue.catch(() => {}).then(task); this.#queue = next; return next; }
|
|
44
|
-
async #event(event) { try { await this.onEvent({ type: 'plugin_status', ...event }); } catch {} }
|
|
45
|
+
async #event(event) { try { await this.onEvent({ type: 'plugin_status', ...event }); } catch {} this.#servicesChanged(); }
|
|
46
|
+
#servicesChanged() { for (const listener of [...this.#serviceWatchers]) try { listener(); } catch {} }
|
|
47
|
+
/**
|
|
48
|
+
* Lectura del HOST de un servicio publicado (ctx.provide), p. ej. para un panel de la TUI.
|
|
49
|
+
* Devuelve { owner, value } o null; el host debe comprobar el dueño y tratar los datos
|
|
50
|
+
* como no confiables. No concede nada al plugin ni al modelo.
|
|
51
|
+
*/
|
|
52
|
+
service(name) { const entry = this.#services.get(name); return entry ? Object.freeze({ owner: entry.owner, value: entry.value }) : null; }
|
|
53
|
+
/** Avisa (sin argumentos) cuando cambia el estado de los plugins o sus servicios. Devuelve la baja. */
|
|
54
|
+
watchServices(listener) { if (typeof listener !== 'function') throw new TypeError('watchServices requires a function.'); this.#serviceWatchers.add(listener); return () => this.#serviceWatchers.delete(listener); }
|
|
45
55
|
#definition(definition) {
|
|
46
56
|
if (!definition || !idPattern.test(definition.id) || definition.id.length > 24 || typeof definition.version !== 'string' || !definition.version || typeof definition.activate !== 'function') throw new Error('Manifiesto de plugin inválido: requiere id, version y activate.');
|
|
47
57
|
const names = list => { if (!Array.isArray(list) || list.length > 32 || list.some(name => typeof name !== 'string' || !name || name.length > 128) || new Set(list).size !== list.length) throw new Error('requires/provides de plugin inválidos.'); return [...list]; };
|
|
@@ -309,6 +319,7 @@ export class PluginRuntime {
|
|
|
309
319
|
this.house.registry.replace(scopes.flatMap(scope => [...scope.operations.keys()]), []);
|
|
310
320
|
for (const scope of scopes) { this.#known.get(scope.id).state = 'disabled'; try { await this.#retire(scope); } catch (error) { errors.push(error); } }
|
|
311
321
|
for (const [name, service] of this.#services) if (service.owner !== 'host') this.#services.delete(name);
|
|
322
|
+
this.#servicesChanged(); this.#serviceWatchers.clear();
|
|
312
323
|
if (errors.length) throw new AggregateError(errors, 'Falló limpieza de plugins.');
|
|
313
324
|
});
|
|
314
325
|
}
|
package/src/process-lock.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { constants, readFileSync, readlinkSync } from 'node:fs';
|
|
2
|
-
import { open, lstat, unlink } from 'node:fs/promises';
|
|
2
|
+
import { open, lstat, unlink, link } from 'node:fs/promises';
|
|
3
3
|
import { hostname } from 'node:os';
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
5
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
@@ -18,6 +18,7 @@ export function processDead(owner) {
|
|
|
18
18
|
const current=start(owner.pid);
|
|
19
19
|
return Boolean(owner.start && current && owner.start!==current);
|
|
20
20
|
}
|
|
21
|
+
const STALE_EMPTY_MS = 30000;
|
|
21
22
|
const fail = (message,code='SESSION_IN_USE') => Object.assign(new Error(message),{code});
|
|
22
23
|
async function inspect(name,fault=async()=>{}) {
|
|
23
24
|
const named=await lstat(name,{bigint:true});if(named.isSymbolicLink()||!named.isFile())throw fail('Unsafe lock type; no lock was removed.','LOCK_UNSAFE');
|
|
@@ -34,6 +35,9 @@ async function inspect(name,fault=async()=>{}) {
|
|
|
34
35
|
if(!await same(name,info))throw fail('Lock changed during private inspection; no owner was assumed.','LOCK_CHANGED');
|
|
35
36
|
const bytes=Buffer.alloc(4097),{bytesRead}=await file.read(bytes,0,bytes.length,0);if(bytesRead>4096)throw fail('Lock exceeds its owner-record bound.','LOCK_UNSAFE');
|
|
36
37
|
const text=bytes.subarray(0,bytesRead).toString('utf8').trim();let owner;
|
|
38
|
+
// An empty lock is a writer that died between creating the file and writing its owner
|
|
39
|
+
// (older Comal created first and wrote later). Fresh, it may still be mid-write; stale, nobody owns it.
|
|
40
|
+
if(!text && Date.now()-Number(info.mtimeMs)>STALE_EMPTY_MS){if(!await same(name,info))throw fail('Lock changed while reading its owner; no owner was assumed.','LOCK_CHANGED');return {info,owner:{empty:true}};}
|
|
37
41
|
try{owner=JSON.parse(text);}catch{throw fail('Lock sin propietario verificable; no se retiró.','LOCK_UNKNOWN');}
|
|
38
42
|
if(typeof owner==='number')owner={pid:owner}; // Legacy PID: only ESRCH proves death.
|
|
39
43
|
if(!owner || !Number.isSafeInteger(owner.pid) || owner.pid<=0)throw fail('Lock sin propietario verificable; no se retiró.','LOCK_UNKNOWN');
|
|
@@ -43,39 +47,43 @@ async function inspect(name,fault=async()=>{}) {
|
|
|
43
47
|
}
|
|
44
48
|
async function same(name,info){try{const current=await lstat(name,{bigint:true});return !current.isSymbolicLink() && current.ino===info.ino && current.dev===info.dev;}catch(e){if(e.code==='ENOENT')return false;throw e;}}
|
|
45
49
|
|
|
50
|
+
// Claim `name` with its owner record already written: the record goes to a private temporary
|
|
51
|
+
// file that is then hard-linked into place, so the lock path never exists empty. null if held.
|
|
52
|
+
async function claim(name,owner){
|
|
53
|
+
const temp=`${name}.${owner.token}.tmp`,file=await open(temp,'wx',0o600);
|
|
54
|
+
try{await file.writeFile(JSON.stringify(owner));await file.sync();await link(temp,name);}
|
|
55
|
+
catch(error){await file.close();await unlink(temp).catch(()=>{});if(error.code==='EEXIST')return null;throw error;}
|
|
56
|
+
await unlink(temp);return file;
|
|
57
|
+
}
|
|
58
|
+
|
|
46
59
|
// Only this exclusion gate may reclaim a dead owner. It is never automatically
|
|
47
60
|
// reclaimed itself: two reapers must not unlink a replacement lock concurrently.
|
|
48
61
|
export async function acquireProcessLock(name,{waitMs=0,validate=async()=>{},resource,fault=async()=>{}}={}) {
|
|
49
62
|
const until=Date.now()+waitMs;
|
|
50
63
|
for(;;){
|
|
51
|
-
let
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if(error.code!=='EEXIST')throw error;
|
|
64
|
+
let recovered;const owner={...processIdentity,token:randomUUID(),...(resource?{resource:structuredClone(resource)}:{})};
|
|
65
|
+
let file=await claim(name,owner);
|
|
66
|
+
if(!file){
|
|
55
67
|
let old;
|
|
56
68
|
try{old=await inspect(name,fault);}catch(e){if(e.code==='ENOENT')continue;if(['LOCK_UNKNOWN','LOCK_CHANGED'].includes(e.code)&&Date.now()<until){await delay(10);continue;}if(e.code==='LOCK_CHANGED')throw fail('The lock changed during observation; no owner was assumed or removed.');throw e;}
|
|
57
|
-
if(processDead(old.owner)){
|
|
69
|
+
if(old.owner.empty || processDead(old.owner)){
|
|
58
70
|
let gate;
|
|
59
71
|
try{gate=await open(`${name}.reclaim`,'wx',0o600);}
|
|
60
72
|
catch(e){if(e.code!=='EEXIST')throw e;if(Date.now()>=until)throw fail('Recuperación de lock en uso o interrumpida; no se retiró.','LOCK_RECOVERY_BUSY');}
|
|
61
73
|
if(gate)try{
|
|
62
74
|
await gate.writeFile(JSON.stringify(processIdentity));await gate.sync();
|
|
63
75
|
const current=await inspect(name,fault).catch(e=>{if(['ENOENT','LOCK_CHANGED'].includes(e.code))return null;throw e;});
|
|
64
|
-
if(current && processDead(current.owner) && await same(name,current.info)){
|
|
76
|
+
if(current && (current.owner.empty || processDead(current.owner)) && await same(name,current.info)){
|
|
65
77
|
await validate(current.owner);
|
|
66
78
|
if(await same(name,current.info)){
|
|
67
79
|
await unlink(name);
|
|
68
|
-
|
|
69
|
-
catch(e){if(e.code!=='EEXIST')throw e;}
|
|
80
|
+
file=await claim(name,owner);if(file)recovered={path:name,owner:current.owner};
|
|
70
81
|
}
|
|
71
82
|
}
|
|
72
83
|
} finally{const info=await gate.stat({bigint:true});if(await same(`${name}.reclaim`,info))await unlink(`${name}.reclaim`);await gate.close();}
|
|
73
84
|
}
|
|
74
85
|
if(!file){if(Date.now()>=until)throw fail('Sesión en uso o propietario no recuperable; no se retiró el lock.');await delay(10);continue;}
|
|
75
86
|
}
|
|
76
|
-
const owner={...processIdentity,token:randomUUID(),...(resource?{resource:structuredClone(resource)}:{})};
|
|
77
|
-
try{await file.writeFile(JSON.stringify(owner));await file.sync();}
|
|
78
|
-
catch(error){const info=await file.stat({bigint:true});if(await same(name,info))await unlink(name);await file.close();throw error;}
|
|
79
87
|
return {file,recovered,release:async()=>{const info=await file.stat({bigint:true});try{if(await same(name,info))await unlink(name);}finally{await file.close();}}};
|
|
80
88
|
}
|
|
81
89
|
}
|
package/src/provider-auth.mjs
CHANGED
|
@@ -13,14 +13,17 @@ export const AUTH_PROVIDERS = Object.freeze([
|
|
|
13
13
|
{ id: 'grok', label: 'Grok / xAI API', method: 'api_key', subscription: false, protocol: 'openai', baseURL: 'https://api.x.ai/v1', apiKeyEnv: 'XAI_API_KEY', documentation: 'https://docs.x.ai/overview' },
|
|
14
14
|
// Ollama Cloud expone una API compatible con OpenAI en /v1 y /api/show para
|
|
15
15
|
// descubrir la ventana. La llave sólo viaja en el header hacia ese origen.
|
|
16
|
-
{ id: 'ollama', label: 'Ollama Cloud', method: 'api_key', subscription: false, protocol: 'openai', baseURL: 'https://ollama.com/', apiKeyEnv: 'OLLAMA_API_KEY', documentation: 'https://docs.ollama.com/cloud' }
|
|
16
|
+
{ id: 'ollama', label: 'Ollama Cloud', method: 'api_key', subscription: false, protocol: 'openai', baseURL: 'https://ollama.com/', apiKeyEnv: 'OLLAMA_API_KEY', documentation: 'https://docs.ollama.com/cloud' },
|
|
17
|
+
// Anthropic API (Claude): llave de la Console, facturación por API. /v1/models lista
|
|
18
|
+
// los modelos y /v1/models/{id} anuncia ventana, salida máxima y niveles de effort.
|
|
19
|
+
{ id: 'anthropic', label: 'Anthropic API (Claude)', method: 'api_key', subscription: false, protocol: 'anthropic', baseURL: 'https://api.anthropic.com', model: 'claude-sonnet-5-5', apiKeyEnv: 'ANTHROPIC_API_KEY', documentation: 'https://docs.claude.com/en/api/overview' }
|
|
17
20
|
].map(Object.freeze));
|
|
18
|
-
const ALIASES = Object.freeze({ grokia: 'grok', xai: 'grok', chatgpt: 'codex', 'kimi-code': 'kimi', 'ollama-cloud': 'ollama' });
|
|
21
|
+
const ALIASES = Object.freeze({ grokia: 'grok', xai: 'grok', chatgpt: 'codex', 'kimi-code': 'kimi', 'ollama-cloud': 'ollama', claude: 'anthropic' });
|
|
19
22
|
const failure = (message, code = 'PROVIDER_AUTH_FAILED') => Object.assign(new Error(message), { code });
|
|
20
23
|
function definition(name) {
|
|
21
24
|
const id = String(name || '').toLowerCase();
|
|
22
25
|
const found = AUTH_PROVIDERS.find(item => item.id === (Object.hasOwn(ALIASES, id) ? ALIASES[id] : id));
|
|
23
|
-
if (!found) throw failure('Unknown login provider: codex, kimi, minimax, grok or
|
|
26
|
+
if (!found) throw failure('Unknown login provider: codex, kimi, minimax, grok, ollama or anthropic.', 'PROVIDER_AUTH_UNKNOWN');
|
|
24
27
|
return found;
|
|
25
28
|
}
|
|
26
29
|
export function authProviderDefinition(name) { return definition(name); }
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { registerSecretEnv } from './child-env.mjs';
|
|
1
2
|
import { readFile } from 'node:fs/promises';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { OpenAIProvider, DEFAULT_LOCAL_URL } from './openai-provider.mjs';
|
|
@@ -5,11 +6,12 @@ import { AnthropicProvider } from './provider.mjs';
|
|
|
5
6
|
import { GeminiProvider } from './gemini-provider.mjs';
|
|
6
7
|
import { authProviderDefaults } from './provider-auth.mjs';
|
|
7
8
|
import { normalizeEffort } from './model-controls.mjs';
|
|
9
|
+
import { normalizeFallbackSetting } from './provider.mjs';
|
|
8
10
|
import {normalizeToolSchemaCapabilities} from './tool-schema.mjs';
|
|
9
11
|
|
|
10
12
|
export const PROVIDER_CAPABILITIES = Object.freeze(['text', 'tools', 'thinking', 'vision', 'video', 'web_search']);
|
|
11
13
|
const PROTOCOLS = ['openai', 'anthropic', 'gemini'];
|
|
12
|
-
const KEYS = new Set(['name', 'provider', 'protocol', 'model', 'baseURL', 'apiKeyEnv', 'capabilities', 'capabilitiesConfigured', 'description', 'effort', 'authProvider','toolSchema']);
|
|
14
|
+
const KEYS = new Set(['name', 'provider', 'protocol', 'model', 'baseURL', 'apiKeyEnv', 'capabilities', 'capabilitiesConfigured', 'description', 'effort', 'authProvider','toolSchema', 'maxTokens', 'contextWindow', 'fallback']);
|
|
13
15
|
const error = message => Object.assign(new Error(message), { code: 'PROVIDER_PROFILE_INVALID' });
|
|
14
16
|
|
|
15
17
|
export function normalizeCapabilities(value = {}, { protocol = 'openai' } = {}) {
|
|
@@ -31,6 +33,7 @@ export function normalizeProviderProfile(input, name = input?.name) {
|
|
|
31
33
|
if (input.name && input.name !== name) throw error('Conflicting profile name.');
|
|
32
34
|
if (input.authProvider !== undefined && !['codex', 'kimi', 'minimax', 'grok'].includes(input.authProvider)) throw error('Invalid authProvider.');
|
|
33
35
|
if (input.effort !== undefined) normalizeEffort(input.effort);
|
|
36
|
+
if (input.fallback !== undefined) normalizeFallbackSetting(input.fallback);
|
|
34
37
|
const toolSchema=input.toolSchema===undefined?undefined:normalizeToolSchemaCapabilities(input.toolSchema);
|
|
35
38
|
const access=input.authProvider?authProviderDefaults(input.authProvider):null;
|
|
36
39
|
const configuredCapabilities = input.capabilitiesConfigured || input.capabilities || {};
|
|
@@ -47,11 +50,17 @@ export function normalizeProviderProfile(input, name = input?.name) {
|
|
|
47
50
|
}
|
|
48
51
|
if (input.apiKeyEnv !== undefined && (typeof input.apiKeyEnv !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(input.apiKeyEnv))) throw error('apiKeyEnv must name an environment variable.');
|
|
49
52
|
if (input.description !== undefined && (typeof input.description !== 'string' || input.description.length > 1024)) throw error('Invalid description.');
|
|
53
|
+
// Output and window budgets are startup defaults for this endpoint; --max-tokens,
|
|
54
|
+
// --context-window, their variables and connection.json still win.
|
|
55
|
+
if (input.maxTokens !== undefined && (!Number.isSafeInteger(input.maxTokens) || input.maxTokens < 64 || input.maxTokens > 1000000)) throw error('maxTokens must be an integer between 64 and 1000000.');
|
|
56
|
+
if (input.contextWindow !== undefined && (!Number.isSafeInteger(input.contextWindow) || input.contextWindow < 4096 || input.contextWindow > 2000000)) throw error('contextWindow must be an integer between 4096 and 2000000.');
|
|
57
|
+
if (input.maxTokens !== undefined && input.contextWindow !== undefined && input.contextWindow < input.maxTokens * 2) throw error('contextWindow must be at least twice maxTokens.');
|
|
50
58
|
return Object.freeze({ name, provider: protocol, protocol, ...((input.model || access?.model) ? { model: input.model || access.model } : {}),
|
|
51
59
|
baseURL: input.baseURL || access?.baseURL || ({ openai: DEFAULT_LOCAL_URL, anthropic: 'https://api.anthropic.com', gemini: 'https://generativelanguage.googleapis.com/v1beta' })[protocol],
|
|
52
60
|
apiKeyEnv: input.apiKeyEnv || ({ openai: 'OPENAI_API_KEY', anthropic: 'ANTHROPIC_API_KEY', gemini: 'GEMINI_API_KEY' })[protocol],
|
|
53
61
|
capabilities: normalizeCapabilities(input.capabilities, { protocol }), capabilitiesConfigured: Object.freeze({ ...configuredCapabilities }),...(toolSchema?{toolSchema:Object.freeze(toolSchema)}:{}),
|
|
54
|
-
...(input.effort !== undefined ? { effort: input.effort } : {}), ...(input.authProvider ? { authProvider: input.authProvider } : {}), ...(input.description ? { description: input.description } : {})
|
|
62
|
+
...(input.effort !== undefined ? { effort: input.effort } : {}), ...(input.authProvider ? { authProvider: input.authProvider } : {}), ...(input.description ? { description: input.description } : {}),
|
|
63
|
+
...(input.maxTokens !== undefined ? { maxTokens: input.maxTokens } : {}), ...(input.contextWindow !== undefined ? { contextWindow: input.contextWindow } : {}), ...(input.fallback !== undefined ? { fallback: input.fallback } : {}) });
|
|
55
64
|
}
|
|
56
65
|
|
|
57
66
|
// Configuration is data only. Secrets are resolved in createProfileProvider and
|
|
@@ -92,11 +101,12 @@ export function selectProfile(registry, name) {
|
|
|
92
101
|
|
|
93
102
|
export function createProfileProvider(profile, { env = process.env, ...overrides } = {}) {
|
|
94
103
|
profile = normalizeProviderProfile(profile);
|
|
104
|
+
registerSecretEnv(profile.apiKeyEnv);
|
|
95
105
|
const capabilities = { ...profile.capabilities };
|
|
96
106
|
if (overrides.vision === 'on') capabilities.vision = true;
|
|
97
107
|
else if (overrides.vision === 'off') capabilities.vision = false;
|
|
98
108
|
const options = { ...overrides, apiKey: overrides.apiKey ?? env[profile.apiKeyEnv], model: overrides.model || profile.model, baseURL: overrides.baseURL || profile.baseURL,
|
|
99
|
-
capabilities, capabilitiesConfigured: profile.capabilitiesConfigured, effort: overrides.effort ?? profile.effort ?? 'auto', vision: overrides.vision || (Object.hasOwn(profile.capabilitiesConfigured, 'vision') ? capabilities.vision ? 'on' : 'off' : 'auto') };
|
|
109
|
+
capabilities, capabilitiesConfigured: profile.capabilitiesConfigured, effort: overrides.effort ?? profile.effort ?? 'auto', fallback: overrides.fallback ?? profile.fallback ?? 'default', vision: overrides.vision || (Object.hasOwn(profile.capabilitiesConfigured, 'vision') ? capabilities.vision ? 'on' : 'off' : 'auto') };
|
|
100
110
|
if (profile.protocol !== 'openai' && !options.apiKey) throw new Error(`Set the profile apiKeyEnv variable ${profile.apiKeyEnv}.`);
|
|
101
111
|
const provider = profile.protocol === 'gemini' ? new GeminiProvider(options) : profile.protocol === 'anthropic' ? new AnthropicProvider(options) : new OpenAIProvider(options);
|
|
102
112
|
const complete = provider.complete.bind(provider);
|