@inceptionstack/roundhouse 0.5.29 → 0.5.31

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/CHANGELOG.md CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  All notable changes to `@inceptionstack/roundhouse` are documented here.
4
4
 
5
+ ## [0.5.31] — 2026-05-14
6
+
7
+ ### Internal
8
+ - **Refactor: session-repair module split + DRY shared error-classifier helper.** Pure refactor, zero behavior change. Addresses 7 maintainability findings from the post-v0.5.30 review:
9
+ - Extracted `matchesErrorPatterns()` shared helper so `isContextOverflowError` and `isToolPairingError` no longer duplicate ~80% of their structure. Both classifiers now walk the `cause` chain (previously only the overflow classifier did — fixed divergent-change smell). Both share `looksLikeValidationError()` gating.
10
+ - Extracted `buildTrimmedEntries()` from `softResetSessionFile` and `attemptSoftResetRecovery()` from `flushMemoryThenCompact`. The lifecycle catch block is now ~25 lines of linear flow (classify → recover → log → persist) instead of ~60 lines with a nested try/catch.
11
+ - `MAX_CAUSE_CHAIN_DEPTH = 5` named constant.
12
+ - Split `src/agents/shared/session-repair.ts` (574 lines, two domains) into four focused files: `session-repair.ts` (81 lines, public surface), `session-soft-reset.ts`, `error-classifiers.ts`, `session-repair-internal.ts`. All public exports preserved via re-exports for backward compat.
13
+ - Introduced `SessionRepairResult` named type replacing anonymous `{entries, report}` shape (named to avoid collision with the existing `RepairResult` in `message-validator.ts`).
14
+ - 2 new regression tests for `isToolPairingError`'s now-fixed cause-chain walking. **536 tests passing.**
15
+
16
+ ## [0.5.30] — 2026-05-14
17
+
18
+ ### Fixed
19
+ - **Soft-reset robustness fixes from codex review of v0.5.29:**
20
+ - **P1 — byte-cap could cut mid-turn.** When `findSoftResetCutIndex()` hit the byte budget before reaching `keepRecentUserTurns`, it returned `i + 1` which could land on an assistant reply or toolResult whose user prompt was about to be dropped. The kept tail then started mid-turn and tool-pairing repair didn't fix that (only orphans, not turn boundaries). Fixed: byte-cap path now snaps to the most-recent user-message boundary we've walked through.
21
+ - **P2 — byte cap measured in JS code units, not real bytes.** `JSON.stringify(e).length` counts UTF-16 code units; non-ASCII content (emoji, CJK) overshot the advertised 250k ceiling 2–3x. Now uses `Buffer.byteLength(..., 'utf8')` end-to-end so reported `bytesAfter` and the cap decision both reflect actual file bytes.
22
+ - **P2 — trim + repair was not atomic end-to-end.** Old flow wrote the trimmed file, then called `repairSessionFile()` which re-backed-up the *already-trimmed* file and rewrote it again. A crash between the two writes left a partial state and lost the true original. Refactored: extracted `repairEntriesInMemory()` so trim + tool-pair repair compose in memory and land as a single backup + atomic rename.
23
+ - **P2 — `isContextOverflowError()` only inspected top-level `.message`.** Wrapped provider errors (`err.cause.message`, Bedrock `ValidationException` carrying overflow text in nested SDK fields) fell through to re-arming `pendingCompact` instead of triggering recovery. Now mirrors `isToolPairingError()`'s nested handling: walks the `cause` chain (bounded, cycle-safe) and stringify-searches gated on a 4xx/`ValidationException` shape so we don't false-positive on unrelated 5xx noise.
24
+ - 7 regression tests added (534 total passing): byte-cap user-boundary snap, UTF-8 byte accounting, single-atomic-write backup integrity, wrapped-cause classification, Bedrock validation classification, false-positive gating, circular-cause safety.
25
+
5
26
  ## [0.5.29] — 2026-05-14
6
27
 
7
28
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inceptionstack/roundhouse",
3
- "version": "0.5.29",
3
+ "version": "0.5.31",
4
4
  "type": "module",
5
5
  "description": "Multi-platform chat gateway that routes messages through a configured AI agent",
6
6
  "license": "MIT",
