praxis-agent 0.65.0 → 0.67.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/README.md +12 -7
  2. package/dist/application/compaction-accounting.d.ts +49 -0
  3. package/dist/application/compaction-accounting.js +349 -0
  4. package/dist/application/compaction-errors.d.ts +21 -0
  5. package/dist/application/compaction-errors.js +24 -0
  6. package/dist/application/context-engine.js +10 -1
  7. package/dist/application/native-session-transcript.d.ts +3 -0
  8. package/dist/application/native-session-transcript.js +36 -4
  9. package/dist/application/session-service.d.ts +2 -0
  10. package/dist/application/session-service.js +526 -197
  11. package/dist/application/turn-accounting.js +0 -4
  12. package/dist/build-identity.json +1 -0
  13. package/dist/cli-runtime.js +2 -0
  14. package/dist/evals/project-eval-comparison.d.ts +4 -1
  15. package/dist/evals/project-eval-comparison.js +234 -78
  16. package/dist/evals/project-eval-identity.d.ts +4 -1
  17. package/dist/evals/project-eval-identity.js +9 -2
  18. package/dist/evals/project-eval-runner.d.ts +24 -2
  19. package/dist/evals/project-eval-runner.js +118 -59
  20. package/dist/evals/project-eval-schema.d.ts +8 -2
  21. package/dist/evals/project-eval-schema.js +27 -11
  22. package/dist/evals/project-eval.d.ts +22 -2
  23. package/dist/evals/project-eval.js +34 -1
  24. package/dist/native/ownership.d.ts +6 -1
  25. package/dist/native/ownership.js +6 -0
  26. package/dist/persistence/native-compaction-receipt-store.d.ts +40 -0
  27. package/dist/persistence/native-compaction-receipt-store.js +380 -0
  28. package/dist/platform/praxis-build-identity.d.ts +14 -0
  29. package/dist/platform/praxis-build-identity.js +204 -0
  30. package/package.json +2 -2
@@ -22,7 +22,7 @@ import { usageCostUsd } from '../core/usage.js';
22
22
  import { isSessionId } from '../core/session.js';
23
23
  import { ContextBudget, estimateModelRequestTokens, isPromptTooLongError, } from '../core/context-budget.js';
24
24
  import { ContextEngine } from './context-engine.js';
25
- import { ContextPreparation } from './context-preparation.js';
25
+ import { ContextPreparation, StaleContextGenerationError, } from './context-preparation.js';
26
26
  import { TurnMemoryCoordinator } from './turn-memory-coordinator.js';
27
27
  import { injectFirstUserMessageContext, projectContextSnapshot, } from '../core/context.js';
28
28
  import { assembleContextSnapshot } from '../core/prompt-composer.js';
@@ -35,6 +35,9 @@ import { NativeTranscriptStore, } from '../persistence/native-transcript-store.j
35
35
  import { NativeSessionTranscript, } from './native-session-transcript.js';
36
36
  import { TurnPersistence } from './turn-persistence.js';
37
37
  import { TurnAccounting } from './turn-accounting.js';
38
+ import { CompactionAccounting } from './compaction-accounting.js';
39
+ import { classifyCompactionError } from './compaction-errors.js';
40
+ import { NativeCompactionReceiptFileStore } from '../persistence/native-compaction-receipt-store.js';
38
41
  import { SubagentLifecycleStore } from '../persistence/subagent-lifecycle-store.js';
39
42
  import { ModelCompactor } from './model-compactor.js';
40
43
  import { agentMemoryPrompt, ClaudeSubagentExecutor, StructuredOutputRegistry, } from './subagent-service.js';
