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

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.1';
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
  }
@@ -565,6 +687,8 @@ type ContextBudgetSnapshot = {
565
687
  readonly accountInFlight: boolean;
566
688
  readonly retryAfterMs: number;
567
689
  readonly expiresAt: string;
690
+ readonly requestStartedAtMonotonic: number;
691
+ readonly lifetimeMs: number;
568
692
  readonly windowEnd: string;
569
693
  readonly correlationId: string;
570
694
  };
@@ -578,7 +702,25 @@ type ContextBudgetRead =
578
702
  readonly error?: Error | undefined;
579
703
  };
580
704
 
581
- function parseContextBudget(value: unknown, now = Date.now()): ContextBudgetSnapshot | undefined {
705
+ function monotonicNow(): number | undefined {
706
+ const host = globalThis as unknown as {
707
+ readonly performance?: { readonly now?: () => number };
708
+ };
709
+ const now = host.performance?.now;
710
+ if (typeof now !== 'function') return undefined;
711
+ try {
712
+ const value = now.call(host.performance);
713
+ return Number.isFinite(value) && value >= 0 ? value : undefined;
714
+ } catch {
715
+ return undefined;
716
+ }
717
+ }
718
+
719
+ export function parseContextBudget(
720
+ value: unknown,
721
+ requestStartedAt: number,
722
+ responseReceivedAt: number,
723
+ ): ContextBudgetSnapshot | undefined {
582
724
  if (!isRecord(value)) return undefined;
583
725
  const allowed = [
584
726
  'schema_version',
@@ -606,9 +748,22 @@ function parseContextBudget(value: unknown, now = Date.now()): ContextBudgetSnap
606
748
  !Number.isFinite(Date.parse(value.window_end))
607
749
  )
608
750
  return undefined;
751
+ const serverTime = Date.parse(value.server_time);
609
752
  const expiresAt = Date.parse(value.expires_at);
610
753
  const windowEnd = Date.parse(value.window_end);
611
- if (expiresAt <= now || expiresAt - now > BUDGET_SNAPSHOT_MAX_AGE_MS || windowEnd < expiresAt)
754
+ const snapshotLifetimeMs = expiresAt - serverTime;
755
+ const requestElapsedMs = responseReceivedAt - requestStartedAt;
756
+ if (
757
+ !Number.isFinite(requestStartedAt) ||
758
+ !Number.isFinite(responseReceivedAt) ||
759
+ requestStartedAt < 0 ||
760
+ requestElapsedMs < 0 ||
761
+ !Number.isSafeInteger(snapshotLifetimeMs) ||
762
+ snapshotLifetimeMs <= 0 ||
763
+ snapshotLifetimeMs > BUDGET_SNAPSHOT_MAX_AGE_MS ||
764
+ requestElapsedMs >= snapshotLifetimeMs ||
765
+ windowEnd < expiresAt
766
+ )
612
767
  return undefined;
613
768
  const nonnegative = (candidate: unknown): candidate is number =>
614
769
  typeof candidate === 'number' && Number.isSafeInteger(candidate) && candidate >= 0;
@@ -621,10 +776,8 @@ function parseContextBudget(value: unknown, now = Date.now()): ContextBudgetSnap
621
776
  !nonnegative(value.retry_after_ms) ||
622
777
  value.retry_after_ms > 3_600_000 ||
623
778
  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))
779
+ value.provider_batches !== 0 ||
780
+ value.provider_model !== null
628
781
  )
629
782
  return undefined;
630
783
  const correlationId = boundedSafeIdentifier(value.correlation_id);
@@ -637,18 +790,20 @@ function parseContextBudget(value: unknown, now = Date.now()): ContextBudgetSnap
637
790
  accountInFlight: value.account_in_flight,
638
791
  retryAfterMs: value.retry_after_ms,
639
792
  expiresAt: value.expires_at,
793
+ requestStartedAtMonotonic: requestStartedAt,
794
+ lifetimeMs: snapshotLifetimeMs,
640
795
  windowEnd: value.window_end,
641
796
  correlationId,
642
797
  };
643
798
  }
644
799
 
645
- function mcpStructuredValue(response: McpResult): unknown {
800
+ function mcpStructuredValue(response: McpResult, maxTextBytes = 8 * 1024): unknown {
646
801
  if (response.structuredContent !== undefined) return response.structuredContent;
647
802
  const text = response.content?.find(
648
803
  (block): block is { readonly type: string; readonly text: string } =>
649
804
  block.type === 'text' && typeof block.text === 'string',
650
805
  )?.text;
651
- if (text === undefined || byteLength(text) > 8 * 1024) return undefined;
806
+ if (text === undefined || byteLength(text) > maxTextBytes) return undefined;
652
807
  try {
653
808
  return JSON.parse(text);
654
809
  } catch {
@@ -656,8 +811,14 @@ function mcpStructuredValue(response: McpResult): unknown {
656
811
  }
657
812
  }
658
813
 
659
- async function readContextBudget(api: HookApi, server: string): Promise<ContextBudgetRead> {
814
+ export async function readContextBudget(
815
+ api: HookApi,
816
+ server: string,
817
+ now: MonotonicClock = monotonicNow,
818
+ ): Promise<ContextBudgetRead> {
660
819
  try {
820
+ const requestStartedAt = now();
821
+ if (requestStartedAt === undefined) return { ok: false, kind: 'provider_unavailable' };
661
822
  const call = api.mcp.call(server, 'brain_context_budget', { schema_version: 1 });
662
823
  const hostTimers = globalThis as unknown as {
663
824
  setTimeout?: (callback: () => void, delay: number) => unknown;
@@ -671,8 +832,12 @@ async function readContextBudget(api: HookApi, server: string): Promise<ContextB
671
832
  resolve(undefined);
672
833
  }, BUDGET_PREFLIGHT_TIMEOUT_MS);
673
834
  });
674
- const response = await Promise.race([call, timeout]);
675
- if (timer !== undefined) hostTimers.clearTimeout?.(timer);
835
+ let response: McpResult | undefined;
836
+ try {
837
+ response = await Promise.race([call, timeout]);
838
+ } finally {
839
+ if (timer !== undefined) hostTimers.clearTimeout?.(timer);
840
+ }
676
841
  if (response === undefined)
677
842
  return { ok: false, kind: timedOut ? 'timeout' : 'invalid_response' };
678
843
  if (response.isError === true) {
@@ -683,7 +848,15 @@ async function readContextBudget(api: HookApi, server: string): Promise<ContextB
683
848
  error,
684
849
  };
685
850
  }
686
- const snapshot = parseContextBudget(mcpStructuredValue(response));
851
+ const responseReceivedAt = now();
852
+ if (responseReceivedAt === undefined) return { ok: false, kind: 'provider_unavailable' };
853
+ if (responseReceivedAt - requestStartedAt > BUDGET_PREFLIGHT_TIMEOUT_MS)
854
+ return { ok: false, kind: 'timeout' };
855
+ const snapshot = parseContextBudget(
856
+ mcpStructuredValue(response),
857
+ requestStartedAt,
858
+ responseReceivedAt,
859
+ );
687
860
  return snapshot === undefined
688
861
  ? { ok: false, kind: 'invalid_response' }
689
862
  : { ok: true, snapshot };
@@ -702,6 +875,25 @@ async function readContextBudget(api: HookApi, server: string): Promise<ContextB
702
875
  }
703
876
  }
704
877
 
