@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.
Files changed (115) hide show
  1. package/README.md +1 -1
  2. package/backend/contracts/remote-commands-v1.json +0 -12
  3. package/backend/contracts/remote-device-v1.json +423 -0
  4. package/backend/contracts/remote-relay-answers-v1.json +209 -0
  5. package/package.json +4 -1
  6. package/skills/mv3/SKILL.md +44 -0
  7. package/skills/mv3/skill.json +4 -0
  8. package/src/agent.mjs +299 -32
  9. package/src/auto-guard.mjs +40 -3
  10. package/src/autocompact.mjs +233 -0
  11. package/src/bash-timeouts.mjs +29 -0
  12. package/src/child-env.mjs +23 -0
  13. package/src/cli.mjs +261 -62
  14. package/src/code-run-artifact.mjs +30 -0
  15. package/src/code-run-journal.mjs +135 -0
  16. package/src/code-run-session-proof.mjs +50 -0
  17. package/src/code-run-session.mjs +97 -0
  18. package/src/code-run-worker.mjs +20 -4
  19. package/src/code-run.mjs +49 -14
  20. package/src/codex-provider.mjs +4 -4
  21. package/src/compaction-notice.mjs +22 -0
  22. package/src/compaction.mjs +162 -23
  23. package/src/connection-settings.mjs +36 -4
  24. package/src/context-pressure.mjs +5 -4
  25. package/src/context.mjs +27 -22
  26. package/src/execution-metrics.mjs +36 -5
  27. package/src/file-recovery-proof.mjs +9 -1
  28. package/src/file-recovery.mjs +36 -3
  29. package/src/gemini-provider.mjs +5 -3
  30. package/src/git-integration-operations.mjs +2 -1
  31. package/src/git-plumbing.mjs +2 -1
  32. package/src/git-source.mjs +2 -1
  33. package/src/git-workspaces.mjs +2 -1
  34. package/src/house.mjs +13 -1
  35. package/src/i18n/cli-es.mjs +12 -1
  36. package/src/i18n/es.mjs +38 -1
  37. package/src/judges.mjs +2 -1
  38. package/src/mcp.mjs +2 -1
  39. package/src/model-controls.mjs +16 -2
  40. package/src/model-deadline.mjs +2 -2
  41. package/src/openai-provider.mjs +8 -4
  42. package/src/operations.mjs +17 -2
  43. package/src/package-identity.mjs +68 -0
  44. package/src/peer-probe.mjs +16 -0
  45. package/src/permissions.mjs +97 -4
  46. package/src/plugin-config.mjs +18 -1
  47. package/src/plugin-controls.mjs +31 -8
  48. package/src/plugin-trust.mjs +15 -6
  49. package/src/plugins/chrome-lab/bridge.mjs +19 -6
  50. package/src/plugins/chrome-lab/client.mjs +242 -12
  51. package/src/plugins/chrome-lab/index.mjs +106 -24
  52. package/src/plugins/chrome-lab/legacy-fill.mjs +25 -0
  53. package/src/plugins/chrome-lab/native-reply.mjs +31 -0
  54. package/src/plugins/open-relay/actions.mjs +60 -9
  55. package/src/plugins/open-relay/client.mjs +3 -3
  56. package/src/plugins/open-relay/decision.mjs +3 -2
  57. package/src/plugins/open-relay/index.mjs +17 -7
  58. package/src/plugins/open-relay/lookup.mjs +41 -4
  59. package/src/plugins/open-relay/notices.mjs +44 -3
  60. package/src/plugins/open-relay/panel.mjs +4 -1
  61. package/src/plugins/open-relay/watch.mjs +27 -8
  62. package/src/process-lock.mjs +20 -12
  63. package/src/provider-auth.mjs +6 -3
  64. package/src/provider-profiles.mjs +13 -3
  65. package/src/provider.mjs +175 -30
  66. package/src/qr-scan.mjs +240 -0
  67. package/src/read-only-check.mjs +6 -1
  68. package/src/relay/broker.mjs +173 -0
  69. package/src/relay/cards.mjs +702 -0
  70. package/src/relay/flow.mjs +886 -0
  71. package/src/relay/follow.mjs +119 -0
  72. package/src/relay/git.mjs +23 -0
  73. package/src/relay/hub-client.mjs +163 -0
  74. package/src/relay/state.mjs +55 -0
  75. package/src/remote-control.mjs +283 -15
  76. package/src/remote-device-card.mjs +111 -0
  77. package/src/remote-device.mjs +92 -0
  78. package/src/remote-permissions.mjs +159 -0
  79. package/src/remote-relay.mjs +468 -0
  80. package/src/remote-stats.mjs +205 -0
  81. package/src/session-bus.mjs +13 -3
  82. package/src/session-provider-settings.mjs +2 -2
  83. package/src/skill-runtime.mjs +8 -6
  84. package/src/source-ownership.mjs +3 -1
  85. package/src/tool-manifest.mjs +71 -0
  86. package/src/tool-packs.mjs +22 -6
  87. package/src/tool-schema.mjs +14 -2
  88. package/src/tools.mjs +96 -27
  89. package/src/tui/controller.mjs +4 -1
  90. package/src/tui/discovery.mjs +17 -7
  91. package/src/tui/inbound-blocks.mjs +5 -1
  92. package/src/tui/screen.mjs +53 -14
  93. package/src/tui/swarm-panel.mjs +19 -8
  94. package/src/turn-timing.mjs +82 -0
  95. package/src/usage-accounting.mjs +12 -1
  96. package/src/vision.mjs +6 -1
  97. package/src/visual/chrome.mjs +379 -0
  98. package/src/visual/fingerprint.mjs +30 -0
  99. package/src/visual/fixtures.mjs +201 -0
  100. package/src/visual/gate.mjs +92 -0
  101. package/src/visual/judge.mjs +114 -0
  102. package/src/visual/ledger.mjs +160 -0
  103. package/src/visual/measure.mjs +145 -0
  104. package/src/visual/names.mjs +7 -0
  105. package/src/visual/operations.mjs +211 -0
  106. package/src/visual/pixel-diff.mjs +96 -0
  107. package/src/visual/png.mjs +103 -0
  108. package/src/visual/registry.mjs +265 -0
  109. package/src/visual/report.mjs +64 -0
  110. package/src/visual/verify.mjs +292 -0
  111. package/src/work-assertions.mjs +15 -4
  112. package/src/work-plan.mjs +166 -30
  113. package/src/work-state.mjs +4 -4
  114. package/src/workflows/chrome-task-adoption.mjs +7 -3
  115. package/src/workspace-serve.mjs +59 -0
