Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/116-l1-recall-raw-pins.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- The canonical L1 compression prompt honors raw (level-0) pins. The live selector already did, but the prompt still recalled the summary covering a pinned span. A frontier summary that covers a force-raw message is now descended rather than recalled: its children take its place, so only the branch containing the pin opens and the raw added is just the pinned L1 chunks (sibling branches stay summarized). Empty-content frontier summaries are descended the same way instead of exposing their whole span raw. Pin precedence matches the kv-stable selector (`isForceRawPinBound`: a fixed `level` wins over `maxLevel`). Merge recall, L3+ merge inputs, transition summaries and the source-only L1 do not consult pins.
78 changes: 65 additions & 13 deletions src/strategies/autobiographical.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2402,6 +2402,18 @@ export class AutobiographicalStrategy implements ResettableStrategy {
* a fixed `level` clamps both ends; a `maxLevel` only caps depth. Honored
* solely by the KV-stable controller — see `ProtectedRange`.
*/
/**
* Whether a pin bound forces a message raw, with the same precedence as the
* kv-stable selector: a fixed `level` wins over `maxLevel`, so
* `{level: 2, maxLevel: 0}` is held at L2 (not raw). A pinned position with
* no level bound is a classic raw pin.
*/
static isForceRawPinBound(bound: { level?: number; maxLevel?: number } | undefined): boolean {
if (bound === undefined) return true;
if (bound.level !== undefined) return bound.level === 0;
return bound.maxLevel === 0;
}

protected pinLevelBounds(messages: StoredMessage[]): Map<number, { level?: number; maxLevel?: number }> {
const out = new Map<number, { level?: number; maxLevel?: number }>();
if (this.pins.length === 0) return out;
Expand Down Expand Up @@ -5318,12 +5330,55 @@ export class AutobiographicalStrategy implements ResettableStrategy {
const messageOrder = new Map<MessageId, number>(
allMessages.map((message, index) => [message.id, index]),
);
const priorSummaries = this.summaries
// Skip empty-content summaries: emitting `{type:'text', text:''}` as a
// recall pair triggers Anthropic 400 "text content blocks must be
// non-empty", which stalls ALL compression (mirrors the render-path guard
// + load-drop). A single empty summary otherwise poisons every compression.
.filter((s) => !s.mergedInto && !!s.content && s.content.trim().length > 0)
// Raw pins in the canonical L1 prompt's recall. The live view shows a
// force-raw message raw; recall here used to include the summary covering
// it anyway. A frontier summary that can't be shown as a recall pair (it
// covers a force-raw message, or its content is empty) is DESCENDED, not
// dropped: its children take its place, so only the branch containing the
// pin opens up, sibling branches stay folded, and the raw added to the
// prompt is just the pinned L1 chunks. This mirrors the live selector
// (kv-control keeps protected raw leaves beside their group's recall).
// Dropping the whole frontier summary instead expanded an entire L3/L4 to
// raw on every L1 while one pin existed (#116 review).
//
// Scope: the canonical L1 prompt. Merge recall, L3+ merge inputs,
// transition summaries and the source-only L1 do not consult pins.
const summariesById = new Map<string, SummaryEntry>();
for (const s of this.summaries) summariesById.set(s.id, s);
const pinnedPositionsSet = this.pinnedPositions(allMessages);
const pinBounds = this.pinLevelBounds(allMessages);
const rawPinnedMessageIds = new Set<MessageId>();
for (let i = 0; i < allMessages.length; i++) {
if (!pinnedPositionsSet.has(i)) continue;
if (AutobiographicalStrategy.isForceRawPinBound(pinBounds.get(i))) {
rawPinnedMessageIds.add(allMessages[i].id);
}
}
const summaryTouchesRawPin = (summary: SummaryEntry): boolean => {
if (rawPinnedMessageIds.size === 0) return false;
const leaves = new Set<MessageId>();
this.expandSummaryToLeafMessageIds(summary, summariesById, leaves);
for (const id of leaves) if (rawPinnedMessageIds.has(id)) return true;
return false;
};
// Skip empty-content summaries as recall pairs: emitting `{type:'text',
// text:''}` triggers Anthropic 400 "text content blocks must be
// non-empty", which stalls ALL compression (mirrors the render-path guard
// + load-drop). They are descended like pinned ones, so their children
// still represent the span instead of all of it going raw.
const recallable = (s: SummaryEntry): boolean =>
!!s.content && s.content.trim().length > 0 && !summaryTouchesRawPin(s);
const mixedFrontier: SummaryEntry[] = [];
const descend = (s: SummaryEntry): void => {
if (recallable(s)) { mixedFrontier.push(s); return; }
if (s.level <= 1) return; // an L1 that can't be shown: its chunk stays raw
Comment on lines +5372 to +5374

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Pinned turns appear out of order When an old L1 is opened by a pin, its raw turns are emitted after all retained recall pairs, including pairs for later events. The compressor then sees those later events before the older conversation, rather than the chronological order used by the live view. This can distort the next memory it writes.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/strategies/autobiographical.ts
Line: 5372-5374

Comment:
**Pinned turns appear out of order** When an old L1 is opened by a pin, its raw turns are emitted after all retained recall pairs, including pairs for later events. The compressor then sees those later events before the older conversation, rather than the chronological order used by the live view. This can distort the next memory it writes.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +5373 to +5374

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Wide pins exceed context When a pin spans many compressed L1s, this change replaces each touched summary with its full raw chunk. The recall budget caps only the summaries that remain, and the canonical request is sent without the context-budget check used for fallback variants. If those raw chunks exceed the compression model's window, the next L1 repeatedly fails with a context-length error instead of compressing.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/strategies/autobiographical.ts
Line: 5373-5374

Comment:
**Wide pins exceed context** When a pin spans many compressed L1s, this change replaces each touched summary with its full raw chunk. The recall budget caps only the summaries that remain, and the canonical request is sent without the context-budget check used for fallback variants. If those raw chunks exceed the compression model's window, the next L1 repeatedly fails with a context-length error instead of compressing.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

for (const childId of s.sourceIds) {
const child = summariesById.get(childId);
if (child) descend(child);
Comment on lines +5375 to +5377

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing children lose coverage Loading legacy state can remove an empty child summary while leaving its ID in a higher-level parent's sourceIds. If a pin on a surviving sibling opens that parent, this loop silently skips the missing child. Its span loses recall coverage and may be replayed raw, needlessly enlarging later L1 prompts. Please handle an incomplete branch explicitly.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/strategies/autobiographical.ts
Line: 5375-5377

Comment:
**Missing children lose coverage** Loading legacy state can remove an empty child summary while leaving its ID in a higher-level parent's `sourceIds`. If a pin on a surviving sibling opens that parent, this loop silently skips the missing child. Its span loses recall coverage and may be replayed raw, needlessly enlarging later L1 prompts. Please handle an incomplete branch explicitly.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +5372 to +5377

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Cyclic summaries overflow descent The existing leaf-expansion walk guards against cycles in stored summary graphs, but this new descent does not. If a raw pin makes an ancestor in a cyclic graph non-recallable, descent revisits it until the stack overflows and compression aborts. A visited guard would keep a damaged store from causing that failure.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/strategies/autobiographical.ts
Line: 5372-5377

Comment:
**Cyclic summaries overflow descent** The existing leaf-expansion walk guards against cycles in stored summary graphs, but this new descent does not. If a raw pin makes an ancestor in a cyclic graph non-recallable, descent revisits it until the stack overflows and compression aborts. A visited guard would keep a damaged store from causing that failure.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

}
};
for (const s of this.summaries) if (!s.mergedInto) descend(s);
const priorSummaries = mixedFrontier
.sort((a, b) => {
const aOrder = messageOrder.get(a.sourceRange.first) ?? Number.MAX_SAFE_INTEGER;
const bOrder = messageOrder.get(b.sourceRange.first) ?? Number.MAX_SAFE_INTEGER;
Expand All @@ -5337,14 +5392,11 @@ export class AutobiographicalStrategy implements ResettableStrategy {
// a budget-dropped summary doesn't make its raw messages reappear.
// Expand summary sourceIds down to leaf message IDs — an L2's
// sourceIds are L1 IDs, not message IDs; a flat walk would miss
// every message it transitively covers (Bug 10). Also expand merged
// L1s as defense in depth.
const summariesById = new Map<string, SummaryEntry>();
for (const s of this.summaries) summariesById.set(s.id, s);
// every message it transitively covers (Bug 10). The frontier is the
// authority here: a second pass expanding every merged L1 would hide the
// raw source of a frontier summary excluded by the pin rule above.
// (summariesById is built above, for that pin-aware recall filter.)
const priorSummaryMessageIds = new Set<MessageId>();
for (const s of this.summaries) {
if (s.level === 1) this.expandSummaryToLeafMessageIds(s, summariesById, priorSummaryMessageIds);
}
for (const s of priorSummaries) {
this.expandSummaryToLeafMessageIds(s, summariesById, priorSummaryMessageIds);
}
Comment on lines 5399 to 5402

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Pinned history disappears When a raw pin covers a summarized message before a reset head-window start, the new frontier removes its recall pair. The head loop begins after that message, and the raw-middle loop begins after the head, so neither emits it. The pinned history disappears from the canonical L1 prompt instead of appearing raw.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/strategies/autobiographical.ts
Line: 5399-5402

Comment:
**Pinned history disappears** When a raw pin covers a summarized message before a reset head-window start, the new frontier removes its recall pair. The head loop begins after that message, and the raw-middle loop begins after the head, so neither emits it. The pinned history disappears from the canonical L1 prompt instead of appearing raw.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Expand Down
73 changes: 73 additions & 0 deletions test/l1-recall-raw-pins-frontier.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* #116 review: a raw pin inside a merged summary must open only the branch
* that contains it. Before the mixed frontier, one pinned message made the
* canonical L1 prompt replay the whole covering L3 raw (reviewer's repro: 187
* old messages raw vs 9 in the live view), on every L1 while the pin existed.
*/
import { describe, it, after } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { ContextManager, AutobiographicalStrategy } from '../src/index.js';

const dir = mkdtempSync(join(tmpdir(), 'l1-recall-pins-'));
after(() => rmSync(dir, { recursive: true, force: true }));

async function forest(name: string) {
const requests: any[] = [];
let calls = 0;
const membrane = { complete: async (req: unknown) => { requests.push(JSON.parse(JSON.stringify(req))); calls++;
return { stopReason: 'end_turn', content: [{ type: 'text', text: `Summary #${calls}: ordinary events.` }], usage: { inputTokens: 500, outputTokens: 100 } }; } };
const strategy = new AutobiographicalStrategy({ compressionModel: 'm', targetChunkTokens: 200, headWindowTokens: 0, recentWindowTokens: 0,
autoTickOnNewMessage: false, minChunkCharsForLLM: 0, summaryParticipant: 'Claude', hierarchical: true, mergeThreshold: 6, adaptiveResolution: true });
const cm = await ContextManager.open({ path: join(dir, name), strategy, membrane: membrane as never });
const ids: string[] = [];
for (let i = 0; i < 240; i++) ids.push(cm.addMessage(i % 2 ? 'Claude' : 'User', [{ type: 'text', text: `OLD-${i} ${'word '.repeat(30)}` }]));
for (let r = 0; r < 200; r++) { await cm.compile(); await cm.tick(); if (calls > 150) break; }
return { cm, strategy, ids, requests };
}

function firstL1After(cm: ContextManager, requests: any[]) {
return async () => {
requests.length = 0;
for (let i = 0; i < 20; i++) cm.addMessage(i % 2 ? 'Claude' : 'User', [{ type: 'text', text: `NEW-${i} ${'word '.repeat(30)}` }]);
await cm.compile(); await cm.tick();
const l1 = requests[0];
const texts: string[] = l1.messages.flatMap((m: any) => m.content).filter((b: any) => typeof b.text === 'string').map((b: any) => b.text);
return { oldRaw: texts.filter((t) => t.startsWith('OLD-')).length, recall: texts.filter((t) => t.startsWith('[CM] Recall memory')).length };
};
}

describe('raw pins open only their own branch of the L1 recall frontier', () => {
it('one pinned message deep in a merged summary adds at most its chunk raw', async () => {
const { cm, strategy, ids, requests } = await forest('one-pin');
const levels: Record<number, number> = {};
for (const s of (strategy as unknown as { summaries: Array<{ level: number; mergedInto?: string }> }).summaries) if (!s.mergedInto) levels[s.level] = (levels[s.level] ?? 0) + 1;
assert.ok((levels[2] ?? 0) + (levels[3] ?? 0) > 0, `need an L2+ frontier, got ${JSON.stringify(levels)}`);

const baseline = await firstL1After(cm, requests)();
cm.pinRange(ids[5], ids[5], { name: 'one-message' });
const pinned = await firstL1After(cm, requests)();

// The pinned message's L1 chunk (a handful of messages) opens; nothing else.
assert.ok(pinned.oldRaw - baseline.oldRaw <= 12, `raw grew by ${pinned.oldRaw - baseline.oldRaw} (was 180+ before the fix)`);
assert.ok(pinned.oldRaw > baseline.oldRaw, 'the pinned chunk itself is shown raw');
assert.ok(pinned.recall >= baseline.recall, 'sibling branches stay summarized as recall pairs');
cm.close();
});
});

describe('pin precedence matches the kv-stable selector', () => {
const raw = AutobiographicalStrategy.isForceRawPinBound;
const cases: Array<[string, { level?: number; maxLevel?: number } | undefined, boolean]> = [
['classic raw pin (no bound)', undefined, true],
['level: 0', { level: 0 }, true],
['maxLevel: 0', { maxLevel: 0 }, true],
['level: 2', { level: 2 }, false],
['maxLevel: 2', { maxLevel: 2 }, false],
['overlapping {level: 2, maxLevel: 0}: level wins', { level: 2, maxLevel: 0 }, false],
['overlapping {level: 0, maxLevel: 3}', { level: 0, maxLevel: 3 }, true],
];
for (const [name, bound, expected] of cases) it(name, () => assert.equal(raw(bound), expected));
});
73 changes: 73 additions & 0 deletions test/summary-reasoning-roundtrip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,79 @@ describe('Summary reasoning round-trip (Fable-5 signed thinking)', () => {
await manager.close();
});

it('compression honors level-0 pins by replacing the covered recall with raw source', async () => {
cleanup();

const requests: Array<{ messages: Array<{ participant: string; content: Array<Record<string, unknown>> }> }> = [];
let calls = 0;
const membrane = {
complete: async (req: (typeof requests)[number]) => {
requests.push(JSON.parse(JSON.stringify(req)));
calls++;
return {
stopReason: 'end_turn',
content: [{ type: 'text', text: `Summary #${calls}: ordinary events.` }],
usage: { inputTokens: 500, outputTokens: 100 },
};
},
};
const strategy = new AutobiographicalStrategy({
...strategyConfig(),
targetChunkTokens: 200,
mergeThreshold: 1_000,
});
const manager = await ContextManager.open({
path: TEST_STORE_PATH,
strategy,
membrane: membrane as never,
});

const textById = new Map<string, string>();
for (let i = 0; i < 20; i++) {
const text = `PIN-SOURCE-${i} ${filler(30)}`;
const id = manager.addMessage(i % 2 === 0 ? 'User' : 'Claude', [{ type: 'text', text }]);
textById.set(id, text);
}
await manager.compile();
await manager.tick();

const state = strategy as unknown as { summaries: SummaryEntry[] };
const pinnedSummary = state.summaries.find((entry) => entry.level === 1);
assert.ok(pinnedSummary, 'setup: an L1 exists to pin back to raw');
manager.pinAtLevel(
pinnedSummary!.sourceRange.first,
pinnedSummary!.sourceRange.last,
0,
{ name: 'compression-raw-pin' },
);

requests.length = 0;
for (let i = 0; i < 20; i++) {
manager.addMessage(i % 2 === 0 ? 'User' : 'Claude', [
{ type: 'text', text: `NEW-CHUNK-${i} ${filler(30)}` },
]);
}
await manager.compile();
await manager.tick();

assert.ok(requests.length >= 1, 'a later compression request was issued');
const requestText = requests
.flatMap((request) => request.messages)
.flatMap((message) => message.content)
.filter((block): block is Record<string, unknown> & { text: string } => typeof block.text === 'string')
.map((block) => block.text)
.join('\n');
assert.ok(
!requestText.includes(`[CM] Recall memory ${pinnedSummary!.id}.`),
'the summary covering a raw-pinned span is not recalled',
);
for (const id of pinnedSummary!.sourceIds) {
assert.ok(requestText.includes(textById.get(id)!), `raw source message ${id} is present`);
}

await manager.close();
});

it('leaves responseContent absent for reasoning-free responses (non-thinking models)', async () => {
cleanup();

Expand Down
Loading