878
+ export function contextBudgetIsFresh(
879
+ snapshot: ContextBudgetSnapshot,
880
+ now = monotonicNow(),
881
+ ): boolean {
882
+ const elapsedMs = now === undefined ? Number.NaN : now - snapshot.requestStartedAtMonotonic;
883
+ return (
884
+ now !== undefined &&
885
+ Number.isFinite(now) &&
886
+ Number.isFinite(snapshot.requestStartedAtMonotonic) &&
887
+ Number.isSafeInteger(snapshot.lifetimeMs) &&
888
+ snapshot.requestStartedAtMonotonic >= 0 &&
889
+ snapshot.lifetimeMs > 0 &&
890
+ snapshot.lifetimeMs <= BUDGET_SNAPSHOT_MAX_AGE_MS &&
891
+ Number.isFinite(elapsedMs) &&
892
+ elapsedMs >= 0 &&
893
+ elapsedMs < snapshot.lifetimeMs
894
+ );
895
+ }
896
+
705
897
  function safeRpcStatus(error: unknown): number | undefined {
706
898
  const record = isRecord(error) ? error : {};
707
899
  const candidate = record.rpc_status ?? record.status ?? record.code;
@@ -809,6 +1001,7 @@ const CHECKPOINT_ERRORS: readonly CheckpointError[] = [
809
1001
  type CompactErrorCode =
810
1002
  | 'state_budget_exceeded'
811
1003
  | 'shared_admission_exceeded'
1004
+ | 'budget_response_invalid'
812
1005
  | 'provider_budget_exceeded'
813
1006
  | 'provider_unavailable'
814
1007
  | 'provider_auth_denied'
@@ -823,6 +1016,7 @@ type CompactErrorCode =
823
1016
  const COMPACT_ERROR_CODES: readonly CompactErrorCode[] = [
824
1017
  'state_budget_exceeded',
825
1018
  'shared_admission_exceeded',
1019
+ 'budget_response_invalid',
826
1020
  'provider_budget_exceeded',
827
1021
  'provider_unavailable',
828
1022
  'provider_auth_denied',
@@ -852,6 +1046,8 @@ type ObservationRecord = {
852
1046
  | 'fallback'
853
1047
  | 'compact_entry'
854
1048
  | 'compact_exit'
1049
+ | 'native_summary_proof'
1050
+ | 'native_fallback_proof'
855
1051
  | 'native_error';
856
1052
  readonly category:
857
1053
  | 'observed'
@@ -886,11 +1082,21 @@ type ObservationRecord = {
886
1082
  readonly selected_count?: number;
887
1083
  readonly deferred_count?: number;
888
1084
  readonly rpc_status?: number;
1085
+ readonly provider_response_format?: 'structured' | 'text' | 'both' | 'missing';
889
1086
  readonly admission_detail?: string;
890
1087
  readonly admission_reason?: string;
891
1088
  readonly request_id?: string;
892
1089
  readonly correlation_id?: string;
893
1090
  readonly retry_after_ms?: number;
1091
+ readonly proof_expected_count?: number;
1092
+ readonly proof_actual_count?: number;
1093
+ readonly proof_match?: 'exact' | 'active_suffix' | 'mismatch' | 'ambiguous';
1094
+ readonly proof_match_offset?: number;
1095
+ readonly proof_view_source?: 'default' | 'api';
1096
+ readonly proof_api_view_match?: 'exact' | 'mismatch' | 'unavailable' | 'not_checked';
1097
+ readonly proof_api_count?: number;
1098
+ readonly proof_delivery_source?: 'atomic_result' | 'legacy_append';
1099
+ readonly proof_delivery_match_count?: number;
894
1100
  readonly reason?:
895
1101
  | 'all_recent'
896
1102
  | 'goal_missing'
@@ -905,7 +1111,14 @@ type ObservationRecord = {
905
1111
  | 'provider_not_attempted'
906
1112
  | 'proof_expired'
907
1113
  | 'proof_mismatch'
908
- | 'native_fallback_unconfirmed';
1114
+ | 'native_fallback_unconfirmed'
1115
+ | 'native_summary_proof_expired'
1116
+ | 'native_summary_host_view_mismatch'
1117
+ | 'native_summary_host_view_ambiguous'
1118
+ | 'native_summary_host_view_unavailable'
1119
+ | 'native_summary_note_read_failed'
1120
+ | 'native_summary_attachment_uncertain'
1121
+ | PlanBindingFailureReason;
909
1122
  };
910
1123
 
911
1124
  function observationConfiguration(options: HookOptions): ObservationOptions {
@@ -1507,6 +1720,17 @@ function observationRecord(
1507
1720
  | 'request_id'
1508
1721
  | 'correlation_id'
1509
1722
  | 'retry_after_ms'
1723
+ | 'provider_response_format'
1724
+ | 'proof_expected_count'
1725
+ | 'proof_actual_count'
1726
+ | 'proof_match'
1727
+ | 'proof_match_offset'
1728
+ | 'proof_view_source'
1729
+ | 'proof_api_view_match'
1730
+ | 'proof_api_count'
1731
+ | 'proof_delivery_source'
1732
+ | 'proof_delivery_match_count'
1733
+ | 'reason'
1510
1734
  > = {},
1511
1735
  reason?: ObservationRecord['reason'],
1512
1736
  mode: ObservationRecord['mode'] = 'compact',
@@ -1550,6 +1774,33 @@ function observationRecord(
1550
1774
  ...(fields.request_id === undefined ? {} : { request_id: fields.request_id }),
1551
1775
  ...(fields.correlation_id === undefined ? {} : { correlation_id: fields.correlation_id }),
1552
1776
  ...(fields.retry_after_ms === undefined ? {} : { retry_after_ms: fields.retry_after_ms }),
1777
+ ...(fields.provider_response_format === undefined
1778
+ ? {}
1779
+ : { provider_response_format: fields.provider_response_format }),
1780
+ ...(fields.proof_expected_count === undefined
1781
+ ? {}
1782
+ : { proof_expected_count: fields.proof_expected_count }),
1783
+ ...(fields.proof_actual_count === undefined
1784
+ ? {}
1785
+ : { proof_actual_count: fields.proof_actual_count }),
1786
+ ...(fields.proof_match === undefined ? {} : { proof_match: fields.proof_match }),
1787
+ ...(fields.proof_match_offset === undefined
1788
+ ? {}
1789
+ : { proof_match_offset: fields.proof_match_offset }),
1790
+ ...(fields.proof_view_source === undefined
1791
+ ? {}
1792
+ : { proof_view_source: fields.proof_view_source }),
1793
+ ...(fields.proof_api_view_match === undefined
1794
+ ? {}
1795
+ : { proof_api_view_match: fields.proof_api_view_match }),
1796
+ ...(fields.proof_api_count === undefined ? {} : { proof_api_count: fields.proof_api_count }),
1797
+ ...(fields.proof_delivery_source === undefined
1798
+ ? {}
1799
+ : { proof_delivery_source: fields.proof_delivery_source }),
1800
+ ...(fields.proof_delivery_match_count === undefined
1801
+ ? {}
1802
+ : { proof_delivery_match_count: fields.proof_delivery_match_count }),
1803
+ ...(fields.reason === undefined ? {} : { reason: fields.reason }),
1553
1804
  ...(reason === undefined ? {} : { reason }),
1554
1805
  };
1555
1806
  }
@@ -1585,7 +1836,19 @@ async function flushProofDiagnostic(
1585
1836
  sessionId: string,
1586
1837
  instanceId: string,
1587
1838
  clientVersion: string,
1588
- reason: 'proof_expired' | 'proof_mismatch' | 'native_fallback_unconfirmed',
1839
+ reason: NonNullable<ObservationRecord['reason']>,
1840
+ fields: Pick<
1841
+ ObservationRecord,
1842
+ | 'proof_expected_count'
1843
+ | 'proof_actual_count'
1844
+ | 'proof_match'
1845
+ | 'proof_match_offset'
1846
+ | 'proof_view_source'
1847
+ | 'proof_api_view_match'
1848
+ | 'proof_api_count'
1849
+ | 'proof_delivery_source'
1850
+ | 'proof_delivery_match_count'
1851
+ > = {},
1589
1852
  ): Promise<void> {
1590
1853
  if (!options.diagnosticsEnabled) return;
1591
1854
  await flushObservationDiagnostics(api, options, operationId, sessionId, instanceId, [
@@ -1595,12 +1858,76 @@ async function flushProofDiagnostic(
1595
1858
  'native_error',
1596
1859
  clientVersion,
1597
1860
  undefined,
1598
- {},
1861
+ fields,
1599
1862
  reason,
1600
1863
  ),
1601
1864
  ]);
1602
1865
  }
1603
1866
 
1867
+ async function flushNativeFallbackProofDiagnostic(
1868
+ api: HookApi,
1869
+ options: ObservationOptions,
1870
+ operationId: string,
1871
+ sessionId: string,
1872
+ instanceId: string,
1873
+ clientVersion: string,
1874
+ fields: Pick<
1875
+ ObservationRecord,
1876
+ | 'proof_expected_count'
1877
+ | 'proof_actual_count'
1878
+ | 'proof_match'
1879
+ | 'proof_match_offset'
1880
+ | 'proof_view_source'
1881
+ | 'proof_api_view_match'
1882
+ | 'proof_api_count'
1883
+ >,
1884
+ ): Promise<void> {
1885
+ if (!options.diagnosticsEnabled) return;
1886
+ await flushObservationDiagnostics(api, options, operationId, sessionId, instanceId, [
1887
+ observationRecord(
1888
+ operationId,
1889
+ 'native_fallback_proof',
1890
+ 'observed',
1891
+ clientVersion,
1892
+ undefined,
1893
+ fields,
1894
+ ),
1895
+ ]);
1896
+ }
1897
+
1898
+ async function flushNativeSummaryProofDiagnostic(
1899
+ api: HookApi,
1900
+ options: ObservationOptions,
1901
+ operationId: string,
1902
+ sessionId: string,
1903
+ instanceId: string,
1904
+ clientVersion: string,
1905
+ fields: Pick<
1906
+ ObservationRecord,
1907
+ | 'proof_expected_count'
1908
+ | 'proof_actual_count'
1909
+ | 'proof_match'
1910
+ | 'proof_match_offset'
1911
+ | 'proof_view_source'
1912
+ | 'proof_api_view_match'
1913
+ | 'proof_api_count'
1914
+ | 'proof_delivery_source'
1915
+ | 'proof_delivery_match_count'
1916
+ >,
1917
+ ): Promise<void> {
1918
+ if (!options.diagnosticsEnabled) return;
1919
+ await flushObservationDiagnostics(api, options, operationId, sessionId, instanceId, [
1920
+ observationRecord(
1921
+ operationId,
1922
+ 'native_summary_proof',
1923
+ 'observed',
1924
+ clientVersion,
1925
+ undefined,
1926
+ fields,
1927
+ ),
1928
+ ]);
1929
+ }
1930
+
1604
1931
  function logDiagnostic(
1605
1932
  api: HookApi,
1606
1933
  hook: 'turn.complete' | 'session.compact',
@@ -1681,11 +2008,11 @@ function visibleSnapshotsMatch(
1681
2008
  rereadMessages: readonly ClaudeMessage[],
1682
2009
  trigger: HookEvent['trigger'],
1683
2010
  instructions: string | undefined,
1684
- ): boolean {
1685
- if (eventMessages.length === 0) return rereadMessages.length === 0;
2011
+ ): number | undefined {
2012
+ if (eventMessages.length === 0) return rereadMessages.length === 0 ? 0 : undefined;
1686
2013
  const eventSignatures = visibleSignatures(eventMessages);
1687
2014
  const rereadSignatures = visibleSignatures(rereadMessages);
1688
- if (sameSequence(eventSignatures, rereadSignatures)) return true;
2015
+ if (sameSequence(eventSignatures, rereadSignatures)) return 0;
1689
2016
  if (
1690
2017
  trigger === 'manual' &&
1691
2018
  instructions === undefined &&
@@ -1717,10 +2044,10 @@ function visibleSnapshotsMatch(
1717
2044
  matches.length === 1 &&
1718
2045
  matches[0] === precedingSignatures.length - eventSignatures.length
1719
2046
  )
1720
- return true;
2047
+ return matches[0];
1721
2048
  }
1722
2049
  }
1723
- if (rereadMessages.length < eventMessages.length) return false;
2050
+ if (rereadMessages.length < eventMessages.length) return undefined;
1724
2051
  const eventShape = eventSignatures;
1725
2052
  const matches: number[] = [];
1726
2053
  for (let index = 0; index <= rereadSignatures.length - eventSignatures.length; index++) {
@@ -1728,7 +2055,9 @@ function visibleSnapshotsMatch(
1728
2055
  matches.push(index);
1729
2056
  if (matches.length > 1) break;
1730
2057
  }
1731
- return matches.length === 1 && matches[0] === rereadMessages.length - eventMessages.length;
2058
+ return matches.length === 1 && matches[0] === rereadMessages.length - eventMessages.length
2059
+ ? matches[0]
2060
+ : undefined;
1732
2061
  }
1733
2062
 
1734
2063
  function diagnosticShape(messages: readonly ClaudeMessage[]): Record<string, unknown> {
@@ -2330,6 +2659,10 @@ type NativeSummaryPending = {
2330
2659
  readonly noteSha256: string;
2331
2660
  readonly expectedPrefixHash: string;
2332
2661
  readonly expectedCount: number;
2662
+ readonly expectedMessageHashes?: readonly string[];
2663
+ readonly deliveryMode?: 'atomic_result' | 'legacy_append';
2664
+ readonly anchorCounts?: readonly number[];
2665
+ readonly anchorHashes?: readonly string[];
2333
2666
  readonly expiresAt: string;
2334
2667
  readonly appendAttempted: boolean;
2335
2668
  readonly nativeCompletedReported: boolean;
@@ -2577,6 +2910,10 @@ function structurallyValidNativeSummaryPending(
2577
2910
  'noteSha256',
2578
2911
  'expectedPrefixHash',
2579
2912
  'expectedCount',
2913
+ 'expectedMessageHashes',
2914
+ 'deliveryMode',
2915
+ 'anchorCounts',
2916
+ 'anchorHashes',
2580
2917
  'expiresAt',
2581
2918
  'appendAttempted',
2582
2919
  'nativeCompletedReported',
@@ -2603,6 +2940,28 @@ function structurallyValidNativeSummaryPending(
2603
2940
  Number.isSafeInteger(value.expectedCount) &&
2604
2941
  (value.expectedCount as number) > 0 &&
2605
2942
  (value.expectedCount as number) <= 10_000 &&
2943
+ (value.expectedMessageHashes === undefined ||
2944
+ (Array.isArray(value.expectedMessageHashes) &&
2945
+ value.expectedMessageHashes.length === value.expectedCount &&
2946
+ value.expectedMessageHashes.length <= MAX_NATIVE_SUMMARY_MESSAGE_HASHES &&
2947
+ value.expectedMessageHashes.every(
2948
+ (hash): hash is string => typeof hash === 'string' && /^[a-f0-9]{16}$/u.test(hash),
2949
+ ))) &&
2950
+ (value.deliveryMode === undefined ||
2951
+ value.deliveryMode === 'atomic_result' ||
2952
+ value.deliveryMode === 'legacy_append') &&
2953
+ ((value.anchorCounts === undefined && value.anchorHashes === undefined) ||
2954
+ (Array.isArray(value.anchorCounts) &&
2955
+ Array.isArray(value.anchorHashes) &&
2956
+ value.anchorCounts.length === value.anchorHashes.length &&
2957
+ value.anchorCounts.length >= 1 &&
2958
+ value.anchorCounts.length <= 3 &&
2959
+ value.anchorCounts.every(
2960
+ (count): count is number => Number.isSafeInteger(count) && count >= 0 && count <= 10_000,
2961
+ ) &&
2962
+ value.anchorHashes.every(
2963
+ (hash): hash is string => typeof hash === 'string' && /^[a-f0-9]{64}$/u.test(hash),
2964
+ ))) &&
2606
2965
  typeof value.expiresAt === 'string' &&
2607
2966
  Number.isFinite(Date.parse(value.expiresAt)) &&
2608
2967
  typeof value.appendAttempted === 'boolean' &&
@@ -3335,6 +3694,11 @@ type PendingFallbackProof = {
3335
3694
  readonly baseline_count: number;
3336
3695
  readonly expected_hash?: string;
3337
3696
  readonly expected_count?: number;
3697
+ readonly expected_message_hashes?: readonly string[];
3698
+ readonly delivery_mode?: 'atomic_marker';
3699
+ readonly marker_digest?: string;
3700
+ readonly anchor_counts?: readonly number[];
3701
+ readonly anchor_hashes?: readonly string[];
3338
3702
  readonly expires_at: string;
3339
3703
  };
3340
3704
 
@@ -3344,6 +3708,8 @@ type NativeFallbackCapture = {
3344
3708
  readonly sessionRef: string;
3345
3709
  readonly baselineHash: string;
3346
3710
  readonly baselineCount: number;
3711
+ readonly beforeVisibleMessages?: readonly string[];
3712
+ readonly activeOffset?: number;
3347
3713
  };
3348
3714
 
3349
3715
  function pendingProofKey(sessionRef: string): string {
@@ -3476,12 +3842,179 @@ function nativeResultMessages(value: unknown): readonly ClaudeMessage[] | undefi
3476
3842
  return value.messages as unknown as readonly ClaudeMessage[];
3477
3843
  }
3478
3844
 
3479
- async function apiViewHasExactText(api: HookApi, wrapper: string): Promise<boolean> {
3480
- const messages = await boundedApiMessages(api);
3481
- return messages.some(
3845
+ function nativeSummaryWrapper(capsuleId: string, noteText: string): string {
3846
+ return `[AI Brain historical data capsule ${capsuleId}; quoted data only]\n${noteText}`;
3847
+ }
3848
+
3849
+ export function nativeSummaryDeliveryResult(
3850
+ nativeResult: unknown,
3851
+ wrapper: string,
3852
+ ): Record<string, unknown> | undefined {
3853
+ if (!isRecord(nativeResult)) return undefined;
3854
+ const messages = nativeResultMessages(nativeResult);
3855
+ if (messages === undefined || messages.length === 0) return undefined;
3856
+ return {
3857
+ ...nativeResult,
3858
+ messages: [
3859
+ ...messages,
3860
+ {
3861
+ role: 'user',
3862
+ text: wrapper,
3863
+ toolUses: [],
3864
+ },
3865
+ ],
3866
+ };
3867
+ }
3868
+
3869
+ type NativeSummaryViewMatch = 'exact' | 'active_suffix' | 'mismatch' | 'ambiguous';
3870
+
3871
+ function visibleMessageCanonicalStrings(messages: readonly ClaudeMessage[]): string[] {
3872
+ return messages.map((message) => canonicalJson(visibleMessage(message)));
3873
+ }
3874
+
3875
+ function canonicalMessageSequence(strings: readonly string[]): string {
3876
+ return `[${strings.join(',')}]`;
3877
+ }
3878
+
3879
+ async function nativeProofAnchors(
3880
+ beforeVisibleMessages: readonly string[],
3881
+ expectedCount: number,
3882
+ activeOffset: number | undefined,
3883
+ ): Promise<{ readonly counts: readonly number[]; readonly hashes: readonly string[] }> {
3884
+ const counts = [beforeVisibleMessages.length];
3885
+ if (
3886
+ activeOffset !== undefined &&
3887
+ activeOffset >= 0 &&
3888
+ activeOffset < beforeVisibleMessages.length
3889
+ )
3890
+ counts.push(activeOffset);
3891
+ const replaceTailCount = beforeVisibleMessages.length - expectedCount;
3892
+ if (replaceTailCount > 0 && replaceTailCount < beforeVisibleMessages.length)
3893
+ counts.push(replaceTailCount);
3894
+ const uniqueCounts = [...new Set(counts)].filter((count) => count <= 10_000);
3895
+ const hashes = await Promise.all(
3896
+ uniqueCounts.map((count) =>
3897
+ sha256(canonicalMessageSequence(beforeVisibleMessages.slice(0, count))),
3898
+ ),
3899
+ );
3900
+ return { counts: uniqueCounts, hashes };
3901
+ }
3902
+
3903
+ /**
3904
+ * Claude keeps the retired transcript prefix in session.messages() after a
3905
+ * native compaction, while the hook result describes the active summary
3906
+ * segment. Accept only the exact prefix or one uniquely matching active
3907
+ * suffix. A repeated match remains ambiguous and fails closed.
3908
+ */
3909
+ export async function nativeSummaryViewMatch(
3910
+ actualMessages: readonly ClaudeMessage[],
3911
+ expectedCount: number,
3912
+ expectedHash: string,
3913
+ expectedMessageHashes: readonly string[] | undefined,
3914
+ anchorCounts: readonly number[] | undefined,
3915
+ anchorHashes: readonly string[] | undefined,
3916
+ ): Promise<NativeSummaryViewMatch> {
3917
+ if (expectedCount === 0 || actualMessages.length < expectedCount) return 'mismatch';
3918
+ const matchingStarts: number[] = [];
3919
+ if (expectedMessageHashes === undefined) {
3920
+ const prefixHash = await sha256(
3921
+ canonicalJson(visibleMessages(actualMessages.slice(0, expectedCount))),
3922
+ );
3923
+ return prefixHash === expectedHash ? 'exact' : 'mismatch';
3924
+ }
3925
+ if (expectedMessageHashes.length !== expectedCount) return 'mismatch';
3926
+ const actualMessageHashes: string[] = [];
3927
+ for (const message of actualMessages)
3928
+ actualMessageHashes.push((await sha256(canonicalJson(visibleMessage(message)))).slice(0, 16));
3929
+ for (let index = 0; index <= actualMessages.length - expectedCount; index += 1) {
3930
+ if (
3931
+ expectedMessageHashes.every((hash, offset) => hash === actualMessageHashes[index + offset])
3932
+ ) {
3933
+ const candidateHash = await sha256(
3934
+ canonicalJson(visibleMessages(actualMessages.slice(index, index + expectedCount))),
3935
+ );
3936
+ if (candidateHash === expectedHash) matchingStarts.push(index);
3937
+ }
3938
+ if (matchingStarts.length > 1) return 'ambiguous';
3939
+ }
3940
+ if (matchingStarts.length !== 1) return 'mismatch';
3941
+ const start = matchingStarts[0] ?? -1;
3942
+ if (start === 0 && actualMessages.length === expectedCount) return 'exact';
3943
+ if (start === 0) return 'exact';
3944
+ if (await pendingAnchorMatches(actualMessages, start, anchorCounts, anchorHashes))
3945
+ return 'active_suffix';
3946
+ return 'mismatch';
3947
+ }
3948
+
3949
+ async function pendingAnchorMatches(
3950
+ actualMessages: readonly ClaudeMessage[],
3951
+ actualOffset: number,
3952
+ anchorCounts: readonly number[] | undefined,
3953
+ anchorHashes: readonly string[] | undefined,
3954
+ ): Promise<boolean> {
3955
+ if (
3956
+ anchorCounts === undefined ||
3957
+ anchorHashes === undefined ||
3958
+ anchorCounts.length !== anchorHashes.length
3959
+ )
3960
+ return false;
3961
+ const anchorIndex = anchorCounts.indexOf(actualOffset);
3962
+ if (anchorIndex < 0) return false;
3963
+ return (
3964
+ (await sha256(canonicalJson(visibleMessages(actualMessages.slice(0, actualOffset))))) ===
3965
+ anchorHashes[anchorIndex]
3966
+ );
3967
+ }
3968
+
3969
+ async function nativeSummaryMatchedOffset(
3970
+ actualMessages: readonly ClaudeMessage[],
3971
+ expectedCount: number,
3972
+ expectedHash: string,
3973
+ expectedMessageHashes: readonly string[] | undefined,
3974
+ anchorCounts: readonly number[] | undefined,
3975
+ anchorHashes: readonly string[] | undefined,
3976
+ ): Promise<number | undefined> {
3977
+ if (
3978
+ expectedMessageHashes === undefined ||
3979
+ anchorCounts === undefined ||
3980
+ anchorHashes === undefined
3981
+ )
3982
+ return undefined;
3983
+ const actualMessageHashes = await Promise.all(
3984
+ actualMessages.map(async (message) =>
3985
+ (await sha256(canonicalJson(visibleMessage(message)))).slice(0, 16),
3986
+ ),
3987
+ );
3988
+ const matches: number[] = [];
3989
+ for (const offset of anchorCounts) {
3990
+ if (offset + expectedCount > actualMessages.length) continue;
3991
+ if (!expectedMessageHashes.every((hash, index) => hash === actualMessageHashes[offset + index]))
3992
+ continue;
3993
+ if (
3994
+ (await sha256(
3995
+ canonicalJson(visibleMessages(actualMessages.slice(offset, offset + expectedCount))),
3996
+ )) !== expectedHash
3997
+ )
3998
+ continue;
3999
+ if (!(await pendingAnchorMatches(actualMessages, offset, anchorCounts, anchorHashes))) continue;
4000
+ matches.push(offset);
4001
+ }
4002
+ return matches.length === 1 ? matches[0] : undefined;
4003
+ }
4004
+
4005
+ function exactTextCount(
4006
+ messages: readonly { readonly role: 'user' | 'assistant'; readonly text: string }[],
4007
+ wrapper: string,
4008
+ ): number {
4009
+ return messages.filter(
3482
4010
  (message) =>
3483
4011
  message.role === 'user' && (message.text === wrapper || message.text === `${wrapper}\n`),
3484
- );
4012
+ ).length;
4013
+ }
4014
+
4015
+ async function apiViewExactTextCount(api: HookApi, wrapper: string): Promise<number> {
4016
+ const messages = await boundedApiMessages(api);
4017
+ return exactTextCount(messages, wrapper);
3485
4018
  }
3486
4019
 
3487
4020
  async function boundedApiMessages(
@@ -3704,6 +4237,11 @@ function structurallyValidPendingProof(value: unknown): value is PendingProof {
3704
4237
  'baseline_count',
3705
4238
  'expected_hash',
3706
4239
  'expected_count',
4240
+ 'expected_message_hashes',
4241
+ 'delivery_mode',
4242
+ 'marker_digest',
4243
+ 'anchor_counts',
4244
+ 'anchor_hashes',
3707
4245
  'expires_at',
3708
4246
  ]);
3709
4247
  if (Object.keys(value).some((key) => !allowedRootKeys.has(key))) return false;
@@ -3724,7 +4262,31 @@ function structurallyValidPendingProof(value: unknown): value is PendingProof {
3724
4262
  (value.baseline_count as number) >= 0 &&
3725
4263
  (value.expected_hash === undefined || /^[a-f0-9]{64}$/u.test(String(value.expected_hash))) &&
3726
4264
  (value.expected_count === undefined ||
3727
- (Number.isSafeInteger(value.expected_count) && (value.expected_count as number) >= 0))
4265
+ (Number.isSafeInteger(value.expected_count) && (value.expected_count as number) >= 0)) &&
4266
+ (value.expected_message_hashes === undefined ||
4267
+ (Array.isArray(value.expected_message_hashes) &&
4268
+ value.expected_message_hashes.length === value.expected_count &&
4269
+ value.expected_message_hashes.length <= MAX_NATIVE_SUMMARY_MESSAGE_HASHES &&
4270
+ value.expected_message_hashes.every(
4271
+ (hash): hash is string => typeof hash === 'string' && /^[a-f0-9]{16}$/u.test(hash),
4272
+ ))) &&
4273
+ (value.delivery_mode === undefined || value.delivery_mode === 'atomic_marker') &&
4274
+ (value.marker_digest === undefined || /^[a-f0-9]{64}$/u.test(String(value.marker_digest))) &&
4275
+ ((value.delivery_mode === undefined && value.marker_digest === undefined) ||
4276
+ (value.delivery_mode === 'atomic_marker' && typeof value.marker_digest === 'string')) &&
4277
+ ((value.anchor_counts === undefined && value.anchor_hashes === undefined) ||
4278
+ (Array.isArray(value.anchor_counts) &&
4279
+ Array.isArray(value.anchor_hashes) &&
4280
+ value.anchor_counts.length === value.anchor_hashes.length &&
4281
+ value.anchor_counts.length >= 1 &&
4282
+ value.anchor_counts.length <= 3 &&
4283
+ value.anchor_counts.every(
4284
+ (count): count is number =>
4285
+ Number.isSafeInteger(count) && count >= 0 && count <= 10_000,
4286
+ ) &&
4287
+ value.anchor_hashes.every(
4288
+ (hash): hash is string => typeof hash === 'string' && /^[a-f0-9]{64}$/u.test(hash),
4289
+ )))
3728
4290
  );
3729
4291
  if (
3730
4292
  value.kind !== 'custom' ||
@@ -4354,6 +4916,26 @@ function getTelemetry(
4354
4916
  return state.current;
4355
4917
  }
4356
4918
 
4919
+ async function nativeFallbackMarker(
4920
+ operationId: string,
4921
+ sessionRef: string,
4922
+ baselineHash: string,
4923
+ ): Promise<{ readonly text: string; readonly digest: string }> {
4924
+ const digest = await sha256(
4925
+ `aibrain-native-fallback-v1|${operationId}|${sessionRef}|${baselineHash}`,
4926
+ );
4927
+ return { digest, text: `[AI Brain native fallback receipt v1:${digest}]` };
4928
+ }
4929
+
4930
+ function nativeFallbackDeliveryResult(nativeResult: unknown, marker: string): unknown {
4931
+ const messages = nativeResultMessages(nativeResult);
4932
+ if (messages === undefined || messages.length === 0) return nativeResult;
4933
+ return {
4934
+ ...(nativeResult as Record<string, unknown>),
4935
+ messages: [...messages, { role: 'user', text: marker, toolUses: [] }],
4936
+ };
4937
+ }
4938
+
4357
4939
  async function queueNativeFallbackProof(
4358
4940
  api: HookApi,
4359
4941
  runtime: ProofRuntimeState,
@@ -4362,12 +4944,30 @@ async function queueNativeFallbackProof(
4362
4944
  baselineHash: string,
4363
4945
  baselineCount: number,
4364
4946
  nativeResult: unknown,
4365
- ): Promise<void> {
4947
+ beforeVisibleMessages: readonly string[] | undefined,
4948
+ activeOffset: number | undefined,
4949
+ ): Promise<unknown> {
4366
4950
  const expectedMessages = nativeResultMessages(nativeResult);
4367
4951
  const expectedHash =
4368
4952
  expectedMessages === undefined
4369
4953
  ? undefined
4370
4954
  : await sha256(canonicalJson(visibleMessages(expectedMessages)));
4955
+ const expectedMessageHashes =
4956
+ expectedMessages !== undefined && expectedMessages.length <= MAX_NATIVE_SUMMARY_MESSAGE_HASHES
4957
+ ? await Promise.all(
4958
+ expectedMessages.map(async (message) =>
4959
+ (await sha256(canonicalJson(visibleMessage(message)))).slice(0, 16),
4960
+ ),
4961
+ )
4962
+ : undefined;
4963
+ const anchors =
4964
+ beforeVisibleMessages === undefined || expectedMessages === undefined
4965
+ ? undefined
4966
+ : await nativeProofAnchors(beforeVisibleMessages, expectedMessages.length, activeOffset);
4967
+ const marker =
4968
+ expectedMessages === undefined
4969
+ ? undefined
4970
+ : await nativeFallbackMarker(operationId, sessionRef, baselineHash);
4371
4971
  const proof: PendingFallbackProof = {
4372
4972
  schema_version: 1,
4373
4973
  kind: 'native_fallback',
@@ -4378,10 +4978,25 @@ async function queueNativeFallbackProof(
4378
4978
  ...(expectedHash === undefined
4379
4979
  ? {}
4380
4980
  : { expected_hash: expectedHash, expected_count: expectedMessages?.length ?? 0 }),
4381
- expires_at: new Date(Date.now() + 120_000).toISOString(),
4981
+ ...(expectedMessageHashes === undefined
4982
+ ? {}
4983
+ : { expected_message_hashes: expectedMessageHashes }),
4984
+ ...(marker === undefined
4985
+ ? {}
4986
+ : { delivery_mode: 'atomic_marker' as const, marker_digest: marker.digest }),
4987
+ ...(anchors === undefined
4988
+ ? {}
4989
+ : { anchor_counts: anchors.counts, anchor_hashes: anchors.hashes }),
4990
+ // Native fallback confirmation may occur after a long host compaction.
4991
+ // Keep its proof lifetime aligned with the durable proof bound instead of
4992
+ // expiring it after two minutes while Claude is still processing.
4993
+ expires_at: new Date(Date.now() + PENDING_PROOF_TTL_MS).toISOString(),
4382
4994
  };
4383
4995
  runtime.pendingProofMemory = proof;
4384
4996
  runtime.pendingProofPersistenceFailed = !(await savePendingProof(api, proof));
4997
+ return runtime.pendingProofPersistenceFailed || marker === undefined
4998
+ ? nativeResult
4999
+ : nativeFallbackDeliveryResult(nativeResult, marker.text);
4385
5000
  }
4386
5001
 
4387
5002
  async function confirmPendingProof(
@@ -4447,21 +5062,101 @@ async function confirmPendingProof(
4447
5062
  return;
4448
5063
  }
4449
5064
  let verified = proof.kind === 'custom' ? await verifyCustomProof(proof, messages) : false;
5065
+ let fallbackMatch: NativeSummaryViewMatch | undefined;
5066
+ let fallbackProofFields: Pick<
5067
+ ObservationRecord,
5068
+ | 'proof_expected_count'
5069
+ | 'proof_actual_count'
5070
+ | 'proof_match'
5071
+ | 'proof_match_offset'
5072
+ | 'proof_view_source'
5073
+ | 'proof_api_view_match'
5074
+ | 'proof_api_count'
5075
+ > = {};
4450
5076
  if (proof.kind === 'native_fallback') {
4451
5077
  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;
5078
+ fallbackProofFields = {
5079
+ ...(expectedCount === undefined ? {} : { proof_expected_count: expectedCount }),
5080
+ proof_actual_count: messages.length,
5081
+ };
5082
+ if (proof.expected_hash !== undefined && expectedCount !== undefined) {
5083
+ if (proof.delivery_mode === 'atomic_marker' && proof.marker_digest !== undefined) {
5084
+ try {
5085
+ const apiView = await boundedApiMessages(api);
5086
+ const marker = await nativeFallbackMarker(
5087
+ proof.operation_id,
5088
+ sessionRef,
5089
+ proof.baseline_hash,
5090
+ );
5091
+ const markerValid = marker.digest === proof.marker_digest;
5092
+ const markerCount = exactTextCount(apiView, marker.text);
5093
+ fallbackProofFields = {
5094
+ ...fallbackProofFields,
5095
+ proof_view_source: 'api',
5096
+ proof_api_view_match: markerValid && markerCount === 1 ? 'exact' : 'mismatch',
5097
+ proof_api_count: apiView.length,
5098
+ };
5099
+ if (markerValid && markerCount === 1) {
5100
+ fallbackMatch = 'exact';
5101
+ fallbackProofFields = {
5102
+ ...fallbackProofFields,
5103
+ proof_match: 'exact',
5104
+ proof_match_offset: 0,
5105
+ };
5106
+ verified = true;
5107
+ }
5108
+ } catch {
5109
+ fallbackProofFields = {
5110
+ ...fallbackProofFields,
5111
+ proof_view_source: 'api',
5112
+ proof_api_view_match: 'unavailable',
5113
+ };
5114
+ }
5115
+ } else {
5116
+ // Compatibility with proofs written by beta15 and earlier. These
5117
+ // proofs predate the API-view fingerprint and retain the old strict
5118
+ // default-view check.
5119
+ fallbackMatch = await nativeSummaryViewMatch(
5120
+ messages,
5121
+ expectedCount,
5122
+ proof.expected_hash,
5123
+ proof.expected_message_hashes,
5124
+ proof.anchor_counts,
5125
+ proof.anchor_hashes,
5126
+ );
5127
+ const fallbackOffset =
5128
+ fallbackMatch === 'active_suffix'
5129
+ ? await nativeSummaryMatchedOffset(
5130
+ messages,
5131
+ expectedCount,
5132
+ proof.expected_hash,
5133
+ proof.expected_message_hashes,
5134
+ proof.anchor_counts,
5135
+ proof.anchor_hashes,
5136
+ )
5137
+ : undefined;
5138
+ fallbackProofFields = {
5139
+ ...fallbackProofFields,
5140
+ proof_view_source: 'default',
5141
+ proof_match: fallbackMatch,
5142
+ ...(fallbackMatch === 'exact' ? { proof_match_offset: 0 } : {}),
5143
+ ...(fallbackOffset === undefined ? {} : { proof_match_offset: fallbackOffset }),
5144
+ };
5145
+ verified = fallbackMatch === 'exact' || fallbackMatch === 'active_suffix';
5146
+ }
5147
+ }
4460
5148
  }
4461
5149
  if (runtime.pendingProofPersistenceFailed) verified = false;
4462
5150
  const telemetry = getTelemetry(api, runtime.brainServer, runtime.options, runtime.telemetryState);
4463
5151
  if (!verified) {
4464
- if (deferMismatch) return;
5152
+ // Only the atomic native fallback marker may wait for the host's active
5153
+ // API view. A prepared JEV capsule remains strict at every callback.
5154
+ if (
5155
+ deferMismatch &&
5156
+ proof.kind === 'native_fallback' &&
5157
+ proof.delivery_mode === 'atomic_marker'
5158
+ )
5159
+ return;
4465
5160
  runtime.pendingProofMemory = undefined;
4466
5161
  runtime.pendingProofPersistenceFailed = false;
4467
5162
  await clearPendingProof(api, sessionRef);
@@ -4474,6 +5169,7 @@ async function confirmPendingProof(
4474
5169
  runtime.observationInstanceId,
4475
5170
  nativeClientVersion(runtime.options),
4476
5171
  reason,
5172
+ fallbackProofFields,
4477
5173
  );
4478
5174
  if (runtime.diagnosticMode)
4479
5175
  api.ui.log(
@@ -4485,6 +5181,16 @@ async function confirmPendingProof(
4485
5181
  runtime.pendingProofMemory = undefined;
4486
5182
  runtime.pendingProofPersistenceFailed = false;
4487
5183
  await clearPendingProof(api, sessionRef);
5184
+ if (proof.kind === 'native_fallback')
5185
+ await flushNativeFallbackProofDiagnostic(
5186
+ api,
5187
+ runtime.observation,
5188
+ proof.operation_id,
5189
+ sessionRef,
5190
+ runtime.observationInstanceId,
5191
+ nativeClientVersion(runtime.options),
5192
+ fallbackProofFields,
5193
+ );
4488
5194
  if (proof.kind === 'custom') {
4489
5195
  const dropped = proof.pairs.filter((pair) => pair.action === 'drop_pair').length;
4490
5196
  const reduced = proof.pairs.filter((pair) => pair.action === 'truncate_result').length;
@@ -4517,9 +5223,43 @@ async function recoverNativeSummaryNote(
4517
5223
  }
4518
5224
  const pendingOperationId = pending.operationId;
4519
5225
  const telemetry = getTelemetry(api, runtime.brainServer, runtime.options, runtime.telemetryState);
5226
+ let proofFields: Pick<
5227
+ ObservationRecord,
5228
+ | 'proof_expected_count'
5229
+ | 'proof_actual_count'
5230
+ | 'proof_match'
5231
+ | 'proof_match_offset'
5232
+ | 'proof_view_source'
5233
+ | 'proof_api_view_match'
5234
+ | 'proof_api_count'
5235
+ | 'proof_delivery_source'
5236
+ | 'proof_delivery_match_count'
5237
+ > = {
5238
+ proof_expected_count: pending.deliveryMode === 'atomic_result' ? 1 : pending.expectedCount,
5239
+ };
5240
+ proofFields = {
5241
+ ...proofFields,
5242
+ proof_delivery_source: pending.deliveryMode ?? 'legacy_append',
5243
+ proof_view_source: pending.deliveryMode === 'atomic_result' ? 'api' : 'default',
5244
+ proof_api_view_match: 'not_checked',
5245
+ };
4520
5246
  const uncertain = async (reason: string): Promise<void> => {
4521
5247
  runtime.pendingNativeSummary = undefined;
4522
5248
  await clearNativeSummaryPending(api, sessionRef).catch(() => undefined);
5249
+ const diagnosticReason: ObservationRecord['reason'] =
5250
+ reason === 'pending_expired'
5251
+ ? 'native_summary_proof_expired'
5252
+ : reason === 'native_view_ambiguous'
5253
+ ? 'native_summary_host_view_ambiguous'
5254
+ : reason === 'native_prefix_mismatch'
5255
+ ? 'native_summary_host_view_mismatch'
5256
+ : reason === 'messages_unavailable' ||
5257
+ reason === 'api_view_unavailable' ||
5258
+ reason === 'api_view_reread_failed'
5259
+ ? 'native_summary_host_view_unavailable'
5260
+ : reason === 'note_read_failed'
5261
+ ? 'native_summary_note_read_failed'
5262
+ : 'native_summary_attachment_uncertain';
4523
5263
  await flushProofDiagnostic(
4524
5264
  api,
4525
5265
  runtime.observation,
@@ -4527,7 +5267,8 @@ async function recoverNativeSummaryNote(
4527
5267
  sessionRef,
4528
5268
  runtime.observationInstanceId,
4529
5269
  nativeClientVersion(runtime.options),
4530
- 'proof_mismatch',
5270
+ diagnosticReason,
5271
+ proofFields,
4531
5272
  );
4532
5273
  if (runtime.diagnosticMode)
4533
5274
  api.ui.log(
@@ -4539,6 +5280,71 @@ async function recoverNativeSummaryNote(
4539
5280
  await uncertain('pending_expired');
4540
5281
  return;
4541
5282
  }
5283
+ if (pending.deliveryMode === 'atomic_result') {
5284
+ let noteText: string;
5285
+ try {
5286
+ noteText = await readNativeSummaryNote(
5287
+ api,
5288
+ runtime.options,
5289
+ pending.operationId,
5290
+ sessionRef,
5291
+ pending.noteSha256,
5292
+ );
5293
+ } catch {
5294
+ await uncertain('note_read_failed');
5295
+ return;
5296
+ }
5297
+ const wrapper = nativeSummaryWrapper(pending.capsuleId, noteText);
5298
+ let apiCount: number;
5299
+ let matchCount: number;
5300
+ try {
5301
+ const apiView = await boundedApiMessages(api);
5302
+ apiCount = apiView.length;
5303
+ matchCount = exactTextCount(apiView, wrapper);
5304
+ } catch {
5305
+ proofFields = { ...proofFields, proof_api_view_match: 'unavailable' };
5306
+ if (!allowAppend) return;
5307
+ await uncertain('api_view_unavailable');
5308
+ return;
5309
+ }
5310
+ proofFields = {
5311
+ ...proofFields,
5312
+ proof_api_count: apiCount,
5313
+ proof_delivery_match_count: matchCount,
5314
+ proof_api_view_match: matchCount === 1 ? 'exact' : 'mismatch',
5315
+ proof_match: matchCount === 1 ? 'exact' : 'mismatch',
5316
+ };
5317
+ if (matchCount !== 1) {
5318
+ if (matchCount === 0 && !allowAppend) return;
5319
+ await uncertain('native_summary_attachment_uncertain');
5320
+ return;
5321
+ }
5322
+ await flushNativeSummaryProofDiagnostic(
5323
+ api,
5324
+ runtime.observation,
5325
+ pendingOperationId,
5326
+ sessionRef,
5327
+ runtime.observationInstanceId,
5328
+ nativeClientVersion(runtime.options),
5329
+ proofFields,
5330
+ );
5331
+ if (!pending.nativeCompletedReported) {
5332
+ const marked: NativeSummaryPending = { ...pending, nativeCompletedReported: true };
5333
+ try {
5334
+ await saveNativeSummaryPending(api, marked);
5335
+ pending = marked;
5336
+ runtime.pendingNativeSummary = marked;
5337
+ } catch {
5338
+ runtime.pendingNativeSummary = marked;
5339
+ pending = marked;
5340
+ }
5341
+ telemetry.nativeSummaryCompleted(pending.operationId);
5342
+ }
5343
+ runtime.pendingNativeSummary = undefined;
5344
+ await clearNativeSummaryPending(api, sessionRef);
5345
+ telemetry.recoveryAttached(pending.operationId);
5346
+ return;
5347
+ }
4542
5348
  let messages: ClaudeMessage[];
4543
5349
  try {
4544
5350
  messages = await api.session.messages();
@@ -4546,10 +5352,60 @@ async function recoverNativeSummaryNote(
4546
5352
  await uncertain('messages_unavailable');
4547
5353
  return;
4548
5354
  }
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');
5355
+ proofFields = { ...proofFields, proof_actual_count: messages.length };
5356
+ const viewMatch = await nativeSummaryViewMatch(
5357
+ messages,
5358
+ pending.expectedCount,
5359
+ pending.expectedPrefixHash,
5360
+ pending.expectedMessageHashes,
5361
+ pending.anchorCounts,
5362
+ pending.anchorHashes,
5363
+ );
5364
+ const viewOffset =
5365
+ viewMatch === 'active_suffix'
5366
+ ? await nativeSummaryMatchedOffset(
5367
+ messages,
5368
+ pending.expectedCount,
5369
+ pending.expectedPrefixHash,
5370
+ pending.expectedMessageHashes,
5371
+ pending.anchorCounts,
5372
+ pending.anchorHashes,
5373
+ )
5374
+ : undefined;
5375
+ proofFields = {
5376
+ ...proofFields,
5377
+ proof_match: viewMatch,
5378
+ ...(viewMatch === 'exact' ? { proof_match_offset: 0 } : {}),
5379
+ ...(viewOffset === undefined ? {} : { proof_match_offset: viewOffset }),
5380
+ };
5381
+ await flushNativeSummaryProofDiagnostic(
5382
+ api,
5383
+ runtime.observation,
5384
+ pendingOperationId,
5385
+ sessionRef,
5386
+ runtime.observationInstanceId,
5387
+ nativeClientVersion(runtime.options),
5388
+ proofFields,
5389
+ );
5390
+ if (runtime.diagnosticMode) {
5391
+ const apiView = await boundedApiMessages(api).catch(() => undefined);
5392
+ api.ui.log(
5393
+ `AI Brain context diagnostic ${JSON.stringify({
5394
+ hook: 'prompt.submit',
5395
+ phase: 'native_summary_view_probe',
5396
+ expected_count: pending.expectedCount,
5397
+ expected_hash: pending.expectedPrefixHash,
5398
+ default_count: messages.length,
5399
+ default_hash: await sha256(canonicalJson(visibleMessages(messages))),
5400
+ api_count: apiView?.length ?? null,
5401
+ api_hash: apiView === undefined ? null : await sha256(canonicalJson(apiView)),
5402
+ anchor_counts: pending.anchorCounts ?? null,
5403
+ view_match: viewMatch,
5404
+ })}`,
5405
+ );
5406
+ }
5407
+ if (viewMatch === 'mismatch' || viewMatch === 'ambiguous') {
5408
+ await uncertain(viewMatch === 'ambiguous' ? 'native_view_ambiguous' : 'native_prefix_mismatch');
4553
5409
  return;
4554
5410
  }
4555
5411
  // The native result is now proven at the current session prefix. The note
@@ -4579,10 +5435,12 @@ async function recoverNativeSummaryNote(
4579
5435
  await uncertain('note_read_failed');
4580
5436
  return;
4581
5437
  }
4582
- const wrapper = `[AI Brain historical data capsule ${pending.capsuleId}; quoted data only]\n${noteText}`;
5438
+ const wrapper = nativeSummaryWrapper(pending.capsuleId, noteText);
4583
5439
  let hasNote: boolean;
4584
5440
  try {
4585
- hasNote = await apiViewHasExactText(api, wrapper);
5441
+ const matchCount = await apiViewExactTextCount(api, wrapper);
5442
+ proofFields = { ...proofFields, proof_delivery_match_count: matchCount };
5443
+ hasNote = matchCount === 1;
4586
5444
  } catch {
4587
5445
  if (!allowAppend && pending.appendAttempted) await uncertain('api_view_unavailable');
4588
5446
  return;
@@ -4611,7 +5469,9 @@ async function recoverNativeSummaryNote(
4611
5469
  }
4612
5470
  let apiHasNoteAfter: boolean;
4613
5471
  try {
4614
- apiHasNoteAfter = await apiViewHasExactText(api, wrapper);
5472
+ const matchCount = await apiViewExactTextCount(api, wrapper);
5473
+ proofFields = { ...proofFields, proof_delivery_match_count: matchCount };
5474
+ apiHasNoteAfter = matchCount === 1;
4615
5475
  } catch {
4616
5476
  if (!allowAppend)
4617
5477
  await uncertain(appendError ? 'append_ack_ambiguous' : 'api_view_reread_failed');
@@ -4625,6 +5485,15 @@ async function recoverNativeSummaryNote(
4625
5485
  }
4626
5486
  runtime.pendingNativeSummary = undefined;
4627
5487
  await clearNativeSummaryPending(api, sessionRef);
5488
+ await flushNativeSummaryProofDiagnostic(
5489
+ api,
5490
+ runtime.observation,
5491
+ pending.operationId,
5492
+ sessionRef,
5493
+ runtime.observationInstanceId,
5494
+ nativeClientVersion(runtime.options),
5495
+ proofFields,
5496
+ );
4628
5497
  telemetry.recoveryAttached(pending.operationId);
4629
5498
  }
4630
5499
 
@@ -4790,7 +5659,11 @@ async function runShadowPreview(
4790
5659
  );
4791
5660
  }
4792
5661
 
4793
- export const register: Register = (on, options) => {
5662
+ const registerWithClock = (
5663
+ on: Parameters<Register>[0],
5664
+ options: HookOptions,
5665
+ monotonicClock: MonotonicClock,
5666
+ ): void => {
4794
5667
  const enabled = options.enabled === true;
4795
5668
  const disclosed = options.disclosed === true;
4796
5669
  const preservationMode: PreservationMode =
@@ -4908,7 +5781,10 @@ export const register: Register = (on, options) => {
4908
5781
 
4909
5782
  on('prompt.submit', async (api, rawEvent, next) => {
4910
5783
  const event = rawEvent as unknown as PromptSubmitHookEvent;
4911
- await confirmPendingProof(api, proofRuntime, true);
5784
+ // The host has completed the prior native turn before accepting a new
5785
+ // prompt. This is the final proof opportunity for a fallback whose first
5786
+ // turn.complete callback observed the retired/default view.
5787
+ await confirmPendingProof(api, proofRuntime);
4912
5788
  if (
4913
5789
  proofRuntime.compactionStrategy === 'native_summary_notes' &&
4914
5790
  enabled &&
@@ -4983,7 +5859,10 @@ export const register: Register = (on, options) => {
4983
5859
  });
4984
5860
 
4985
5861
  on('turn.complete', async (api, event, next) => {
4986
- await confirmPendingProof(api, proofRuntime);
5862
+ // The native host can publish the result before its active API view catches
5863
+ // up. Keep only the atomic fallback marker pending through this lifecycle
5864
+ // edge; prompt.submit performs the strict confirmation on the next turn.
5865
+ await confirmPendingProof(api, proofRuntime, true);
4987
5866
  if (
4988
5867
  proofRuntime.compactionStrategy === 'native_summary_notes' &&
4989
5868
  enabled &&
@@ -5155,7 +6034,7 @@ export const register: Register = (on, options) => {
5155
6034
  nativeContinuationStarted = true;
5156
6035
  const nativeResult = await next(event);
5157
6036
  if (fallbackCapture !== undefined)
5158
- await queueNativeFallbackProof(
6037
+ return queueNativeFallbackProof(
5159
6038
  api,
5160
6039
  proofRuntime,
5161
6040
  fallbackCapture.operationId,
@@ -5163,6 +6042,8 @@ export const register: Register = (on, options) => {
5163
6042
  fallbackCapture.baselineHash,
5164
6043
  fallbackCapture.baselineCount,
5165
6044
  nativeResult,
6045
+ fallbackCapture.beforeVisibleMessages,
6046
+ fallbackCapture.activeOffset,
5166
6047
  );
5167
6048
  return nativeResult;
5168
6049
  };
@@ -5225,6 +6106,8 @@ export const register: Register = (on, options) => {
5225
6106
  | 'request_id'
5226
6107
  | 'correlation_id'
5227
6108
  | 'retry_after_ms'
6109
+ | 'provider_response_format'
6110
+ | 'reason'
5228
6111
  > = {},
5229
6112
  ): void => {
5230
6113
  if (!observation.diagnosticsEnabled || diagnosticRecords.length >= 64) return;
@@ -5285,6 +6168,8 @@ export const register: Register = (on, options) => {
5285
6168
  let proofSessionRef: string | undefined;
5286
6169
  let proofBaselineHash: string | undefined;
5287
6170
  let proofBaselineCount = 0;
6171
+ let baselineActiveOffset: number | undefined;
6172
+ let proofBaselineVisibleMessages: readonly string[] | undefined;
5288
6173
  let historicalNotes: readonly VerifiedHistoricalNote[] = [];
5289
6174
  try {
5290
6175
  const current = event.messages;
@@ -5303,14 +6188,22 @@ export const register: Register = (on, options) => {
5303
6188
  });
5304
6189
  const rereadBaselineHash = await sha256(canonicalJson(reread));
5305
6190
  proofBaselineHash = rereadBaselineHash;
5306
- proofBaselineCount = current.length;
5307
- if (!visibleSnapshotsMatch(current, reread, event.trigger, event.instructions)) {
6191
+ proofBaselineCount = reread.length;
6192
+ const rereadActiveOffset = visibleSnapshotsMatch(
6193
+ current,
6194
+ reread,
6195
+ event.trigger,
6196
+ event.instructions,
6197
+ );
6198
+ if (rereadActiveOffset === undefined) {
5308
6199
  await diagnosticMismatch(api, diagnosticMode, 'reread_before', current, reread);
5309
6200
  markDiagnostic('snapshot', 'invalid_response');
5310
6201
  markDiagnostic('fallback', 'invalid_response');
5311
6202
  logFallback(api, 'reread_before', undefined, policyVersion === 2);
5312
6203
  return continueWithTelemetryFlush();
5313
6204
  }
6205
+ baselineActiveOffset = rereadActiveOffset;
6206
+ proofBaselineVisibleMessages = visibleMessageCanonicalStrings(reread);
5314
6207
  stage = 'projection';
5315
6208
  const built = buildProjection(
5316
6209
  current,
@@ -5359,7 +6252,7 @@ export const register: Register = (on, options) => {
5359
6252
  markDiagnostic('budget', 'provider_not_attempted', {
5360
6253
  item_count: built.pairs.filter((pair) => !pair.protectedPair).length,
5361
6254
  });
5362
- const budgetRead = await readContextBudget(api, brainServer);
6255
+ const budgetRead = await readContextBudget(api, brainServer, monotonicClock);
5363
6256
  if (!budgetRead.ok) {
5364
6257
  const admission = budgetRead.kind === 'admission_denied';
5365
6258
  const category: ObservationRecord['category'] = admission
@@ -5374,8 +6267,13 @@ export const register: Register = (on, options) => {
5374
6267
  const errorMetadata: Partial<ContextErrorMetadata> = isRecord(budgetRead.error)
5375
6268
  ? (budgetRead.error as ContextErrorMetadata)
5376
6269
  : {};
6270
+ const budgetErrorCode = admission
6271
+ ? 'shared_admission_exceeded'
6272
+ : budgetRead.kind === 'invalid_response'
6273
+ ? 'budget_response_invalid'
6274
+ : 'provider_unavailable';
5377
6275
  markDiagnostic('budget', category, {
5378
- compact_error_code: admission ? 'shared_admission_exceeded' : 'provider_unavailable',
6276
+ compact_error_code: budgetErrorCode,
5379
6277
  ...(typeof errorMetadata.admissionDetail === 'string'
5380
6278
  ? { admission_detail: errorMetadata.admissionDetail }
5381
6279
  : {}),
@@ -5390,7 +6288,7 @@ export const register: Register = (on, options) => {
5390
6288
  : {}),
5391
6289
  });
5392
6290
  markDiagnostic('fallback', category, {
5393
- compact_error_code: admission ? 'shared_admission_exceeded' : 'provider_unavailable',
6291
+ compact_error_code: budgetErrorCode,
5394
6292
  });
5395
6293
  logFallback(
5396
6294
  api,
@@ -5406,7 +6304,9 @@ export const register: Register = (on, options) => {
5406
6304
  operationId,
5407
6305
  sessionRef,
5408
6306
  baselineHash: rereadBaselineHash,
5409
- baselineCount: current.length,
6307
+ baselineCount: reread.length,
6308
+ beforeVisibleMessages: visibleMessageCanonicalStrings(reread),
6309
+ activeOffset: baselineActiveOffset,
5410
6310
  });
5411
6311
  }
5412
6312
  const budget = budgetRead.snapshot;
@@ -5431,7 +6331,9 @@ export const register: Register = (on, options) => {
5431
6331
  operationId,
5432
6332
  sessionRef,
5433
6333
  baselineHash: rereadBaselineHash,
5434
- baselineCount: current.length,
6334
+ baselineCount: reread.length,
6335
+ beforeVisibleMessages: visibleMessageCanonicalStrings(reread),
6336
+ activeOffset: baselineActiveOffset,
5435
6337
  });
5436
6338
  }
5437
6339
  let providerProjection = built.projection;
@@ -5506,7 +6408,9 @@ export const register: Register = (on, options) => {
5506
6408
  operationId,
5507
6409
  sessionRef,
5508
6410
  baselineHash: rereadBaselineHash,
5509
- baselineCount: current.length,
6411
+ baselineCount: reread.length,
6412
+ beforeVisibleMessages: visibleMessageCanonicalStrings(reread),
6413
+ activeOffset: baselineActiveOffset,
5510
6414
  });
5511
6415
  }
5512
6416
  providerProjection = window.projection;
@@ -5552,18 +6456,42 @@ export const register: Register = (on, options) => {
5552
6456
  projection: providerProjection,
5553
6457
  ...(policyVersion === 2 ? { schema_version: 2, options: wireOptions } : {}),
5554
6458
  };
6459
+ await persistDiagnostics();
6460
+ if (!contextBudgetIsFresh(budget, monotonicClock())) {
6461
+ markDiagnostic('budget', 'invalid_response', {
6462
+ compact_error_code: 'budget_response_invalid',
6463
+ });
6464
+ markDiagnostic('fallback', 'invalid_response', {
6465
+ compact_error_code: 'budget_response_invalid',
6466
+ });
6467
+ logFallback(api, 'budget_unavailable', undefined, policyVersion === 2);
6468
+ getTelemetry(api, brainServer, options, telemetryState).fallback(
6469
+ operationId,
6470
+ 'budget_unavailable',
6471
+ );
6472
+ return continueWithTelemetryFlush({
6473
+ operationId,
6474
+ sessionRef,
6475
+ baselineHash: rereadBaselineHash,
6476
+ baselineCount: reread.length,
6477
+ beforeVisibleMessages: visibleMessageCanonicalStrings(reread),
6478
+ activeOffset: baselineActiveOffset,
6479
+ });
6480
+ }
5555
6481
  stage = 'mcp_call';
5556
6482
  markDiagnostic('provider_request', 'request_attempted', {
5557
6483
  item_count: providerPairs.filter((pair) => !pair.protectedPair).length,
5558
6484
  byte_count: byteLength(JSON.stringify(input)),
5559
6485
  });
5560
- await persistDiagnostics();
5561
6486
  if (diagnosticMode)
5562
6487
  api.ui.log(
5563
6488
  `AI Brain context diagnostic ${JSON.stringify({ hook: 'session.compact', phase: 'mcp_started' })}`,
5564
6489
  );
5565
6490
  const call = api.mcp.call(brainServer, 'brain_context_compact', input);
5566
6491
  pendingMcp = call;
6492
+ // The request has started, so persist its attempt without delaying the
6493
+ // budget freshness check or extending the provider deadline.
6494
+ void persistDiagnostics();
5567
6495
  let timedOut = false;
5568
6496
  const hostTimers = globalThis as unknown as {
5569
6497
  setTimeout?: (callback: () => void, delay: number) => unknown;
@@ -5610,13 +6538,41 @@ export const register: Register = (on, options) => {
5610
6538
  markDiagnostic(
5611
6539
  'provider_response',
5612
6540
  response.isError === true ? 'native_error' : 'response_obtained',
6541
+ providerResponseMetadata(response),
5613
6542
  );
5614
6543
  if (response.isError === true) throw providerResponseError(response);
5615
6544
  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);
6545
+ const structured = mcpStructuredValue(response, MAX_RESULT_BYTES);
6546
+ let plan: Record<string, unknown>;
6547
+ try {
6548
+ const serialized = JSON.stringify(structured);
6549
+ const responseText = response.content?.find(
6550
+ (block): block is { readonly type: string; readonly text: string } =>
6551
+ block.type === 'text' && typeof block.text === 'string',
6552
+ )?.text;
6553
+ if (
6554
+ (serialized === undefined &&
6555
+ response.structuredContent === undefined &&
6556
+ responseText === undefined) ||
6557
+ (response.structuredContent === undefined &&
6558
+ responseText !== undefined &&
6559
+ byteLength(responseText) > MAX_RESULT_BYTES) ||
6560
+ (serialized !== undefined && byteLength(serialized) > MAX_RESULT_BYTES)
6561
+ )
6562
+ throw new Error(
6563
+ serialized === undefined &&
6564
+ response.structuredContent === undefined &&
6565
+ responseText === undefined
6566
+ ? 'AI Brain plan envelope is missing'
6567
+ : 'AI Brain response is too large',
6568
+ );
6569
+ plan = parsePlan(structured, policyVersion, v2Options);
6570
+ } catch (error) {
6571
+ throw planBindingFailure(
6572
+ planParseFailureReason(error),
6573
+ 'AI Brain plan failed validation',
6574
+ );
6575
+ }
5620
6576
  const planDecisions = plan.decisions;
5621
6577
  const planStats = plan.stats;
5622
6578
  markDiagnostic('plan_validation', 'provider_decision', {
@@ -5627,22 +6583,30 @@ export const register: Register = (on, options) => {
5627
6583
  `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
6584
  );
5629
6585
  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');
6586
+ const bindingReason = planBindingFailureReason(plan, {
6587
+ sessionRef,
6588
+ revision,
6589
+ projectionHash: await sha256(canonicalJson(providerProjection)),
6590
+ ...(policyVersion === 2 ? { optionsHash: optionsHash ?? null } : {}),
6591
+ });
6592
+ if (policyVersion === 2 && optionsHash === undefined)
6593
+ throw planBindingFailure('plan_options_mismatch', 'AI Brain plan options are missing');
6594
+ if (bindingReason !== undefined)
6595
+ throw planBindingFailure(bindingReason, 'AI Brain plan binding was rejected');
5638
6596
  if ((await api.session.id()) !== sessionRef)
5639
- throw new Error('Claude session changed during planning');
6597
+ throw planBindingFailure(
6598
+ 'session_changed_during_planning',
6599
+ 'Claude session changed during planning',
6600
+ );
5640
6601
  stage = 'reread_after';
5641
6602
  const latestVisible = await api.session.messages();
5642
6603
  if ((await sha256(canonicalJson(latestVisible))) !== rereadBaselineHash) {
5643
6604
  await diagnosticMismatch(api, diagnosticMode, 'reread_after', current, latestVisible);
5644
6605
  logFallback(api, 'reread_after', undefined, policyVersion === 2);
5645
- throw new Error('Claude transcript changed during planning');
6606
+ throw planBindingFailure(
6607
+ 'snapshot_changed_during_planning',
6608
+ 'Claude transcript changed during planning',
6609
+ );
5646
6610
  }
5647
6611
  if (compactionStrategy === 'native_summary_notes') {
5648
6612
  const latestHistoricalNotes = await collectVerifiedHistoricalNotes(
@@ -5655,7 +6619,10 @@ export const register: Register = (on, options) => {
5655
6619
  latestHistoricalNotes.map((note) => [note.capsuleId, note.noteSha256]),
5656
6620
  ) !== canonicalJson(historicalNotes.map((note) => [note.capsuleId, note.noteSha256]))
5657
6621
  )
5658
- throw new Error('native_note_capsule_changed');
6622
+ throw planBindingFailure(
6623
+ 'historical_notes_changed_during_planning',
6624
+ 'Claude historical notes changed during planning',
6625
+ );
5659
6626
  }
5660
6627
  if (plan.status === 'noop') {
5661
6628
  markDiagnostic('candidate_preparation', 'skipped');
@@ -5729,6 +6696,7 @@ export const register: Register = (on, options) => {
5729
6696
  transformed,
5730
6697
  );
5731
6698
  getTelemetry(api, brainServer, options, telemetryState).notesWritten(operationId);
6699
+ const beforeVisibleMessages = visibleMessageCanonicalStrings(reread);
5732
6700
  nativeContinuationStarted = true;
5733
6701
  const nativeInput = appendHistoricalNotes(transformed, historicalNotes);
5734
6702
  const nativeResult = await next({ ...event, messages: nativeInput });
@@ -5738,6 +6706,29 @@ export const register: Register = (on, options) => {
5738
6706
  throw new Error('native_summary_result_missing');
5739
6707
  const configuration = fileCheckpointConfiguration(options);
5740
6708
  if (configuration.mode !== 'helper') throw new Error('native_note_helper_unavailable');
6709
+ const expectedMessageHashes =
6710
+ expectedMessages.length <= MAX_NATIVE_SUMMARY_MESSAGE_HASHES
6711
+ ? await Promise.all(
6712
+ expectedMessages.map(async (message) =>
6713
+ (await sha256(canonicalJson(visibleMessage(message)))).slice(0, 16),
6714
+ ),
6715
+ )
6716
+ : undefined;
6717
+ const wrapper = nativeSummaryWrapper(note.capsuleId, note.noteText);
6718
+ const deliveredResult = nativeSummaryDeliveryResult(nativeResult, wrapper);
6719
+ if (deliveredResult === undefined) {
6720
+ getTelemetry(api, brainServer, options, telemetryState).uncertain(
6721
+ operationId,
6722
+ 'native_summary_recovery',
6723
+ );
6724
+ return nativeResult;
6725
+ }
6726
+ const deliveryMode: NativeSummaryPending['deliveryMode'] = 'atomic_result';
6727
+ const anchors = await nativeProofAnchors(
6728
+ beforeVisibleMessages,
6729
+ expectedMessages.length,
6730
+ baselineActiveOffset,
6731
+ );
5741
6732
  const pendingNativeSummary: NativeSummaryPending = {
5742
6733
  schemaVersion: 1,
5743
6734
  operationId,
@@ -5747,13 +6738,19 @@ export const register: Register = (on, options) => {
5747
6738
  noteSha256: note.noteSha256,
5748
6739
  expectedPrefixHash: await sha256(canonicalJson(visibleMessages(expectedMessages))),
5749
6740
  expectedCount: expectedMessages.length,
6741
+ ...(expectedMessageHashes === undefined ? {} : { expectedMessageHashes }),
6742
+ deliveryMode,
6743
+ anchorCounts: anchors.counts,
6744
+ anchorHashes: anchors.hashes,
5750
6745
  expiresAt: new Date(Date.now() + PENDING_PROOF_TTL_MS).toISOString(),
5751
6746
  appendAttempted: false,
5752
6747
  nativeCompletedReported: false,
5753
6748
  };
6749
+ let deliveryIntentSaved = false;
5754
6750
  try {
5755
6751
  await saveNativeSummaryPending(api, pendingNativeSummary);
5756
6752
  proofRuntime.pendingNativeSummary = pendingNativeSummary;
6753
+ deliveryIntentSaved = true;
5757
6754
  } catch {
5758
6755
  if (nativeSessionRef !== undefined)
5759
6756
  await flushProofDiagnostic(
@@ -5763,7 +6760,7 @@ export const register: Register = (on, options) => {
5763
6760
  nativeSessionRef,
5764
6761
  observationInstanceId,
5765
6762
  nativeClientVersion(options),
5766
- 'proof_mismatch',
6763
+ 'native_summary_attachment_uncertain',
5767
6764
  );
5768
6765
  getTelemetry(api, brainServer, options, telemetryState).uncertain(
5769
6766
  operationId,
@@ -5772,7 +6769,7 @@ export const register: Register = (on, options) => {
5772
6769
  }
5773
6770
  if (options.telemetryEnabled === true && options.telemetryDisclosed === true)
5774
6771
  await getTelemetry(api, brainServer, options, telemetryState).flush();
5775
- return nativeResult;
6772
+ return deliveryIntentSaved ? (deliveredResult ?? nativeResult) : nativeResult;
5776
6773
  }
5777
6774
  return { messages: transformed };
5778
6775
  } catch (error) {
@@ -5791,7 +6788,7 @@ export const register: Register = (on, options) => {
5791
6788
  nativeSessionRef,
5792
6789
  observationInstanceId,
5793
6790
  nativeClientVersion(options),
5794
- 'proof_mismatch',
6791
+ 'native_summary_attachment_uncertain',
5795
6792
  );
5796
6793
  }
5797
6794
  throw error;
@@ -5817,6 +6814,14 @@ export const register: Register = (on, options) => {
5817
6814
  const rpcStatus = safeRpcStatus(error);
5818
6815
  const checkpoint = failureStage === 'checkpoint' ? checkpointFailure(error) : undefined;
5819
6816
  const errorMetadata = isRecord(error) ? error : {};
6817
+ const planReason =
6818
+ failureStage === 'parse_plan' ||
6819
+ failureStage === 'match_plan' ||
6820
+ failureStage === 'reread_after'
6821
+ ? isPlanBindingFailureReason(errorMetadata.planBindingFailureReason)
6822
+ ? errorMetadata.planBindingFailureReason
6823
+ : undefined
6824
+ : undefined;
5820
6825
  const admissionFallback = isAdmissionFallback(error);
5821
6826
  const compactCode =
5822
6827
  isRecord(error) &&
@@ -5855,6 +6860,7 @@ export const register: Register = (on, options) => {
5855
6860
  ...(typeof errorMetadata.retryAfterMs === 'number'
5856
6861
  ? { retry_after_ms: errorMetadata.retryAfterMs }
5857
6862
  : {}),
6863
+ ...(planReason === undefined ? {} : { reason: planReason }),
5858
6864
  ...(checkpoint === undefined
5859
6865
  ? {}
5860
6866
  : { checkpoint_phase: checkpoint.phase, checkpoint_error: checkpoint.code }),
@@ -5867,6 +6873,7 @@ export const register: Register = (on, options) => {
5867
6873
  markDiagnostic('fallback', failureCategory, {
5868
6874
  ...(rpcStatus === undefined ? {} : { rpc_status: rpcStatus }),
5869
6875
  ...(compactCode === undefined ? {} : { compact_error_code: compactCode }),
6876
+ ...(planReason === undefined ? {} : { reason: planReason }),
5870
6877
  });
5871
6878
  const telemetry = getTelemetry(api, brainServer, options, telemetryState);
5872
6879
  telemetry.fallback(operationId, admissionFallback ? 'admission_denied' : stage);
@@ -5878,6 +6885,12 @@ export const register: Register = (on, options) => {
5878
6885
  sessionRef: proofSessionRef,
5879
6886
  baselineHash: proofBaselineHash,
5880
6887
  baselineCount: proofBaselineCount,
6888
+ ...(proofBaselineVisibleMessages === undefined
6889
+ ? {}
6890
+ : { beforeVisibleMessages: proofBaselineVisibleMessages }),
6891
+ ...(baselineActiveOffset === undefined
6892
+ ? {}
6893
+ : { activeOffset: baselineActiveOffset }),
5881
6894
  }
5882
6895
  : undefined,
5883
6896
  );
@@ -5905,3 +6918,13 @@ export const register: Register = (on, options) => {
5905
6918
  });
5906
6919
  });
5907
6920
  };
6921
+
6922
+ export const register: Register = (on, options) => registerWithClock(on, options, monotonicNow);
6923
+
6924
+ export function registerForTesting(
6925
+ on: Parameters<Register>[0],
6926
+ options: HookOptions,
6927
+ monotonicClock: MonotonicClock,
6928
+ ): void {
6929
+ registerWithClock(on, options, monotonicClock);
6930
+ }