@brunobracaioli/aibrain 0.3.0-beta.7 → 0.4.0-beta.10

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.
@@ -107,6 +107,7 @@ type HookApi = {
107
107
  };
108
108
  };
109
109
  type HookOptions = Record<string, unknown>;
110
+ type MonotonicClock = () => number | undefined;
110
111
  type PreservationMode = 'preserve_internal' | 'upstream_compatible';
111
112
  type CompactionStrategy = 'direct_experimental' | 'native_summary_notes';
112
113
  type ToolPolicy = 'read_only' | 'compatible';
@@ -150,7 +151,7 @@ type Register = (
150
151
  ) => void;
151
152
 
152
153
  const MAX_RESULT_BYTES = 256 * 1024;
153
- const CLAUDE_HOOK_VERSION = '0.3.0-beta.7';
154
+ const CLAUDE_HOOK_VERSION = '0.4.0-beta.7';
154
155
  const MAX_DIAGNOSTIC_BYTE_COUNT = 128 * 1024 * 1024;
155
156
  const COOLDOWN_MS = 60_000;
156
157
  const MAX_TRUNCATED_CODE_POINTS = 300;
@@ -176,6 +177,7 @@ const NATIVE_SUMMARY_PENDING_KEY_PREFIX = 'context-compaction/native-summary-pen
176
177
  const NATIVE_SUMMARY_PENDING_INDEX_KEY = 'context-compaction/native-summary-pending-index';
177
178
  const MAX_NATIVE_SUMMARY_PENDING = 4;
178
179
  const MAX_NATIVE_NOTE_BYTES = 96 * 1024 - 256;
180
+ const MAX_NATIVE_SUMMARY_MESSAGE_HASHES = 256;
179
181
  const MAX_CHECKPOINTS = 4;
180
182
  const MAX_CHECKPOINT_KEYS = 128;
181
183
  const CHECKPOINT_KEY_PREFIX = 'context-compaction/checkpoint/';
