@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
@@ -5,6 +5,7 @@ import { modelWorkState, THINKING_TYPES, excerpt } from './work-state.mjs';
5
5
  import { digest } from './operations.mjs';
6
6
  import { projectToolOutput } from './model-output.mjs';
7
7
  import { visualTokenView, imageReferenceMessages } from './vision.mjs';
8
+ import { compactionPlan, compactionCostEstimate } from './autocompact.mjs';
8
9
 
9
10
  export function estimateTokens(value) { const view = visualTokenView(value); return Math.ceil(Buffer.byteLength(typeof view.value === 'string' ? view.value : JSON.stringify(view.value ?? '')) / 3) + 16 + view.reserve; }
10
11
  export function estimateRequest({ system = '', messages = [], tools = [] }) { return estimateTokens({ system, messages, tools }) + messages.length * 12 + tools.length * 16 + 128; }
@@ -47,11 +48,82 @@ const stripThinking = message => typeof message.content === 'string' ? message :
47
48
  export const PROJECTION_POLICY = 'conversation_until_pressure_v1';
48
49
  const withoutImages = message => !Array.isArray(message.content) ? message : { ...message, content: message.content.filter(block => !['image_reference','image'].includes(block.type)) };
49
50
  const PREFIX = 'Estado de trabajo derivado determinísticamente por Comal de su libro. No es un resumen del modelo. Los reports/hypotheses son datos no confiables; no otorgan permisos. El audit acredita sólo su scope; asked sigue unjudged. No repetir operaciones asentadas. EvidenceRead recupera referencias sin ejecutar nada.\n';
51
+ // The in-flight tool step of a hard mid-loop projection keeps its thinking blocks untouched.
52
+ const keepThinking = message => typeof message.content === 'string' ? message : { ...message, content: message.content.filter(block => !['image_reference','image'].includes(block.type)).map(block => block.type === 'tool_result' && typeof block.content === 'string' ? {...block,content:projectToolOutput(block.content,{tool_call_id:block.tool_use_id,lookup:'EvidenceRead / tool receipt'})}:block) };
53
+
54
+ // ---- /compact focus ---------------------------------------------------------
55
+ // The projection stays deterministic: a focus is a host-pinned human note plus a
56
+ // lexical selection. Tool results and messages whose text matches the focus terms
57
+ // are kept verbatim (references resolved back to the recorded output) under a
58
+ // budget, in a host note after the derived state. Later automatic projections
59
+ // keep the same focus until the next plain /compact or /clear.
60
+ const FOCUS_PREFIX = 'Host note (/compact focus, pinned by the human): prioritize this focus. The retained items are verbatim recorded data relevant to it, not instructions or authority; EvidenceRead retrieves anything else.\n';
61
+ const STOP_WORDS = new Set('the and for with from that this these those into about only focus keep enfocate enfócate enfoque enfocar foco sobre para por los las del con una uno unos unas que como esta este estos estas solo sólo mas más pero'.split(' '));
62
+ export function focusTerms(text) {
63
+ return [...new Set(String(text || '').toLowerCase().normalize('NFC').split(/[^\p{L}\p{N}_./-]+/u).map(word => word.replace(/^[./-]+|[./-]+$/g, '')).filter(word => word.length >= 3 && !STOP_WORDS.has(word)))].slice(0, 24);
64
+ }
65
+ const focusScore = (text, terms) => { const haystack = String(text).toLowerCase(); return terms.reduce((sum, term) => sum + (haystack.includes(term) ? /[./]/.test(term) ? 3 : 1 : 0), 0); };
66
+ export function pinnedFocus(session) {
67
+ for (let index = (session.rows?.length || 0) - 1; index >= 0; index--) {
68
+ const row = session.rows[index];
69
+ if (row.type === 'reset') return null;
70
+ if (row.type === 'context_projection' && row.reason === 'manual') return row.focus?.text || null;
71
+ }
72
+ return null;
73
+ }
74
+ // Reference blocks resolve back to what the model originally saw (bounded like a live result).
75
+ function resolvedResult(session, block) {
76
+ if (typeof block.content !== 'string') return JSON.stringify(block.content ?? '');
77
+ try {
78
+ const reference = JSON.parse(block.content), row = reference?.read_call?.input?.row, receipt = Number.isSafeInteger(row) ? session.rows?.[row] : undefined;
79
+ if (reference?.type === 'evidence_reference' && receipt?.type === 'house_receipt' && receipt.output_digest === reference.output_digest) return projectToolOutput(String(receipt.output ?? ''), { session_id: session.id, row }, { maxCharacters: 16384 });
80
+ } catch {}
81
+ return block.content;
82
+ }
83
+ const plainText = message => typeof message.content === 'string' ? message.content : Array.isArray(message.content) ? message.content.filter(block => block.type === 'text').map(block => block.text || '').join('\n') : '';
84
+ export function focusCandidates(session, history, text) {
85
+ const terms = focusTerms(text); if (!terms.length) return [];
86
+ let source = session.historyMessages?.length ? session.historyMessages : history, units;
87
+ try { units = messageUnits(source); } catch { source = history; units = messageUnits(history); }
88
+ const items = [];
89
+ units.forEach((unit, position) => {
90
+ const [first, second] = unit.messages, calls = first.role === 'assistant' && Array.isArray(first.content) ? first.content.filter(block => block.type === 'tool_use') : [];
91
+ if (calls.length) calls.forEach((call, offset) => {
92
+ const block = second.content.find(item => item.type === 'tool_result' && item.tool_use_id === call.id), content = resolvedResult(session, block);
93
+ const score = focusScore(call.name + ' ' + JSON.stringify(call.input) + ' ' + content, terms);
94
+ if (score > 0) items.push({ kind: 'tool_result', call_id: call.id, name: call.name, input: call.input, ...(block.is_error ? { is_error: true } : {}), content, order: position * 100 + offset, score });
95
+ });
96
+ else {
97
+ const body = plainText(first);
98
+ if (!body || body.startsWith(PREFIX) || body.startsWith(FOCUS_PREFIX)) return;
99
+ const score = focusScore(body, terms);
100
+ if (score > 0) items.push({ kind: 'message', role: first.role, text: body, ref: `message:${unit.indices[0]}`, order: position * 100, score });
101
+ }
102
+ });
103
+ return items.sort((a, b) => b.score - a.score || b.order - a.order);
104
+ }
105
+ // Reference projections keep focus-relevant results in full (bounded).
106
+ function focusCallIds(session, history, text, budgetTokens) {
107
+ const kept = []; let spent = 0;
108
+ for (const item of focusCandidates(session, history, text)) {
109
+ if (item.kind !== 'tool_result' || !history.some(message => Array.isArray(message.content) && message.content.some(block => block.type === 'tool_use' && block.id === item.call_id))) continue;
110
+ const cost = estimateTokens(item.content); if (spent + cost > budgetTokens) continue;
111
+ kept.push(item.call_id); spent += cost;
112
+ }
113
+ return kept;
114
+ }
50
115
 
