@milpa/comal 0.2.2 → 0.2.4
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-device-v1.json +423 -0
- package/backend/contracts/remote-relay-answers-v1.json +209 -0
- package/package.json +4 -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 +261 -62
- 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 +38 -1
- 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 +97 -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/open-relay/actions.mjs +60 -9
- package/src/plugins/open-relay/client.mjs +3 -3
- package/src/plugins/open-relay/decision.mjs +3 -2
- package/src/plugins/open-relay/index.mjs +17 -7
- package/src/plugins/open-relay/lookup.mjs +41 -4
- package/src/plugins/open-relay/notices.mjs +44 -3
- package/src/plugins/open-relay/panel.mjs +4 -1
- package/src/plugins/open-relay/watch.mjs +27 -8
- 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/qr-scan.mjs +240 -0
- package/src/read-only-check.mjs +6 -1
- package/src/relay/broker.mjs +173 -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 +283 -15
- package/src/remote-device-card.mjs +111 -0
- package/src/remote-device.mjs +92 -0
- package/src/remote-permissions.mjs +159 -0
- package/src/remote-relay.mjs +468 -0
- package/src/remote-stats.mjs +205 -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/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/controller.mjs +4 -1
- package/src/tui/discovery.mjs +17 -7
- package/src/tui/inbound-blocks.mjs +5 -1
- package/src/tui/screen.mjs +53 -14
- package/src/tui/swarm-panel.mjs +19 -8
- 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,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);
|
package/src/provider.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { normalizeUsage, bindUsageAccounting, accountingIdentity } from './usage
|
|
|
3
3
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
4
4
|
import { visionSettings } from './vision.mjs';
|
|
5
5
|
import { normalizeEffort, providerEffortWire } from './model-controls.mjs';
|
|
6
|
+
import { modelDeadline, modelTimingSettings } from './model-deadline.mjs';
|
|
6
7
|
|
|
7
8
|
export function mergeMessages(messages) {
|
|
8
9
|
const merged = [];
|
|
@@ -62,37 +63,164 @@ function truncationDetails(content = []) {
|
|
|
62
63
|
...(last?.type === 'tool_use' && typeof last.name === 'string' && last.name ? { tool_name: last.name } : {}) };
|
|
63
64
|
}
|
|
64
65
|
|
|
65
|
-
|
|
66
|
+
const clip = (value, max = 300) => typeof value === 'string' ? (value.length > max ? value.slice(0, max) + '…' : value) : undefined;
|
|
67
|
+
|
|
68
|
+
// Forma del request sin contenido (roles, tipos de bloque, breakpoints) para
|
|
69
|
+
// diagnosticar un 400. Nunca cabeceras, texto ni argumentos.
|
|
70
|
+
export function requestShape(wire = {}) {
|
|
71
|
+
const marks = blocks => Array.isArray(blocks) ? blocks.filter(block => block?.cache_control).length : 0;
|
|
72
|
+
const describe = block => `${block?.type}${block?.cache_control ? '*' : ''}${block?.type === 'thinking' ? (block.signature ? '+sig' : '-sig') : ''}${block?.type === 'text' && !block.text ? '(empty)' : ''}`;
|
|
73
|
+
const messages = (wire.messages || []).map(message => `${message.role?.[0]}:${Array.isArray(message.content) ? message.content.map(describe).join(',') : 'string'}`);
|
|
74
|
+
return { cache_breakpoints: marks(wire.tools) + marks(Array.isArray(wire.system) ? wire.system : []) + (wire.messages || []).reduce((sum, message) => sum + marks(message.content), 0),
|
|
75
|
+
messages: messages.length > 24 ? [...messages.slice(0, 4), `…${messages.length - 14} more…`, ...messages.slice(-10)] : messages };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Conserva el cuerpo de error de la API (status, error.type, error.message) en
|
|
79
|
+
// vez de un mensaje genérico. Las cabeceras del request nunca se registran.
|
|
80
|
+
export async function providerHttpError(response, label = 'Anthropic', wire) {
|
|
81
|
+
let body = null;
|
|
82
|
+
try { const text = await response.text(); try { body = JSON.parse(text); } catch { body = { error: { type: 'non_json_body', message: clip(text, 200) } }; } } catch {}
|
|
83
|
+
const errorType = typeof body?.error?.type === 'string' ? body.error.type : 'unknown', apiMessage = clip(body?.error?.message) || '';
|
|
84
|
+
const requestId = response.headers?.get?.('request-id') || (typeof body?.request_id === 'string' ? body.request_id : null);
|
|
85
|
+
return Object.assign(new Error(`${label} HTTP ${response.status} ${errorType}: ${apiMessage || 'no message'}`), { code: 'PROVIDER_HTTP_ERROR', status: response.status,
|
|
86
|
+
details: { status: response.status, error_type: errorType, error_message: apiMessage, ...(requestId ? { request_id: clip(requestId, 80) } : {}), ...(wire ? { request_shape: requestShape(wire) } : {}) } });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function invalidToolCall(reason, index, block, message, trace) {
|
|
90
|
+
const name = typeof block?.name === 'string' ? clip(block.name, 64) : undefined;
|
|
91
|
+
return Object.assign(new Error(`Invalid tool call: block ${index}${name ? ` (${name})` : ''} ${reason}.`), { code: 'TOOL_CALL_INVALID', usage: message.usage, usage_accounting: normalizeUsage('anthropic', message.usage),
|
|
92
|
+
details: { block_index: index, block_type: block?.type, ...(name ? { tool_name: name } : {}), reason, ...(block?.truncated_input_chars !== undefined ? { input_chars: block.truncated_input_chars } : {}),
|
|
93
|
+
...(block?.input_parse_error ? { parse_error: block.input_parse_error } : {}), stop_reason: message.stop_reason, ...(trace ? { stream_blocks: trace } : {}) } });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function validateMessage(message, trace) {
|
|
66
97
|
if (!message || !Array.isArray(message.content) || !message.stop_reason) throw new Error('Incomplete model response.');
|
|
67
98
|
if(message.stop_reason==='max_tokens')throw Object.assign(new Error('Anthropic response truncated; no tools were executed.'),{code:'OUTPUT_LENGTH',usage:message.usage,usage_accounting:normalizeUsage('anthropic',message.usage),details:truncationDetails(message.content)});
|
|
68
99
|
const ids = new Set();
|
|
69
|
-
for (const block of message.content) {
|
|
100
|
+
for (const [index, block] of message.content.entries()) {
|
|
70
101
|
if (!block || typeof block.type !== 'string') throw new Error('Invalid content block.');
|
|
71
102
|
if (block.type === 'tool_use') {
|
|
72
|
-
|
|
103
|
+
const reason = typeof block.id !== 'string' || !block.id ? 'has no id' : ids.has(block.id) ? 'repeats an id' : typeof block.name !== 'string' || !block.name ? 'has no name'
|
|
104
|
+
: block.input_parse_error ? 'has unparseable input JSON' : !block.input || typeof block.input !== 'object' || Array.isArray(block.input) ? `has ${Array.isArray(block.input) ? 'an array' : block.input === null ? 'null' : typeof block.input} input` : null;
|
|
105
|
+
if (reason) throw invalidToolCall(reason, index, block, message, trace);
|
|
73
106
|
ids.add(block.id);
|
|
74
107
|
}
|
|
75
108
|
}
|
|
76
109
|
return message;
|
|
77
110
|
}
|
|
78
111
|
|
|
112
|
+
// Prompt caching explícito (válido en todos los modelos Claude): tres breakpoints
|
|
113
|
+
// como máximo, de lo más estable a lo más reciente. 1) la última herramienta
|
|
114
|
+
// (caché de tools), 2) el system (tools+system), 3) el último bloque cacheable
|
|
115
|
+
// del último mensaje (prefijo completo de la conversación). El turno siguiente
|
|
116
|
+
// lee hasta el breakpoint previo y escribe sólo lo nuevo. Bloques thinking no
|
|
117
|
+
// admiten cache_control; se busca hacia atrás el último que sí.
|
|
118
|
+
export const adaptiveThinkingModel = model => /^claude-(?:opus|sonnet|haiku)-5(?:[-.]|$)/.test(model || '');
|
|
119
|
+
// Modelos cuyos clasificadores enrutan a un fallback del lado del servidor por
|
|
120
|
+
// categoría de rechazo (forma "default"). Sólo hacia api.anthropic.com.
|
|
121
|
+
const FALLBACK_MODELS = /^claude-(?:opus-5-5|sonnet-5-5|opus-5|fable-5-1)$/;
|
|
122
|
+
export const fallbackSupportedModel = model => FALLBACK_MODELS.test(model || '');
|
|
123
|
+
export function normalizeFallbackSetting(value) {
|
|
124
|
+
const mode = String(value ?? 'default');
|
|
125
|
+
if (mode !== 'default' && mode !== 'off') throw new Error('--anthropic-fallback must be "default" or "off".');
|
|
126
|
+
return mode;
|
|
127
|
+
}
|
|
128
|
+
// Rechazo del clasificador: HTTP 200 con stop_reason "refusal". Se ramifica por
|
|
129
|
+
// stop_reason, nunca por stop_details (puede ser null); explanation no está garantizado.
|
|
130
|
+
export function refusalInfo(message) {
|
|
131
|
+
if (!message || message.stop_reason !== 'refusal') return null;
|
|
132
|
+
const details = message.stop_details && typeof message.stop_details === 'object' ? message.stop_details : null;
|
|
133
|
+
return { category: typeof details?.category === 'string' ? details.category : null, explanation: typeof details?.explanation === 'string' ? details.explanation : null };
|
|
134
|
+
}
|
|
135
|
+
// La señal de "quién respondió" es un fallback_message en usage.iterations, no el
|
|
136
|
+
// bloque (los turnos pegajosos no traen bloque). El model de nivel superior nombra
|
|
137
|
+
// al modelo que produjo el mensaje.
|
|
138
|
+
export function fallbackServed(message) {
|
|
139
|
+
const iterations = Array.isArray(message?.usage?.iterations) ? message.usage.iterations : [];
|
|
140
|
+
if (!iterations.some(entry => entry?.type === 'fallback_message')) return null;
|
|
141
|
+
const switches = Array.isArray(message?.content) ? message.content.filter(block => block?.type === 'fallback') : [];
|
|
142
|
+
return { served_by: message.model || null, switches: switches.map(block => ({ from: block.from?.model || null, to: block.to?.model || null })) };
|
|
143
|
+
}
|
|
144
|
+
// Reenvío de un turno con fallback a mitad de salida: se omiten los bloques
|
|
145
|
+
// thinking/redacted_thinking/tool_use ANTERIORES al último bloque fallback; el
|
|
146
|
+
// texto y todo lo posterior al límite viajan normal. El bloque fallback es una
|
|
147
|
+
// marca de auditoría ignorada (se conserva). Determinista por contenido: la
|
|
148
|
+
// historia sigue siendo append-only.
|
|
149
|
+
export function echoFallbackContent(content) {
|
|
150
|
+
if (!Array.isArray(content)) return content;
|
|
151
|
+
let boundary = -1;
|
|
152
|
+
content.forEach((block, index) => { if (block?.type === 'fallback') boundary = index; });
|
|
153
|
+
if (boundary < 0) return content;
|
|
154
|
+
return content.filter((block, index) => !(index < boundary && (block?.type === 'thinking' || block?.type === 'redacted_thinking' || block?.type === 'tool_use')));
|
|
155
|
+
}
|
|
156
|
+
// Un rechazo termina como rechazo, no como éxito vacío. Un fallback que respondió
|
|
157
|
+
// se marca para que el libro, el timing y la contabilidad lo vean.
|
|
158
|
+
export function annotateOutcome(accounted, message, onText) {
|
|
159
|
+
const refusal = refusalInfo(message);
|
|
160
|
+
const served = fallbackServed(message);
|
|
161
|
+
if (refusal) { accounted.refusal = { ...refusal, fallback_ran: Boolean(served), served_model: message.model || null }; return accounted; }
|
|
162
|
+
if (served) accounted.fallback = served;
|
|
163
|
+
if (onText) for (const block of accounted.content || []) if (block.type === 'text') onText(block.text);
|
|
164
|
+
return accounted;
|
|
165
|
+
}
|
|
166
|
+
const CACHE = Object.freeze({ type: 'ephemeral' });
|
|
167
|
+
const CACHEABLE = new Set(['text', 'image', 'tool_use', 'tool_result', 'document']);
|
|
168
|
+
// Los cache_control que traiga el llamador se descartan (también dentro de un
|
|
169
|
+
// tool_result): la API rechaza más de 4 breakpoints, y aquí se ponen 3 exactos.
|
|
170
|
+
// cache_control no es contenido; quitarlo o moverlo no edita bloques anteriores.
|
|
171
|
+
const uncached = block => {
|
|
172
|
+
if (!block || typeof block !== 'object') return block;
|
|
173
|
+
const { cache_control: ignored, ...rest } = block;
|
|
174
|
+
return Array.isArray(rest.content) ? { ...rest, content: rest.content.map(uncached) } : rest;
|
|
175
|
+
};
|
|
176
|
+
export function withCacheBreakpoints({ system, tools = [], messages = [] }) {
|
|
177
|
+
const plainTools = tools.map(uncached);
|
|
178
|
+
const cachedTools = plainTools.length ? [...plainTools.slice(0, -1), { ...plainTools.at(-1), cache_control: CACHE }] : plainTools;
|
|
179
|
+
const plainSystem = Array.isArray(system) ? system.map(uncached) : system;
|
|
180
|
+
const cachedSystem = typeof system === 'string' && system ? [{ type: 'text', text: system, cache_control: CACHE }]
|
|
181
|
+
: Array.isArray(plainSystem) && plainSystem.length ? [...plainSystem.slice(0, -1), { ...plainSystem.at(-1), cache_control: CACHE }] : plainSystem;
|
|
182
|
+
const cachedMessages = messages.map(message => ({ ...message, content: Array.isArray(message.content) ? message.content.map(uncached) : message.content }));
|
|
183
|
+
for (let m = cachedMessages.length - 1; m >= 0; m--) {
|
|
184
|
+
const content = cachedMessages[m].content;
|
|
185
|
+
if (!Array.isArray(content)) continue;
|
|
186
|
+
const index = content.findLastIndex(block => CACHEABLE.has(block?.type) && !(block.type === 'text' && !block.text));
|
|
187
|
+
if (index >= 0) { content[index] = { ...content[index], cache_control: CACHE }; break; }
|
|
188
|
+
}
|
|
189
|
+
return { system: cachedSystem, tools: cachedTools, messages: cachedMessages };
|
|
190
|
+
}
|
|
191
|
+
|
|
79
192
|
export class AnthropicProvider {
|
|
80
|
-
constructor({ apiKey = process.env.ANTHROPIC_API_KEY, baseURL = process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com', model = process.env.COMAL_MODEL || process.env.HARNESS_MODEL || 'claude-sonnet-
|
|
193
|
+
constructor({ apiKey = process.env.ANTHROPIC_API_KEY, baseURL = process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com', model = process.env.COMAL_MODEL || process.env.HARNESS_MODEL || 'claude-sonnet-5-5', maxTokens = 8192, effort = 'auto', capabilitiesConfigured = {}, vision = 'auto', imageTokens = 4096, timeoutMs, idleTimeoutMs, fallback = 'default', fetchImpl = fetch, promptCaching, thinkingDisplay = 'summarized' } = {}) {
|
|
81
194
|
if (!apiKey) throw new Error('Set ANTHROPIC_API_KEY or use --demo to try without credentials.');
|
|
82
195
|
const url = new URL(baseURL.replace(/\/$/, '') + '/v1/messages');
|
|
83
196
|
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) throw new Error('The provider requires HTTPS; HTTP is allowed only on localhost.');
|
|
84
197
|
if (url.username || url.password || url.search || url.hash) throw new Error('Invalid provider URL.');
|
|
85
198
|
this.protocol = 'anthropic'; this.effort = normalizeEffort(effort); this.capabilitiesConfigured = { ...capabilitiesConfigured }; this.configuredCapabilities = this.capabilitiesConfigured;
|
|
86
|
-
|
|
199
|
+
// Prompt caching por omisión sólo hacia la API de Anthropic: otros endpoints
|
|
200
|
+
// compatibles (MiniMax) pueden rechazar cache_control. promptCaching:true lo fuerza.
|
|
201
|
+
this.promptCaching = promptCaching ?? url.hostname === 'api.anthropic.com';
|
|
202
|
+
// Claude 5.x piensa de forma adaptativa y por omisión omite el texto del
|
|
203
|
+
// razonamiento (display: omitted). Se pide el resumen para que el libro y el
|
|
204
|
+
// progreso lo vean; nunca thinking.disabled ni budget_tokens.
|
|
205
|
+
this.thinkingDisplay = thinkingDisplay;
|
|
206
|
+
this.apiKey = apiKey; this.url = url; this.model = model; this.maxTokens = maxTokens; this.fetch = fetchImpl;
|
|
207
|
+
// Idle + total timeouts (shared with the other providers): abort only when no
|
|
208
|
+
// SSE bytes arrive for idleTimeoutMs, within a generous overall cap. Replaces
|
|
209
|
+
// the old fixed 90 s whole-request timeout that killed long high-effort turns.
|
|
210
|
+
Object.assign(this, modelTimingSettings({ timeoutMs, idleTimeoutMs }, {}));
|
|
211
|
+
this.setFallback(fallback);
|
|
87
212
|
Object.assign(this, visionSettings({ vision, imageTokens }, {}));
|
|
88
213
|
}
|
|
89
214
|
getVisionCapability() { if (this.vision === 'auto' && this.modelCapabilities && this.modelCapabilities.model === this.model && typeof this.modelCapabilities.capabilities.vision === 'boolean') return { supported: this.modelCapabilities.capabilities.vision, source: this.modelCapabilities.provenance.vision?.source || 'model_discovery', model: this.model, imageTokens: this.imageTokens }; return { supported: this.vision === 'on', source: this.vision === 'on' ? 'explicit_user_configuration' : this.vision === 'off' ? 'disabled' : 'server_capability_unknown', model: this.model, imageTokens: this.imageTokens }; }
|
|
90
215
|
getCapabilities() { return { text: true, tools: true, vision: this.vision === 'on', video: false, web_search: false, ...this.capabilitiesConfigured, ...(this.modelCapabilities && this.modelCapabilities.model === this.model ? Object.fromEntries(Object.entries(this.modelCapabilities.capabilities).filter(([,value]) => value !== null)) : {}), video: false, web_search: false }; }
|
|
91
216
|
analyzeMedia(request) { return analyzeImages(this, request); }
|
|
92
|
-
|
|
217
|
+
// Default form: on for supported models reached through api.anthropic.com; off
|
|
218
|
+
// otherwise. Keeps fallbackRequested so /config and profiles can persist intent.
|
|
219
|
+
setFallback(value) { this.fallbackRequested = normalizeFallbackSetting(value); this.fallbackEligible = this.fallbackRequested === 'default' && this.url.hostname === 'api.anthropic.com' && fallbackSupportedModel(this.model); this.fallback = this.fallbackEligible ? 'default' : 'off'; return this.fallback; }
|
|
220
|
+
async complete({ system, messages, tools = [], schemaCatalog = [], contextIdentity, signal, onText = () => {}, onProgress = () => {}, onReasoning = () => {}, onReasoningEnd = () => {}, onToolCall = () => {}, purpose = 'worker', maxTokens = this.maxTokens }) {
|
|
93
221
|
const accountingRequest={system,messages,tools,maxTokens}, identity={...accountingIdentity(this),projection_epoch:contextIdentity?.projection_epoch ?? null};
|
|
94
|
-
const schemaFrame=compileToolSchemas(tools,{provider:this.protocol,capabilities:{...(this.getCapabilities?.() || {}),...(this.toolSchemaCapabilities?{tool_schema:this.toolSchemaCapabilities}:{})},catalogue:schemaCatalog}); tools=schemaFrame.tools;
|
|
95
|
-
messages = messages.map(message => !Array.isArray(message.content) ? message : { ...message, content: message.content.flatMap(original => {
|
|
222
|
+
const schemaFrame=compileToolSchemas(tools,{provider:this.protocol,capabilities:{...(this.getCapabilities?.() || {}),...(this.toolSchemaCapabilities?{tool_schema:this.toolSchemaCapabilities}:{})},catalogue:schemaCatalog,hoistTopLevelCombinators:true}); tools=schemaFrame.tools;
|
|
223
|
+
messages = messages.map(message => !Array.isArray(message.content) ? message : { ...message, content: echoFallbackContent(message.content).flatMap(original => {
|
|
96
224
|
// Signatures belong to their issuing protocol. Preserve the durable book,
|
|
97
225
|
// but omit another provider's private reasoning from the Anthropic wire.
|
|
98
226
|
if (original.type === 'openai_reasoning' || original.type === 'thinking' && original.gemini_part) return [];
|
|
@@ -103,57 +231,74 @@ export class AnthropicProvider {
|
|
|
103
231
|
if (!this.getVisionCapability().supported) throw new Error('Vision is not enabled for this provider; no pixels were sent.');
|
|
104
232
|
return { type: 'image', source: block.source };
|
|
105
233
|
}) }).filter(message => !Array.isArray(message.content) || message.content.length);
|
|
106
|
-
const
|
|
234
|
+
const deadline = modelDeadline({ signal, timeoutMs: this.timeoutMs, idleTimeoutMs: this.idleTimeoutMs, purpose, provider: 'Anthropic' });
|
|
107
235
|
const effortWire = await providerEffortWire(this, { signal });
|
|
108
|
-
const
|
|
236
|
+
const wire = this.promptCaching ? withCacheBreakpoints({ system, tools, messages: mergeMessages(messages) }) : { system, tools, messages: mergeMessages(messages) };
|
|
237
|
+
const thinking = adaptiveThinkingModel(this.model) && this.thinkingDisplay ? { thinking: { type: 'adaptive', display: this.thinkingDisplay } } : {};
|
|
238
|
+
const body = JSON.stringify({ ...thinking, ...effortWire, model: this.model, max_tokens: maxTokens, system: wire.system, messages: wire.messages, ...(wire.tools.length ? { tools: wire.tools } : {}), stream: true, ...(this.fallback === 'default' ? { fallbacks: 'default' } : {}) });
|
|
109
239
|
const account=result=>({...result,usage_accounting:bindUsageAccounting(normalizeUsage('anthropic',result.usage),accountingRequest,identity,body),schema_diagnostics:schemaFrame.diagnostics,schema_manifest:schemaFrame.manifest});
|
|
110
|
-
const finish=result=>{try{return account(validateMessage(result));}catch(error){if(error.usage_accounting)error.usage_accounting=bindUsageAccounting(error.usage_accounting,accountingRequest,identity,body);throw error;}};
|
|
111
|
-
if (Buffer.byteLength(body) > 8 * 1024 * 1024) throw new Error('The context exceeds 8 MiB; start a new session with /clear.');
|
|
240
|
+
const finish=(result,trace)=>{try{return account(validateMessage(result,trace));}catch(error){if(error.usage_accounting)error.usage_accounting=bindUsageAccounting(error.usage_accounting,accountingRequest,identity,body);throw error;}};
|
|
112
241
|
let response;
|
|
242
|
+
try {
|
|
243
|
+
if (Buffer.byteLength(body) > 8 * 1024 * 1024) throw new Error('The context exceeds 8 MiB; start a new session with /clear.');
|
|
113
244
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
114
|
-
response = await this.fetch(this.url, { method: 'POST', headers: { 'content-type': 'application/json', 'x-api-key': this.apiKey, 'anthropic-version': '2023-06-01' }, body, signal:
|
|
245
|
+
response = await this.fetch(this.url, { method: 'POST', headers: { 'content-type': 'application/json', 'x-api-key': this.apiKey, 'anthropic-version': '2023-06-01', ...(this.fallback === 'default' ? { 'anthropic-beta': 'server-side-fallback-2026-07-01' } : {}) }, body, signal: deadline.signal });
|
|
115
246
|
if (![429, 529, 503].includes(response.status) || attempt === 2) break;
|
|
116
247
|
await response.body?.cancel();
|
|
117
248
|
const retry = Number(response.headers.get('retry-after'));
|
|
118
|
-
await delay(Number.isFinite(retry) && retry > 0 ? Math.min(retry * 1000, 5000) : 300 * 2 ** attempt, undefined, { signal:
|
|
119
|
-
}
|
|
120
|
-
if (!response.ok) {
|
|
121
|
-
await response.body?.cancel();
|
|
122
|
-
throw new Error(`API HTTP ${response.status}. Check the key, model, limits or endpoint.`);
|
|
249
|
+
await delay(Number.isFinite(retry) && retry > 0 ? Math.min(retry * 1000, 5000) : 300 * 2 ** attempt, undefined, { signal: deadline.signal });
|
|
123
250
|
}
|
|
251
|
+
if (!response.ok) throw await providerHttpError(response, 'Anthropic', wire);
|
|
124
252
|
if (!(response.headers.get('content-type') || '').includes('text/event-stream')) {
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
return
|
|
253
|
+
const raw = await response.json();
|
|
254
|
+
if (raw && raw.stop_reason === 'refusal') raw.content = [];
|
|
255
|
+
return annotateOutcome(finish(raw), raw, onText);
|
|
128
256
|
}
|
|
129
|
-
let message, stopped = false;
|
|
130
|
-
const blocks = new Map(), partial = new Map(), closed = new Set();
|
|
257
|
+
let message, stopped = false, textChars = 0, reasoningChars = 0, argumentChars = 0, lastProgress = 0;
|
|
258
|
+
const blocks = new Map(), partial = new Map(), closed = new Set(), started = Date.now();
|
|
259
|
+
// Esqueleto del stream (índice, tipo, deltas) para diagnosticar sin volcar contenido.
|
|
260
|
+
const trace = new Map(), traced = (index, field) => { const entry = trace.get(index); if (entry) entry[field] = (entry[field] || 0) + 1; };
|
|
261
|
+
const progress = (force = false) => { if (force || Date.now() - lastProgress >= 750) { lastProgress = Date.now(); onProgress({ text_chars: textChars, reasoning_chars: reasoningChars, tool_argument_chars: argumentChars, elapsed_ms: Date.now() - started, purpose }); } };
|
|
131
262
|
for await (const event of sseEvents(response.body)) {
|
|
132
|
-
|
|
263
|
+
deadline.touch();
|
|
264
|
+
if (event.type === 'error') throw Object.assign(new Error(`Anthropic streaming error ${event.error?.type || 'unknown'}: ${clip(event.error?.message) || 'no message'}`), { code: 'PROVIDER_STREAM_ERROR', details: { error_type: event.error?.type || 'unknown', error_message: clip(event.error?.message) || '' } });
|
|
133
265
|
if (event.type === 'message_start') { if (message) throw new Error('Duplicate message_start.'); message = structuredClone(event.message); }
|
|
134
266
|
else if (event.type === 'content_block_start') {
|
|
135
267
|
if (blocks.has(event.index)) throw new Error('Duplicate block.');
|
|
136
268
|
blocks.set(event.index, structuredClone(event.content_block));
|
|
269
|
+
if (trace.size < 64) trace.set(event.index, { index: event.index, type: event.content_block?.type, order: trace.size });
|
|
270
|
+
if (event.content_block?.type === 'tool_use') onToolCall(); else if (/thinking/.test(event.content_block?.type || '')) onReasoning();
|
|
137
271
|
} else if (event.type === 'content_block_delta') {
|
|
138
272
|
const block = blocks.get(event.index);
|
|
139
273
|
if (!block || closed.has(event.index)) throw new Error('Delta outside its block.');
|
|
140
274
|
const d = event.delta;
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
else if (d.type === '
|
|
275
|
+
traced(event.index, d?.type || 'unknown_delta');
|
|
276
|
+
if (d.type === 'text_delta') { block.text = (block.text || '') + d.text; textChars += d.text.length; onText(d.text); }
|
|
277
|
+
else if (d.type === 'input_json_delta') { partial.set(event.index, (partial.get(event.index) || '') + d.partial_json); argumentChars += d.partial_json.length; }
|
|
278
|
+
else if (d.type === 'thinking_delta') { block.thinking = (block.thinking || '') + d.thinking; reasoningChars += d.thinking.length; }
|
|
144
279
|
else if (d.type === 'signature_delta') block.signature = (block.signature || '') + d.signature;
|
|
145
280
|
} else if (event.type === 'content_block_stop') {
|
|
146
281
|
if (!blocks.has(event.index)) throw new Error('Unknown block closure.');
|
|
147
|
-
if (partial.has(event.index)) {
|
|
282
|
+
if (partial.has(event.index)) {
|
|
283
|
+
const block = blocks.get(event.index), raw = partial.get(event.index);
|
|
284
|
+
// Sin argumentos el stream puede traer partial_json vacío: se conserva el input de content_block_start.
|
|
285
|
+
if (raw.trim()) { try { block.input = JSON.parse(raw); } catch (error) { delete block.input; block.truncated_input_chars = raw.length; block.input_parse_error = clip(String(error.message).replace(/,? ".*$/s, ''), 120); } }
|
|
286
|
+
}
|
|
148
287
|
closed.add(event.index);
|
|
288
|
+
if (/thinking/.test(blocks.get(event.index).type || '')) onReasoningEnd();
|
|
149
289
|
} else if (event.type === 'message_delta') {
|
|
150
290
|
if (!message) throw new Error('Delta without a message.');
|
|
151
291
|
Object.assign(message, event.delta); message.usage = { ...message.usage, ...event.usage };
|
|
152
292
|
} else if (event.type === 'message_stop') stopped = true;
|
|
293
|
+
progress();
|
|
153
294
|
}
|
|
154
|
-
|
|
295
|
+
progress(true);
|
|
296
|
+
if (!stopped || !message || closed.size !== blocks.size) throw Object.assign(new Error('Streaming interrupted; no tools from this response were executed.'), { code: 'MODEL_STREAM_ERROR' });
|
|
155
297
|
message.content = [...blocks.entries()].sort((a, b) => a[0] - b[0]).map(([, block]) => block);
|
|
156
|
-
|
|
298
|
+
// Rechazo a mitad de stream: se descarta la salida parcial en vez de tratarla como completa.
|
|
299
|
+
if (message.stop_reason === 'refusal') message.content = [];
|
|
300
|
+
return annotateOutcome(finish(message, [...trace.values()]), message, null);
|
|
301
|
+
} catch (error) { throw deadline.error(error); } finally { deadline.close(); }
|
|
157
302
|
}
|
|
158
303
|
}
|
|
159
304
|
|
package/src/qr-scan.mjs
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// /rc device scan: reads the M5Stack's pairing QR with a local camera, only while the person asked for it, 30 s at most
|
|
2
|
+
// in total (finding the camera included). One ffmpeg stays open for the scan (so focus and exposure settle) and writes
|
|
3
|
+
// 2 frames a second into a private 0700 directory; each completed frame is decoded with zbarimg and deleted right
|
|
4
|
+
// after, and the directory goes at the end (and on process exit). Nothing is kept and nothing leaves this machine.
|
|
5
|
+
// Processes are spawned with argv arrays (no shell). `spawn`, `now`, `sleep`, `readdir` (of /dev) and `tmpRoot` are
|
|
6
|
+
// injectable so tests run without a camera.
|
|
7
|
+
import { spawn as nodeSpawn } from 'node:child_process';
|
|
8
|
+
import { rmSync } from 'node:fs';
|
|
9
|
+
import { chmod, lstat, mkdtemp, readdir as fsReaddir, rm } from 'node:fs/promises';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { t, registerMessages } from './i18n/index.mjs';
|
|
13
|
+
|
|
14
|
+
/** The QR on the device's pairing screen: this prefix plus the code as shown, uppercase Crockford XXXX-XXXX.
|
|
15
|
+
* It carries nothing secret (the device's poll token never goes in it). Same constant as
|
|
16
|
+
* devices/m5stack/lib/comal_core/pairing.h (PAIR_QR_PREFIX). */
|
|
17
|
+
export const PAIR_QR_PREFIX = 'comal-pair:v1:';
|
|
18
|
+
const PAIR_QR = /^comal-pair:v1:([0-9A-HJKMNP-TV-Z]{4}-[0-9A-HJKMNP-TV-Z]{4})$/;
|
|
19
|
+
const CAMERA = /^\/dev\/video[0-9]+$/;
|
|
20
|
+
export const SCAN_TIMEOUT_MS = 30000;
|
|
21
|
+
export const SCAN_INTERVAL_MS = 250; // how often the private directory is looked at for completed frames
|
|
22
|
+
const STREAM_SIZE = '1280x720';
|
|
23
|
+
const STREAM_FPS = 2;
|
|
24
|
+
const PROBE_TIMEOUT_MS = 5000;
|
|
25
|
+
const GRAB_TIMEOUT_MS = 10000;
|
|
26
|
+
const FRAME_MAX_BYTES = 32 * 1024 * 1024;
|
|
27
|
+
const FRAME = /^f[0-9]{6}\.png$/;
|
|
28
|
+
const DIR_PREFIX = 'comal-qr-';
|
|
29
|
+
const DIR_SHAPE = /^comal-qr-[A-Za-z0-9]{6}$/; // what mkdtemp makes of DIR_PREFIX: only these are swept
|
|
30
|
+
const STALE_MS = 120000; // a scan touches its directory every half second; one untouched for 2 min is left over
|
|
31
|
+
|
|
32
|
+
registerMessages({
|
|
33
|
+
'The camera must be a /dev/videoN device.': 'La cámara debe ser un dispositivo /dev/videoN.',
|
|
34
|
+
'COMAL_CAMERA must be a /dev/videoN device.': 'COMAL_CAMERA debe ser un dispositivo /dev/videoN.',
|
|
35
|
+
'No camera found (/dev/video*). Pass one with /rc device scan /dev/videoN, or type the code: /rc device XXXX-XXXX.': 'No se encontró una cámara (/dev/video*). Indica una con /rc device scan /dev/videoN, o escribe el código: /rc device XXXX-XXXX.',
|
|
36
|
+
'Scanning needs ffmpeg and zbarimg. Install the ffmpeg and zbar packages, or type the code: /rc device XXXX-XXXX.': 'Escanear necesita ffmpeg y zbarimg. Instala los paquetes ffmpeg y zbar, o escribe el código: /rc device XXXX-XXXX.',
|
|
37
|
+
'Could not read the camera {camera}: {reason}': 'No se pudo leer la cámara {camera}: {reason}',
|
|
38
|
+
'No pairing QR seen in {seconds} s. Bring the M5Stack 10-20 cm from the camera, so the QR fills about a third of the picture, and try again, or type the code: /rc device XXXX-XXXX.': 'No se vio un QR de emparejamiento en {seconds} s. Acerca el M5Stack a 10-20 cm de la cámara, que el QR ocupe un tercio de la imagen, e inténtalo de nuevo, o escribe el código: /rc device XXXX-XXXX.',
|
|
39
|
+
'QR scan cancelled; the camera is off.': 'Escaneo del QR cancelado; la cámara está apagada.',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/** zbarimg's stdout counts only when it is exactly one symbol: one matching line plus its newline. */
|
|
43
|
+
function onlyPairCode(stdout) {
|
|
44
|
+
const text = stdout.toString('utf8').replace(/\r?\n$/, '');
|
|
45
|
+
return /[\r\n]/.test(text) ? null : pairCodeFromQr(text);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The code in a pairing QR's text, or null for anything else (which is never echoed). */
|
|
49
|
+
export function pairCodeFromQr(text) {
|
|
50
|
+
const match = PAIR_QR.exec(typeof text === 'string' ? text : '');
|
|
51
|
+
return match ? match[1] : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const isCameraPath = value => typeof value === 'string' && CAMERA.test(value);
|
|
55
|
+
|
|
56
|
+
const failure = (code, message) => Object.assign(new Error(message), { code });
|
|
57
|
+
const aborted = () => Object.assign(new Error(t('QR scan cancelled; the camera is off.')), { name: 'AbortError', code: 'ABORT_ERR' });
|
|
58
|
+
const missingTools = () => failure('QR_SCAN_TOOLS', t('Scanning needs ffmpeg and zbarimg. Install the ffmpeg and zbar packages, or type the code: /rc device XXXX-XXXX.'));
|
|
59
|
+
const timedOut = seconds => failure('QR_SCAN_TIMEOUT', t('No pairing QR seen in {seconds} s. Bring the M5Stack 10-20 cm from the camera, so the QR fills about a third of the picture, and try again, or type the code: /rc device XXXX-XXXX.', { seconds: String(seconds) }));
|
|
60
|
+
const oneLine = text => String(text || '').split(/\r?\n/).map(line => line.trim()).find(Boolean)?.replace(/[\u0000-\u001f\u007f-\u009f]/g, '').slice(0, 200) || 'no frame';
|
|
61
|
+
|
|
62
|
+
/** Runs one process to its end: {code, stdout, stderr}. Killed on abort or after `timeoutMs`; ENOENT → missing tools. */
|
|
63
|
+
function run(spawn, cmd, args, { signal, timeoutMs = GRAB_TIMEOUT_MS } = {}) {
|
|
64
|
+
return new Promise((resolve, reject) => {
|
|
65
|
+
if (signal?.aborted) { reject(aborted()); return; }
|
|
66
|
+
let child;
|
|
67
|
+
try { child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], shell: false }); }
|
|
68
|
+
catch (error) { reject(error.code === 'ENOENT' ? missingTools() : error); return; }
|
|
69
|
+
const out = [], err = [];
|
|
70
|
+
let size = 0, errSize = 0, settled = false;
|
|
71
|
+
const settle = (fn, value) => { if (settled) return; settled = true; clearTimeout(timer); signal?.removeEventListener('abort', onAbort); fn(value); };
|
|
72
|
+
const kill = () => { try { child.kill('SIGKILL'); } catch {} };
|
|
73
|
+
const onAbort = () => { kill(); settle(reject, aborted()); };
|
|
74
|
+
const timer = setTimeout(() => { kill(); settle(resolve, { code: null, stdout: Buffer.alloc(0), stderr: 'timed out' }); }, timeoutMs);
|
|
75
|
+
timer.unref?.();
|
|
76
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
77
|
+
child.stdout?.on('data', chunk => { size += chunk.length; if (size > FRAME_MAX_BYTES) { kill(); settle(resolve, { code: null, stdout: Buffer.alloc(0), stderr: 'frame too large' }); } else out.push(chunk); });
|
|
78
|
+
child.stderr?.on('data', chunk => { if (errSize < 4096) { err.push(chunk); errSize += chunk.length; } });
|
|
79
|
+
child.on('error', error => settle(reject, error.code === 'ENOENT' ? missingTools() : error));
|
|
80
|
+
child.on('close', code => settle(resolve, { code, stdout: Buffer.concat(out), stderr: Buffer.concat(err).toString('utf8') }));
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const grabArgs = device => ['-hide_banner', '-loglevel', 'error', '-f', 'v4l2', '-i', device, '-frames:v', '1', '-f', 'image2pipe', '-vcodec', 'png', '-'];
|
|
85
|
+
const DECODE_ARGS = ['--raw', '-q', '-Sdisable', '-Sqrcode.enable'];
|
|
86
|
+
|
|
87
|
+
/** True when `v4l2-ctl --all` says this node itself captures video (Device Caps; Capabilities is the whole device's). */
|
|
88
|
+
function capturesVideo(text) {
|
|
89
|
+
const lines = String(text).split('\n');
|
|
90
|
+
const start = lines.findIndex(line => /^\s*Device Caps\s*:/.test(line));
|
|
91
|
+
if (start < 0) return false;
|
|
92
|
+
const indent = lines[start].match(/^\s*/)[0].length;
|
|
93
|
+
for (const line of lines.slice(start + 1)) {
|
|
94
|
+
if (!line.trim() || line.match(/^\s*/)[0].length <= indent) break;
|
|
95
|
+
if (/^\s*Video Capture(?: Multiplanar)?\s*$/.test(line)) return true;
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The camera: `camera` (argument) > env COMAL_CAMERA > the first /dev/videoN that v4l2-ctl reports as Video Capture
|
|
101
|
+
* (without v4l2-ctl: the first one ffmpeg can read a frame from). Anything that is not /dev/videoN is refused. */
|
|
102
|
+
export async function pickCamera({ camera, env = process.env, spawn = nodeSpawn, readdir = fsReaddir, signal, deadline = Infinity, now = Date.now, seconds = SCAN_TIMEOUT_MS / 1000 } = {}) {
|
|
103
|
+
if (camera !== undefined && camera !== null && camera !== '') {
|
|
104
|
+
if (!isCameraPath(camera)) throw failure('QR_SCAN_CAMERA_PATH', t('The camera must be a /dev/videoN device.'));
|
|
105
|
+
return camera;
|
|
106
|
+
}
|
|
107
|
+
const fromEnv = env?.COMAL_CAMERA?.trim();
|
|
108
|
+
if (fromEnv) {
|
|
109
|
+
if (!isCameraPath(fromEnv)) throw failure('QR_SCAN_CAMERA_PATH', t('COMAL_CAMERA must be a /dev/videoN device.'));
|
|
110
|
+
return fromEnv;
|
|
111
|
+
}
|
|
112
|
+
let names = [];
|
|
113
|
+
try { names = await readdir('/dev'); } catch {}
|
|
114
|
+
const candidates = names.filter(name => /^video[0-9]+$/.test(name)).sort((a, b) => Number(a.slice(5)) - Number(b.slice(5))).map(name => '/dev/' + name);
|
|
115
|
+
// Probing counts toward the scan's time: each probe gets what is left, and none starts past the deadline.
|
|
116
|
+
const left = limit => { const ms = Math.min(limit, deadline - now()); if (ms <= 0) throw timedOut(seconds); return ms; };
|
|
117
|
+
let haveV4l2 = true;
|
|
118
|
+
for (const device of candidates) {
|
|
119
|
+
if (haveV4l2) {
|
|
120
|
+
try {
|
|
121
|
+
const result = await run(spawn, 'v4l2-ctl', ['--device', device, '--all'], { signal, timeoutMs: left(PROBE_TIMEOUT_MS) });
|
|
122
|
+
if (result.code === 0 && capturesVideo(result.stdout.toString('utf8'))) return device;
|
|
123
|
+
continue;
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (error.name === 'AbortError' || error.code === 'QR_SCAN_TIMEOUT') throw error;
|
|
126
|
+
if (error.code !== 'QR_SCAN_TOOLS') continue;
|
|
127
|
+
haveV4l2 = false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const result = await run(spawn, 'ffmpeg', grabArgs(device), { signal, timeoutMs: left(GRAB_TIMEOUT_MS) });
|
|
131
|
+
if (result.code === 0 && result.stdout.length) return device;
|
|
132
|
+
}
|
|
133
|
+
throw failure('QR_SCAN_NO_CAMERA', t('No camera found (/dev/video*). Pass one with /rc device scan /dev/videoN, or type the code: /rc device XXXX-XXXX.'));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const abortableSleep = (ms, signal) => new Promise((resolve, reject) => {
|
|
137
|
+
if (signal?.aborted) { reject(aborted()); return; }
|
|
138
|
+
const timer = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve(); }, ms);
|
|
139
|
+
const onAbort = () => { clearTimeout(timer); reject(aborted()); };
|
|
140
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// How the camera is opened, in order, each tried when the one before ends without a frame: 1280x720 MJPEG (a UVC
|
|
144
|
+
// camera often has 1280x720 only as MJPEG and silently gives 640x480 raw frames for a bare -video_size), 1280x720 in
|
|
145
|
+
// its default format, then its own default size.
|
|
146
|
+
const STREAM_ATTEMPTS = [['-input_format', 'mjpeg', '-video_size', STREAM_SIZE], ['-video_size', STREAM_SIZE], []];
|
|
147
|
+
// `-t` (an input option) is the seconds left to the scan, rounded up: if Comal dies without its exit hook (SIGKILL, OOM,
|
|
148
|
+
// a crash) ffmpeg still ends itself, and turns the camera off, at the scan's deadline.
|
|
149
|
+
const streamArgs = (device, dir, attempt, seconds) => ['-hide_banner', '-loglevel', 'error', '-f', 'v4l2', ...STREAM_ATTEMPTS[attempt], '-t', String(seconds), '-i', device, '-vf', `fps=${STREAM_FPS}`, '-f', 'image2', path.join(dir, 'f%06d.png')];
|
|
150
|
+
|
|
151
|
+
/** The long-running ffmpeg of a scan: {child, exited, error, stderr, closed}. */
|
|
152
|
+
function startStream(spawn, args) {
|
|
153
|
+
const stream = { child: null, exited: false, error: null, stderr: '' };
|
|
154
|
+
stream.closed = new Promise(resolve => {
|
|
155
|
+
try { stream.child = spawn('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'], shell: false }); }
|
|
156
|
+
catch (error) { stream.exited = true; stream.error = error; resolve(); return; }
|
|
157
|
+
stream.child.stderr?.on('data', chunk => { if (stream.stderr.length < 4096) stream.stderr += chunk.toString('utf8'); });
|
|
158
|
+
stream.child.on('error', error => { stream.error = error; stream.exited = true; resolve(); });
|
|
159
|
+
stream.child.on('close', () => { stream.exited = true; resolve(); });
|
|
160
|
+
});
|
|
161
|
+
return stream;
|
|
162
|
+
}
|
|
163
|
+
/** Kills a stream and waits (briefly) until it is gone, so nothing writes into the directory being removed. */
|
|
164
|
+
async function stopStream(stream) {
|
|
165
|
+
if (!stream || stream.exited) return;
|
|
166
|
+
try { stream.child.kill('SIGKILL'); } catch {}
|
|
167
|
+
let timer;
|
|
168
|
+
await Promise.race([stream.closed, new Promise(resolve => { timer = setTimeout(resolve, 2000); timer.unref?.(); })]);
|
|
169
|
+
clearTimeout(timer);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Removes this user's own comal-qr-* directories in `root` that no scan has touched for a while (a killed process). */
|
|
173
|
+
async function sweepStale(root) {
|
|
174
|
+
let names = [];
|
|
175
|
+
try { names = await fsReaddir(root); } catch { return; }
|
|
176
|
+
const uid = process.getuid?.();
|
|
177
|
+
for (const name of names) {
|
|
178
|
+
if (!DIR_SHAPE.test(name)) continue;
|
|
179
|
+
try {
|
|
180
|
+
const info = await lstat(path.join(root, name));
|
|
181
|
+
if (!info.isDirectory() || (uid !== undefined && info.uid !== uid) || Date.now() - info.mtimeMs < STALE_MS) continue;
|
|
182
|
+
await rm(path.join(root, name), { recursive: true, force: true });
|
|
183
|
+
} catch {}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Scans for the pairing QR for up to `timeoutMs` in total, finding the camera included. Resolves {code, camera}. */
|
|
188
|
+
export async function scanPairingQr({ camera, env = process.env, timeoutMs = SCAN_TIMEOUT_MS, intervalMs = SCAN_INTERVAL_MS, signal, spawn = nodeSpawn, now = Date.now, sleep, readdir = fsReaddir, tmpRoot = os.tmpdir() } = {}) {
|
|
189
|
+
if (signal?.aborted) throw aborted();
|
|
190
|
+
const deadline = now() + timeoutMs, seconds = Math.round(timeoutMs / 1000);
|
|
191
|
+
const left = limit => { const ms = Math.min(limit, deadline - now()); if (ms <= 0) throw timedOut(seconds); return ms; };
|
|
192
|
+
const pause = sleep ?? (ms => abortableSleep(ms, signal));
|
|
193
|
+
// Both tools are checked before the camera turns on.
|
|
194
|
+
for (const [cmd, args] of [['ffmpeg', ['-hide_banner', '-version']], ['zbarimg', ['--version']]]) await run(spawn, cmd, args, { signal, timeoutMs: left(PROBE_TIMEOUT_MS) });
|
|
195
|
+
const root = path.resolve(tmpRoot);
|
|
196
|
+
await sweepStale(root);
|
|
197
|
+
const dir = await mkdtemp(path.join(root, DIR_PREFIX));
|
|
198
|
+
let stream = null;
|
|
199
|
+
const onExit = () => { try { stream?.child?.kill('SIGKILL'); } catch {} try { rmSync(dir, { recursive: true, force: true }); } catch {} };
|
|
200
|
+
process.once('exit', onExit);
|
|
201
|
+
try {
|
|
202
|
+
await chmod(dir, 0o700);
|
|
203
|
+
const device = await pickCamera({ camera, env, spawn, readdir, signal, deadline, now, seconds });
|
|
204
|
+
left(1);
|
|
205
|
+
let attempt = 0, seen = 0;
|
|
206
|
+
const secondsLeft = () => Math.max(1, Math.ceil((deadline - now()) / 1000));
|
|
207
|
+
stream = startStream(spawn, streamArgs(device, dir, attempt, secondsLeft()));
|
|
208
|
+
for (;;) {
|
|
209
|
+
await pause(Math.max(0, Math.min(intervalMs, deadline - now())));
|
|
210
|
+
if (signal?.aborted) throw aborted();
|
|
211
|
+
// Read whether ffmpeg ended BEFORE listing: then every listed frame is complete. While it runs, the newest frame
|
|
212
|
+
// may still be being written, so only the ones older than it are complete. Of those only the newest is decoded:
|
|
213
|
+
// older ones are deleted unread, so a slow decode never builds a backlog of stale frames.
|
|
214
|
+
const ended = stream.exited;
|
|
215
|
+
const frames = (await fsReaddir(dir)).filter(name => FRAME.test(name)).sort();
|
|
216
|
+
const complete = ended ? frames : frames.slice(0, -1);
|
|
217
|
+
if (complete.length) {
|
|
218
|
+
seen += complete.length;
|
|
219
|
+
for (const name of complete.slice(0, -1)) await rm(path.join(dir, name), { force: true });
|
|
220
|
+
const file = path.join(dir, complete.at(-1));
|
|
221
|
+
let decoded;
|
|
222
|
+
try { decoded = await run(spawn, 'zbarimg', [...DECODE_ARGS, file], { signal, timeoutMs: Math.max(1, Math.min(PROBE_TIMEOUT_MS, deadline - now())) }); }
|
|
223
|
+
finally { await rm(file, { force: true }); }
|
|
224
|
+
const code = decoded.code === 0 ? onlyPairCode(decoded.stdout) : null;
|
|
225
|
+
if (code) return { code, camera: device };
|
|
226
|
+
}
|
|
227
|
+
if (ended && !frames.length) {
|
|
228
|
+
if (stream.error) throw stream.error.code === 'ENOENT' ? missingTools() : stream.error;
|
|
229
|
+
// A camera that refuses a format or size is opened again the next way, down to its own default.
|
|
230
|
+
if (!seen && attempt + 1 < STREAM_ATTEMPTS.length) { attempt += 1; left(1); stream = startStream(spawn, streamArgs(device, dir, attempt, secondsLeft())); continue; }
|
|
231
|
+
throw failure('QR_SCAN_CAMERA', t('Could not read the camera {camera}: {reason}', { camera: device, reason: oneLine(stream.stderr) }));
|
|
232
|
+
}
|
|
233
|
+
if (now() >= deadline) throw timedOut(seconds);
|
|
234
|
+
}
|
|
235
|
+
} finally {
|
|
236
|
+
await stopStream(stream);
|
|
237
|
+
await rm(dir, { recursive: true, force: true });
|
|
238
|
+
process.removeListener('exit', onExit);
|
|
239
|
+
}
|
|
240
|
+
}
|
package/src/read-only-check.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { shellCommands } from './auto-guard.mjs';
|
|
2
|
+
import { toolEffects } from './operations.mjs';
|
|
2
3
|
|
|
3
4
|
// A deliberately small executable subset, not a claim that arbitrary Bash is safe.
|
|
4
5
|
// The native executor runs these greps with fixed argv and no shell.
|
|
@@ -40,6 +41,10 @@ export const openRelayObservation = operation => OPEN_RELAY_READS.has(operation)
|
|
|
40
41
|
// omarchy: catálogo, clasificación, lecturas revisadas y guía. Change y SystemChange no son observaciones.
|
|
41
42
|
const OMARCHY_READS = new Set(['Status', 'Commands', 'Classify', 'Read', 'Guide'].map(name => `plugin__omarchy__${name}`));
|
|
42
43
|
export const omarchyObservation = operation => OMARCHY_READS.has(operation);
|
|
43
|
-
|
|
44
|
+
// The operation contract (toolEffects) is the authority for pure local reads; this list only adds hosts' verifiers.
|
|
45
|
+
// Session introspection changes with every row: never a credited or auto-refreshed check.
|
|
46
|
+
const INTROSPECTION = new Set(['WorkSnapshot', 'HouseSnapshot', 'AuditStatus', 'OperationCatalog']);
|
|
47
|
+
const localRead = operation => { if (INTROSPECTION.has(operation)) return false; const effects = toolEffects(operation); return effects.workspace === 'read' && effects.external === 'none' && effects.process === 'none' && effects.authority === 'none'; };
|
|
48
|
+
export const readOnlyWorkCheck = (operation, args) => ['ChromeVerify','SourceVerify'].includes(operation) || localRead(operation) || operation==='GitInspect'&&Boolean(args)&&Object.keys(args).length===0 || CHROME_LAB_READS.has(operation) || (operation === 'Bash' && Boolean(readOnlyBashCheck(args?.command)));
|
|
44
49
|
export const RUNTIME_CHECK_IDS = Object.freeze(['target_id','pageId','page_id','context_id','contextId']);
|
|
45
50
|
export const runtimeBoundReadCheck = (operation, args) => readOnlyWorkCheck(operation,args) && (RUNTIME_CHECK_IDS.some(key => Object.hasOwn(args || {},key)) || operation==='plugin__chrome-lab__WorkerDiagnostics'&&Object.hasOwn(args||{},'connection_id')&&Object.hasOwn(args||{},'browser_endpoint_id'));
|