@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.
@@ -1,832 +1,221 @@
1
- import { readFileSync, appendFileSync, mkdirSync, readdirSync, writeFileSync, existsSync, renameSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import { pruneMessages } from 'ai';
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: `Please summarize this earlier conversation segment to preserve context:\n\n${transcript}`,
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
- return messageContentToText(response.message.content).trim();
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
- * Disk-backed session extension.
135
- *
136
- * ## Architecture
137
- *
138
- * **One JSONL file per session** (`<sessionKey>.jsonl`). Append-only
139
- * every message the agent sees (human or bot, top-level or threaded) is
140
- * appended. This log is the source of truth.
141
- *
142
- * **Snapshot at beforeTurn**: when a turn starts, the extension takes a
143
- * read-only snapshot of the session log at that moment. The turn runs
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
- // Drop trailing user messages that don't have a following assistant
304
- // response. This happens when a turn was interrupted (crash, halt,
305
- // kill -9). Including an unanswered user request from a prior session
306
- // confuses the agent — it sees a request it never responded to.
307
- function dropIncompleteTrailingTurn(records) {
308
- const result = [...records];
309
- while (result.length > 0 && result[result.length - 1].role === 'user') {
310
- result.pop();
311
- }
312
- return result;
313
- }
314
- function dropIncompleteTrailingMessages(messages) {
315
- const result = [...messages];
316
- while (result.length > 0 && result[result.length - 1].role === 'user') {
317
- result.pop();
318
- }
319
- return result;
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 activeModel = config?.model ?? agent.model;
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 = identityFromCtx(ctx);
66
+ const identity = identityFromTurn(ctx.turn);
421
67
  if (!identity)
422
68
  return;
423
- const { key, messageTs, threadTs, isDirect } = identity;
424
- const isDm = isDirect ?? false;
425
- const effectiveMax = sessionLimits.get(key) ?? maxContextMessages;
426
- // ── Auto-summarization ──────────────────────────────────
427
- // When conversation history reaches effectiveMax, auto-summarize the
428
- // oldest chunk so earlier context is retained without thrashing prompt cache.
429
- const liveRecords = store.get(key) ?? [];
430
- const convCount = liveRecords.filter(isConversationMessage).length;
431
- let autoCompacted = false;
432
- let compactedCount = 0;
433
- if (autoSummarize && activeModel && convCount >= effectiveMax) {
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 summaryText = await generateSessionSummary(activeModel, recordsToSummarize);
446
- if (summaryText) {
447
- const summaryRecord = {
448
- role: 'assistant',
449
- content: `[Conversation summary — prior history compacted ${new Date().toISOString()}]\n\n${summaryText}`,
450
- ts: recordsToSummarize[recordsToSummarize.length - 1]?.ts || String(Date.now() / 1000),
451
- recordedAt: new Date().toISOString(),
452
- };
453
- const compactedRecords = [summaryRecord, ...recordsToKeep];
454
- store.set(key, compactedRecords);
455
- const oldPath = filePath(key);
456
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
457
- const archivedName = `${key.replace(/[^a-zA-Z0-9_-]/g, '_')}_${timestamp}.jsonl`;
458
- const archivedPath = join(dir, archivedName);
459
- if (existsSync(oldPath)) {
460
- try {
461
- renameSync(oldPath, archivedPath);
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 (err) {
471
- console.warn('[disk-session] auto-summarization failed, using window slice:', err);
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
- // Snapshot: copy the current records (read-only).
476
- const currentRecords = store.get(key) ?? [];
477
- const snapshot = [...currentRecords];
478
- // Build the forked context from the snapshot.
479
- const history = buildContext(snapshot, threadTs, isDm, effectiveMax, contextEvictionStep);
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 = identityFromCtx(ctx);
115
+ const identity = identityFromTurn(ctx.turn);
528
116
  if (!identity)
529
117
  return;
530
- const { key, messageTs, threadTs } = identity;
531
- const written = ctx.turn.metadata['_diskSessionWrittenMessages'];
532
- const sessionMeta = ctx.turn.metadata.sessionMeta;
533
- const newSteering = [];
534
- for (const msg of ctx.turn.messages) {
535
- if (msg.role === 'user' && (!written || !written.has(msg))) {
536
- newSteering.push({
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 = identityFromCtx(ctx);
564
- if (!identity)
129
+ const identity = identityFromTurn(ctx.turn);
130
+ const response = ctx.modelResponse;
131
+ if (!identity || !response?.message)
565
132
  return;
566
- const { key, messageTs, threadTs } = identity;
567
- const usage = ctx.modelResponse?.usage;
568
- if (usage) {
569
- const prev = ctx.turn.metadata['_diskSessionUsage'] ?? { promptTokens: 0, completionTokens: 0, iterations: 0 };
570
- ctx.turn.metadata['_diskSessionUsage'] = {
571
- promptTokens: prev.promptTokens + usage.promptTokens,
572
- completionTokens: prev.completionTokens + usage.completionTokens,
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 = identityFromCtx(ctx);
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
- const sessionMeta = ctx.turn.metadata.sessionMeta;
617
- const record = {
145
+ appendMessageRecord(identity, ctx.turn.metadata._diskSessionTurnId, {
618
146
  role: 'tool',
619
- content: toolResult.content,
620
- toolCallId: toolResult.toolCallId,
621
- name: toolCall.name,
622
- ts: messageTs,
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 = identityFromCtx(ctx);
153
+ const identity = identityFromTurn(ctx.turn);
632
154
  if (!identity)
633
155
  return;
634
- const { key, messageTs, threadTs } = identity;
635
- delete ctx.turn.metadata['_diskSessionHistoryLen'];
636
- // ── Compaction: rotate the session file ──────────────────
637
- // When metadata.compacting is set, this turn was a compaction
638
- // request (triggered by /compact). The agent's response is the
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
- if (unwrittenSteering.length > 0) {
666
- persistRecords(key, unwrittenSteering);
667
- }
668
- // Pull accumulated usage for this turn (set by afterLLM hook).
669
- const usage = ctx.turn.metadata['_diskSessionUsage'];
670
- delete ctx.turn.metadata['_diskSessionUsage'];
671
- // Attach accumulated usage to the last assistant record in memory.
672
- if (usage) {
673
- const records = store.get(key) ?? [];
674
- for (let i = records.length - 1; i >= 0; i--) {
675
- if (records[i].role === 'assistant') {
676
- records[i].usage = usage;
677
- break;
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 = identityFromCtx(ctx);
178
+ const identity = identityFromTurn(ctx.turn);
707
179
  if (!identity)
708
180
  return;
709
- const { key } = identity;
710
- if (isContextLengthError(ctx.error)) {
711
- const currentLimit = sessionLimits.get(key) ?? maxContextMessages;
712
- const nextLimit = fallbackThresholds.find((t) => t < currentLimit);
713
- if (nextLimit && nextLimit > 0) {
714
- sessionLimits.set(key, nextLimit);
715
- const records = store.get(key) ?? [];
716
- const convRecords = records.filter(isConversationMessage);
717
- if (convRecords.length > nextLimit && activeModel) {
718
- try {
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 store.get(sessionKey) ?? [];
194
+ return ledger.snapshot(sessionKey);
756
195
  },
757
- /**
758
- * Append a control record (e.g. /halt) to the session log.
759
- * Control records are persisted to disk but never included in the
760
- * LLM context they're for audit/logging only.
761
- */
762
- appendControl(sessionKey, content, ts, threadTs) {
763
- const record = {
764
- role: 'control',
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: ts ?? String(Date.now() / 1000),
205
+ ts,
767
206
  ...(threadTs ? { threadTs } : {}),
768
- recordedAt: new Date().toISOString(),
769
- };
770
- persistRecords(sessionKey, [record]);
207
+ });
771
208
  },
772
- /**
773
- * Append a background message to the session log. Stored as a
774
- * `system` record so the agent sees it as context ("here's what
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?.ts ?? String(Date.now() / 1000),
793
- ...(opts?.threadTs ? { threadTs: opts.threadTs } : {}),
794
- recordedAt: new Date().toISOString(),
795
- ...(opts?.meta ? { meta: opts.meta } : {}),
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
  }