@@ -13,6 +13,11 @@ export function protectPath(directory, { reason, names = [] } = {}) {
13
13
  protectedPaths.set(key, { directory: path.resolve(directory), reason: reason || 'Ruta protegida por un plugin activo.', names: names.filter(name => typeof name === 'string' && name.length >= 3) });
14
14
  return () => protectedPaths.delete(key);
15
15
  }
16
+ /** ¿Está `file` (absoluta) bajo una ruta que un plugin activo protege? */
17
+ export function isProtectedPath(file) {
18
+ for (const { directory } of protectedPaths.values()) { const relative = path.relative(directory, file); if (relative === '' || (relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative))) return true; }
19
+ return false;
20
+ }
16
21
  export function protectedPathMention(command, cwd) {
17
22
  if (!protectedPaths.size || typeof command !== 'string') return null;
18
23
  const home = os.homedir(), tokens = command.split(/[\s'"`;&|()<>=]+/).filter(Boolean);
@@ -24,13 +29,43 @@ export function protectedPathMention(command, cwd) {
24
29
  const expanded = token.startsWith('~/') ? path.join(home, token.slice(2)) : token;
25
30
  const resolved = path.resolve(cwd || process.cwd(), expanded.replace(/[*?\[\]{}].*$/, '') || '.');
26
31
  if (resolved === directory || resolved.startsWith(directory + path.sep)) return entry.reason;
27
- // Un glob o un prefijo que alcanza la ruta protegida también la menciona.
28
- if (/[*?\[\]{}]/.test(expanded) && directory.startsWith(resolved)) return entry.reason;
32
+ // Un glob que puede expandirse a la ruta protegida o a uno de sus padres también la menciona.
33
+ if (/[*?\[\]{}]/.test(expanded) && globReaches(path.resolve(cwd || process.cwd(), expanded), directory)) return entry.reason;
29
34
  }
30
35
  }
31
36
  return null;
32
37
  }
33
38
 
39
+ // Whether an absolute glob could expand to `directory` or to one of its parents, compared
40
+ // segment by segment. Like bash, a wildcard does not match a leading dot and an unclosed
41
+ // brace is literal. When unsure (`**`, a brace group spanning a slash), it answers yes.
42
+ function globReaches(pattern, directory) {
43
+ if (/\{[^{}]*\/[^{}]*\}/.test(pattern)) return true;
44
+ const wanted = pattern.split(path.sep), target = directory.split(path.sep);
45
+ for (let i = 0; i < Math.min(wanted.length, target.length); i++) {
46
+ const segment = wanted[i];
47
+ if (segment.includes('**')) return true;
48
+ const braces = /\{[^{}]*,[^{}]*\}/.test(segment);
49
+ if (!/[*?\[\]{}]/.test(segment)) { if (segment !== target[i]) return false; continue; }
50
+ let source = '', depth = 0;
51
+ for (let j = 0; j < segment.length; j++) {
52
+ const c = segment[j];
53
+ if (c === '*') source += '[^/]*';
54
+ else if (c === '?') source += '[^/]';
55
+ else if (c === '[') { const end = segment.indexOf(']', j + 2); if (end < 0) source += '\\['; else { source += '[^/]'; j = end; } }
56
+ else if (c === '{' && braces) { source += '(?:'; depth++; }
57
+ else if (c === ',' && depth) source += '|';
58
+ else if (c === '}' && depth) { source += ')'; depth--; }
59
+ else source += c.replace(/[.+^$()|\\\]{}]/g, '\\$&');
60
+ }
61
+ if (depth) return true;
62
+ if (target[i].startsWith('.') && /^[*?\[]/.test(segment)) return false;
63
+ let matches; try { matches = new RegExp(`^${source}$`).test(target[i]); } catch { return true; }
64
+ if (!matches) return false;
65
+ }
66
+ return true;
67
+ }
68
+
34
69
  // Deliberately limited lexer. Unsupported expansions/wrappers require human review.
35
70
  export function shellCommands(command) {
36
71
  const commands = []; let tokens = [], token = '', quote = '', started = false;
@@ -96,7 +131,9 @@ export function inspectAutoAction(name, input, cwd) {
96
131
  if (executable === 'git' && (args.includes('--hard') || args.includes('clean') || args.some(a => /^--force(?:-with-lease)?$/.test(a) || /^-[^-]*f/.test(a)))) destructive = true;
97
132
  if (['dd', 'docker', 'kubectl', 'terraform', 'psql', 'mysql', 'sqlite3'].includes(executable)) { opaque = true; destructive = true; }
98
133
  const commandLookup = executable === 'command' && args[0] === '-v' && args.length > 1 && args.slice(1).every(value => !value.startsWith('-'));
99
- const testRunner = (executable === 'node' && args[0] === '--test') || (executable.startsWith('python') && args[0] === '-m' && args[1] === 'pytest');
134
+ // `node --check FILE…` only parses the files; any other flag could load or run code.
135
+ const syntaxCheck = executable === 'node' && ['--check', '-c'].includes(args[0]) && args.length > 1 && args.slice(1).every(value => !value.startsWith('-'));
136
+ const testRunner = syntaxCheck || (executable === 'node' && args[0] === '--test') || (executable.startsWith('python') && args[0] === '-m' && args[1] === 'pytest');
100
137
  if (/^(?:sh|bash|zsh|fish|node|python\d*|ruby|perl|php|eval|exec|xargs|env|command|timeout|busybox|npx|for|while|until|if|then|do|done|case|esac|function)$/.test(executable) && !testRunner && !commandLookup) opaque = true;
101
138
  if (tokens[0].includes('=')) opaque = true;
102
139
  if (executable === 'find' && args.some(arg => ['-delete', '-exec', '-execdir'].includes(arg))) { destructive = true; opaque = true; }
@@ -0,0 +1,233 @@
1
+ // Compaction timing policy. The compaction window is a target for proactive
2
+ // projection, separate from the model's physical context window: large windows
3
+ // stay cheaper and sharper when the conversation is projected long before the
4
+ // physical limit. All thresholds are in "used" tokens = estimated input plus the
5
+ // output reserve. The projection itself stays deterministic (compaction.mjs).
6
+ import { readFile, mkdir, writeFile, rename, unlink } from 'node:fs/promises';
7
+ import path from 'node:path';
8
+ import os from 'node:os';
9
+ import { createHash, randomUUID } from 'node:crypto';
10
+ import { comalEnvironment } from './compat.mjs';
11
+ import { t, registerMessages } from './i18n/index.mjs';
12
+
13
+ registerMessages({
14
+ 'Use /autocompact N|Nk|auto|off|default (for example 400k).': 'Usa /autocompact N|Nk|auto|off|default (por ejemplo 400k).',
15
+ 'The compaction window must be between 4096 and 2000000 tokens.': 'La ventana de compactación debe estar entre 4096 y 2000000 tokens.',
16
+ '--autocompact / COMAL_AUTOCOMPACT: {reason}': '--autocompact / COMAL_AUTOCOMPACT: {reason}',
17
+ 'COMAL_CACHE_TTL_MS must be off or an integer between 1000 and 86400000.': 'COMAL_CACHE_TTL_MS debe ser off o un entero entre 1000 y 86400000.',
18
+ 'Saved autocompact settings exceed 16 KiB.': 'La configuración guardada de autocompact excede 16 KiB.',
19
+ 'Invalid saved autocompact settings.': 'Configuración guardada de autocompact inválida.',
20
+ });
21
+
22
+ export const LARGE_WINDOW = 600000, LARGE_DEFAULT = 400000, SOFT_RATIO = .75;
23
+ export const AUTO_MIN = 300000, AUTO_LARGE_CAP = 600000, AUTO_FACTOR = 1.3, AUTO_MIN_SAMPLES = 3, AUTO_SAMPLES = 50;
24
+ // Provider prompt-cache lifetimes known to the host. Anthropic's default
25
+ // ephemeral cache lives 5 minutes; other providers are opt-in (COMAL_CACHE_TTL_MS).
26
+ export const CACHE_TTL_MS = Object.freeze({ anthropic: 300000 });
27
+ // Relative input-token prices used for the cost estimate: Anthropic 5-minute
28
+ // cache writes cost 1.25x input, cache reads 0.1x. Recorded as an assumption.
29
+ export const CACHE_RATES = Object.freeze({ basis: 'anthropic_ephemeral_5m_relative', uncached: 1, cache_write: 1.25, cache_read: .1 });
30
+
31
+ /** N | Nk | Nm | auto | off | default → setting. `default` means "no per-model value". */
32
+ export function parseAutocompact(text) {
33
+ const value = String(text ?? '').trim().toLowerCase();
34
+ if (['auto', 'off', 'default'].includes(value)) return { mode: value };
35
+ const match = /^(\d+(?:\.\d+)?)\s*([km])?$/.exec(value);
36
+ if (!match) throw Object.assign(new Error(t('Use /autocompact N|Nk|auto|off|default (for example 400k).')), { code: 'AUTOCOMPACT_INVALID' });
37
+ const tokens = Math.round(Number(match[1]) * (match[2] === 'k' ? 1000 : match[2] === 'm' ? 1000000 : 1));
38
+ if (!Number.isSafeInteger(tokens) || tokens < 4096 || tokens > 2000000) throw Object.assign(new Error(t('The compaction window must be between 4096 and 2000000 tokens.')), { code: 'AUTOCOMPACT_INVALID' });
39
+ return { mode: 'fixed', tokens };
40
+ }
41
+ const serialize = setting => setting.mode === 'fixed' ? setting.tokens : setting.mode;
42
+ const deserialize = value => typeof value === 'number' ? parseAutocompact(String(value)) : parseAutocompact(value);
43
+
44
+ export function percentile(values, ratio) {
45
+ const sorted = values.filter(Number.isFinite).sort((a, b) => a - b);
46
+ if (!sorted.length) return null;
47
+ return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(ratio * sorted.length) - 1))];
48
+ }
49
+
50
+ /**
51
+ * Resolves the compaction window and thresholds for one request shape.
52
+ * setting: {mode:'fixed',tokens}|{mode:'auto'}|{mode:'off'}|null (default).
53
+ * peaks: recent per-task peaks of this project (used tokens), newest last.
54
+ */
55
+ export function compactionPlan({ contextWindow, outputReserve = 0, overhead = 0, setting = null, settingSource = 'default', peaks = [], triggerRatio = .9 }) {
56
+ const physical = Math.floor(contextWindow * triggerRatio);
57
+ const floor = 2 * outputReserve + Math.max(0, Math.ceil(overhead));
58
+ const base = contextWindow >= LARGE_WINDOW ? LARGE_DEFAULT : physical;
59
+ const mode = setting?.mode && setting.mode !== 'default' ? setting.mode : 'default';
60
+ let window = base, source = 'default', reason = contextWindow >= LARGE_WINDOW ? 'large_window_default' : 'trigger_ratio_of_window', auto;
61
+ if (mode === 'off') { window = physical; source = 'off'; reason = 'proactive_compaction_disabled'; }
62
+ else if (mode === 'fixed') { window = setting.tokens; source = 'user'; reason = 'user_setting'; }
63
+ else if (mode === 'auto') {
64
+ source = 'auto';
65
+ const samples = peaks.filter(value => Number.isFinite(value) && value > 0).slice(-AUTO_SAMPLES), cap = contextWindow >= LARGE_WINDOW ? AUTO_LARGE_CAP : physical;
66
+ const minimum = Math.min(AUTO_MIN, cap), p90 = percentile(samples, .9);
67
+ auto = { samples: samples.length, p90_peak: p90, factor: AUTO_FACTOR, minimum, cap };
68
+ if (samples.length < AUTO_MIN_SAMPLES) { window = base; reason = 'auto_insufficient_samples'; }
69
+ else { window = Math.min(cap, Math.max(minimum, Math.ceil(p90 * AUTO_FACTOR))); reason = 'auto_p90_task_peak'; }
70
+ }
71
+ const clamps = [];
72
+ if (window > physical) { window = physical; clamps.push('physical_window'); }
73
+ if (window < floor) { window = Math.min(floor, physical); clamps.push('lower_bound'); }
74
+ return {
75
+ policy: 'compaction_window_v1', units: 'input_plus_output_reserve', context_window: contextWindow, physical_threshold: physical,
76
+ compaction_window: window, source, setting_source: mode === 'default' ? 'default' : settingSource, reason, clamps, lower_bound: floor,
77
+ soft_ratio: mode === 'off' ? null : SOFT_RATIO, soft_threshold: mode === 'off' ? null : Math.floor(window * SOFT_RATIO), hard_threshold: window,
78
+ ...(auto ? { auto } : {}),
79
+ };
80
+ }
81
+
82
+ /** One human line explaining where the compaction window comes from. */
83
+ export function describePlan(plan) {
84
+ const k = value => value >= 10000 ? `${Math.round(value / 1000)}k` : String(value);
85
+ const clamps = plan.clamps.length ? ` · ${plan.clamps.map(item => item === 'lower_bound' ? t('raised to the lower bound {v0}', { v0: k(plan.lower_bound) }) : t('capped at {v0}', { v0: k(plan.physical_threshold) })).join(' · ')}` : '';
86
+ if (plan.source === 'auto') {
87
+ if (plan.reason === 'auto_insufficient_samples') return t('auto: {v0} task peaks recorded (needs {v1}); using the default', { v0: plan.auto.samples, v1: AUTO_MIN_SAMPLES }) + clamps;
88
+ return t('auto: p90 task peak {v0} × {v1} over {v2} tasks, bounded {v3}–{v4}', { v0: k(plan.auto.p90_peak), v1: plan.auto.factor, v2: plan.auto.samples, v3: k(plan.auto.minimum), v4: k(plan.auto.cap) }) + clamps;
89
+ }
90
+ if (plan.source === 'user') return t('user setting ({v0})', { v0: plan.setting_source }) + clamps;
91
+ if (plan.source === 'off') return t('off: only the physical limit compacts') + clamps;
92
+ return (plan.reason === 'large_window_default' ? t('default for windows ≥ {v0}', { v0: k(LARGE_WINDOW) }) : t('default: {v0} of the window', { v0: '0.9' })) + clamps;
93
+ }
94
+ registerMessages({
95
+ 'raised to the lower bound {v0}': 'elevada al mínimo {v0}', 'capped at {v0}': 'limitada a {v0}',
96
+ 'auto: {v0} task peaks recorded (needs {v1}); using the default': 'auto: {v0} picos de tarea registrados (necesita {v1}); usa el valor por omisión',
97
+ 'auto: p90 task peak {v0} × {v1} over {v2} tasks, bounded {v3}–{v4}': 'auto: pico p90 de tarea {v0} × {v1} sobre {v2} tareas, acotado {v3}–{v4}',
98
+ 'user setting ({v0})': 'ajuste del usuario ({v0})', 'off: only the physical limit compacts': 'off: sólo compacta el límite físico',
99
+ 'default for windows ≥ {v0}': 'valor por omisión para ventanas ≥ {v0}', 'default: {v0} of the window': 'por omisión: {v0} de la ventana',
100
+ });
101
+
102
+ /** Cache TTL for the idle rule: explicit setting, else the provider's known TTL, else none. */
103
+ export function resolveCacheTtl(env = process.env) {
104
+ const value = comalEnvironment(env).COMAL_CACHE_TTL_MS;
105
+ if (value === undefined || value === '') return undefined;
106
+ if (/^(?:off|0|none)$/i.test(String(value).trim())) return null;
107
+ const number = Number(value);
108
+ if (!Number.isSafeInteger(number) || number < 1000 || number > 86400000) throw new Error(t('COMAL_CACHE_TTL_MS must be off or an integer between 1000 and 86400000.'));
109
+ return number;
110
+ }
111
+
112
+ // ---- Per-model persistence: $XDG_CONFIG_HOME/comal/autocompact.json -------
113
+ export const autocompactFile = (env = process.env) => path.join(env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'comal', 'autocompact.json');
114
+ function normalizeSaved(value) {
115
+ if (!value || typeof value !== 'object' || Array.isArray(value) || value.version !== 1 || !value.models || typeof value.models !== 'object' || Array.isArray(value.models)) throw new Error(t('Invalid saved autocompact settings.'));
116
+ const models = {};
117
+ for (const [model, setting] of Object.entries(value.models)) {
118
+ if (!model || model.length > 256 || /[\x00-\x1f\x7f]/.test(model)) throw new Error(t('Invalid saved autocompact settings.'));
119
+ const parsed = deserialize(setting); if (parsed.mode === 'default') continue;
120
+ models[model] = serialize(parsed);
121
+ }
122
+ return { version: 1, models };
123
+ }
124
+ export async function loadAutocompactSettings({ env = process.env } = {}) {
125
+ try {
126
+ const text = await readFile(autocompactFile(env), 'utf8');
127
+ if (Buffer.byteLength(text) > 16384) throw new Error(t('Saved autocompact settings exceed 16 KiB.'));
128
+ return normalizeSaved(JSON.parse(text));
129
+ } catch (error) { if (error.code === 'ENOENT') return { version: 1, models: {} }; throw error; }
130
+ }
131
+ /** Saves (or with mode 'default' removes) the value for one model. Same atomic, private write as connection.json. */
132
+ export async function saveAutocompactSetting(model, setting, { env = process.env } = {}) {
133
+ const current = await loadAutocompactSettings({ env }).catch(() => ({ version: 1, models: {} })), models = { ...current.models };
134
+ if (setting.mode === 'default') delete models[model]; else models[model] = serialize(setting);
135
+ const data = normalizeSaved({ version: 1, models }), file = autocompactFile(env), temporary = file + '.' + randomUUID(), text = JSON.stringify(data) + '\n';
136
+ if (Buffer.byteLength(text) > 16384) throw new Error(t('Saved autocompact settings exceed 16 KiB.'));
137
+ await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
138
+ try { await writeFile(temporary, text, { flag: 'wx', mode: 0o600 }); await rename(temporary, file); }
139
+ finally { await unlink(temporary).catch(error => { if (error.code !== 'ENOENT') throw error; }); }
140
+ return data;
141
+ }
142
+ /** Precedence: --autocompact > COMAL_AUTOCOMPACT > saved value for the model > default. */
143
+ export function resolveAutocompactSetting({ flag, env = process.env, saved = { models: {} }, model } = {}) {
144
+ const variable = comalEnvironment(env).COMAL_AUTOCOMPACT;
145
+ const pick = (value, source) => { try { const setting = parseAutocompact(value); return setting.mode === 'default' ? null : { setting, source }; } catch (error) { throw new Error(t('--autocompact / COMAL_AUTOCOMPACT: {reason}', { reason: error.message })); } };
146
+ if (flag !== undefined && flag !== null && flag !== '') { const chosen = pick(flag, 'cli'); if (chosen) return chosen; }
147
+ else if (variable !== undefined && variable !== '') { const chosen = pick(variable, 'environment'); if (chosen) return chosen; }
148
+ const stored = model ? saved?.models?.[model] : undefined;
149
+ if (stored !== undefined) return { setting: deserialize(stored), source: 'saved' };
150
+ return { setting: null, source: 'default' };
151
+ }
152
+
153
+ // ---- Per-project task peaks: $XDG_STATE_HOME/comal/projects/<hash>/ --------
154
+ // Outside the workspace on purpose: these are host statistics, not project files.
155
+ export const projectStateDir = (cwd, env = process.env) => path.join(comalEnvironment(env).COMAL_CONTEXT_STATS_DIR || path.join(env.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state'), 'comal', 'projects'), createHash('sha256').update(cwd).digest('hex').slice(0, 16));
156
+ export class ContextPeakStats {
157
+ constructor({ cwd, env = process.env, file } = {}) { this.file = file || path.join(projectStateDir(cwd, env), 'context-peaks.json'); this.cache = null; }
158
+ async load() {
159
+ try {
160
+ const text = await readFile(this.file, 'utf8'); if (Buffer.byteLength(text) > 65536) return [];
161
+ const value = JSON.parse(text); return Array.isArray(value?.peaks) ? value.peaks.filter(item => item && Number.isFinite(item.task_tokens)) : [];
162
+ } catch { return []; }
163
+ }
164
+ /** Task peaks in used tokens (input + output reserve), oldest first. */
165
+ async taskPeaks() { this.cache = await this.load(); return this.cache.map(item => item.task_tokens); }
166
+ async record(sample) {
167
+ const peaks = [...await this.load(), { ...sample, at: new Date().toISOString() }].slice(-100), temporary = this.file + '.' + randomUUID();
168
+ await mkdir(path.dirname(this.file), { recursive: true, mode: 0o700 });
169
+ try { await writeFile(temporary, JSON.stringify({ version: 1, peaks }) + '\n', { flag: 'wx', mode: 0o600 }); await rename(temporary, this.file); }
170
+ finally { await unlink(temporary).catch(() => {}); }
171
+ this.cache = peaks; return peaks;
172
+ }
173
+ }
174
+
175
+ /** Estimated effect on the next requests, in input-token equivalents. Only when the provider reported usage. */
176
+ export function compactionCostEstimate({ rows = [], before, after, trigger, overhead = 0 }) {
177
+ const last = rows.findLast(row => row.type === 'model_usage' && row.usage_accounting)?.usage_accounting;
178
+ if (!last || !Number.isSafeInteger(last.context_input_tokens)) return null;
179
+ const warm = trigger !== 'idle_cache_expired', saved = Math.max(0, before - after);
180
+ // The system/tools prefix is not touched by a projection: while warm it stays a cache read.
181
+ const prefix = warm ? Math.max(0, Math.min(overhead, after)) : 0;
182
+ const without = Math.round(before * (warm ? CACHE_RATES.cache_read : CACHE_RATES.cache_write)), withCompaction = Math.round(prefix * CACHE_RATES.cache_read + (after - prefix) * CACHE_RATES.cache_write), following = Math.round(saved * CACHE_RATES.cache_read);
183
+ return { unit: 'input_token_equivalents', rates: CACHE_RATES, cache_assumed: warm ? 'warm' : 'expired', next_request_without_compaction: without, next_request_with_compaction: withCompaction,
184
+ following_request_saving: following, break_even_requests: withCompaction <= without ? 0 : following > 0 ? Math.ceil((withCompaction - without) / following) : null,
185
+ last_measured: { protocol: last.protocol || null, context_input_tokens: last.context_input_tokens, cache_read_tokens: last.cache_read_tokens ?? null, cache_write_tokens: last.cache_write_tokens ?? null } };
186
+ }
187
+
188
+ // ---- Presentation (host text only) ------------------------------------------
189
+ export { compactionNotice } from './compaction-notice.mjs';
190
+ const kilo = value => !Number.isFinite(value) ? '?' : value >= 10000 ? `${Math.round(value / 1000)}k` : String(Math.round(value));
191
+ registerMessages({
192
+ 'Compaction window: {v0} · {v1}': 'Ventana de compactación: {v0} · {v1}',
193
+ 'Thresholds: soft {v0} (at turn end or after the prompt cache expires) · hard {v1} (any request) · physical {v2}': 'Umbrales: suave {v0} (al terminar el turno o al expirar la caché) · duro {v1} (cualquier petición) · físico {v2}',
194
+ 'Thresholds: no proactive compaction · physical {v0}': 'Umbrales: sin compactación proactiva · físico {v0}',
195
+ 'Usage: ~{v0} used (input + output reserve) · {v1}% of the compaction window': 'Uso: ~{v0} usados (entrada + reserva de salida) · {v1}% de la ventana de compactación',
196
+ 'Last compaction: {v0} · ~{v1} → ~{v2} tokens · {v3}': 'Última compactación: {v0} · ~{v1} → ~{v2} tokens · {v3}',
197
+ 'Last compaction: none in this session': 'Última compactación: ninguna en esta sesión',
198
+ 'Idle rule: prompt cache {v0} min ({v1})': 'Regla de inactividad: caché de prompt {v0} min ({v1})',
199
+ 'Idle rule: off (no known prompt-cache lifetime; set COMAL_CACHE_TTL_MS)': 'Regla de inactividad: apagada (sin duración de caché conocida; usa COMAL_CACHE_TTL_MS)',
200
+ 'Pinned focus: {v0}': 'Foco fijado: {v0}',
201
+ 'Change: /autocompact N|Nk|auto|off|default · /compact focus TEXT': 'Cambiar: /autocompact N|Nk|auto|off|default · /compact enfócate en TEXTO',
202
+ 'Compaction window for {v0}: {v1} · {v2}\nSoft {v3} · hard {v4} · lower bound {v5}': 'Ventana de compactación para {v0}: {v1} · {v2}\nSuave {v3} · duro {v4} · mínimo {v5}',
203
+ 'Saved for {v0} in {v1}.': 'Guardado para {v0} en {v1}.',
204
+ 'Applied to this session; not saved: {v0}': 'Aplicado a esta sesión; no guardado: {v0}',
205
+ 'The startup flag --autocompact still wins over the saved value.': 'La bandera --autocompact sigue mandando sobre el valor guardado.',
206
+ 'The environment variable COMAL_AUTOCOMPACT still wins over the saved value.': 'La variable COMAL_AUTOCOMPACT sigue mandando sobre el valor guardado.',
207
+ 'Compaction window per model (N, Nk, auto, off)': 'Ventana de compactación por modelo (N, Nk, auto, off)',
208
+ 'Use /compact or /compact focus TEXT (also: /compact enfócate en TEXTO).': 'Usa /compact o /compact focus TEXT (también: /compact enfócate en TEXTO).',
209
+ 'Focus pinned: {v0} · {v1} results/messages kept verbatim (~{v2} tokens, budget {v3}){v4}.': 'Foco fijado: {v0} · {v1} resultados/mensajes conservados completos (~{v2} tokens, presupuesto {v3}){v4}.',
210
+ ' · {v0} omitted by budget': ' · {v0} omitidos por presupuesto',
211
+ });
212
+ export function formatCompactionStatus({ plan, status, last, cacheTtlMs, protocol, focus }) {
213
+ const used = status.input_tokens + status.output_reserve;
214
+ const lines = [t('Compaction window: {v0} · {v1}', { v0: kilo(plan.compaction_window), v1: describePlan(plan) }),
215
+ plan.soft_threshold === null ? t('Thresholds: no proactive compaction · physical {v0}', { v0: kilo(plan.physical_threshold) })
216
+ : t('Thresholds: soft {v0} (at turn end or after the prompt cache expires) · hard {v1} (any request) · physical {v2}', { v0: kilo(plan.soft_threshold), v1: kilo(plan.hard_threshold), v2: kilo(plan.physical_threshold) }),
217
+ t('Usage: ~{v0} used (input + output reserve) · {v1}% of the compaction window', { v0: kilo(used), v1: Math.round(used / plan.compaction_window * 100) }),
218
+ last ? t('Last compaction: {v0} · ~{v1} → ~{v2} tokens · {v3}', { v0: last.compaction_trigger || last.reason || last.trigger, v1: kilo(last.before_tokens), v2: kilo(last.after_tokens), v3: last.timestamp || '' }) : t('Last compaction: none in this session'),
219
+ Number.isSafeInteger(cacheTtlMs) && cacheTtlMs > 0 ? t('Idle rule: prompt cache {v0} min ({v1})', { v0: Math.round(cacheTtlMs / 60000 * 10) / 10, v1: protocol || 'configured' }) : t('Idle rule: off (no known prompt-cache lifetime; set COMAL_CACHE_TTL_MS)'),
220
+ ...(focus ? [t('Pinned focus: {v0}', { v0: focus })] : []),
221
+ t('Change: /autocompact N|Nk|auto|off|default · /compact focus TEXT')];
222
+ return lines.join('\n');
223
+ }
224
+ export const formatTokens = kilo;
225
+ /** /compact focus TEXT · /compact foco TEXTO · /compact enfócate en TEXTO. Returns null for plain /compact. */
226
+ export function parseCompactArguments(arg) {
227
+ const text = String(arg || '').trim(); if (!text) return { focus: null };
228
+ const match = /^(?:focus(?:\s+on)?|foco|enf[oó]ca(?:te|r)(?:\s+en)?)\s*:?\s+([\s\S]+)$/iu.exec(text);
229
+ if (!match || !match[1].trim()) throw Object.assign(new Error(t('Use /compact or /compact focus TEXT (also: /compact enfócate en TEXTO).')), { code: 'COMPACT_ARGUMENTS' });
230
+ if (match[1].length > 2000) throw Object.assign(new Error(t('Use /compact or /compact focus TEXT (also: /compact enfócate en TEXTO).')), { code: 'COMPACT_ARGUMENTS' });
231
+ return { focus: match[1].trim() };
232
+ }
233
+ registerMessages({ 'Use /model ID first.': 'Usa /model ID primero.' });
@@ -0,0 +1,29 @@
1
+ import { comalEnvironment } from './compat.mjs';
2
+
3
+ // Shell commands (Bash and declared Git checks) get a default timeout and a
4
+ // ceiling the model may ask for. Test suites and builds routinely need minutes,
5
+ // so the default ceiling matches the MCP request limit (30 min, mcp-rpc.mjs).
6
+ export const BASH_DEFAULT_TIMEOUT_MS = 30000;
7
+ export const BASH_DEFAULT_MAX_TIMEOUT_MS = 1800000;
8
+ const HARD_LIMIT_MS = 86400000;
9
+
10
+ export function bashTimeoutSettings(options = {}, env = process.env) {
11
+ env = comalEnvironment(env);
12
+ const maxMs = Number(options.maxTimeoutMs ?? env.COMAL_BASH_MAX_TIMEOUT_MS ?? BASH_DEFAULT_MAX_TIMEOUT_MS);
13
+ if (!Number.isSafeInteger(maxMs) || maxMs < 1000 || maxMs > HARD_LIMIT_MS) throw new Error(`COMAL_BASH_MAX_TIMEOUT_MS must be an integer between 1000 and ${HARD_LIMIT_MS} ms.`);
14
+ const defaultMs = Number(options.timeoutMs ?? env.COMAL_BASH_TIMEOUT_MS ?? Math.min(BASH_DEFAULT_TIMEOUT_MS, maxMs));
15
+ if (!Number.isSafeInteger(defaultMs) || defaultMs < 1 || defaultMs > maxMs) throw new Error(`COMAL_BASH_TIMEOUT_MS must be an integer between 1 and the ceiling (${maxMs} ms).`);
16
+ return { defaultMs, maxMs };
17
+ }
18
+
19
+ // Read once per process: tool schemas are fixed when the tool catalogue loads.
20
+ export const BASH_TIMEOUTS = Object.freeze(bashTimeoutSettings());
21
+
22
+ export function bashTimeoutSchema({ maxMs } = BASH_TIMEOUTS) {
23
+ return { type: 'integer', minimum: 1, maximum: maxMs };
24
+ }
25
+
26
+ // Kept short: this text is in every request's tool catalogue.
27
+ export function bashDescription({ defaultMs, maxMs } = BASH_TIMEOUTS) {
28
+ return `Shell command, after permission (NOT an OS sandbox). Use ListDirectory/Glob/Grep/Read to list/find/read files, also in --add-dir dirs (no permission). Timeout ${defaultMs} ms; pass a longer one (max ${maxMs}) for test suites and builds. Serve test pages with WorkspaceServe, not a background server.`;
29
+ }
@@ -0,0 +1,23 @@
1
+ // Environment for processes Comal starts on behalf of the model (Bash, MCP servers, Git).
2
+ // Provider credentials stay in the Comal process: a command the model writes never
3
+ // inherits them, so `env` or a script cannot print or send the key.
4
+ const PROVIDER_SECRET = /^(?:COMAL|HARNESS|OPENAI|ANTHROPIC|GEMINI|GOOGLE|OLLAMA|DEEPSEEK|OPENROUTER|MISTRAL|GROQ|XAI|AZURE_OPENAI)_(?:API_KEY|AUTH_TOKEN|ACCESS_TOKEN)$/;
5
+ const VALID_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
6
+ const registered = new Set();
7
+
8
+ // Names declared by a provider profile (apiKeyEnv) or `login --api-key-env` are secrets too.
9
+ export function registerSecretEnv(name) {
10
+ if (typeof name === 'string' && VALID_NAME.test(name)) registered.add(name);
11
+ }
12
+
13
+ export function isProviderSecretEnv(name) {
14
+ return PROVIDER_SECRET.test(name) || registered.has(name);
15
+ }
16
+
17
+ // A copy of env without provider credentials; `extra` (explicit per-process values, e.g.
18
+ // an MCP server's own env block) is applied afterwards and kept as configured.
19
+ export function childEnvironment(env = process.env, extra = {}) {
20
+ const out = {};
21
+ for (const [key, value] of Object.entries(env)) if (value !== undefined && !isProviderSecretEnv(key)) out[key] = value;
22
+ return { ...out, ...extra };
23
+ }