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.
- package/dist/application/session-memory.d.ts +43 -10
- package/dist/application/session-memory.js +263 -62
- package/dist/application/session-service.d.ts +13 -4
- package/dist/application/session-service.js +131 -35
- package/dist/cli-runtime.d.ts +4 -0
- package/dist/cli-runtime.js +10 -0
- package/package.json +1 -1
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
import type
|
|
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
|
-
*
|
|
84
|
-
*
|
|
85
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
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 (
|
|
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,
|
|
336
|
+
return this.scheduleExtraction(tokens, toolCalls, messageId, messageSnapshot, naturalBreak);
|
|
213
337
|
}
|
|
214
|
-
/**
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
|
|
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
|
-
|
|
222
|
-
if (
|
|
223
|
-
|
|
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.
|
|
233
|
-
this.observedToolCalls +=
|
|
234
|
-
|
|
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 =
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
|
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 >=
|
|
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 -
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
...
|
|
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.
|
|
75
|
-
*
|
|
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
|
|
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
|
|
311
|
-
*
|
|
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 >=
|
|
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 >=
|
|
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 ||
|
|
440
|
+
(estimatedTokens >= MEMORY_COMPACT_MIN_SUFFIX_TOKENS ||
|
|
441
|
+
index === latestCompactBoundary)) {
|
|
408
442
|
break;
|
|
409
443
|
}
|
|
410
444
|
}
|
|
411
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2697
|
-
await
|
|
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:
|
|
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
|
|
3274
|
-
const
|
|
3275
|
-
|
|
3276
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
3441
|
-
if (
|
|
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([
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
4114
|
-
|
|
4115
|
-
|
|
4116
|
-
|
|
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
|
-
|
|
4137
|
-
|
|
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 = {
|
package/dist/cli-runtime.d.ts
CHANGED
|
@@ -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;
|
package/dist/cli-runtime.js
CHANGED
|
@@ -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
|