praxis-agent 0.66.0 → 0.67.1

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.
@@ -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';
@@ -33,8 +33,11 @@ import { lastUserPrompt, projectTranscriptDisplay, unresolvedActiveToolCallIds,
33
33
  import { InMemoryTranscriptStore } from '../persistence/in-memory-transcript-store.js';
34
34
  import { NativeTranscriptStore, } from '../persistence/native-transcript-store.js';
35
35
  import { NativeSessionTranscript, } from './native-session-transcript.js';
36
- import { TurnPersistence } from './turn-persistence.js';
36
+ import { deriveTurnProjectionCursor, 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}`);
@@ -2537,7 +2636,7 @@ export class ClaudeSessionService {
2537
2636
  const view = persistence.view();
2538
2637
  return {
2539
2638
  entries: [...view.projectionEntries],
2540
- tail: view.projectionTail,
2639
+ cursor: view.projectionCursor,
2541
2640
  };
2542
2641
  };
2543
2642
  const commitProjection = async (entries) => {
@@ -2698,7 +2797,7 @@ export class ClaudeSessionService {
2698
2797
  const flushRecoveryHookOutcomes = async () => {
2699
2798
  const entries = [];
2700
2799
  let history = projectionSnapshot().entries;
2701
- let parentUuid = this.logicalTailUuid(projectionSnapshot().tail);
2800
+ let parentUuid = projectionSnapshot().cursor.lastEntryId;
2702
2801
  for (const outcome of pendingRecoveryHookOutcomes) {
2703
2802
  const outcomeEntries = createClaudeHookAttachmentEntries(outcome, {
2704
2803
  ...this.translationContext(sessionId, projectionSnapshot()),
@@ -2707,9 +2806,8 @@ export class ClaudeSessionService {
2707
2806
  });
2708
2807
  entries.push(...outcomeEntries);
2709
2808
  history = [...history, ...outcomeEntries];
2710
- const lastEntry = outcomeEntries.at(-1);
2711
- if (typeof lastEntry?.uuid === 'string')
2712
- parentUuid = lastEntry.uuid;
2809
+ const batchCursor = deriveTurnProjectionCursor(outcomeEntries);
2810
+ parentUuid = batchCursor.lastEntryId ?? parentUuid;
2713
2811
  }
2714
2812
  if (entries.length === 0) {
2715
2813
  pendingRecoveryHookOutcomes.length = 0;
@@ -3380,8 +3478,32 @@ export class ClaudeSessionService {
3380
3478
  ...(budget ? { budget } : {}),
3381
3479
  autoCompact: this.options.autoCompact !== false,
3382
3480
  memory: {
3383
- beforeCompact: () => turnMemory.beforeCompact(),
3384
- afterCompact: () => turnMemory.afterCompact(),
3481
+ beforeCompact: async () => {
3482
+ try {
3483
+ await turnMemory.beforeCompact();
3484
+ }
3485
+ catch (error) {
3486
+ throw classifyCompactionError(error, {
3487
+ trigger: 'auto',
3488
+ phase: 'validation',
3489
+ durableState: 'not_committed',
3490
+ recoveryDisposition: 'none',
3491
+ });
3492
+ }
3493
+ },
3494
+ afterCompact: async () => {
3495
+ try {
3496
+ await turnMemory.afterCompact();
3497
+ }
3498
+ catch (error) {
3499
+ throw classifyCompactionError(error, {
3500
+ trigger: 'auto',
3501
+ phase: 'post_commit',
3502
+ durableState: 'committed',
3503
+ recoveryDisposition: 'reconcile',
3504
+ });
3505
+ }
3506
+ },
3385
3507
  },
3386
3508
  });
3387
3509
  observeModelRequestUsage = ({ usage, messages, tools }) => {
@@ -3431,14 +3553,29 @@ export class ClaudeSessionService {
3431
3553
  }).envelope,
3432
3554
  propose: async () => {
3433
3555
  if (!budget)
3434
- throw new Error('Context budget is unavailable');
3435
- const definitions = contextPreparation.project().envelope.tools;
3436
- const historyMessages = activeTurnMessages();
3556
+ throw classifyCompactionError(new Error('Context budget is unavailable'), {
3557
+ trigger: 'auto',
3558
+ phase: 'validation',
3559
+ durableState: 'not_committed',
3560
+ recoveryDisposition: 'none',
3561
+ });
3562
+ const definitions = await autoValidation(() => contextPreparation.project().envelope.tools);
3563
+ const historyMessages = await autoValidation(activeTurnMessages);
3437
3564
  if (historyMessages.length === 0)
3438
- throw new Error('Cannot compact an empty native transcript');
3565
+ throw classifyCompactionError(new Error('Cannot compact an empty native transcript'), {
3566
+ trigger: 'auto',
3567
+ phase: 'validation',
3568
+ durableState: 'not_committed',
3569
+ recoveryDisposition: 'none',
3570
+ });
3439
3571
  if (unresolvedActiveToolCallIds(historyMessages).length > 0)
3440
- throw new Error('Cannot compact a native transcript with unresolved tool calls');
3441
- const irreducibleMessages = contextPreparation.project({
3572
+ throw classifyCompactionError(new Error('Cannot compact a native transcript with unresolved tool calls'), {
3573
+ trigger: 'auto',
3574
+ phase: 'validation',
3575
+ durableState: 'not_committed',
3576
+ recoveryDisposition: 'none',
3577
+ });
3578
+ const irreducibleMessages = await autoValidation(() => contextPreparation.project({
3442
3579
  includeHistory: false,
3443
3580
  includeMemory: false,
3444
3581
  pendingMessages: [
@@ -3448,7 +3585,7 @@ export class ClaudeSessionService {
3448
3585
  content,
3449
3586
  })),
3450
3587
  ],
3451
- }).envelope.messages;
3588
+ }).envelope.messages);
3452
3589
  let compactableMessages = historyMessages;
3453
3590
  let preservedMessages = [];
3454
3591
  let compactionLogicalParentId;
@@ -3467,7 +3604,12 @@ export class ClaudeSessionService {
3467
3604
  }
3468
3605
  }
3469
3606
  if (found < 0)
3470
- throw new Error('Native automatic compaction could not match the current-turn suffix');
3607
+ throw classifyCompactionError(new Error('Native automatic compaction could not match the current-turn suffix'), {
3608
+ trigger: 'auto',
3609
+ phase: 'validation',
3610
+ durableState: 'not_committed',
3611
+ recoveryDisposition: 'none',
3612
+ });
3471
3613
  matchedIndexes.push(found);
3472
3614
  searchFrom = found;
3473
3615
  }
@@ -3493,7 +3635,7 @@ export class ClaudeSessionService {
3493
3635
  }
3494
3636
  }
3495
3637
  const memorySelection = sessionMemory
3496
- ? await this.selectMemoryPreservedCompact(sessionId, persistence.view().projectionEntries)
3638
+ ? await autoValidation(() => this.selectMemoryPreservedCompact(sessionId, persistence.view().projectionEntries))
3497
3639
  : null;
3498
3640
  if (memorySelection) {
3499
3641
  compactableMessages = [
@@ -3521,47 +3663,102 @@ export class ClaudeSessionService {
3521
3663
  message.content === anchor.content))?.id;
3522
3664
  }
3523
3665
  }
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) => ({
3666
+ await autoValidation(() => {
3667
+ this.options.eventSink?.({
3668
+ type: 'state',
3669
+ state: 'compacting',
3670
+ });
3671
+ });
3672
+ const preTokens = await autoValidation(() => estimateModelRequestTokens(compactableMessages));
3673
+ const compactEnvelope = await autoValidation(() => {
3674
+ const envelope = budget.evaluate([
3675
+ ...irreducibleMessages,
3676
+ {
3541
3677
  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 } : {}),
3678
+ content: formatClaudeCompactSummary(''),
3679
+ },
3680
+ ], definitions);
3681
+ budget.assertFits(envelope);
3682
+ return envelope;
3549
3683
  });
3550
- const { durationMs, durationWithoutRetriesMs } = requireCompactionDurations(compacted);
3684
+ let compacted;
3685
+ try {
3686
+ compacted = await (this.options.compactor ?? new ModelCompactor(provider)).compact({
3687
+ messages: [
3688
+ ...compactableMessages,
3689
+ ...successfulHookOutput(await this.runAdvisoryHook(sessionId, 'PreCompact', {
3690
+ trigger: 'auto',
3691
+ custom_instructions: null,
3692
+ }, 'auto', signal)).map((content) => ({
3693
+ role: 'user',
3694
+ content: `Additional summarization context: ${content}`,
3695
+ })),
3696
+ ],
3697
+ targetTokens: Math.min(8192, Math.max(1, compactEnvelope.availableTokens -
3698
+ compactEnvelope.estimatedTokens)),
3699
+ contextWindowTokens: budget.contextWindowTokens,
3700
+ ...(signal ? { signal } : {}),
3701
+ });
3702
+ }
3703
+ catch (error) {
3704
+ throw classifyCompactionError(error, {
3705
+ trigger: 'auto',
3706
+ phase: 'generation',
3707
+ durableState: 'not_committed',
3708
+ recoveryDisposition: 'none',
3709
+ });
3710
+ }
3711
+ let durationMs;
3712
+ let durationWithoutRetriesMs;
3713
+ try {
3714
+ const durations = requireCompactionDurations(compacted);
3715
+ durationMs = durations.durationMs;
3716
+ durationWithoutRetriesMs = durations.durationWithoutRetriesMs;
3717
+ }
3718
+ catch (error) {
3719
+ throw classifyCompactionError(error, {
3720
+ trigger: 'auto',
3721
+ phase: 'validation',
3722
+ durableState: 'not_committed',
3723
+ recoveryDisposition: 'none',
3724
+ });
3725
+ }
3551
3726
  if (signal?.aborted)
3552
3727
  throw new AgentRunCancelledError();
3553
- const postTokens = estimateModelRequestTokens([
3728
+ const postTokens = await autoValidation(() => estimateModelRequestTokens([
3554
3729
  { role: 'user', content: compacted.summary },
3555
3730
  ...preservedMessages,
3556
- ]);
3731
+ ]));
3557
3732
  const compactModel = compacted.model !== undefined && compacted.model.trim() !== ''
3558
3733
  ? compacted.model
3559
3734
  : provider.model;
3560
- const preparedCompaction = turnAccounting.prepareCompaction({
3735
+ if (compacted.summary.trim().length === 0)
3736
+ throw classifyCompactionError(new Error('Native compact summary must not be blank'), {
3737
+ trigger: 'auto',
3738
+ phase: 'validation',
3739
+ durableState: 'not_committed',
3740
+ recoveryDisposition: 'none',
3741
+ });
3742
+ const preparedCompaction = await autoValidation(() => turnAccounting.prepareCompaction({
3561
3743
  usage: compacted.usage,
3562
- ...(compactModel === undefined ? {} : { model: compactModel }),
3744
+ ...(!hasNonZeroUsage(compacted.usage) ||
3745
+ compactModel === undefined
3746
+ ? {}
3747
+ : { model: compactModel }),
3563
3748
  durationApiMs: durationMs,
3564
3749
  durationApiWithoutRetriesMs: durationWithoutRetriesMs,
3750
+ }));
3751
+ const preparedTransaction = await compactionAccounting.prepare({
3752
+ trigger: 'auto',
3753
+ metric: {
3754
+ usage: compacted.usage,
3755
+ ...(!hasNonZeroUsage(compacted.usage) ||
3756
+ compactModel === undefined
3757
+ ? {}
3758
+ : { model: compactModel }),
3759
+ durationApiMs: durationMs,
3760
+ durationApiWithoutRetriesMs: durationWithoutRetriesMs,
3761
+ },
3565
3762
  });
3566
3763
  const summaryMessage = {
3567
3764
  role: 'user',
@@ -3597,54 +3794,138 @@ export class ClaudeSessionService {
3597
3794
  commit: async () => {
3598
3795
  if (signal?.aborted)
3599
3796
  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
- }));
3797
+ const committed = await replacement.commit(async () => {
3798
+ try {
3799
+ return await persistence.commit({
3800
+ kind: 'compaction',
3801
+ input: {
3802
+ summary: compacted.summary,
3803
+ trigger: 'auto',
3804
+ preTokens,
3805
+ postTokens,
3806
+ durationMs,
3807
+ boundaryId: preparedTransaction.boundaryId,
3808
+ summaryId: preparedTransaction.summaryId,
3809
+ preservedMessages: replayMessages,
3810
+ ...(compactionLogicalParentId === undefined
3811
+ ? {}
3812
+ : { logicalParentId: compactionLogicalParentId }),
3813
+ ...(memorySelection
3814
+ ? {
3815
+ direction: 'from',
3816
+ messagesSummarized: compactableMessages.length,
3817
+ preservePrefix: false,
3818
+ }
3819
+ : {}),
3820
+ },
3821
+ });
3822
+ }
3823
+ catch (error) {
3824
+ throw classifyCompactionError(error, {
3825
+ trigger: 'auto',
3826
+ phase: 'transcript_commit',
3827
+ durableState: 'indeterminate',
3828
+ recoveryDisposition: 'reconcile',
3829
+ });
3830
+ }
3831
+ });
3621
3832
  const ids = committed.value;
3622
3833
  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);
3834
+ throw classifyCompactionError(new Error('Turn persistence returned an invalid compaction receipt'), {
3835
+ trigger: 'auto',
3836
+ phase: 'transcript_commit',
3837
+ durableState: 'indeterminate',
3838
+ recoveryDisposition: 'reconcile',
3839
+ });
3840
+ try {
3841
+ preparedCompaction.commit();
3842
+ await preparedTransaction.commit({
3843
+ kind: 'compaction',
3844
+ boundaryId: ids.boundaryId,
3845
+ summaryId: ids.summaryId,
3846
+ });
3847
+ }
3848
+ catch (error) {
3849
+ throw classifyCompactionError(error, {
3850
+ trigger: 'auto',
3851
+ phase: 'accounting_commit',
3852
+ durableState: 'committed',
3853
+ recoveryDisposition: 'reconcile',
3854
+ });
3855
+ }
3856
+ try {
3857
+ await this.runAdvisoryHook(sessionId, 'PostCompact', { trigger: 'auto', compact_summary: compacted.summary }, 'auto', signal);
3858
+ }
3859
+ catch (error) {
3860
+ throw classifyCompactionError(error, {
3861
+ trigger: 'auto',
3862
+ phase: 'post_commit',
3863
+ durableState: 'committed',
3864
+ recoveryDisposition: 'reconcile',
3865
+ });
3866
+ }
3867
+ try {
3868
+ if (this.options.hooks) {
3869
+ const outcome = await this.hookLifecycle.refresh(sessionId, hookSession, signal);
3870
+ if (outcome)
3871
+ await recordHookOutcome(outcome);
3872
+ }
3873
+ this.options.contextAssembler?.invalidate?.({
3874
+ lifecycleId: sessionId,
3875
+ reason: 'compact',
3876
+ });
3877
+ await refreshRuntimeContext?.();
3878
+ this.options.eventSink?.({
3879
+ type: 'compact-boundary',
3880
+ trigger: 'auto',
3881
+ preTokens,
3882
+ uuid: ids.boundaryId,
3883
+ });
3884
+ }
3885
+ catch (error) {
3886
+ throw classifyCompactionError(error, {
3887
+ trigger: 'auto',
3888
+ phase: 'post_commit',
3889
+ durableState: 'committed',
3890
+ recoveryDisposition: 'reconcile',
3891
+ });
3629
3892
  }
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
3893
  },
3643
3894
  };
3644
3895
  },
3645
3896
  });
3897
+ const autoValidation = async (operation) => {
3898
+ try {
3899
+ return await operation();
3900
+ }
3901
+ catch (error) {
3902
+ if (error instanceof StaleContextGenerationError)
3903
+ throw error;
3904
+ throw classifyCompactionError(error, {
3905
+ trigger: 'auto',
3906
+ phase: 'validation',
3907
+ durableState: 'not_committed',
3908
+ recoveryDisposition: 'none',
3909
+ });
3910
+ }
3911
+ };
3912
+ const prepareContext = async (port) => {
3913
+ try {
3914
+ await contextEngine.prepare(port, signal);
3915
+ }
3916
+ catch (error) {
3917
+ if (error instanceof StaleContextGenerationError)
3918
+ throw error;
3919
+ throw classifyCompactionError(error, {
3920
+ trigger: 'auto',
3921
+ phase: 'validation',
3922
+ durableState: 'not_committed',
3923
+ recoveryDisposition: 'none',
3924
+ });
3925
+ }
3926
+ };
3646
3927
  if (shellCommand === undefined) {
3647
- await contextEngine.prepare(contextTransitionPort(pendingUserMessages), signal);
3928
+ await prepareContext(contextTransitionPort(pendingUserMessages));
3648
3929
  }
3649
3930
  for (const [index, message] of expandedMessages.entries()) {
3650
3931
  if (shellCommand !== undefined)
@@ -3826,7 +4107,7 @@ export class ClaudeSessionService {
3826
4107
  };
3827
4108
  }
3828
4109
  if (shellCommand === undefined && budget) {
3829
- await contextEngine.prepare(contextTransitionPort([], currentTurnUserMessages ?? []), signal);
4110
+ await prepareContext(contextTransitionPort([], currentTurnUserMessages ?? []));
3830
4111
  const projection = contextPreparation.project({
3831
4112
  includeMemory: false,
3832
4113
  });
@@ -3866,7 +4147,7 @@ export class ClaudeSessionService {
3866
4147
  }
3867
4148
  await refreshRuntimeContext?.();
3868
4149
  if (shellCommand === undefined) {
3869
- await contextEngine.prepare(contextTransitionPort([], currentTurnUserMessages ?? []), signal);
4150
+ await prepareContext(contextTransitionPort([], currentTurnUserMessages ?? []));
3870
4151
  }
3871
4152
  const projection = contextPreparation.project();
3872
4153
  runtimeRequest.stableSystemMessageCount =
@@ -4002,7 +4283,20 @@ export class ClaudeSessionService {
4002
4283
  catch (error) {
4003
4284
  if (!budget || !isPromptTooLongError(error))
4004
4285
  throw error;
4005
- const recovery = await contextEngine.recover(error, contextTransitionPort([], currentTurnUserMessages ?? []), signal);
4286
+ let recovery;
4287
+ try {
4288
+ recovery = await contextEngine.recover(error, contextTransitionPort([], currentTurnUserMessages ?? []), signal);
4289
+ }
4290
+ catch (recoveryError) {
4291
+ if (recoveryError instanceof StaleContextGenerationError)
4292
+ throw recoveryError;
4293
+ throw classifyCompactionError(recoveryError, {
4294
+ trigger: 'auto',
4295
+ phase: 'validation',
4296
+ durableState: 'not_committed',
4297
+ recoveryDisposition: 'none',
4298
+ });
4299
+ }
4006
4300
  if (recovery.kind !== 'retry') {
4007
4301
  surfaceExhaustedRecovery(error);
4008
4302
  throw error;
@@ -4141,6 +4435,8 @@ export class ClaudeSessionService {
4141
4435
  sessionId,
4142
4436
  store: nativeStore,
4143
4437
  });
4438
+ const compactionAccounting = this.compactionAccounting(sessionId, nativeStore);
4439
+ await compactionAccounting.recover();
4144
4440
  const activationKind = requireExisting
4145
4441
  ? resumeSessionAt === undefined
4146
4442
  ? { kind: 'resume' }
@@ -4148,7 +4444,7 @@ export class ClaudeSessionService {
4148
4444
  : { kind: 'start' };
4149
4445
  result = await nativeTranscript.withLease(activationKind, async (nativeLease) => {
4150
4446
  const persistence = new TurnPersistence({ native: nativeLease });
4151
- return runUnderLease(persistence);
4447
+ return runUnderLease(persistence, compactionAccounting);
4152
4448
  });
4153
4449
  }
4154
4450
  return result;
@@ -4182,7 +4478,7 @@ export class ClaudeSessionService {
4182
4478
  translationContext(sessionId, snapshot) {
4183
4479
  return {
4184
4480
  sessionId,
4185
- parentUuid: this.logicalTailUuid(snapshot.tail),
4481
+ parentUuid: snapshot.cursor.lastEntryId,
4186
4482
  cwd: this.activeCwd(),
4187
4483
  claudeVersion: this.options.claudeVersion,
4188
4484
  gitBranch: null,
@@ -4612,6 +4908,38 @@ export class ClaudeSessionService {
4612
4908
  }
4613
4909
  return this.store(sessionId);
4614
4910
  }
4911
+ compactionAccounting(sessionId, nativeStore) {
4912
+ const existing = this.compactionAccountings.get(sessionId);
4913
+ if (existing)
4914
+ return existing;
4915
+ const tracker = this.sessionCostTrackers.get(sessionId);
4916
+ if (!tracker)
4917
+ throw new Error(`Session cost tracker is not active for session ${sessionId}`);
4918
+ const sessionPaths = this.paths(sessionId);
4919
+ const accounting = new CompactionAccounting({
4920
+ sessionId,
4921
+ tracker,
4922
+ ...(this.options.pricing ? { pricing: this.options.pricing } : {}),
4923
+ ...(this.options.sessionPersistence === false
4924
+ ? {}
4925
+ : {
4926
+ receiptStore: new NativeCompactionReceiptFileStore({
4927
+ sidecarRoot: sessionPaths.praxisRoot,
4928
+ }),
4929
+ }),
4930
+ ...(this.options.costStateStore
4931
+ ? { costStateStore: this.options.costStateStore }
4932
+ : {}),
4933
+ readTranscript: async () => {
4934
+ const loaded = await nativeStore.withLease((lease) => lease.load());
4935
+ if (loaded.status === 'conflict')
4936
+ throw new Error(`native transcript lease conflict: ${loaded.reason}`);
4937
+ return loaded.value.records.map((record) => record.event);
4938
+ },
4939
+ });
4940
+ this.compactionAccountings.set(sessionId, accounting);
4941
+ return accounting;
4942
+ }
4615
4943
  assertSessionPersistence() {
4616
4944
  if (this.options.sessionPersistence === false) {
4617
4945
  throw new Error('Session persistence is disabled');
@@ -4925,8 +5253,5 @@ export class ClaudeSessionService {
4925
5253
  }
4926
5254
  return factory(base, operations, sessionId, enabledTools);
4927
5255
  }
4928
- logicalTailUuid(tail) {
4929
- return tail.branchParentId ?? tail.lastEventId;
4930
- }
4931
5256
  }
4932
5257
  //# sourceMappingURL=session-service.js.map