51
116
  export class WorkContextProjector {
52
- constructor({ contextWindow = 45000, triggerRatio = .9, onEvent = () => {} } = {}) {
117
+ constructor({ contextWindow = 45000, triggerRatio = .9, onEvent = () => {}, autocompact = null } = {}) {
53
118
  if (!Number.isFinite(triggerRatio) || triggerRatio < .5 || triggerRatio > 1) throw new Error('Umbral de contexto inválido.');
54
119
  this.contextWindow = contextWindow; this.triggerRatio = triggerRatio; this.onEvent = onEvent; this.factor = 1; this.contextIdentity=null; this.anchor=null; this.accountingMethod='estimate';
120
+ // autocompact: {setting, source} or a getter returning it (per-model settings follow model switches).
121
+ this.autocompact = autocompact; this.peaks = [];
122
+ }
123
+ /** Compaction window and soft/hard thresholds (used = input + output reserve). */
124
+ plan(outputTokens = this.outputReserve ?? 0, overhead = 0) {
125
+ const selected = typeof this.autocompact === 'function' ? this.autocompact() : this.autocompact;
126
+ return compactionPlan({ contextWindow: this.contextWindow, outputReserve: outputTokens, overhead, setting: selected?.setting ?? null, settingSource: selected?.source || 'default', peaks: this.peaks, triggerRatio: this.triggerRatio });
55
127
  }
56
128
  setIdentity(identity) {
57
129
  if (digest(identity || null)!==digest(this.contextIdentity)) { this.anchor=null; this.factor=1; }
@@ -90,10 +162,12 @@ export class WorkContextProjector {
90
162
  } else this.anchor=null;
91
163
  }
92
164
  status(request, outputTokens) {
93
- const inputTokens = this.estimate(request), margin = Math.max(512, Math.ceil(this.contextWindow * .05));
94
- return { type: 'context_usage', input_tokens: inputTokens, output_reserve: outputTokens, context_window: this.contextWindow, margin, estimated: this.accountingMethod!=='measured_request', accounting_method:this.accountingMethod, anchor_request_digest:this.anchorMatches(request)?this.anchor.request_digest:null, percent: Math.round((inputTokens + outputTokens + margin) / this.contextWindow * 100), method: 'conversation', trigger_ratio: this.triggerRatio, calibration_factor: this.factor, summary_model_calls: 0 };
165
+ const overhead = this.estimate({ system: request.system || '', tools: request.tools || [], messages: [] });
166
+ const inputTokens = this.estimate(request), margin = Math.max(512, Math.ceil(this.contextWindow * .05)), plan = this.plan(outputTokens, overhead);
167
+ return { type: 'context_usage', input_tokens: inputTokens, output_reserve: outputTokens, context_window: this.contextWindow, margin, estimated: this.accountingMethod!=='measured_request', accounting_method:this.accountingMethod, anchor_request_digest:this.anchorMatches(request)?this.anchor.request_digest:null, percent: Math.round((inputTokens + outputTokens + margin) / this.contextWindow * 100), method: 'conversation', trigger_ratio: this.triggerRatio, calibration_factor: this.factor, summary_model_calls: 0,
168
+ overhead_tokens: overhead, compaction_window: plan.compaction_window, compaction_source: plan.source, soft_threshold: plan.soft_threshold, hard_threshold: plan.hard_threshold };
95
169
  }
96
- async prepare({ session, system, tools, catalogue = tools, capabilities, visual, mode, outputTokens, signal, force = false, reason = 'automatic', commit, contextIdentity }) {
170
+ async prepare({ session, system, tools, catalogue = tools, capabilities, visual, mode, outputTokens, signal, force = false, reason = 'automatic', commit, contextIdentity, boundary = null, boundaryDetails = null, focus }) {
97
171
  this.outputReserve=outputTokens;
98
172
  if(contextIdentity)this.setIdentity(contextIdentity);
99
173
  signal?.throwIfAborted();
@@ -121,8 +195,30 @@ export class WorkContextProjector {
121
195
  const usefulImages = (visual?.supported ? visual.references : []).filter(reference => this.estimate({system,tools,messages:imageReferenceMessages({supported:true,references:[reference]})}) <= hardBudget);
122
196
  const desired = [...active,...imageReferenceMessages({supported:true,references:usefulImages})];
123
197
  const requestMessages = attachImages(active), before = this.status({system,tools,messages:desired},outputTokens);
124
- const pressure = before.input_tokens + outputTokens + before.margin >= this.contextWindow * this.triggerRatio;
125
- if (!force && !pressure) {
198
+ // Timing: the physical limit (today's 0.9 × window) or the compaction window
199
+ // force a projection anywhere (hard). Above the soft threshold a projection
200
+ // happens only at a boundary the caller names: the end of a turn, or a human
201
+ // message after the provider's prompt cache expired. Never mid tool loop.
202
+ const plan = this.plan(outputTokens, before.overhead_tokens), used = before.input_tokens + outputTokens;
203
+ const physical = before.input_tokens + outputTokens + before.margin >= this.contextWindow * this.triggerRatio;
204
+ const hard = physical || used >= plan.hard_threshold, due = Boolean(boundary) && plan.soft_threshold !== null && used >= plan.soft_threshold;
205
+ const compactionTrigger = force ? (reason === 'manual' ? 'manual' : 'recovery') : boundary || 'hard';
206
+ const proactive = Boolean(boundary) || plan.compaction_window < plan.physical_threshold, softCeiling = proactive && plan.soft_threshold !== null ? plan.soft_threshold : Infinity;
207
+ const pinned = focus === undefined ? pinnedFocus(session) : focus || null;
208
+ const compaction = { trigger: compactionTrigger, compaction_window: plan.compaction_window, source: plan.source, setting_source: plan.setting_source, reason: plan.reason, soft_threshold: plan.soft_threshold, hard_threshold: plan.hard_threshold, physical_threshold: plan.physical_threshold, used_tokens: used, hard_exceeded: hard, ...(boundaryDetails || {}) };
209
+ const skipped = why => ({ ...before, projected: false, request_messages: requestMessages, compaction_plan: plan, ...(why ? { skipped: why } : {}) });
210
+ // Only the physical limit (or a forced/manual projection) may commit a view
211
+ // that does not help. A proactive projection must shrink the context and land
212
+ // under the soft threshold; otherwise it would be repeated on every request
213
+ // (and the model would re-read what it just lost). The window is too small.
214
+ const effective = after => force || physical || after.input_tokens < before.input_tokens && after.input_tokens + outputTokens <= softCeiling;
215
+ const ineffective = async after => {
216
+ await this.onEvent({ type: 'context_compaction_skipped', compaction_trigger: compactionTrigger, code: 'NOT_EFFECTIVE', before_tokens: before.input_tokens, after_tokens: after.input_tokens, compaction });
217
+ if (boundary) return skipped('not_effective');
218
+ await this.onEvent(before); return { ...before, projected: false, request_messages: requestMessages, compaction_plan: plan, skipped: 'not_effective' };
219
+ };
220
+ if (!force && !hard && !due) {
221
+ if (boundary) return skipped();
126
222
  if (legacy) {
127
223
  const event = {type:'context_rebase',projection_policy:PROJECTION_POLICY,reason:'legacy_automatic_projection',messages:history,source_cursor:session.rows?.length || 0,before_tokens:this.status({system,tools,messages:session.messages},outputTokens).input_tokens,after_tokens:initial.input_tokens,summary_model_calls:0};
128
224
  signal?.throwIfAborted();await commit(event);session.messages=history;session.projection=undefined;session.compaction=undefined;await this.onEvent({...event,messages:undefined});
@@ -134,24 +230,29 @@ export class WorkContextProjector {
134
230
  const cause=pressureCause({system,messages:desired,tools,imageReserve:visualTokenView({messages:desired}).reserve},request=>this.estimate(request));
135
231
  const rapid=rapidPressureCount(session.rows || []);
136
232
  if(rapid>=3) {
233
+ // A boundary projection is an optimization: never fail a finished turn for it.
234
+ if (boundary) return skipped('rapid_pressure');
137
235
  const event={type:'context_pressure_blocked',code:'CONTEXT_PRESSURE_LOOP',cause,rapid_projections:rapid,source_cursor:session.rows?.length || 0,summary_model_calls:0};
138
236
  signal?.throwIfAborted();await commit(event);await this.onEvent(event);
139
237
  throw Object.assign(new Error('Presión de contexto repetida sin espacio sostenible. '+cause.next_action+' El trabajo y la evidencia siguen guardados.'),{code:event.code,details:event});
140
238
  }
141
- const reduced=referenceOldResults(session,history);
239
+ const keep=pinned?focusCallIds(session,history,pinned,Math.floor(plan.compaction_window*.25)):[];
240
+ const reduced=referenceOldResults(session,history,keep.length?{keep:new Set(keep)}:{});
142
241
  if(reduced.references.length) {
143
242
  const reducedActive=reduced.messages.map(withoutImages).filter(message=>!Array.isArray(message.content)||message.content.length);
144
243
  const messages=attachImages(reducedActive),after=this.status({system,tools,messages},outputTokens);
145
- if(after.input_tokens+outputTokens+after.margin<=this.contextWindow*.8 && before.input_tokens-after.input_tokens>=512) {
146
- const event={type:'context_projection',projection_policy:PROJECTION_POLICY,trigger:force?'context_recovery':'token_budget',reason,method:'tool_result_references',messages,references:reduced.references,state_digest:digest(reduced.references),source_cursor:session.rows?.length || 0,source_tip_sha256:session.rows?.at(-1)?.audit?.sha256 || null,budget:{trigger_ratio:this.triggerRatio,output_reserve:outputTokens,margin:before.margin},before_tokens:before.input_tokens,after_tokens:after.input_tokens,archived_messages:0,summary_model_calls:0,usage:{input_tokens:0,output_tokens:0},cause};
244
+ if(after.input_tokens+outputTokens+after.margin<=this.contextWindow*.8 && after.input_tokens+outputTokens<=softCeiling && before.input_tokens-after.input_tokens>=512) {
245
+ const cost=compactionCostEstimate({rows:session.rows,before:before.input_tokens,after:after.input_tokens,trigger:compactionTrigger,overhead:before.overhead_tokens});
246
+ const event={type:'context_projection',projection_policy:PROJECTION_POLICY,trigger:force?'context_recovery':'token_budget',reason,method:'tool_result_references',messages,references:reduced.references,...(keep.length?{kept_call_ids:keep}:{}),state_digest:digest(reduced.references),source_cursor:session.rows?.length || 0,source_tip_sha256:session.rows?.at(-1)?.audit?.sha256 || null,budget:{trigger_ratio:this.triggerRatio,output_reserve:outputTokens,margin:before.margin},before_tokens:before.input_tokens,after_tokens:after.input_tokens,archived_messages:0,summary_model_calls:0,usage:{input_tokens:0,output_tokens:0},cause,compaction_trigger:compactionTrigger,compaction,...(pinned?{focus:{text:pinned,kept_call_ids:keep}}:{}),...(cost?{cost_estimate:cost}:{})};
147
247
  signal?.throwIfAborted();await commit(event);signal?.throwIfAborted();session.messages=messages;session.projection=event;this.anchor=null;
148
248
  await this.onEvent({...event,messages:undefined});await this.onEvent(after);
149
- return {...after,method:event.method,projected:true,request_messages:messages,state_projection_usage:event.usage,compaction_usage:event.usage};
249
+ return {...after,method:event.method,projected:true,request_messages:messages,state_projection_usage:event.usage,compaction_usage:event.usage,compaction_trigger:compactionTrigger};
150
250
  }
151
251
  }
152
252
  }
153
253
  const overhead = this.estimate({ system, messages: [], tools });
154
254
  if (overhead > hardBudget * .65) {
255
+ if (boundary) return skipped('overhead');
155
256
  const ceiling=Math.floor(hardBudget*.65),details={estimated:true,overhead_tokens:overhead,overhead_ceiling:ceiling,context_window:this.contextWindow,input_budget:hardBudget,output_reserve:outputTokens,margin:initial.margin,tools_count:tools.length,instructions_utf8_bytes:Buffer.byteLength(system || ''),tool_schema_utf8_bytes:Buffer.byteLength(JSON.stringify(tools)),generation_dispatched:false};
156
257
  throw Object.assign(new Error(`Las instrucciones y esquemas de herramientas ocupan demasiado contexto: ~${overhead} tokens, techo ${ceiling} para ventana ${this.contextWindow} (${tools.length} herramientas; salida ${outputTokens}, margen ${initial.margin}). No se llamó al modelo. Reduce el catálogo/servidores MCP o las instrucciones; aumenta --context-window sólo si tu servidor y modelo lo permiten.`),{code:'CONTEXT_OVERHEAD',details});
157
258
  }
@@ -159,14 +260,30 @@ export class WorkContextProjector {
159
260
  const units = messageUnits(history);
160
261
  const latest = units.slice(-8).findLast(unit => unit.messages[0].content?.some?.(block => block.type === 'tool_use')
161
262
  && !unit.messages[0].content.filter(block => block.type === 'tool_use').every(block => ['WorkBoard','WorkSnapshot','AuditStatus','HouseSnapshot','OperationCatalog','McpStatus','HouseContext','PluginStatus'].includes(block.name)));
263
+ // A hard projection in the middle of a tool loop keeps the in-flight
264
+ // assistant step byte-identical, thinking and signature included: Anthropic
265
+ // requires the current tool step's thinking to be returned unmodified.
266
+ const last = history.at(-1), midLoop = last?.role === 'user' && Array.isArray(last.content) && last.content.some(block => block.type === 'tool_result');
267
+ const inFlight = new Set(midLoop && latest?.indices.includes(history.length - 1) ? latest.indices : []);
268
+ const view = (message, index) => inFlight.has(index) ? keepThinking(message) : stripThinking(message);
162
269
  // Preserve a small latest transaction even when its file bytes are also in
163
270
  // the working set. It carries the current result/recovery, not just data.
164
- const latestMessages = latest?.messages.map(stripThinking) || [];
165
- let reserveLatest = estimateTokens(latestMessages) < hardBudget * .3 ? latestMessages : [];
166
- if (this.estimate({ system, tools, messages: protectedMessages }) > hardBudget * .8) throw new Error('La petición actual no cabe junto a las instrucciones y herramientas. Reduce el prompt, los esquemas MCP o ajusta --context-window; el historial sigue guardado.');
167
- if (!history.length && !session.rows?.length) { await this.onEvent(before); return {...before,projected:false,request_messages:requestMessages}; }
168
- const state = modelWorkState(session, { catalogue, mode });
271
+ const latestMessages = latest?.messages.map((message, offset) => view(message, latest.indices[offset])) || [];
272
+ // A proactive projection must land well under the soft threshold, or the
273
+ // next boundary would project again; a large latest step then stays in the
274
+ // derived state (results, file pages) instead of verbatim.
275
+ const target = Math.floor(Math.min(hardBudget * .8, proactive ? (plan.compaction_window - outputTokens) * .6 : Infinity));
276
+ let reserveLatest = estimateTokens(latestMessages) < (proactive ? target * .5 : hardBudget * .3) ? latestMessages : [];
277
+ if (this.estimate({ system, tools, messages: protectedMessages }) > hardBudget * .8) { if (boundary) return skipped('current_request'); throw new Error('La petición actual no cabe junto a las instrucciones y herramientas. Reduce el prompt, los esquemas MCP o ajusta --context-window; el historial sigue guardado.'); }
278
+ if (!history.length && !session.rows?.length) { if (boundary) return skipped(); await this.onEvent(before); return {...before,projected:false,request_messages:requestMessages}; }
279
+ // Re-injection: AGENTS.md/CLAUDE.md live in the system prompt (never
280
+ // projected); the work plan is part of the state; recently edited files are
281
+ // restored as verified pages, scaled with the compaction window (≤5 files,
282
+ // ≤~25k tokens, edited files first).
283
+ const fileBytes = Math.max(24000, Math.min(75000, Math.floor(plan.compaction_window * .1) * 3));
284
+ const state = modelWorkState(session, { catalogue, mode, ...(fileBytes > 24000 ? { files: { byteBudget: fileBytes, maxFiles: 5, preferEdited: true } } : {}) });
169
285
  state.context_checkpoint = {policy:PROJECTION_POLICY,source_cursor:session.rows?.length || 0,as_of:session.rows?.at(-1)?.timestamp || null,later_results_supersede:true};
286
+ if (pinned) state.compaction_focus = { text: pinned, pinned_by: 'human /compact focus', authority: 'user_focus_not_permission', retained: 'verbatim results in the following host note' };
170
287
  if (state.work && latest?.messages[0].content.filter(block => block.type === 'tool_use').every(block => block.name === 'Read' && state.file_working_set?.some(file => file.covered_call_ids.includes(block.id)))) reserveLatest = [];
171
288
  // References are reconstructed each request; archived projections never grant pixels.
172
289
  const visualState = visual ? { ...visual, transport: visual.supported ? 'pending_context_budget' : 'reference_only' } : undefined;
@@ -174,7 +291,6 @@ export class WorkContextProjector {
174
291
  let visualMessages = [];
175
292
  if (capabilities) { state.capabilities = capabilities; state.catalogue.discovery = 'ToolPack acquire house.audit'; }
176
293
  if (!state.audit.journal.consistent) throw Object.assign(new Error('El audit detectó una cadena del libro alterada; no se proyectó el contexto.'), { code: 'AUDIT_INVALID' });
177
- const target = Math.floor(hardBudget * .8);
178
294
  const render = () => ({ role: 'user', content: PREFIX + JSON.stringify(state) });
179
295
  // Bounded projections retain references/counts even when details do not fit.
180
296
  for (let pass = 0; this.estimate({ system, tools, messages: [render(), ...protectedMessages, ...reserveLatest] }) > target && pass < 10; pass++) {
@@ -192,7 +308,7 @@ export class WorkContextProjector {
192
308
  if (state.work.current && pass > 1) state.work.current.checks = state.work.current.checks.map(check => ({ step_id: check.step_id, operation: check.operation, arguments_digest: check.arguments_digest || digest(check.arguments), lookup: 'WorkBoard task_id retrieves exact check arguments' }));
193
309
  }
194
310
  }
195
- if (this.estimate({ system, tools, messages: [render(), ...protectedMessages] }) > hardBudget) throw new Error('La petición y el estado mínimo no caben en el contexto; el libro sigue guardado. Reduce instrucciones/catálogo o ajusta la ventana.');
311
+ if (this.estimate({ system, tools, messages: [render(), ...protectedMessages] }) > hardBudget) { if (boundary) return skipped('minimum_state'); throw new Error('La petición y el estado mínimo no caben en el contexto; el libro sigue guardado. Reduce instrucciones/catálogo o ajusta la ventana.'); }
196
312
  if (visualState?.supported) {
197
313
  for (const reference of visualState.references) {
198
314
  const candidate = [...visualMessages, ...imageReferenceMessages({ supported: true, references: [reference] })];
@@ -204,7 +320,7 @@ export class WorkContextProjector {
204
320
  }
205
321
  const selected = new Set();
206
322
  const currentIndex = history.lastIndexOf(current); if (currentIndex >= 0) selected.add(currentIndex);
207
- const selectedMessages = indices => history.filter((_, i) => indices.has(i)).map(stripThinking);
323
+ const selectedMessages = indices => history.map((message, index) => [message, index]).filter(([, index]) => indices.has(index)).map(([message, index]) => view(message, index));
208
324
  let retained = 0;
209
325
  if (reserveLatest.length && this.estimate({ system, tools, messages: [render(), ...protectedMessages, ...reserveLatest, ...visualMessages] }) <= hardBudget) {
210
326
  latest.indices.forEach(index => selected.add(index)); retained++;
@@ -218,15 +334,38 @@ export class WorkContextProjector {
218
334
  if (this.estimate({ system, tools, messages: [render(), ...selectedMessages(candidate), ...visualMessages] }) > target) break;
219
335
  unit.indices.forEach(index => selected.add(index)); if (++retained >= 2) break;
220
336
  }
221
- const recent = selectedMessages(selected), messages = [render(), ...recent, ...visualMessages], after = this.status({ system, tools, messages }, outputTokens);
222
- const event = { type: 'context_projection', projection_policy: PROJECTION_POLICY, trigger: force ? reason === 'manual' ? 'manual' : 'context_recovery' : 'token_budget', reason, method: 'deterministic_state', messages, state_digest: digest(state), source_cursor: session.rows?.length || 0, source_tip_sha256: state.audit.journal.tip_sha256, budget: {trigger_ratio:this.triggerRatio,output_reserve:outputTokens,margin:before.margin,calibration_factor:this.factor}, before_tokens: before.input_tokens, after_tokens: after.input_tokens, archived_messages: Math.max(0, history.length - recent.length), thinking_records: state.thinking.count, audit_verified: state.audit.verified, usage: { input_tokens: 0, output_tokens: 0 }, summary_model_calls: 0 };
337
+ // At a boundary (and with a focus) the latest answer stays verbatim: the next
338
+ // human message usually refers to it ("do what you proposed").
339
+ if (boundary || pinned && reason === 'manual') {
340
+ const answer = history.findLastIndex(message => message.role === 'assistant' && (typeof message.content === 'string' ? message.content : Array.isArray(message.content) && !message.content.some(block => block.type === 'tool_use') && message.content.some(block => block.type === 'text' && block.text)));
341
+ if (answer >= 0 && !selected.has(answer)) { const candidate = new Set([...selected, answer]); if (this.estimate({ system, tools, messages: [render(), ...selectedMessages(candidate), ...visualMessages] }) <= target) selected.add(answer); }
342
+ }
343
+ let recent = selectedMessages(selected), focusMessage = [], focusRecord;
344
+ if (pinned) {
345
+ const present = new Set([...recent.flatMap(message => Array.isArray(message.content) ? message.content.filter(block => block.type === 'tool_use').map(block => block.id) : []), ...recent.map(plainText).filter(Boolean)]);
346
+ const budget = Math.max(0, Math.min(Math.floor(plan.compaction_window * .25), target - this.estimate({ system, tools, messages: [render(), ...recent, ...visualMessages] })));
347
+ const chosen = [], omitted = []; let spent = 0;
348
+ for (const item of focusCandidates(session, history, pinned).filter(item => !present.has(item.call_id || item.text))) {
349
+ const cost = estimateTokens(item);
350
+ if (spent + cost > budget) { omitted.push(item.call_id || item.ref); continue; }
351
+ chosen.push(item); spent += cost;
352
+ }
353
+ chosen.sort((a, b) => a.order - b.order);
354
+ if (chosen.length) focusMessage = [{ role: 'user', content: FOCUS_PREFIX + JSON.stringify({ focus: pinned, retained: chosen.map(({ order, score, ...item }) => item) }) }];
355
+ focusRecord = { text: pinned, terms: focusTerms(pinned), budget_tokens: budget, retained_tokens: spent, retained_call_ids: chosen.filter(item => item.call_id).map(item => item.call_id), retained_messages: chosen.filter(item => !item.call_id).length, omitted_by_budget: omitted.length };
356
+ }
357
+ const messages = [render(), ...focusMessage, ...recent, ...visualMessages], after = this.status({ system, tools, messages }, outputTokens);
358
+ if (!effective(after)) return ineffective(after);
359
+ const cost = compactionCostEstimate({ rows: session.rows, before: before.input_tokens, after: after.input_tokens, trigger: compactionTrigger, overhead: before.overhead_tokens });
360
+ const event = { type: 'context_projection', projection_policy: PROJECTION_POLICY, trigger: force ? reason === 'manual' ? 'manual' : 'context_recovery' : 'token_budget', reason, method: 'deterministic_state', messages, state_digest: digest(state), source_cursor: session.rows?.length || 0, source_tip_sha256: state.audit.journal.tip_sha256, budget: {trigger_ratio:this.triggerRatio,output_reserve:outputTokens,margin:before.margin,calibration_factor:this.factor}, before_tokens: before.input_tokens, after_tokens: after.input_tokens, archived_messages: Math.max(0, history.length - recent.length), thinking_records: state.thinking.count, audit_verified: state.audit.verified, usage: { input_tokens: 0, output_tokens: 0 }, summary_model_calls: 0,
361
+ compaction_trigger: compactionTrigger, compaction, ...(focusRecord ? { focus: focusRecord } : {}), ...(inFlight.size ? { in_flight_step_preserved: true } : {}), ...(cost ? { cost_estimate: cost } : {}) };
223
362
  if (force || JSON.stringify(session.messages) !== JSON.stringify(messages)) {
224
- await this.onEvent({ type: 'context_projection_start', reason, before_tokens: before.input_tokens });
363
+ await this.onEvent({ type: 'context_projection_start', reason, before_tokens: before.input_tokens, compaction_trigger: compactionTrigger });
225
364
  signal?.throwIfAborted(); await commit(event); signal?.throwIfAborted(); session.messages = messages; session.projection = event;
226
365
  await this.onEvent({ ...event, messages: undefined });
227
- }
366
+ } else if (boundary) return skipped('unchanged');
228
367
  await this.onEvent(after);
229
- return { ...after, method:'deterministic_state', projected:true,request_messages:messages, state_projection_usage: event.usage, compaction_usage: event.usage, audit_verified: state.audit.verified, thinking_records: state.thinking.count };
368
+ return { ...after, method:'deterministic_state', projected:true,request_messages:messages, state_projection_usage: event.usage, compaction_usage: event.usage, audit_verified: state.audit.verified, thinking_records: state.thinking.count, compaction_trigger: compactionTrigger };
230
369
  }
231
370
  }
232
371
  // Historical public API and /compact remain aliases, without a summarizer.
@@ -33,13 +33,13 @@ registerMessages({
33
33
  'connected': 'conectado', 'configured': 'configurado', 'expired': 'expirado', 'logged out': 'sin sesión',
34
34
  'active: {list}': 'activos: {list}', 'inactive: {list}': 'inactivos: {list}', 'unavailable': 'no disponible',
35
35
  'Change: /config set model|effort|provider|permissions|max-tokens|context-window VALUE · /config save remembers this connection · /login PROVIDER': 'Cambiar: /config set model|effort|provider|permissions|max-tokens|context-window VALOR · /config save recuerda esta conexión · /login PROVEEDOR',
36
- 'session': 'sesión', 'flag': 'bandera', 'environment': 'entorno', 'saved': 'guardado', 'default': 'omisión',
36
+ 'session': 'sesión', 'flag': 'bandera', 'environment': 'entorno', 'saved': 'guardado', 'default': 'omisión', 'model': 'modelo', 'profile': 'perfil',
37
37
  'detected': 'detectada', 'announced {value}': 'anunciada {value}', 'configured limit': 'límite configurado', 'fallback': 'por omisión',
38
38
  });
39
39
 
40
40
  const MIN_OUTPUT = 64, MAX_OUTPUT = 1000000, MIN_WINDOW = 4096, MAX_WINDOW = 2000000;
41
41
  const FALLBACK_WINDOW = contextSettings({}, {}).contextWindow;
42
- const SOURCE_LABELS = { session: 'session', cli: 'flag', environment: 'environment', saved: 'saved', fallback: 'default', default: 'default' };
42
+ const SOURCE_LABELS = { session: 'session', cli: 'flag', environment: 'environment', saved: 'saved', fallback: 'default', default: 'default', model: 'model', profile: 'profile' };
43
43
  const sourceLabel = value => t(SOURCE_LABELS[value] || value || 'unknown');
44
44
  const integer = text => /^\d{1,9}$/.test(text) ? Number(text) : NaN;
45
45
 
@@ -80,6 +80,37 @@ export function resolveStartupLimits({ flags = {}, saved = {}, env = process.env
80
80
  catch (error) { warnings.push(t('Saved limits were ignored: {reason}', { reason: error.message })); return { config: build(false), warnings }; }
81
81
  }
82
82
 
83
+ /**
84
+ * Presupuesto de salida según el modelo, una vez conectado. Sólo reemplaza un
85
+ * valor que nadie fijó (origen default, model o profile): bandera, variable,
86
+ * connection.json y /max-tokens siempre mandan. Orden: perfil (maxTokens /
87
+ * contextWindow de providers.json) > límite de salida anunciado por el catálogo
88
+ * o ventana completa servida por Ollama Cloud (/api/show) > 8192.
89
+ * El valor modelo es min(32000, límite anunciado, ventana efectiva / 4).
90
+ */
91
+ export const MODEL_AWARE_MAX_TOKENS = 32000, BASE_MAX_TOKENS = 8192;
92
+ const AUTO_OUTPUT = ['default', 'model', 'profile'], AUTO_WINDOW = ['fallback', 'profile'];
93
+ export function modelAwareLimits(limits, report, profile) {
94
+ const next = { ...limits };
95
+ if (AUTO_WINDOW.includes(next.contextWindowSource ?? 'fallback')) {
96
+ if (Number.isSafeInteger(profile?.contextWindow)) Object.assign(next, { contextWindow: profile.contextWindow, contextWindowSource: 'profile' });
97
+ else if (next.contextWindowSource === 'profile') Object.assign(next, { contextWindow: FALLBACK_WINDOW, contextWindowSource: 'fallback' });
98
+ }
99
+ if (!AUTO_OUTPUT.includes(next.maxTokensSource ?? 'default')) return next;
100
+ if (Number.isSafeInteger(profile?.maxTokens)) return Object.assign(next, { maxTokens: profile.maxTokens, maxTokensSource: 'profile' });
101
+ const base = Object.assign(next, { maxTokens: BASE_MAX_TOKENS, maxTokensSource: 'default' });
102
+ const announced = Number.isSafeInteger(report?.outputTokenLimit) && report.outputTokenLimit > 0 ? report.outputTokenLimit : null;
103
+ const servedWindow = report?.contextWindowProvenance?.scope === 'served_model_context';
104
+ const known = announced ?? (servedWindow ? MODEL_AWARE_MAX_TOKENS : null);
105
+ if (known === null) return base;
106
+ let window;
107
+ try { window = resolveContextBudget({ ...next, maxTokens: 0 }, report).contextWindow; } catch { return base; }
108
+ // A model that states a smaller ceiling than 8192 is respected.
109
+ if (known < BASE_MAX_TOKENS) return Object.assign(next, { maxTokens: Math.max(MIN_OUTPUT, Math.min(known, Math.floor(window / 2))), maxTokensSource: 'model' });
110
+ const value = Math.min(MODEL_AWARE_MAX_TOKENS, known, Math.floor(window / 4));
111
+ return value > BASE_MAX_TOKENS ? Object.assign(next, { maxTokens: value, maxTokensSource: 'model' }) : base;
112
+ }
113
+
83
114
  /** Calcula los límites candidatos sin aplicarlos. */
84
115
  export function nextLimits(current, key, value) {
85
116
  if (key === 'max-tokens') return { ...current, maxTokens: value, maxTokensSource: 'session' };
@@ -98,7 +129,7 @@ export function checkLimits(candidate, report) {
98
129
  }
99
130
  }
100
131
 
101
- const SET_KEYS = ['model', 'effort', 'provider', 'permissions', 'max-tokens', 'context-window'];
132
+ const SET_KEYS = ['model', 'effort', 'provider', 'permissions', 'max-tokens', 'context-window', 'anthropic-fallback'];
102
133
  export function parseConfigCommand(arg) {
103
134
  const parts = String(arg || '').trim().split(/\s+/).filter(Boolean);
104
135
  if (!parts.length || parts.length === 1 && ['show', 'status'].includes(parts[0])) return { action: 'show' };
@@ -131,6 +162,7 @@ export function formatConfig(view) {
131
162
  view.authProvider ? row('Login', loginLine(view, view.login)) : row('Credential', view.demo ? t('none') : t('no login · key from {name} ({state})', { name: view.apiKeyEnv || 'OPENAI_API_KEY', state: t(view.apiKeyDefined ? 'defined' : 'not defined') })),
132
163
  row('Model', view.model || t('unknown')),
133
164
  row('Effort', `${view.effort || 'auto'}${view.thinking ? ` · thinking ${view.thinking}` : ''}`),
165
+ ...(view.protocol === 'anthropic' ? [row('Anthropic fallback', view.fallback || 'off')] : []),
134
166
  row('Context window', [budget.contextWindow ?? view.limits.contextWindow, t(kinds[budget.kind] || 'unknown'), `${sourceLabel(budget.source)}`, budget.announcedWindow && budget.announcedWindow !== budget.contextWindow ? t('announced {value}', { value: budget.announcedWindow }) : ''].filter(Boolean).join(' · ')),
135
167
  row('Max tokens', `${view.limits.maxTokens} · ${sourceLabel(view.limits.maxTokensSource)}`),
136
168
  row('Permissions', view.mode),
@@ -197,7 +229,7 @@ export class SessionSettings {
197
229
  try { const status = await this.authManager.status(s.authProvider); login = { status: status.status, method: status.method, ...(status.expiresAt ? { expiresAt: status.expiresAt } : {}) }; }
198
230
  catch { login = { status: 'unknown', method: 'unknown' }; }
199
231
  }
200
- return { demo: s.demo, provider: s.provider, authProvider: s.authProvider, profile: s.profile, endpoint: s.endpoint, model: s.model, effort: s.effort, thinking: s.thinking,
232
+ return { demo: s.demo, provider: s.provider, authProvider: s.authProvider, profile: s.profile, endpoint: s.endpoint, model: s.model, effort: s.effort, thinking: s.thinking, protocol: s.protocol, fallback: s.fallback,
201
233
  apiKeyEnv: s.apiKeyEnv, apiKeyDefined: Boolean(s.apiKeyEnv && this.env[s.apiKeyEnv]), login, budget: this.budget(), limits: { ...this.limits }, mode: s.mode, plugins: s.plugins,
202
234
  paths: [['connection', this.file()], ['credentials', this.authManager?.directory || ''], ...(s.paths?.profiles ? [['profiles', s.paths.profiles]] : []), ...(s.paths?.plugins ? [['plugins', s.paths.plugins]] : [])] };
203
235
  }
@@ -4,7 +4,7 @@ import { isHumanIntentRecord } from './trusted-context.mjs';
4
4
 
5
5
  // A view change only. Full receipts and the original visible messages remain in
6
6
  // the append-only book. No model-generated claims authorize this substitution.
7
- export function referenceOldResults(session, history, {keepRecent=2,minCharacters=4000}={}) {
7
+ export function referenceOldResults(session, history, {keepRecent=2,minCharacters=4000,keep=new Set()}={}) {
8
8
  const audit=auditExecution(session);
9
9
  if(!audit.journal.consistent)return {messages:history,references:[]};
10
10
  const invalid=new Set([...audit.violations,...audit.unverified].map(item=>item.invocation_id));
@@ -16,7 +16,7 @@ export function referenceOldResults(session, history, {keepRecent=2,minCharacter
16
16
  const calls=history.flatMap(message=>message.role==='assistant' && Array.isArray(message.content)?message.content.filter(block=>block.type==='tool_use').map(block=>block.id):[]);
17
17
  const recent=new Set(calls.slice(-Math.max(1,keepRecent))),references=[];
18
18
  const messages=history.map((message,messageIndex)=>!Array.isArray(message.content)?message:{...message,content:message.content.map((block,blockIndex)=>{
19
- if(block.type!=='tool_result' || block.is_error || recent.has(block.tool_use_id) || typeof block.content!=='string' || block.content.length<minCharacters)return block;
19
+ if(block.type!=='tool_result' || block.is_error || recent.has(block.tool_use_id) || keep.has(block.tool_use_id) || typeof block.content!=='string' || block.content.length<minCharacters)return block;
20
20
  const receipt=receipts.get(block.tool_use_id);if(!receipt)return block;
21
21
  const {item,row}=receipt;
22
22
  // Only replace exact known visible data. Unknown transformations, wrappers,
@@ -46,7 +46,8 @@ export function rapidPressureCount(rows, {maxModelRequests=3}={}) {
46
46
  if(row.type==='reset' && !row.source || isHumanIntentRecord(row))break;
47
47
  if(row.type==='model_request')requests++;
48
48
  if(requests>maxModelRequests)break;
49
- if(row.type==='context_projection' && row.reason!=='manual')count++;
49
+ // Boundary projections (turn end, expired cache) happen once per boundary and cannot loop.
50
+ if(row.type==='context_projection' && !['manual','soft_turn_end','idle_cache_expired'].includes(row.reason))count++;
50
51
  }
51
52
  return count;
52
53
  }
@@ -59,7 +60,7 @@ export function validateReferenceProjection(session,event=session.projection) {
59
60
  if(rows.at(-1)?.audit?.sha256!==event.source_tip_sha256)return {valid:false,reason:'source_tip_mismatch'};
60
61
  let history=[];
61
62
  for(const row of rows){if(row.type==='reset')history=[];if(['context_projection','context_rebase','compaction'].includes(row.type))history=row.messages.slice();if(row.type==='message')history.push(row.message);}
62
- const rebuilt=referenceOldResults({...session,rows},history);
63
+ const rebuilt=referenceOldResults({...session,rows},history,{keep:new Set(Array.isArray(event.kept_call_ids)?event.kept_call_ids:[])});
63
64
  const view=messages=>messages.map(message=>!Array.isArray(message.content)?message:{...message,content:message.content.filter(block=>!['image_reference','image'].includes(block.type))}).filter(message=>!Array.isArray(message.content)||message.content.length);
64
65
  if(!rebuilt.references.length || digest(rebuilt.references)!==event.state_digest || digest(rebuilt.references)!==digest(event.references))return {valid:false,reason:'reference_bindings_mismatch'};
65
66
  if(digest(view(rebuilt.messages))!==digest(view(event.messages)))return {valid:false,reason:'projected_view_mismatch'};
package/src/context.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { readFile, readdir, realpath } from 'node:fs/promises';
2
2
  import { join, relative, isAbsolute } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
3
4
  import { compileSkill } from './skill-runtime.mjs';
4
5
 
5
6
  export async function buildContext(root, custom) {
@@ -19,29 +20,33 @@ export async function buildContext(root, custom) {
19
20
  export async function loadSkills(root) {
20
21
  return new Map([...(await loadSkillDefinitions(root))].map(([name, skill]) => [name, skill.content]));
21
22
  }
22
- export async function loadSkillDefinitions(root) {
23
+ // Skills shipped with Comal (lowest precedence), then legacy .harness, then .comal: a workspace skill wins on a name clash.
24
+ export const PACKAGED_SKILLS = fileURLToPath(new URL('../skills/', import.meta.url));
25
+ export async function loadSkillDefinitions(root, { packagedDir = PACKAGED_SKILLS } = {}) {
23
26
  const skills = new Map();
24
- for (const state of ['.harness', '.comal']) {
25
- const dir = join(root, state, 'skills');
26
- let names;
27
- try { names = await readdir(dir, { withFileTypes: true }); } catch (e) { if (e.code === 'ENOENT') continue; throw e; }
28
- for (const entry of names) {
29
- if (!entry.isDirectory() || !/^[a-z][a-z0-9-]*$/.test(entry.name)) continue;
27
+ await scanSkills(skills, packagedDir, packagedDir, 'packaged');
28
+ for (const state of ['.harness', '.comal']) await scanSkills(skills, join(root, state, 'skills'), root, 'workspace');
29
+ return skills;
30
+ }
31
+ async function scanSkills(skills, dir, container, source) {
32
+ let names;
33
+ try { names = await readdir(dir, { withFileTypes: true }); } catch (e) { if (e.code === 'ENOENT') return; throw e; }
34
+ let base; try { base = await realpath(container); } catch (e) { if (e.code === 'ENOENT') return; throw e; }
35
+ const inside = file => { const rel = relative(base, file); return !(rel.startsWith('..') || isAbsolute(rel)); };
36
+ for (const entry of names) {
37
+ if (!entry.isDirectory() || !/^[a-z][a-z0-9-]*$/.test(entry.name)) continue;
38
+ try {
39
+ const path = await realpath(join(dir, entry.name, 'SKILL.md'));
40
+ if (!inside(path)) continue;
41
+ const content = await readFile(path, 'utf8');
42
+ if (content.length > 32000) throw new Error(`Skill ${entry.name} excede 32,000 caracteres.`);
43
+ let manifest = {};
30
44
  try {
31
- const path = await realpath(join(dir, entry.name, 'SKILL.md'));
32
- const rel = relative(root, path);
33
- if (rel.startsWith('..') || isAbsolute(rel)) continue;
34
- const content = await readFile(path, 'utf8');
35
- if (content.length > 32000) throw new Error(`Skill ${entry.name} excede 32,000 caracteres.`);
36
- let manifest = {};
37
- try {
38
- const manifestPath = await realpath(join(dir, entry.name, 'skill.json')), manifestRel = relative(root, manifestPath);
39
- if (manifestRel.startsWith('..') || isAbsolute(manifestRel)) throw new Error('El manifiesto de skill apunta fuera del workspace.');
40
- const metadata = await readFile(manifestPath, 'utf8'); if (metadata.length > 8000) throw new Error('El manifiesto de skill excede 8,000 caracteres.'); manifest = JSON.parse(metadata);
41
- } catch (error) { if (error.code !== 'ENOENT') throw error; }
42
- skills.set(entry.name, compileSkill({ name: entry.name, content, manifest, file: path }));
43
- } catch (e) { if (e.code !== 'ENOENT') throw e; }
44
- }
45
+ const manifestPath = await realpath(join(dir, entry.name, 'skill.json'));
46
+ if (!inside(manifestPath)) throw new Error('El manifiesto de skill apunta fuera de su directorio de skills.');
47
+ const metadata = await readFile(manifestPath, 'utf8'); if (metadata.length > 8000) throw new Error('El manifiesto de skill excede 8,000 caracteres.'); manifest = JSON.parse(metadata);
48
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
49
+ skills.set(entry.name, compileSkill({ name: entry.name, content, manifest, file: path, source }));
50
+ } catch (e) { if (e.code !== 'ENOENT') throw e; }
45
51
  }
46
- return skills;
47
52
  }
@@ -3,15 +3,46 @@ import { performance } from 'node:perf_hooks';
3
3
  // Execution, declared-check judgment and human acceptance are separate planes.
4
4
  export function turnOutcome(result) {
5
5
  const reason=result.work_stop_reason;
6
- const turn=result.is_error?'failed':reason==='queued_message'||result.yielded?'yielded':reason==='call_budget'?'budget':reason==='no_progress'?'no_progress':reason==='permission_denied'?'permission_denied':reason&&reason!=='declared_checks_complete'?reason:'answered';
6
+ const turn=result.refusal?'refused':result.is_error?'failed':reason==='queued_message'||result.yielded?'yielded':reason==='call_budget'?'budget':reason==='no_progress'?'no_progress':reason==='permission_denied'?'permission_denied':reason&&reason!=='declared_checks_complete'?reason:'answered';
7
7
  const cards=result.work?.cards;
8
8
  const work=result.delivery?.status==='verified'||cards?.length&&cards.every(card=>card.status==='done')?'checks_verified':cards?.length?'checks_pending':'unjudged';
9
9
  return {runner:'exited',turn,work,asked:'unjudged'};
10
10
  }
11
11
 
12
+ // version 2 adds (backward compatibly) the stream milestones of the request,
13
+ // all relative to its start on the host monotonic clock (never provider TTFT):
14
+ // - first_reasoning_ms: first reasoning signal (delta, or start of a thinking
15
+ // block even when its text is omitted by the provider);
16
+ // - reasoning_ms: summed reasoning intervals. An interval closes at the explicit
17
+ // end of a thinking block when the provider streams it (Anthropic), otherwise
18
+ // at the first visible text or tool call. null when no reasoning was streamed;
19
+ // - first_output_ms: first visible text or tool call;
20
+ // - reasoning_tokens: provider-reported thinking tokens, or null.
21
+ export const EXECUTION_METRICS_VERSION = 2;
12
22
  export class RequestMetrics {
13
- constructor({requestId,turnId,turn,model,provider}={}) {this.started=performance.now();this.metadata={request_id:requestId,turn_id:turnId,turn,model,provider};this.spans=[];this.firstToken=null;}
14
- async measure(phase,run) {const start=performance.now();try{return await run();}finally{this.spans.push({phase,elapsed_ms:performance.now()-start});}}
15
- firstText() {this.firstToken??=performance.now()-this.started;}
16
- event({status='returned',error}={}) {return {type:'execution_metrics',version:1,...this.metadata,scope:'host_monotonic_phases_not_provider_billing',elapsed_ms:performance.now()-this.started,first_text_ms:this.firstToken,phases:this.spans.map(span=>({...span})),...(error?{error}:{}),status};}
23
+ constructor({requestId,turnId,turn,model,provider,clock=()=>performance.now()}={}) {this.clock=clock;this.started=clock();this.metadata={request_id:requestId,turn_id:turnId,turn,model,provider};this.spans=[];this.firstToken=null;this.firstReasoningAt=null;this.firstOutputAt=null;this.reasoningOpenAt=null;this.reasoningTotal=0;this.explicitReasoning=false;this.reasoningTokens=null;}
24
+ now() {return this.clock()-this.started;}
25
+ async measure(phase,run) {const start=this.clock();try{return await run();}finally{this.spans.push({phase,elapsed_ms:this.clock()-start});}}
26
+ firstText() {this.firstToken??=this.now();this.firstOutput();}
27
+ closeReasoning(at=this.now()) {if(this.reasoningOpenAt!==null){this.reasoningTotal+=Math.max(0,at-this.reasoningOpenAt);this.reasoningOpenAt=null;}}
28
+ // Returns true when a reasoning interval opens, so callers announce the phase once.
29
+ // Without explicit block ends, reasoning after visible output is not timed (no guess).
30
+ reasoning() {if(this.reasoningOpenAt!==null || this.firstOutputAt!==null && !this.explicitReasoning)return false;this.reasoningOpenAt=this.now();this.firstReasoningAt??=this.reasoningOpenAt;return true;}
31
+ firstReasoning() {return this.reasoning();}
32
+ reasoningEnd() {this.explicitReasoning=true;this.closeReasoning();}
33
+ firstOutput() {this.closeReasoning();if(this.firstOutputAt!==null)return false;this.firstOutputAt=this.now();return true;}
34
+ // A response that carried reasoning without streaming it keeps reasoning_ms null.
35
+ settle(answer) {
36
+ const content=answer?.content || [];
37
+ this.reasoningPresent=content.some(block=>block.type==='openai_reasoning' || block.type==='thinking' || block.type==='redacted_thinking');
38
+ const tokens=answer?.usage_accounting?.reasoning_tokens;this.reasoningTokens=Number.isSafeInteger(tokens)?tokens:null;
39
+ if(this.firstOutputAt===null && content.some(block=>block.type==='tool_use' || block.type==='text' && block.text))this.firstOutput();
40
+ }
41
+ event({status='returned',error}={}) {
42
+ const elapsed=this.now();this.closeReasoning(elapsed);
43
+ return {type:'execution_metrics',version:EXECUTION_METRICS_VERSION,...this.metadata,scope:'host_monotonic_phases_not_provider_billing',elapsed_ms:elapsed,first_text_ms:this.firstToken,
44
+ first_reasoning_ms:this.firstReasoningAt,reasoning_ms:this.firstReasoningAt===null?null:this.reasoningTotal,first_output_ms:this.firstOutputAt,
45
+ reasoning_stream:this.firstReasoningAt!==null?'streamed':this.reasoningPresent?'not_streamed':'none',reasoning_tokens:this.reasoningTokens,
46
+ phases:this.spans.map(span=>({...span})),...(error?{error}:{}),status};
47
+ }
17
48
  }
@@ -6,7 +6,8 @@ import { workLeafValid, workRecoveryValid } from './work-recovery-proof.mjs';
6
6
  import { chromeRecoveryValid,chromeObservationRetryValid } from './chrome-recovery-proof.mjs';
7
7
  import { agentRecoveryValid } from './agent-recovery-proof.mjs';
8
8
  import { chromeCandidateRecoveryValid } from './chrome-candidate-recovery-proof.mjs';
9
- export const recoveryAttribution=kind=>({file_postcondition:'not_established',not_started:'handler_not_entered',source_recorded:'recorded_source_children_not_delivery',source_task_recorded:'recorded_source_task_children_not_delivery',source_task_read:'read_result_unavailable',read_interrupted:'read_result_unavailable',work_not_dispatched:'no_completed_mutation_dispatch',work_child_receipts:'recorded_child_receipts',work_draft_saved:'recorded_draft_chunk',chrome_candidate:'recorded_candidate_children_not_current_delivery',chrome_publication:'observed_publication_only',chrome_observation_retry:'read_result_unavailable',agent_launch_recorded:'registered_children_only',agent_not_dispatched:'no_child_dispatch',agent_read_retry:'read_result_unavailable',agent_control_recorded:'control_handler_return_unavailable',git_integration_revision_call:'recorded_revision_composition_not_current_delivery',git_integration_intent:'registered_git_intent_not_current_delivery',git_integration_not_dispatched:'no_git_dispatch_from_this_handler'})[kind] || 'unknown';
9
+ import {codeRunSessionRecoveryValid} from './code-run-session-proof.mjs';
10
+ export const recoveryAttribution=kind=>kind==='code_run_journal'?'reconstructed_read_only_continuation':kind==='code_run_journal_read'?'read_result_unavailable':({file_postcondition:'not_established',not_started:'handler_not_entered',source_recorded:'recorded_source_children_not_delivery',source_task_recorded:'recorded_source_task_children_not_delivery',source_task_read:'read_result_unavailable',read_interrupted:'read_result_unavailable',work_not_dispatched:'no_completed_mutation_dispatch',work_child_receipts:'recorded_child_receipts',work_draft_saved:'recorded_draft_chunk',chrome_candidate:'recorded_candidate_children_not_current_delivery',chrome_publication:'observed_publication_only',chrome_observation_retry:'read_result_unavailable',agent_launch_recorded:'registered_children_only',agent_not_dispatched:'no_child_dispatch',agent_read_retry:'read_result_unavailable',agent_control_recorded:'control_handler_return_unavailable',git_integration_revision_call:'recorded_revision_composition_not_current_delivery',git_integration_intent:'registered_git_intent_not_current_delivery',git_integration_not_dispatched:'no_git_dispatch_from_this_handler'})[kind] || 'unknown';
10
11
  export function nativeRecoveryRead(rows,p){const c=rows.findLast(r=>r.type==='house_contract'&&r.contract_digest===p.contract_digest)?.contract;return ['Read','Glob','Grep','ListDirectory','FileInfo','GitInspect','GitIntegrationStatus','GitIntegrationEvidence','SourceRead'].includes(p.operation)&&c?.origin===null&&c.name===p.operation&&c.effects.workspace==='read'&&c.effects.external==='none'&&c.permission===p.operation&&(p.operation!=='GitInspect'||!c.steps&&c.effects.process==='read'&&c.effects.authority==='none');}
11
12
 
12
13
  export const recoveredFileOutput = preparation => JSON.stringify({...JSON.parse(preparation.output),status:'postcondition_observed',prepared_status:JSON.parse(preparation.output).status,recovered:true,handler_return:'unavailable',attribution:'not_established'});
@@ -31,6 +32,13 @@ function receiptValid(rows,index,row) {
31
32
  if(rows.slice(r.proposal_ref+1,index).some(e=>e.type==='house_receipt' && e.invocation_id===row.invocation_id))return false;
32
33
  const begins=rows.slice(r.proposal_ref+1,index).filter(e=>e.type==='house_started' && e.invocation_id===row.invocation_id);
33
34
  if(begins.some(begin=>['parent_invocation_id','operation','arguments_digest','contract_digest','actor','surface','work','house','session_id','execution_process'].some(key=>digest(begin[key]??null)!==digest(proposal[key]??null))))return false;
35
+ if(r.kind==='acknowledged_uncertain'){
36
+ const ack=rows[r.acknowledgement_ref];
37
+ return Number.isSafeInteger(r.acknowledgement_ref)&&r.acknowledgement_ref>r.proposal_ref&&r.acknowledgement_ref<index&&ack?.type==='process_recovery_acknowledged'&&ack.invocation_id===row.invocation_id&&ack.actor?.kind==='human'&&typeof ack.reason==='string'&&ack.reason.length>=20
38
+ &&rows.slice(r.proposal_ref+1,r.acknowledgement_ref).some(e=>e.type==='process_recovery_blocked'&&e.invocation_id===row.invocation_id)
39
+ &&row.status==='refused'&&row.code==='HOUSE_RECOVERY_ACKNOWLEDGED_UNCERTAIN'&&row.effects_observed==='unknown';
40
+ }
41
+ if(r.kind==='code_run_journal'||r.kind==='code_run_journal_read')return begins.length===1&&codeRunSessionRecoveryValid(rows,index,row);
34
42
  if(r.kind==='read_interrupted')return begins.length===1 && nativeRecoveryRead(rows.slice(0,index),proposal) && row.status==='refused' && row.code==='HOUSE_RECOVERY_READ_RETRY' && row.effects_observed==='read_only' && !rows.slice(r.proposal_ref,index).some(e=>e.type==='house_proposal' && e.parent_invocation_id===proposal.invocation_id);
35
43
  if(r.kind==='source_task_recorded'||r.kind==='source_task_read')return begins.length===1&&sourceTaskRecoveryValid(rows,index,row);
36
44
  if(r.kind==='source_recorded')return begins.length===1&&sourceRecoveryValid(rows,index,row);