praxis-agent 0.26.0 → 0.27.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.
@@ -1,9 +1,14 @@
1
- import type { ModelMessage } from '../core/runtime.js';
1
+ import { type ModelMessage } from '../core/runtime.js';
2
2
  /** Versioned, explicit progress state for one session's extracted memory. */
3
3
  export interface SessionMemoryState {
4
4
  schemaVersion: 1;
5
5
  initialized: boolean;
6
6
  lastObservedTokens: number;
7
+ /** Current-context growth baseline after a durable compaction reduction. */
8
+ growthBaselineTokens?: number;
9
+ /** Latest extraction attempt occupancy, successful or failed. Older
10
+ * sidecars omit it and fall back to the successful watermark. */
11
+ lastAttemptedTokens?: number;
7
12
  lastObservedToolCalls: number;
8
13
  lastSummarizedMessageId: string | null;
9
14
  extractionStartedAt: number | null;
@@ -19,6 +24,10 @@ export declare class SessionMemoryTimeoutError extends Error {
19
24
  constructor(message: string);
20
25
  }
21
26
  export declare function createFreshSessionMemoryState(): SessionMemoryState;
27
+ export interface SessionMemorySnapshot {
28
+ state: SessionMemoryState;
29
+ summary: string;
30
+ }
22
31
  export interface SessionMemoryStoreOptions {
23
32
  configRoot: string;
24
33
  sessionId: string;
@@ -35,13 +44,23 @@ export interface SessionMemoryStoreOptions {
35
44
  */
36
45
  export declare class SessionMemoryStore {
37
46
  private readonly directory;
47
+ private readonly artifactsDirectory;
38
48
  private readonly stateFile;
39
49
  private readonly summaryFile;
40
50
  constructor(options: SessionMemoryStoreOptions);
41
51
  load(): Promise<SessionMemoryState>;
52
+ /** Reads the pointer record once, then resolves exactly the artifact named
53
+ * by that record. Concurrent commits cannot pair an old watermark with a
54
+ * newer summary. */
55
+ loadSnapshot(): Promise<SessionMemorySnapshot>;
56
+ private loadRecord;
42
57
  loadSummary(): Promise<string>;
58
+ private loadSummaryForRecord;
43
59
  writeSummary(summary: string): Promise<void>;
44
- writeState(state: SessionMemoryState): Promise<void>;
60
+ writeState(state: SessionMemoryState, summaryFile?: string): Promise<void>;
61
+ /** Writes an immutable summary artifact, then atomically commits its pointer
62
+ * with the progress watermark. summary.md is a best-effort readable mirror. */
63
+ commitExtraction(state: SessionMemoryState, summary: string): Promise<void>;
45
64
  clear(): Promise<void>;
46
65
  }
47
66
  export interface SessionMemoryExtractorInput {
@@ -49,15 +68,18 @@ export interface SessionMemoryExtractorInput {
49
68
  tokens: number;
50
69
  toolCalls: number;
51
70
  messages?: readonly ModelMessage[];
71
+ signal: AbortSignal;
52
72
  }
53
73
  export type SessionMemoryExtractor = (input: SessionMemoryExtractorInput) => Promise<string> | string;
54
74
  export interface SessionMemoryControllerOptions {
55
75
  store: SessionMemoryStore;
56
76
  extractor: SessionMemoryExtractor;
77
+ onExtractionError?: (error: unknown) => void;
57
78
  initTokens?: number;
58
79
  updateTokens?: number;
59
80
  updateToolCalls?: number;
60
81
  waitTimeoutMs?: number;
82
+ staleExtractionMs?: number;
61
83
  }
62
84
  /**
63
85
  * Serialized extraction lifecycle for one session. Concurrent callers of
@@ -71,20 +93,23 @@ export declare class SessionMemoryController {
71
93
  private readonly updateTokens;
72
94
  private readonly updateToolCalls;
73
95
  private readonly waitTimeoutMs;
96
+ private readonly staleExtractionMs;
74
97
  private stateValue;
75
98
  private summaryValue;
76
99
  private inFlight;
100
+ private extractionController;
77
101
  private loading;
78
102
  private observedTokens;
103
+ private tokenBaseline;
104
+ private attemptTokenBaseline;
79
105
  private observedToolCalls;
106
+ private closed;
80
107
  constructor(options: SessionMemoryControllerOptions);
81
108
  observe(tokens: number, toolCalls: number, messageId: string, messages?: readonly ModelMessage[]): Promise<boolean>;
82
- /**
83
- * Adds non-negative deltas to the current cumulative observed totals and
84
- * delegates to the serialized extraction path with those cumulative totals.
85
- * The persisted counters only advance once an extraction succeeds.
86
- */
87
- observeDelta(inputTokens: number, toolCalls: number, messageId: string, messages?: readonly ModelMessage[]): Promise<boolean>;
109
+ /** Observes the current provider-visible context occupancy and a per-turn
110
+ * tool-call delta. Unlike provider input-token deltas, occupancy may shrink
111
+ * after compaction; that establishes a new growth baseline. */
112
+ observeContext(currentTokens: number, turnToolCalls: number, messageId: string, messages?: readonly ModelMessage[]): Promise<boolean>;
88
113
  /**
89
114
  * Records the observed totals and starts an extraction when the fixed
90
115
  * eligibility contract is met. Returns true when an extraction is running or
@@ -97,14 +122,22 @@ export declare class SessionMemoryController {
97
122
  state(): Promise<SessionMemoryState>;
98
123
  /**
99
124
  * Resolves when no extraction is running; rejects on extraction failure.
100
- * Compact callers wait softly: if extraction outlives the bounded
101
- * `waitTimeoutMs`, this resolves so compaction can proceed anyway.
125
+ * Normal turns do not await this diagnostic seam.
102
126
  */
103
127
  waitForIdle(): Promise<void>;
128
+ /** Compact consumes only the last committed artifact. It waits softly for a
129
+ * useful extraction, swallows retryable failures, and cancels stale work. */
130
+ waitForCompact(): Promise<void>;
131
+ /** Cancels owned extraction work and waits only for the configured bounded
132
+ * interval. A provider that ignores AbortSignal cannot hold service close. */
133
+ close(): Promise<void>;
104
134
  clear(): Promise<void>;
105
135
  private ensureLoaded;
106
136
  private loadState;
107
137
  private isExtractionDue;
108
138
  private runExtraction;
139
+ private assertOpen;
140
+ private rebaseAfterContextReduction;
141
+ private waitBoundedly;
109
142
  }
110
143
  //# sourceMappingURL=session-memory.d.ts.map
@@ -1,5 +1,7 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { readFile, rm } from 'node:fs/promises';
2
3
  import { join, resolve } from 'node:path';
4
+ import { AgentRunCancelledError } from '../core/runtime.js';
3
5
  import { isClaudeSessionId } from '../compatibility/claude/paths.js';
4
6
  import { writeFileAtomically } from '../platform/atomic-write.js';
5
7
  /** A persisted extraction this old is recovered as stale and safely re-extracted. */
@@ -21,6 +23,8 @@ export function createFreshSessionMemoryState() {
21
23
  schemaVersion: 1,
22
24
  initialized: false,
23
25
  lastObservedTokens: 0,
26
+ growthBaselineTokens: 0,
27
+ lastAttemptedTokens: 0,
24
28
  lastObservedToolCalls: 0,
25
29
  lastSummarizedMessageId: null,
26
30
  extractionStartedAt: null,
@@ -49,6 +53,14 @@ function assertValidSessionMemoryState(value) {
49
53
  if (!isNonNegativeSafeInteger(record.lastObservedTokens)) {
50
54
  throw new SessionMemoryStateError('Session memory state lastObservedTokens must be a non-negative safe integer');
51
55
  }
56
+ if (record.growthBaselineTokens !== undefined &&
57
+ !isNonNegativeSafeInteger(record.growthBaselineTokens)) {
58
+ throw new SessionMemoryStateError('Session memory state growthBaselineTokens must be a non-negative safe integer when present');
59
+ }
60
+ if (record.lastAttemptedTokens !== undefined &&
61
+ !isNonNegativeSafeInteger(record.lastAttemptedTokens)) {
62
+ throw new SessionMemoryStateError('Session memory state lastAttemptedTokens must be a non-negative safe integer when present');
63
+ }
52
64
  if (!isNonNegativeSafeInteger(record.lastObservedToolCalls)) {
53
65
  throw new SessionMemoryStateError('Session memory state lastObservedToolCalls must be a non-negative safe integer');
54
66
  }
@@ -67,6 +79,7 @@ function assertValidSessionMemoryState(value) {
67
79
  throw new SessionMemoryStateError('Session memory state extractionError must be a string or null');
68
80
  }
69
81
  }
82
+ const SESSION_MEMORY_ARTIFACT_PATTERN = /^artifacts\/[a-f0-9]{64}\.md$/u;
70
83
  function parseSessionMemoryState(source) {
71
84
  let value;
72
85
  try {
@@ -76,7 +89,34 @@ function parseSessionMemoryState(source) {
76
89
  throw new SessionMemoryStateError('Session memory state is not valid JSON');
77
90
  }
78
91
  assertValidSessionMemoryState(value);
79
- return value;
92
+ const record = value;
93
+ if (record.summaryFile !== undefined &&
94
+ (typeof record.summaryFile !== 'string' ||
95
+ !SESSION_MEMORY_ARTIFACT_PATTERN.test(record.summaryFile))) {
96
+ throw new SessionMemoryStateError('Session memory state summaryFile must be a safe artifact path when present');
97
+ }
98
+ const state = {
99
+ schemaVersion: record.schemaVersion,
100
+ initialized: record.initialized,
101
+ lastObservedTokens: record.lastObservedTokens,
102
+ ...(record.growthBaselineTokens === undefined
103
+ ? {}
104
+ : { growthBaselineTokens: record.growthBaselineTokens }),
105
+ ...(record.lastAttemptedTokens === undefined
106
+ ? {}
107
+ : { lastAttemptedTokens: record.lastAttemptedTokens }),
108
+ lastObservedToolCalls: record.lastObservedToolCalls,
109
+ lastSummarizedMessageId: record.lastSummarizedMessageId,
110
+ extractionStartedAt: record.extractionStartedAt,
111
+ extractionCompletedAt: record.extractionCompletedAt,
112
+ extractionError: record.extractionError,
113
+ };
114
+ return {
115
+ state,
116
+ ...(typeof record.summaryFile === 'string'
117
+ ? { summaryFile: record.summaryFile }
118
+ : {}),
119
+ };
80
120
  }
81
121
  /**
82
122
  * Durable sidecar for one session's extracted memory. Claude compatibility
@@ -89,6 +129,7 @@ function parseSessionMemoryState(source) {
89
129
  */
90
130
  export class SessionMemoryStore {
91
131
  directory;
132
+ artifactsDirectory;
92
133
  stateFile;
93
134
  summaryFile;
94
135
  constructor(options) {
@@ -102,21 +143,50 @@ export class SessionMemoryStore {
102
143
  this.directory = resolve(options.sidecarRoot ?? resolve(options.configRoot, 'praxis'), 'session-memory', options.sessionId);
103
144
  this.stateFile = join(this.directory, 'state.json');
104
145
  this.summaryFile = join(this.directory, 'summary.md');
146
+ this.artifactsDirectory = join(this.directory, 'artifacts');
105
147
  }
106
148
  async load() {
149
+ return (await this.loadRecord()).state;
150
+ }
151
+ /** Reads the pointer record once, then resolves exactly the artifact named
152
+ * by that record. Concurrent commits cannot pair an old watermark with a
153
+ * newer summary. */
154
+ async loadSnapshot() {
155
+ const record = await this.loadRecord();
156
+ return {
157
+ state: record.state,
158
+ summary: await this.loadSummaryForRecord(record),
159
+ };
160
+ }
161
+ async loadRecord() {
107
162
  let source;
108
163
  try {
109
164
  source = await readFile(this.stateFile, 'utf8');
110
165
  }
111
166
  catch (error) {
112
167
  if (error.code === 'ENOENT') {
113
- return createFreshSessionMemoryState();
168
+ return { state: createFreshSessionMemoryState() };
114
169
  }
115
170
  throw error;
116
171
  }
117
172
  return parseSessionMemoryState(source);
118
173
  }
119
174
  async loadSummary() {
175
+ const record = await this.loadRecord();
176
+ return this.loadSummaryForRecord(record);
177
+ }
178
+ async loadSummaryForRecord(record) {
179
+ if (record.summaryFile !== undefined) {
180
+ try {
181
+ return await readFile(join(this.directory, record.summaryFile), 'utf8');
182
+ }
183
+ catch (error) {
184
+ if (error.code === 'ENOENT') {
185
+ throw new SessionMemoryStateError(`Session memory summary artifact is missing: ${record.summaryFile}`);
186
+ }
187
+ throw error;
188
+ }
189
+ }
120
190
  let source;
121
191
  try {
122
192
  source = await readFile(this.summaryFile, 'utf8');
@@ -134,19 +204,59 @@ export class SessionMemoryStore {
134
204
  }
135
205
  await writeFileAtomically(this.summaryFile, summary);
136
206
  }
137
- async writeState(state) {
207
+ async writeState(state, summaryFile) {
138
208
  assertValidSessionMemoryState(state);
139
- const existing = await this.load();
140
- if (state.lastObservedTokens < existing.lastObservedTokens ||
141
- state.lastObservedToolCalls < existing.lastObservedToolCalls) {
142
- throw new SessionMemoryStateError('Session memory observed counters must be monotonic');
209
+ if (summaryFile !== undefined &&
210
+ !SESSION_MEMORY_ARTIFACT_PATTERN.test(summaryFile)) {
211
+ throw new SessionMemoryStateError('Session memory summaryFile must be a safe artifact path');
143
212
  }
144
- await writeFileAtomically(this.stateFile, `${JSON.stringify(state, null, 2)}\n`);
213
+ const existing = await this.loadRecord();
214
+ if (state.lastObservedToolCalls < existing.state.lastObservedToolCalls) {
215
+ throw new SessionMemoryStateError('Session memory observed tool-call counter must be monotonic');
216
+ }
217
+ await writeFileAtomically(this.stateFile, `${JSON.stringify({
218
+ ...state,
219
+ ...(summaryFile !== undefined
220
+ ? { summaryFile }
221
+ : existing.summaryFile !== undefined
222
+ ? { summaryFile: existing.summaryFile }
223
+ : {}),
224
+ }, null, 2)}\n`);
225
+ }
226
+ /** Writes an immutable summary artifact, then atomically commits its pointer
227
+ * with the progress watermark. summary.md is a best-effort readable mirror. */
228
+ async commitExtraction(state, summary) {
229
+ if (typeof summary !== 'string' || summary.trim().length === 0) {
230
+ throw new SessionMemoryStateError('Session memory summary must be a non-empty string');
231
+ }
232
+ const existing = await this.loadRecord();
233
+ const digest = createHash('sha256')
234
+ .update(state.lastSummarizedMessageId ?? '')
235
+ .update('\0')
236
+ .update(summary)
237
+ .digest('hex');
238
+ const summaryFile = `artifacts/${digest}.md`;
239
+ const artifact = join(this.directory, summaryFile);
240
+ await writeFileAtomically(artifact, summary);
241
+ try {
242
+ await this.writeState(state, summaryFile);
243
+ }
244
+ catch (error) {
245
+ if (existing.summaryFile !== summaryFile) {
246
+ await rm(artifact, { force: true }).catch(() => undefined);
247
+ }
248
+ throw error;
249
+ }
250
+ await this.writeSummary(summary).catch(() => undefined);
251
+ // Superseded artifacts remain immutable and readable for any concurrent
252
+ // reader that loaded their pointer before this commit. clear() performs
253
+ // lifecycle-safe reclamation after the controller is idle.
145
254
  }
146
255
  async clear() {
147
256
  await Promise.all([
148
257
  rm(this.stateFile, { force: true }),
149
258
  rm(this.summaryFile, { force: true }),
259
+ rm(this.artifactsDirectory, { recursive: true, force: true }),
150
260
  ]);
151
261
  }
152
262
  }
@@ -162,18 +272,25 @@ export class SessionMemoryController {
162
272
  updateTokens;
163
273
  updateToolCalls;
164
274
  waitTimeoutMs;
275
+ staleExtractionMs;
165
276
  stateValue = null;
166
277
  summaryValue = '';
167
278
  inFlight = null;
279
+ extractionController = null;
168
280
  loading = null;
169
281
  observedTokens = 0;
282
+ tokenBaseline = 0;
283
+ attemptTokenBaseline = 0;
170
284
  observedToolCalls = 0;
285
+ closed = false;
171
286
  constructor(options) {
172
287
  this.options = options;
173
288
  this.initTokens = options.initTokens ?? 10_000;
174
289
  this.updateTokens = options.updateTokens ?? 5_000;
175
290
  this.updateToolCalls = options.updateToolCalls ?? 3;
176
291
  this.waitTimeoutMs = options.waitTimeoutMs ?? 15_000;
292
+ this.staleExtractionMs =
293
+ options.staleExtractionMs ?? STALE_EXTRACTION_THRESHOLD_MS;
177
294
  for (const [name, value] of [
178
295
  ['initTokens', this.initTokens],
179
296
  ['updateTokens', this.updateTokens],
@@ -188,9 +305,16 @@ export class SessionMemoryController {
188
305
  this.waitTimeoutMs <= 0) {
189
306
  throw new SessionMemoryStateError('Session memory waitTimeoutMs must be a positive number');
190
307
  }
308
+ if (typeof this.staleExtractionMs !== 'number' ||
309
+ !Number.isFinite(this.staleExtractionMs) ||
310
+ this.staleExtractionMs <= 0) {
311
+ throw new SessionMemoryStateError('Session memory staleExtractionMs must be a positive number');
312
+ }
191
313
  }
192
314
  async observe(tokens, toolCalls, messageId, messages) {
315
+ const messageSnapshot = cloneMessages(messages);
193
316
  await this.ensureLoaded();
317
+ this.assertOpen();
194
318
  const state = this.stateValue;
195
319
  if (state === null) {
196
320
  throw new SessionMemoryStateError('Session memory state is unavailable');
@@ -202,38 +326,32 @@ export class SessionMemoryController {
202
326
  if (typeof messageId !== 'string' || messageId.length === 0) {
203
327
  throw new SessionMemoryStateError('Session memory message ID must be a non-empty string');
204
328
  }
205
- if (tokens < state.lastObservedTokens ||
206
- toolCalls < state.lastObservedToolCalls) {
207
- throw new SessionMemoryStateError(`Session memory observed counters regressed (tokens ${tokens} < ${state.lastObservedTokens}, toolCalls ${toolCalls} < ${state.lastObservedToolCalls})`);
329
+ if (toolCalls < state.lastObservedToolCalls) {
330
+ throw new SessionMemoryStateError(`Session memory observed tool-call counter regressed (${toolCalls} < ${state.lastObservedToolCalls})`);
208
331
  }
332
+ await this.rebaseAfterContextReduction(tokens);
209
333
  // A direct absolute observation reports a natural break when no tool calls
210
334
  // have accumulated since the last successful extraction.
211
335
  const naturalBreak = toolCalls === state.lastObservedToolCalls;
212
- return this.scheduleExtraction(tokens, toolCalls, messageId, messages, naturalBreak);
336
+ return this.scheduleExtraction(tokens, toolCalls, messageId, messageSnapshot, naturalBreak);
213
337
  }
214
- /**
215
- * Adds non-negative deltas to the current cumulative observed totals and
216
- * delegates to the serialized extraction path with those cumulative totals.
217
- * The persisted counters only advance once an extraction succeeds.
218
- */
219
- async observeDelta(inputTokens, toolCalls, messageId, messages) {
338
+ /** Observes the current provider-visible context occupancy and a per-turn
339
+ * tool-call delta. Unlike provider input-token deltas, occupancy may shrink
340
+ * after compaction; that establishes a new growth baseline. */
341
+ async observeContext(currentTokens, turnToolCalls, messageId, messages) {
342
+ const messageSnapshot = cloneMessages(messages);
220
343
  await this.ensureLoaded();
221
- const state = this.stateValue;
222
- if (state === null) {
223
- throw new SessionMemoryStateError('Session memory state is unavailable');
224
- }
225
- if (!isNonNegativeSafeInteger(inputTokens) ||
226
- !isNonNegativeSafeInteger(toolCalls)) {
227
- throw new SessionMemoryStateError('Session memory observed deltas must be non-negative safe integers');
344
+ this.assertOpen();
345
+ if (!isNonNegativeSafeInteger(currentTokens) ||
346
+ !isNonNegativeSafeInteger(turnToolCalls)) {
347
+ throw new SessionMemoryStateError('Session memory context occupancy and tool-call delta must be non-negative safe integers');
228
348
  }
229
349
  if (typeof messageId !== 'string' || messageId.length === 0) {
230
350
  throw new SessionMemoryStateError('Session memory message ID must be a non-empty string');
231
351
  }
232
- this.observedTokens += inputTokens;
233
- this.observedToolCalls += toolCalls;
234
- // A zero-tool-call turn is a natural break: the last assistant turn made
235
- // no tool calls.
236
- return this.scheduleExtraction(this.observedTokens, this.observedToolCalls, messageId, messages, toolCalls === 0);
352
+ await this.rebaseAfterContextReduction(currentTokens);
353
+ this.observedToolCalls += turnToolCalls;
354
+ return this.scheduleExtraction(currentTokens, this.observedToolCalls, messageId, messageSnapshot, turnToolCalls === 0);
237
355
  }
238
356
  /**
239
357
  * Records the observed totals and starts an extraction when the fixed
@@ -241,19 +359,23 @@ export class SessionMemoryController {
241
359
  * was just scheduled; callers on normal turns never await the extraction.
242
360
  */
243
361
  scheduleExtraction(tokens, toolCalls, messageId, messages, naturalBreak) {
244
- this.observedTokens = Math.max(this.observedTokens, tokens);
362
+ this.observedTokens = tokens;
245
363
  this.observedToolCalls = Math.max(this.observedToolCalls, toolCalls);
246
364
  if (!this.isExtractionDue(this.observedTokens, this.observedToolCalls, naturalBreak)) {
247
365
  return false;
248
366
  }
249
367
  if (this.inFlight === null) {
250
- const extraction = this.runExtraction(this.observedTokens, this.observedToolCalls, messageId, messages);
368
+ const controller = new AbortController();
369
+ this.extractionController = controller;
370
+ const extraction = this.runExtraction(this.observedTokens, this.observedToolCalls, messageId, messages, controller.signal);
251
371
  this.inFlight = extraction;
252
372
  extraction
253
373
  .catch(() => undefined)
254
374
  .finally(() => {
255
- if (this.inFlight === extraction)
375
+ if (this.inFlight === extraction) {
256
376
  this.inFlight = null;
377
+ this.extractionController = null;
378
+ }
257
379
  });
258
380
  }
259
381
  return true;
@@ -273,25 +395,45 @@ export class SessionMemoryController {
273
395
  }
274
396
  /**
275
397
  * Resolves when no extraction is running; rejects on extraction failure.
276
- * Compact callers wait softly: if extraction outlives the bounded
277
- * `waitTimeoutMs`, this resolves so compaction can proceed anyway.
398
+ * Normal turns do not await this diagnostic seam.
278
399
  */
279
400
  async waitForIdle() {
280
401
  await this.ensureLoaded();
281
402
  const extraction = this.inFlight;
282
403
  if (extraction === null)
283
404
  return;
284
- let timer;
285
- const timeout = new Promise((resolve) => {
286
- timer = setTimeout(resolve, this.waitTimeoutMs);
287
- });
288
- try {
289
- await Promise.race([extraction, timeout]);
290
- }
291
- finally {
292
- if (timer !== undefined)
293
- clearTimeout(timer);
405
+ await extraction;
406
+ }
407
+ /** Compact consumes only the last committed artifact. It waits softly for a
408
+ * useful extraction, swallows retryable failures, and cancels stale work. */
409
+ async waitForCompact() {
410
+ await this.ensureLoaded();
411
+ const extraction = this.inFlight;
412
+ if (extraction === null)
413
+ return;
414
+ const startedAt = this.stateValue?.extractionStartedAt;
415
+ if (startedAt !== null &&
416
+ startedAt !== undefined &&
417
+ Date.now() - startedAt >= this.staleExtractionMs) {
418
+ this.extractionController?.abort();
419
+ // Stale work is no longer useful to compact. The extraction owns its
420
+ // eventual retryable error commit and cannot commit a summary after the
421
+ // aborted signal is observed.
422
+ return;
294
423
  }
424
+ const completed = await this.waitBoundedly(extraction, true);
425
+ if (!completed)
426
+ this.extractionController?.abort();
427
+ }
428
+ /** Cancels owned extraction work and waits only for the configured bounded
429
+ * interval. A provider that ignores AbortSignal cannot hold service close. */
430
+ async close() {
431
+ this.closed = true;
432
+ const extraction = this.inFlight;
433
+ this.extractionController?.abort();
434
+ if (extraction === null)
435
+ return;
436
+ await this.waitBoundedly(extraction, true);
295
437
  }
296
438
  async clear() {
297
439
  await this.ensureLoaded();
@@ -302,6 +444,8 @@ export class SessionMemoryController {
302
444
  const fresh = createFreshSessionMemoryState();
303
445
  this.stateValue = fresh;
304
446
  this.observedTokens = fresh.lastObservedTokens;
447
+ this.tokenBaseline = fresh.lastObservedTokens;
448
+ this.attemptTokenBaseline = fresh.lastObservedTokens;
305
449
  this.observedToolCalls = fresh.lastObservedToolCalls;
306
450
  this.summaryValue = '';
307
451
  }
@@ -315,10 +459,7 @@ export class SessionMemoryController {
315
459
  return this.loading;
316
460
  }
317
461
  async loadState() {
318
- const [state, summary] = await Promise.all([
319
- this.options.store.load(),
320
- this.options.store.loadSummary(),
321
- ]);
462
+ const { state, summary } = await this.options.store.loadSnapshot();
322
463
  if (state.extractionStartedAt !== null &&
323
464
  state.extractionCompletedAt === null) {
324
465
  const elapsed = Date.now() - state.extractionStartedAt;
@@ -326,7 +467,7 @@ export class SessionMemoryController {
326
467
  ...state,
327
468
  extractionStartedAt: null,
328
469
  extractionCompletedAt: null,
329
- extractionError: elapsed >= STALE_EXTRACTION_THRESHOLD_MS
470
+ extractionError: elapsed >= this.staleExtractionMs
330
471
  ? `Session memory extraction is stale after ${elapsed}ms`
331
472
  : 'Session memory extraction was interrupted',
332
473
  };
@@ -337,6 +478,10 @@ export class SessionMemoryController {
337
478
  this.stateValue = state;
338
479
  }
339
480
  this.observedTokens = this.stateValue.lastObservedTokens;
481
+ this.tokenBaseline =
482
+ this.stateValue.growthBaselineTokens ?? this.stateValue.lastObservedTokens;
483
+ this.attemptTokenBaseline =
484
+ this.stateValue.lastAttemptedTokens ?? this.stateValue.lastObservedTokens;
340
485
  this.observedToolCalls = this.stateValue.lastObservedToolCalls;
341
486
  this.summaryValue = summary;
342
487
  }
@@ -344,57 +489,68 @@ export class SessionMemoryController {
344
489
  const state = this.stateValue;
345
490
  if (state === null)
346
491
  return false;
347
- if (!state.initialized)
348
- return tokens >= this.initTokens;
492
+ if (!state.initialized) {
493
+ return tokens >= this.initTokens && tokens > this.attemptTokenBaseline;
494
+ }
349
495
  // Unchanged context must not retrigger; tool-call growth alone is never
350
496
  // enough without at least the update-token growth.
351
- if (tokens - state.lastObservedTokens < this.updateTokens)
497
+ if (tokens - this.tokenBaseline < this.updateTokens ||
498
+ tokens <= this.attemptTokenBaseline) {
352
499
  return false;
500
+ }
353
501
  return (toolCalls - state.lastObservedToolCalls >= this.updateToolCalls ||
354
502
  naturalBreak);
355
503
  }
356
- async runExtraction(tokens, toolCalls, messageId, messages) {
504
+ async runExtraction(tokens, toolCalls, messageId, messages, signal) {
357
505
  const state = this.stateValue;
358
506
  if (state === null) {
359
507
  throw new SessionMemoryStateError('Session memory state is unavailable');
360
508
  }
361
- this.stateValue = {
509
+ const started = {
362
510
  ...state,
511
+ lastAttemptedTokens: tokens,
363
512
  extractionStartedAt: Date.now(),
364
513
  extractionCompletedAt: null,
365
514
  extractionError: null,
366
515
  };
367
- await this.options.store.writeState(this.stateValue);
516
+ this.attemptTokenBaseline = tokens;
368
517
  try {
518
+ await this.options.store.writeState(started);
519
+ this.stateValue = started;
520
+ if (signal?.aborted || this.closed)
521
+ throw new AgentRunCancelledError();
369
522
  const summary = await this.options.extractor({
370
523
  summary: this.summaryValue,
371
524
  tokens,
372
525
  toolCalls,
373
526
  ...(messages?.length ? { messages } : {}),
527
+ signal: signal ?? new AbortController().signal,
374
528
  });
529
+ if (signal?.aborted || this.closed)
530
+ throw new AgentRunCancelledError();
375
531
  if (typeof summary !== 'string' || summary.trim().length === 0) {
376
532
  throw new SessionMemoryStateError('Session memory extractor returned an empty summary');
377
533
  }
378
- // Persist the summary before the completed state so a crash in between
379
- // is recovered as a stale extraction and safely re-extracted.
380
- await this.options.store.writeSummary(summary);
381
- this.stateValue = {
534
+ const completed = {
382
535
  ...this.stateValue,
383
536
  initialized: true,
384
537
  lastObservedTokens: tokens,
538
+ growthBaselineTokens: tokens,
385
539
  lastObservedToolCalls: toolCalls,
386
540
  lastSummarizedMessageId: messageId,
387
541
  extractionStartedAt: null,
388
542
  extractionCompletedAt: Date.now(),
389
543
  extractionError: null,
390
544
  };
391
- await this.options.store.writeState(this.stateValue);
545
+ await this.options.store.commitExtraction(completed, summary);
546
+ this.stateValue = completed;
547
+ this.tokenBaseline = tokens;
392
548
  this.summaryValue = summary;
393
549
  }
394
550
  catch (error) {
395
551
  const failure = error instanceof Error ? error.message : String(error);
396
552
  this.stateValue = {
397
- ...this.stateValue,
553
+ ...started,
398
554
  extractionStartedAt: null,
399
555
  extractionCompletedAt: null,
400
556
  extractionError: failure,
@@ -402,8 +558,53 @@ export class SessionMemoryController {
402
558
  await this.options.store
403
559
  .writeState(this.stateValue)
404
560
  .catch(() => undefined);
561
+ try {
562
+ this.options.onExtractionError?.(error);
563
+ }
564
+ catch {
565
+ // Operational warning sinks cannot change extraction lifecycle state.
566
+ }
405
567
  throw error;
406
568
  }
407
569
  }
570
+ assertOpen() {
571
+ if (this.closed) {
572
+ throw new SessionMemoryStateError('Session memory controller is closed');
573
+ }
574
+ }
575
+ async rebaseAfterContextReduction(tokens) {
576
+ if (tokens >= this.tokenBaseline && tokens >= this.attemptTokenBaseline) {
577
+ return;
578
+ }
579
+ this.tokenBaseline = Math.min(this.tokenBaseline, tokens);
580
+ this.attemptTokenBaseline = Math.min(this.attemptTokenBaseline, tokens);
581
+ if (this.stateValue === null)
582
+ return;
583
+ this.stateValue = {
584
+ ...this.stateValue,
585
+ growthBaselineTokens: this.tokenBaseline,
586
+ lastAttemptedTokens: this.attemptTokenBaseline,
587
+ };
588
+ await this.options.store.writeState(this.stateValue);
589
+ }
590
+ async waitBoundedly(extraction, ignoreFailure) {
591
+ let timer;
592
+ const timeout = new Promise((resolve) => {
593
+ timer = setTimeout(() => resolve(false), this.waitTimeoutMs);
594
+ });
595
+ try {
596
+ return await Promise.race([
597
+ (ignoreFailure ? extraction.catch(() => undefined) : extraction).then(() => true),
598
+ timeout,
599
+ ]);
600
+ }
601
+ finally {
602
+ if (timer !== undefined)
603
+ clearTimeout(timer);
604
+ }
605
+ }
606
+ }
607
+ function cloneMessages(messages) {
608
+ return messages === undefined ? undefined : structuredClone(messages);
408
609
  }
409
610
  //# sourceMappingURL=session-memory.js.map
@@ -3,6 +3,7 @@ import type { ClaudeConditionalRuleResolver } from '../compatibility/claude/cont
3
3
  import { type DataPlane } from '../persistence/data-plane.js';
4
4
  import { type ClaudeFileResource, type ClaudeFileResourceConfig } from '../compatibility/claude/file-resources.js';
5
5
  import { type ClaudeDisplayTranscriptItem } from '../compatibility/claude/projection.js';
6
+ import { type ClaudeTranscriptEntry } from '../compatibility/claude/schema.js';
6
7
  import { type ModelDocument, type ModelImage, type ModelToolCall, type ModelProvider, type ModelUsage, type PermissionApproval, type PermissionDecision, type PermissionResolver, type PermissionUpdate, type RuntimeEventSink, type ToolRegistry } from '../core/runtime.js';
7
8
  import { type BackgroundTaskSnapshot } from './background-task-runtime.js';
8
9
  import type { ModelPricingRegistry } from '../core/usage.js';
@@ -57,6 +58,9 @@ export interface ClaudeSessionServiceOptions {
57
58
  enableWorkflows?: boolean;
58
59
  providerForModel?: (model: string) => ModelProvider;
59
60
  providerForMainModel?: (model: string) => ModelProvider;
61
+ /** Creates a provider adapter dedicated to Session memory requests so
62
+ * adapter-local cache and retry state are not shared with the foreground. */
63
+ sessionMemoryProviderFactory?: () => ModelProvider;
60
64
  explicitModel?: boolean;
61
65
  explicitSystemPrompt?: boolean;
62
66
  agentInitialPromptHandledExternally?: boolean;
@@ -71,9 +75,8 @@ export interface ClaudeSessionServiceOptions {
71
75
  brief?: boolean;
72
76
  collectMetrics?: boolean;
73
77
  sessionPersistence?: boolean;
74
- /** Enable durable per-session memory extraction and injection. Defaults to
75
- * enabled whenever session persistence is enabled; ignored when
76
- * `sessionPersistence === false`. */
78
+ /** Enable durable per-session memory extraction and injection. It runs only
79
+ * when persistence and an isolated provider factory are available. */
77
80
  enableSessionMemory?: boolean;
78
81
  sessionKind?: 'bg';
79
82
  workspace?: WorkspaceContext;
@@ -161,6 +164,9 @@ export interface RewindPoint {
161
164
  fileRestoreAvailable: boolean;
162
165
  }
163
166
  export declare function workflowTokenTarget(prompt: string): number | null;
167
+ /** Walks back from the active tail to the inclusive start of the recent suffix
168
+ * retained verbatim, then completes sibling adjacency at the boundary. */
169
+ export declare function memoryPreservedSuffixStart(activeEntries: readonly ClaudeTranscriptEntry[]): number;
164
170
  export type HookSessionEndReason = 'clear' | 'resume' | 'other';
165
171
  export declare class ClaudeSessionService {
166
172
  private readonly options;
@@ -183,6 +189,7 @@ export declare class ClaudeSessionService {
183
189
  private activeCostSessionId;
184
190
  private closeCostSavePromise;
185
191
  private readonly sessionMemoryControllers;
192
+ private resolvedSessionMemoryProvider;
186
193
  private readonly hookLifecycle;
187
194
  private runtimeCwd;
188
195
  constructor(options: ClaudeSessionServiceOptions);
@@ -244,7 +251,7 @@ export declare class ClaudeSessionService {
244
251
  private sessionNameEntries;
245
252
  private hasSessionName;
246
253
  private sessionName;
247
- /** Conservative selective-preservation seam for manual compact: when the
254
+ /** Conservative selective-preservation seam for full compact: when the
248
255
  * durable session memory watermark matches an active entry, summarize the
249
256
  * last good memory artifact plus the post-watermark branch and retain a
250
257
  * recent suffix. Returns null to fall back to the existing full-compaction
@@ -288,6 +295,8 @@ export declare class ClaudeSessionService {
288
295
  private boundSessionMemorySummary;
289
296
  private formatSessionMemoryConversation;
290
297
  private extractSessionMemory;
298
+ private sessionMemoryProvider;
299
+ private sessionMemoryProviderFactory;
291
300
  private toolCapabilities;
292
301
  private capabilityToolNames;
293
302
  private append;
@@ -307,10 +307,10 @@ Return ONLY an updated Markdown document that becomes the session's durable memo
307
307
  * branch can provide them. Larger suffixes stay conservative. */
308
308
  const MEMORY_COMPACT_MIN_SUFFIX_MESSAGES = 5;
309
309
  const MEMORY_COMPACT_MIN_SUFFIX_TOKENS = 10_000;
310
- /** Above this estimated compactor projection the selective path falls back to
311
- * the existing full-compaction behavior instead of building an oversized
312
- * projection. */
310
+ /** Above this estimated post-compact envelope the selective path falls back to
311
+ * full compaction instead of retaining an oversized active projection. */
313
312
  const MEMORY_COMPACT_MAX_PROJECTION_TOKENS = 40_000;
313
+ const MEMORY_COMPACT_MAX_SUMMARY_TOKENS = 8_192;
314
314
  function isTextBearingClaudeEntry(entry) {
315
315
  if (typeof entry.message !== 'object' ||
316
316
  entry.message === null ||
@@ -360,12 +360,38 @@ function claudeAssistantHasToolUse(entry, ids) {
360
360
  typeof block.id === 'string' &&
361
361
  ids.has(block.id));
362
362
  }
363
+ function claudeAssistantResponseId(entry) {
364
+ if (entry?.type !== 'assistant' ||
365
+ typeof entry.message !== 'object' ||
366
+ entry.message === null ||
367
+ Array.isArray(entry.message)) {
368
+ return null;
369
+ }
370
+ const id = entry.message.id;
371
+ return typeof id === 'string' && id.length > 0 ? id : null;
372
+ }
373
+ /** Claude transcripts may normalize one provider response into consecutive
374
+ * assistant records with the same message.id. Keep the whole response on one
375
+ * side of the compact boundary so thinking and tool siblings stay together. */
376
+ function completeClaudeAssistantResponseBoundary(activeEntries, startIndex, lowerBound) {
377
+ const responseId = claudeAssistantResponseId(activeEntries[startIndex]);
378
+ if (responseId === null)
379
+ return startIndex;
380
+ let boundary = startIndex;
381
+ for (let index = startIndex - 1; index >= lowerBound; index -= 1) {
382
+ const candidate = activeEntries[index];
383
+ if (claudeAssistantResponseId(candidate) !== responseId)
384
+ break;
385
+ boundary = index;
386
+ }
387
+ return boundary;
388
+ }
363
389
  /** Pulls the preserved-suffix boundary backward so a tool_use assistant entry
364
390
  * and its tool_result user entry stay siblings: the suffix never opens with an
365
391
  * orphaned tool_result and the compacted input never ends with a dangling
366
392
  * tool_use. Same-response thinking/tool blocks live in one assistant entry,
367
393
  * so entry-level cutting never splits them. */
368
- function completeClaudeToolPairBoundary(activeEntries, startIndex) {
394
+ function completeClaudeToolPairBoundary(activeEntries, startIndex, lowerBound) {
369
395
  let boundary = startIndex;
370
396
  while (boundary < activeEntries.length) {
371
397
  const entry = activeEntries[boundary];
@@ -375,7 +401,7 @@ function completeClaudeToolPairBoundary(activeEntries, startIndex) {
375
401
  if (ids.size === 0)
376
402
  break;
377
403
  let extended = false;
378
- for (let index = boundary - 1; index >= 0; index -= 1) {
404
+ for (let index = boundary - 1; index >= lowerBound; index -= 1) {
379
405
  const candidate = activeEntries[index];
380
406
  if (candidate && claudeAssistantHasToolUse(candidate, ids)) {
381
407
  boundary = index;
@@ -390,11 +416,18 @@ function completeClaudeToolPairBoundary(activeEntries, startIndex) {
390
416
  }
391
417
  /** Walks back from the active tail to the inclusive start of the recent suffix
392
418
  * retained verbatim, then completes sibling adjacency at the boundary. */
393
- function memoryPreservedSuffixStart(activeEntries) {
419
+ export function memoryPreservedSuffixStart(activeEntries) {
420
+ let latestCompactBoundary = 0;
421
+ for (let index = activeEntries.length - 1; index >= 0; index -= 1) {
422
+ if (activeEntries[index]?.isCompactSummary === true) {
423
+ latestCompactBoundary = index;
424
+ break;
425
+ }
426
+ }
394
427
  let start = activeEntries.length;
395
428
  let textBearing = 0;
396
429
  let estimatedTokens = 0;
397
- for (let index = activeEntries.length - 1; index >= 0; index -= 1) {
430
+ for (let index = activeEntries.length - 1; index >= latestCompactBoundary; index -= 1) {
398
431
  const entry = activeEntries[index];
399
432
  if (!entry)
400
433
  continue;
@@ -404,11 +437,13 @@ function memoryPreservedSuffixStart(activeEntries) {
404
437
  textBearing += 1;
405
438
  estimatedTokens += estimateModelRequestTokens(projectClaudeModelMessages([entry]));
406
439
  if (textBearing >= MEMORY_COMPACT_MIN_SUFFIX_MESSAGES &&
407
- (estimatedTokens >= MEMORY_COMPACT_MIN_SUFFIX_TOKENS || index === 0)) {
440
+ (estimatedTokens >= MEMORY_COMPACT_MIN_SUFFIX_TOKENS ||
441
+ index === latestCompactBoundary)) {
408
442
  break;
409
443
  }
410
444
  }
411
- return completeClaudeToolPairBoundary(activeEntries, start);
445
+ const toolSafeStart = completeClaudeToolPairBoundary(activeEntries, start, latestCompactBoundary);
446
+ return completeClaudeAssistantResponseBoundary(activeEntries, toolSafeStart, latestCompactBoundary);
412
447
  }
413
448
  function validPromptSuggestion(value) {
414
449
  const suggestion = value.trim();
@@ -558,6 +593,7 @@ export class ClaudeSessionService {
558
593
  activeCostSessionId;
559
594
  closeCostSavePromise;
560
595
  sessionMemoryControllers = new Map();
596
+ resolvedSessionMemoryProvider;
561
597
  hookLifecycle;
562
598
  runtimeCwd;
563
599
  constructor(options) {
@@ -708,6 +744,7 @@ export class ClaudeSessionService {
708
744
  await Promise.all([...this.backgroundNotificationWrites.values()]);
709
745
  this.hostedSubagents.clear();
710
746
  this.backgroundTasks.clear();
747
+ await Promise.all([...this.sessionMemoryControllers.values()].map((controller) => controller.close()));
711
748
  this.sessionMemoryControllers.clear();
712
749
  await this.workflowManager?.close();
713
750
  this.closeCostSavePromise ??= this.persistActiveSessionCost();
@@ -2685,7 +2722,7 @@ export class ClaudeSessionService {
2685
2722
  ];
2686
2723
  const irreducible = budget.evaluate(irreducibleMessages, definitions);
2687
2724
  budget.assertFits(irreducible);
2688
- const logicalParentUuid = compactionAnchorUuid;
2725
+ let logicalParentUuid = compactionAnchorUuid;
2689
2726
  if (!logicalParentUuid || historyMessages.length === 0) {
2690
2727
  budget.assertFits(predicted);
2691
2728
  throw new Error('Cannot compact an empty Claude transcript');
@@ -2693,8 +2730,17 @@ export class ClaudeSessionService {
2693
2730
  if (findUnresolvedClaudeToolCalls(snapshot.entries).length > 0) {
2694
2731
  throw new Error('Cannot compact a Claude session with unresolved tool calls');
2695
2732
  }
2696
- if (sessionMemory) {
2697
- await sessionMemory.waitForIdle();
2733
+ const memorySelection = sessionMemory
2734
+ ? await this.selectMemoryPreservedCompact(sessionId, selectClaudeActiveTranscript(snapshot.entries))
2735
+ : null;
2736
+ const compactorMessages = memorySelection
2737
+ ? [
2738
+ memorySelection.memoryMessage,
2739
+ ...projectClaudeModelMessages(memorySelection.compactedEntries),
2740
+ ]
2741
+ : historyMessages;
2742
+ if (memorySelection) {
2743
+ logicalParentUuid = memorySelection.logicalParentUuid;
2698
2744
  }
2699
2745
  this.options.eventSink?.({ type: 'state', state: 'compacting' });
2700
2746
  const compactEnvelope = budget.evaluate([
@@ -2716,7 +2762,7 @@ export class ClaudeSessionService {
2716
2762
  targetTokens = 1;
2717
2763
  }
2718
2764
  const compacted = await (this.options.compactor ?? new ModelCompactor(provider)).compact({
2719
- messages: historyMessages,
2765
+ messages: compactorMessages,
2720
2766
  targetTokens,
2721
2767
  contextWindowTokens: budget.contextWindowTokens,
2722
2768
  ...(signal ? { signal } : {}),
@@ -2748,6 +2794,11 @@ export class ClaudeSessionService {
2748
2794
  cwd: this.activeCwd(),
2749
2795
  claudeVersion: this.options.claudeVersion,
2750
2796
  gitBranch: null,
2797
+ ...(memorySelection
2798
+ ? {
2799
+ preservedUuids: memorySelection.preservedEntries.flatMap((entry) => typeof entry.uuid === 'string' ? [entry.uuid] : []),
2800
+ }
2801
+ : {}),
2751
2802
  createUuid: () => uuids.shift() ?? randomUUID(),
2752
2803
  now: () => timestamp,
2753
2804
  });
@@ -3270,21 +3321,25 @@ export class ClaudeSessionService {
3270
3321
  });
3271
3322
  }
3272
3323
  if (sessionMemory && finalLeafUuid) {
3273
- const turnInputTokens = result.usage?.inputTokens ?? 0;
3274
- const warn = (error) => this.options.eventSink?.({
3275
- type: 'warning',
3276
- message: `Session memory extraction failed: ${error instanceof Error ? error.message : String(error)}`,
3277
- });
3324
+ const memorySnapshot = projectClaudeModelMessages(snapshot.entries);
3325
+ const providerVisibleMessages = [
3326
+ ...contextMessages,
3327
+ ...injectTurnContext(memorySnapshot),
3328
+ ];
3329
+ const currentContextTokens = budget
3330
+ ? budget.evaluate(providerVisibleMessages, definitions)
3331
+ .occupancyTokens
3332
+ : estimateModelRequestTokens(providerVisibleMessages, definitions);
3278
3333
  try {
3279
- await sessionMemory.observeDelta(turnInputTokens, currentTurnToolCalls, finalLeafUuid, projectClaudeModelMessages(snapshot.entries));
3280
- // Normal turns schedule extraction without awaiting it; failures
3281
- // surface as a warning while the sidecar stays retryable.
3282
- sessionMemory.waitForIdle().catch(warn);
3334
+ await sessionMemory.observeContext(currentContextTokens, currentTurnToolCalls, finalLeafUuid, memorySnapshot);
3283
3335
  }
3284
3336
  catch (error) {
3285
3337
  // A failed observation must not fail the user turn; the sidecar
3286
3338
  // retains a retryable error for the next observation.
3287
- warn(error);
3339
+ this.options.eventSink?.({
3340
+ type: 'warning',
3341
+ message: `Session memory observation failed: ${error instanceof Error ? error.message : String(error)}`,
3342
+ });
3288
3343
  }
3289
3344
  }
3290
3345
  turnCompleted = true;
@@ -3402,7 +3457,7 @@ export class ClaudeSessionService {
3402
3457
  return agentName;
3403
3458
  return null;
3404
3459
  }
3405
- /** Conservative selective-preservation seam for manual compact: when the
3460
+ /** Conservative selective-preservation seam for full compact: when the
3406
3461
  * durable session memory watermark matches an active entry, summarize the
3407
3462
  * last good memory artifact plus the post-watermark branch and retain a
3408
3463
  * recent suffix. Returns null to fall back to the existing full-compaction
@@ -3412,6 +3467,7 @@ export class ClaudeSessionService {
3412
3467
  const controller = this.sessionMemoryController(sessionId);
3413
3468
  if (controller === null)
3414
3469
  return null;
3470
+ await controller.waitForCompact();
3415
3471
  let watermark = null;
3416
3472
  let memorySummary = '';
3417
3473
  try {
@@ -3437,11 +3493,18 @@ export class ClaudeSessionService {
3437
3493
  return null;
3438
3494
  const compactedEntries = activeEntries.slice(watermarkIndex + 1, suffixStart);
3439
3495
  const preservedEntries = activeEntries.slice(suffixStart);
3440
- const selectedMessages = projectClaudeModelMessages(compactedEntries);
3441
- if (selectedMessages.length === 0)
3496
+ const preservedMessages = projectClaudeModelMessages(preservedEntries);
3497
+ if (projectClaudeModelMessages(compactedEntries).length === 0)
3442
3498
  return null;
3443
3499
  const memoryMessage = { role: 'user', content: memorySummary };
3444
- if (estimateModelRequestTokens([memoryMessage, ...selectedMessages]) >
3500
+ if (estimateModelRequestTokens([
3501
+ {
3502
+ role: 'user',
3503
+ content: formatClaudeCompactSummary(''),
3504
+ },
3505
+ ...preservedMessages,
3506
+ ]) +
3507
+ MEMORY_COMPACT_MAX_SUMMARY_TOKENS >
3445
3508
  MEMORY_COMPACT_MAX_PROJECTION_TOKENS) {
3446
3509
  return null;
3447
3510
  }
@@ -4045,7 +4108,9 @@ export class ClaudeSessionService {
4045
4108
  return false;
4046
4109
  if (this.options.sessionPersistence === false)
4047
4110
  return false;
4048
- return true;
4111
+ if (this.options.sessionKind === 'bg')
4112
+ return false;
4113
+ return this.sessionMemoryProviderFactory() !== null;
4049
4114
  }
4050
4115
  sessionMemoryController(sessionId) {
4051
4116
  if (!this.sessionMemoryEnabled() || !isClaudeSessionId(sessionId)) {
@@ -4059,7 +4124,11 @@ export class ClaudeSessionService {
4059
4124
  sessionId,
4060
4125
  sidecarRoot: this.paths(sessionId).praxisRoot,
4061
4126
  }),
4062
- extractor: (input) => this.extractSessionMemory(sessionId, input),
4127
+ extractor: (input) => this.extractSessionMemory(input),
4128
+ onExtractionError: (error) => this.options.eventSink?.({
4129
+ type: 'warning',
4130
+ message: `Session memory extraction failed: ${error instanceof Error ? error.message : String(error)}`,
4131
+ }),
4063
4132
  });
4064
4133
  this.sessionMemoryControllers.set(sessionId, controller);
4065
4134
  }
@@ -4110,10 +4179,16 @@ export class ClaudeSessionService {
4110
4179
  parts.push(`Tool result: ${message.content}`);
4111
4180
  }
4112
4181
  }
4113
- return this.boundSessionMemorySummary(parts.join('\n\n'));
4114
- }
4115
- async extractSessionMemory(sessionId, input) {
4116
- const provider = this.provider();
4182
+ // The successful watermark names the newest message in this snapshot, so
4183
+ // extraction must receive every message in full. Summary injection remains
4184
+ // bounded separately; provider refusal leaves the old watermark retryable.
4185
+ return parts.join('\n\n');
4186
+ }
4187
+ async extractSessionMemory(input) {
4188
+ const provider = this.sessionMemoryProvider();
4189
+ if (provider === null) {
4190
+ throw new SessionMemoryStateError('Session memory requires an isolated provider factory');
4191
+ }
4117
4192
  const messages = [
4118
4193
  { role: 'system', content: SESSION_MEMORY_EXTRACTION_PROMPT },
4119
4194
  ...(input.summary.trim().length === 0
@@ -4133,8 +4208,11 @@ export class ClaudeSessionService {
4133
4208
  },
4134
4209
  ]),
4135
4210
  ];
4136
- const metrics = await completeMeteredModelRequest(provider, { messages }, {
4137
- onMetrics: (recorded) => this.recordAuxiliaryMetrics(sessionId, recorded),
4211
+ // Session memory is operational side work: it never contributes to the
4212
+ // foreground result or the persisted session cost/usage tracker.
4213
+ const metrics = await completeMeteredModelRequest(provider, {
4214
+ messages,
4215
+ signal: input.signal,
4138
4216
  });
4139
4217
  if (metrics.toolCalls.length > 0) {
4140
4218
  throw new SessionMemoryStateError('Session memory extraction must not call tools');
@@ -4145,6 +4223,24 @@ export class ClaudeSessionService {
4145
4223
  }
4146
4224
  return summary;
4147
4225
  }
4226
+ sessionMemoryProvider() {
4227
+ if (this.resolvedSessionMemoryProvider !== undefined) {
4228
+ return this.resolvedSessionMemoryProvider;
4229
+ }
4230
+ const factory = this.sessionMemoryProviderFactory();
4231
+ this.resolvedSessionMemoryProvider = factory?.() ?? null;
4232
+ return this.resolvedSessionMemoryProvider;
4233
+ }
4234
+ sessionMemoryProviderFactory() {
4235
+ if (this.options.sessionMemoryProviderFactory) {
4236
+ return this.options.sessionMemoryProviderFactory;
4237
+ }
4238
+ const selectProvider = this.options.providerForMainModel ?? this.options.providerForModel;
4239
+ const foregroundModel = this.options.provider?.model;
4240
+ return foregroundModel && selectProvider
4241
+ ? () => selectProvider(foregroundModel)
4242
+ : null;
4243
+ }
4148
4244
  toolCapabilities() {
4149
4245
  const taskNames = this.options.taskToolNames ?? [];
4150
4246
  const input = {
@@ -178,6 +178,10 @@ export interface CliDependencies extends InteractiveServiceFactory {
178
178
  * to the existing default behavior (undefined).
179
179
  */
180
180
  export declare function resolveRuntimeModel(explicitModel: string | undefined, environment: NodeJS.ProcessEnv, settings: PraxisRuntimeSettings | undefined): string | undefined;
181
+ /** Builds the default CLI's isolated Session-memory adapter seam. Each
182
+ * invocation reconstructs the selected main-model provider stack instead of
183
+ * sharing foreground adapter-local cache and retry state. */
184
+ export declare function createSessionMemoryProviderFactory(providerForMainModel: ((model: string) => ModelProvider) | undefined, model: string | undefined): (() => ModelProvider) | undefined;
181
185
  export declare function resolveInteractiveRuntimeSettingsLocation(dataPlane: DataPlane, environment?: NodeJS.ProcessEnv): {
182
186
  configRoot: string;
183
187
  statePath: string;
@@ -749,6 +749,14 @@ export function resolveRuntimeModel(explicitModel, environment, settings) {
749
749
  (envModel !== undefined && envModel.trim() !== '' ? envModel : undefined) ??
750
750
  settingsModel);
751
751
  }
752
+ /** Builds the default CLI's isolated Session-memory adapter seam. Each
753
+ * invocation reconstructs the selected main-model provider stack instead of
754
+ * sharing foreground adapter-local cache and retry state. */
755
+ export function createSessionMemoryProviderFactory(providerForMainModel, model) {
756
+ if (!providerForMainModel || !model)
757
+ return undefined;
758
+ return () => providerForMainModel(model);
759
+ }
752
760
  export function resolveInteractiveRuntimeSettingsLocation(dataPlane, environment = process.env) {
753
761
  const configRoot = resolveDataPlaneRoot({ dataPlane, environment });
754
762
  return {
@@ -854,6 +862,7 @@ const createDefaultService = async ({ eventSink, requireProvider, hooksOnly = fa
854
862
  projectIdentity: await resolveClaudeProjectIdentity({ cwd }),
855
863
  sidecarPath: resolveUnknownCostSidecarPath(dataPlane, configRoot),
856
864
  });
865
+ const sessionMemoryProviderFactory = createSessionMemoryProviderFactory(providerForMainModel, model);
857
866
  const options = {
858
867
  configRoot,
859
868
  dataPlane,
@@ -879,6 +888,7 @@ const createDefaultService = async ({ eventSink, requireProvider, hooksOnly = fa
879
888
  : { maxBudgetUsd: cli.maxBudgetUsd }),
880
889
  pricing: ModelPricingRegistry.fromEnvironment(runtimeEnvironment.PRAXIS_PRICING_JSON),
881
890
  collectMetrics: true,
891
+ ...(sessionMemoryProviderFactory ? { sessionMemoryProviderFactory } : {}),
882
892
  ...(sessionKind === undefined ? {} : { sessionKind }),
883
893
  workspace,
884
894
  ...(cli.worktreeRequested
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "praxis-agent",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
4
  "description": "Local-first, single-user general agent for the command line.",
5
5
  "license": "MIT",
6
6
  "author": "wuqisen",