@@ -670,6 +673,7 @@ export class ClaudeSessionService {
670
673
  activeProvider;
671
674
  mcpClosePromise;
672
675
  sessionCostTrackers = new Map();
676
+ compactionAccountings = new Map();
673
677
  activeCostSessionId;
674
678
  closeCostSavePromise;
675
679
  closeMetadataSavePromise;
@@ -918,6 +922,7 @@ export class ClaudeSessionService {
918
922
  await this.closeMetadataSavePromise;
919
923
  this.closeCostSavePromise ??= this.persistActiveSessionCost();
920
924
  await this.closeCostSavePromise;
925
+ this.compactionAccountings.clear();
921
926
  this.mcpClosePromise ??= this.options.mcp?.close?.() ?? Promise.resolve();
922
927
  await this.mcpClosePromise;
923
928
  await this.leadOperations?.close();
@@ -1996,12 +2001,21 @@ export class ClaudeSessionService {
1996
2001
  this.assertSessionPersistence();
1997
2002
  const provider = this.provider();
1998
2003
  const sessionPaths = this.paths(sessionId);
2004
+ const nativeStore = new NativeTranscriptStore({
2005
+ transcriptFile: sessionPaths.sessionFile,
2006
+ lockFile: join(sessionPaths.praxisRoot, 'locks', `${sessionId}.lock`),
2007
+ });
2008
+ const existingTranscript = await nativeStore.load();
2009
+ if (existingTranscript.records.length === 0)
2010
+ throw new Error('native transcript session is missing or empty');
2011
+ if (existingTranscript.records.some((record) => record.event.sessionId !== sessionId))
2012
+ throw new Error('native transcript sessionId does not match');
2013
+ await this.activateSessionCostTracker(sessionId);
2014
+ const compactionAccounting = this.compactionAccounting(sessionId, nativeStore);
2015
+ await compactionAccounting.recover();
1999
2016
  const nativeTranscript = new NativeSessionTranscript({
2000
2017
  sessionId,
2001
- store: new NativeTranscriptStore({
2002
- transcriptFile: sessionPaths.sessionFile,
2003
- lockFile: join(sessionPaths.praxisRoot, 'locks', `${sessionId}.lock`),
2004
- }),
2018
+ store: nativeStore,
2005
2019
  });
2006
2020
  return nativeTranscript.withLease({
2007
2021
  kind: 'resume',
@@ -2014,7 +2028,12 @@ export class ClaudeSessionService {
2014
2028
  if (selection) {
2015
2029
  const targetIndex = branchEvents.findIndex((event) => event.id === selection.messageId);
2016
2030
  if (targetIndex < 0)
2017
- throw new Error(`No native rewind point found with event.id: ${selection.messageId}`);
2031
+ throw classifyCompactionError(new Error(`No native rewind point found with event.id: ${selection.messageId}`), {
2032
+ trigger: 'manual',
2033
+ phase: 'validation',
2034
+ durableState: 'not_committed',
2035
+ recoveryDisposition: 'none',
2036
+ });
2018
2037
  const selectedEvents = selection.direction === 'from'
2019
2038
  ? branchEvents.slice(targetIndex)
2020
2039
  : branchEvents.slice(0, targetIndex);
@@ -2029,7 +2048,17 @@ export class ClaudeSessionService {
2029
2048
  : branchEvents[targetIndex - 1])?.id;
2030
2049
  }
2031
2050
  else {
2032
- memorySelection = await this.selectMemoryPreservedCompact(sessionId, projectNativeSessionEntries(branchEvents));
2051
+ try {
2052
+ memorySelection = await this.selectMemoryPreservedCompact(sessionId, projectNativeSessionEntries(branchEvents));
2053
+ }
2054
+ catch (error) {
2055
+ throw classifyCompactionError(error, {
2056
+ trigger: 'manual',
2057
+ phase: 'validation',
2058
+ durableState: 'not_committed',
2059
+ recoveryDisposition: 'none',
2060
+ });
2061
+ }
2033
2062
  if (memorySelection) {
2034
2063
  messages = [
2035
2064
  memorySelection.memoryMessage,
@@ -2040,118 +2069,188 @@ export class ClaudeSessionService {
2040
2069
  }
2041
2070
  }
2042
2071
  if (messages.length === 0)
2043
- throw new Error('Cannot compact an empty native transcript');
2044
- // Do not create cost-state sidecars until the transcript has been
2045
- // validated and a compactable message set exists.
2046
- await this.activateSessionCostTracker(sessionId);
2072
+ throw classifyCompactionError(new Error('Cannot compact an empty native transcript'), {
2073
+ trigger: 'manual',
2074
+ phase: 'validation',
2075
+ durableState: 'not_committed',
2076
+ recoveryDisposition: 'none',
2077
+ });
2047
2078
  const preTokens = estimateModelRequestTokens(messages);
2048
2079
  this.options.eventSink?.({ type: 'state', state: 'compacting' });
2049
2080
  const contextWindowTokens = this.contextBudget(provider)?.contextWindowTokens ??
2050
2081
  provider.capabilities.contextWindowTokens ??
2051
2082
  200_000;
2052
- const preCompact = await this.runAdvisoryHook(sessionId, 'PreCompact', {
2053
- trigger: 'manual',
2054
- custom_instructions: selection?.context ?? null,
2055
- }, 'manual', signal);
2056
- const compacted = await (this.options.compactor ?? new ModelCompactor(provider)).compact({
2057
- messages: [
2058
- ...messages,
2059
- ...(selection?.context
2060
- ? [
2061
- {
2062
- role: 'user',
2063
- content: `Additional summarization context: ${selection.context}`,
2064
- },
2065
- ]
2066
- : []),
2067
- ...successfulHookOutput(preCompact).map((content) => ({
2068
- role: 'user',
2069
- content: `Additional summarization context: ${content}`,
2070
- })),
2071
- ],
2072
- targetTokens: Math.min(8192, Math.max(1, Math.floor(contextWindowTokens / 4))),
2073
- contextWindowTokens,
2074
- ...(signal ? { signal } : {}),
2075
- });
2083
+ let preCompact;
2084
+ try {
2085
+ preCompact = await this.runAdvisoryHook(sessionId, 'PreCompact', {
2086
+ trigger: 'manual',
2087
+ custom_instructions: selection?.context ?? null,
2088
+ }, 'manual', signal);
2089
+ }
2090
+ catch (error) {
2091
+ throw classifyCompactionError(error, {
2092
+ trigger: 'manual',
2093
+ phase: 'generation',
2094
+ durableState: 'not_committed',
2095
+ recoveryDisposition: 'none',
2096
+ });
2097
+ }
2098
+ let compacted;
2099
+ try {
2100
+ compacted = await (this.options.compactor ?? new ModelCompactor(provider)).compact({
2101
+ messages: [
2102
+ ...messages,
2103
+ ...(selection?.context
2104
+ ? [
2105
+ {
2106
+ role: 'user',
2107
+ content: `Additional summarization context: ${selection.context}`,
2108
+ },
2109
+ ]
2110
+ : []),
2111
+ ...successfulHookOutput(preCompact).map((content) => ({
2112
+ role: 'user',
2113
+ content: `Additional summarization context: ${content}`,
2114
+ })),
2115
+ ],
2116
+ targetTokens: Math.min(8192, Math.max(1, Math.floor(contextWindowTokens / 4))),
2117
+ contextWindowTokens,
2118
+ ...(signal ? { signal } : {}),
2119
+ });
2120
+ }
2121
+ catch (error) {
2122
+ throw classifyCompactionError(error, {
2123
+ trigger: 'manual',
2124
+ phase: 'generation',
2125
+ durableState: 'not_committed',
2126
+ recoveryDisposition: 'none',
2127
+ });
2128
+ }
2076
2129
  if (signal?.aborted)
2077
2130
  throw new AgentRunCancelledError();
2078
- const { durationMs, durationWithoutRetriesMs } = requireCompactionDurations(compacted);
2079
- requireManualCompactUsage(compacted.usage);
2131
+ let durationMs;
2132
+ let durationWithoutRetriesMs;
2133
+ try {
2134
+ const durations = requireCompactionDurations(compacted);
2135
+ durationMs = durations.durationMs;
2136
+ durationWithoutRetriesMs = durations.durationWithoutRetriesMs;
2137
+ requireManualCompactUsage(compacted.usage);
2138
+ }
2139
+ catch (error) {
2140
+ throw classifyCompactionError(error, {
2141
+ trigger: 'manual',
2142
+ phase: 'validation',
2143
+ durableState: 'not_committed',
2144
+ recoveryDisposition: 'none',
2145
+ });
2146
+ }
2080
2147
  const compactModel = compacted.model !== undefined && compacted.model.trim() !== ''
2081
2148
  ? compacted.model
2082
2149
  : provider.model;
2083
2150
  const meaningfulMetering = compacted.summary.trim().length > 0 &&
2084
- (hasNonZeroUsage(compacted.usage) || durationMs > 0);
2151
+ hasNonZeroUsage(compacted.usage);
2152
+ if (compacted.summary.trim().length === 0)
2153
+ throw classifyCompactionError(new Error('Native compact summary must not be blank'), {
2154
+ trigger: 'manual',
2155
+ phase: 'validation',
2156
+ durableState: 'not_committed',
2157
+ recoveryDisposition: 'none',
2158
+ });
2085
2159
  if (meaningfulMetering &&
2086
2160
  (compactModel === undefined || compactModel.trim() === ''))
2087
- throw new Error('Manual compact usage requires a nonblank model identity');
2088
- const tracker = this.sessionCostTrackers.get(sessionId);
2089
- if (!tracker)
2090
- throw new Error(`Session cost tracker is not active for session ${sessionId}`);
2091
- let meteringTurnInput;
2092
- if (meaningfulMetering &&
2093
- compactModel !== undefined &&
2094
- compactModel.trim() !== '') {
2095
- const pricing = this.options.pricing?.resolve(compactModel);
2096
- const costUsd = pricing
2097
- ? usageCostUsd(compacted.usage, pricing)
2098
- : undefined;
2099
- meteringTurnInput = {
2100
- model: compactModel,
2101
- usage: compacted.usage,
2102
- ...(costUsd === undefined ? {} : { costUsd }),
2103
- ...(compacted.usage.webSearchRequests === undefined
2104
- ? {}
2105
- : { webSearchRequests: compacted.usage.webSearchRequests }),
2106
- apiDurationMs: durationMs,
2107
- apiDurationWithoutRetriesMs: durationWithoutRetriesMs,
2108
- };
2109
- const preflight = new ClaudeSessionCostTracker({
2110
- sessionId,
2111
- restored: tracker.snapshot(),
2161
+ throw classifyCompactionError(new Error('Manual compact usage requires a nonblank model identity'), {
2162
+ trigger: 'manual',
2163
+ phase: 'validation',
2164
+ durableState: 'not_committed',
2165
+ recoveryDisposition: 'none',
2112
2166
  });
2113
- preflight.recordTurn(meteringTurnInput);
2114
- }
2167
+ const preparedTransaction = await compactionAccounting.prepare({
2168
+ trigger: 'manual',
2169
+ metric: {
2170
+ usage: compacted.usage,
2171
+ ...(hasNonZeroUsage(compacted.usage) && compactModel !== undefined
2172
+ ? { model: compactModel }
2173
+ : {}),
2174
+ durationApiMs: durationMs,
2175
+ durationApiWithoutRetriesMs: durationWithoutRetriesMs,
2176
+ },
2177
+ });
2115
2178
  const postTokens = estimateModelRequestTokens([
2116
2179
  { role: 'user', content: compacted.summary },
2117
2180
  ]);
2118
2181
  if (signal?.aborted)
2119
2182
  throw new AgentRunCancelledError();
2120
- const ids = await lease.appendCompaction({
2121
- summary: compacted.summary,
2122
- trigger: 'manual',
2123
- preTokens,
2124
- postTokens,
2125
- durationMs,
2126
- ...(preservedMessages.length > 0 ? { preservedMessages } : {}),
2127
- ...(logicalParentId === undefined ? {} : { logicalParentId }),
2128
- ...(selection
2129
- ? {
2130
- direction: selection.direction === 'to'
2131
- ? 'up_to'
2132
- : 'from',
2133
- messagesSummarized: messages.length,
2134
- preservePrefix: selection.direction === 'from' &&
2135
- branchEvents.findIndex((event) => event.id === selection.messageId) > 0,
2136
- }
2137
- : {}),
2138
- ...(memorySelection
2139
- ? {
2140
- direction: 'from',
2141
- messagesSummarized: messages.length,
2142
- preservePrefix: false,
2143
- }
2144
- : {}),
2145
- });
2146
- if (meteringTurnInput !== undefined)
2147
- tracker.recordTurn(meteringTurnInput);
2148
- await this.runAdvisoryHook(sessionId, 'PostCompact', { trigger: 'manual', compact_summary: compacted.summary }, 'manual', signal);
2149
- this.options.eventSink?.({
2150
- type: 'compact-boundary',
2151
- trigger: 'manual',
2152
- preTokens,
2153
- uuid: ids.boundaryId,
2183
+ let ids;
2184
+ try {
2185
+ ids = await lease.appendCompaction({
2186
+ summary: compacted.summary,
2187
+ trigger: 'manual',
2188
+ preTokens,
2189
+ postTokens,
2190
+ durationMs,
2191
+ boundaryId: preparedTransaction.boundaryId,
2192
+ summaryId: preparedTransaction.summaryId,
2193
+ ...(preservedMessages.length > 0 ? { preservedMessages } : {}),
2194
+ ...(logicalParentId === undefined ? {} : { logicalParentId }),
2195
+ ...(selection
2196
+ ? {
2197
+ direction: selection.direction === 'to'
2198
+ ? 'up_to'
2199
+ : 'from',
2200
+ messagesSummarized: messages.length,
2201
+ preservePrefix: selection.direction === 'from' &&
2202
+ branchEvents.findIndex((event) => event.id === selection.messageId) > 0,
2203
+ }
2204
+ : {}),
2205
+ ...(memorySelection
2206
+ ? {
2207
+ direction: 'from',
2208
+ messagesSummarized: messages.length,
2209
+ preservePrefix: false,
2210
+ }
2211
+ : {}),
2212
+ });
2213
+ }
2214
+ catch (error) {
2215
+ throw classifyCompactionError(error, {
2216
+ trigger: 'manual',
2217
+ phase: 'transcript_commit',
2218
+ durableState: 'indeterminate',
2219
+ recoveryDisposition: 'reconcile',
2220
+ });
2221
+ }
2222
+ await preparedTransaction.commit({
2223
+ kind: 'compaction',
2224
+ boundaryId: ids.boundaryId,
2225
+ summaryId: ids.summaryId,
2154
2226
  });
2227
+ try {
2228
+ await this.runAdvisoryHook(sessionId, 'PostCompact', { trigger: 'manual', compact_summary: compacted.summary }, 'manual', signal);
2229
+ }
2230
+ catch (error) {
2231
+ throw classifyCompactionError(error, {
2232
+ trigger: 'manual',
2233
+ phase: 'post_commit',
2234
+ durableState: 'committed',
2235
+ recoveryDisposition: 'reconcile',
2236
+ });
2237
+ }
2238
+ try {
2239
+ this.options.eventSink?.({
2240
+ type: 'compact-boundary',
2241
+ trigger: 'manual',
2242
+ preTokens,
2243
+ uuid: ids.boundaryId,
2244
+ });
2245
+ }
2246
+ catch (error) {
2247
+ throw classifyCompactionError(error, {
2248
+ trigger: 'manual',
2249
+ phase: 'post_commit',
2250
+ durableState: 'committed',
2251
+ recoveryDisposition: 'reconcile',
2252
+ });
2253
+ }
2155
2254
  return {
2156
2255
  summary: compacted.summary,
2157
2256
  usage: compacted.usage,
@@ -2340,7 +2439,7 @@ export class ClaudeSessionService {
2340
2439
  await this.appendNativeCommand(sessionId, `/permission-mode ${permissionMode}`);
2341
2440
  await this.options.interactiveTools?.setMode(sessionId, permissionMode);
2342
2441
  }
2343
- async activateSessionCostTracker(sessionId) {
2442
+ async activateSessionCostTracker(sessionId, persistPrevious = true) {
2344
2443
  if (this.activeCostSessionId === sessionId)
2345
2444
  return;
2346
2445
  const store = this.options.costStateStore;
@@ -2349,7 +2448,7 @@ export class ClaudeSessionService {
2349
2448
  // Native state slot is not overwritten before it is read.
2350
2449
  const loaded = await store.load(sessionId);
2351
2450
  const currentId = this.activeCostSessionId;
2352
- if (currentId !== undefined) {
2451
+ if (persistPrevious && currentId !== undefined) {
2353
2452
  const current = this.sessionCostTrackers.get(currentId);
2354
2453
  if (current)
2355
2454
  await store.save(current.snapshot());
@@ -2405,17 +2504,17 @@ export class ClaudeSessionService {
2405
2504
  });
2406
2505
  }
2407
2506
  async costSnapshot(sessionId) {
2408
- const existing = this.sessionCostTrackers.get(sessionId);
2409
- if (existing) {
2410
- return this.withDetachedSubagentUsage(sessionId, existing.snapshot());
2411
- }
2412
- const store = this.options.costStateStore;
2413
- const loaded = store ? await store.load(sessionId) : null;
2414
- const tracker = new ClaudeSessionCostTracker({
2415
- sessionId,
2416
- ...(loaded ? { restored: loaded } : {}),
2417
- });
2418
- this.sessionCostTrackers.set(sessionId, tracker);
2507
+ const priorActiveCostSessionId = this.activeCostSessionId;
2508
+ await this.activateSessionCostTracker(sessionId, false);
2509
+ // Snapshots are observational and must not change which session close()
2510
+ // persists. A subsequent turn/compact activation will perform the normal
2511
+ // target-load-before-current-save transition.
2512
+ this.activeCostSessionId = priorActiveCostSessionId;
2513
+ const tracker = this.sessionCostTrackers.get(sessionId);
2514
+ if (!tracker)
2515
+ throw new Error(`Session cost tracker is not active for session ${sessionId}`);
2516
+ const accounting = this.compactionAccounting(sessionId, this.nativeStore(sessionId));
2517
+ await accounting.recover();
2419
2518
  return this.withDetachedSubagentUsage(sessionId, tracker.snapshot());
2420
2519
  }
2421
2520
  async withDetachedSubagentUsage(sessionId, base) {
@@ -2524,7 +2623,7 @@ export class ClaudeSessionService {
2524
2623
  }
2525
2624
  const sessionPaths = this.paths(sessionId);
2526
2625
  const toolResultDirectory = join(sessionPaths.projectRoot, sessionId, 'tool-results');
2527
- const runUnderLease = async (persistence) => {
2626
+ const runUnderLease = async (persistence, compactionAccounting) => {
2528
2627
  const activeTracker = this.sessionCostTrackers.get(sessionId);
2529
2628
  if (!activeTracker) {
2530
2629
  throw new Error(`Session cost tracker is not active for session ${sessionId}`);
@@ -3380,8 +3479,32 @@ export class ClaudeSessionService {
3380
3479
  ...(budget ? { budget } : {}),
3381
3480
  autoCompact: this.options.autoCompact !== false,
3382
3481
  memory: {
3383
- beforeCompact: () => turnMemory.beforeCompact(),
3384
- afterCompact: () => turnMemory.afterCompact(),
3482
+ beforeCompact: async () => {
3483
+ try {
3484
+ await turnMemory.beforeCompact();
3485
+ }
3486
+ catch (error) {
3487
+ throw classifyCompactionError(error, {
3488
+ trigger: 'auto',
3489
+ phase: 'validation',
3490
+ durableState: 'not_committed',
3491
+ recoveryDisposition: 'none',
3492
+ });
3493
+ }
3494
+ },
3495
+ afterCompact: async () => {
3496
+ try {
3497
+ await turnMemory.afterCompact();
3498
+ }
3499
+ catch (error) {
3500
+ throw classifyCompactionError(error, {
3501
+ trigger: 'auto',
3502
+ phase: 'post_commit',
3503
+ durableState: 'committed',
3504
+ recoveryDisposition: 'reconcile',
3505
+ });
3506
+ }
3507
+ },
3385
3508
  },
3386
3509
  });
3387
3510
  observeModelRequestUsage = ({ usage, messages, tools }) => {
@@ -3431,14 +3554,29 @@ export class ClaudeSessionService {
3431
3554
  }).envelope,
3432
3555
  propose: async () => {
3433
3556
  if (!budget)
3434
- throw new Error('Context budget is unavailable');
3435
- const definitions = contextPreparation.project().envelope.tools;
3436
- const historyMessages = activeTurnMessages();
3557
+ throw classifyCompactionError(new Error('Context budget is unavailable'), {
3558
+ trigger: 'auto',
3559
+ phase: 'validation',
3560
+ durableState: 'not_committed',
3561
+ recoveryDisposition: 'none',
3562
+ });
3563
+ const definitions = await autoValidation(() => contextPreparation.project().envelope.tools);
3564
+ const historyMessages = await autoValidation(activeTurnMessages);
3437
3565
  if (historyMessages.length === 0)
3438
- throw new Error('Cannot compact an empty native transcript');
3566
+ throw classifyCompactionError(new Error('Cannot compact an empty native transcript'), {
3567
+ trigger: 'auto',
3568
+ phase: 'validation',
3569
+ durableState: 'not_committed',
3570
+ recoveryDisposition: 'none',
3571
+ });
3439
3572
  if (unresolvedActiveToolCallIds(historyMessages).length > 0)
3440
- throw new Error('Cannot compact a native transcript with unresolved tool calls');
3441
- const irreducibleMessages = contextPreparation.project({
3573
+ throw classifyCompactionError(new Error('Cannot compact a native transcript with unresolved tool calls'), {
3574
+ trigger: 'auto',
3575
+ phase: 'validation',
3576
+ durableState: 'not_committed',
3577
+ recoveryDisposition: 'none',
3578
+ });
3579
+ const irreducibleMessages = await autoValidation(() => contextPreparation.project({
3442
3580
  includeHistory: false,
3443
3581
  includeMemory: false,
3444
3582
  pendingMessages: [
@@ -3448,7 +3586,7 @@ export class ClaudeSessionService {
3448
3586
  content,
3449
3587
  })),
3450
3588
  ],
3451
- }).envelope.messages;
3589
+ }).envelope.messages);
3452
3590
  let compactableMessages = historyMessages;
3453
3591
  let preservedMessages = [];
3454
3592
  let compactionLogicalParentId;
@@ -3467,7 +3605,12 @@ export class ClaudeSessionService {
3467
3605
  }
3468
3606
  }
3469
3607
  if (found < 0)
3470
- throw new Error('Native automatic compaction could not match the current-turn suffix');
3608
+ throw classifyCompactionError(new Error('Native automatic compaction could not match the current-turn suffix'), {
3609
+ trigger: 'auto',
3610
+ phase: 'validation',
3611
+ durableState: 'not_committed',
3612
+ recoveryDisposition: 'none',
3613
+ });
3471
3614
  matchedIndexes.push(found);
3472
3615
  searchFrom = found;
3473
3616
  }
@@ -3493,7 +3636,7 @@ export class ClaudeSessionService {
3493
3636
  }
3494
3637
  }
3495
3638
  const memorySelection = sessionMemory
3496
- ? await this.selectMemoryPreservedCompact(sessionId, persistence.view().projectionEntries)
3639
+ ? await autoValidation(() => this.selectMemoryPreservedCompact(sessionId, persistence.view().projectionEntries))
3497
3640
  : null;
3498
3641
  if (memorySelection) {
3499
3642
  compactableMessages = [
@@ -3521,47 +3664,102 @@ export class ClaudeSessionService {
3521
3664
  message.content === anchor.content))?.id;
3522
3665
  }
3523
3666
  }
3524
- this.options.eventSink?.({ type: 'state', state: 'compacting' });
3525
- const preTokens = estimateModelRequestTokens(compactableMessages);
3526
- const compactEnvelope = budget.evaluate([
3527
- ...irreducibleMessages,
3528
- {
3529
- role: 'user',
3530
- content: formatClaudeCompactSummary(''),
3531
- },
3532
- ], definitions);
3533
- budget.assertFits(compactEnvelope);
3534
- const compacted = await (this.options.compactor ?? new ModelCompactor(provider)).compact({
3535
- messages: [
3536
- ...compactableMessages,
3537
- ...successfulHookOutput(await this.runAdvisoryHook(sessionId, 'PreCompact', {
3538
- trigger: 'auto',
3539
- custom_instructions: null,
3540
- }, 'auto', signal)).map((content) => ({
3667
+ await autoValidation(() => {
3668
+ this.options.eventSink?.({
3669
+ type: 'state',
3670
+ state: 'compacting',
3671
+ });
3672
+ });
3673
+ const preTokens = await autoValidation(() => estimateModelRequestTokens(compactableMessages));
3674
+ const compactEnvelope = await autoValidation(() => {
3675
+ const envelope = budget.evaluate([
3676
+ ...irreducibleMessages,
3677
+ {
3541
3678
  role: 'user',
3542
- content: `Additional summarization context: ${content}`,
3543
- })),
3544
- ],
3545
- targetTokens: Math.min(8192, Math.max(1, compactEnvelope.availableTokens -
3546
- compactEnvelope.estimatedTokens)),
3547
- contextWindowTokens: budget.contextWindowTokens,
3548
- ...(signal ? { signal } : {}),
3679
+ content: formatClaudeCompactSummary(''),
3680
+ },
3681
+ ], definitions);
3682
+ budget.assertFits(envelope);
3683
+ return envelope;
3549
3684
  });
3550
- const { durationMs, durationWithoutRetriesMs } = requireCompactionDurations(compacted);
3685
+ let compacted;
3686
+ try {
3687
+ compacted = await (this.options.compactor ?? new ModelCompactor(provider)).compact({
3688
+ messages: [
3689
+ ...compactableMessages,
3690
+ ...successfulHookOutput(await this.runAdvisoryHook(sessionId, 'PreCompact', {
3691
+ trigger: 'auto',
3692
+ custom_instructions: null,
3693
+ }, 'auto', signal)).map((content) => ({
3694
+ role: 'user',
3695
+ content: `Additional summarization context: ${content}`,
3696
+ })),
3697
+ ],
3698
+ targetTokens: Math.min(8192, Math.max(1, compactEnvelope.availableTokens -
3699
+ compactEnvelope.estimatedTokens)),
3700
+ contextWindowTokens: budget.contextWindowTokens,
3701
+ ...(signal ? { signal } : {}),
3702
+ });
3703
+ }
3704
+ catch (error) {
3705
+ throw classifyCompactionError(error, {
3706
+ trigger: 'auto',
3707
+ phase: 'generation',
3708
+ durableState: 'not_committed',
3709
+ recoveryDisposition: 'none',
3710
+ });
3711
+ }
3712
+ let durationMs;
3713
+ let durationWithoutRetriesMs;
3714
+ try {
3715
+ const durations = requireCompactionDurations(compacted);
3716
+ durationMs = durations.durationMs;
3717
+ durationWithoutRetriesMs = durations.durationWithoutRetriesMs;
3718
+ }
3719
+ catch (error) {
3720
+ throw classifyCompactionError(error, {
3721
+ trigger: 'auto',
3722
+ phase: 'validation',
3723
+ durableState: 'not_committed',
3724
+ recoveryDisposition: 'none',
3725
+ });
3726
+ }
3551
3727
  if (signal?.aborted)
3552
3728
  throw new AgentRunCancelledError();
3553
- const postTokens = estimateModelRequestTokens([
3729
+ const postTokens = await autoValidation(() => estimateModelRequestTokens([
3554
3730
  { role: 'user', content: compacted.summary },
3555
3731
  ...preservedMessages,
3556
- ]);
3732
+ ]));
3557
3733
  const compactModel = compacted.model !== undefined && compacted.model.trim() !== ''
3558
3734
  ? compacted.model
3559
3735
  : provider.model;
3560
- const preparedCompaction = turnAccounting.prepareCompaction({
3736
+ if (compacted.summary.trim().length === 0)
3737
+ throw classifyCompactionError(new Error('Native compact summary must not be blank'), {
3738
+ trigger: 'auto',
3739
+ phase: 'validation',
3740
+ durableState: 'not_committed',
3741
+ recoveryDisposition: 'none',
3742
+ });
3743
+ const preparedCompaction = await autoValidation(() => turnAccounting.prepareCompaction({
3561
3744
  usage: compacted.usage,
3562
- ...(compactModel === undefined ? {} : { model: compactModel }),
3745
+ ...(!hasNonZeroUsage(compacted.usage) ||
3746
+ compactModel === undefined
3747
+ ? {}
3748
+ : { model: compactModel }),
3563
3749
  durationApiMs: durationMs,
3564
3750
  durationApiWithoutRetriesMs: durationWithoutRetriesMs,
3751
+ }));
3752
+ const preparedTransaction = await compactionAccounting.prepare({
3753
+ trigger: 'auto',
3754
+ metric: {
3755
+ usage: compacted.usage,
3756
+ ...(!hasNonZeroUsage(compacted.usage) ||
3757
+ compactModel === undefined
3758
+ ? {}
3759
+ : { model: compactModel }),
3760
+ durationApiMs: durationMs,
3761
+ durationApiWithoutRetriesMs: durationWithoutRetriesMs,
3762
+ },
3565
3763
  });
3566
3764
  const summaryMessage = {
3567
3765
  role: 'user',
@@ -3597,54 +3795,138 @@ export class ClaudeSessionService {
3597
3795
  commit: async () => {
3598
3796
  if (signal?.aborted)
3599
3797
  throw new AgentRunCancelledError();
3600
- const committed = await replacement.commit(() => persistence.commit({
3601
- kind: 'compaction',
3602
- input: {
3603
- summary: compacted.summary,
3604
- trigger: 'auto',
3605
- preTokens,
3606
- postTokens,
3607
- durationMs,
3608
- preservedMessages: replayMessages,
3609
- ...(compactionLogicalParentId === undefined
3610
- ? {}
3611
- : { logicalParentId: compactionLogicalParentId }),
3612
- ...(memorySelection
3613
- ? {
3614
- direction: 'from',
3615
- messagesSummarized: compactableMessages.length,
3616
- preservePrefix: false,
3617
- }
3618
- : {}),
3619
- },
3620
- }));
3798
+ const committed = await replacement.commit(async () => {
3799
+ try {
3800
+ return await persistence.commit({
3801
+ kind: 'compaction',
3802
+ input: {
3803
+ summary: compacted.summary,
3804
+ trigger: 'auto',
3805
+ preTokens,
3806
+ postTokens,
3807
+ durationMs,
3808
+ boundaryId: preparedTransaction.boundaryId,
3809
+ summaryId: preparedTransaction.summaryId,
3810
+ preservedMessages: replayMessages,
3811
+ ...(compactionLogicalParentId === undefined
3812
+ ? {}
3813
+ : { logicalParentId: compactionLogicalParentId }),
3814
+ ...(memorySelection
3815
+ ? {
3816
+ direction: 'from',
3817
+ messagesSummarized: compactableMessages.length,
3818
+ preservePrefix: false,
3819
+ }
3820
+ : {}),
3821
+ },
3822
+ });
3823
+ }
3824
+ catch (error) {
3825
+ throw classifyCompactionError(error, {
3826
+ trigger: 'auto',
3827
+ phase: 'transcript_commit',
3828
+ durableState: 'indeterminate',
3829
+ recoveryDisposition: 'reconcile',
3830
+ });
3831
+ }
3832
+ });
3621
3833
  const ids = committed.value;
3622
3834
  if (ids.kind !== 'compaction')
3623
- throw new Error('Turn persistence returned an invalid compaction receipt');
3624
- await this.runAdvisoryHook(sessionId, 'PostCompact', { trigger: 'auto', compact_summary: compacted.summary }, 'auto', signal);
3625
- if (this.options.hooks) {
3626
- const outcome = await this.hookLifecycle.refresh(sessionId, hookSession, signal);
3627
- if (outcome)
3628
- await recordHookOutcome(outcome);
3835
+ throw classifyCompactionError(new Error('Turn persistence returned an invalid compaction receipt'), {
3836
+ trigger: 'auto',
3837
+ phase: 'transcript_commit',
3838
+ durableState: 'indeterminate',
3839
+ recoveryDisposition: 'reconcile',
3840
+ });
3841
+ try {
3842
+ preparedCompaction.commit();
3843
+ await preparedTransaction.commit({
3844
+ kind: 'compaction',
3845
+ boundaryId: ids.boundaryId,
3846
+ summaryId: ids.summaryId,
3847
+ });
3848
+ }
3849
+ catch (error) {
3850
+ throw classifyCompactionError(error, {
3851
+ trigger: 'auto',
3852
+ phase: 'accounting_commit',
3853
+ durableState: 'committed',
3854
+ recoveryDisposition: 'reconcile',
3855
+ });
3856
+ }
3857
+ try {
3858
+ await this.runAdvisoryHook(sessionId, 'PostCompact', { trigger: 'auto', compact_summary: compacted.summary }, 'auto', signal);
3859
+ }
3860
+ catch (error) {
3861
+ throw classifyCompactionError(error, {
3862
+ trigger: 'auto',
3863
+ phase: 'post_commit',
3864
+ durableState: 'committed',
3865
+ recoveryDisposition: 'reconcile',
3866
+ });
3867
+ }
3868
+ try {
3869
+ if (this.options.hooks) {
3870
+ const outcome = await this.hookLifecycle.refresh(sessionId, hookSession, signal);
3871
+ if (outcome)
3872
+ await recordHookOutcome(outcome);
3873
+ }
3874
+ this.options.contextAssembler?.invalidate?.({
3875
+ lifecycleId: sessionId,
3876
+ reason: 'compact',
3877
+ });
3878
+ await refreshRuntimeContext?.();
3879
+ this.options.eventSink?.({
3880
+ type: 'compact-boundary',
3881
+ trigger: 'auto',
3882
+ preTokens,
3883
+ uuid: ids.boundaryId,
3884
+ });
3885
+ }
3886
+ catch (error) {
3887
+ throw classifyCompactionError(error, {
3888
+ trigger: 'auto',
3889
+ phase: 'post_commit',
3890
+ durableState: 'committed',
3891
+ recoveryDisposition: 'reconcile',
3892
+ });
3629
3893
  }
3630
- this.options.contextAssembler?.invalidate?.({
3631
- lifecycleId: sessionId,
3632
- reason: 'compact',
3633
- });
3634
- await refreshRuntimeContext?.();
3635
- this.options.eventSink?.({
3636
- type: 'compact-boundary',
3637
- trigger: 'auto',
3638
- preTokens,
3639
- uuid: ids.boundaryId,
3640
- });
3641
- preparedCompaction.commit();
3642
3894
  },
3643
3895
  };
3644
3896
  },
3645
3897
  });
3898
+ const autoValidation = async (operation) => {
3899
+ try {
3900
+ return await operation();
3901
+ }
3902
+ catch (error) {
3903
+ if (error instanceof StaleContextGenerationError)
3904
+ throw error;
3905
+ throw classifyCompactionError(error, {
3906
+ trigger: 'auto',
3907
+ phase: 'validation',
3908
+ durableState: 'not_committed',
3909
+ recoveryDisposition: 'none',
3910
+ });
3911
+ }
3912
+ };
3913
+ const prepareContext = async (port) => {
3914
+ try {
3915
+ await contextEngine.prepare(port, signal);
3916
+ }
3917
+ catch (error) {
3918
+ if (error instanceof StaleContextGenerationError)
3919
+ throw error;
3920
+ throw classifyCompactionError(error, {
3921
+ trigger: 'auto',
3922
+ phase: 'validation',
3923
+ durableState: 'not_committed',
3924
+ recoveryDisposition: 'none',
3925
+ });
3926
+ }
3927
+ };
3646
3928
  if (shellCommand === undefined) {
3647
- await contextEngine.prepare(contextTransitionPort(pendingUserMessages), signal);
3929
+ await prepareContext(contextTransitionPort(pendingUserMessages));
3648
3930
  }
3649
3931
  for (const [index, message] of expandedMessages.entries()) {
3650
3932
  if (shellCommand !== undefined)
@@ -3826,7 +4108,7 @@ export class ClaudeSessionService {
3826
4108
  };
3827
4109
  }
3828
4110
  if (shellCommand === undefined && budget) {
3829
- await contextEngine.prepare(contextTransitionPort([], currentTurnUserMessages ?? []), signal);
4111
+ await prepareContext(contextTransitionPort([], currentTurnUserMessages ?? []));
3830
4112
  const projection = contextPreparation.project({
3831
4113
  includeMemory: false,
3832
4114
  });
@@ -3866,7 +4148,7 @@ export class ClaudeSessionService {
3866
4148
  }
3867
4149
  await refreshRuntimeContext?.();
3868
4150
  if (shellCommand === undefined) {
3869
- await contextEngine.prepare(contextTransitionPort([], currentTurnUserMessages ?? []), signal);
4151
+ await prepareContext(contextTransitionPort([], currentTurnUserMessages ?? []));
3870
4152
  }
3871
4153
  const projection = contextPreparation.project();
3872
4154
  runtimeRequest.stableSystemMessageCount =
@@ -4002,7 +4284,20 @@ export class ClaudeSessionService {
4002
4284
  catch (error) {
4003
4285
  if (!budget || !isPromptTooLongError(error))
4004
4286
  throw error;
4005
- const recovery = await contextEngine.recover(error, contextTransitionPort([], currentTurnUserMessages ?? []), signal);
4287
+ let recovery;
4288
+ try {
4289
+ recovery = await contextEngine.recover(error, contextTransitionPort([], currentTurnUserMessages ?? []), signal);
4290
+ }
4291
+ catch (recoveryError) {
4292
+ if (recoveryError instanceof StaleContextGenerationError)
4293
+ throw recoveryError;
4294
+ throw classifyCompactionError(recoveryError, {
4295
+ trigger: 'auto',
4296
+ phase: 'validation',
4297
+ durableState: 'not_committed',
4298
+ recoveryDisposition: 'none',
4299
+ });
4300
+ }
4006
4301
  if (recovery.kind !== 'retry') {
4007
4302
  surfaceExhaustedRecovery(error);
4008
4303
  throw error;
@@ -4141,6 +4436,8 @@ export class ClaudeSessionService {
4141
4436
  sessionId,
4142
4437
  store: nativeStore,
4143
4438
  });
4439
+ const compactionAccounting = this.compactionAccounting(sessionId, nativeStore);
4440
+ await compactionAccounting.recover();
4144
4441
  const activationKind = requireExisting
4145
4442
  ? resumeSessionAt === undefined
4146
4443
  ? { kind: 'resume' }
@@ -4148,7 +4445,7 @@ export class ClaudeSessionService {
4148
4445
  : { kind: 'start' };
4149
4446
  result = await nativeTranscript.withLease(activationKind, async (nativeLease) => {
4150
4447
  const persistence = new TurnPersistence({ native: nativeLease });
4151
- return runUnderLease(persistence);
4448
+ return runUnderLease(persistence, compactionAccounting);
4152
4449
  });
4153
4450
  }
4154
4451
  return result;
@@ -4612,6 +4909,38 @@ export class ClaudeSessionService {
4612
4909
  }
4613
4910
  return this.store(sessionId);
4614
4911
  }
4912
+ compactionAccounting(sessionId, nativeStore) {
4913
+ const existing = this.compactionAccountings.get(sessionId);
4914
+ if (existing)
4915
+ return existing;
4916
+ const tracker = this.sessionCostTrackers.get(sessionId);
4917
+ if (!tracker)
4918
+ throw new Error(`Session cost tracker is not active for session ${sessionId}`);
4919
+ const sessionPaths = this.paths(sessionId);
4920
+ const accounting = new CompactionAccounting({
4921
+ sessionId,
4922
+ tracker,
4923
+ ...(this.options.pricing ? { pricing: this.options.pricing } : {}),
4924
+ ...(this.options.sessionPersistence === false
4925
+ ? {}
4926
+ : {
4927
+ receiptStore: new NativeCompactionReceiptFileStore({
4928
+ sidecarRoot: sessionPaths.praxisRoot,
4929
+ }),
4930
+ }),
4931
+ ...(this.options.costStateStore
4932
+ ? { costStateStore: this.options.costStateStore }
4933
+ : {}),
4934
+ readTranscript: async () => {
4935
+ const loaded = await nativeStore.withLease((lease) => lease.load());
4936
+ if (loaded.status === 'conflict')
4937
+ throw new Error(`native transcript lease conflict: ${loaded.reason}`);
4938
+ return loaded.value.records.map((record) => record.event);
4939
+ },
4940
+ });
4941
+ this.compactionAccountings.set(sessionId, accounting);
4942
+ return accounting;
4943
+ }
4615
4944
  assertSessionPersistence() {
4616
4945
  if (this.options.sessionPersistence === false) {
4617
4946
  throw new Error('Session persistence is disabled');