@@ -28,7 +28,9 @@ import {
28
28
 
29
29
  import type { AgentAdapter, AgentAdapterFactory, AgentMessage, AgentResponse, AgentStreamEvent, MessageContext } from "../../types";
30
30
  import { formatMessage, extractCustomMessage, customContentToText } from "./message-format";
31
- import { isToolPairingError, repairSessionFile, softResetSessionFile, type SoftResetReport } from "../shared/session-repair";
31
+ import { isToolPairingError } from "../shared/error-classifiers";
32
+ import { repairSessionFile } from "../shared/session-repair";
33
+ import { softResetSessionFile, type SoftResetReport } from "../shared/session-soft-reset";
32
34
  import { SESSIONS_DIR } from "../../config";
33
35
  import { DEBUG_STREAM, threadIdToDir } from "../../util";
34
36
 
@@ -0,0 +1,71 @@
1
+ export const MAX_CAUSE_CHAIN_DEPTH = 5;
2
+
3
+ interface ErrorPatternMatchOptions {
4
+ stringifyGate?: (err: unknown) => boolean;
5
+ }
6
+
7
+ /**
8
+ * Stringify-search gate: only walk serialized error fields when the error
9
+ * looks like a 4xx / Bedrock ValidationException. Avoids false-positives
10
+ * from unrelated 5xx noise that happens to contain trigger phrases.
11
+ */
12
+ function looksLikeValidationError(err: unknown): boolean {
13
+ const name = (err as { name?: string }).name ?? '';
14
+ const httpStatus =
15
+ (err as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode;
16
+ return name === 'ValidationException' || httpStatus === 400;
17
+ }
18
+
19
+ export function matchesErrorPatterns(
20
+ err: unknown,
21
+ patterns: RegExp[],
22
+ options: ErrorPatternMatchOptions = {},
23
+ ): boolean {
24
+ if (!err) return false;
25
+
26
+ const matches = (value: unknown): boolean => {
27
+ const message = (value as { message?: string }).message ?? String(value);
28
+ return patterns.some(pattern => pattern.test(message));
29
+ };
30
+
31
+ if (matches(err)) return true;
32
+
33
+ let current: unknown = (err as { cause?: unknown }).cause;
34
+ for (let depth = 0; depth < MAX_CAUSE_CHAIN_DEPTH && current; depth++) {
35
+ if (matches(current)) return true;
36
+ current = (current as { cause?: unknown }).cause;
37
+ }
38
+
39
+ if (!options.stringifyGate?.(err)) {
40
+ return false;
41
+ }
42
+
43
+ try {
44
+ const serialized = JSON.stringify(err);
45
+ return patterns.some(pattern => pattern.test(serialized));
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ export function isContextOverflowError(err: unknown): boolean {
52
+ const patterns = [
53
+ /prompt is too long/i,
54
+ /tokens?\s*[>>]\s*\d+\s*maximum/i,
55
+ /input is too long/i,
56
+ /context length exceeded/i,
57
+ /maximum context length/i,
58
+ ];
59
+ return matchesErrorPatterns(err, patterns, { stringifyGate: looksLikeValidationError });
60
+ }
61
+
62
+ export function isToolPairingError(err: unknown): boolean {
63
+ const patterns = [
64
+ /tool_use.*without.*tool_result/i,
65
+ /tool_result.*without.*tool_use/i,
66
+ /toolUse.*without.*toolResult/i,
67
+ /unmatched.*tool.?use/i,
68
+ /orphan.*tool/i,
69
+ ];
70
+ return matchesErrorPatterns(err, patterns, { stringifyGate: looksLikeValidationError });
71
+ }
@@ -0,0 +1,239 @@
1
+ import { readFileSync, writeFileSync, renameSync, existsSync, copyFileSync } from 'node:fs';
2
+ import { dirname, basename, join } from 'node:path';
3
+ import { validateToolPairing } from './message-validator';
4
+ import type { Message, ToolCall, AssistantMessage, ToolResultMessage } from '@earendil-works/pi-ai';
5
+
6
+ /** Minimal structural type for a pi-ai session file entry (we only touch message entries). */
7
+ export interface SessionFileEntry {
8
+ type: string;
9
+ id?: string;
10
+ parentId?: string | null;
11
+ message?: Message;
12
+ // other fields preserved as-is
13
+ [key: string]: unknown;
14
+ }
15
+
16
+ export interface SessionRepairReport {
17
+ repaired: boolean;
18
+ droppedEntryIds: string[];
19
+ droppedToolCallIds: string[];
20
+ droppedToolResultIds: string[];
21
+ backupPath?: string;
22
+ totalEntries: number;
23
+ }
24
+
25
+ export interface SessionRepairResult {
26
+ entries: SessionFileEntry[];
27
+ report: SessionRepairReport;
28
+ }
29
+
30
+ /** Parse a .jsonl session file. Tolerant of trailing blank lines. Throws on malformed JSON. */
31
+ export function parseSessionFile(path: string): SessionFileEntry[] {
32
+ const raw = readFileSync(path, 'utf8');
33
+ const lines = raw.split('\n');
34
+ const entries: SessionFileEntry[] = [];
35
+ for (let i = 0; i < lines.length; i++) {
36
+ const line = lines[i];
37
+ if (!line.trim()) continue;
38
+ try {
39
+ entries.push(JSON.parse(line) as SessionFileEntry);
40
+ } catch (err) {
41
+ throw new Error(`Session file parse error at line ${i + 1}: ${(err as Error).message}`);
42
+ }
43
+ }
44
+ return entries;
45
+ }
46
+
47
+ /**
48
+ * Extract `Message[]` from file entries in the order they appear.
49
+ * Only includes entries of type "message" (skips session header, model_change, etc).
50
+ */
51
+ function extractMessages(entries: SessionFileEntry[]): { messages: Message[]; entryIndex: number[] } {
52
+ const messages: Message[] = [];
53
+ const entryIndex: number[] = [];
54
+ for (let i = 0; i < entries.length; i++) {
55
+ const entry = entries[i];
56
+ if (entry.type === 'message' && entry.message) {
57
+ messages.push(entry.message);
58
+ entryIndex.push(i);
59
+ }
60
+ }
61
+ return { messages, entryIndex };
62
+ }
63
+
64
+ /**
65
+ * Re-parent children of dropped entries to preserve tree validity.
66
+ * If entry X is dropped and entry Y has parentId=X, set Y.parentId = X.parentId.
67
+ */
68
+ function reparentDroppedEntries(
69
+ entries: SessionFileEntry[],
70
+ droppedEntryIds: Set<string>
71
+ ): SessionFileEntry[] {
72
+ const entryById = new Map<string, SessionFileEntry>();
73
+ for (const entry of entries) {
74
+ if (entry.id) entryById.set(entry.id, entry);
75
+ }
76
+
77
+ const remap = new Map<string, string | null>();
78
+ const resolveAncestor = (id: string, visited: Set<string> = new Set()): string | null => {
79
+ if (remap.has(id)) return remap.get(id)!;
80
+ if (!droppedEntryIds.has(id)) return id;
81
+ if (visited.has(id)) {
82
+ remap.set(id, null);
83
+ return null;
84
+ }
85
+ visited.add(id);
86
+ const entry = entryById.get(id);
87
+ const parent = entry?.parentId ?? null;
88
+ const resolved = parent === null ? null : resolveAncestor(parent, visited);
89
+ remap.set(id, resolved);
90
+ return resolved;
91
+ };
92
+
93
+ const kept: SessionFileEntry[] = [];
94
+ for (const entry of entries) {
95
+ if (entry.id && droppedEntryIds.has(entry.id)) continue;
96
+ if (entry.parentId && droppedEntryIds.has(entry.parentId)) {
97
+ kept.push({ ...entry, parentId: resolveAncestor(entry.parentId) });
98
+ } else {
99
+ kept.push(entry);
100
+ }
101
+ }
102
+ return kept;
103
+ }
104
+
105
+ /**
106
+ * Compute the set of entry IDs to drop based on orphaned tool IDs.
107
+ *
108
+ * - Orphaned toolResult message → drop the whole entry
109
+ * - Orphaned toolCall inside an assistant message → drop the entry only if the
110
+ * toolCall was the *only* content block (otherwise keep the entry with the
111
+ * block stripped; handled separately in applyEntryEdits)
112
+ */
113
+ function findEntriesToDrop(
114
+ entries: SessionFileEntry[],
115
+ orphanedToolCallIds: Set<string>,
116
+ orphanedToolResultIds: Set<string>
117
+ ): { entriesToDrop: Set<string>; entriesToEdit: Map<string, string[]> } {
118
+ const entriesToDrop = new Set<string>();
119
+ const entriesToEdit = new Map<string, string[]>();
120
+
121
+ for (const entry of entries) {
122
+ if (entry.type !== 'message' || !entry.message || !entry.id) continue;
123
+ const message = entry.message;
124
+
125
+ if (message.role === 'toolResult') {
126
+ const toolResult = message as ToolResultMessage;
127
+ if (orphanedToolResultIds.has(toolResult.toolCallId)) {
128
+ entriesToDrop.add(entry.id);
129
+ }
130
+ continue;
131
+ }
132
+
133
+ if (message.role === 'assistant') {
134
+ const assistantMessage = message as AssistantMessage;
135
+ const orphanCallIds: string[] = [];
136
+ let hasNonOrphanContent = false;
137
+ for (const block of assistantMessage.content) {
138
+ if ((block as ToolCall).type === 'toolCall') {
139
+ const callId = (block as ToolCall).id;
140
+ if (orphanedToolCallIds.has(callId)) {
141
+ orphanCallIds.push(callId);
142
+ } else {
143
+ hasNonOrphanContent = true;
144
+ }
145
+ } else {
146
+ hasNonOrphanContent = true;
147
+ }
148
+ }
149
+ if (orphanCallIds.length === 0) continue;
150
+ if (hasNonOrphanContent) {
151
+ entriesToEdit.set(entry.id, orphanCallIds);
152
+ } else {
153
+ entriesToDrop.add(entry.id);
154
+ }
155
+ }
156
+ }
157
+
158
+ return { entriesToDrop, entriesToEdit };
159
+ }
160
+
161
+ /** Apply in-place edits to assistant entries: strip orphaned toolCall blocks. */
162
+ function applyEntryEdits(
163
+ entries: SessionFileEntry[],
164
+ entriesToEdit: Map<string, string[]>
165
+ ): SessionFileEntry[] {
166
+ if (entriesToEdit.size === 0) return entries;
167
+ return entries.map(entry => {
168
+ if (!entry.id || !entriesToEdit.has(entry.id)) return entry;
169
+ const orphanIds = new Set(entriesToEdit.get(entry.id)!);
170
+ const message = entry.message as AssistantMessage;
171
+ const cleanedContent = message.content.filter(block => {
172
+ if ((block as ToolCall).type === 'toolCall') {
173
+ return !orphanIds.has((block as ToolCall).id);
174
+ }
175
+ return true;
176
+ });
177
+ return { ...entry, message: { ...message, content: cleanedContent } };
178
+ });
179
+ }
180
+
181
+ /** Atomic write: tmp file + rename. Preserves partial-failure safety. */
182
+ export function atomicWrite(path: string, content: string): void {
183
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
184
+ writeFileSync(tmp, content, { encoding: 'utf8' });
185
+ renameSync(tmp, path);
186
+ }
187
+
188
+ /** Back up the original file before mutation. Returns the backup path. */
189
+ export function backupFile(path: string): string {
190
+ const ts = Date.now();
191
+ const backupPath = join(dirname(path), `${basename(path)}.bak-${ts}`);
192
+ copyFileSync(path, backupPath);
193
+ return backupPath;
194
+ }
195
+
196
+ /**
197
+ * Pure in-memory tool-pairing repair. Takes entries, returns repaired entries
198
+ * + a report. Does not touch the filesystem.
199
+ */
200
+ export function repairEntriesInMemory(entries: SessionFileEntry[]): SessionRepairResult {
201
+ const { messages } = extractMessages(entries);
202
+ const validation = validateToolPairing(messages);
203
+
204
+ if (validation.isValid) {
205
+ return {
206
+ entries,
207
+ report: {
208
+ repaired: false,
209
+ droppedEntryIds: [],
210
+ droppedToolCallIds: [],
211
+ droppedToolResultIds: [],
212
+ totalEntries: entries.length,
213
+ },
214
+ };
215
+ }
216
+
217
+ const orphanedCalls = new Set(validation.orphanedToolCallIds);
218
+ const orphanedResults = new Set(validation.orphanedToolResultIds);
219
+ const { entriesToDrop, entriesToEdit } = findEntriesToDrop(entries, orphanedCalls, orphanedResults);
220
+ const edited = applyEntryEdits(entries, entriesToEdit);
221
+ const kept = reparentDroppedEntries(edited, entriesToDrop);
222
+
223
+ return {
224
+ entries: kept,
225
+ report: {
226
+ repaired: true,
227
+ droppedEntryIds: Array.from(entriesToDrop),
228
+ droppedToolCallIds: validation.orphanedToolCallIds,
229
+ droppedToolResultIds: validation.orphanedToolResultIds,
230
+ totalEntries: entries.length,
231
+ },
232
+ };
233
+ }
234
+
235
+ export function assertSessionFileExists(path: string): void {
236
+ if (!existsSync(path)) {
237
+ throw new Error(`Session file not found: ${path}`);
238
+ }
239
+ }
@@ -10,10 +10,9 @@ import {
10
10
  parseSessionFile,
11
11
  inspectSessionFile,
12
12
  repairSessionFile,
13
- isToolPairingError,
14
- softResetSessionFile,
15
- isContextOverflowError,
16
13
  } from './session-repair';
14
+ import { isToolPairingError, isContextOverflowError } from './error-classifiers';
15
+ import { softResetSessionFile } from './session-soft-reset';
17
16
 
18
17
  // ---------- fixtures ----------
19
18
 
@@ -306,6 +305,24 @@ describe('session-repair', () => {
306
305
  expect(isToolPairingError(err)).toBe(true);
307
306
  });
308
307
 
308
+ it('matches wrapped Bedrock ValidationException through cause chain', () => {
309
+ const err = new Error('session resume failed', {
310
+ cause: Object.assign(new Error('Request failed with status 400'), {
311
+ name: 'ValidationException',
312
+ $metadata: { httpStatusCode: 400 },
313
+ cause: { message: 'messages.3: `tool_use` ids were found without `tool_result` blocks immediately after' },
314
+ }),
315
+ });
316
+ expect(isToolPairingError(err)).toBe(true);
317
+ });
318
+
319
+ it('matches wrapped tool pairing text from a nested cause without stringify fallback', () => {
320
+ const err = new Error('session resume failed', {
321
+ cause: new Error('toolUse id abc123 without matching toolResult'),
322
+ });
323
+ expect(isToolPairingError(err)).toBe(true);
324
+ });
325
+
309
326
  it('does not match unrelated 400s', () => {
310
327
  const err = new Error('Invalid model ID');
311
328
  expect(isToolPairingError(err)).toBe(false);
@@ -505,6 +522,104 @@ describe('softResetSessionFile', () => {
505
522
  expect(() => softResetSessionFile('/nonexistent/path.jsonl')).toThrow(/not found/);
506
523
  });
507
524
 
525
+ it('softResetSessionFile_ByteCapHit_SnapsToUserTurnBoundary_NeverStartsMidTurn', () => {
526
+ // Regression test for codex P1: byte-cap path used to return `i + 1`
527
+ // which could land mid-turn (assistant reply or toolResult with no user
528
+ // prompt above it). Fixed to snap to the most-recent user-message index.
529
+ // Arrange: many small turns, byte cap forces an early cut.
530
+ const entries: object[] = [HEADER, MODEL_CHANGE];
531
+ let parent: string | null = 'mc-1';
532
+ for (let i = 1; i <= 30; i++) {
533
+ entries.push(...userTurn(`t${i}`, parent));
534
+ parent = `t${i}a`;
535
+ }
536
+ const path = tmpJsonl(entries);
537
+
538
+ // Act: very tight byte budget so cap fires before keepRecentUserTurns reached.
539
+ const report = softResetSessionFile(path, { keepRecentUserTurns: 100, maxBytes: 600 });
540
+
541
+ // Assert: reset happened AND first kept entry is a user message.
542
+ expect(report.reset).toBe(true);
543
+ const trimmed = parseSessionFile(path);
544
+ expect(trimmed[0].type).toBe('session'); // header preserved
545
+ expect(trimmed[1].message?.role).toBe('user'); // first kept = user turn
546
+ expect(trimmed[1].parentId).toBeNull(); // re-parented
547
+ });
548
+
549
+ it('softResetSessionFile_NonAsciiContent_ReportedBytesMatchActualFileBytes', () => {
550
+ // Regression test for codex P2: trim used JSON.stringify(e).length
551
+ // (UTF-16 code units) but reported bytesAfter from real file bytes.
552
+ // After fix, both use Buffer.byteLength(..., 'utf8').
553
+ // Arrange: turns containing multi-byte UTF-8 (each emoji = 4 bytes,
554
+ // length 2 in code units — 2x discrepancy).
555
+ const entries: object[] = [HEADER, MODEL_CHANGE];
556
+ const emojis = '🚀🔥🎉✨💡'.repeat(20); // ~100 bytes per turn
557
+ let parent: string | null = 'mc-1';
558
+ for (let i = 1; i <= 20; i++) {
559
+ entries.push(
560
+ userMsg(`t${i}u`, parent, `${emojis} text-${i}`),
561
+ {
562
+ type: 'message', id: `t${i}a`, parentId: `t${i}u`,
563
+ timestamp: '2026-05-01T00:00:04Z',
564
+ message: {
565
+ role: 'assistant',
566
+ content: [{ type: 'text', text: `${emojis} reply-${i}` }],
567
+ api: 'bedrock-converse-stream', provider: 'amazon-bedrock', model: 'claude',
568
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
569
+ stopReason: 'endTurn', timestamp: 4,
570
+ },
571
+ },
572
+ );
573
+ parent = `t${i}a`;
574
+ }
575
+ const path = tmpJsonl(entries);
576
+
577
+ // Act
578
+ const report = softResetSessionFile(path, { keepRecentUserTurns: 100, maxBytes: 2000 });
579
+
580
+ // Assert: reported bytesAfter matches actual file bytes (true UTF-8 size).
581
+ expect(report.reset).toBe(true);
582
+ const actualBytes = readFileSync(path).length;
583
+ expect(report.bytesAfter).toBe(actualBytes);
584
+ // And we honored the cap (allow some slack for snap-to-user-boundary).
585
+ expect(report.bytesAfter).toBeLessThan(4000);
586
+ });
587
+
588
+ it('softResetSessionFile_OnSingleAtomicWrite_OriginalBackupIsRecoverable', () => {
589
+ // Regression test for codex P2: previously trim wrote once, then
590
+ // repairSessionFile() wrote again with its OWN backup of the
591
+ // already-trimmed file. After fix, only one backup exists and it's
592
+ // the true original.
593
+ // Arrange: session with orphaned tool pair so post-cut repair fires.
594
+ const oldToolCall = assistantToolCall('a-old', 'mc-1', 'call-X');
595
+ const orphanedResult = {
596
+ type: 'message', id: 'tr-1', parentId: 'a-old',
597
+ timestamp: '2026-05-01T00:00:05Z',
598
+ message: { role: 'toolResult', toolCallId: 'call-X', content: 'ok', timestamp: 5 },
599
+ };
600
+ const entries: object[] = [HEADER, MODEL_CHANGE, userMsg('u-old', 'mc-1', 'old'), oldToolCall];
601
+ let parent: string | null = 'a-old';
602
+ for (let i = 1; i <= 5; i++) {
603
+ entries.push(...userTurn(`f${i}`, parent));
604
+ parent = `f${i}a`;
605
+ }
606
+ entries.splice(6, 0, orphanedResult);
607
+ const path = tmpJsonl(entries);
608
+ const originalBytes = readFileSync(path);
609
+
610
+ // Act
611
+ const report = softResetSessionFile(path, { keepRecentUserTurns: 3 });
612
+
613
+ // Assert: backup contents = TRUE original (pre-trim, pre-repair),
614
+ // not an intermediate trimmed-but-unrepaired state.
615
+ expect(report.reset).toBe(true);
616
+ expect(report.backupPath).toBeDefined();
617
+ const backup = readFileSync(report.backupPath!);
618
+ expect(backup.equals(originalBytes)).toBe(true);
619
+ // Final on-disk file is internally consistent.
620
+ expect(inspectSessionFile(path).hasOrphans).toBe(false);
621
+ });
622
+
508
623
  it('softResetSessionFile_BytesCapHonored_StopsCutAtCap', () => {
509
624
  // Arrange: each turn is small but we set a tiny byte cap so we cut early.
510
625
  const entries: object[] = [HEADER, MODEL_CHANGE];
@@ -549,4 +664,46 @@ describe('isContextOverflowError', () => {
549
664
  expect(isContextOverflowError(undefined)).toBe(false);
550
665
  expect(isContextOverflowError({})).toBe(false);
551
666
  });
667
+
668
+ it('classifies overflow when text lives in err.cause.message (wrapped SDK error)', () => {
669
+ // Regression test for codex P2: wrapped provider errors used to fall
670
+ // through to re-arming pendingCompact. After fix, cause-chain is walked.
671
+ const inner = new Error('prompt is too long: 212776 tokens > 200000 maximum');
672
+ const outer = new Error('Summarization failed');
673
+ (outer as { cause?: unknown }).cause = inner;
674
+ expect(isContextOverflowError(outer)).toBe(true);
675
+ });
676
+
677
+ it('classifies overflow on Bedrock ValidationException with nested overflow text', () => {
678
+ // Regression test: Bedrock SDK can carry the useful text in nested
679
+ // $metadata or stringify-only fields. We only stringify-search when
680
+ // the error LOOKS like a 4xx validation (mirrors isToolPairingError).
681
+ const err = Object.assign(new Error('validation failed'), {
682
+ name: 'ValidationException',
683
+ $metadata: { httpStatusCode: 400 },
684
+ detail: { reason: 'prompt is too long' },
685
+ });
686
+ expect(isContextOverflowError(err)).toBe(true);
687
+ });
688
+
689
+ it('does NOT stringify-search arbitrary errors that contain overflow keywords', () => {
690
+ // Negative case: gating prevents false-positives on unrelated 5xx errors
691
+ // whose payload happens to contain trigger phrases.
692
+ const err = Object.assign(new Error('internal error'), {
693
+ name: 'InternalServerError',
694
+ $metadata: { httpStatusCode: 500 },
695
+ diagnostics: 'log line: prompt is too long check disabled',
696
+ });
697
+ expect(isContextOverflowError(err)).toBe(false);
698
+ });
699
+
700
+ it('does not loop forever on circular cause chains', () => {
701
+ // Safety: cause walk is bounded.
702
+ const a = new Error('outer');
703
+ const b = new Error('inner');
704
+ (a as { cause?: unknown }).cause = b;
705
+ (b as { cause?: unknown }).cause = a; // cycle
706
+ expect(() => isContextOverflowError(a)).not.toThrow();
707
+ expect(isContextOverflowError(a)).toBe(false);
708
+ });
552
709
  });
@@ -1,216 +1,33 @@
1
1
  /**
2
- * session-repair.ts — File-level session repair for corrupted pi-ai session files.
3
- *
4
- * Pi-ai persists sessions as JSONL at ~/.roundhouse/sessions/<thread>/<id>.jsonl.
5
- * Each line is a `FileEntry` in a tree (parentId links). Message entries wrap
6
- * pi-ai `Message` objects (role: user | assistant | toolResult).
7
- *
8
- * Corruption scenarios (mid-session):
9
- * - Tool execution aborted → toolCall entry written, toolResult never lands
10
- * - Process crash between tool completion and result persist
11
- * - Manual Ctrl-C mid-tool
12
- *
13
- * On next resume, pi-ai loads these entries → sends history to the model →
14
- * model rejects with "toolUse without toolResult" (Bedrock/Anthropic 400).
15
- *
16
- * This module detects and repairs orphaned tool pairs at the file level,
17
- * preserving the parentId tree by re-parenting children of dropped entries.
18
- *
19
- * Delegates tool-pairing logic to message-validator.ts.
20
- */
21
-
22
- import { readFileSync, writeFileSync, renameSync, existsSync, copyFileSync } from 'node:fs';
23
- import { dirname, basename, join } from 'node:path';
24
- import { validateToolPairing } from './message-validator.js';
25
- import type { Message, ToolCall, AssistantMessage, ToolResultMessage } from '@earendil-works/pi-ai';
26
-
27
- /** Minimal structural type for a pi-ai session file entry (we only touch message entries). */
28
- interface SessionFileEntry {
29
- type: string;
30
- id?: string;
31
- parentId?: string | null;
32
- message?: Message;
33
- // other fields preserved as-is
34
- [key: string]: unknown;
35
- }
36
-
37
- export interface SessionRepairReport {
38
- repaired: boolean;
39
- droppedEntryIds: string[];
40
- droppedToolCallIds: string[];
41
- droppedToolResultIds: string[];
42
- backupPath?: string;
43
- totalEntries: number;
44
- }
45
-
46
- /** Parse a .jsonl session file. Tolerant of trailing blank lines. Throws on malformed JSON. */
47
- export function parseSessionFile(path: string): SessionFileEntry[] {
48
- const raw = readFileSync(path, 'utf8');
49
- const lines = raw.split('\n');
50
- const entries: SessionFileEntry[] = [];
51
- for (let i = 0; i < lines.length; i++) {
52
- const line = lines[i];
53
- if (!line.trim()) continue;
54
- try {
55
- entries.push(JSON.parse(line) as SessionFileEntry);
56
- } catch (err) {
57
- throw new Error(`Session file parse error at line ${i + 1}: ${(err as Error).message}`);
58
- }
59
- }
60
- return entries;
61
- }
62
-
63
- /**
64
- * Extract `Message[]` from file entries in the order they appear.
65
- * Only includes entries of type "message" (skips session header, model_change, etc).
66
- */
67
- function extractMessages(entries: SessionFileEntry[]): { messages: Message[]; entryIndex: number[] } {
68
- const messages: Message[] = [];
69
- const entryIndex: number[] = []; // parallel array: messages[i] came from entries[entryIndex[i]]
70
- for (let i = 0; i < entries.length; i++) {
71
- const e = entries[i];
72
- if (e.type === 'message' && e.message) {
73
- messages.push(e.message);
74
- entryIndex.push(i);
75
- }
76
- }
77
- return { messages, entryIndex };
78
- }
79
-
80
- /**
81
- * Re-parent children of dropped entries to preserve tree validity.
82
- * If entry X is dropped and entry Y has parentId=X, set Y.parentId = X.parentId.
83
- */
84
- function reparentDroppedEntries(
85
- entries: SessionFileEntry[],
86
- droppedEntryIds: Set<string>
87
- ): SessionFileEntry[] {
88
- // Build a map: droppedId → nearest non-dropped ancestor (walk up the tree)
89
- const entryById = new Map<string, SessionFileEntry>();
90
- for (const e of entries) {
91
- if (e.id) entryById.set(e.id, e);
92
- }
93
-
94
- const remap = new Map<string, string | null>();
95
- const resolveAncestor = (id: string, visited: Set<string> = new Set()): string | null => {
96
- if (remap.has(id)) return remap.get(id)!;
97
- if (!droppedEntryIds.has(id)) return id;
98
- if (visited.has(id)) {
99
- // Cycle in parentId chain (self-parent or loop) — bail with null rather than
100
- // blow the stack. Should never happen in a well-formed session file.
101
- remap.set(id, null);
102
- return null;
103
- }
104
- visited.add(id);
105
- const e = entryById.get(id);
106
- const parent = e?.parentId ?? null;
107
- const resolved = parent === null ? null : resolveAncestor(parent, visited);
108
- remap.set(id, resolved);
109
- return resolved;
110
- };
111
-
112
- const kept: SessionFileEntry[] = [];
113
- for (const e of entries) {
114
- if (e.id && droppedEntryIds.has(e.id)) continue;
115
- if (e.parentId && droppedEntryIds.has(e.parentId)) {
116
- kept.push({ ...e, parentId: resolveAncestor(e.parentId) });
117
- } else {
118
- kept.push(e);
119
- }
120
- }
121
- return kept;
122
- }
123
-
124
- /**
125
- * Compute the set of entry IDs to drop based on orphaned tool IDs.
126
- *
127
- * - Orphaned toolResult message → drop the whole entry
128
- * - Orphaned toolCall inside an assistant message → drop the entry only if the
129
- * toolCall was the *only* content block (otherwise keep the entry with the
130
- * block stripped; handled separately in applyEntryEdits)
2
+ * session-repair.ts — File-level repair for orphaned toolCall/toolResult pairs.
131
3
  */
132
- function findEntriesToDrop(
133
- entries: SessionFileEntry[],
134
- orphanedToolCallIds: Set<string>,
135
- orphanedToolResultIds: Set<string>
136
- ): { entriesToDrop: Set<string>; entriesToEdit: Map<string, string[]> } {
137
- const entriesToDrop = new Set<string>();
138
- const entriesToEdit = new Map<string, string[]>(); // entryId → toolCallIds to strip
139
-
140
- for (const e of entries) {
141
- if (e.type !== 'message' || !e.message || !e.id) continue;
142
- const msg = e.message;
143
4
 
144
- if (msg.role === 'toolResult') {
145
- const tr = msg as ToolResultMessage;
146
- if (orphanedToolResultIds.has(tr.toolCallId)) {
147
- entriesToDrop.add(e.id);
148
- }
149
- continue;
150
- }
151
-
152
- if (msg.role === 'assistant') {
153
- const am = msg as AssistantMessage;
154
- const orphanCallIds: string[] = [];
155
- let hasNonOrphanContent = false;
156
- for (const block of am.content) {
157
- if ((block as ToolCall).type === 'toolCall') {
158
- const callId = (block as ToolCall).id;
159
- if (orphanedToolCallIds.has(callId)) {
160
- orphanCallIds.push(callId);
161
- } else {
162
- hasNonOrphanContent = true;
163
- }
164
- } else {
165
- hasNonOrphanContent = true;
166
- }
167
- }
168
- if (orphanCallIds.length === 0) continue;
169
- if (hasNonOrphanContent) {
170
- entriesToEdit.set(e.id, orphanCallIds);
171
- } else {
172
- entriesToDrop.add(e.id);
173
- }
174
- }
175
- }
176
-
177
- return { entriesToDrop, entriesToEdit };
178
- }
179
-
180
- /** Apply in-place edits to assistant entries: strip orphaned toolCall blocks. */
181
- function applyEntryEdits(
182
- entries: SessionFileEntry[],
183
- entriesToEdit: Map<string, string[]>
184
- ): SessionFileEntry[] {
185
- if (entriesToEdit.size === 0) return entries;
186
- return entries.map(e => {
187
- if (!e.id || !entriesToEdit.has(e.id)) return e;
188
- const orphanIds = new Set(entriesToEdit.get(e.id)!);
189
- const msg = e.message as AssistantMessage;
190
- const cleanedContent = msg.content.filter(block => {
191
- if ((block as ToolCall).type === 'toolCall') {
192
- return !orphanIds.has((block as ToolCall).id);
193
- }
194
- return true;
195
- });
196
- return { ...e, message: { ...msg, content: cleanedContent } };
197
- });
198
- }
199
-
200
- /** Atomic write: tmp file + rename. Preserves partial-failure safety. */
201
- function atomicWrite(path: string, content: string): void {
202
- const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
203
- writeFileSync(tmp, content, { encoding: 'utf8' });
204
- renameSync(tmp, path);
205
- }
206
-
207
- /** Back up the original file before mutation. Returns the backup path. */
208
- function backupFile(path: string): string {
209
- const ts = Date.now();
210
- const backupPath = join(dirname(path), `${basename(path)}.bak-${ts}`);
211
- copyFileSync(path, backupPath);
212
- return backupPath;
213
- }
5
+ import { validateToolPairing } from './message-validator';
6
+ import {
7
+ assertSessionFileExists,
8
+ atomicWrite,
9
+ backupFile,
10
+ parseSessionFile,
11
+ repairEntriesInMemory,
12
+ } from './session-repair-internal';
13
+
14
+ export { parseSessionFile } from './session-repair-internal';
15
+ export type {
16
+ SessionRepairResult,
17
+ SessionFileEntry,
18
+ SessionRepairReport,
19
+ } from './session-repair-internal';
20
+ export {
21
+ MAX_CAUSE_CHAIN_DEPTH,
22
+ isContextOverflowError,
23
+ isToolPairingError,
24
+ matchesErrorPatterns,
25
+ } from './error-classifiers';
26
+ export {
27
+ softResetSessionFile,
28
+ type SoftResetOptions,
29
+ type SoftResetReport,
30
+ } from './session-soft-reset';
214
31
 
215
32
  /**
216
33
  * Validate a session file for orphaned tool pairs without modifying it.
@@ -224,7 +41,9 @@ export function inspectSessionFile(path: string): {
224
41
  totalMessages: number;
225
42
  } {
226
43
  const entries = parseSessionFile(path);
227
- const { messages } = extractMessages(entries);
44
+ const messages = entries
45
+ .filter(entry => entry.type === 'message' && entry.message)
46
+ .map(entry => entry.message!);
228
47
  const validation = validateToolPairing(messages);
229
48
  return {
230
49
  hasOrphans: !validation.isValid,
@@ -246,258 +65,17 @@ export function inspectSessionFile(path: string): {
246
65
  *
247
66
  * @returns report describing what was repaired
248
67
  */
249
- export function repairSessionFile(path: string): SessionRepairReport {
250
- if (!existsSync(path)) {
251
- throw new Error(`Session file not found: ${path}`);
252
- }
253
-
254
- const entries = parseSessionFile(path);
255
- const { messages } = extractMessages(entries);
256
- const validation = validateToolPairing(messages);
257
-
258
- if (validation.isValid) {
259
- return {
260
- repaired: false,
261
- droppedEntryIds: [],
262
- droppedToolCallIds: [],
263
- droppedToolResultIds: [],
264
- totalEntries: entries.length,
265
- };
266
- }
267
-
268
- const orphanedCalls = new Set(validation.orphanedToolCallIds);
269
- const orphanedResults = new Set(validation.orphanedToolResultIds);
270
-
271
- const { entriesToDrop, entriesToEdit } = findEntriesToDrop(entries, orphanedCalls, orphanedResults);
272
- const edited = applyEntryEdits(entries, entriesToEdit);
273
- const kept = reparentDroppedEntries(edited, entriesToDrop);
274
-
275
- const backupPath = backupFile(path);
276
- const newContent = kept.map(e => JSON.stringify(e)).join('\n') + '\n';
277
- atomicWrite(path, newContent);
278
-
279
- return {
280
- repaired: true,
281
- droppedEntryIds: Array.from(entriesToDrop),
282
- droppedToolCallIds: validation.orphanedToolCallIds,
283
- droppedToolResultIds: validation.orphanedToolResultIds,
284
- backupPath,
285
- totalEntries: entries.length,
286
- };
287
- }
288
-
289
- // ── Soft reset (recovery from already-overflowed sessions) ──────────────
290
-
291
- /**
292
- * When a session has grown past the model's context window, normal compact
293
- * cannot recover — the summarizer prompt itself overflows. Soft reset trims
294
- * the session jsonl on disk to its most-recent N user turns, drops everything
295
- * older, and re-runs the tool-pairing repair so what's left is internally
296
- * consistent.
297
- *
298
- * Trade-off: loses fidelity for older turns. The roundhouse memory layer
299
- * (MEMORY.md, daily front-page) re-injects on the next turn, so the agent
300
- * still has its durable context — just not the verbatim message history.
301
- *
302
- * Conservative defaults aim for ~30–40% of a 200k window so the next compact
303
- * has ample room to summarize.
304
- */
305
- export interface SoftResetOptions {
306
- /** Keep at most this many user turns from the tail (default: 8). */
307
- keepRecentUserTurns?: number;
308
- /** Hard cap on jsonl bytes after trim (default: 250_000 ≈ 60–80k tokens). */
309
- maxBytes?: number;
310
- }
311
-
312
- export interface SoftResetReport {
313
- reset: boolean;
314
- reason: string;
315
- entriesBefore: number;
316
- entriesAfter: number;
317
- bytesBefore: number;
318
- bytesAfter: number;
319
- backupPath?: string;
320
- /** Tool-pairing repair report on the trimmed file (orphans created by the cut). */
321
- postRepair?: SessionRepairReport;
322
- }
323
-
324
- /**
325
- * Find a safe cut index in the entries array. Walk backwards from the end
326
- * looking for user message entries; the cut sits *just before* the Nth
327
- * most-recent user message we encounter. Returns the index of the first
328
- * entry to KEEP (i.e. all entries[0..cutIdx) are dropped).
329
- *
330
- * If we can't find enough user messages, returns 1 to keep everything except
331
- * the session header (which we preserve separately).
332
- */
333
- function findSoftResetCutIndex(
334
- entries: SessionFileEntry[],
335
- keepRecentUserTurns: number,
336
- maxBytes: number,
337
- ): { cutIdx: number; reason: string } {
338
- let userTurnsSeen = 0;
339
- let bytesAccumulated = 0;
340
- // Scan tail-to-head, stop when we've collected enough user turns OR exceeded byte budget.
341
- for (let i = entries.length - 1; i >= 0; i--) {
342
- const e = entries[i];
343
- bytesAccumulated += JSON.stringify(e).length + 1; // +1 for newline
344
- if (e.type === 'message' && e.message?.role === 'user') {
345
- userTurnsSeen++;
346
- if (userTurnsSeen >= keepRecentUserTurns) {
347
- return { cutIdx: i, reason: `kept-${userTurnsSeen}-user-turns` };
348
- }
349
- }
350
- // Byte cap is a safety net for sessions where a single turn is enormous
351
- // (e.g. one turn dumped a 200k file). Stop once we'd exceed the cap.
352
- if (bytesAccumulated > maxBytes && userTurnsSeen > 0) {
353
- return { cutIdx: i + 1, reason: `byte-cap-${bytesAccumulated}b` };
354
- }
355
- }
356
- // Not enough user turns in the file — keep everything except header.
357
- // (Header is always at index 0 and is preserved by the writer separately.)
358
- return { cutIdx: 1, reason: 'fewer-turns-than-target' };
359
- }
360
-
361
- /**
362
- * Soft-reset a pi-ai session jsonl: keep the most-recent N user turns + their
363
- * surrounding messages, drop everything older. Always preserves the session
364
- * header (entries[0]). Re-parents the first kept entry to null so the tree
365
- * remains valid. Re-runs tool-pairing repair on the trimmed file because
366
- * the cut likely orphaned some toolCall/toolResult pairs.
367
- *
368
- * Atomic + backup: same safety pattern as repairSessionFile.
369
- *
370
- * @returns report describing what was reset, or `{reset:false}` if nothing to do.
371
- */
372
- export function softResetSessionFile(
373
- path: string,
374
- options: SoftResetOptions = {},
375
- ): SoftResetReport {
376
- if (!existsSync(path)) {
377
- throw new Error(`Session file not found: ${path}`);
378
- }
379
-
380
- const keepRecentUserTurns = options.keepRecentUserTurns ?? 8;
381
- const maxBytes = options.maxBytes ?? 250_000;
68
+ export function repairSessionFile(path: string) {
69
+ assertSessionFileExists(path);
382
70
 
383
71
  const entries = parseSessionFile(path);
384
- const bytesBefore = readFileSync(path).length;
385
-
386
- // Need at least header + a couple of messages to be worth resetting.
387
- if (entries.length < 4) {
388
- return {
389
- reset: false,
390
- reason: 'session-too-small',
391
- entriesBefore: entries.length,
392
- entriesAfter: entries.length,
393
- bytesBefore,
394
- bytesAfter: bytesBefore,
395
- };
396
- }
397
-
398
- const { cutIdx, reason } = findSoftResetCutIndex(entries, keepRecentUserTurns, maxBytes);
399
-
400
- // No-op if cut is already at the start (nothing to drop besides header).
401
- if (cutIdx <= 1) {
402
- return {
403
- reset: false,
404
- reason: `cut-at-start (${reason})`,
405
- entriesBefore: entries.length,
406
- entriesAfter: entries.length,
407
- bytesBefore,
408
- bytesAfter: bytesBefore,
409
- };
410
- }
72
+ const { entries: repaired, report } = repairEntriesInMemory(entries);
411
73
 
412
- // Build trimmed entries: header + tail.
413
- // Re-parent the first kept tail entry to null so the tree root is intact.
414
- const header = entries[0];
415
- const tail = entries.slice(cutIdx);
416
- if (tail.length > 0 && tail[0].parentId !== undefined) {
417
- tail[0] = { ...tail[0], parentId: null };
418
- }
419
- const trimmed = [header, ...tail];
74
+ if (!report.repaired) return report;
420
75
 
421
76
  const backupPath = backupFile(path);
422
- const newContent = trimmed.map(e => JSON.stringify(e)).join('\n') + '\n';
77
+ const newContent = repaired.map(entry => JSON.stringify(entry)).join('\n') + '\n';
423
78
  atomicWrite(path, newContent);
424
79
 
425
- // The cut may have orphaned tool pairs (e.g. toolResult kept but its
426
- // toolCall is now in the dropped section). Run repair to clean those up.
427
- const postRepair = repairSessionFile(path);
428
-
429
- const bytesAfter = readFileSync(path).length;
430
- return {
431
- reset: true,
432
- reason,
433
- entriesBefore: entries.length,
434
- entriesAfter: trimmed.length - postRepair.droppedEntryIds.length,
435
- bytesBefore,
436
- bytesAfter,
437
- backupPath,
438
- postRepair,
439
- };
440
- }
441
-
442
- // ── Error classifiers ────────────────────────────────────────────────────
443
-
444
- /**
445
- * Detect whether an error from pi-ai / the model provider indicates the
446
- * session has grown past the model's context window (input > max).
447
- *
448
- * Triggers soft-reset recovery in the memory lifecycle. Intentionally narrow:
449
- * only matches the well-known overflow phrasings, not generic 4xx errors.
450
- */
451
- export function isContextOverflowError(err: unknown): boolean {
452
- if (!err) return false;
453
- const msg = (err as { message?: string }).message ?? String(err);
454
- const patterns = [
455
- /prompt is too long/i,
456
- /tokens?\s*[>>]\s*\d+\s*maximum/i,
457
- /input is too long/i,
458
- /context length exceeded/i,
459
- /maximum context length/i,
460
- ];
461
- return patterns.some(p => p.test(msg));
462
- }
463
-
464
- /**
465
- * Detect whether an error from pi-ai / the model provider indicates a
466
- * tool-pairing mismatch that can be recovered by session repair.
467
- *
468
- * Matches Bedrock Converse and Anthropic error shapes. Intentionally narrow —
469
- * we don't want to repair on unrelated 400s.
470
- */
471
- export function isToolPairingError(err: unknown): boolean {
472
- if (!err) return false;
473
- const msg = (err as { message?: string }).message ?? String(err);
474
- const name = (err as { name?: string }).name ?? '';
475
-
476
- // Bedrock Converse: "messages.N: `tool_use` ids were found without `tool_result` blocks..."
477
- // Anthropic direct: similar phrasing
478
- const patterns = [
479
- /tool_use.*without.*tool_result/i,
480
- /tool_result.*without.*tool_use/i,
481
- /toolUse.*without.*toolResult/i,
482
- /unmatched.*tool.?use/i,
483
- /orphan.*tool/i,
484
- ];
485
-
486
- if (patterns.some(p => p.test(msg))) return true;
487
-
488
- // Bedrock ValidationException may carry the pairing text in nested fields
489
- // (e.g. err.cause.message, $metadata). Only stringify-search when the error
490
- // *looks* like a Bedrock validation error — avoid noisy matches on unrelated
491
- // messages that happen to contain '400'.
492
- const httpStatus =
493
- (err as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode;
494
- if (name === 'ValidationException' || httpStatus === 400) {
495
- try {
496
- const full = JSON.stringify(err);
497
- if (patterns.some(p => p.test(full))) return true;
498
- } catch {
499
- /* circular structure — give up */
500
- }
501
- }
502
- return false;
80
+ return { ...report, backupPath };
503
81
  }
@@ -0,0 +1,120 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import {
3
+ assertSessionFileExists,
4
+ atomicWrite,
5
+ backupFile,
6
+ parseSessionFile,
7
+ repairEntriesInMemory,
8
+ type SessionFileEntry,
9
+ type SessionRepairReport,
10
+ } from './session-repair-internal';
11
+
12
+ export interface SoftResetOptions {
13
+ /** Keep at most this many user turns from the tail (default: 8). */
14
+ keepRecentUserTurns?: number;
15
+ /** Hard cap on jsonl bytes after trim (default: 250_000 ≈ 60–80k tokens). */
16
+ maxBytes?: number;
17
+ }
18
+
19
+ export interface SoftResetReport {
20
+ reset: boolean;
21
+ reason: string;
22
+ entriesBefore: number;
23
+ entriesAfter: number;
24
+ bytesBefore: number;
25
+ bytesAfter: number;
26
+ backupPath?: string;
27
+ /** Tool-pairing repair report on the trimmed file (orphans created by the cut). */
28
+ postRepair?: SessionRepairReport;
29
+ }
30
+
31
+ function findSoftResetCutIndex(
32
+ entries: SessionFileEntry[],
33
+ keepRecentUserTurns: number,
34
+ maxBytes: number,
35
+ ): { cutIdx: number; reason: string } {
36
+ let userTurnsSeen = 0;
37
+ let bytesAccumulated = 0;
38
+ let lastUserIdx = -1;
39
+
40
+ for (let i = entries.length - 1; i >= 0; i--) {
41
+ const entry = entries[i];
42
+ bytesAccumulated += Buffer.byteLength(JSON.stringify(entry), 'utf8') + 1;
43
+ if (entry.type === 'message' && entry.message?.role === 'user') {
44
+ userTurnsSeen++;
45
+ lastUserIdx = i;
46
+ if (userTurnsSeen >= keepRecentUserTurns) {
47
+ return { cutIdx: i, reason: `kept-${userTurnsSeen}-user-turns` };
48
+ }
49
+ }
50
+ if (bytesAccumulated > maxBytes && userTurnsSeen > 0) {
51
+ return { cutIdx: lastUserIdx, reason: `byte-cap-${bytesAccumulated}b` };
52
+ }
53
+ }
54
+
55
+ return { cutIdx: 1, reason: 'fewer-turns-than-target' };
56
+ }
57
+
58
+ function buildTrimmedEntries(entries: SessionFileEntry[], cutIdx: number): SessionFileEntry[] {
59
+ const header = entries[0];
60
+ const tail = entries.slice(cutIdx);
61
+ if (tail.length > 0 && tail[0].parentId !== undefined) {
62
+ tail[0] = { ...tail[0], parentId: null };
63
+ }
64
+ return [header, ...tail];
65
+ }
66
+
67
+ export function softResetSessionFile(
68
+ path: string,
69
+ options: SoftResetOptions = {},
70
+ ): SoftResetReport {
71
+ assertSessionFileExists(path);
72
+
73
+ const keepRecentUserTurns = options.keepRecentUserTurns ?? 8;
74
+ const maxBytes = options.maxBytes ?? 250_000;
75
+
76
+ const entries = parseSessionFile(path);
77
+ const bytesBefore = readFileSync(path).length;
78
+
79
+ if (entries.length < 4) {
80
+ return {
81
+ reset: false,
82
+ reason: 'session-too-small',
83
+ entriesBefore: entries.length,
84
+ entriesAfter: entries.length,
85
+ bytesBefore,
86
+ bytesAfter: bytesBefore,
87
+ };
88
+ }
89
+
90
+ const { cutIdx, reason } = findSoftResetCutIndex(entries, keepRecentUserTurns, maxBytes);
91
+ if (cutIdx <= 1) {
92
+ return {
93
+ reset: false,
94
+ reason: `cut-at-start (${reason})`,
95
+ entriesBefore: entries.length,
96
+ entriesAfter: entries.length,
97
+ bytesBefore,
98
+ bytesAfter: bytesBefore,
99
+ };
100
+ }
101
+
102
+ const trimmed = buildTrimmedEntries(entries, cutIdx);
103
+ const repaired = repairEntriesInMemory(trimmed);
104
+
105
+ const backupPath = backupFile(path);
106
+ const newContent = repaired.entries.map(entry => JSON.stringify(entry)).join('\n') + '\n';
107
+ atomicWrite(path, newContent);
108
+
109
+ const bytesAfter = Buffer.byteLength(newContent, 'utf8');
110
+ return {
111
+ reset: true,
112
+ reason,
113
+ entriesBefore: entries.length,
114
+ entriesAfter: repaired.entries.length,
115
+ bytesBefore,
116
+ bytesAfter,
117
+ backupPath,
118
+ postRepair: repaired.report,
119
+ };
120
+ }
@@ -16,7 +16,7 @@ import { shouldInjectMemory, classifyContextPressure, isSoftFlushOnCooldown } fr
16
16
  import { buildMemoryInjection, injectMemoryIntoMessage } from "./inject";
17
17
  import { buildFlushPrompt } from "./prompts";
18
18
  import { bootstrapMemoryFiles } from "./bootstrap";
19
- import { isContextOverflowError } from "../agents/shared/session-repair";
19
+ import { isContextOverflowError } from "../agents/shared/error-classifiers";
20
20
  import { appendFile, mkdir } from "node:fs/promises";
21
21
  import { join } from "node:path";
22
22
  import { homedir } from "node:os";
@@ -51,6 +51,32 @@ function appendCompactLog(entry: CompactLogEntry): void {
51
51
  .catch((err) => console.warn(`[memory] timing log write failed:`, (err as Error).message));
52
52
  }
53
53
 
54
+ async function attemptSoftResetRecovery(
55
+ err: unknown,
56
+ threadId: string,
57
+ agent: AgentAdapter,
58
+ onProgress?: (step: string) => void | Promise<void>,
59
+ ): Promise<{ attempted: boolean; succeeded: boolean }> {
60
+ if (!isContextOverflowError(err) || !agent.softReset) {
61
+ return { attempted: false, succeeded: false };
62
+ }
63
+
64
+ try {
65
+ await onProgress?.("♻️ Session overflowed — soft-resetting to recent turns...");
66
+ const report = await agent.softReset(threadId);
67
+ if (report?.reset) {
68
+ console.warn(`[memory] soft-reset recovered ${threadId} from overflow`);
69
+ return { attempted: true, succeeded: true };
70
+ }
71
+
72
+ console.warn(`[memory] soft-reset returned no-op for ${threadId} (${(report as { reason?: string } | null)?.reason ?? "unknown"})`);
73
+ return { attempted: true, succeeded: false };
74
+ } catch (resetErr) {
75
+ console.error(`[memory] soft-reset failed for ${threadId}:`, (resetErr as Error).message);
76
+ return { attempted: true, succeeded: false };
77
+ }
78
+ }
79
+
54
80
  // ── Memory mode detection ────────────────────────────
55
81
 
56
82
  /**
@@ -360,34 +386,7 @@ export async function flushMemoryThenCompact(
360
386
  } catch (err) {
361
387
  const errMsg = (err as Error).message;
362
388
  console.error(`[memory] flush+compact failed for ${threadId}:`, errMsg);
363
-
364
- // Recovery path: when the session has grown past the model's context
365
- // window, the summarizer prompt itself overflows and compact() throws
366
- // "prompt is too long". Threshold tuning prevents *new* sessions from
367
- // hitting this, but does nothing for sessions already past the line.
368
- // Trim the on-disk session jsonl to its most recent N user turns and
369
- // mark the next turn for a fresh memory injection. We do NOT retry
370
- // compact inline — that would extend the thread lock for another long
371
- // operation. The trimmed session is small enough that the next user
372
- // turn proceeds normally; any soft pressure from injected memory will
373
- // trigger a regular compact later.
374
- let softResetAttempted = false;
375
- let softResetSucceeded = false;
376
- if (isContextOverflowError(err) && agent.softReset) {
377
- softResetAttempted = true;
378
- try {
379
- await onProgress?.("♻️ Session overflowed — soft-resetting to recent turns...");
380
- const report = await agent.softReset(threadId);
381
- if (report?.reset) {
382
- softResetSucceeded = true;
383
- console.warn(`[memory] soft-reset recovered ${threadId} from overflow`);
384
- } else {
385
- console.warn(`[memory] soft-reset returned no-op for ${threadId} (${(report as { reason?: string } | null)?.reason ?? "unknown"})`);
386
- }
387
- } catch (resetErr) {
388
- console.error(`[memory] soft-reset failed for ${threadId}:`, (resetErr as Error).message);
389
- }
390
- }
389
+ const recovery = await attemptSoftResetRecovery(err, threadId, agent, onProgress);
391
390
 
392
391
  appendCompactLog({
393
392
  threadId,
@@ -401,13 +400,13 @@ export async function flushMemoryThenCompact(
401
400
  totalMs: Date.now() - t0,
402
401
  model: flushModel ?? "default",
403
402
  status: "failed",
404
- error: (softResetAttempted
405
- ? `${softResetSucceeded ? "soft-reset-recovered" : "soft-reset-failed"}: ${errMsg}`
403
+ error: (recovery.attempted
404
+ ? `${recovery.succeeded ? "soft-reset-recovered" : "soft-reset-failed"}: ${errMsg}`
406
405
  : errMsg).slice(0, 500),
407
406
  });
408
407
 
409
408
  try {
410
- if (softResetSucceeded) {
409
+ if (recovery.succeeded) {
411
410
  // Soft reset cleared the overflow. Mark the next turn for memory
412
411
  // re-injection so the agent has its durable context, and clear the
413
412
  // pendingCompact flag — there's nothing left to compact now.