praxis-agent 0.25.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,6 +251,13 @@ export declare class ClaudeSessionService {
244
251
  private sessionNameEntries;
245
252
  private hasSessionName;
246
253
  private sessionName;
254
+ /** Conservative selective-preservation seam for full compact: when the
255
+ * durable session memory watermark matches an active entry, summarize the
256
+ * last good memory artifact plus the post-watermark branch and retain a
257
+ * recent suffix. Returns null to fall back to the existing full-compaction
258
+ * behavior (missing/invalid watermark, empty projection, or oversized
259
+ * projection). */
260
+ private selectMemoryPreservedCompact;
247
261
  private lastMessageUuid;
248
262
  private promptIdForToolCall;
249
263
  private assistantIdForToolCall;
@@ -281,6 +295,8 @@ export declare class ClaudeSessionService {
281
295
  private boundSessionMemorySummary;
282
296
  private formatSessionMemoryConversation;
283
297
  private extractSessionMemory;
298
+ private sessionMemoryProvider;
299
+ private sessionMemoryProviderFactory;
284
300
  private toolCapabilities;
285
301
  private capabilityToolNames;
286
302
  private append;
@@ -13,7 +13,7 @@ import { ClaudeFileHistory } from '../compatibility/claude/file-history.js';
13
13
  import { getClaudePrLink } from '../compatibility/claude/pr-links.js';
14
14
  import { getClaudeAgentSetting, getClaudeLastPrompt, projectClaudeDisplayTranscript, projectClaudeModelMessages, } from '../compatibility/claude/projection.js';
15
15
  import { selectClaudeSchemaAdapter, } from '../compatibility/claude/schema.js';
16
- import { findUnresolvedClaudeToolCalls } from '../compatibility/claude/tool-links.js';
16
+ import { findUnresolvedClaudeToolCalls, getClaudeContentBlocks, } from '../compatibility/claude/tool-links.js';
17
17
  import { createClaudeAgentSettingEntry, createClaudeHookAttachmentEntries, createClaudeLastPromptEntry, createClaudeRuleAttachmentEntry, translateProviderEvents, } from '../compatibility/claude/translation.js';
18
18
  import { AgentRunCancelledError, AgentRuntime, } from '../core/runtime.js';
19
19
  import { BackgroundTaskRuntime, } from './background-task-runtime.js';
@@ -302,6 +302,149 @@ Update the durable session memory from the conversation so far. Preserve:
302
302
  Omit transient chatter, credentials, secrets, or personal data.
303
303
 
304
304
  Return ONLY an updated Markdown document that becomes the session's durable memory. Do not call tools. Do not include any prose outside the Markdown document.`;
305
+ /** Memory-anchored manual compact keeps a recent suffix of at least five
306
+ * text-bearing messages and approximately ten thousand tokens when the active
307
+ * branch can provide them. Larger suffixes stay conservative. */
308
+ const MEMORY_COMPACT_MIN_SUFFIX_MESSAGES = 5;
309
+ const MEMORY_COMPACT_MIN_SUFFIX_TOKENS = 10_000;
310
+ /** Above this estimated post-compact envelope the selective path falls back to
311
+ * full compaction instead of retaining an oversized active projection. */
312
+ const MEMORY_COMPACT_MAX_PROJECTION_TOKENS = 40_000;
313
+ const MEMORY_COMPACT_MAX_SUMMARY_TOKENS = 8_192;
314
+ function isTextBearingClaudeEntry(entry) {
315
+ if (typeof entry.message !== 'object' ||
316
+ entry.message === null ||
317
+ Array.isArray(entry.message)) {
318
+ return false;
319
+ }
320
+ const message = entry.message;
321
+ const content = message.content;
322
+ if (message.role === 'user') {
323
+ if (typeof content === 'string')
324
+ return content.trim().length > 0;
325
+ if (!Array.isArray(content))
326
+ return false;
327
+ return content.some((block) => typeof block === 'object' &&
328
+ block !== null &&
329
+ (block.type === 'text' ||
330
+ block.type === 'tool_result' ||
331
+ block.type === 'image' ||
332
+ block.type === 'document'));
333
+ }
334
+ if (message.role === 'assistant') {
335
+ if (!Array.isArray(content))
336
+ return false;
337
+ return content.some((block) => typeof block === 'object' &&
338
+ block !== null &&
339
+ (block.type === 'text' ||
340
+ block.type === 'tool_use' ||
341
+ block.type === 'thinking'));
342
+ }
343
+ return false;
344
+ }
345
+ function claudeToolResultIds(entry) {
346
+ const ids = new Set();
347
+ if (entry.type !== 'user')
348
+ return ids;
349
+ for (const block of getClaudeContentBlocks(entry)) {
350
+ if (block.type === 'tool_result' && typeof block.tool_use_id === 'string') {
351
+ ids.add(block.tool_use_id);
352
+ }
353
+ }
354
+ return ids;
355
+ }
356
+ function claudeAssistantHasToolUse(entry, ids) {
357
+ if (entry.type !== 'assistant')
358
+ return false;
359
+ return getClaudeContentBlocks(entry).some((block) => block.type === 'tool_use' &&
360
+ typeof block.id === 'string' &&
361
+ ids.has(block.id));
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
+ }
389
+ /** Pulls the preserved-suffix boundary backward so a tool_use assistant entry
390
+ * and its tool_result user entry stay siblings: the suffix never opens with an
391
+ * orphaned tool_result and the compacted input never ends with a dangling
392
+ * tool_use. Same-response thinking/tool blocks live in one assistant entry,
393
+ * so entry-level cutting never splits them. */
394
+ function completeClaudeToolPairBoundary(activeEntries, startIndex, lowerBound) {
395
+ let boundary = startIndex;
396
+ while (boundary < activeEntries.length) {
397
+ const entry = activeEntries[boundary];
398
+ if (!entry)
399
+ break;
400
+ const ids = claudeToolResultIds(entry);
401
+ if (ids.size === 0)
402
+ break;
403
+ let extended = false;
404
+ for (let index = boundary - 1; index >= lowerBound; index -= 1) {
405
+ const candidate = activeEntries[index];
406
+ if (candidate && claudeAssistantHasToolUse(candidate, ids)) {
407
+ boundary = index;
408
+ extended = true;
409
+ break;
410
+ }
411
+ }
412
+ if (!extended)
413
+ break;
414
+ }
415
+ return boundary;
416
+ }
417
+ /** Walks back from the active tail to the inclusive start of the recent suffix
418
+ * retained verbatim, then completes sibling adjacency at the boundary. */
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
+ }
427
+ let start = activeEntries.length;
428
+ let textBearing = 0;
429
+ let estimatedTokens = 0;
430
+ for (let index = activeEntries.length - 1; index >= latestCompactBoundary; index -= 1) {
431
+ const entry = activeEntries[index];
432
+ if (!entry)
433
+ continue;
434
+ start = index;
435
+ if (!isTextBearingClaudeEntry(entry))
436
+ continue;
437
+ textBearing += 1;
438
+ estimatedTokens += estimateModelRequestTokens(projectClaudeModelMessages([entry]));
439
+ if (textBearing >= MEMORY_COMPACT_MIN_SUFFIX_MESSAGES &&
440
+ (estimatedTokens >= MEMORY_COMPACT_MIN_SUFFIX_TOKENS ||
441
+ index === latestCompactBoundary)) {
442
+ break;
443
+ }
444
+ }
445
+ const toolSafeStart = completeClaudeToolPairBoundary(activeEntries, start, latestCompactBoundary);
446
+ return completeClaudeAssistantResponseBoundary(activeEntries, toolSafeStart, latestCompactBoundary);
447
+ }
305
448
  function validPromptSuggestion(value) {
306
449
  const suggestion = value.trim();
307
450
  if (!suggestion)
@@ -450,6 +593,7 @@ export class ClaudeSessionService {
450
593
  activeCostSessionId;
451
594
  closeCostSavePromise;
452
595
  sessionMemoryControllers = new Map();
596
+ resolvedSessionMemoryProvider;
453
597
  hookLifecycle;
454
598
  runtimeCwd;
455
599
  constructor(options) {
@@ -600,6 +744,7 @@ export class ClaudeSessionService {
600
744
  await Promise.all([...this.backgroundNotificationWrites.values()]);
601
745
  this.hostedSubagents.clear();
602
746
  this.backgroundTasks.clear();
747
+ await Promise.all([...this.sessionMemoryControllers.values()].map((controller) => controller.close()));
603
748
  this.sessionMemoryControllers.clear();
604
749
  await this.workflowManager?.close();
605
750
  this.closeCostSavePromise ??= this.persistActiveSessionCost();
@@ -1464,6 +1609,7 @@ export class ClaudeSessionService {
1464
1609
  let selectedEntries = activeEntries;
1465
1610
  let logicalParentUuid = this.lastMessageUuid(activeEntries);
1466
1611
  let preservedEntries = [];
1612
+ let memoryMessage;
1467
1613
  if (selection) {
1468
1614
  const targetIndex = activeEntries.findIndex((entry) => entry.uuid === selection.messageId);
1469
1615
  if (targetIndex < 0) {
@@ -1486,8 +1632,18 @@ export class ClaudeSessionService {
1486
1632
  : this.lastMessageUuid(selectedEntries);
1487
1633
  }
1488
1634
  }
1635
+ else {
1636
+ const memorySelection = await this.selectMemoryPreservedCompact(sessionId, activeEntries);
1637
+ if (memorySelection) {
1638
+ selectedEntries = [...memorySelection.compactedEntries];
1639
+ preservedEntries = [...memorySelection.preservedEntries];
1640
+ logicalParentUuid = memorySelection.logicalParentUuid;
1641
+ memoryMessage = memorySelection.memoryMessage;
1642
+ }
1643
+ }
1489
1644
  const selectedMessages = projectClaudeModelMessages(selectedEntries);
1490
1645
  const messages = [
1646
+ ...(memoryMessage === undefined ? [] : [memoryMessage]),
1491
1647
  ...selectedMessages,
1492
1648
  ...(selection?.context
1493
1649
  ? [
@@ -1588,7 +1744,11 @@ export class ClaudeSessionService {
1588
1744
  },
1589
1745
  preservedUuids: preservedEntries.flatMap((entry) => typeof entry.uuid === 'string' ? [entry.uuid] : []),
1590
1746
  }
1591
- : {}),
1747
+ : memoryMessage
1748
+ ? {
1749
+ preservedUuids: preservedEntries.flatMap((entry) => typeof entry.uuid === 'string' ? [entry.uuid] : []),
1750
+ }
1751
+ : {}),
1592
1752
  createUuid: () => uuids.shift() ?? randomUUID(),
1593
1753
  });
1594
1754
  const appendResult = await lease.appendMany(snapshot.tail, entries);
@@ -2562,7 +2722,7 @@ export class ClaudeSessionService {
2562
2722
  ];
2563
2723
  const irreducible = budget.evaluate(irreducibleMessages, definitions);
2564
2724
  budget.assertFits(irreducible);
2565
- const logicalParentUuid = compactionAnchorUuid;
2725
+ let logicalParentUuid = compactionAnchorUuid;
2566
2726
  if (!logicalParentUuid || historyMessages.length === 0) {
2567
2727
  budget.assertFits(predicted);
2568
2728
  throw new Error('Cannot compact an empty Claude transcript');
@@ -2570,8 +2730,17 @@ export class ClaudeSessionService {
2570
2730
  if (findUnresolvedClaudeToolCalls(snapshot.entries).length > 0) {
2571
2731
  throw new Error('Cannot compact a Claude session with unresolved tool calls');
2572
2732
  }
2573
- if (sessionMemory) {
2574
- 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;
2575
2744
  }
2576
2745
  this.options.eventSink?.({ type: 'state', state: 'compacting' });
2577
2746
  const compactEnvelope = budget.evaluate([
@@ -2593,7 +2762,7 @@ export class ClaudeSessionService {
2593
2762
  targetTokens = 1;
2594
2763
  }
2595
2764
  const compacted = await (this.options.compactor ?? new ModelCompactor(provider)).compact({
2596
- messages: historyMessages,
2765
+ messages: compactorMessages,
2597
2766
  targetTokens,
2598
2767
  contextWindowTokens: budget.contextWindowTokens,
2599
2768
  ...(signal ? { signal } : {}),
@@ -2625,6 +2794,11 @@ export class ClaudeSessionService {
2625
2794
  cwd: this.activeCwd(),
2626
2795
  claudeVersion: this.options.claudeVersion,
2627
2796
  gitBranch: null,
2797
+ ...(memorySelection
2798
+ ? {
2799
+ preservedUuids: memorySelection.preservedEntries.flatMap((entry) => typeof entry.uuid === 'string' ? [entry.uuid] : []),
2800
+ }
2801
+ : {}),
2628
2802
  createUuid: () => uuids.shift() ?? randomUUID(),
2629
2803
  now: () => timestamp,
2630
2804
  });
@@ -3147,21 +3321,25 @@ export class ClaudeSessionService {
3147
3321
  });
3148
3322
  }
3149
3323
  if (sessionMemory && finalLeafUuid) {
3150
- const turnInputTokens = result.usage?.inputTokens ?? 0;
3151
- const warn = (error) => this.options.eventSink?.({
3152
- type: 'warning',
3153
- message: `Session memory extraction failed: ${error instanceof Error ? error.message : String(error)}`,
3154
- });
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);
3155
3333
  try {
3156
- await sessionMemory.observeDelta(turnInputTokens, currentTurnToolCalls, finalLeafUuid, projectClaudeModelMessages(snapshot.entries));
3157
- // Normal turns schedule extraction without awaiting it; failures
3158
- // surface as a warning while the sidecar stays retryable.
3159
- sessionMemory.waitForIdle().catch(warn);
3334
+ await sessionMemory.observeContext(currentContextTokens, currentTurnToolCalls, finalLeafUuid, memorySnapshot);
3160
3335
  }
3161
3336
  catch (error) {
3162
3337
  // A failed observation must not fail the user turn; the sidecar
3163
3338
  // retains a retryable error for the next observation.
3164
- warn(error);
3339
+ this.options.eventSink?.({
3340
+ type: 'warning',
3341
+ message: `Session memory observation failed: ${error instanceof Error ? error.message : String(error)}`,
3342
+ });
3165
3343
  }
3166
3344
  }
3167
3345
  turnCompleted = true;
@@ -3279,6 +3457,67 @@ export class ClaudeSessionService {
3279
3457
  return agentName;
3280
3458
  return null;
3281
3459
  }
3460
+ /** Conservative selective-preservation seam for full compact: when the
3461
+ * durable session memory watermark matches an active entry, summarize the
3462
+ * last good memory artifact plus the post-watermark branch and retain a
3463
+ * recent suffix. Returns null to fall back to the existing full-compaction
3464
+ * behavior (missing/invalid watermark, empty projection, or oversized
3465
+ * projection). */
3466
+ async selectMemoryPreservedCompact(sessionId, activeEntries) {
3467
+ const controller = this.sessionMemoryController(sessionId);
3468
+ if (controller === null)
3469
+ return null;
3470
+ await controller.waitForCompact();
3471
+ let watermark = null;
3472
+ let memorySummary = '';
3473
+ try {
3474
+ const [state, summary] = await Promise.all([
3475
+ controller.state(),
3476
+ controller.summary(),
3477
+ ]);
3478
+ watermark = state.lastSummarizedMessageId;
3479
+ memorySummary = summary;
3480
+ }
3481
+ catch {
3482
+ return null;
3483
+ }
3484
+ if (watermark === null || watermark.length === 0)
3485
+ return null;
3486
+ if (memorySummary.trim().length === 0)
3487
+ return null;
3488
+ const watermarkIndex = activeEntries.findIndex((entry) => entry.uuid === watermark);
3489
+ if (watermarkIndex < 0)
3490
+ return null;
3491
+ const suffixStart = memoryPreservedSuffixStart(activeEntries);
3492
+ if (suffixStart <= 0 || suffixStart <= watermarkIndex + 1)
3493
+ return null;
3494
+ const compactedEntries = activeEntries.slice(watermarkIndex + 1, suffixStart);
3495
+ const preservedEntries = activeEntries.slice(suffixStart);
3496
+ const preservedMessages = projectClaudeModelMessages(preservedEntries);
3497
+ if (projectClaudeModelMessages(compactedEntries).length === 0)
3498
+ return null;
3499
+ const memoryMessage = { role: 'user', content: memorySummary };
3500
+ if (estimateModelRequestTokens([
3501
+ {
3502
+ role: 'user',
3503
+ content: formatClaudeCompactSummary(''),
3504
+ },
3505
+ ...preservedMessages,
3506
+ ]) +
3507
+ MEMORY_COMPACT_MAX_SUMMARY_TOKENS >
3508
+ MEMORY_COMPACT_MAX_PROJECTION_TOKENS) {
3509
+ return null;
3510
+ }
3511
+ const logicalParentUuid = this.lastMessageUuid(activeEntries.slice(0, suffixStart));
3512
+ if (logicalParentUuid === null)
3513
+ return null;
3514
+ return {
3515
+ compactedEntries,
3516
+ preservedEntries,
3517
+ memoryMessage,
3518
+ logicalParentUuid,
3519
+ };
3520
+ }
3282
3521
  lastMessageUuid(entries) {
3283
3522
  for (let index = entries.length - 1; index >= 0; index -= 1) {
3284
3523
  const entry = entries[index];
@@ -3869,7 +4108,9 @@ export class ClaudeSessionService {
3869
4108
  return false;
3870
4109
  if (this.options.sessionPersistence === false)
3871
4110
  return false;
3872
- return true;
4111
+ if (this.options.sessionKind === 'bg')
4112
+ return false;
4113
+ return this.sessionMemoryProviderFactory() !== null;
3873
4114
  }
3874
4115
  sessionMemoryController(sessionId) {
3875
4116
  if (!this.sessionMemoryEnabled() || !isClaudeSessionId(sessionId)) {
@@ -3883,7 +4124,11 @@ export class ClaudeSessionService {
3883
4124
  sessionId,
3884
4125
  sidecarRoot: this.paths(sessionId).praxisRoot,
3885
4126
  }),
3886
- 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
+ }),
3887
4132
  });
3888
4133
  this.sessionMemoryControllers.set(sessionId, controller);
3889
4134
  }
@@ -3934,10 +4179,16 @@ export class ClaudeSessionService {
3934
4179
  parts.push(`Tool result: ${message.content}`);
3935
4180
  }
3936
4181
  }
3937
- return this.boundSessionMemorySummary(parts.join('\n\n'));
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');
3938
4186
  }
3939
- async extractSessionMemory(sessionId, input) {
3940
- const provider = this.provider();
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
+ }
3941
4192
  const messages = [
3942
4193
  { role: 'system', content: SESSION_MEMORY_EXTRACTION_PROMPT },
3943
4194
  ...(input.summary.trim().length === 0
@@ -3957,8 +4208,11 @@ export class ClaudeSessionService {
3957
4208
  },
3958
4209
  ]),
3959
4210
  ];
3960
- const metrics = await completeMeteredModelRequest(provider, { messages }, {
3961
- 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,
3962
4216
  });
3963
4217
  if (metrics.toolCalls.length > 0) {
3964
4218
  throw new SessionMemoryStateError('Session memory extraction must not call tools');
@@ -3969,6 +4223,24 @@ export class ClaudeSessionService {
3969
4223
  }
3970
4224
  return summary;
3971
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
+ }
3972
4244
  toolCapabilities() {
3973
4245
  const taskNames = this.options.taskToolNames ?? [];
3974
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.25.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",