@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
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// session.stats for /rc surfaces (browser and paired M5Stack): a bounded summary built from what
|
|
2
|
+
// /status, /context and the status bar already compute. Pure module: numbers, enums and bounded
|
|
3
|
+
// labels only — never paths, file names, tool arguments or the cwd.
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { stripForDevice } from './remote-device-card.mjs';
|
|
6
|
+
|
|
7
|
+
const CONTRACT = JSON.parse(readFileSync(new URL('../backend/contracts/remote-device-v1.json', import.meta.url), 'utf8')).stats;
|
|
8
|
+
export const STATS_SCHEMA = CONTRACT.schema;
|
|
9
|
+
export const STATS_MAX_BYTES = CONTRACT.maxBytes;
|
|
10
|
+
// A change publishes a durable session.stats at most once a minute (each one is a stored row against the session's
|
|
11
|
+
// 10,000-event cap and the account's storage); a turn end and a device connecting publish at once (1 s floor).
|
|
12
|
+
export const STATS_INTERVAL_MS = 60000;
|
|
13
|
+
export const STATS_FLOOR_MS = 1000;
|
|
14
|
+
|
|
15
|
+
const GROUPS = STATS_SCHEMA.properties;
|
|
16
|
+
const limit = (group, field) => GROUPS[group].properties[field].maxLength;
|
|
17
|
+
const MODES = GROUPS.permissions.properties.mode.enum;
|
|
18
|
+
const TRANSPORTS = GROUPS.connection.properties.transport.enum;
|
|
19
|
+
const EFFORT_LEVELS = GROUPS.model.properties.effort_levels.maxItems;
|
|
20
|
+
const SOURCES = GROUPS.capabilities.properties.sources.maxProperties;
|
|
21
|
+
const SOURCE_NAME = /^[A-Za-z0-9_.:-]{1,64}$/;
|
|
22
|
+
|
|
23
|
+
const count = value => Number.isSafeInteger(value) && value >= 0 ? value : undefined;
|
|
24
|
+
const flag = value => typeof value === 'boolean' ? value : null;
|
|
25
|
+
const cut = (text, max) => Array.from(text).slice(0, max).join('');
|
|
26
|
+
/** A label as the device shows it: invisible characters out, one line, cut to the schema's length. */
|
|
27
|
+
function label(value, max) {
|
|
28
|
+
if (typeof value !== 'string') return undefined;
|
|
29
|
+
const text = cut(stripForDevice(value).replace(/\s+/g, ' ').trim(), max).trim();
|
|
30
|
+
return text || undefined;
|
|
31
|
+
}
|
|
32
|
+
// A token is path-like when it holds a separator, starts at home, or names a file (word.ext).
|
|
33
|
+
const PATHISH = /[\\/]|^~|^[A-Za-z]:/;
|
|
34
|
+
const FILENAME = /\w\.[A-Za-z][A-Za-z0-9]{0,7}$/;
|
|
35
|
+
const MODEL_FILE = /\w\.(gguf|ggml|bin|safetensors|onnx|pt|pth|mlx|llamafile|json|ya?ml|toml)$/i;
|
|
36
|
+
/** Free text (objective, task title) without the cwd, paths or file names: each such token becomes «…». */
|
|
37
|
+
function redacted(value, cwd, max) {
|
|
38
|
+
if (typeof value !== 'string') return undefined;
|
|
39
|
+
let text = stripForDevice(value).replace(/\s+/g, ' ');
|
|
40
|
+
if (typeof cwd === 'string' && cwd.length > 1) text = text.split(cwd).join('…');
|
|
41
|
+
text = text.split(' ').map(token => {
|
|
42
|
+
const bare = token.replace(/^[("'«“‘[]+|[)"'»”’\],.;:!?]+$/g, '');
|
|
43
|
+
return bare && (PATHISH.test(bare) || FILENAME.test(bare)) ? token.replace(bare, '…') : token;
|
|
44
|
+
}).join(' ');
|
|
45
|
+
return label(text, max);
|
|
46
|
+
}
|
|
47
|
+
/** A model or profile name. Local providers name a weights or profile FILE: only its bare name is shown, never the
|
|
48
|
+
* path (or the cwd) — `/home/x/models/qwen-7b.gguf` reads `qwen-7b`; an id that is no path stays whole. */
|
|
49
|
+
function nameLabel(value, cwd, max) {
|
|
50
|
+
if (typeof value !== 'string') return undefined;
|
|
51
|
+
let text = stripForDevice(value).replace(/\s+/g, ' ').trim();
|
|
52
|
+
if (typeof cwd === 'string' && cwd.length > 1) text = text.split(cwd).join('');
|
|
53
|
+
text = text.split(' ').map(token => {
|
|
54
|
+
const path = PATHISH.test(token);
|
|
55
|
+
let name = path ? token.split(/[\\/]+/).filter(Boolean).at(-1) ?? '' : token;
|
|
56
|
+
if (/^~|^[A-Za-z]:$/.test(name)) name = '';
|
|
57
|
+
// The last segment of a path loses any extension; an id that is no path only a model file's (meta.llama3 stays).
|
|
58
|
+
return (path ? FILENAME.test(name) : MODEL_FILE.test(name)) ? name.replace(/\.[A-Za-z][A-Za-z0-9]{0,11}$/, '') : name;
|
|
59
|
+
}).filter(Boolean).join(' ');
|
|
60
|
+
return label(text, max);
|
|
61
|
+
}
|
|
62
|
+
const compact = group => {
|
|
63
|
+
for (const key of Object.keys(group)) if (group[key] === undefined) delete group[key];
|
|
64
|
+
return Object.keys(group).length ? group : undefined;
|
|
65
|
+
};
|
|
66
|
+
const tally = value => {
|
|
67
|
+
if (!value || typeof value !== 'object') return undefined;
|
|
68
|
+
return compact({ input: count(value.input), output: count(value.output), cached: count(value.cached) });
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Sources (each optional; a missing source omits its group or field):
|
|
73
|
+
* metadata (cli metadata()), promptCaching, contextStatus / contextPlan (compactor status/plan as
|
|
74
|
+
* /context computes them), messages {active, original}, lastCompaction (context_projection row),
|
|
75
|
+
* usage {last, session, calls, call_limit}, timing (result.timing), work (projectRemoteWorkspace),
|
|
76
|
+
* permissions {armed, until ms} (RemoteHost status().permission_answers), transport, addDirCount, cwd.
|
|
77
|
+
*/
|
|
78
|
+
export function buildSessionStats({ metadata, promptCaching, contextStatus, contextPlan, messages, lastCompaction, usage, timing, work, permissions, transport, addDirCount, cwd } = {}) {
|
|
79
|
+
const stats = {};
|
|
80
|
+
if (metadata && typeof metadata === 'object') {
|
|
81
|
+
const levels = metadata.modelCapabilities?.effort?.levels;
|
|
82
|
+
stats.model = compact({
|
|
83
|
+
provider: label(metadata.provider, limit('model', 'provider')),
|
|
84
|
+
profile: nameLabel(metadata.profile, cwd, limit('model', 'profile')),
|
|
85
|
+
model: nameLabel(metadata.model, cwd, limit('model', 'model')),
|
|
86
|
+
effort: label(metadata.effort, limit('model', 'effort')),
|
|
87
|
+
effort_levels: Array.isArray(levels) ? levels.map(level => label(level, GROUPS.model.properties.effort_levels.items.maxLength)).filter(Boolean).slice(0, EFFORT_LEVELS) : undefined,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
const reported = metadata?.modelCapabilities?.capabilities;
|
|
91
|
+
if ((reported && typeof reported === 'object') || typeof promptCaching === 'boolean') {
|
|
92
|
+
const provenance = metadata?.modelCapabilities?.provenance || {}, sources = {};
|
|
93
|
+
const capabilities = {};
|
|
94
|
+
if (reported && typeof reported === 'object') for (const name of ['tools', 'vision', 'thinking']) {
|
|
95
|
+
capabilities[name] = flag(reported[name]);
|
|
96
|
+
const source = provenance[name]?.source;
|
|
97
|
+
if (typeof source === 'string' && SOURCE_NAME.test(source)) sources[name] = source;
|
|
98
|
+
}
|
|
99
|
+
capabilities.cache = typeof promptCaching === 'boolean' ? promptCaching : null;
|
|
100
|
+
if (typeof promptCaching === 'boolean') sources.cache = 'provider';
|
|
101
|
+
if (Object.keys(sources).length) capabilities.sources = Object.fromEntries(Object.entries(sources).slice(0, SOURCES));
|
|
102
|
+
stats.capabilities = capabilities;
|
|
103
|
+
}
|
|
104
|
+
{
|
|
105
|
+
const until = Number(permissions?.until);
|
|
106
|
+
const group = compact({
|
|
107
|
+
mode: MODES.includes(metadata?.mode) ? metadata.mode : undefined,
|
|
108
|
+
device_armed: typeof permissions?.armed === 'boolean' ? permissions.armed : undefined,
|
|
109
|
+
device_until: permissions ? (permissions.armed && Number.isFinite(until) && until > 0 ? Math.floor(until / 1000) : null) : undefined,
|
|
110
|
+
add_dirs: count(addDirCount),
|
|
111
|
+
});
|
|
112
|
+
if (group) stats.permissions = group;
|
|
113
|
+
}
|
|
114
|
+
if (contextStatus || contextPlan || messages || lastCompaction) {
|
|
115
|
+
const status = contextStatus || {}, plan = contextPlan || status;
|
|
116
|
+
const percent = typeof status.percent === 'number' && Number.isFinite(status.percent) ? Math.min(100, Math.max(0, status.percent)) : undefined;
|
|
117
|
+
const at = Date.parse(lastCompaction?.timestamp ?? lastCompaction?.at ?? '');
|
|
118
|
+
const group = compact({
|
|
119
|
+
input_tokens: count(status.input_tokens), window: count(status.context_window), output_reserve: count(status.output_reserve), percent,
|
|
120
|
+
compaction_window: count(plan.compaction_window), soft_threshold: count(plan.soft_threshold), hard_threshold: count(plan.hard_threshold),
|
|
121
|
+
last_compaction: lastCompaction ? compact({
|
|
122
|
+
at: Number.isFinite(at) && at >= 0 ? Math.floor(at / 1000) : undefined,
|
|
123
|
+
trigger: label(lastCompaction.compaction_trigger ?? lastCompaction.trigger, GROUPS.context.properties.last_compaction.properties.trigger.maxLength),
|
|
124
|
+
before: count(lastCompaction.before_tokens), after: count(lastCompaction.after_tokens),
|
|
125
|
+
}) ?? null : undefined,
|
|
126
|
+
messages_active: count(messages?.active), messages_original: count(messages?.original),
|
|
127
|
+
});
|
|
128
|
+
if (group) stats.context = group;
|
|
129
|
+
}
|
|
130
|
+
if (usage && typeof usage === 'object') {
|
|
131
|
+
const group = compact({ last: tally(usage.last), session: tally(usage.session), calls: count(usage.calls), call_limit: count(usage.call_limit) });
|
|
132
|
+
if (group) stats.usage = group;
|
|
133
|
+
}
|
|
134
|
+
if (timing && typeof timing === 'object') {
|
|
135
|
+
const group = compact({ comal_ms: count(timing.comal_ms), reasoning_ms: count(timing.reasoning_ms), first_response_ms: count(timing.first_output_ms ?? timing.first_text_ms), tools_ms: count(timing.tools_ms), human_wait_ms: count(timing.human_wait_ms) });
|
|
136
|
+
if (group) stats.timing = group;
|
|
137
|
+
}
|
|
138
|
+
if (work && typeof work === 'object' && (work.plan || work.tasks_total)) {
|
|
139
|
+
const tasks = Array.isArray(work.tasks) ? work.tasks : [], checks = tasks.flatMap(task => Array.isArray(task?.checks) ? task.checks : []);
|
|
140
|
+
const current = tasks.find(task => task?.status === 'in_progress');
|
|
141
|
+
const group = compact({
|
|
142
|
+
objective: redacted(work.plan?.objective, cwd, limit('work', 'objective')),
|
|
143
|
+
done: count(work.counts?.done), total: count(work.tasks_total),
|
|
144
|
+
current_task: current ? redacted(current.title, cwd, limit('work', 'current_task')) ?? null : null,
|
|
145
|
+
checks_passing: checks.filter(check => check?.status === 'passed').length,
|
|
146
|
+
checks_failing: checks.filter(check => ['failed', 'refused', 'interrupted', 'not_dispatched'].includes(check?.status)).length,
|
|
147
|
+
});
|
|
148
|
+
if (group) stats.work = group;
|
|
149
|
+
}
|
|
150
|
+
if (TRANSPORTS.includes(transport)) stats.connection = { transport, host_online: transport !== 'disconnected' };
|
|
151
|
+
for (const key of Object.keys(stats)) if (stats[key] === undefined) delete stats[key];
|
|
152
|
+
return fitStats(stats) ?? {};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// What goes first when a summary does not fit: free text, then timing, then the rest that is not essential.
|
|
156
|
+
const DROPS = [
|
|
157
|
+
stats => { delete stats.work?.objective; },
|
|
158
|
+
stats => { delete stats.work?.current_task; },
|
|
159
|
+
stats => { delete stats.timing; },
|
|
160
|
+
stats => { delete stats.capabilities?.sources; },
|
|
161
|
+
stats => { delete stats.model?.effort_levels; },
|
|
162
|
+
stats => { delete stats.work; },
|
|
163
|
+
stats => { delete stats.usage; },
|
|
164
|
+
];
|
|
165
|
+
/** The summary itself when it fits in `max` bytes; otherwise a copy with optional parts dropped; null if nothing fits. */
|
|
166
|
+
export function fitStats(stats, max = STATS_MAX_BYTES) {
|
|
167
|
+
if (!stats || typeof stats !== 'object') return null;
|
|
168
|
+
const size = value => Buffer.byteLength(JSON.stringify(value));
|
|
169
|
+
if (size(stats) <= max) return stats;
|
|
170
|
+
const copy = structuredClone(stats);
|
|
171
|
+
for (const drop of DROPS) {
|
|
172
|
+
drop(copy);
|
|
173
|
+
for (const key of Object.keys(copy)) if (copy[key] && typeof copy[key] === 'object' && !Object.keys(copy[key]).length) delete copy[key];
|
|
174
|
+
if (size(copy) <= max) return copy;
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Context numbers exactly as /context computes them (same compactor calls). */
|
|
180
|
+
export function readContext({ compactor, system, tools, maxTokens, session }) {
|
|
181
|
+
const contextStatus = compactor.status({ system, messages: session.messages, tools }, maxTokens);
|
|
182
|
+
const contextPlan = compactor.plan(contextStatus.output_reserve, contextStatus.overhead_tokens);
|
|
183
|
+
return { contextStatus, contextPlan, messages: { active: session.messages.length, original: session.historyMessages.length } };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const tokens = usage => ({ input: count(usage?.input_tokens) ?? 0, output: count(usage?.output_tokens) ?? 0 });
|
|
187
|
+
const cacheRead = row => count(row?.usage_accounting?.cache_read_tokens) ?? 0;
|
|
188
|
+
/** Usage, last timing and last compaction from the session book. Session totals count what the status
|
|
189
|
+
* bar counts (results not already including subagents, and failed requests with usage). */
|
|
190
|
+
export function statsFromRows(rows = []) {
|
|
191
|
+
const counted = rows.filter(row => (row.type === 'result' && !row.usage_includes_subagents) || (row.type === 'error' && row.usage));
|
|
192
|
+
const turns = rows.filter(row => row.type === 'result' && !row.usage_includes_subagents && row.usage && row.turns > 0);
|
|
193
|
+
const last = turns.at(-1), reads = rows.filter(row => row.type === 'model_usage');
|
|
194
|
+
const session = counted.reduce((sum, row) => { const n = tokens(row.usage); return { input: sum.input + n.input, output: sum.output + n.output }; }, { input: 0, output: 0 });
|
|
195
|
+
const usage = counted.length || reads.length ? {
|
|
196
|
+
...(last ? { last: { ...tokens(last.usage), cached: last.turn_id ? reads.filter(row => row.turn_id === last.turn_id).reduce((n, row) => n + cacheRead(row), 0) : 0 } } : {}),
|
|
197
|
+
session: { ...session, cached: reads.reduce((n, row) => n + cacheRead(row), 0) },
|
|
198
|
+
...(last ? { calls: last.turns } : {}),
|
|
199
|
+
} : null;
|
|
200
|
+
return {
|
|
201
|
+
usage,
|
|
202
|
+
timing: rows.findLast(row => row.type === 'result' && row.timing && typeof row.timing === 'object')?.timing ?? null,
|
|
203
|
+
lastCompaction: rows.findLast(row => row.type === 'context_projection') ?? null,
|
|
204
|
+
};
|
|
205
|
+
}
|
package/src/session-bus.mjs
CHANGED
|
@@ -61,13 +61,21 @@ function mailbox(rows) {
|
|
|
61
61
|
/** Local, same-user IPC. The private registry authenticates live instances; it is
|
|
62
62
|
* not a sandbox against other programs already running as this OS user. */
|
|
63
63
|
export class SessionBus {
|
|
64
|
-
constructor({ store, getSessionId, getMetadata = () => ({}), getAuthority = () => ({}), getChain = () => ({}), directory = process.env.COMAL_PEER_DIR || path.join(os.homedir(), '.local/state/comal/peers'), inbound, onPending, onEvent, timeoutMs = 5000, ttlMs = TTL, platform = process.platform } = {}) {
|
|
64
|
+
constructor({ store, getSessionId, getMetadata = () => ({}), getAuthority = () => ({}), getChain = () => ({}), directory = process.env.COMAL_PEER_DIR || path.join(os.homedir(), '.local/state/comal/peers'), inbound, onPending, onEvent, timeoutMs = 5000, ttlMs = TTL, platform = process.platform, disabled = false } = {}) {
|
|
65
65
|
if (!store || typeof getSessionId !== 'function') throw new TypeError('SessionBus requiere store y getSessionId.');
|
|
66
66
|
if (inbound !== undefined && !['accept', 'hold', 'refuse'].includes(inbound)) throw fail('PEER_INBOUND_INVALID', 'Usa accept, hold o refuse.');
|
|
67
67
|
Object.assign(this, { store, getSessionId, getMetadata, getAuthority, getChain, directory: path.resolve(directory), onPending, onEvent, timeoutMs, ttlMs: Math.min(TTL, Math.max(10, ttlMs)) });
|
|
68
|
-
this.availability = Object.freeze(platform === 'win32' ? { available: false, transport: 'none', code: 'PEER_PLATFORM_UNSUPPORTED', reason: 'Cross-session messaging is unavailable on native Windows; the current IPC requires POSIX private directories and Unix sockets.' } : { available: true, transport: 'unix' });
|
|
68
|
+
this.availability = Object.freeze(disabled ? { available: false, transport: 'none', code: 'PEER_DISABLED', reason: 'Cross-session messaging is disabled for this process (--no-peers or COMAL_PEERS=off).' } : platform === 'win32' ? { available: false, transport: 'none', code: 'PEER_PLATFORM_UNSUPPORTED', reason: 'Cross-session messaging is unavailable on native Windows; the current IPC requires POSIX private directories and Unix sockets.' } : { available: true, transport: 'unix' });
|
|
69
69
|
this.state = 'idle'; this.inbound = inbound || 'accept'; this.inboundOverride = inbound; this.inboundConfigured = new Set(); this.sockets = new Set(); this._starting = undefined;
|
|
70
70
|
}
|
|
71
|
+
/** Turns a failed start into an unavailable bus (headless workers in a sandbox).
|
|
72
|
+
* Local work continues; peer operations then fail with the recorded reason. */
|
|
73
|
+
async degrade(error) {
|
|
74
|
+
await this.close().catch(() => {});
|
|
75
|
+
this.availability = Object.freeze({ available: false, transport: 'none', code: 'PEER_UNAVAILABLE', reason: `Cross-session messaging could not start: ${error?.code || error?.message || 'unknown error'}.`, ...(error?.code ? { cause: error.code } : {}) });
|
|
76
|
+
this._unavailableReported = true; this._event({ type: 'peer_unavailable', ...this.availability });
|
|
77
|
+
return this.availability;
|
|
78
|
+
}
|
|
71
79
|
_requireSupported() {
|
|
72
80
|
if (!this.availability.available) throw Object.assign(fail(this.availability.code, this.availability.reason), { availability: this.availability });
|
|
73
81
|
}
|
|
@@ -332,7 +340,9 @@ export class SessionBus {
|
|
|
332
340
|
return admitted;
|
|
333
341
|
});
|
|
334
342
|
if (result.status === 'accepted' && result.notice.urgent) this._signal();
|
|
335
|
-
|
|
343
|
+
// El aviso aceptado va entero en el evento: la TUI lo pinta al llegar aunque no despierte
|
|
344
|
+
// un turno (los no urgentes solo llegan al modelo con el siguiente mensaje).
|
|
345
|
+
this._event({ type: 'plugin_notice_admitted', plugin: normalized.plugin, ref: normalized.ref, status: result.status, ...(result.status === 'accepted' ? { notice: result.notice } : {}) });
|
|
336
346
|
return { status: result.status, ...(result.notice_id ? { notice_id: result.notice_id } : {}) };
|
|
337
347
|
}
|
|
338
348
|
async _drainNotices(id) {
|
|
@@ -11,7 +11,7 @@ export function sessionProviderSettings({ current, profiles, options: v = {}, en
|
|
|
11
11
|
const selected = requested ? selectProfile(profiles, requested) : null;
|
|
12
12
|
const modelOverride=v.model || env.COMAL_MODEL || env.OPENAI_MODEL;
|
|
13
13
|
const effort = (same, fallback) => v.effort || env.COMAL_EFFORT || (same && (!modelOverride || modelOverride === current?.model) ? saved?.effort : null) || fallback || 'auto';
|
|
14
|
-
const common = { maxTokens, ...(v.thinking ? { thinking: v.thinking } : {}), ...(v.vision ? { vision: v.vision } : {}), ...(v['image-tokens'] ? { imageTokens: Number(v['image-tokens']) } : {}) };
|
|
14
|
+
const common = { maxTokens, ...(v.thinking ? { thinking: v.thinking } : {}), ...(v.vision ? { vision: v.vision } : {}), ...(v['image-tokens'] ? { imageTokens: Number(v['image-tokens']) } : {}), ...(v['anthropic-fallback'] ? { fallback: v['anthropic-fallback'] } : {}) };
|
|
15
15
|
const access=v['auth-provider']?authProviderDefaults(v['auth-provider']):null;
|
|
16
16
|
// An explicit login selects its transport, not just a credential to attach
|
|
17
17
|
// to the old profile's URL/model. Keep profile settings only for the same
|
|
@@ -30,7 +30,7 @@ export function sessionProviderSettings({ current, profiles, options: v = {}, en
|
|
|
30
30
|
if (!explicitEndpoint && saved && !hasEnvEndpoint) {
|
|
31
31
|
const schema=saved.toolSchema?{toolSchema:structuredClone(saved.toolSchema)}:{};
|
|
32
32
|
const model = v.model || env.COMAL_MODEL || current.model;
|
|
33
|
-
if (saved.authProvider) return { ...common, ...schema, ...authProviderDefaults(saved.authProvider), model, baseURL: saved.baseURL, effort: effort(true), ...(saved.thinking && !v.thinking ? { thinking: saved.thinking } : {}),vision:v.vision || saved.vision || 'auto',imageTokens:Number(v['image-tokens'] || saved.imageTokens || 4096),capabilitiesConfigured:saved.capabilitiesConfigured || {} };
|
|
33
|
+
if (saved.authProvider) return { ...common, ...schema, ...authProviderDefaults(saved.authProvider), model, baseURL: saved.baseURL, effort: effort(true), ...(saved.fallback ? { fallback: saved.fallback } : {}), ...(saved.thinking && !v.thinking ? { thinking: saved.thinking } : {}),vision:v.vision || saved.vision || 'auto',imageTokens:Number(v['image-tokens'] || saved.imageTokens || 4096),capabilitiesConfigured:saved.capabilitiesConfigured || {} };
|
|
34
34
|
if (saved.provider === 'gemini') return { ...common, ...schema, provider: saved.provider, effort: effort(true), profile: { name: 'session', provider: saved.provider, baseURL: saved.baseURL, model, apiKeyEnv: saved.apiKeyEnv, capabilities: saved.capabilities, capabilitiesConfigured: saved.capabilitiesConfigured || saved.capabilities } };
|
|
35
35
|
return { ...schema, ...providerSettings({ provider: saved.provider, baseURL: saved.baseURL, model, thinking: v.thinking || saved.thinking, vision: v.vision || saved.vision, imageTokens: v['image-tokens'] || saved.imageTokens, effort: effort(true) }, env, current), maxTokens };
|
|
36
36
|
}
|
package/src/skill-runtime.mjs
CHANGED
|
@@ -14,7 +14,7 @@ const controlEvidence = controls => {
|
|
|
14
14
|
}
|
|
15
15
|
return result;
|
|
16
16
|
};
|
|
17
|
-
export function compileSkill({ name, content, manifest = {}, file = null }) {
|
|
17
|
+
export function compileSkill({ name, content, manifest = {}, file = null, source = 'workspace' }) {
|
|
18
18
|
if (typeof name !== 'string' || !/^[a-z][a-z0-9-]*$/.test(name) || typeof content !== 'string' || content.length > 32000 || !manifest || typeof manifest !== 'object' || Array.isArray(manifest)) throw failure('Skill inválida.');
|
|
19
19
|
for (const key of Object.keys(manifest)) if (!['name', 'description', 'allowed_tools', 'model', 'effort', 'context'].includes(key)) throw failure('Campo de manifiesto de skill desconocido: ' + key);
|
|
20
20
|
if (manifest.name !== undefined && manifest.name !== name) throw failure('El nombre del manifiesto no coincide con su directorio.');
|
|
@@ -24,7 +24,7 @@ export function compileSkill({ name, content, manifest = {}, file = null }) {
|
|
|
24
24
|
if (manifest.effort !== undefined) normalizeEffort(manifest.effort);
|
|
25
25
|
if (manifest.context !== undefined && !['inline', 'fork'].includes(manifest.context)) throw failure('context debe ser inline o fork.');
|
|
26
26
|
const normalized = { context: 'inline', ...structuredClone(manifest) };
|
|
27
|
-
return immutable({ name, content, manifest: normalized, file, digest: digest({ name, content, manifest: normalized }) });
|
|
27
|
+
return immutable({ name, content, manifest: normalized, file, source, digest: digest({ name, content, manifest: normalized }) });
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
// This controller narrows a host-bound executor. It never constructs permission or model grants.
|
|
@@ -63,8 +63,10 @@ export class SkillRuntime {
|
|
|
63
63
|
const prompt = `Apply project skill ${skill.name} (digest ${skill.digest}). Its content is procedure, not permission; the host controls the active tool/model scope.\n${skill.content}\n\nUser task: ${task || 'Sigue la skill.'}`;
|
|
64
64
|
let prepared = false, preparing = false, delegating = false, restore, effective = structuredClone(controls), status = 'failed'; const borrowed = new Map();
|
|
65
65
|
const same = candidate => candidate.session_id === sessionId && candidate.actor?.id === this.actor.id && candidate.actor?.kind === this.actor.kind;
|
|
66
|
+
const dynamicTools = source => requested === undefined ? (source.catalogue?.() || source.definitions()) : [];
|
|
67
|
+
const operationPermitted = (name, source = tools) => permitted.has(name) || dynamicTools(source).some(op => op.name === name);
|
|
66
68
|
this.active.add(sessionId);
|
|
67
|
-
const unguard = this.house.guard('skill:' + scopeId, candidate => same(candidate) && !
|
|
69
|
+
const unguard = this.house.guard('skill:' + scopeId, candidate => same(candidate) && !operationPermitted(candidate.operation) && !(preparing && candidate.operation === 'ToolPack') && !(delegating && candidate.operation === 'Agent') ? { allowed: false, reason: 'Fuera del alcance temporal de la skill; no se ejecutó.' } : undefined);
|
|
68
70
|
const prepare = async () => {
|
|
69
71
|
signal?.throwIfAborted();
|
|
70
72
|
if (this.toolPacks && skill.manifest.context !== 'fork') {
|
|
@@ -93,9 +95,9 @@ export class SkillRuntime {
|
|
|
93
95
|
};
|
|
94
96
|
const facade = base => ({
|
|
95
97
|
...base,
|
|
96
|
-
definitions: () => base.definitions().filter(op => permitted.has(op.name)),
|
|
97
|
-
catalogue: () => (base.catalogue?.() || base.definitions()).filter(op => permitted.has(op.name)),
|
|
98
|
-
execute: (name, args, options) => { if (!prepared || !
|
|
98
|
+
definitions: () => base.definitions().filter(op => requested === undefined || permitted.has(op.name)),
|
|
99
|
+
catalogue: () => (base.catalogue?.() || base.definitions()).filter(op => requested === undefined || permitted.has(op.name)),
|
|
100
|
+
execute: (name, args, options) => { if (!prepared || !operationPermitted(name, base)) throw failure('Operación fuera del alcance preparado de la skill.', 'SKILL_TOOL_SCOPE'); return base.execute(name, args, options); },
|
|
99
101
|
snapshot: () => facade(base.snapshot?.() || base),
|
|
100
102
|
});
|
|
101
103
|
try {
|
package/src/source-ownership.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {comalEnvironment} from './compat.mjs';
|
|
1
2
|
import {mkdir,lstat,realpath,readFile,stat} from 'node:fs/promises';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import os from 'node:os';
|
|
@@ -9,7 +10,8 @@ import {ensurePrivateDirectory,assertPrivateDirectory} from './private-fs.mjs';
|
|
|
9
10
|
|
|
10
11
|
const fail=(message,code='SOURCE_LOCK_UNRESOLVED')=>Object.assign(new Error(message),{code});
|
|
11
12
|
const normal=file=>process.platform==='win32'?file.toLowerCase():file;
|
|
12
|
-
|
|
13
|
+
// COMAL_SOURCE_LOCK_DIR relocates the registry (read-only or ephemeral HOME, e.g. a sandbox).
|
|
14
|
+
export const defaultSourceLockDirectory=(env=process.env)=>{const dir=comalEnvironment(env).COMAL_SOURCE_LOCK_DIR;return dir?path.resolve(dir):path.join(os.homedir(),'.comal','source-locks');};
|
|
13
15
|
export function sourceLockFile(file,directory=defaultSourceLockDirectory()){
|
|
14
16
|
return path.join(directory,createHash('sha256').update(normal(file)).digest('hex')+'.lock');
|
|
15
17
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { digest } from './operations.mjs';
|
|
2
|
+
|
|
3
|
+
// Per-tool manifests (the host's offer on model_request, the provider's compiled
|
|
4
|
+
// schema manifest and diagnostics on model_usage) barely change between calls but
|
|
5
|
+
// cost tens of KB each. They are stored once per distinct digest in a
|
|
6
|
+
// `tool_manifest` row, appended before its first reference; request/usage rows keep
|
|
7
|
+
// only the digest plus cheap counters. Readers resolve through this module and still
|
|
8
|
+
// accept older books where the manifest was inline. A reference whose row is outside
|
|
9
|
+
// the rows at hand (a copied or truncated range) resolves to null instead of throwing.
|
|
10
|
+
export const MANIFEST_KINDS = Object.freeze({
|
|
11
|
+
request_tools: { inline: 'tools', ref: 'tools_manifest_digest' },
|
|
12
|
+
schema_manifest: { inline: 'schema_manifest', ref: 'schema_manifest_digest' },
|
|
13
|
+
schema_diagnostics: { inline: 'schema_diagnostics', ref: 'schema_diagnostics_digest' },
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const INDEX = Symbol('toolManifestIndex');
|
|
17
|
+
const key = (kind, value) => kind + '\0' + value;
|
|
18
|
+
|
|
19
|
+
// Accepts session rows, a loaded session ({rows}) or an index built earlier.
|
|
20
|
+
export function manifestIndex(source) {
|
|
21
|
+
if (source?.[INDEX]) return source;
|
|
22
|
+
const rows = Array.isArray(source) ? source : source?.rows || [], map = new Map();
|
|
23
|
+
for (const row of rows) if (row?.type === 'tool_manifest' && MANIFEST_KINDS[row.kind] && Array.isArray(row.entries) && !map.has(key(row.kind, row.digest))) map.set(key(row.kind, row.digest), row.entries);
|
|
24
|
+
return { [INDEX]: true, map };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function resolveManifest(source, row, kind) {
|
|
28
|
+
const spec = MANIFEST_KINDS[kind];
|
|
29
|
+
if (!spec || !row) return null;
|
|
30
|
+
if (row[spec.inline] !== undefined) return row[spec.inline];
|
|
31
|
+
const ref = row[spec.ref];
|
|
32
|
+
if (typeof ref !== 'string') return null;
|
|
33
|
+
return manifestIndex(source).map.get(key(kind, ref)) ?? null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const requestTools = (source, row) => resolveManifest(source, row, 'request_tools');
|
|
37
|
+
export const usageSchemaManifest = (source, row) => resolveManifest(source, row, 'schema_manifest');
|
|
38
|
+
export const usageSchemaDiagnostics = (source, row) => resolveManifest(source, row, 'schema_diagnostics');
|
|
39
|
+
|
|
40
|
+
// Returns rows whose request/usage records carry their manifests inline again, for
|
|
41
|
+
// readers written against the old shape. Rows are copied only when hydrated. For a range
|
|
42
|
+
// of a book, pass the whole book (or its manifestIndex) as `source`: the manifest row may
|
|
43
|
+
// lie before the range.
|
|
44
|
+
export function inlineManifests(rows, source = rows) {
|
|
45
|
+
const index = manifestIndex(source);
|
|
46
|
+
return rows.map(row => {
|
|
47
|
+
if (row?.type !== 'model_request' && row?.type !== 'model_usage') return row;
|
|
48
|
+
let copy = row;
|
|
49
|
+
for (const [kind, spec] of Object.entries(MANIFEST_KINDS)) {
|
|
50
|
+
if (row[spec.inline] !== undefined || typeof row[spec.ref] !== 'string') continue;
|
|
51
|
+
const entries = index.map.get(key(kind, row[spec.ref]));
|
|
52
|
+
if (entries !== undefined) { if (copy === row) copy = { ...row }; copy[spec.inline] = entries; }
|
|
53
|
+
}
|
|
54
|
+
return copy;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Appends the manifest row unless the session (or this process, for rows appended
|
|
59
|
+
// after `rows` was loaded) already holds that kind+digest. Returns the digest, or null
|
|
60
|
+
// when there is nothing to store.
|
|
61
|
+
export async function ensureManifest({ store, sessionId, rows = [], kind, entries, seen }) {
|
|
62
|
+
if (!MANIFEST_KINDS[kind]) throw new Error('Unknown tool manifest kind: ' + kind);
|
|
63
|
+
if (!Array.isArray(entries)) return null;
|
|
64
|
+
const value = digest(entries), id = key(kind, value);
|
|
65
|
+
if (seen?.has(id)) return value;
|
|
66
|
+
if (!rows.some(row => row?.type === 'tool_manifest' && row.kind === kind && row.digest === value)) {
|
|
67
|
+
await store.append(sessionId, { type: 'tool_manifest', kind, digest: value, count: entries.length, entries });
|
|
68
|
+
}
|
|
69
|
+
seen?.add(id);
|
|
70
|
+
return value;
|
|
71
|
+
}
|
package/src/tool-packs.mjs
CHANGED
|
@@ -2,11 +2,16 @@ import { workProjection, workDigest } from './work-plan.mjs';
|
|
|
2
2
|
import { digest } from './operations.mjs';
|
|
3
3
|
import { checkOperationCandidates } from './work-check-contracts.mjs';
|
|
4
4
|
import { searchToolCatalog } from './tool-catalog.mjs';
|
|
5
|
+
import { VISUAL_AGENT_OPERATIONS } from './visual/names.mjs';
|
|
5
6
|
|
|
6
7
|
const BROWSER = ['new_page','list_pages','navigate_page','list_console_messages','list_network_requests','take_snapshot'];
|
|
7
8
|
const CAPTURE = ['take_screenshot'];
|
|
8
9
|
const ADMIN = ['WorkSnapshot','AuditStatus','HouseSnapshot','OperationCatalog'];
|
|
9
|
-
|
|
10
|
+
// A task capability asks for a pack only when it names one: an offered pack, mcp:SERVER or plugin:ID.
|
|
11
|
+
// visual:COMPONENT is served by visual.accept; any other capability is not a pack.
|
|
12
|
+
const requiredPacks = (capabilities = [], offers = []) => [...new Set(capabilities.map(capability => capability.startsWith('visual:') ? 'visual.accept'
|
|
13
|
+
: offers.some(offer => offer.id === capability) || /^(mcp|plugin):./.test(capability) ? capability : null).filter(Boolean))];
|
|
14
|
+
const SCOPED = {'workspace.source':['SourceDraft','SourceRetain','SourceRead','SourcePublish'],'sessions.peers':['ListAgents','SendMessage'],'provider.features':['ProviderCapabilities','ProviderSearch','AnalyzeMedia'],'workspace.inspect':['ListDirectory','FileInfo'],'visual.accept':[...VISUAL_AGENT_OPERATIONS],'workspace.serve':['WorkspaceServe'],'workspace.git':['GitInspect','GitIntegrationPrepare','GitIntegrationRevise','GitIntegrationVerify','GitIntegrationApply','GitIntegrationStatus','GitIntegrationEvidence']};
|
|
10
15
|
const TEMPORARY=new Set([...ADMIN,...Object.values(SCOPED).flat()]);
|
|
11
16
|
// Native artifact binding needs the tiny metadata/hash contract on the initial
|
|
12
17
|
// surface. Search/acquire may still describe it; a lease never grants permission.
|
|
@@ -26,7 +31,7 @@ export class ToolPacks {
|
|
|
26
31
|
return [{ id: 'browser.verify', available: browser.length === BROWSER.length, count: browser.length, purpose: 'DOM, console, network and navigation; execution still judged.' },
|
|
27
32
|
{ id: 'browser.capture', available: all.some(op => op.name === 'mcp__chrome-devtools__take_screenshot'), count: all.filter(op => this.inPack(op,'browser.capture')).length, purpose: 'Screenshot pixels in accepted receipts. Vision adapter sends only to a capable model; capture is not a visual verdict. Omit filePath to request inline image content.' },
|
|
28
33
|
{ id: 'house.audit', available: all.some(op=>ADMIN.includes(op.name)), count: all.filter(op=>ADMIN.includes(op.name)).length, purpose: 'Full forensic views; projected state and EvidenceRead already suffice for normal work.' },
|
|
29
|
-
...Object.entries(SCOPED).map(([id,names])=>({id,available:all.some(op=>names.includes(op.name)),count:all.filter(op=>names.includes(op.name)).length,purpose:id==='workspace.source'?'Retain rejected source or append plan-free data, inspect it, then publish via native Write/Edit with frozen versions and current permissions. No authority from storage.':id==='sessions.peers'?'Discover/message live sessions by provider capability; no authority transfer.':id==='workspace.inspect'?'List directory and inspect file size/media signature without shell.':id==='workspace.git'?'Inspect source and, where registered, combine owned Agent offers, verify declared checks and CAS reserved refs. Native permissions still apply; human checkout preserved.':'Current provider native search/image/video tools where supported.'})),
|
|
34
|
+
...Object.entries(SCOPED).map(([id,names])=>({id,available:all.some(op=>names.includes(op.name)),count:all.filter(op=>names.includes(op.name)).length,purpose:id==='workspace.source'?'Retain rejected source or append plan-free data, inspect it, then publish via native Write/Edit with frozen versions and current permissions. No authority from storage.':id==='sessions.peers'?'Discover/message live sessions by provider capability; no authority transfer.':id==='workspace.inspect'?'List directory and inspect file size/media signature without shell.':id==='workspace.serve'?'Serve a workspace folder read-only on 127.0.0.1 for browser and extension tests, instead of a Bash server; closes with the session.':id==='visual.accept'?'Visual acceptance of UI components in visual/components: house captures vs human-approved references (pixels or independent vision judge). VisualVerify is the WorkPlan check of UI tasks.':id==='workspace.git'?'Inspect source and, where registered, combine owned Agent offers, verify declared checks and CAS reserved refs. Native permissions still apply; human checkout preserved.':'Current provider native search/image/video tools where supported.'})),
|
|
30
35
|
...[...servers].map(([server, tools]) => ({ id: 'mcp:' + server, available: true, count: tools.length, purpose: 'Search or select up to 12 exact names with ToolPack; omit tools only when count <=12.' })),
|
|
31
36
|
...[...plugins].map(([id, tools]) => ({ id: 'plugin:' + id, available: true, count: tools.length, purpose: tools[0].origin.discovery?.description || 'Search or select deferred plugin operations; acquisition grants no permission.' }))];
|
|
32
37
|
}
|
|
@@ -48,7 +53,7 @@ export class ToolPacks {
|
|
|
48
53
|
if (!scopeValid || !contractValid) { expired.push(lease.pack); expiredDetails.push({pack:lease.pack,task_id:lease.task_id || null,reason:!scopeValid?'scope_changed':'contracts_changed'}); continue; }
|
|
49
54
|
lease.tools.forEach(tool => { visible.add(tool.name); if(!pinned.has(tool.name)) tokens.set(tool.name,(tokens.get(tool.name)||'')+':'+lease.audit.sha256); }); active.push({ id: lease.pack, tools: lease.tools.map(tool => tool.name), task_id: lease.task_id || null });
|
|
50
55
|
}
|
|
51
|
-
const view = { visible, tokens, turn, work, state: { offers: this.offers(actor), required: work?.current?.capabilities
|
|
56
|
+
const view = { visible, tokens, turn, work, state: { offers: this.offers(actor), required: requiredPacks(work?.current?.capabilities, this.offers(actor)), active, pinned: this.pinned(sessionId,actor).filter(name => byName.has(name)), expired, expired_details:expiredDetails, acquisition_grants_permission: false } };
|
|
52
57
|
this.views.set(this.key(sessionId,actor), view); return view.state;
|
|
53
58
|
}
|
|
54
59
|
recoveryCall(operation, actor) {
|
|
@@ -59,7 +64,7 @@ export class ToolPacks {
|
|
|
59
64
|
unavailable(operation, context) {
|
|
60
65
|
if (this.visible(operation,context.sessionId,context.actor)) return;
|
|
61
66
|
const recovery = this.recoveryCall(operation,context.actor); if (!recovery) return;
|
|
62
|
-
return {code:'TOOL_PACK_REQUIRED',message:`Herramienta no disponible: pack no adquirido o expirado para esta etapa/actor. No se ejecutó; no es una denegación del permiso de la operación. Ejecuta recovery_call y espera el siguiente catálogo: ${JSON.stringify(recovery)}
|
|
67
|
+
return {code:'TOOL_PACK_REQUIRED',message:`Herramienta no disponible: pack no adquirido o expirado para esta etapa/actor. No se ejecutó; no es una denegación del permiso de la operación y los permisos se juzgan por separado. Ejecuta recovery_call y espera el siguiente catálogo: ${JSON.stringify({recovery_call:recovery})}`};
|
|
63
68
|
}
|
|
64
69
|
async notifyChanges(sessionId, actor) {
|
|
65
70
|
const state = await this.prepare(sessionId,actor), session = await this.store.load(sessionId), view = this.views.get(this.key(sessionId,actor));
|
|
@@ -122,7 +127,11 @@ export class ToolPacks {
|
|
|
122
127
|
finally { clearTimeout(timer); controller.abort(); }
|
|
123
128
|
}
|
|
124
129
|
options.signal?.throwIfAborted(); await this.prepare(options.sessionId, options.actor);
|
|
125
|
-
|
|
130
|
+
// Tools the query names exactly that this request already offers need no search or acquisition.
|
|
131
|
+
const named = new Set(input.query.split(/[^A-Za-z0-9_]+/).filter(Boolean));
|
|
132
|
+
const availableNow = this.house.registry.catalogue({ actor: options.actor, surface: 'agent' }).map(op => op.name).filter(name => named.has(name) && this.visible(name, options.sessionId, options.actor));
|
|
133
|
+
const now = availableNow.length ? { available_now: availableNow, next_action: `${availableNow.join(', ')} ya está disponible en esta petición: llámala directamente, sin adquirir nada.` } : {};
|
|
134
|
+
return { ...searchToolCatalog(this.catalogue(options.actor), { ...input, inPack: (op, pack) => this.inPack(op, pack), acquireCall: name => this.recoveryCall(name, options.actor) }), ...now, servers: states().filter(item => !selected || item.name === selected).slice(0, 12), ...(refresh ? { refresh } : {}) };
|
|
126
135
|
}
|
|
127
136
|
definition() {
|
|
128
137
|
return { name: 'ToolPack', description: 'Discover/acquire/release temporary schemas (browser.verify, browser.capture, mcp:SERVER, plugin:ID, house.audit, sessions.peers, provider.features, workspace.inspect, workspace.git). search with query returns <=5 ranked canonical tools and exact acquire_call; search does not acquire. list with pack returns its tool names; list without pack returns offers only. browser.capture supplies inline screenshots; browser.verify supplies navigation/DOM snapshot/console/network ONLY. For interaction acquire mcp:chrome-devtools with tools:["mcp__chrome-devtools__click"] or ["mcp__chrome-devtools__evaluate_script"] before use. Acquired schemas appear NEXT request. Select <=12 canonical names or unique short tool names scoped to that pack; the host returns canonical names. Missing server uses separate MCP config/connection judgment. No permission grant.',
|
|
@@ -132,7 +141,14 @@ export class ToolPacks {
|
|
|
132
141
|
const { sessionId, actor } = options;
|
|
133
142
|
await this.prepare(sessionId,actor);
|
|
134
143
|
if (input.action === 'search') return JSON.stringify(await this.search(input, options));
|
|
135
|
-
if (input.action === 'list')
|
|
144
|
+
if (input.action === 'list') {
|
|
145
|
+
const state = this.views.get(this.key(sessionId,actor)).state;
|
|
146
|
+
if (!input.pack) return JSON.stringify(state);
|
|
147
|
+
const tools = this.catalogue(actor).filter(op => this.inPack(op,input.pack)).map(op => op.name);
|
|
148
|
+
// A plan capability such as visual:COMPONENT is not a pack; say so instead of an empty list.
|
|
149
|
+
const known = tools.length || (state.offers || []).some(offer => offer.id === input.pack);
|
|
150
|
+
return JSON.stringify({ ...state, tools, ...(known ? {} : { unknown_pack: input.pack, next_action: `No hay un pack ${input.pack}. Usa uno de offers[].id (${(state.offers || []).map(offer => offer.id).join(', ')}); las capabilities de una tarea del plan no son packs.` }) });
|
|
151
|
+
}
|
|
136
152
|
if (!input.pack) throw new Error('ToolPack requiere pack.');
|
|
137
153
|
if (input.action === 'release') {
|
|
138
154
|
await options.recordWork({ type: 'tool_pack_lease', source: 'capability_host', action: 'release', pack: input.pack });
|
package/src/tool-schema.mjs
CHANGED
|
@@ -24,7 +24,13 @@ const schemas = (node, path = '') => {
|
|
|
24
24
|
|
|
25
25
|
// A transport may remove dialect metadata only under an explicit identical dialect, never assertions.
|
|
26
26
|
// Missing capability information is explicitly unverified passthrough, not an invented guarantee.
|
|
27
|
-
|
|
27
|
+
// Exception, opted in by an adapter whose API rejects them (Anthropic: "input_schema does not support
|
|
28
|
+
// oneOf, allOf, or anyOf at the top level"): top-level combinators leave the transport schema, are
|
|
29
|
+
// recorded as a validation-changing transform, and are restated in the description. The host contract
|
|
30
|
+
// (Ajv over the canonical schema) keeps enforcing them; the provider only loses a hint.
|
|
31
|
+
const TOP_LEVEL_COMBINATORS = ['allOf', 'anyOf', 'oneOf'];
|
|
32
|
+
const HOISTED_NOTE_LIMIT = 1500;
|
|
33
|
+
export function compileToolSchemas(tools, { provider = 'unknown', capabilities = {}, catalogue = [], hoistTopLevelCombinators = false } = {}) {
|
|
28
34
|
const policy = normalizeToolSchemaCapabilities(capabilities.tool_schema), diagnostics = [], manifest = [];
|
|
29
35
|
const compiled = tools.map(tool => {
|
|
30
36
|
if (!tool?.input_schema || typeof tool.input_schema !== 'object' || Array.isArray(tool.input_schema)) throw Object.assign(new Error('Definición de herramienta sin schema de entrada válido; no se envió al proveedor.'), { code: 'TOOL_SCHEMA_UNSUPPORTED', details: { provider, diagnostics: [{ name: tool?.name || null, status: 'unsupported', rejected: [{ path: '/', reason: 'missing_or_invalid_input_schema' }] }], executed: false } });
|
|
@@ -40,11 +46,17 @@ export function compileToolSchemas(tools, { provider = 'unknown', capabilities =
|
|
|
40
46
|
if (policy.transport_dialect !== transport.$schema) rejected.push({ keyword: '$schema', path: '/$schema', reason: 'cannot_remove_dialect_without_identical_transport_dialect' });
|
|
41
47
|
else { delete transport.$schema; transforms.push({ path: '/$schema', action: 'omit_identical_dialect_annotation', changes_validation: false }); }
|
|
42
48
|
}
|
|
49
|
+
let description = tool.description;
|
|
50
|
+
if (hoistTopLevelCombinators) {
|
|
51
|
+
const hoisted = Object.fromEntries(TOP_LEVEL_COMBINATORS.filter(keyword => Object.hasOwn(transport, keyword)).map(keyword => [keyword, transport[keyword]]));
|
|
52
|
+
for (const keyword of Object.keys(hoisted)) { delete transport[keyword]; transforms.push({ path: '/' + keyword, action: 'hoist_top_level_combinator_to_host', changes_validation: true, enforced_by: 'host_contract' }); }
|
|
53
|
+
if (Object.keys(hoisted).length) { const note = JSON.stringify(hoisted); description = `${description || ''}\nInput must also satisfy (host-validated): ${note.length > HOISTED_NOTE_LIMIT ? note.slice(0, HOISTED_NOTE_LIMIT) + '…' : note}`; }
|
|
54
|
+
}
|
|
43
55
|
const status = rejected.length ? 'unsupported' : policy.supported_keywords || policy.unsupported_keywords || policy.dialects ? 'compatible_with_declared_capabilities' : 'unverified_passthrough';
|
|
44
56
|
diagnostics.push({ name: tool.name, provider, status, transforms, rejected, authority: 'transport_compatibility_only' });
|
|
45
57
|
const contract = catalogue.find(item => item.name === tool.name);
|
|
46
58
|
manifest.push({ name: tool.name, provider, upstream_schema_digest: contract?.origin?.schema_provenance?.upstream_digest || null, canonical_schema_digest: contract?.input_schema ? digest(contract.input_schema) : null, offered_schema_digest: digest(canonical), transport_schema_digest: digest(transport), status });
|
|
47
|
-
return { ...tool, input_schema: transport };
|
|
59
|
+
return { ...tool, ...(description !== tool.description ? { description } : {}), input_schema: transport };
|
|
48
60
|
});
|
|
49
61
|
if (diagnostics.some(item => item.status === 'unsupported')) throw Object.assign(new Error('El proveedor no admite el schema de herramientas; no se enviaron ni debilitaron restricciones. ' + diagnostics.filter(item => item.status === 'unsupported').map(item => item.name).join(', ')), { code: 'TOOL_SCHEMA_UNSUPPORTED', details: { provider, diagnostics, manifest, executed: false } });
|
|
50
62
|
return { tools: compiled, diagnostics, manifest };
|