@@ -215,6 +217,74 @@ function canonicalJson(value: unknown): string {
215
217
  .join(',')}}`;
216
218
  }
217
219
 
220
+ export type PlanBindingFailureReason =
221
+ | 'plan_session_mismatch'
222
+ | 'plan_revision_mismatch'
223
+ | 'plan_projection_mismatch'
224
+ | 'plan_options_mismatch'
225
+ | 'plan_expired'
226
+ | 'plan_parse_envelope_invalid'
227
+ | 'plan_parse_metadata_invalid'
228
+ | 'plan_parse_decisions_invalid'
229
+ | 'plan_parse_fitting_invalid'
230
+ | 'plan_parse_stats_invalid'
231
+ | 'plan_parse_response_too_large'
232
+ | 'plan_parse_failed'
233
+ | 'session_changed_during_planning'
234
+ | 'snapshot_changed_during_planning'
235
+ | 'historical_notes_changed_during_planning';
236
+
237
+ const PLAN_BINDING_FAILURE_REASONS: ReadonlySet<string> = new Set([
238
+ 'plan_session_mismatch',
239
+ 'plan_revision_mismatch',
240
+ 'plan_projection_mismatch',
241
+ 'plan_options_mismatch',
242
+ 'plan_expired',
243
+ 'plan_parse_envelope_invalid',
244
+ 'plan_parse_metadata_invalid',
245
+ 'plan_parse_decisions_invalid',
246
+ 'plan_parse_fitting_invalid',
247
+ 'plan_parse_stats_invalid',
248
+ 'plan_parse_response_too_large',
249
+ 'plan_parse_failed',
250
+ 'session_changed_during_planning',
251
+ 'snapshot_changed_during_planning',
252
+ 'historical_notes_changed_during_planning',
253
+ ]);
254
+
255
+ function isPlanBindingFailureReason(value: unknown): value is PlanBindingFailureReason {
256
+ return typeof value === 'string' && PLAN_BINDING_FAILURE_REASONS.has(value);
257
+ }
258
+
259
+ export function planParseFailureReason(error: unknown): PlanBindingFailureReason {
260
+ const message = error instanceof Error ? error.message : '';
261
+ if (message === 'AI Brain response is too large') return 'plan_parse_response_too_large';
262
+ if (message.includes('decision') || message.includes('probability'))
263
+ return 'plan_parse_decisions_invalid';
264
+ if (message.includes('fitting')) return 'plan_parse_fitting_invalid';
265
+ if (message.includes('statistic') || message.includes('reduction'))
266
+ return 'plan_parse_stats_invalid';
267
+ if (message.includes('metadata')) return 'plan_parse_metadata_invalid';
268
+ if (
269
+ message.includes('structured plan') ||
270
+ message.includes('plan envelope') ||
271
+ message.includes('unknown fields')
272
+ )
273
+ return 'plan_parse_envelope_invalid';
274
+ return 'plan_parse_failed';
275
+ }
276
+
277
+ function planBindingFailure(
278
+ reason: PlanBindingFailureReason,
279
+ message: string,
280
+ ): Error & { readonly planBindingFailureReason: PlanBindingFailureReason } {
281
+ const error = new Error(message) as Error & {
282
+ planBindingFailureReason: PlanBindingFailureReason;
283
+ };
284
+ error.planBindingFailureReason = reason;
285
+ return error;
286
+ }
287
+
218
288
  async function sha256(value: string): Promise<string> {
219
289
  if (globalThis.crypto?.subtle === undefined) throw new Error('host crypto is unavailable');
220
290
  const digest = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(value));
@@ -224,6 +294,58 @@ async function sha256(value: string): Promise<string> {
224
294
  function byteLength(value: string): number {
225
295
  return new TextEncoder().encode(value).byteLength;
226
296
  }
297
+
298
+ function providerResponseMetadata(
299
+ response: McpResult,
300
+ ): Pick<ObservationRecord, 'provider_response_format' | 'byte_count' | 'item_count'> {
301
+ const hasStructured = response.structuredContent !== undefined;
302
+ const textBlocks =
303
+ response.content?.filter(
304
+ (block): block is { readonly type: string; readonly text: string } =>
305
+ block.type === 'text' && typeof block.text === 'string',
306
+ ) ?? [];
307
+ const format = hasStructured
308
+ ? textBlocks.length > 0
309
+ ? 'both'
310
+ : 'structured'
311
+ : textBlocks.length > 0
312
+ ? 'text'
313
+ : 'missing';
314
+ let byteCount = 0;
315
+ if (hasStructured) {
316
+ const serialized = JSON.stringify(response.structuredContent);
317
+ if (serialized !== undefined) byteCount += byteLength(serialized);
318
+ }
319
+ for (const block of textBlocks) byteCount += byteLength(block.text);
320
+ return {
321
+ provider_response_format: format,
322
+ byte_count: Math.min(byteCount, MAX_DIAGNOSTIC_BYTE_COUNT),
323
+ item_count: Math.min(response.content?.length ?? 0, 10_000_000),
324
+ };
325
+ }
326
+
327
+ export function planBindingFailureReason(
328
+ plan: Readonly<Record<string, unknown>>,
329
+ expected: Readonly<{
330
+ sessionRef: string;
331
+ revision: string;
332
+ projectionHash: string;
333
+ optionsHash?: string | null;
334
+ now?: number;
335
+ }>,
336
+ ): PlanBindingFailureReason | undefined {
337
+ if (plan.sessionRef !== expected.sessionRef) return 'plan_session_mismatch';
338
+ if (plan.revision !== expected.revision) return 'plan_revision_mismatch';
339
+ if (plan.projectionHash !== expected.projectionHash) return 'plan_projection_mismatch';
340
+ if (expected.optionsHash === null) return 'plan_options_mismatch';
341
+ if (expected.optionsHash !== undefined && plan.optionsHash !== expected.optionsHash)
342
+ return 'plan_options_mismatch';
343
+ const expiresAt = Date.parse(String(plan.expiresAt));
344
+ if (!Number.isFinite(expiresAt) || expiresAt <= (expected.now ?? Date.now()))
345
+ return 'plan_expired';
346
+ return undefined;
347
+ }
348
+
227
349
  function unique(values: readonly string[]): string[] {
228
350
  return [...new Set(values)];
229
351
  }
@@ -341,6 +463,10 @@ function v2WireOptions(options: V2Options): Record<string, unknown> {
341
463
 
342
464
  function safeErrorCategory(error: unknown): string {
343
465
  const record = isRecord(error) ? error : {};
466
+ // A coded error names its own reason; the keyword guess below would read
467
+ // "AI Brain plan does not meet the minimum estimated reduction" as invalid_plan.
468
+ if (COMPACT_ERROR_CODES.includes(record.compactErrorCode as CompactErrorCode))
469
+ return record.compactErrorCode as CompactErrorCode;
344
470
  const code = typeof record.code === 'string' ? record.code.toLowerCase() : '';
345
471
  const name = typeof record.name === 'string' ? record.name.toLowerCase() : '';
346
472
  const message = typeof record.message === 'string' ? record.message.toLowerCase() : '';
@@ -565,6 +691,8 @@ type ContextBudgetSnapshot = {
565
691
  readonly accountInFlight: boolean;
566
692
  readonly retryAfterMs: number;
567
693
  readonly expiresAt: string;
694
+ readonly requestStartedAtMonotonic: number;
695
+ readonly lifetimeMs: number;
568
696
  readonly windowEnd: string;
569
697
  readonly correlationId: string;
570
698
  };
@@ -578,7 +706,25 @@ type ContextBudgetRead =
578
706
  readonly error?: Error | undefined;
579
707
  };
580
708
 
581
- function parseContextBudget(value: unknown, now = Date.now()): ContextBudgetSnapshot | undefined {
709
+ function monotonicNow(): number | undefined {
710
+ const host = globalThis as unknown as {
711
+ readonly performance?: { readonly now?: () => number };
712
+ };
713
+ const now = host.performance?.now;
714
+ if (typeof now !== 'function') return undefined;
715
+ try {
716
+ const value = now.call(host.performance);
717
+ return Number.isFinite(value) && value >= 0 ? value : undefined;
718
+ } catch {
719
+ return undefined;
720
+ }
721
+ }
722
+
723
+ export function parseContextBudget(
724
+ value: unknown,
725
+ requestStartedAt: number,
726
+ responseReceivedAt: number,
727
+ ): ContextBudgetSnapshot | undefined {
582
728
  if (!isRecord(value)) return undefined;
583
729
  const allowed = [
584
730
  'schema_version',
@@ -606,9 +752,22 @@ function parseContextBudget(value: unknown, now = Date.now()): ContextBudgetSnap
606
752
  !Number.isFinite(Date.parse(value.window_end))
607
753
  )
608
754
  return undefined;
755
+ const serverTime = Date.parse(value.server_time);
609
756
  const expiresAt = Date.parse(value.expires_at);
610
757
  const windowEnd = Date.parse(value.window_end);
611
- if (expiresAt <= now || expiresAt - now > BUDGET_SNAPSHOT_MAX_AGE_MS || windowEnd < expiresAt)
758
+ const snapshotLifetimeMs = expiresAt - serverTime;
759
+ const requestElapsedMs = responseReceivedAt - requestStartedAt;
760
+ if (
761
+ !Number.isFinite(requestStartedAt) ||
762
+ !Number.isFinite(responseReceivedAt) ||
763
+ requestStartedAt < 0 ||
764
+ requestElapsedMs < 0 ||
765
+ !Number.isSafeInteger(snapshotLifetimeMs) ||
766
+ snapshotLifetimeMs <= 0 ||
767
+ snapshotLifetimeMs > BUDGET_SNAPSHOT_MAX_AGE_MS ||
768
+ requestElapsedMs >= snapshotLifetimeMs ||
769
+ windowEnd < expiresAt
770
+ )
612
771
  return undefined;
613
772
  const nonnegative = (candidate: unknown): candidate is number =>
614
773
  typeof candidate === 'number' && Number.isSafeInteger(candidate) && candidate >= 0;
@@ -621,10 +780,8 @@ function parseContextBudget(value: unknown, now = Date.now()): ContextBudgetSnap
621
780
  !nonnegative(value.retry_after_ms) ||
622
781
  value.retry_after_ms > 3_600_000 ||
623
782
  typeof value.account_in_flight !== 'boolean' ||
624
- !nonnegative(value.provider_batches) ||
625
- value.provider_batches > 4 ||
626
- (value.provider_model !== null &&
627
- (typeof value.provider_model !== 'string' || value.provider_model.length > 128))
783
+ value.provider_batches !== 0 ||
784
+ value.provider_model !== null
628
785
  )
629
786
  return undefined;
630
787
  const correlationId = boundedSafeIdentifier(value.correlation_id);
@@ -637,18 +794,20 @@ function parseContextBudget(value: unknown, now = Date.now()): ContextBudgetSnap
637
794
  accountInFlight: value.account_in_flight,
638
795
  retryAfterMs: value.retry_after_ms,
639
796
  expiresAt: value.expires_at,
797
+ requestStartedAtMonotonic: requestStartedAt,
798
+ lifetimeMs: snapshotLifetimeMs,
640
799
  windowEnd: value.window_end,
641
800
  correlationId,
642
801
  };
643
802
  }
644
803
 
645
- function mcpStructuredValue(response: McpResult): unknown {
804
+ function mcpStructuredValue(response: McpResult, maxTextBytes = 8 * 1024): unknown {
646
805
  if (response.structuredContent !== undefined) return response.structuredContent;
647
806
  const text = response.content?.find(
648
807
  (block): block is { readonly type: string; readonly text: string } =>
649
808
  block.type === 'text' && typeof block.text === 'string',
650
809
  )?.text;
651
- if (text === undefined || byteLength(text) > 8 * 1024) return undefined;
810
+ if (text === undefined || byteLength(text) > maxTextBytes) return undefined;
652
811
  try {
653
812
  return JSON.parse(text);
654
813
  } catch {
@@ -656,8 +815,14 @@ function mcpStructuredValue(response: McpResult): unknown {
656
815
  }
657
816
  }
658
817
 
659
- async function readContextBudget(api: HookApi, server: string): Promise<ContextBudgetRead> {
818
+ export async function readContextBudget(
819
+ api: HookApi,
820
+ server: string,
821
+ now: MonotonicClock = monotonicNow,
822
+ ): Promise<ContextBudgetRead> {
660
823
  try {
824
+ const requestStartedAt = now();
825
+ if (requestStartedAt === undefined) return { ok: false, kind: 'provider_unavailable' };
661
826
  const call = api.mcp.call(server, 'brain_context_budget', { schema_version: 1 });
662
827
  const hostTimers = globalThis as unknown as {
663
828
  setTimeout?: (callback: () => void, delay: number) => unknown;
@@ -671,8 +836,12 @@ async function readContextBudget(api: HookApi, server: string): Promise<ContextB
671
836
  resolve(undefined);
672
837
  }, BUDGET_PREFLIGHT_TIMEOUT_MS);
673
838
  });
674
- const response = await Promise.race([call, timeout]);
675
- if (timer !== undefined) hostTimers.clearTimeout?.(timer);
839
+ let response: McpResult | undefined;
840
+ try {
841
+ response = await Promise.race([call, timeout]);
842
+ } finally {
843
+ if (timer !== undefined) hostTimers.clearTimeout?.(timer);
844
+ }
676
845
  if (response === undefined)
677
846
  return { ok: false, kind: timedOut ? 'timeout' : 'invalid_response' };
678
847
  if (response.isError === true) {
@@ -683,7 +852,15 @@ async function readContextBudget(api: HookApi, server: string): Promise<ContextB
683
852
  error,
684
853
  };
685
854
  }
686
- const snapshot = parseContextBudget(mcpStructuredValue(response));
855
+ const responseReceivedAt = now();
856
+ if (responseReceivedAt === undefined) return { ok: false, kind: 'provider_unavailable' };
857
+ if (responseReceivedAt - requestStartedAt > BUDGET_PREFLIGHT_TIMEOUT_MS)
858
+ return { ok: false, kind: 'timeout' };
859
+ const snapshot = parseContextBudget(
860
+ mcpStructuredValue(response),
861
+ requestStartedAt,
862
+ responseReceivedAt,
863
+ );
687
864
  return snapshot === undefined
688
865
  ? { ok: false, kind: 'invalid_response' }
689
866
  : { ok: true, snapshot };
@@ -702,6 +879,25 @@ async function readContextBudget(api: HookApi, server: string): Promise<ContextB
702
879
  }
703
880
  }
704
881
 
882
+ export function contextBudgetIsFresh(
883
+ snapshot: ContextBudgetSnapshot,
884
+ now = monotonicNow(),
885
+ ): boolean {
886
+ const elapsedMs = now === undefined ? Number.NaN : now - snapshot.requestStartedAtMonotonic;
887
+ return (
888
+ now !== undefined &&
889
+ Number.isFinite(now) &&
890
+ Number.isFinite(snapshot.requestStartedAtMonotonic) &&
891
+ Number.isSafeInteger(snapshot.lifetimeMs) &&
892
+ snapshot.requestStartedAtMonotonic >= 0 &&
893
+ snapshot.lifetimeMs > 0 &&
894
+ snapshot.lifetimeMs <= BUDGET_SNAPSHOT_MAX_AGE_MS &&
895
+ Number.isFinite(elapsedMs) &&
896
+ elapsedMs >= 0 &&
897
+ elapsedMs < snapshot.lifetimeMs
898
+ );
899
+ }
900
+
705
901
  function safeRpcStatus(error: unknown): number | undefined {
706
902
  const record = isRecord(error) ? error : {};
707
903
  const candidate = record.rpc_status ?? record.status ?? record.code;
@@ -809,6 +1005,7 @@ const CHECKPOINT_ERRORS: readonly CheckpointError[] = [
809
1005
  type CompactErrorCode =
810
1006
  | 'state_budget_exceeded'
811
1007
  | 'shared_admission_exceeded'
1008
+ | 'budget_response_invalid'
812
1009
  | 'provider_budget_exceeded'
813
1010
  | 'provider_unavailable'
814
1011
  | 'provider_auth_denied'
@@ -823,6 +1020,7 @@ type CompactErrorCode =
823
1020
  const COMPACT_ERROR_CODES: readonly CompactErrorCode[] = [
824
1021
  'state_budget_exceeded',
825
1022
  'shared_admission_exceeded',
1023
+ 'budget_response_invalid',
826
1024
  'provider_budget_exceeded',
827
1025
  'provider_unavailable',
828
1026
  'provider_auth_denied',
@@ -852,6 +1050,8 @@ type ObservationRecord = {
852
1050
  | 'fallback'
853
1051
  | 'compact_entry'
854
1052
  | 'compact_exit'
1053
+ | 'native_summary_proof'
1054
+ | 'native_fallback_proof'
855
1055
  | 'native_error';
856
1056
  readonly category:
857
1057
  | 'observed'
@@ -886,11 +1086,21 @@ type ObservationRecord = {
886
1086
  readonly selected_count?: number;
887
1087
  readonly deferred_count?: number;
888
1088
  readonly rpc_status?: number;
1089
+ readonly provider_response_format?: 'structured' | 'text' | 'both' | 'missing';
889
1090
  readonly admission_detail?: string;
890
1091
  readonly admission_reason?: string;
891
1092
  readonly request_id?: string;
892
1093
  readonly correlation_id?: string;
893
1094
  readonly retry_after_ms?: number;
1095
+ readonly proof_expected_count?: number;
1096
+ readonly proof_actual_count?: number;
1097
+ readonly proof_match?: 'exact' | 'active_suffix' | 'mismatch' | 'ambiguous';
1098
+ readonly proof_match_offset?: number;
1099
+ readonly proof_view_source?: 'default' | 'api';
1100
+ readonly proof_api_view_match?: 'exact' | 'mismatch' | 'unavailable' | 'not_checked';
1101
+ readonly proof_api_count?: number;
1102
+ readonly proof_delivery_source?: 'atomic_result' | 'legacy_append';
1103
+ readonly proof_delivery_match_count?: number;
894
1104
  readonly reason?:
895
1105
  | 'all_recent'
896
1106
  | 'goal_missing'
@@ -905,7 +1115,14 @@ type ObservationRecord = {
905
1115
  | 'provider_not_attempted'
906
1116
  | 'proof_expired'
907
1117
  | 'proof_mismatch'
908
- | 'native_fallback_unconfirmed';
1118
+ | 'native_fallback_unconfirmed'
1119
+ | 'native_summary_proof_expired'
1120
+ | 'native_summary_host_view_mismatch'
1121
+ | 'native_summary_host_view_ambiguous'
1122
+ | 'native_summary_host_view_unavailable'
1123
+ | 'native_summary_note_read_failed'
1124
+ | 'native_summary_attachment_uncertain'
1125
+ | PlanBindingFailureReason;
909
1126
  };
910
1127
 
911
1128
  function observationConfiguration(options: HookOptions): ObservationOptions {
@@ -1507,6 +1724,17 @@ function observationRecord(
1507
1724
  | 'request_id'
1508
1725
  | 'correlation_id'
1509
1726
  | 'retry_after_ms'
1727
+ | 'provider_response_format'
1728
+ | 'proof_expected_count'
1729
+ | 'proof_actual_count'
1730
+ | 'proof_match'
1731
+ | 'proof_match_offset'
1732
+ | 'proof_view_source'
1733
+ | 'proof_api_view_match'
1734
+ | 'proof_api_count'
1735
+ | 'proof_delivery_source'
1736
+ | 'proof_delivery_match_count'
1737
+ | 'reason'
1510
1738
  > = {},
1511
1739
  reason?: ObservationRecord['reason'],
1512
1740
  mode: ObservationRecord['mode'] = 'compact',
@@ -1550,6 +1778,33 @@ function observationRecord(
1550
1778
  ...(fields.request_id === undefined ? {} : { request_id: fields.request_id }),
1551
1779
  ...(fields.correlation_id === undefined ? {} : { correlation_id: fields.correlation_id }),
1552
1780
  ...(fields.retry_after_ms === undefined ? {} : { retry_after_ms: fields.retry_after_ms }),
1781
+ ...(fields.provider_response_format === undefined
1782
+ ? {}
1783
+ : { provider_response_format: fields.provider_response_format }),
1784
+ ...(fields.proof_expected_count === undefined
1785
+ ? {}
1786
+ : { proof_expected_count: fields.proof_expected_count }),
1787
+ ...(fields.proof_actual_count === undefined
1788
+ ? {}
1789
+ : { proof_actual_count: fields.proof_actual_count }),
1790
+ ...(fields.proof_match === undefined ? {} : { proof_match: fields.proof_match }),
1791
+ ...(fields.proof_match_offset === undefined
1792
+ ? {}
1793
+ : { proof_match_offset: fields.proof_match_offset }),
1794
+ ...(fields.proof_view_source === undefined
1795
+ ? {}
1796
+ : { proof_view_source: fields.proof_view_source }),
1797
+ ...(fields.proof_api_view_match === undefined
1798
+ ? {}
1799
+ : { proof_api_view_match: fields.proof_api_view_match }),
1800
+ ...(fields.proof_api_count === undefined ? {} : { proof_api_count: fields.proof_api_count }),
1801
+ ...(fields.proof_delivery_source === undefined
1802
+ ? {}
1803
+ : { proof_delivery_source: fields.proof_delivery_source }),
1804
+ ...(fields.proof_delivery_match_count === undefined
1805
+ ? {}
1806
+ : { proof_delivery_match_count: fields.proof_delivery_match_count }),
1807
+ ...(fields.reason === undefined ? {} : { reason: fields.reason }),
1553
1808
  ...(reason === undefined ? {} : { reason }),
1554
1809
  };
1555
1810
  }
@@ -1585,7 +1840,19 @@ async function flushProofDiagnostic(
1585
1840
  sessionId: string,
1586
1841
  instanceId: string,
1587
1842
  clientVersion: string,
1588
- reason: 'proof_expired' | 'proof_mismatch' | 'native_fallback_unconfirmed',
1843
+ reason: NonNullable<ObservationRecord['reason']>,
1844
+ fields: Pick<
1845
+ ObservationRecord,
1846
+ | 'proof_expected_count'
1847
+ | 'proof_actual_count'
1848
+ | 'proof_match'
1849
+ | 'proof_match_offset'
1850
+ | 'proof_view_source'
1851
+ | 'proof_api_view_match'
1852
+ | 'proof_api_count'
1853
+ | 'proof_delivery_source'
1854
+ | 'proof_delivery_match_count'
1855
+ > = {},
1589
1856
  ): Promise<void> {
1590
1857
  if (!options.diagnosticsEnabled) return;
1591
1858
  await flushObservationDiagnostics(api, options, operationId, sessionId, instanceId, [
@@ -1595,12 +1862,76 @@ async function flushProofDiagnostic(
1595
1862
  'native_error',
1596
1863
  clientVersion,
1597
1864
  undefined,
1598
- {},
1865
+ fields,
1599
1866
  reason,
1600
1867
  ),
1601
1868
  ]);
1602
1869
  }
1603
1870
 
1871
+ async function flushNativeFallbackProofDiagnostic(
1872
+ api: HookApi,
1873
+ options: ObservationOptions,
1874
+ operationId: string,
1875
+ sessionId: string,
1876
+ instanceId: string,
1877
+ clientVersion: string,
1878
+ fields: Pick<
1879
+ ObservationRecord,
1880
+ | 'proof_expected_count'
1881
+ | 'proof_actual_count'
1882
+ | 'proof_match'
1883
+ | 'proof_match_offset'
1884
+ | 'proof_view_source'
1885
+ | 'proof_api_view_match'
1886
+ | 'proof_api_count'
1887
+ >,
1888
+ ): Promise<void> {
1889
+ if (!options.diagnosticsEnabled) return;
1890
+ await flushObservationDiagnostics(api, options, operationId, sessionId, instanceId, [
1891
+ observationRecord(
1892
+ operationId,
1893
+ 'native_fallback_proof',
1894
+ 'observed',
1895
+ clientVersion,
1896
+ undefined,
1897
+ fields,
1898
+ ),
1899
+ ]);
1900
+ }
1901
+
1902
+ async function flushNativeSummaryProofDiagnostic(
1903
+ api: HookApi,
1904
+ options: ObservationOptions,
1905
+ operationId: string,
1906
+ sessionId: string,
1907
+ instanceId: string,
1908
+ clientVersion: string,
1909
+ fields: Pick<
1910
+ ObservationRecord,
1911
+ | 'proof_expected_count'
1912
+ | 'proof_actual_count'
1913
+ | 'proof_match'
1914
+ | 'proof_match_offset'
1915
+ | 'proof_view_source'
1916
+ | 'proof_api_view_match'
1917
+ | 'proof_api_count'
1918
+ | 'proof_delivery_source'
1919
+ | 'proof_delivery_match_count'
1920
+ >,
1921
+ ): Promise<void> {
1922
+ if (!options.diagnosticsEnabled) return;
1923
+ await flushObservationDiagnostics(api, options, operationId, sessionId, instanceId, [
1924
+ observationRecord(
1925
+ operationId,
1926
+ 'native_summary_proof',
1927
+ 'observed',
1928
+ clientVersion,
1929
+ undefined,
1930
+ fields,
1931
+ ),
1932
+ ]);
1933
+ }
1934
+
1604
1935
  function logDiagnostic(
1605
1936
  api: HookApi,
1606
1937
  hook: 'turn.complete' | 'session.compact',
@@ -1681,11 +2012,11 @@ function visibleSnapshotsMatch(
1681
2012
  rereadMessages: readonly ClaudeMessage[],
1682
2013
  trigger: HookEvent['trigger'],
1683
2014
  instructions: string | undefined,
1684
- ): boolean {
1685
- if (eventMessages.length === 0) return rereadMessages.length === 0;
2015
+ ): number | undefined {
2016
+ if (eventMessages.length === 0) return rereadMessages.length === 0 ? 0 : undefined;
1686
2017
  const eventSignatures = visibleSignatures(eventMessages);
1687
2018
  const rereadSignatures = visibleSignatures(rereadMessages);
1688
- if (sameSequence(eventSignatures, rereadSignatures)) return true;
2019
+ if (sameSequence(eventSignatures, rereadSignatures)) return 0;
1689
2020
  if (
1690
2021
  trigger === 'manual' &&
1691
2022
  instructions === undefined &&
@@ -1717,10 +2048,10 @@ function visibleSnapshotsMatch(
1717
2048
  matches.length === 1 &&
1718
2049
  matches[0] === precedingSignatures.length - eventSignatures.length
1719
2050
  )
1720
- return true;
2051
+ return matches[0];
1721
2052
  }
1722
2053
  }
1723
- if (rereadMessages.length < eventMessages.length) return false;
2054
+ if (rereadMessages.length < eventMessages.length) return undefined;
1724
2055
  const eventShape = eventSignatures;
1725
2056
  const matches: number[] = [];
1726
2057
  for (let index = 0; index <= rereadSignatures.length - eventSignatures.length; index++) {
@@ -1728,7 +2059,9 @@ function visibleSnapshotsMatch(
1728
2059
  matches.push(index);
1729
2060
  if (matches.length > 1) break;
1730
2061
  }
1731
- return matches.length === 1 && matches[0] === rereadMessages.length - eventMessages.length;
2062
+ return matches.length === 1 && matches[0] === rereadMessages.length - eventMessages.length
2063
+ ? matches[0]
2064
+ : undefined;
1732
2065
  }
1733
2066
 
1734
2067
  function diagnosticShape(messages: readonly ClaudeMessage[]): Record<string, unknown> {
@@ -2330,6 +2663,10 @@ type NativeSummaryPending = {
2330
2663
  readonly noteSha256: string;
2331
2664
  readonly expectedPrefixHash: string;
2332
2665
  readonly expectedCount: number;
2666
+ readonly expectedMessageHashes?: readonly string[];
2667
+ readonly deliveryMode?: 'atomic_result' | 'legacy_append';
2668
+ readonly anchorCounts?: readonly number[];
2669
+ readonly anchorHashes?: readonly string[];
2333
2670
  readonly expiresAt: string;
2334
2671
  readonly appendAttempted: boolean;
2335
2672
  readonly nativeCompletedReported: boolean;
@@ -2577,6 +2914,10 @@ function structurallyValidNativeSummaryPending(
2577
2914
  'noteSha256',
2578
2915
  'expectedPrefixHash',
2579
2916
  'expectedCount',
2917
+ 'expectedMessageHashes',
2918
+ 'deliveryMode',
2919
+ 'anchorCounts',
2920
+ 'anchorHashes',
2580
2921
  'expiresAt',
2581
2922
  'appendAttempted',
2582
2923
  'nativeCompletedReported',
@@ -2603,6 +2944,28 @@ function structurallyValidNativeSummaryPending(
2603
2944
  Number.isSafeInteger(value.expectedCount) &&
2604
2945
  (value.expectedCount as number) > 0 &&
2605
2946
  (value.expectedCount as number) <= 10_000 &&
2947
+ (value.expectedMessageHashes === undefined ||
2948
+ (Array.isArray(value.expectedMessageHashes) &&
2949
+ value.expectedMessageHashes.length === value.expectedCount &&
2950
+ value.expectedMessageHashes.length <= MAX_NATIVE_SUMMARY_MESSAGE_HASHES &&
2951
+ value.expectedMessageHashes.every(
2952
+ (hash): hash is string => typeof hash === 'string' && /^[a-f0-9]{16}$/u.test(hash),
2953
+ ))) &&
2954
+ (value.deliveryMode === undefined ||
2955
+ value.deliveryMode === 'atomic_result' ||
2956
+ value.deliveryMode === 'legacy_append') &&
2957
+ ((value.anchorCounts === undefined && value.anchorHashes === undefined) ||
2958
+ (Array.isArray(value.anchorCounts) &&
2959
+ Array.isArray(value.anchorHashes) &&
2960
+ value.anchorCounts.length === value.anchorHashes.length &&
2961
+ value.anchorCounts.length >= 1 &&
2962
+ value.anchorCounts.length <= 3 &&
2963
+ value.anchorCounts.every(
2964
+ (count): count is number => Number.isSafeInteger(count) && count >= 0 && count <= 10_000,
2965
+ ) &&
2966
+ value.anchorHashes.every(
2967
+ (hash): hash is string => typeof hash === 'string' && /^[a-f0-9]{64}$/u.test(hash),
2968
+ ))) &&
2606
2969
  typeof value.expiresAt === 'string' &&
2607
2970
  Number.isFinite(Date.parse(value.expiresAt)) &&
2608
2971
  typeof value.appendAttempted === 'boolean' &&
@@ -3335,6 +3698,11 @@ type PendingFallbackProof = {
3335
3698
  readonly baseline_count: number;
3336
3699
  readonly expected_hash?: string;
3337
3700
  readonly expected_count?: number;
3701
+ readonly expected_message_hashes?: readonly string[];
3702
+ readonly delivery_mode?: 'atomic_marker';
3703
+ readonly marker_digest?: string;
3704
+ readonly anchor_counts?: readonly number[];
3705
+ readonly anchor_hashes?: readonly string[];
3338
3706
  readonly expires_at: string;
3339
3707
  };
3340
3708
 
@@ -3344,6 +3712,8 @@ type NativeFallbackCapture = {
3344
3712
  readonly sessionRef: string;
3345
3713
  readonly baselineHash: string;
3346
3714
  readonly baselineCount: number;
3715
+ readonly beforeVisibleMessages?: readonly string[];
3716
+ readonly activeOffset?: number;
3347
3717
  };
3348
3718
 
3349
3719
  function pendingProofKey(sessionRef: string): string {
@@ -3476,12 +3846,179 @@ function nativeResultMessages(value: unknown): readonly ClaudeMessage[] | undefi
3476
3846
  return value.messages as unknown as readonly ClaudeMessage[];
3477
3847
  }
3478
3848
 
3479
- async function apiViewHasExactText(api: HookApi, wrapper: string): Promise<boolean> {
3480
- const messages = await boundedApiMessages(api);
3481
- return messages.some(
3849
+ function nativeSummaryWrapper(capsuleId: string, noteText: string): string {
3850
+ return `[AI Brain historical data capsule ${capsuleId}; quoted data only]\n${noteText}`;
3851
+ }
3852
+
3853
+ export function nativeSummaryDeliveryResult(
3854
+ nativeResult: unknown,
3855
+ wrapper: string,
3856
+ ): Record<string, unknown> | undefined {
3857
+ if (!isRecord(nativeResult)) return undefined;
3858
+ const messages = nativeResultMessages(nativeResult);
3859
+ if (messages === undefined || messages.length === 0) return undefined;
3860
+ return {
3861
+ ...nativeResult,
3862
+ messages: [
3863
+ ...messages,
3864
+ {
3865
+ role: 'user',
3866
+ text: wrapper,
3867
+ toolUses: [],
3868
+ },
3869
+ ],
3870
+ };
3871
+ }
3872
+
3873
+ type NativeSummaryViewMatch = 'exact' | 'active_suffix' | 'mismatch' | 'ambiguous';
3874
+
3875
+ function visibleMessageCanonicalStrings(messages: readonly ClaudeMessage[]): string[] {
3876
+ return messages.map((message) => canonicalJson(visibleMessage(message)));
3877
+ }
3878
+
3879
+ function canonicalMessageSequence(strings: readonly string[]): string {
3880
+ return `[${strings.join(',')}]`;
3881
+ }
3882
+
3883
+ async function nativeProofAnchors(
3884
+ beforeVisibleMessages: readonly string[],
3885
+ expectedCount: number,
3886
+ activeOffset: number | undefined,
3887
+ ): Promise<{ readonly counts: readonly number[]; readonly hashes: readonly string[] }> {
3888
+ const counts = [beforeVisibleMessages.length];
3889
+ if (
3890
+ activeOffset !== undefined &&
3891
+ activeOffset >= 0 &&
3892
+ activeOffset < beforeVisibleMessages.length
3893
+ )
3894
+ counts.push(activeOffset);
3895
+ const replaceTailCount = beforeVisibleMessages.length - expectedCount;
3896
+ if (replaceTailCount > 0 && replaceTailCount < beforeVisibleMessages.length)
3897
+ counts.push(replaceTailCount);
3898
+ const uniqueCounts = [...new Set(counts)].filter((count) => count <= 10_000);
3899
+ const hashes = await Promise.all(
3900
+ uniqueCounts.map((count) =>
3901
+ sha256(canonicalMessageSequence(beforeVisibleMessages.slice(0, count))),
3902
+ ),
3903
+ );
3904
+ return { counts: uniqueCounts, hashes };
3905
+ }
3906
+
3907
+ /**
3908
+ * Claude keeps the retired transcript prefix in session.messages() after a
3909
+ * native compaction, while the hook result describes the active summary
3910
+ * segment. Accept only the exact prefix or one uniquely matching active
3911
+ * suffix. A repeated match remains ambiguous and fails closed.
3912
+ */
3913
+ export async function nativeSummaryViewMatch(
3914
+ actualMessages: readonly ClaudeMessage[],
3915
+ expectedCount: number,
3916
+ expectedHash: string,
3917
+ expectedMessageHashes: readonly string[] | undefined,
3918
+ anchorCounts: readonly number[] | undefined,
3919
+ anchorHashes: readonly string[] | undefined,
3920
+ ): Promise<NativeSummaryViewMatch> {
3921
+ if (expectedCount === 0 || actualMessages.length < expectedCount) return 'mismatch';
3922
+ const matchingStarts: number[] = [];
3923
+ if (expectedMessageHashes === undefined) {
3924
+ const prefixHash = await sha256(
3925
+ canonicalJson(visibleMessages(actualMessages.slice(0, expectedCount))),
3926
+ );
3927
+ return prefixHash === expectedHash ? 'exact' : 'mismatch';
3928
+ }
3929
+ if (expectedMessageHashes.length !== expectedCount) return 'mismatch';
3930
+ const actualMessageHashes: string[] = [];
3931
+ for (const message of actualMessages)
3932
+ actualMessageHashes.push((await sha256(canonicalJson(visibleMessage(message)))).slice(0, 16));
3933
+ for (let index = 0; index <= actualMessages.length - expectedCount; index += 1) {
3934
+ if (
3935
+ expectedMessageHashes.every((hash, offset) => hash === actualMessageHashes[index + offset])
3936
+ ) {
3937
+ const candidateHash = await sha256(
3938
+ canonicalJson(visibleMessages(actualMessages.slice(index, index + expectedCount))),
3939
+ );
3940
+ if (candidateHash === expectedHash) matchingStarts.push(index);
3941
+ }
3942
+ if (matchingStarts.length > 1) return 'ambiguous';
3943
+ }
3944
+ if (matchingStarts.length !== 1) return 'mismatch';
3945
+ const start = matchingStarts[0] ?? -1;
3946
+ if (start === 0 && actualMessages.length === expectedCount) return 'exact';
3947
+ if (start === 0) return 'exact';
3948
+ if (await pendingAnchorMatches(actualMessages, start, anchorCounts, anchorHashes))
3949
+ return 'active_suffix';
3950
+ return 'mismatch';
3951
+ }
3952
+
3953
+ async function pendingAnchorMatches(
3954
+ actualMessages: readonly ClaudeMessage[],
3955
+ actualOffset: number,
3956
+ anchorCounts: readonly number[] | undefined,
3957
+ anchorHashes: readonly string[] | undefined,
3958
+ ): Promise<boolean> {
3959
+ if (
3960
+ anchorCounts === undefined ||
3961
+ anchorHashes === undefined ||
3962
+ anchorCounts.length !== anchorHashes.length
3963
+ )
3964
+ return false;
3965
+ const anchorIndex = anchorCounts.indexOf(actualOffset);
3966
+ if (anchorIndex < 0) return false;
3967
+ return (
3968
+ (await sha256(canonicalJson(visibleMessages(actualMessages.slice(0, actualOffset))))) ===
3969
+ anchorHashes[anchorIndex]
3970
+ );
3971
+ }
3972
+
3973
+ async function nativeSummaryMatchedOffset(
3974
+ actualMessages: readonly ClaudeMessage[],
3975
+ expectedCount: number,
3976
+ expectedHash: string,
3977
+ expectedMessageHashes: readonly string[] | undefined,
3978
+ anchorCounts: readonly number[] | undefined,
3979
+ anchorHashes: readonly string[] | undefined,
3980
+ ): Promise<number | undefined> {
3981
+ if (
3982
+ expectedMessageHashes === undefined ||
3983
+ anchorCounts === undefined ||
3984
+ anchorHashes === undefined
3985
+ )
3986
+ return undefined;
3987
+ const actualMessageHashes = await Promise.all(
3988
+ actualMessages.map(async (message) =>
3989
+ (await sha256(canonicalJson(visibleMessage(message)))).slice(0, 16),
3990
+ ),
3991
+ );
3992
+ const matches: number[] = [];
3993
+ for (const offset of anchorCounts) {
3994
+ if (offset + expectedCount > actualMessages.length) continue;
3995
+ if (!expectedMessageHashes.every((hash, index) => hash === actualMessageHashes[offset + index]))
3996
+ continue;
3997
+ if (
3998
+ (await sha256(
3999
+ canonicalJson(visibleMessages(actualMessages.slice(offset, offset + expectedCount))),
4000
+ )) !== expectedHash
4001
+ )
4002
+ continue;
4003
+ if (!(await pendingAnchorMatches(actualMessages, offset, anchorCounts, anchorHashes))) continue;
4004
+ matches.push(offset);
4005
+ }
4006
+ return matches.length === 1 ? matches[0] : undefined;
4007
+ }
4008
+
4009
+ function exactTextCount(
4010
+ messages: readonly { readonly role: 'user' | 'assistant'; readonly text: string }[],
4011
+ wrapper: string,
4012
+ ): number {
4013
+ return messages.filter(
3482
4014
  (message) =>
3483
4015
  message.role === 'user' && (message.text === wrapper || message.text === `${wrapper}\n`),
3484
- );
4016
+ ).length;
4017
+ }
4018
+
4019
+ async function apiViewExactTextCount(api: HookApi, wrapper: string): Promise<number> {
4020
+ const messages = await boundedApiMessages(api);
4021
+ return exactTextCount(messages, wrapper);
3485
4022
  }
3486
4023
 
3487
4024
  async function boundedApiMessages(
@@ -3704,6 +4241,11 @@ function structurallyValidPendingProof(value: unknown): value is PendingProof {
3704
4241
  'baseline_count',
3705
4242
  'expected_hash',
3706
4243
  'expected_count',
4244
+ 'expected_message_hashes',
4245
+ 'delivery_mode',
4246
+ 'marker_digest',
4247
+ 'anchor_counts',
4248
+ 'anchor_hashes',
3707
4249
  'expires_at',
3708
4250
  ]);
3709
4251
  if (Object.keys(value).some((key) => !allowedRootKeys.has(key))) return false;
@@ -3724,7 +4266,31 @@ function structurallyValidPendingProof(value: unknown): value is PendingProof {
3724
4266
  (value.baseline_count as number) >= 0 &&
3725
4267
  (value.expected_hash === undefined || /^[a-f0-9]{64}$/u.test(String(value.expected_hash))) &&
3726
4268
  (value.expected_count === undefined ||
3727
- (Number.isSafeInteger(value.expected_count) && (value.expected_count as number) >= 0))
4269
+ (Number.isSafeInteger(value.expected_count) && (value.expected_count as number) >= 0)) &&
4270
+ (value.expected_message_hashes === undefined ||
4271
+ (Array.isArray(value.expected_message_hashes) &&
4272
+ value.expected_message_hashes.length === value.expected_count &&
4273
+ value.expected_message_hashes.length <= MAX_NATIVE_SUMMARY_MESSAGE_HASHES &&
4274
+ value.expected_message_hashes.every(
4275
+ (hash): hash is string => typeof hash === 'string' && /^[a-f0-9]{16}$/u.test(hash),
4276
+ ))) &&
4277
+ (value.delivery_mode === undefined || value.delivery_mode === 'atomic_marker') &&
4278
+ (value.marker_digest === undefined || /^[a-f0-9]{64}$/u.test(String(value.marker_digest))) &&
4279
+ ((value.delivery_mode === undefined && value.marker_digest === undefined) ||
4280
+ (value.delivery_mode === 'atomic_marker' && typeof value.marker_digest === 'string')) &&
4281
+ ((value.anchor_counts === undefined && value.anchor_hashes === undefined) ||
4282
+ (Array.isArray(value.anchor_counts) &&
4283
+ Array.isArray(value.anchor_hashes) &&
4284
+ value.anchor_counts.length === value.anchor_hashes.length &&
4285
+ value.anchor_counts.length >= 1 &&
4286
+ value.anchor_counts.length <= 3 &&
4287
+ value.anchor_counts.every(
4288
+ (count): count is number =>
4289
+ Number.isSafeInteger(count) && count >= 0 && count <= 10_000,
4290
+ ) &&
4291
+ value.anchor_hashes.every(
4292
+ (hash): hash is string => typeof hash === 'string' && /^[a-f0-9]{64}$/u.test(hash),
4293
+ )))
3728
4294
  );
3729
4295
  if (
3730
4296
  value.kind !== 'custom' ||
@@ -4354,6 +4920,26 @@ function getTelemetry(
4354
4920
  return state.current;
4355
4921
  }
4356
4922
 
4923
+ async function nativeFallbackMarker(
4924
+ operationId: string,
4925
+ sessionRef: string,
4926
+ baselineHash: string,
4927
+ ): Promise<{ readonly text: string; readonly digest: string }> {
4928
+ const digest = await sha256(
4929
+ `aibrain-native-fallback-v1|${operationId}|${sessionRef}|${baselineHash}`,
4930
+ );
4931
+ return { digest, text: `[AI Brain native fallback receipt v1:${digest}]` };
4932
+ }
4933
+
4934
+ function nativeFallbackDeliveryResult(nativeResult: unknown, marker: string): unknown {
4935
+ const messages = nativeResultMessages(nativeResult);
4936
+ if (messages === undefined || messages.length === 0) return nativeResult;
4937
+ return {
4938
+ ...(nativeResult as Record<string, unknown>),
4939
+ messages: [...messages, { role: 'user', text: marker, toolUses: [] }],
4940
+ };
4941
+ }
4942
+
4357
4943
  async function queueNativeFallbackProof(
4358
4944
  api: HookApi,
4359
4945
  runtime: ProofRuntimeState,
@@ -4362,12 +4948,30 @@ async function queueNativeFallbackProof(
4362
4948
  baselineHash: string,
4363
4949
  baselineCount: number,
4364
4950
  nativeResult: unknown,
4365
- ): Promise<void> {
4951
+ beforeVisibleMessages: readonly string[] | undefined,
4952
+ activeOffset: number | undefined,
4953
+ ): Promise<unknown> {
4366
4954
  const expectedMessages = nativeResultMessages(nativeResult);
4367
4955
  const expectedHash =
4368
4956
  expectedMessages === undefined
4369
4957
  ? undefined
4370
4958
  : await sha256(canonicalJson(visibleMessages(expectedMessages)));
4959
+ const expectedMessageHashes =
4960
+ expectedMessages !== undefined && expectedMessages.length <= MAX_NATIVE_SUMMARY_MESSAGE_HASHES
4961
+ ? await Promise.all(
4962
+ expectedMessages.map(async (message) =>
4963
+ (await sha256(canonicalJson(visibleMessage(message)))).slice(0, 16),
4964
+ ),
4965
+ )
4966
+ : undefined;
4967
+ const anchors =
4968
+ beforeVisibleMessages === undefined || expectedMessages === undefined
4969
+ ? undefined
4970
+ : await nativeProofAnchors(beforeVisibleMessages, expectedMessages.length, activeOffset);
4971
+ const marker =
4972
+ expectedMessages === undefined
4973
+ ? undefined
4974
+ : await nativeFallbackMarker(operationId, sessionRef, baselineHash);
4371
4975
  const proof: PendingFallbackProof = {
4372
4976
  schema_version: 1,
4373
4977
  kind: 'native_fallback',
@@ -4378,10 +4982,25 @@ async function queueNativeFallbackProof(
4378
4982
  ...(expectedHash === undefined
4379
4983
  ? {}
4380
4984
  : { expected_hash: expectedHash, expected_count: expectedMessages?.length ?? 0 }),
4381
- expires_at: new Date(Date.now() + 120_000).toISOString(),
4985
+ ...(expectedMessageHashes === undefined
4986
+ ? {}
4987
+ : { expected_message_hashes: expectedMessageHashes }),
4988
+ ...(marker === undefined
4989
+ ? {}
4990
+ : { delivery_mode: 'atomic_marker' as const, marker_digest: marker.digest }),
4991
+ ...(anchors === undefined
4992
+ ? {}
4993
+ : { anchor_counts: anchors.counts, anchor_hashes: anchors.hashes }),
4994
+ // Native fallback confirmation may occur after a long host compaction.
4995
+ // Keep its proof lifetime aligned with the durable proof bound instead of
4996
+ // expiring it after two minutes while Claude is still processing.
4997
+ expires_at: new Date(Date.now() + PENDING_PROOF_TTL_MS).toISOString(),
4382
4998
  };
4383
4999
  runtime.pendingProofMemory = proof;
4384
5000
  runtime.pendingProofPersistenceFailed = !(await savePendingProof(api, proof));
5001
+ return runtime.pendingProofPersistenceFailed || marker === undefined
5002
+ ? nativeResult
5003
+ : nativeFallbackDeliveryResult(nativeResult, marker.text);
4385
5004
  }
4386
5005
 
4387
5006
  async function confirmPendingProof(
@@ -4447,21 +5066,101 @@ async function confirmPendingProof(
4447
5066
  return;
4448
5067
  }
4449
5068
  let verified = proof.kind === 'custom' ? await verifyCustomProof(proof, messages) : false;
5069
+ let fallbackMatch: NativeSummaryViewMatch | undefined;
5070
+ let fallbackProofFields: Pick<
5071
+ ObservationRecord,
5072
+ | 'proof_expected_count'
5073
+ | 'proof_actual_count'
5074
+ | 'proof_match'
5075
+ | 'proof_match_offset'
5076
+ | 'proof_view_source'
5077
+ | 'proof_api_view_match'
5078
+ | 'proof_api_count'
5079
+ > = {};
4450
5080
  if (proof.kind === 'native_fallback') {
4451
5081
  const expectedCount = proof.expected_count;
4452
- const actualPrefix = expectedCount === undefined ? messages : messages.slice(0, expectedCount);
4453
- const latestHash = await sha256(canonicalJson(visibleMessages(actualPrefix)));
4454
- verified =
4455
- proof.expected_hash !== undefined &&
4456
- expectedCount !== undefined &&
4457
- messages.length >= expectedCount &&
4458
- latestHash === proof.expected_hash &&
4459
- actualPrefix.length === expectedCount;
5082
+ fallbackProofFields = {
5083
+ ...(expectedCount === undefined ? {} : { proof_expected_count: expectedCount }),
5084
+ proof_actual_count: messages.length,
5085
+ };
5086
+ if (proof.expected_hash !== undefined && expectedCount !== undefined) {
5087
+ if (proof.delivery_mode === 'atomic_marker' && proof.marker_digest !== undefined) {
5088
+ try {
5089
+ const apiView = await boundedApiMessages(api);
5090
+ const marker = await nativeFallbackMarker(
5091
+ proof.operation_id,
5092
+ sessionRef,
5093
+ proof.baseline_hash,
5094
+ );
5095
+ const markerValid = marker.digest === proof.marker_digest;
5096
+ const markerCount = exactTextCount(apiView, marker.text);
5097
+ fallbackProofFields = {
5098
+ ...fallbackProofFields,
5099
+ proof_view_source: 'api',
5100
+ proof_api_view_match: markerValid && markerCount === 1 ? 'exact' : 'mismatch',
5101
+ proof_api_count: apiView.length,
5102
+ };
5103
+ if (markerValid && markerCount === 1) {
5104
+ fallbackMatch = 'exact';
5105
+ fallbackProofFields = {
5106
+ ...fallbackProofFields,
5107
+ proof_match: 'exact',
5108
+ proof_match_offset: 0,
5109
+ };
5110
+ verified = true;
5111
+ }
5112
+ } catch {
5113
+ fallbackProofFields = {
5114
+ ...fallbackProofFields,
5115
+ proof_view_source: 'api',
5116
+ proof_api_view_match: 'unavailable',
5117
+ };
5118
+ }
5119
+ } else {
5120
+ // Compatibility with proofs written by beta15 and earlier. These
5121
+ // proofs predate the API-view fingerprint and retain the old strict
5122
+ // default-view check.
5123
+ fallbackMatch = await nativeSummaryViewMatch(
5124
+ messages,
5125
+ expectedCount,
5126
+ proof.expected_hash,
5127
+ proof.expected_message_hashes,
5128
+ proof.anchor_counts,
5129
+ proof.anchor_hashes,
5130
+ );
5131
+ const fallbackOffset =
5132
+ fallbackMatch === 'active_suffix'
5133
+ ? await nativeSummaryMatchedOffset(
5134
+ messages,
5135
+ expectedCount,
5136
+ proof.expected_hash,
5137
+ proof.expected_message_hashes,
5138
+ proof.anchor_counts,
5139
+ proof.anchor_hashes,
5140
+ )
5141
+ : undefined;
5142
+ fallbackProofFields = {
5143
+ ...fallbackProofFields,
5144
+ proof_view_source: 'default',
5145
+ proof_match: fallbackMatch,
5146
+ ...(fallbackMatch === 'exact' ? { proof_match_offset: 0 } : {}),
5147
+ ...(fallbackOffset === undefined ? {} : { proof_match_offset: fallbackOffset }),
5148
+ };
5149
+ verified = fallbackMatch === 'exact' || fallbackMatch === 'active_suffix';
5150
+ }
5151
+ }
4460
5152
  }
4461
5153
  if (runtime.pendingProofPersistenceFailed) verified = false;
4462
5154
  const telemetry = getTelemetry(api, runtime.brainServer, runtime.options, runtime.telemetryState);
4463
5155
  if (!verified) {
4464
- if (deferMismatch) return;
5156
+ // Only the atomic native fallback marker may wait for the host's active
5157
+ // API view. A prepared JEV capsule remains strict at every callback.
5158
+ if (
5159
+ deferMismatch &&
5160
+ proof.kind === 'native_fallback' &&
5161
+ proof.delivery_mode === 'atomic_marker'
5162
+ )
5163
+ return;
4465
5164
  runtime.pendingProofMemory = undefined;
4466
5165
  runtime.pendingProofPersistenceFailed = false;
4467
5166
  await clearPendingProof(api, sessionRef);
@@ -4474,6 +5173,7 @@ async function confirmPendingProof(
4474
5173
  runtime.observationInstanceId,
4475
5174
  nativeClientVersion(runtime.options),
4476
5175
  reason,
5176
+ fallbackProofFields,
4477
5177
  );
4478
5178
  if (runtime.diagnosticMode)
4479
5179
  api.ui.log(
@@ -4485,6 +5185,16 @@ async function confirmPendingProof(
4485
5185
  runtime.pendingProofMemory = undefined;
4486
5186
  runtime.pendingProofPersistenceFailed = false;
4487
5187
  await clearPendingProof(api, sessionRef);
5188
+ if (proof.kind === 'native_fallback')
5189
+ await flushNativeFallbackProofDiagnostic(
5190
+ api,
5191
+ runtime.observation,
5192
+ proof.operation_id,
5193
+ sessionRef,
5194
+ runtime.observationInstanceId,
5195
+ nativeClientVersion(runtime.options),
5196
+ fallbackProofFields,
5197
+ );
4488
5198
  if (proof.kind === 'custom') {
4489
5199
  const dropped = proof.pairs.filter((pair) => pair.action === 'drop_pair').length;
4490
5200
  const reduced = proof.pairs.filter((pair) => pair.action === 'truncate_result').length;
@@ -4517,9 +5227,43 @@ async function recoverNativeSummaryNote(
4517
5227
  }
4518
5228
  const pendingOperationId = pending.operationId;
4519
5229
  const telemetry = getTelemetry(api, runtime.brainServer, runtime.options, runtime.telemetryState);
5230
+ let proofFields: Pick<
5231
+ ObservationRecord,
5232
+ | 'proof_expected_count'
5233
+ | 'proof_actual_count'
5234
+ | 'proof_match'
5235
+ | 'proof_match_offset'
5236
+ | 'proof_view_source'
5237
+ | 'proof_api_view_match'
5238
+ | 'proof_api_count'
5239
+ | 'proof_delivery_source'
5240
+ | 'proof_delivery_match_count'
5241
+ > = {
5242
+ proof_expected_count: pending.deliveryMode === 'atomic_result' ? 1 : pending.expectedCount,
5243
+ };
5244
+ proofFields = {
5245
+ ...proofFields,
5246
+ proof_delivery_source: pending.deliveryMode ?? 'legacy_append',
5247
+ proof_view_source: pending.deliveryMode === 'atomic_result' ? 'api' : 'default',
5248
+ proof_api_view_match: 'not_checked',
5249
+ };
4520
5250
  const uncertain = async (reason: string): Promise<void> => {
4521
5251
  runtime.pendingNativeSummary = undefined;
4522
5252
  await clearNativeSummaryPending(api, sessionRef).catch(() => undefined);
5253
+ const diagnosticReason: ObservationRecord['reason'] =
5254
+ reason === 'pending_expired'
5255
+ ? 'native_summary_proof_expired'
5256
+ : reason === 'native_view_ambiguous'
5257
+ ? 'native_summary_host_view_ambiguous'
5258
+ : reason === 'native_prefix_mismatch'
5259
+ ? 'native_summary_host_view_mismatch'
5260
+ : reason === 'messages_unavailable' ||
5261
+ reason === 'api_view_unavailable' ||
5262
+ reason === 'api_view_reread_failed'
5263
+ ? 'native_summary_host_view_unavailable'
5264
+ : reason === 'note_read_failed'
5265
+ ? 'native_summary_note_read_failed'
5266
+ : 'native_summary_attachment_uncertain';
4523
5267
  await flushProofDiagnostic(
4524
5268
  api,
4525
5269
  runtime.observation,
@@ -4527,7 +5271,8 @@ async function recoverNativeSummaryNote(
4527
5271
  sessionRef,
4528
5272
  runtime.observationInstanceId,
4529
5273
  nativeClientVersion(runtime.options),
4530
- 'proof_mismatch',
5274
+ diagnosticReason,
5275
+ proofFields,
4531
5276
  );
4532
5277
  if (runtime.diagnosticMode)
4533
5278
  api.ui.log(
@@ -4539,6 +5284,71 @@ async function recoverNativeSummaryNote(
4539
5284
  await uncertain('pending_expired');
4540
5285
  return;
4541
5286
  }
5287
+ if (pending.deliveryMode === 'atomic_result') {
5288
+ let noteText: string;
5289
+ try {
5290
+ noteText = await readNativeSummaryNote(
5291
+ api,
5292
+ runtime.options,
5293
+ pending.operationId,
5294
+ sessionRef,
5295
+ pending.noteSha256,
5296
+ );
5297
+ } catch {
5298
+ await uncertain('note_read_failed');
5299
+ return;
5300
+ }
5301
+ const wrapper = nativeSummaryWrapper(pending.capsuleId, noteText);
5302
+ let apiCount: number;
5303
+ let matchCount: number;
5304
+ try {
5305
+ const apiView = await boundedApiMessages(api);
5306
+ apiCount = apiView.length;
5307
+ matchCount = exactTextCount(apiView, wrapper);
5308
+ } catch {
5309
+ proofFields = { ...proofFields, proof_api_view_match: 'unavailable' };
5310
+ if (!allowAppend) return;
5311
+ await uncertain('api_view_unavailable');
5312
+ return;
5313
+ }
5314
+ proofFields = {
5315
+ ...proofFields,
5316
+ proof_api_count: apiCount,
5317
+ proof_delivery_match_count: matchCount,
5318
+ proof_api_view_match: matchCount === 1 ? 'exact' : 'mismatch',
5319
+ proof_match: matchCount === 1 ? 'exact' : 'mismatch',
5320
+ };
5321
+ if (matchCount !== 1) {
5322
+ if (matchCount === 0 && !allowAppend) return;
5323
+ await uncertain('native_summary_attachment_uncertain');
5324
+ return;
5325
+ }
5326
+ await flushNativeSummaryProofDiagnostic(
5327
+ api,
5328
+ runtime.observation,
5329
+ pendingOperationId,
5330
+ sessionRef,
5331
+ runtime.observationInstanceId,
5332
+ nativeClientVersion(runtime.options),
5333
+ proofFields,
5334
+ );
5335
+ if (!pending.nativeCompletedReported) {
5336
+ const marked: NativeSummaryPending = { ...pending, nativeCompletedReported: true };
5337
+ try {
5338
+ await saveNativeSummaryPending(api, marked);
5339
+ pending = marked;
5340
+ runtime.pendingNativeSummary = marked;
5341
+ } catch {
5342
+ runtime.pendingNativeSummary = marked;
5343
+ pending = marked;
5344
+ }
5345
+ telemetry.nativeSummaryCompleted(pending.operationId);
5346
+ }
5347
+ runtime.pendingNativeSummary = undefined;
5348
+ await clearNativeSummaryPending(api, sessionRef);
5349
+ telemetry.recoveryAttached(pending.operationId);
5350
+ return;
5351
+ }
4542
5352
  let messages: ClaudeMessage[];
4543
5353
  try {
4544
5354
  messages = await api.session.messages();
@@ -4546,10 +5356,60 @@ async function recoverNativeSummaryNote(
4546
5356
  await uncertain('messages_unavailable');
4547
5357
  return;
4548
5358
  }
4549
- const prefix = messages.slice(0, pending.expectedCount);
4550
- const prefixHash = await sha256(canonicalJson(visibleMessages(prefix)));
4551
- if (messages.length < pending.expectedCount || prefixHash !== pending.expectedPrefixHash) {
4552
- await uncertain('native_prefix_mismatch');
5359
+ proofFields = { ...proofFields, proof_actual_count: messages.length };
5360
+ const viewMatch = await nativeSummaryViewMatch(
5361
+ messages,
5362
+ pending.expectedCount,
5363
+ pending.expectedPrefixHash,
5364
+ pending.expectedMessageHashes,
5365
+ pending.anchorCounts,
5366
+ pending.anchorHashes,
5367
+ );
5368
+ const viewOffset =
5369
+ viewMatch === 'active_suffix'
5370
+ ? await nativeSummaryMatchedOffset(
5371
+ messages,
5372
+ pending.expectedCount,
5373
+ pending.expectedPrefixHash,
5374
+ pending.expectedMessageHashes,
5375
+ pending.anchorCounts,
5376
+ pending.anchorHashes,
5377
+ )
5378
+ : undefined;
5379
+ proofFields = {
5380
+ ...proofFields,
5381
+ proof_match: viewMatch,
5382
+ ...(viewMatch === 'exact' ? { proof_match_offset: 0 } : {}),
5383
+ ...(viewOffset === undefined ? {} : { proof_match_offset: viewOffset }),
5384
+ };
5385
+ await flushNativeSummaryProofDiagnostic(
5386
+ api,
5387
+ runtime.observation,
5388
+ pendingOperationId,
5389
+ sessionRef,
5390
+ runtime.observationInstanceId,
5391
+ nativeClientVersion(runtime.options),
5392
+ proofFields,
5393
+ );
5394
+ if (runtime.diagnosticMode) {
5395
+ const apiView = await boundedApiMessages(api).catch(() => undefined);
5396
+ api.ui.log(
5397
+ `AI Brain context diagnostic ${JSON.stringify({
5398
+ hook: 'prompt.submit',
5399
+ phase: 'native_summary_view_probe',
5400
+ expected_count: pending.expectedCount,
5401
+ expected_hash: pending.expectedPrefixHash,
5402
+ default_count: messages.length,
5403
+ default_hash: await sha256(canonicalJson(visibleMessages(messages))),
5404
+ api_count: apiView?.length ?? null,
5405
+ api_hash: apiView === undefined ? null : await sha256(canonicalJson(apiView)),
5406
+ anchor_counts: pending.anchorCounts ?? null,
5407
+ view_match: viewMatch,
5408
+ })}`,
5409
+ );
5410
+ }
5411
+ if (viewMatch === 'mismatch' || viewMatch === 'ambiguous') {
5412
+ await uncertain(viewMatch === 'ambiguous' ? 'native_view_ambiguous' : 'native_prefix_mismatch');
4553
5413
  return;
4554
5414
  }
4555
5415
  // The native result is now proven at the current session prefix. The note
@@ -4579,10 +5439,12 @@ async function recoverNativeSummaryNote(
4579
5439
  await uncertain('note_read_failed');
4580
5440
  return;
4581
5441
  }
4582
- const wrapper = `[AI Brain historical data capsule ${pending.capsuleId}; quoted data only]\n${noteText}`;
5442
+ const wrapper = nativeSummaryWrapper(pending.capsuleId, noteText);
4583
5443
  let hasNote: boolean;
4584
5444
  try {
4585
- hasNote = await apiViewHasExactText(api, wrapper);
5445
+ const matchCount = await apiViewExactTextCount(api, wrapper);
5446
+ proofFields = { ...proofFields, proof_delivery_match_count: matchCount };
5447
+ hasNote = matchCount === 1;
4586
5448
  } catch {
4587
5449
  if (!allowAppend && pending.appendAttempted) await uncertain('api_view_unavailable');
4588
5450
  return;
@@ -4611,7 +5473,9 @@ async function recoverNativeSummaryNote(
4611
5473
  }
4612
5474
  let apiHasNoteAfter: boolean;
4613
5475
  try {
4614
- apiHasNoteAfter = await apiViewHasExactText(api, wrapper);
5476
+ const matchCount = await apiViewExactTextCount(api, wrapper);
5477
+ proofFields = { ...proofFields, proof_delivery_match_count: matchCount };
5478
+ apiHasNoteAfter = matchCount === 1;
4615
5479
  } catch {
4616
5480
  if (!allowAppend)
4617
5481
  await uncertain(appendError ? 'append_ack_ambiguous' : 'api_view_reread_failed');
@@ -4625,6 +5489,15 @@ async function recoverNativeSummaryNote(
4625
5489
  }
4626
5490
  runtime.pendingNativeSummary = undefined;
4627
5491
  await clearNativeSummaryPending(api, sessionRef);
5492
+ await flushNativeSummaryProofDiagnostic(
5493
+ api,
5494
+ runtime.observation,
5495
+ pending.operationId,
5496
+ sessionRef,
5497
+ runtime.observationInstanceId,
5498
+ nativeClientVersion(runtime.options),
5499
+ proofFields,
5500
+ );
4628
5501
  telemetry.recoveryAttached(pending.operationId);
4629
5502
  }
4630
5503
 
@@ -4790,7 +5663,11 @@ async function runShadowPreview(
4790
5663
  );
4791
5664
  }
4792
5665
 
4793
- export const register: Register = (on, options) => {
5666
+ const registerWithClock = (
5667
+ on: Parameters<Register>[0],
5668
+ options: HookOptions,
5669
+ monotonicClock: MonotonicClock,
5670
+ ): void => {
4794
5671
  const enabled = options.enabled === true;
4795
5672
  const disclosed = options.disclosed === true;
4796
5673
  const preservationMode: PreservationMode =
@@ -4908,7 +5785,10 @@ export const register: Register = (on, options) => {
4908
5785
 
4909
5786
  on('prompt.submit', async (api, rawEvent, next) => {
4910
5787
  const event = rawEvent as unknown as PromptSubmitHookEvent;
4911
- await confirmPendingProof(api, proofRuntime, true);
5788
+ // The host has completed the prior native turn before accepting a new
5789
+ // prompt. This is the final proof opportunity for a fallback whose first
5790
+ // turn.complete callback observed the retired/default view.
5791
+ await confirmPendingProof(api, proofRuntime);
4912
5792
  if (
4913
5793
  proofRuntime.compactionStrategy === 'native_summary_notes' &&
4914
5794
  enabled &&
@@ -4983,7 +5863,10 @@ export const register: Register = (on, options) => {
4983
5863
  });
4984
5864
 
4985
5865
  on('turn.complete', async (api, event, next) => {
4986
- await confirmPendingProof(api, proofRuntime);
5866
+ // The native host can publish the result before its active API view catches
5867
+ // up. Keep only the atomic fallback marker pending through this lifecycle
5868
+ // edge; prompt.submit performs the strict confirmation on the next turn.
5869
+ await confirmPendingProof(api, proofRuntime, true);
4987
5870
  if (
4988
5871
  proofRuntime.compactionStrategy === 'native_summary_notes' &&
4989
5872
  enabled &&
@@ -5155,7 +6038,7 @@ export const register: Register = (on, options) => {
5155
6038
  nativeContinuationStarted = true;
5156
6039
  const nativeResult = await next(event);
5157
6040
  if (fallbackCapture !== undefined)
5158
- await queueNativeFallbackProof(
6041
+ return queueNativeFallbackProof(
5159
6042
  api,
5160
6043
  proofRuntime,
5161
6044
  fallbackCapture.operationId,
@@ -5163,6 +6046,8 @@ export const register: Register = (on, options) => {
5163
6046
  fallbackCapture.baselineHash,
5164
6047
  fallbackCapture.baselineCount,
5165
6048
  nativeResult,
6049
+ fallbackCapture.beforeVisibleMessages,
6050
+ fallbackCapture.activeOffset,
5166
6051
  );
5167
6052
  return nativeResult;
5168
6053
  };
@@ -5225,6 +6110,8 @@ export const register: Register = (on, options) => {
5225
6110
  | 'request_id'
5226
6111
  | 'correlation_id'
5227
6112
  | 'retry_after_ms'
6113
+ | 'provider_response_format'
6114
+ | 'reason'
5228
6115
  > = {},
5229
6116
  ): void => {
5230
6117
  if (!observation.diagnosticsEnabled || diagnosticRecords.length >= 64) return;
@@ -5285,6 +6172,8 @@ export const register: Register = (on, options) => {
5285
6172
  let proofSessionRef: string | undefined;
5286
6173
  let proofBaselineHash: string | undefined;
5287
6174
  let proofBaselineCount = 0;
6175
+ let baselineActiveOffset: number | undefined;
6176
+ let proofBaselineVisibleMessages: readonly string[] | undefined;
5288
6177
  let historicalNotes: readonly VerifiedHistoricalNote[] = [];
5289
6178
  try {
5290
6179
  const current = event.messages;
@@ -5303,14 +6192,22 @@ export const register: Register = (on, options) => {
5303
6192
  });
5304
6193
  const rereadBaselineHash = await sha256(canonicalJson(reread));
5305
6194
  proofBaselineHash = rereadBaselineHash;
5306
- proofBaselineCount = current.length;
5307
- if (!visibleSnapshotsMatch(current, reread, event.trigger, event.instructions)) {
6195
+ proofBaselineCount = reread.length;
6196
+ const rereadActiveOffset = visibleSnapshotsMatch(
6197
+ current,
6198
+ reread,
6199
+ event.trigger,
6200
+ event.instructions,
6201
+ );
6202
+ if (rereadActiveOffset === undefined) {
5308
6203
  await diagnosticMismatch(api, diagnosticMode, 'reread_before', current, reread);
5309
6204
  markDiagnostic('snapshot', 'invalid_response');
5310
6205
  markDiagnostic('fallback', 'invalid_response');
5311
6206
  logFallback(api, 'reread_before', undefined, policyVersion === 2);
5312
6207
  return continueWithTelemetryFlush();
5313
6208
  }
6209
+ baselineActiveOffset = rereadActiveOffset;
6210
+ proofBaselineVisibleMessages = visibleMessageCanonicalStrings(reread);
5314
6211
  stage = 'projection';
5315
6212
  const built = buildProjection(
5316
6213
  current,
@@ -5359,7 +6256,7 @@ export const register: Register = (on, options) => {
5359
6256
  markDiagnostic('budget', 'provider_not_attempted', {
5360
6257
  item_count: built.pairs.filter((pair) => !pair.protectedPair).length,
5361
6258
  });
5362
- const budgetRead = await readContextBudget(api, brainServer);
6259
+ const budgetRead = await readContextBudget(api, brainServer, monotonicClock);
5363
6260
  if (!budgetRead.ok) {
5364
6261
  const admission = budgetRead.kind === 'admission_denied';
5365
6262
  const category: ObservationRecord['category'] = admission
@@ -5374,8 +6271,13 @@ export const register: Register = (on, options) => {
5374
6271
  const errorMetadata: Partial<ContextErrorMetadata> = isRecord(budgetRead.error)
5375
6272
  ? (budgetRead.error as ContextErrorMetadata)
5376
6273
  : {};
6274
+ const budgetErrorCode = admission
6275
+ ? 'shared_admission_exceeded'
6276
+ : budgetRead.kind === 'invalid_response'
6277
+ ? 'budget_response_invalid'
6278
+ : 'provider_unavailable';
5377
6279
  markDiagnostic('budget', category, {
5378
- compact_error_code: admission ? 'shared_admission_exceeded' : 'provider_unavailable',
6280
+ compact_error_code: budgetErrorCode,
5379
6281
  ...(typeof errorMetadata.admissionDetail === 'string'
5380
6282
  ? { admission_detail: errorMetadata.admissionDetail }
5381
6283
  : {}),
@@ -5390,7 +6292,7 @@ export const register: Register = (on, options) => {
5390
6292
  : {}),
5391
6293
  });
5392
6294
  markDiagnostic('fallback', category, {
5393
- compact_error_code: admission ? 'shared_admission_exceeded' : 'provider_unavailable',
6295
+ compact_error_code: budgetErrorCode,
5394
6296
  });
5395
6297
  logFallback(
5396
6298
  api,
@@ -5406,7 +6308,9 @@ export const register: Register = (on, options) => {
5406
6308
  operationId,
5407
6309
  sessionRef,
5408
6310
  baselineHash: rereadBaselineHash,
5409
- baselineCount: current.length,
6311
+ baselineCount: reread.length,
6312
+ beforeVisibleMessages: visibleMessageCanonicalStrings(reread),
6313
+ activeOffset: baselineActiveOffset,
5410
6314
  });
5411
6315
  }
5412
6316
  const budget = budgetRead.snapshot;
@@ -5431,7 +6335,9 @@ export const register: Register = (on, options) => {
5431
6335
  operationId,
5432
6336
  sessionRef,
5433
6337
  baselineHash: rereadBaselineHash,
5434
- baselineCount: current.length,
6338
+ baselineCount: reread.length,
6339
+ beforeVisibleMessages: visibleMessageCanonicalStrings(reread),
6340
+ activeOffset: baselineActiveOffset,
5435
6341
  });
5436
6342
  }
5437
6343
  let providerProjection = built.projection;
@@ -5506,7 +6412,9 @@ export const register: Register = (on, options) => {
5506
6412
  operationId,
5507
6413
  sessionRef,
5508
6414
  baselineHash: rereadBaselineHash,
5509
- baselineCount: current.length,
6415
+ baselineCount: reread.length,
6416
+ beforeVisibleMessages: visibleMessageCanonicalStrings(reread),
6417
+ activeOffset: baselineActiveOffset,
5510
6418
  });
5511
6419
  }
5512
6420
  providerProjection = window.projection;
@@ -5552,18 +6460,42 @@ export const register: Register = (on, options) => {
5552
6460
  projection: providerProjection,
5553
6461
  ...(policyVersion === 2 ? { schema_version: 2, options: wireOptions } : {}),
5554
6462
  };
6463
+ await persistDiagnostics();
6464
+ if (!contextBudgetIsFresh(budget, monotonicClock())) {
6465
+ markDiagnostic('budget', 'invalid_response', {
6466
+ compact_error_code: 'budget_response_invalid',
6467
+ });
6468
+ markDiagnostic('fallback', 'invalid_response', {
6469
+ compact_error_code: 'budget_response_invalid',
6470
+ });
6471
+ logFallback(api, 'budget_unavailable', undefined, policyVersion === 2);
6472
+ getTelemetry(api, brainServer, options, telemetryState).fallback(
6473
+ operationId,
6474
+ 'budget_unavailable',
6475
+ );
6476
+ return continueWithTelemetryFlush({
6477
+ operationId,
6478
+ sessionRef,
6479
+ baselineHash: rereadBaselineHash,
6480
+ baselineCount: reread.length,
6481
+ beforeVisibleMessages: visibleMessageCanonicalStrings(reread),
6482
+ activeOffset: baselineActiveOffset,
6483
+ });
6484
+ }
5555
6485
  stage = 'mcp_call';
5556
6486
  markDiagnostic('provider_request', 'request_attempted', {
5557
6487
  item_count: providerPairs.filter((pair) => !pair.protectedPair).length,
5558
6488
  byte_count: byteLength(JSON.stringify(input)),
5559
6489
  });
5560
- await persistDiagnostics();
5561
6490
  if (diagnosticMode)
5562
6491
  api.ui.log(
5563
6492
  `AI Brain context diagnostic ${JSON.stringify({ hook: 'session.compact', phase: 'mcp_started' })}`,
5564
6493
  );
5565
6494
  const call = api.mcp.call(brainServer, 'brain_context_compact', input);
5566
6495
  pendingMcp = call;
6496
+ // The request has started, so persist its attempt without delaying the
6497
+ // budget freshness check or extending the provider deadline.
6498
+ void persistDiagnostics();
5567
6499
  let timedOut = false;
5568
6500
  const hostTimers = globalThis as unknown as {
5569
6501
  setTimeout?: (callback: () => void, delay: number) => unknown;
@@ -5610,13 +6542,41 @@ export const register: Register = (on, options) => {
5610
6542
  markDiagnostic(
5611
6543
  'provider_response',
5612
6544
  response.isError === true ? 'native_error' : 'response_obtained',
6545
+ providerResponseMetadata(response),
5613
6546
  );
5614
6547
  if (response.isError === true) throw providerResponseError(response);
5615
6548
  stage = 'parse_plan';
5616
- const structured = mcpStructuredValue(response);
5617
- if (byteLength(JSON.stringify(structured)) > MAX_RESULT_BYTES)
5618
- throw new Error('AI Brain response is too large');
5619
- const plan = parsePlan(structured, policyVersion, v2Options);
6549
+ const structured = mcpStructuredValue(response, MAX_RESULT_BYTES);
6550
+ let plan: Record<string, unknown>;
6551
+ try {
6552
+ const serialized = JSON.stringify(structured);
6553
+ const responseText = response.content?.find(
6554
+ (block): block is { readonly type: string; readonly text: string } =>
6555
+ block.type === 'text' && typeof block.text === 'string',
6556
+ )?.text;
6557
+ if (
6558
+ (serialized === undefined &&
6559
+ response.structuredContent === undefined &&
6560
+ responseText === undefined) ||
6561
+ (response.structuredContent === undefined &&
6562
+ responseText !== undefined &&
6563
+ byteLength(responseText) > MAX_RESULT_BYTES) ||
6564
+ (serialized !== undefined && byteLength(serialized) > MAX_RESULT_BYTES)
6565
+ )
6566
+ throw new Error(
6567
+ serialized === undefined &&
6568
+ response.structuredContent === undefined &&
6569
+ responseText === undefined
6570
+ ? 'AI Brain plan envelope is missing'
6571
+ : 'AI Brain response is too large',
6572
+ );
6573
+ plan = parsePlan(structured, policyVersion, v2Options);
6574
+ } catch (error) {
6575
+ throw planBindingFailure(
6576
+ planParseFailureReason(error),
6577
+ 'AI Brain plan failed validation',
6578
+ );
6579
+ }
5620
6580
  const planDecisions = plan.decisions;
5621
6581
  const planStats = plan.stats;
5622
6582
  markDiagnostic('plan_validation', 'provider_decision', {
@@ -5627,22 +6587,30 @@ export const register: Register = (on, options) => {
5627
6587
  `AI Brain context diagnostic ${JSON.stringify({ hook: 'session.compact', phase: 'plan_ready', status: plan.status, eligible_count: isRecord(planStats) && typeof planStats.eligible_count === 'number' ? planStats.eligible_count : 0, batch_count: isRecord(planStats) && typeof planStats.batch_count === 'number' ? planStats.batch_count : 0 })}`,
5628
6588
  );
5629
6589
  stage = 'match_plan';
5630
- if (
5631
- plan.sessionRef !== sessionRef ||
5632
- plan.revision !== revision ||
5633
- plan.projectionHash !== (await sha256(canonicalJson(providerProjection))) ||
5634
- (policyVersion === 2 && plan.optionsHash !== optionsHash) ||
5635
- Date.parse(String(plan.expiresAt)) <= Date.now()
5636
- )
5637
- throw new Error('AI Brain plan is stale');
6590
+ const bindingReason = planBindingFailureReason(plan, {
6591
+ sessionRef,
6592
+ revision,
6593
+ projectionHash: await sha256(canonicalJson(providerProjection)),
6594
+ ...(policyVersion === 2 ? { optionsHash: optionsHash ?? null } : {}),
6595
+ });
6596
+ if (policyVersion === 2 && optionsHash === undefined)
6597
+ throw planBindingFailure('plan_options_mismatch', 'AI Brain plan options are missing');
6598
+ if (bindingReason !== undefined)
6599
+ throw planBindingFailure(bindingReason, 'AI Brain plan binding was rejected');
5638
6600
  if ((await api.session.id()) !== sessionRef)
5639
- throw new Error('Claude session changed during planning');
6601
+ throw planBindingFailure(
6602
+ 'session_changed_during_planning',
6603
+ 'Claude session changed during planning',
6604
+ );
5640
6605
  stage = 'reread_after';
5641
6606
  const latestVisible = await api.session.messages();
5642
6607
  if ((await sha256(canonicalJson(latestVisible))) !== rereadBaselineHash) {
5643
6608
  await diagnosticMismatch(api, diagnosticMode, 'reread_after', current, latestVisible);
5644
6609
  logFallback(api, 'reread_after', undefined, policyVersion === 2);
5645
- throw new Error('Claude transcript changed during planning');
6610
+ throw planBindingFailure(
6611
+ 'snapshot_changed_during_planning',
6612
+ 'Claude transcript changed during planning',
6613
+ );
5646
6614
  }
5647
6615
  if (compactionStrategy === 'native_summary_notes') {
5648
6616
  const latestHistoricalNotes = await collectVerifiedHistoricalNotes(
@@ -5655,7 +6623,10 @@ export const register: Register = (on, options) => {
5655
6623
  latestHistoricalNotes.map((note) => [note.capsuleId, note.noteSha256]),
5656
6624
  ) !== canonicalJson(historicalNotes.map((note) => [note.capsuleId, note.noteSha256]))
5657
6625
  )
5658
- throw new Error('native_note_capsule_changed');
6626
+ throw planBindingFailure(
6627
+ 'historical_notes_changed_during_planning',
6628
+ 'Claude historical notes changed during planning',
6629
+ );
5659
6630
  }
5660
6631
  if (plan.status === 'noop') {
5661
6632
  markDiagnostic('candidate_preparation', 'skipped');
@@ -5729,6 +6700,7 @@ export const register: Register = (on, options) => {
5729
6700
  transformed,
5730
6701
  );
5731
6702
  getTelemetry(api, brainServer, options, telemetryState).notesWritten(operationId);
6703
+ const beforeVisibleMessages = visibleMessageCanonicalStrings(reread);
5732
6704
  nativeContinuationStarted = true;
5733
6705
  const nativeInput = appendHistoricalNotes(transformed, historicalNotes);
5734
6706
  const nativeResult = await next({ ...event, messages: nativeInput });
@@ -5738,6 +6710,29 @@ export const register: Register = (on, options) => {
5738
6710
  throw new Error('native_summary_result_missing');
5739
6711
  const configuration = fileCheckpointConfiguration(options);
5740
6712
  if (configuration.mode !== 'helper') throw new Error('native_note_helper_unavailable');
6713
+ const expectedMessageHashes =
6714
+ expectedMessages.length <= MAX_NATIVE_SUMMARY_MESSAGE_HASHES
6715
+ ? await Promise.all(
6716
+ expectedMessages.map(async (message) =>
6717
+ (await sha256(canonicalJson(visibleMessage(message)))).slice(0, 16),
6718
+ ),
6719
+ )
6720
+ : undefined;
6721
+ const wrapper = nativeSummaryWrapper(note.capsuleId, note.noteText);
6722
+ const deliveredResult = nativeSummaryDeliveryResult(nativeResult, wrapper);
6723
+ if (deliveredResult === undefined) {
6724
+ getTelemetry(api, brainServer, options, telemetryState).uncertain(
6725
+ operationId,
6726
+ 'native_summary_recovery',
6727
+ );
6728
+ return nativeResult;
6729
+ }
6730
+ const deliveryMode: NativeSummaryPending['deliveryMode'] = 'atomic_result';
6731
+ const anchors = await nativeProofAnchors(
6732
+ beforeVisibleMessages,
6733
+ expectedMessages.length,
6734
+ baselineActiveOffset,
6735
+ );
5741
6736
  const pendingNativeSummary: NativeSummaryPending = {
5742
6737
  schemaVersion: 1,
5743
6738
  operationId,
@@ -5747,13 +6742,19 @@ export const register: Register = (on, options) => {
5747
6742
  noteSha256: note.noteSha256,
5748
6743
  expectedPrefixHash: await sha256(canonicalJson(visibleMessages(expectedMessages))),
5749
6744
  expectedCount: expectedMessages.length,
6745
+ ...(expectedMessageHashes === undefined ? {} : { expectedMessageHashes }),
6746
+ deliveryMode,
6747
+ anchorCounts: anchors.counts,
6748
+ anchorHashes: anchors.hashes,
5750
6749
  expiresAt: new Date(Date.now() + PENDING_PROOF_TTL_MS).toISOString(),
5751
6750
  appendAttempted: false,
5752
6751
  nativeCompletedReported: false,
5753
6752
  };
6753
+ let deliveryIntentSaved = false;
5754
6754
  try {
5755
6755
  await saveNativeSummaryPending(api, pendingNativeSummary);
5756
6756
  proofRuntime.pendingNativeSummary = pendingNativeSummary;
6757
+ deliveryIntentSaved = true;
5757
6758
  } catch {
5758
6759
  if (nativeSessionRef !== undefined)
5759
6760
  await flushProofDiagnostic(
@@ -5763,7 +6764,7 @@ export const register: Register = (on, options) => {
5763
6764
  nativeSessionRef,
5764
6765
  observationInstanceId,
5765
6766
  nativeClientVersion(options),
5766
- 'proof_mismatch',
6767
+ 'native_summary_attachment_uncertain',
5767
6768
  );
5768
6769
  getTelemetry(api, brainServer, options, telemetryState).uncertain(
5769
6770
  operationId,
@@ -5772,7 +6773,7 @@ export const register: Register = (on, options) => {
5772
6773
  }
5773
6774
  if (options.telemetryEnabled === true && options.telemetryDisclosed === true)
5774
6775
  await getTelemetry(api, brainServer, options, telemetryState).flush();
5775
- return nativeResult;
6776
+ return deliveryIntentSaved ? (deliveredResult ?? nativeResult) : nativeResult;
5776
6777
  }
5777
6778
  return { messages: transformed };
5778
6779
  } catch (error) {
@@ -5791,7 +6792,7 @@ export const register: Register = (on, options) => {
5791
6792
  nativeSessionRef,
5792
6793
  observationInstanceId,
5793
6794
  nativeClientVersion(options),
5794
- 'proof_mismatch',
6795
+ 'native_summary_attachment_uncertain',
5795
6796
  );
5796
6797
  }
5797
6798
  throw error;
@@ -5817,6 +6818,14 @@ export const register: Register = (on, options) => {
5817
6818
  const rpcStatus = safeRpcStatus(error);
5818
6819
  const checkpoint = failureStage === 'checkpoint' ? checkpointFailure(error) : undefined;
5819
6820
  const errorMetadata = isRecord(error) ? error : {};
6821
+ const planReason =
6822
+ failureStage === 'parse_plan' ||
6823
+ failureStage === 'match_plan' ||
6824
+ failureStage === 'reread_after'
6825
+ ? isPlanBindingFailureReason(errorMetadata.planBindingFailureReason)
6826
+ ? errorMetadata.planBindingFailureReason
6827
+ : undefined
6828
+ : undefined;
5820
6829
  const admissionFallback = isAdmissionFallback(error);
5821
6830
  const compactCode =
5822
6831
  isRecord(error) &&
@@ -5855,6 +6864,7 @@ export const register: Register = (on, options) => {
5855
6864
  ...(typeof errorMetadata.retryAfterMs === 'number'
5856
6865
  ? { retry_after_ms: errorMetadata.retryAfterMs }
5857
6866
  : {}),
6867
+ ...(planReason === undefined ? {} : { reason: planReason }),
5858
6868
  ...(checkpoint === undefined
5859
6869
  ? {}
5860
6870
  : { checkpoint_phase: checkpoint.phase, checkpoint_error: checkpoint.code }),
@@ -5867,6 +6877,7 @@ export const register: Register = (on, options) => {
5867
6877
  markDiagnostic('fallback', failureCategory, {
5868
6878
  ...(rpcStatus === undefined ? {} : { rpc_status: rpcStatus }),
5869
6879
  ...(compactCode === undefined ? {} : { compact_error_code: compactCode }),
6880
+ ...(planReason === undefined ? {} : { reason: planReason }),
5870
6881
  });
5871
6882
  const telemetry = getTelemetry(api, brainServer, options, telemetryState);
5872
6883
  telemetry.fallback(operationId, admissionFallback ? 'admission_denied' : stage);
@@ -5878,6 +6889,12 @@ export const register: Register = (on, options) => {
5878
6889
  sessionRef: proofSessionRef,
5879
6890
  baselineHash: proofBaselineHash,
5880
6891
  baselineCount: proofBaselineCount,
6892
+ ...(proofBaselineVisibleMessages === undefined
6893
+ ? {}
6894
+ : { beforeVisibleMessages: proofBaselineVisibleMessages }),
6895
+ ...(baselineActiveOffset === undefined
6896
+ ? {}
6897
+ : { activeOffset: baselineActiveOffset }),
5881
6898
  }
5882
6899
  : undefined,
5883
6900
  );
@@ -5905,3 +6922,13 @@ export const register: Register = (on, options) => {
5905
6922
  });
5906
6923
  });
5907
6924
  };
6925
+
6926
+ export const register: Register = (on, options) => registerWithClock(on, options, monotonicNow);
6927
+
6928
+ export function registerForTesting(
6929
+ on: Parameters<Register>[0],
6930
+ options: HookOptions,
6931
+ monotonicClock: MonotonicClock,
6932
+ ): void {
6933
+ registerWithClock(on, options, monotonicClock);
6934
+ }