@xandout/libra-harness 0.1.131 → 0.1.133
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/extensions/disk-session/extension.d.ts +12 -186
- package/dist/extensions/disk-session/extension.d.ts.map +1 -1
- package/dist/extensions/disk-session/extension.js +157 -768
- package/dist/extensions/disk-session/extension.js.map +1 -1
- package/dist/extensions/disk-session/index.d.ts +6 -2
- package/dist/extensions/disk-session/index.d.ts.map +1 -1
- package/dist/extensions/disk-session/index.js +3 -1
- package/dist/extensions/disk-session/index.js.map +1 -1
- package/dist/extensions/disk-session/ledger.d.ts +62 -0
- package/dist/extensions/disk-session/ledger.d.ts.map +1 -0
- package/dist/extensions/disk-session/ledger.js +62 -0
- package/dist/extensions/disk-session/ledger.js.map +1 -0
- package/dist/extensions/disk-session/projector.d.ts +26 -0
- package/dist/extensions/disk-session/projector.d.ts.map +1 -0
- package/dist/extensions/disk-session/projector.js +279 -0
- package/dist/extensions/disk-session/projector.js.map +1 -0
- package/package.json +1 -1
|
@@ -1,832 +1,221 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { messageContentToText } from '@xandout/libra-harness';
|
|
5
|
-
/**
|
|
6
|
-
* Default resolver: reads `metadata.session` as a `SessionIdentity`,
|
|
7
|
-
* falls back to `metadata.sessionId` as a plain key, then to `'default'`.
|
|
8
|
-
*/
|
|
1
|
+
import { messageContentToText } from '../../types.js';
|
|
2
|
+
import { SessionLedger, } from './ledger.js';
|
|
3
|
+
import { applyCheckpoint, compactionChunk, latestCheckpoint, projectContext, scopeKey, selectScope, transcriptForSummary, } from './projector.js';
|
|
9
4
|
const defaultResolver = {
|
|
10
5
|
resolve(metadata) {
|
|
11
6
|
const session = metadata.session;
|
|
12
7
|
if (session && typeof session.key === 'string')
|
|
13
8
|
return session;
|
|
14
9
|
const sessionId = metadata.sessionId;
|
|
15
|
-
if (sessionId)
|
|
10
|
+
if (typeof sessionId === 'string' && sessionId)
|
|
16
11
|
return { key: sessionId, messageTs: '' };
|
|
17
12
|
return { key: 'default', messageTs: '' };
|
|
18
13
|
},
|
|
19
14
|
};
|
|
20
|
-
/**
|
|
21
|
-
* Sanitizes a message sequence to strictly conform to LLM tool-call schemas:
|
|
22
|
-
* 1. Every message with role 'tool' MUST directly follow an assistant message
|
|
23
|
-
* with a matching tool call id (or follow valid sibling tool messages for that same assistant).
|
|
24
|
-
* Any orphan or duplicate tool messages are dropped.
|
|
25
|
-
* 2. If an assistant message has toolCalls, but some or all tool calls were never
|
|
26
|
-
* fulfilled (e.g. session interrupted mid-turn or tool execution halted), its
|
|
27
|
-
* toolCalls array is pruned to only the fulfilled tool calls.
|
|
28
|
-
* 3. If an assistant message has toolCalls but none were fulfilled:
|
|
29
|
-
* - If the assistant message has non-empty text content, toolCalls is stripped.
|
|
30
|
-
* - If the assistant message has no text content, it is removed entirely.
|
|
31
|
-
*/
|
|
32
|
-
export function sanitizeConversationMessages(messages) {
|
|
33
|
-
const result = [];
|
|
34
|
-
let pendingAssistantIdx = -1;
|
|
35
|
-
let pendingToolCallIds = null;
|
|
36
|
-
let fulfilledToolCallIds = new Set();
|
|
37
|
-
function finalizePendingAssistant() {
|
|
38
|
-
if (pendingAssistantIdx === -1 || !pendingToolCallIds)
|
|
39
|
-
return;
|
|
40
|
-
const assistantMsg = result[pendingAssistantIdx];
|
|
41
|
-
if (assistantMsg && assistantMsg.toolCalls) {
|
|
42
|
-
const keptCalls = assistantMsg.toolCalls.filter((tc) => fulfilledToolCallIds.has(tc.id));
|
|
43
|
-
if (keptCalls.length > 0) {
|
|
44
|
-
assistantMsg.toolCalls = keptCalls;
|
|
45
|
-
}
|
|
46
|
-
else {
|
|
47
|
-
delete assistantMsg.toolCalls;
|
|
48
|
-
const text = messageContentToText(assistantMsg.content).trim();
|
|
49
|
-
if (!text) {
|
|
50
|
-
result.splice(pendingAssistantIdx, 1);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
pendingAssistantIdx = -1;
|
|
55
|
-
pendingToolCallIds = null;
|
|
56
|
-
fulfilledToolCallIds = new Set();
|
|
57
|
-
}
|
|
58
|
-
for (const msg of messages) {
|
|
59
|
-
if (msg.role === 'tool') {
|
|
60
|
-
if (!pendingToolCallIds || !msg.toolCallId || !pendingToolCallIds.has(msg.toolCallId)) {
|
|
61
|
-
// Orphan tool message with no matching preceding assistant tool call — drop it
|
|
62
|
-
continue;
|
|
63
|
-
}
|
|
64
|
-
if (fulfilledToolCallIds.has(msg.toolCallId)) {
|
|
65
|
-
// Duplicate tool message for the same tool call id — drop it
|
|
66
|
-
continue;
|
|
67
|
-
}
|
|
68
|
-
fulfilledToolCallIds.add(msg.toolCallId);
|
|
69
|
-
result.push(msg);
|
|
70
|
-
continue;
|
|
71
|
-
}
|
|
72
|
-
// Any non-tool message finalizes the preceding assistant's tool-calls
|
|
73
|
-
finalizePendingAssistant();
|
|
74
|
-
if (msg.role === 'assistant') {
|
|
75
|
-
if (msg.toolCalls && msg.toolCalls.length > 0) {
|
|
76
|
-
pendingAssistantIdx = result.length;
|
|
77
|
-
pendingToolCallIds = new Set(msg.toolCalls.map((tc) => tc.id));
|
|
78
|
-
fulfilledToolCallIds = new Set();
|
|
79
|
-
result.push({ ...msg, toolCalls: [...msg.toolCalls] });
|
|
80
|
-
}
|
|
81
|
-
else {
|
|
82
|
-
result.push(msg);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
else {
|
|
86
|
-
// user or system message
|
|
87
|
-
result.push(msg);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
finalizePendingAssistant();
|
|
91
|
-
return result;
|
|
92
|
-
}
|
|
93
|
-
export function isContextLengthError(err) {
|
|
94
|
-
if (!err)
|
|
95
|
-
return false;
|
|
96
|
-
const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
|
|
97
|
-
return (msg.includes('context_length') ||
|
|
98
|
-
msg.includes('context length') ||
|
|
99
|
-
msg.includes('prompt is too long') ||
|
|
100
|
-
msg.includes('maximum context length') ||
|
|
101
|
-
msg.includes('exceeds the context window') ||
|
|
102
|
-
msg.includes('too many tokens') ||
|
|
103
|
-
msg.includes('token limit') ||
|
|
104
|
-
msg.includes('max_tokens') ||
|
|
105
|
-
msg.includes('string_above_max_length'));
|
|
106
|
-
}
|
|
107
15
|
export async function generateSessionSummary(model, records) {
|
|
108
|
-
const summarySystemPrompt = 'You are a precise conversation summarizer for an AI agent. ' +
|
|
109
|
-
'Provide a dense, structured summary of the provided conversation history. ' +
|
|
110
|
-
'Preserve all essential technical details, user goals, key decisions, file paths, ' +
|
|
111
|
-
'code changes, commands executed, tool outputs, errors, and pending tasks. ' +
|
|
112
|
-
'Do not include conversational filler. Output only the summary.';
|
|
113
|
-
const transcript = records
|
|
114
|
-
.map((r) => {
|
|
115
|
-
const text = messageContentToText(r.content).trim();
|
|
116
|
-
const role = r.role.toUpperCase();
|
|
117
|
-
const tools = r.toolCalls ? ` [called: ${r.toolCalls.map((tc) => tc.name).join(', ')}]` : '';
|
|
118
|
-
return `[${role}${tools}]: ${text}`;
|
|
119
|
-
})
|
|
120
|
-
.filter((line) => line.length > 0)
|
|
121
|
-
.join('\n\n');
|
|
122
16
|
const response = await model.generate({
|
|
123
|
-
messages: [
|
|
124
|
-
{
|
|
17
|
+
messages: [{
|
|
125
18
|
role: 'user',
|
|
126
|
-
content: `
|
|
127
|
-
},
|
|
128
|
-
|
|
129
|
-
systemPrompt: summarySystemPrompt,
|
|
19
|
+
content: `Summarize this earlier conversation segment for continued agent work. Preserve user goals, decisions, file paths, code changes, commands, tool outputs, errors, and pending tasks. Omit filler.\n\n${transcriptForSummary(records)}`,
|
|
20
|
+
}],
|
|
21
|
+
systemPrompt: 'Produce a dense, precise context checkpoint. Output only the summary.',
|
|
130
22
|
});
|
|
131
|
-
|
|
23
|
+
const usage = response.usage ? {
|
|
24
|
+
promptTokens: response.usage.promptTokens,
|
|
25
|
+
completionTokens: response.usage.completionTokens,
|
|
26
|
+
...(response.usage.cachedPromptTokens !== undefined ? { cachedPromptTokens: response.usage.cachedPromptTokens } : {}),
|
|
27
|
+
...(response.usage.reasoningTokens !== undefined ? { reasoningTokens: response.usage.reasoningTokens } : {}),
|
|
28
|
+
} : undefined;
|
|
29
|
+
return { content: messageContentToText(response.message.content).trim(), ...(usage ? { usage } : {}) };
|
|
132
30
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
* against the snapshot. Concurrent turns each take their own snapshot
|
|
145
|
-
* and don't see each other's in-progress work.
|
|
146
|
-
*
|
|
147
|
-
* **Fork for threads**: when a thread reply comes in (identity has
|
|
148
|
-
* `threadTs`), the snapshot is filtered to include only:
|
|
149
|
-
* 1. Top-level messages before the thread parent (channel context)
|
|
150
|
-
* 2. All messages in that thread (parent + replies)
|
|
151
|
-
* 3. Recent top-level messages after the last thread reply
|
|
152
|
-
*
|
|
153
|
-
* **Append after turn**: when the turn finishes, new messages (user +
|
|
154
|
-
* assistant + tool calls) are appended to the session log. No data is
|
|
155
|
-
* ever rewritten or reordered.
|
|
156
|
-
*
|
|
157
|
-
* **Stable prefix for LLM caching**: the messages array is built as
|
|
158
|
-
* [system prompt] + [stable session history] + [new user message]
|
|
159
|
-
* The session history grows monotonically (append-only), so earlier
|
|
160
|
-
* tokens stay cached upstream. Per-turn metadata is injected at the end
|
|
161
|
-
* of the context (by a host-side `beforeContext` hook), not the
|
|
162
|
-
* beginning, to preserve the cache prefix.
|
|
163
|
-
*
|
|
164
|
-
* ## Host integration
|
|
165
|
-
*
|
|
166
|
-
* The host supplies a {@link SessionResolver} (or writes
|
|
167
|
-
* `metadata.session`) so the extension knows which session a turn
|
|
168
|
-
* belongs to. The extension itself is host-agnostic — it doesn't know
|
|
169
|
-
* about Slack, Discord, or any other platform.
|
|
170
|
-
*/
|
|
171
|
-
export default function createDiskSessionExtension(config) {
|
|
172
|
-
const dir = config?.sessionDir ?? './sessions';
|
|
173
|
-
const maxRecords = config?.maxRecords ?? 1000;
|
|
174
|
-
const maxContextMessages = config?.maxContextMessages ?? 50;
|
|
175
|
-
const contextEvictionStep = config?.contextEvictionStep ?? Math.max(1, Math.floor(maxContextMessages / 2));
|
|
176
|
-
const autoSummarize = config?.autoSummarize ?? true;
|
|
177
|
-
const fallbackThresholds = config?.fallbackThresholds ?? [
|
|
178
|
-
maxContextMessages,
|
|
179
|
-
Math.max(10, Math.floor(maxContextMessages / 2)),
|
|
180
|
-
Math.max(5, Math.floor(maxContextMessages / 4)),
|
|
181
|
-
];
|
|
182
|
-
const channelContextMessages = config?.channelContextMessages ?? 10;
|
|
183
|
-
const recentChannelMessages = config?.recentChannelMessages ?? 5;
|
|
184
|
-
const loadOnStartup = config?.loadOnStartup ?? true;
|
|
185
|
-
const resolver = config?.resolver ?? defaultResolver;
|
|
186
|
-
mkdirSync(dir, { recursive: true });
|
|
187
|
-
// In-memory cache: sessionKey → SessionRecord[]
|
|
188
|
-
// This is the live log. Reads take a snapshot (copy); writes append.
|
|
189
|
-
const store = new Map();
|
|
190
|
-
// Effective context limit per session for fallback backoff
|
|
191
|
-
const sessionLimits = new Map();
|
|
192
|
-
// ── Load existing sessions from disk ──────────────────────────
|
|
193
|
-
if (loadOnStartup) {
|
|
194
|
-
try {
|
|
195
|
-
const files = readdirSync(dir).filter((f) => f.endsWith('.jsonl'));
|
|
196
|
-
let totalRecords = 0;
|
|
197
|
-
for (const file of files) {
|
|
198
|
-
try {
|
|
199
|
-
const raw = readFileSync(join(dir, file), 'utf-8');
|
|
200
|
-
const lines = raw.split('\n').filter((l) => l.trim());
|
|
201
|
-
// Parse per-line so a single corrupt record doesn't discard the
|
|
202
|
-
// entire session file — bad lines are skipped, good ones kept.
|
|
203
|
-
const records = [];
|
|
204
|
-
for (const line of lines) {
|
|
205
|
-
try {
|
|
206
|
-
records.push(JSON.parse(line));
|
|
207
|
-
}
|
|
208
|
-
catch {
|
|
209
|
-
// Skip corrupt line, keep the rest of the session.
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
const sessionKey = file.replace(/\.jsonl$/, '');
|
|
213
|
-
store.set(sessionKey, records.slice(-maxRecords));
|
|
214
|
-
totalRecords += records.length;
|
|
215
|
-
}
|
|
216
|
-
catch {
|
|
217
|
-
// Skip unreadable files.
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
if (store.size > 0 && config?.verbose !== false) {
|
|
221
|
-
console.log(`[disk-session] loaded ${store.size} session(s), ${totalRecords} record(s) from ${dir}`);
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
catch {
|
|
225
|
-
// Directory doesn't exist — start empty.
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
function filePath(sessionKey) {
|
|
229
|
-
const safe = sessionKey.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
230
|
-
return join(dir, `${safe}.jsonl`);
|
|
231
|
-
}
|
|
232
|
-
function appendToFile(sessionKey, records) {
|
|
233
|
-
if (records.length === 0)
|
|
234
|
-
return;
|
|
235
|
-
const lines = records.map((r) => JSON.stringify(r)).join('\n') + '\n';
|
|
236
|
-
try {
|
|
237
|
-
appendFileSync(filePath(sessionKey), lines);
|
|
238
|
-
}
|
|
239
|
-
catch (err) {
|
|
240
|
-
console.error(`[disk-session] failed to append to ${sessionKey}:`, err);
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
function persistRecords(sessionKey, newRecords) {
|
|
244
|
-
const records = store.get(sessionKey) ?? [];
|
|
245
|
-
records.push(...newRecords);
|
|
246
|
-
if (records.length > maxRecords) {
|
|
247
|
-
records.splice(0, records.length - maxRecords);
|
|
248
|
-
}
|
|
249
|
-
store.set(sessionKey, records);
|
|
250
|
-
appendToFile(sessionKey, newRecords);
|
|
251
|
-
}
|
|
252
|
-
function handleCompaction(turn, key, messageTs) {
|
|
253
|
-
const summary = turn.response?.message ?? '';
|
|
254
|
-
const oldPath = filePath(key);
|
|
255
|
-
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
256
|
-
const archivedName = `${key.replace(/[^a-zA-Z0-9_-]/g, '_')}_${timestamp}.jsonl`;
|
|
257
|
-
const archivedPath = join(dir, archivedName);
|
|
258
|
-
if (existsSync(oldPath)) {
|
|
259
|
-
renameSync(oldPath, archivedPath);
|
|
260
|
-
}
|
|
261
|
-
const summaryRecord = {
|
|
262
|
-
role: 'assistant',
|
|
263
|
-
content: `[Session summary — prior conversation compacted ${new Date().toISOString()}]\n\n${summary}`,
|
|
264
|
-
ts: messageTs || String(Date.now() / 1000),
|
|
265
|
-
recordedAt: new Date().toISOString(),
|
|
266
|
-
};
|
|
267
|
-
store.set(key, [summaryRecord]);
|
|
268
|
-
appendToFile(key, [summaryRecord]);
|
|
269
|
-
turn.metadata['_compactArchivedFile'] = archivedName;
|
|
270
|
-
}
|
|
271
|
-
// ── Convert between Message and SessionRecord ──────────────────
|
|
272
|
-
function toMessage(r) {
|
|
273
|
-
return {
|
|
274
|
-
// `toMessage` is only called on records that passed
|
|
275
|
-
// `isConversationMessage`, which excludes 'control' (and 'tool').
|
|
276
|
-
role: r.role,
|
|
277
|
-
content: r.content,
|
|
278
|
-
...(r.toolCalls ? { toolCalls: r.toolCalls } : {}),
|
|
279
|
-
...(r.toolCallId ? { toolCallId: r.toolCallId } : {}),
|
|
280
|
-
...(r.name ? { name: r.name } : {}),
|
|
281
|
-
};
|
|
282
|
-
}
|
|
283
|
-
function toRecord(msg, ts, threadTs) {
|
|
284
|
-
return {
|
|
285
|
-
role: msg.role,
|
|
286
|
-
content: msg.content,
|
|
287
|
-
...(msg.toolCalls ? { toolCalls: msg.toolCalls } : {}),
|
|
288
|
-
...(msg.toolCallId ? { toolCallId: msg.toolCallId } : {}),
|
|
289
|
-
...(msg.name ? { name: msg.name } : {}),
|
|
290
|
-
ts,
|
|
291
|
-
...(threadTs ? { threadTs } : {}),
|
|
292
|
-
recordedAt: new Date().toISOString(),
|
|
293
|
-
};
|
|
294
|
-
}
|
|
295
|
-
// Filter for context windows: keep user, system, assistant, and tool messages.
|
|
296
|
-
// We no longer filter out tool calls here. Instead, we rely on turn-boundary
|
|
297
|
-
// slicing to ensure we don't split a tool sequence.
|
|
298
|
-
const isConversationMessage = (r) => {
|
|
299
|
-
if (r.role === 'control')
|
|
300
|
-
return false;
|
|
301
|
-
return true;
|
|
31
|
+
export default function createDiskSessionExtension(config = {}) {
|
|
32
|
+
const ledger = new SessionLedger(config.sessionDir ?? './sessions', {
|
|
33
|
+
loadOnStartup: config.loadOnStartup,
|
|
34
|
+
verbose: config.verbose,
|
|
35
|
+
});
|
|
36
|
+
const resolver = config.resolver ?? defaultResolver;
|
|
37
|
+
const policy = {
|
|
38
|
+
maxMessages: config.maxContextMessages ?? 50,
|
|
39
|
+
channelContextMessages: config.channelContextMessages ?? 10,
|
|
40
|
+
recentChannelMessages: config.recentChannelMessages ?? 5,
|
|
41
|
+
toolCallRetention: Math.max(1, config.toolCallRetention ?? 3),
|
|
302
42
|
};
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
}
|
|
321
|
-
function sliceAtTurnBoundary(records, max, step) {
|
|
322
|
-
if (records.length <= max)
|
|
323
|
-
return records;
|
|
324
|
-
const evictionStep = step && step > 0 ? step : 1;
|
|
325
|
-
let startIndex;
|
|
326
|
-
if (evictionStep <= 1) {
|
|
327
|
-
startIndex = records.length - max;
|
|
328
|
-
}
|
|
329
|
-
else {
|
|
330
|
-
const baseline = Math.max(0, max - evictionStep);
|
|
331
|
-
const excess = records.length - baseline;
|
|
332
|
-
const chunks = Math.floor(excess / evictionStep);
|
|
333
|
-
startIndex = chunks * evictionStep;
|
|
334
|
-
}
|
|
335
|
-
// Skip forward until we find a turn boundary ('user' or 'system')
|
|
336
|
-
while (startIndex < records.length &&
|
|
337
|
-
records[startIndex].role !== 'user' &&
|
|
338
|
-
records[startIndex].role !== 'system') {
|
|
339
|
-
startIndex++;
|
|
340
|
-
}
|
|
341
|
-
return records.slice(startIndex);
|
|
342
|
-
}
|
|
343
|
-
// ── Build context (the "fork") from a snapshot ────────────────
|
|
344
|
-
// The snapshot is a read-only copy of the session records at the
|
|
345
|
-
// moment the turn started. This function builds the messages array
|
|
346
|
-
// that the agent will see.
|
|
347
|
-
//
|
|
348
|
-
// For top-level messages and DMs: last N records (sliced at turn boundary).
|
|
349
|
-
// For thread replies: channel context before parent + thread history.
|
|
350
|
-
function buildContext(snapshot, threadTs, isDirect, maxContext = maxContextMessages, step = contextEvictionStep) {
|
|
351
|
-
if (snapshot.length === 0)
|
|
352
|
-
return [];
|
|
353
|
-
let rawRecords;
|
|
354
|
-
// DM or top-level message: last N messages, but safe-sliced to not
|
|
355
|
-
// split tool-call sequences.
|
|
356
|
-
if (isDirect || !threadTs) {
|
|
357
|
-
const filtered = snapshot.filter(isConversationMessage);
|
|
358
|
-
const complete = dropIncompleteTrailingTurn(filtered);
|
|
359
|
-
rawRecords = sliceAtTurnBoundary(complete, maxContext, step);
|
|
360
|
-
}
|
|
361
|
-
else {
|
|
362
|
-
// Thread: fork from channel context.
|
|
363
|
-
const parentIdx = snapshot.findIndex((r) => r.ts === threadTs);
|
|
364
|
-
if (parentIdx === -1) {
|
|
365
|
-
// Parent not in snapshot (evicted from cache or very old).
|
|
366
|
-
// Fall back to last N messages.
|
|
367
|
-
const filtered = snapshot.filter(isConversationMessage);
|
|
368
|
-
const complete = dropIncompleteTrailingTurn(filtered);
|
|
369
|
-
rawRecords = sliceAtTurnBoundary(complete, maxContext, step);
|
|
370
|
-
}
|
|
371
|
-
else {
|
|
372
|
-
// Top-level messages before the parent (channel context at fork point).
|
|
373
|
-
const topLevelBefore = snapshot
|
|
374
|
-
.slice(0, parentIdx)
|
|
375
|
-
.filter((r) => !r.threadTs && isConversationMessage(r));
|
|
376
|
-
const slicedTopLevelBefore = sliceAtTurnBoundary(topLevelBefore, channelContextMessages);
|
|
377
|
-
// All messages in this thread (including the parent).
|
|
378
|
-
const threadMessages = snapshot
|
|
379
|
-
.filter((r) => (r.ts === threadTs || r.threadTs === threadTs) && isConversationMessage(r));
|
|
380
|
-
// Recent top-level messages after the last thread reply.
|
|
381
|
-
const lastThreadTs = threadMessages[threadMessages.length - 1]?.ts ?? threadTs;
|
|
382
|
-
let lastThreadIdx = parentIdx;
|
|
383
|
-
for (let i = snapshot.length - 1; i >= 0; i--) {
|
|
384
|
-
if (snapshot[i].ts === lastThreadTs) {
|
|
385
|
-
lastThreadIdx = i;
|
|
386
|
-
break;
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
const recentTopLevel = snapshot
|
|
390
|
-
.slice(lastThreadIdx + 1)
|
|
391
|
-
.filter((r) => !r.threadTs && isConversationMessage(r));
|
|
392
|
-
const slicedRecentTopLevel = recentChannelMessages > 0
|
|
393
|
-
? sliceAtTurnBoundary(recentTopLevel, recentChannelMessages)
|
|
394
|
-
: [];
|
|
395
|
-
rawRecords = [...slicedTopLevelBefore, ...threadMessages, ...slicedRecentTopLevel];
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
const messages = rawRecords.map(toMessage);
|
|
399
|
-
// pruneMessages strips reasoning parts from older assistant messages —
|
|
400
|
-
// we store reasoning in the JSONL for the record, but the model doesn't
|
|
401
|
-
// need its own old thinking tokens re-sent to it each turn.
|
|
402
|
-
const pruned = pruneMessages({ messages: messages, reasoning: 'all' });
|
|
403
|
-
const sanitized = sanitizeConversationMessages(pruned);
|
|
404
|
-
return dropIncompleteTrailingMessages(sanitized);
|
|
405
|
-
}
|
|
406
|
-
// ── Resolve session identity from turn metadata ───────────────
|
|
407
|
-
function identityFromCtx(ctx) {
|
|
408
|
-
return resolver.resolve(ctx.turn.request.metadata ?? {});
|
|
409
|
-
}
|
|
43
|
+
const compactingScopes = new Set();
|
|
44
|
+
const identityFromTurn = (turn) => resolver.resolve(turn.request.metadata ?? {});
|
|
45
|
+
const appendMessageRecord = (identity, turnId, message, meta, usage, systemPrompt) => ledger.append({
|
|
46
|
+
kind: 'message',
|
|
47
|
+
sessionKey: identity.key,
|
|
48
|
+
turnId,
|
|
49
|
+
role: message.role,
|
|
50
|
+
content: message.content,
|
|
51
|
+
...(message.toolCalls?.length ? { toolCalls: message.toolCalls } : {}),
|
|
52
|
+
...(message.toolCallId ? { toolCallId: message.toolCallId } : {}),
|
|
53
|
+
...(message.name ? { name: message.name } : {}),
|
|
54
|
+
ts: identity.messageTs,
|
|
55
|
+
...(identity.threadTs ? { threadTs: identity.threadTs } : {}),
|
|
56
|
+
...(meta ? { meta } : {}),
|
|
57
|
+
...(usage ? { usage } : {}),
|
|
58
|
+
...(systemPrompt ? { systemPrompt } : {}),
|
|
59
|
+
});
|
|
410
60
|
return {
|
|
411
61
|
name: 'disk-session',
|
|
412
62
|
priority: -100,
|
|
413
63
|
install(agent) {
|
|
414
|
-
const
|
|
415
|
-
// ── beforeTurn: take snapshot, build context, prepend ─────
|
|
416
|
-
// The snapshot is a copy of the session records at this moment.
|
|
417
|
-
// Concurrent turns each get their own snapshot — they don't
|
|
418
|
-
// see each other's in-progress work.
|
|
64
|
+
const summaryModel = config.model ?? agent.model;
|
|
419
65
|
agent.hook('beforeTurn', 'disk-session', async (ctx) => {
|
|
420
|
-
const identity =
|
|
66
|
+
const identity = identityFromTurn(ctx.turn);
|
|
421
67
|
if (!identity)
|
|
422
68
|
return;
|
|
423
|
-
const
|
|
424
|
-
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
const step = config?.contextEvictionStep ?? Math.max(1, Math.floor(effectiveMax / 2));
|
|
435
|
-
let cutIndex = Math.min(step, liveRecords.length - 1);
|
|
436
|
-
while (cutIndex < liveRecords.length &&
|
|
437
|
-
liveRecords[cutIndex].role !== 'user' &&
|
|
438
|
-
liveRecords[cutIndex].role !== 'system') {
|
|
439
|
-
cutIndex++;
|
|
440
|
-
}
|
|
441
|
-
if (cutIndex > 0 && cutIndex < liveRecords.length) {
|
|
442
|
-
const recordsToSummarize = liveRecords.slice(0, cutIndex);
|
|
443
|
-
const recordsToKeep = liveRecords.slice(cutIndex);
|
|
69
|
+
const turnId = crypto.randomUUID();
|
|
70
|
+
ctx.turn.metadata._diskSessionTurnId = turnId;
|
|
71
|
+
const scope = scopeKey(identity);
|
|
72
|
+
let snapshot = ledger.snapshot(identity.key);
|
|
73
|
+
if (config.autoSummarize !== false && summaryModel && !compactingScopes.has(`${identity.key}:${scope}`)) {
|
|
74
|
+
const selected = selectScope(snapshot, identity, policy);
|
|
75
|
+
const applied = applyCheckpoint(selected, latestCheckpoint(snapshot, scope));
|
|
76
|
+
const chunk = compactionChunk(applied.messages, policy.maxMessages);
|
|
77
|
+
if (chunk.length > 0) {
|
|
78
|
+
const lockKey = `${identity.key}:${scope}`;
|
|
79
|
+
compactingScopes.add(lockKey);
|
|
444
80
|
try {
|
|
445
|
-
const
|
|
446
|
-
if (
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
}
|
|
463
|
-
catch { }
|
|
464
|
-
}
|
|
465
|
-
appendToFile(key, compactedRecords);
|
|
466
|
-
autoCompacted = true;
|
|
467
|
-
compactedCount = recordsToSummarize.length;
|
|
81
|
+
const summary = await generateSessionSummary(summaryModel, chunk);
|
|
82
|
+
if (summary.content) {
|
|
83
|
+
ledger.append({
|
|
84
|
+
kind: 'summary',
|
|
85
|
+
sessionKey: identity.key,
|
|
86
|
+
turnId,
|
|
87
|
+
scope,
|
|
88
|
+
throughRecordId: chunk[chunk.length - 1].id,
|
|
89
|
+
sourceRecordCount: chunk.length,
|
|
90
|
+
content: summary.content,
|
|
91
|
+
...(summary.usage ? { usage: summary.usage } : {}),
|
|
92
|
+
policyVersion: 1,
|
|
93
|
+
ts: identity.messageTs,
|
|
94
|
+
...(identity.threadTs ? { threadTs: identity.threadTs } : {}),
|
|
95
|
+
});
|
|
96
|
+
snapshot = ledger.snapshot(identity.key);
|
|
97
|
+
ctx.turn.metadata._autoCompacted = true;
|
|
468
98
|
}
|
|
469
99
|
}
|
|
470
|
-
catch (
|
|
471
|
-
console.warn('[disk-session]
|
|
100
|
+
catch (error) {
|
|
101
|
+
console.warn('[disk-session] summary checkpoint failed; using bounded raw context:', error);
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
compactingScopes.delete(lockKey);
|
|
472
105
|
}
|
|
473
106
|
}
|
|
474
107
|
}
|
|
475
|
-
|
|
476
|
-
const
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
// ── Persist the user message immediately ────────────────
|
|
481
|
-
// Write the incoming user message to disk NOW, before the
|
|
482
|
-
// agent runs. If the process crashes mid-turn, the user's
|
|
483
|
-
// message is already saved. The assistant response is
|
|
484
|
-
// appended later in afterTurn.
|
|
485
|
-
//
|
|
486
|
-
// The system prompt is captured at afterTurn (not here) because
|
|
487
|
-
// beforeContext hooks (which run after beforeTurn) may modify
|
|
488
|
-
// ctx.turn.systemPrompt. Recording it here would miss those
|
|
489
|
-
// modifications. See afterTurn for the capture.
|
|
490
|
-
//
|
|
491
|
-
// Enrichment bag: any extension that ran before us in
|
|
492
|
-
// beforeTurn may have written into `sessionMeta`. We persist
|
|
493
|
-
// it opaquely — disk-session does not inspect the contents.
|
|
494
|
-
const sessionMeta = ctx.turn.metadata.sessionMeta;
|
|
495
|
-
const userRecord = {
|
|
496
|
-
...toRecord({ role: 'user', content: ctx.turn.request.message }, messageTs, threadTs),
|
|
497
|
-
...(sessionMeta ? { meta: sessionMeta } : {}),
|
|
498
|
-
};
|
|
499
|
-
persistRecords(key, [userRecord]);
|
|
500
|
-
// Track how many history messages we prepended so afterTurn
|
|
501
|
-
// knows where new messages start.
|
|
502
|
-
ctx.turn.metadata['_diskSessionHistoryLen'] = history.length;
|
|
503
|
-
// Immediately before the model acts: if auto-compaction occurred,
|
|
504
|
-
// inform the model so it can act accordingly. Never inform the human.
|
|
505
|
-
const messagesToPrepend = [...history];
|
|
506
|
-
if (autoCompacted) {
|
|
507
|
-
const modelNotice = {
|
|
508
|
-
role: 'system',
|
|
509
|
-
content: `[System Notice: Earlier conversation history (${compactedCount} messages) was automatically compacted into the summary above to preserve context. Refer to the summary for earlier decisions, code changes, and context.]`,
|
|
510
|
-
};
|
|
511
|
-
messagesToPrepend.push(modelNotice);
|
|
512
|
-
ctx.turn.metadata['_autoCompacted'] = true;
|
|
513
|
-
}
|
|
514
|
-
if (messagesToPrepend.length > 0) {
|
|
515
|
-
ctx.turn.messages = [...messagesToPrepend, ...ctx.turn.messages];
|
|
516
|
-
}
|
|
517
|
-
// Track all messages currently in ctx.turn.messages (both history and the
|
|
518
|
-
// initial user message just written) so beforeLLM only persists new steering messages.
|
|
519
|
-
const writtenMessages = new Set(ctx.turn.messages);
|
|
520
|
-
ctx.turn.metadata['_diskSessionWrittenMessages'] = writtenMessages;
|
|
108
|
+
const history = await projectContext(snapshot, identity, policy);
|
|
109
|
+
const meta = ctx.turn.metadata.sessionMeta;
|
|
110
|
+
appendMessageRecord(identity, turnId, { role: 'user', content: ctx.turn.request.message }, meta, undefined, ctx.turn.systemPrompt);
|
|
111
|
+
ctx.turn.messages = [...history, ...ctx.turn.messages];
|
|
112
|
+
ctx.turn.metadata._diskSessionWrittenMessages = new Set(ctx.turn.messages);
|
|
521
113
|
});
|
|
522
|
-
// ── beforeLLM: persist any mid-turn steering messages ─────
|
|
523
|
-
// When turn.steer() is called, agent.ts injects steering messages
|
|
524
|
-
// into turn.messages with a [steering] prefix. We persist them to disk
|
|
525
|
-
// right before the LLM runs to respond to them.
|
|
526
114
|
agent.hook('beforeLLM', 'disk-session', async (ctx) => {
|
|
527
|
-
const identity =
|
|
115
|
+
const identity = identityFromTurn(ctx.turn);
|
|
528
116
|
if (!identity)
|
|
529
117
|
return;
|
|
530
|
-
const
|
|
531
|
-
const
|
|
532
|
-
const
|
|
533
|
-
const
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
...toRecord(msg, messageTs, threadTs),
|
|
538
|
-
...(sessionMeta ? { meta: sessionMeta } : {}),
|
|
539
|
-
});
|
|
540
|
-
written?.add(msg);
|
|
118
|
+
const written = ctx.turn.metadata._diskSessionWrittenMessages;
|
|
119
|
+
const turnId = ctx.turn.metadata._diskSessionTurnId;
|
|
120
|
+
const meta = ctx.turn.metadata.sessionMeta;
|
|
121
|
+
for (const message of ctx.turn.messages) {
|
|
122
|
+
if (message.role === 'user' && !written?.has(message)) {
|
|
123
|
+
appendMessageRecord(identity, turnId, message, meta);
|
|
124
|
+
written?.add(message);
|
|
541
125
|
}
|
|
542
126
|
}
|
|
543
|
-
if (newSteering.length > 0) {
|
|
544
|
-
const records = store.get(key) ?? [];
|
|
545
|
-
records.push(...newSteering);
|
|
546
|
-
if (records.length > maxRecords) {
|
|
547
|
-
records.splice(0, records.length - maxRecords);
|
|
548
|
-
}
|
|
549
|
-
store.set(key, records);
|
|
550
|
-
appendToFile(key, newSteering);
|
|
551
|
-
}
|
|
552
127
|
});
|
|
553
|
-
// ── afterLLM: accumulate token usage + append assistant msg ──
|
|
554
|
-
// Each LLM call returns usage (prompt/completion tokens). We
|
|
555
|
-
// accumulate them across all iterations in the turn so the final
|
|
556
|
-
// assistant record has the total cost.
|
|
557
|
-
//
|
|
558
|
-
// We also append the assistant message (including tool calls) to
|
|
559
|
-
// the session log immediately, so the JSONL file is updated
|
|
560
|
-
// iteration by iteration rather than only at afterTurn. Tool
|
|
561
|
-
// results are appended in the afterTool hook below.
|
|
562
128
|
agent.hook('afterLLM', 'disk-session', async (ctx) => {
|
|
563
|
-
const identity =
|
|
564
|
-
|
|
129
|
+
const identity = identityFromTurn(ctx.turn);
|
|
130
|
+
const response = ctx.modelResponse;
|
|
131
|
+
if (!identity || !response?.message)
|
|
565
132
|
return;
|
|
566
|
-
const
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
iterations: prev.iterations + 1,
|
|
574
|
-
...(usage.cachedPromptTokens && {
|
|
575
|
-
cachedPromptTokens: (prev.cachedPromptTokens ?? 0) + usage.cachedPromptTokens,
|
|
576
|
-
}),
|
|
577
|
-
...(usage.reasoningTokens && {
|
|
578
|
-
reasoningTokens: (prev.reasoningTokens ?? 0) + usage.reasoningTokens,
|
|
579
|
-
}),
|
|
580
|
-
};
|
|
581
|
-
}
|
|
582
|
-
// ── Incremental append: write the assistant message now ──
|
|
583
|
-
// The agent core pushes the assistant message to turn.messages
|
|
584
|
-
// AFTER afterLLM fires, so we can't read it from there yet.
|
|
585
|
-
// Instead, we build the record from modelResponse.message.
|
|
586
|
-
const modelResponse = ctx.modelResponse;
|
|
587
|
-
if (modelResponse?.message) {
|
|
588
|
-
const sessionMeta = ctx.turn.metadata.sessionMeta;
|
|
589
|
-
const usage = modelResponse.usage;
|
|
590
|
-
const record = {
|
|
591
|
-
...toRecord(modelResponse.message, messageTs, threadTs),
|
|
592
|
-
...(usage ? { usage: {
|
|
593
|
-
promptTokens: usage.promptTokens,
|
|
594
|
-
completionTokens: usage.completionTokens,
|
|
595
|
-
iterations: 1,
|
|
596
|
-
...(usage.cachedPromptTokens !== undefined && { cachedPromptTokens: usage.cachedPromptTokens }),
|
|
597
|
-
...(usage.reasoningTokens !== undefined && { reasoningTokens: usage.reasoningTokens }),
|
|
598
|
-
} } : {}),
|
|
599
|
-
...(sessionMeta ? { meta: sessionMeta } : {}),
|
|
600
|
-
};
|
|
601
|
-
persistRecords(key, [record]);
|
|
602
|
-
}
|
|
133
|
+
const usage = response.usage ? {
|
|
134
|
+
promptTokens: response.usage.promptTokens,
|
|
135
|
+
completionTokens: response.usage.completionTokens,
|
|
136
|
+
...(response.usage.cachedPromptTokens !== undefined ? { cachedPromptTokens: response.usage.cachedPromptTokens } : {}),
|
|
137
|
+
...(response.usage.reasoningTokens !== undefined ? { reasoningTokens: response.usage.reasoningTokens } : {}),
|
|
138
|
+
} : undefined;
|
|
139
|
+
appendMessageRecord(identity, ctx.turn.metadata._diskSessionTurnId, response.message, ctx.turn.metadata.sessionMeta, usage);
|
|
603
140
|
});
|
|
604
|
-
// ── afterTool: append tool results incrementally ──────────────
|
|
605
|
-
// Each tool result is written to disk as soon as it completes,
|
|
606
|
-
// so the JSONL file reflects progress in real time.
|
|
607
141
|
agent.hook('afterTool', 'disk-session', async (ctx) => {
|
|
608
|
-
const identity =
|
|
609
|
-
if (!identity)
|
|
610
|
-
return;
|
|
611
|
-
const { key, messageTs, threadTs } = identity;
|
|
612
|
-
const toolResult = ctx.toolResult;
|
|
613
|
-
const toolCall = ctx.toolCall;
|
|
614
|
-
if (!toolResult || !toolCall)
|
|
142
|
+
const identity = identityFromTurn(ctx.turn);
|
|
143
|
+
if (!identity || !ctx.toolCall || !ctx.toolResult)
|
|
615
144
|
return;
|
|
616
|
-
|
|
617
|
-
const record = {
|
|
145
|
+
appendMessageRecord(identity, ctx.turn.metadata._diskSessionTurnId, {
|
|
618
146
|
role: 'tool',
|
|
619
|
-
content: toolResult.content,
|
|
620
|
-
toolCallId:
|
|
621
|
-
name: toolCall.name,
|
|
622
|
-
|
|
623
|
-
...(threadTs ? { threadTs } : {}),
|
|
624
|
-
recordedAt: new Date().toISOString(),
|
|
625
|
-
...(sessionMeta ? { meta: sessionMeta } : {}),
|
|
626
|
-
};
|
|
627
|
-
persistRecords(key, [record]);
|
|
147
|
+
content: ctx.toolResult.content,
|
|
148
|
+
toolCallId: ctx.toolCall.id,
|
|
149
|
+
name: ctx.toolCall.name,
|
|
150
|
+
}, ctx.turn.metadata.sessionMeta);
|
|
628
151
|
});
|
|
629
|
-
// ── afterTurn: finalize session turn ──────────────────────────
|
|
630
152
|
agent.hook('afterTurn', 'disk-session', async (ctx) => {
|
|
631
|
-
const identity =
|
|
153
|
+
const identity = identityFromTurn(ctx.turn);
|
|
632
154
|
if (!identity)
|
|
633
155
|
return;
|
|
634
|
-
const
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
// summary. Instead of appending to the old session, we:
|
|
640
|
-
// 1. Rename the old JSONL file with a timestamp suffix
|
|
641
|
-
// 2. Seed a new session with the summary as the first record
|
|
642
|
-
// The compaction request/response are already in the old file
|
|
643
|
-
// (written by beforeTurn + the normal append below would have
|
|
644
|
-
// run, but we return early before that).
|
|
645
|
-
const isCompacting = ctx.turn.request.metadata?.compacting === true;
|
|
646
|
-
if (isCompacting) {
|
|
647
|
-
handleCompaction(ctx.turn, key, messageTs);
|
|
648
|
-
return;
|
|
649
|
-
}
|
|
650
|
-
// ── Persist any trailing unwritten steering messages ─────
|
|
651
|
-
// (in case a steering message arrived and the turn halted before beforeLLM ran)
|
|
652
|
-
const written = ctx.turn.metadata['_diskSessionWrittenMessages'];
|
|
653
|
-
delete ctx.turn.metadata['_diskSessionWrittenMessages'];
|
|
654
|
-
const sessionMeta = ctx.turn.metadata.sessionMeta;
|
|
655
|
-
const unwrittenSteering = [];
|
|
656
|
-
for (const msg of ctx.turn.messages) {
|
|
657
|
-
if (msg.role === 'user' && (!written || !written.has(msg))) {
|
|
658
|
-
unwrittenSteering.push({
|
|
659
|
-
...toRecord(msg, messageTs, threadTs),
|
|
660
|
-
...(sessionMeta ? { meta: sessionMeta } : {}),
|
|
661
|
-
});
|
|
662
|
-
written?.add(msg);
|
|
156
|
+
const written = ctx.turn.metadata._diskSessionWrittenMessages;
|
|
157
|
+
for (const message of ctx.turn.messages) {
|
|
158
|
+
if (message.role === 'user' && !written?.has(message)) {
|
|
159
|
+
appendMessageRecord(identity, ctx.turn.metadata._diskSessionTurnId, message, ctx.turn.metadata.sessionMeta);
|
|
160
|
+
written?.add(message);
|
|
663
161
|
}
|
|
664
162
|
}
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
}
|
|
679
|
-
}
|
|
680
|
-
}
|
|
681
|
-
// Backfill system prompt on the last user record (in-memory only).
|
|
682
|
-
// The system prompt wasn't available at beforeTurn (beforeContext
|
|
683
|
-
// hooks hadn't run yet), so we update it now.
|
|
684
|
-
const finalSystemPrompt = ctx.turn.systemPrompt;
|
|
685
|
-
if (finalSystemPrompt) {
|
|
686
|
-
const records = store.get(key) ?? [];
|
|
687
|
-
for (let i = records.length - 1; i >= 0; i--) {
|
|
688
|
-
if (records[i].role === 'user') {
|
|
689
|
-
records[i].systemPrompt = finalSystemPrompt;
|
|
690
|
-
break;
|
|
691
|
-
}
|
|
692
|
-
}
|
|
693
|
-
}
|
|
694
|
-
// Trim in-memory cache (file keeps full history).
|
|
695
|
-
const records = store.get(key) ?? [];
|
|
696
|
-
if (records.length > maxRecords) {
|
|
697
|
-
records.splice(0, records.length - maxRecords);
|
|
698
|
-
store.set(key, records);
|
|
699
|
-
}
|
|
163
|
+
ledger.append({
|
|
164
|
+
kind: 'event',
|
|
165
|
+
event: 'turn-complete',
|
|
166
|
+
sessionKey: identity.key,
|
|
167
|
+
turnId: ctx.turn.metadata._diskSessionTurnId,
|
|
168
|
+
content: ctx.turn.response?.message ?? '',
|
|
169
|
+
systemPrompt: ctx.turn.systemPrompt,
|
|
170
|
+
finishReason: ctx.turn.response?.finishReason,
|
|
171
|
+
ts: identity.messageTs,
|
|
172
|
+
...(identity.threadTs ? { threadTs: identity.threadTs } : {}),
|
|
173
|
+
});
|
|
174
|
+
delete ctx.turn.metadata._diskSessionWrittenMessages;
|
|
175
|
+
delete ctx.turn.metadata._diskSessionTurnId;
|
|
700
176
|
});
|
|
701
|
-
// ── onError: context length backoff fallback ───────────────
|
|
702
|
-
// If the model provider rejects a request due to context length,
|
|
703
|
-
// step down to the next fallback limit (e.g. 100 -> 50 -> 25)
|
|
704
|
-
// and compact the session immediately.
|
|
705
177
|
agent.hook('onError', 'disk-session', async (ctx) => {
|
|
706
|
-
const identity =
|
|
178
|
+
const identity = identityFromTurn(ctx.turn);
|
|
707
179
|
if (!identity)
|
|
708
180
|
return;
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
const toEvict = convRecords.length - nextLimit;
|
|
720
|
-
let cut = Math.min(toEvict + Math.max(1, Math.floor(nextLimit / 2)), records.length - 1);
|
|
721
|
-
while (cut < records.length && records[cut].role !== 'user' && records[cut].role !== 'system') {
|
|
722
|
-
cut++;
|
|
723
|
-
}
|
|
724
|
-
if (cut > 0 && cut < records.length) {
|
|
725
|
-
const chunk = records.slice(0, cut);
|
|
726
|
-
const keep = records.slice(cut);
|
|
727
|
-
const summary = await generateSessionSummary(activeModel, chunk);
|
|
728
|
-
const summaryRecord = {
|
|
729
|
-
role: 'assistant',
|
|
730
|
-
content: `[Conversation summary — context limit backed off to ${nextLimit} messages ${new Date().toISOString()}]\n\n${summary}`,
|
|
731
|
-
ts: chunk[chunk.length - 1]?.ts || String(Date.now() / 1000),
|
|
732
|
-
recordedAt: new Date().toISOString(),
|
|
733
|
-
};
|
|
734
|
-
const compacted = [summaryRecord, ...keep];
|
|
735
|
-
store.set(key, compacted);
|
|
736
|
-
appendToFile(key, compacted);
|
|
737
|
-
}
|
|
738
|
-
}
|
|
739
|
-
catch { }
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
|
-
}
|
|
181
|
+
ledger.append({
|
|
182
|
+
kind: 'event',
|
|
183
|
+
event: 'turn-error',
|
|
184
|
+
sessionKey: identity.key,
|
|
185
|
+
turnId: ctx.turn.metadata._diskSessionTurnId,
|
|
186
|
+
content: ctx.error instanceof Error ? `${ctx.error.name}: ${ctx.error.message}` : String(ctx.error),
|
|
187
|
+
systemPrompt: ctx.turn.systemPrompt,
|
|
188
|
+
ts: identity.messageTs,
|
|
189
|
+
...(identity.threadTs ? { threadTs: identity.threadTs } : {}),
|
|
190
|
+
});
|
|
743
191
|
});
|
|
744
192
|
},
|
|
745
|
-
/** Get current effective message limit for a session (reflects any fallback backoff). */
|
|
746
|
-
getEffectiveLimit(sessionKey = 'default') {
|
|
747
|
-
return sessionLimits.get(sessionKey) ?? maxContextMessages;
|
|
748
|
-
},
|
|
749
|
-
/** Manually set effective message limit for a session. */
|
|
750
|
-
setEffectiveLimit(sessionKey, limit) {
|
|
751
|
-
sessionLimits.set(sessionKey, limit);
|
|
752
|
-
},
|
|
753
|
-
/** Get all records for a session. */
|
|
754
193
|
getRecords(sessionKey = 'default') {
|
|
755
|
-
return
|
|
194
|
+
return ledger.snapshot(sessionKey);
|
|
756
195
|
},
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
196
|
+
getSessions() {
|
|
197
|
+
return ledger.sessions();
|
|
198
|
+
},
|
|
199
|
+
appendControl(sessionKey, content, ts = String(Date.now() / 1000), threadTs) {
|
|
200
|
+
ledger.append({
|
|
201
|
+
kind: 'event',
|
|
202
|
+
event: 'control',
|
|
203
|
+
sessionKey,
|
|
765
204
|
content,
|
|
766
|
-
ts
|
|
205
|
+
ts,
|
|
767
206
|
...(threadTs ? { threadTs } : {}),
|
|
768
|
-
|
|
769
|
-
};
|
|
770
|
-
persistRecords(sessionKey, [record]);
|
|
207
|
+
});
|
|
771
208
|
},
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
* the channel discussed") rather than a message to respond to.
|
|
776
|
-
*
|
|
777
|
-
* Unlike `appendControl`, system records ARE included in the LLM
|
|
778
|
-
* context window — they appear in the conversation history when
|
|
779
|
-
* the agent is triggered, giving it awareness of recent channel
|
|
780
|
-
* activity without needing to be mentioned on every message.
|
|
781
|
-
*
|
|
782
|
-
* The host should include sender info in the content (e.g.
|
|
783
|
-
* `[U123]: hey anyone seen the invoice?`) so the agent can
|
|
784
|
-
* distinguish who said what. Additional metadata (files, blocks,
|
|
785
|
-
* etc.) goes in `opts.meta` and is persisted to the JSONL for
|
|
786
|
-
* audit/debugging but not shown to the LLM.
|
|
787
|
-
*/
|
|
788
|
-
appendMessage(sessionKey, content, opts) {
|
|
789
|
-
const record = {
|
|
209
|
+
appendMessage(sessionKey, content, opts = {}) {
|
|
210
|
+
ledger.append({
|
|
211
|
+
kind: 'message',
|
|
790
212
|
role: 'system',
|
|
213
|
+
sessionKey,
|
|
791
214
|
content,
|
|
792
|
-
ts: opts
|
|
793
|
-
...(opts
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
};
|
|
797
|
-
persistRecords(sessionKey, [record]);
|
|
798
|
-
},
|
|
799
|
-
/** List all session keys. */
|
|
800
|
-
getSessions() {
|
|
801
|
-
return Array.from(store.keys());
|
|
802
|
-
},
|
|
803
|
-
/** Clear a single session (memory + disk). */
|
|
804
|
-
clear(sessionKey = 'default') {
|
|
805
|
-
store.delete(sessionKey);
|
|
806
|
-
const path = filePath(sessionKey);
|
|
807
|
-
if (existsSync(path)) {
|
|
808
|
-
try {
|
|
809
|
-
writeFileSync(path, '');
|
|
810
|
-
}
|
|
811
|
-
catch {
|
|
812
|
-
// ignore
|
|
813
|
-
}
|
|
814
|
-
}
|
|
815
|
-
},
|
|
816
|
-
/** Clear all sessions. */
|
|
817
|
-
clearAll() {
|
|
818
|
-
for (const key of store.keys()) {
|
|
819
|
-
const path = filePath(key);
|
|
820
|
-
if (existsSync(path)) {
|
|
821
|
-
try {
|
|
822
|
-
writeFileSync(path, '');
|
|
823
|
-
}
|
|
824
|
-
catch {
|
|
825
|
-
// ignore
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
}
|
|
829
|
-
store.clear();
|
|
215
|
+
ts: opts.ts ?? String(Date.now() / 1000),
|
|
216
|
+
...(opts.threadTs ? { threadTs: opts.threadTs } : {}),
|
|
217
|
+
...(opts.meta ? { meta: opts.meta } : {}),
|
|
218
|
+
});
|
|
830
219
|
},
|
|
831
220
|
};
|
|
832
221
|
}
|