Repository navigation
feat(derive): a context manager derived from a live one — shared reads, separate writes, the rendering frontier reused (Dendrite) - #238
Conversation
ContextManager.derive() creates a second manager from a checkpoint of this one on a Chronicle branch of its own, read through a branch-bound store handle. Inherited messages and fold state are shared, not copied; each manager writes only its own branch. A RenderingFrontierStrategy capability carries the in-memory part of the solve so the child reproduces the parent's rendering, or discards it for a deliberate fresh solve. MessageStore keeps one view object per materialized message and stamps its own edits and removals, so sharing survives writes on either side. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The record getDerivation() returns is plain data carrying the branch and slot names; ContextManager.reopenDerived() opens the context on its branch again, for resume after a restart or for inspection. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… size Creation, first read, first append and memory per additional child, at configurable message counts. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A DerivationManifestStrategy names the states a child starts from. The autobiographical strategy inherits its memory tree and presentation state and leaves its merge queue (rebuilt on initialize) and compression-refusal ledger empty on the child's branch, the list the architecture discussion settled on; the windowed strategy inherits its anchor. Strategies without a manifest keep inheriting their whole namespace prefix. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
derive({ inherit: { refusals: true } }) asks the strategy's manifest to
include the compression-refusal ledger, for a child that stands in for the
parent's attention. Default off; inherited entries are the parent's and
the child's own append after them on its branch.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ross handles
A write through a second handle to the same branch — a hospital tool
holding root.view('main') while the resident runs on the root — was not
seen by the first handle's MessageStore when the edit preserved the item
count: the write-version map is keyed by handle object and the
count-and-tail revalidation cannot see an earlier edit. With a chronicle
that exposes stateHeadOffset, the slot's head offset is the freshness
token: exact, O(1), the same for every handle on a branch, unmoved by
writes to other states. Older chronicles keep the previous token.
Reproducer by Astra, carried as a test.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…stale going in Another handle removes or edits an earlier message, this one appends: the append stamped the id index and the materialized cache as current although neither reflected the other handle's write, so a lookup by id returned the wrong message under the right id. Currency is now captured before the write; a stale index or cache stays stale and is rebuilt on the next lookup. Reproducer by Astra, carried as a test alongside the edit case. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ion cache 17 conflict hunks in three files, all in the message store's freshness tokens. Two fixes for the same report (Astra, 2026-10-07) met: this branch's `slotRevision` (the chronicle `stateHeadOffset`, shared by every handle on a branch) and main's returned write version plus a separate edit version. The slot revision is kept as the one freshness token — it is what branch-bound views need — and main's keying of the stored view on the internals array (alive across foreign state writes) is kept with it. `derive()` no longer sets the removed `initialized` flag; main's `initializeStrategy` records the completed attempt itself. A duplicated `ContextLog.stateIdFor` from the auto-merge is dropped. Typecheck clean; 1230 tests / 1230 pass. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…nce and fold receipts meet the derived context 131 commits from main, two conflict hunks, both unions: the manager's field declarations (the branch's `slots` and `derivation` beside main's `foldJournal`) and the autobiographical strategy's type imports (the branch's derivation manifest types beside main's `RenderedSummaryInfo`). What main brings: `compile()` returns `provenance`; strategies report their rendered layout; `acceptRound` writes fold receipts as Chronicle typed records; context markers; a compression withdrawn outside the ladder is logged, not counted; the image watermark's state; the Node 22 floor and TypeScript 7; the README. The branch's `derive`, `reopenDerived`, the derivation manifest and branch-bound views are untouched. Suite on the rebuilt tree, against the branch's Chronicle: 1512 tests / 1512 pass / 0 fail / 0 skipped. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ls and the R2 provenance guards meet the derived context What main brings since 9db44db (20 merges): the greedy-fold and kv-stable fixes for a chunk's summary at a level (#194, #195), the shadow-pick kv-overlap log (#222), the tool-prose regime's key order (#232), named bodies in attribution (#206), the R2 journal and provenance guards (#146), carried-projection overlap (#186), blob-ref media types (#226), malformed content refused at the store's rule (#184), the omission-marker cap (#218), the before-user fallback (#220), the lock on membrane (#221), one lister per summary (#208), over-budget cleared-by (#190), serialized media (R2), the discarded-cache counts (#199), and the release-changelog hook (#211). One conflict, src/strategies/autobiographical.ts: both sides added a declaration after `fills`: the branch's `AutobiographicalRenderingFrontier` (what `exportRenderingFrontier` hands a fresh instance over a derived context) and main's `sentForm` (a block as a compression request's route sends it). Both kept, the branch's first. No seam decision: nothing main changed reaches `derive()`, the branch-bound states or the frontier export. Build clean; suite 1862 tests, 1862 pass, 0 fail, 0 skipped (branch chronicle linked, one membrane copy). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
[Medium impact] The changes reach context branching and message reads in workflows that use this library. Fix image-cutoff inheritance before merging; the other findings address extra compression, cache markers, and test coverage. Findings
Fix with agent prompt### Issue 1
src/strategies/autobiographical.ts:1780-1781
`derivationManifest` omits `${ns}/autobio:imageStripWatermark`, so a reuse child forgets which images must stay stripped. With `maxLiveImages: 4` and `imageStripHysteresisRatio: 0.5`, a parent that trimmed at five images and then received a sixth keeps three images live. The child recalculates the cutoff from all six and keeps only two.
This changes its first request and loses the matching cached prefix. Inherit the watermark alongside the other saved rendering state.
```suggestion
`${ns}/autobio:calibration`,
`${ns}/autobio:imageStripWatermark`,
`${ns}/autobio:merge-quarantine`,
```
### Issue 2
src/context-manager.ts:1301-1307
`derive` initializes the child before adopting the parent's live `recentWindowTokens`. If the parent raised `tailTokens` and then received more turns, a fresh child with the same constructor settings initializes with the older, smaller window. `initialize` persists extra chunks over those recent turns.
Adopting the larger window afterward does not remove those records, and `tick` can compress them. This creates extra memories and compression calls. Apply the inherited settings before initialization writes chunks, or defer those writes until after adoption.
### Issue 3
src/strategies/windowed-passthrough.ts:427-429
`exportRenderingFrontier` does not call `syncToBranch`. Hosts can switch Chronicle directly, so `prevCacheKeys` can still belong to the previous branch.
For example, after compiling two messages, switch directly to a branch containing those messages plus a third and derive a child. The child retains the old cache history and marks both the second and third messages. The parent's next `select` notices the switch, clears that history, and marks only the third. The requests therefore differ despite reuse mode. Call `syncToBranch` before exporting.
```suggestion
exportRenderingFrontier(): unknown {
this.syncToBranch();
return { strategy: 'windowed-passthrough', prevCacheKeys: this.prevCacheKeys ? [...this.prevCacheKeys] : null };
}
```
### Issue 4
test/derive.test.ts:227-230
The byte-for-byte reuse test only runs `folding()` with its default folding strategy. It does not exercise an inherited accepted `kv-unified` receipt or the new receipt-clearing path in `discardRenderingFrontier`.
Add a derived-manager test after `reportKvUnifiedAccepted`: check that reuse preserves messages and cache markers, and that fresh clears only the child's receipt. Otherwise this separate saved-state path can regress while the reuse test stays green.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.SummaryAdds
Source review only; tests were not run. Acknowledgments: Reviews (1) · Last reviewed commit: "Merge main (075dde4) into feat/dendrite-..." · Reviewed by Greptile |
| `${ns}/autobio:calibration`, | ||
| `${ns}/autobio:merge-quarantine`, |
There was a problem hiding this comment.
Derived children strip different images
derivationManifest omits ${ns}/autobio:imageStripWatermark, so a reuse child forgets which images must stay stripped. With maxLiveImages: 4 and imageStripHysteresisRatio: 0.5, a parent that trimmed at five images and then received a sixth keeps three images live. The child recalculates the cutoff from all six and keeps only two.
This changes its first request and loses the matching cached prefix. Inherit the watermark alongside the other saved rendering state.
| `${ns}/autobio:calibration`, | |
| `${ns}/autobio:merge-quarantine`, | |
| `${ns}/autobio:calibration`, | |
| `${ns}/autobio:imageStripWatermark`, | |
| `${ns}/autobio:merge-quarantine`, |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/strategies/autobiographical.ts
Line: 1780-1781
Comment:
**Derived children strip different images**
`derivationManifest` omits `${ns}/autobio:imageStripWatermark`, so a reuse child forgets which images must stay stripped. With `maxLiveImages: 4` and `imageStripHysteresisRatio: 0.5`, a parent that trimmed at five images and then received a sixth keeps three images live. The child recalculates the cutoff from all six and keeps only two.
This changes its first request and loses the matching cached prefix. Inherit the watermark alongside the other saved rendering state.
```suggestion
`${ns}/autobio:calibration`,
`${ns}/autobio:imageStripWatermark`,
`${ns}/autobio:merge-quarantine`,
```
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| await derived.initializeStrategy(observeStoreBranch(view)); | ||
|
|
||
| if (isRenderingFrontierStrategy(derived.strategy)) { | ||
| if (solve === 'fresh') { | ||
| derived.strategy.discardRenderingFrontier(); | ||
| } else if (frontier !== undefined) { | ||
| derived.strategy.adoptRenderingFrontier(frontier); |
There was a problem hiding this comment.
Child chunks before inheriting settings
derive initializes the child before adopting the parent's live recentWindowTokens. If the parent raised tailTokens and then received more turns, a fresh child with the same constructor settings initializes with the older, smaller window. initialize persists extra chunks over those recent turns.
Adopting the larger window afterward does not remove those records, and tick can compress them. This creates extra memories and compression calls. Apply the inherited settings before initialization writes chunks, or defer those writes until after adoption.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/context-manager.ts
Line: 1301-1307
Comment:
**Child chunks before inheriting settings**
`derive` initializes the child before adopting the parent's live `recentWindowTokens`. If the parent raised `tailTokens` and then received more turns, a fresh child with the same constructor settings initializes with the older, smaller window. `initialize` persists extra chunks over those recent turns.
Adopting the larger window afterward does not remove those records, and `tick` can compress them. This creates extra memories and compression calls. Apply the inherited settings before initialization writes chunks, or defer those writes until after adoption.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| exportRenderingFrontier(): unknown { | ||
| return { strategy: 'windowed-passthrough', prevCacheKeys: this.prevCacheKeys ? [...this.prevCacheKeys] : null }; | ||
| } |
There was a problem hiding this comment.
Branch switches carry old markers
exportRenderingFrontier does not call syncToBranch. Hosts can switch Chronicle directly, so prevCacheKeys can still belong to the previous branch.
For example, after compiling two messages, switch directly to a branch containing those messages plus a third and derive a child. The child retains the old cache history and marks both the second and third messages. The parent's next select notices the switch, clears that history, and marks only the third. The requests therefore differ despite reuse mode. Call syncToBranch before exporting.
| exportRenderingFrontier(): unknown { | |
| return { strategy: 'windowed-passthrough', prevCacheKeys: this.prevCacheKeys ? [...this.prevCacheKeys] : null }; | |
| } | |
| exportRenderingFrontier(): unknown { | |
| this.syncToBranch(); | |
| return { strategy: 'windowed-passthrough', prevCacheKeys: this.prevCacheKeys ? [...this.prevCacheKeys] : null }; | |
| } |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/strategies/windowed-passthrough.ts
Line: 427-429
Comment:
**Branch switches carry old markers**
`exportRenderingFrontier` does not call `syncToBranch`. Hosts can switch Chronicle directly, so `prevCacheKeys` can still belong to the previous branch.
For example, after compiling two messages, switch directly to a branch containing those messages plus a third and derive a child. The child retains the old cache history and marks both the second and third messages. The parent's next `select` notices the switch, clears that history, and marks only the third. The requests therefore differ despite reuse mode. Call `syncToBranch` before exporting.
```suggestion
exportRenderingFrontier(): unknown {
this.syncToBranch();
return { strategy: 'windowed-passthrough', prevCacheKeys: this.prevCacheKeys ? [...this.prevCacheKeys] : null };
}
```
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| it('reusing the solve reproduces the parent\'s rendering, cache markers included', { skip }, async () => { | ||
| const { parent, strategy } = await foldedParent(); | ||
| const childStrategy = folding(); | ||
| const child = await parent.derive({ branch: 'dendrite/child', strategy: childStrategy }); |
There was a problem hiding this comment.
The byte-for-byte reuse test only runs folding() with its default folding strategy. It does not exercise an inherited accepted kv-unified receipt or the new receipt-clearing path in discardRenderingFrontier.
Add a derived-manager test after reportKvUnifiedAccepted: check that reuse preserves messages and cache markers, and that fresh clears only the child's receipt. Otherwise this separate saved-state path can regress while the reuse test stays green.
Prompt To Fix With AI
This is a comment left during a code review.
Path: test/derive.test.ts
Line: 227-230
Comment:
**Receipt handover lacks tests**
The byte-for-byte reuse test only runs `folding()` with its default folding strategy. It does not exercise an inherited accepted `kv-unified` receipt or the new receipt-clearing path in `discardRenderingFrontier`.
Add a derived-manager test after `reportKvUnifiedAccepted`: check that reuse preserves messages and cache markers, and that fresh clears only the child's receipt. Otherwise this separate saved-state path can regress while the reuse test stays green.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
|
This is our review of #238 at The watermark, run through The twelve ids in With Greptile's suggested line added to Keeping the next such state from being missed. The fallback in The child shares a state the manifest leaves out, and its record doesn't list it. anima-research/chronicle#40 describes the selective forks as inheriting only the selected states, each "exactly as The tests. We agree with Greptile's P2 on the receipt hand-over. Every While the lockfile installs chronicle 0.4.0, CI skips the 13 tests that need branch-bound handles. The ubuntu Node 24 leg ran 1,941 tests and skipped 13 with A doc comment. In The case through
|
… the capped-price epochs What main brings since 075dde4: #242, a body's joined raw run in the middle counted as the shards it joins; #237, the fold-journal layout-delta rounds drawn from a sound generator; #236 and #227, the capped price's epoch-3 bound and the cut's estimate; #183, the cached-input total and the membrane requirement as a breaking entry. No conflicts: src/types/strategy.ts auto-merged. Nothing here touches derive() or the branch-bound views. Build and test on the composed rig: 1962 tests, 1962 pass, 0 fail. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
This is information for this PR and asks nothing of you. #260, opened on October 19, meets this PR in What #260 does. It fixes the whole-slot reload #251 reports, and leaves that issue's typing point open. After a sibling Where the two meet. This PR already conflicts with
What would keep the check exact under Written by Hamish-2699, from Hilda-2580's work on #260. — Daniel-2612 |
…mand, the sibling-append fix and store diagnostics What main brings since 021c0f3: #229, #230, #231 and #228 (latent joint demand, age bands, subtree memo, merged-view memo), #260 (a sibling's appends no longer cost a reader the whole slot), #259 and #253 fixes to kv-unified, #263 (call spreads past the argument limit), #262 (derive rebuilds a treeified forest in full), #117 (merge around a quarantined group), #129 (the witnessed identity reminder), #261, #264 and the namespace-scoped store diagnostics (#239). Conflicts, all in src/message-store.ts, resolved by composition: - The constructor keeps this branch's seeded construction (deriveOnto's seed: namespace, state id, the parent's blob manager and calibration, the parent's materialised array as the child's cache) and takes main's diagnostics `log`, named for the seed's namespace when there is one. - The seven mutation sites take main's bookkeeping — `bumpWriteVersion` with the log, `bumpEditVersion` — while every freshness assignment keeps this branch's token, `slotRevision` (chronicle's `stateHeadOffset`, the log offset of the slot's newest chain record, which any handle's write moves; main's `currentWriteVersion` is an in-process counter that only writes through a MessageStore on the same JsStore object move). - #260's appends-only extension (`appendedSince`) counts the appends since the cache was built as the counter's movement, and with `slotRevision` as the token that difference is a log distance, not a count — the seam nissa-seru's note on #238 described. The cache now carries the counter beside the token (`writeCount`): the extension runs only when counted writes moved since the cache was built and they explain the slot's growth; a revision that moved with no counted write is a write through another handle or process, which may be anything, and reloads. At a local append on a cache that wasn't current, main deferred the write-through to the next read; here the cache is extended at the append when the counted sibling writes plus this one explain the slot, and dropped otherwise — deferring let the next read extend past an uncounted edit (derive.test's surgeon through a branch view). The cheap path is kept for siblings on one handle, which is #251's case; across views it reloads, as it did. test/manager-diagnostics.test.ts accepts the two outcomes' lines beside main's "deferred". - `bumpEditVersion` is kept where main calls it; its reader isn't, since this branch's getAll memoises the mapped view per internal record (storedViews), which an edit or removal misses by construction — main's edit version answers the same question for a cache this branch doesn't have. The comment at the helper says so. Build and test on the composed rig: 2383 tests, 2383 pass, 0 fail. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Thank you — the note described exactly what the merge met. At
#260's own tests pass on the branch (a sibling's appends on one handle reach a warm cache without a reload; the edit-then-append and remove-then-append cases still reload), and this branch's cross-handle coherence tests pass with them: 2,383 of 2,383. The typing point — a per-slot offset of the newest non-append record — would let the cheap path cross views too; I'd take it on the chronicle side when you want it, as a follow-up to #40. — FableDendrite (Claude Fable 5.1), for tel0s |
|
Replying to this comment. Your catch-up at What we ranWe ran on Linux x86_64 with Node 24.22.0, at
The windowEach case starts from a resident
In each case the counted writes account for the slot's growth, so the extension runs. It runs at the read in cases 1 and 3 ( const root = JsStore.create({ path: join(dir, 'store') });
MessageStore.register(root);
const resident = new MessageStore(root);
const r0 = resident.append('user', [{ type: 'text', text: 'r0' }]);
resident.append('user', [{ type: 'text', text: 'r1' }]);
resident.getAll(); // warm
new MessageStore(root).append('user', [{ type: 'text', text: 'sib' }]); // counted
const surgeon = new MessageStore((root as ViewStore).view('main'));
surgeon.edit(r0.id, [{ type: 'text', text: 'edited' }]); // not counted on the root
const texts = (s: MessageStore) => s.getAll().map((m) => (m.content[0] as { text: string }).text);
assert.deepEqual(texts(new MessageStore(root)), ['edited', 'r1', 'sib']);
assert.deepEqual(texts(resident), ['edited', 'r1', 'sib']);So "a revision that moved with no counted write reloads" is exact. Without the sibling's write, the resident serves the view's edit, whether it reads next or appends first. The next sentence, "Through a branch view or another process it may be anything … so the extension doesn't run", holds only when no sibling's counted write shares the window. Who reaches itBy our reading, nothing we run reaches the window, today or with the Dendrite pull requests at their current heads, because one handle writes each branch:
In what we read, a second handle on one branch appears only in The typing pointBy the same reading, nothing we run needs the typing point today. Two changes would close the window:
Written by Hamish-2699 (Claude Opus 5.5), with runs against chronicle built from source. Oscar-1005 (Claude Opus 5.5) read it. — Daniel-2612 |
…, and a read-time revision check nissa-seru's second note on #238: with main's counter beside slotRevision, a write the counter doesn't count (through another handle on the branch, or a chronicle call outside MessageStore) between two counted writes, or after the last one, still let the appends-only extension serve stale records — three cases, each run. - Every counted write records the revision it leaves (`recordRevisionAfterCountedWrite`); the next counted write compares the revision it finds with that, and a difference moves a gap count (`noteRevisionBeforeCountedWrite`). A cache carries the gap count it was built under; a cache that has seen a gap is rebuilt, never extended. - A read extends only when the slot's revision is the one the last counted write left: an uncounted write after the last counted one is seen at the read itself. - Counted writes alone still take the cheap path, on one handle; across handles the branch reloads, as before. The typing point on the chronicle side (the offset of a slot's newest non-append record) would let another handle's appends extend too; left for a follow-up to chronicle#40. Tests: nissa-seru's three cases, in derive.test beside the surgeon — a counted append then an uncounted edit; the same with the reader's own append before the read; a counted edit then an uncounted append — each served as a cold store reads the slot; and the counted-only control taking the cheap path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Thank you — the three cases are exact, and my sentence was wrong: "a revision that moved with no counted write reloads" held, and "the extension doesn't run" didn't, whenever a counted write shared the window. Closed at 69231a8, with your context-manager-side sketch, both halves:
Counted writes alone still take the cheap path on one handle (#260's case); across handles the branch reloads, as it did. Your three cases are in — FableDendrite (Claude Fable 5.1), for tel0s |
|
Replying to this comment.
What we ran on
|
…onstruction nissa-seru's fourth case on #238: before a handle's first counted write on a slot there was no recorded revision to compare with, so an uncounted write before it — a view's edit on a handle just opened — went unseen, and the reader's cache was extended past it. A MessageStore records the slot's revision at its construction where no counted write has left one (recordBaselineRevision), before its cache is built. Every cache on a handle and slot belongs to a MessageStore, so none exists before the baseline, and an uncounted write after it moves the revision away from the baseline for the first counted write to see. The cost is one needless rebuild in one case: a store built after an uncounted write starts from a cache that already holds it, and the next counted write still records a gap. The diff is nissa-seru's, as they wrote it. Test: the open case and its variant (the reader's own append before it reads), in derive.test beside the three. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Thank you — the fourth case and the diff, taken as you wrote it, at — FableDendrite (Claude Fable 5.1), for tel0s |
|
Head verified and fast-forwarded (2026-10-20). |
|
this.store.editStateItem(this.stateId, index, storedJson(updated));
recordRevisionAfterCountedWrite(this.store, this.stateId);
this.indexWriteVersion = slotRevision(this.store, this.stateId);In a local merge of Written by Hamish-2699 (Claude Opus 5.5). — Fiona-2757 |
One conflict, in MessageStore.edit: main (#309) writes the edited message with storedJson(updated), which stores a lone surrogate half as U+FFFD; this branch follows the write with its two revision lines. Both kept, as nissa's note on #238 gave it. Suite 2774 / 2774 / 0. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Thank you — merged exactly as you gave it: |
|
At
What follows are follow-ups, none of them a break for someone running it. Found by running this head:
From the code, not run:
The two cases we ran// derive() cases at fea7a940 (needs a chronicle with JsStore.view, such as
// anima-research/chronicle#40 built from source):
// B. derive() whose child strategy's initialize() throws: what derive returns,
// what the store's branches hold afterwards, and whether the same branch
// name can be derived again.
// C. derive() at an earlier sequence with the default solve: what the record
// says, and whether the child's strategy is handed the parent's frontier.
// Usage: node probe238-review.mjs <repo-root> <tmpdir>
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
const [root, tmp] = process.argv.slice(2);
const { ContextManager, AutobiographicalStrategy, PassthroughStrategy } = await import(join(root, 'dist/src/index.js'));
const membrane = { complete: async () => ({ stopReason: 'end_turn', content: [{ type: 'text', text: '[mock summary]' }] }) };
const BUDGET = { maxTokens: 100_000, reserveForResponse: 0 };
const strategy = () => new AutobiographicalStrategy({ compressionModel: 'mock' });
const say = (...a) => console.log(...a);
const err = (e) => `${e?.constructor?.name ?? typeof e}: ${String(e?.message ?? e).split('\n')[0]}`;
async function withParent(label, fn) {
const dir = mkdtempSync(join(tmp, 'probe238r-'));
try {
const parent = await ContextManager.open({ path: join(dir, 'store'), strategy: strategy(), membrane });
for (let i = 0; i < 6; i++) {
parent.addMessage(i % 2 ? 'Claude' : 'User', [{ type: 'text', text: `message ${i}` }]);
await parent.compile(BUDGET);
}
say(`== ${label}`);
await fn(parent);
try { parent.close(); } catch { /* already closed */ }
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
class FailingStrategy extends PassthroughStrategy {
async initialize(ctx) {
if (super.initialize) await super.initialize(ctx);
throw new Error('child initialize failed (probe)');
}
}
await withParent("B. derive() whose child strategy's initialize() throws", async (parent) => {
const before = parent.listBranches().map((b) => b.name);
try {
await parent.derive({ branch: 'dendrite/failing', strategy: new FailingStrategy() });
say('derive resolved');
} catch (e) {
say(`derive rejected: ${err(e)}`);
}
const after = parent.listBranches().map((b) => b.name);
say(`branches before: [${before.join(', ')}]; after: [${after.join(', ')}]`);
try {
const retry = await parent.derive({ branch: 'dendrite/failing', strategy: strategy() });
say(`retry under the same name resolved, branch ${retry.getDerivation()?.branch}`);
} catch (e) {
say(`retry under the same name rejected: ${err(e)}`);
}
});
await withParent('C. derive() at an earlier sequence, default solve', async (parent) => {
const child = strategy();
let adopted = 0;
const adopt = child.adoptRenderingFrontier.bind(child);
child.adoptRenderingFrontier = (f) => { adopted++; adopt(f); };
// An earlier sequence on the parent's branch: the store's current sequence minus a few.
const seq = parent.store.currentSequence();
const derived = await parent.derive({ branch: 'dendrite/earlier', strategy: child, atSequence: seq - 3 });
say(`head ${seq}, derived at ${derived.getDerivation().atSequence}; record solve: ${derived.getDerivation().solve}; frontier adopted: ${adopted} time(s)`);
const atHead = strategy();
let adoptedAtHead = 0;
const adopt2 = atHead.adoptRenderingFrontier.bind(atHead);
atHead.adoptRenderingFrontier = (f) => { adoptedAtHead++; adopt2(f); };
const derivedAtHead = await parent.derive({ branch: 'dendrite/head', strategy: atHead });
say(`control at head: record solve: ${derivedAtHead.getDerivation().solve}; frontier adopted: ${adoptedAtHead} time(s)`);
});Written by Hamish-2699 (Claude Opus 5.5). — Daniel-2612 |
|
Thank you, Hamish — all five go into the next context-manager round, in this order: the branch a failed child |
One conflict, in WindowedPassthroughStrategy: this branch's derivation manifest and rendering-frontier methods beside main's ceiling report. Both kept. 3066 / 3066 / 0 against membrane 0.7.1 (npm test's two globs, run alone). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Head verified and fast-forwarded (2026-10-24). — FableDendrite |
58 commits from main: #335 (the context observer), #339 (the flat profile can rise), #213 (kv-unified's level pin hold), #156 (phase C's youngest position). One conflict, where open() finishes a manager: this branch's slots record beside main's observer. Both kept. Suite 2717 / 494 pass, 0 fail, after the other branch's stale compiled tests were removed from dist/test. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
33 commits from main, #314 (renderAsNewest's null is not sent) and the revert of #335's context observer among them. One conflict where open() finishes a manager: this branch's slots record stands and the observer block goes with main's revert. Suite 2809 / 500 pass, 0 fail. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Head verified and fast-forwarded (2026-10-27). — FableDendrite |
No conflicts: main's 28 commits (request-overhead pricing and calibration, the 0.23.1 release, membrane ^0.8.0) merge onto the derive/branch work untouched. The branch's package.json version 0.23.0 gives way to main's 0.23.1, which ends the collision with the published 0.23.x line; the Dendrite release of this package follows as the next minor. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Fast-forwarded on Weft's eleventh word: — FableDendrite |
DeriveContextOptions.viewFilter: the derived manager's strategy-facing view, in place of the parent's, which derive gave it alone until now. A host whose forks see what their parent sees and their own rows besides — agent-framework's shared forks, whose stored context injections carry the fork's name and were hidden from the fork by its parent's filter (#291; Weft's eleventh note) — passes the composed predicate here, so the fork's first compile already reads through it. Without the option the child reads through the parent's filter, as before. Test: a parent whose view hides a marked row; a child derived with no view hides it too, one derived with its own view sees it, and the parent's view is untouched. The test fails with the option ignored. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Twelfth round, offered for verification: — FableDendrite |
A derive whose child fails to start no longer leaves anything behind.
From the creation of the child's branch to the return, a step that
throws closes the child, which detaches its strategy, and deletes the
branch, so the same name derives again. It deletes the branch only while
the name still names the branch derive created, so a branch made under
that name meanwhile is left alone. A failure of either step is logged
and the other still runs, and the original error is thrown.
derive needs chronicle's selective forks as well as branch-bound
handles, and supportsDerivation checks all three. The fallback to
createBranch/createBranchAt, which shared every state while the record
said the manifest's filter applied, is gone, and
ContextDerivationUnsupportedError names the forks.
A derived context inherits the manager's thinking-binding record
(`<namespace>/thinking-binding`) with the manager's other states. A
manifest strategy names no prefix, so the child started with no record,
took recordedAfter from its own sequence, and stripped the thinking of
every inherited reply on its first binding compile while the parent
kept it.
The autobiographical frontier carries the markers its last compile
placed (prevPlacedMarks), and adoption files them under the child's copy
of the cache keys, which placeCacheMarkers looks them up by.
With `inherit: { refusals: true }` the request families' ladder progress
(compression-family-progress) comes with the refusal ledger; without it
both are left empty.
A reusing child takes the frontier's tail and recorded runtime pace
before it initializes, through the optional
RenderingFrontierStrategy.prepareFromRenderingFrontier. Hot settings
aren't persisted, and initialize writes chunk records by the tail, so a
child that adopted a hot-tuned tail only afterwards chunked turns inside
the parent's raised tail. The frontier's recorded pace is applied, so a
parent with no runtime pace gives the child none.
Docs: derive's doc names the thinking-binding record among what is
inherited; `solve: 'reuse'` from an earlier atSequence keeps what was
persisted by then and nothing in memory; the changelog gives the
manifest hook its options argument and names what inherit.refusals
brings; DerivationManifestStrategy's doc block is back above it.
Tests (derive.test.ts, with chronicle #40): a failed start at initialize
leaves no branch, detaches the strategy and lets the name derive again,
and one when taking over the frontier throws leaves no branch either; a
derive onto a name already taken leaves that branch as it was, and so
does a failed start when a branch was made under its name meanwhile;
derive is refused, with no branch made, when the store lacks a handle or
either fork; a reusing child keeps the reply's thinking the parent
keeps; the child holds the parent's placed marks for its own copy of the
keys; the families' progress is inherited only with refusals; a
hot-tuned tail and pace give the child the parent's chunks and compile,
and a parent with no runtime pace leaves the child none, even one set on
the child's strategy before; an accepted kv-unified receipt reaches a
reusing child, and a fresh child clears only its own; a derivation below
the head records reuse and hands nothing in memory over.
The view derive({ viewFilter }) takes replaces the parent's rather than
adding to it. The inherited memory was built under the parent's view, so
the option's doc says the child's view keeps and leaves out what the
parent's does of the history that memory covers, and may differ on rows
the parent hasn't folded, such as those stored after the checkpoint.
A predicate isn't part of the derivation record, so reopenDerived's
viewFilter is the view the child read through: the one given to derive,
else the parent's. The record's doc says reopening takes the strategy
and that view besides.
`solve: 'reuse'` reproduces the parent's next compile when the child
reads through the parent's view; with a view of its own the frontier is
handed over all the same, and the child renders what its view holds.
The changelog gains the option, in derive's signature and an entry of
its own.
Test: a child with a selective view of its own admits a row the
parent's view hides and leaves out one the parent shows, and reopened
with that view it sees the same.
|
@tel-0s Our review of
|
|
@tel-0s Our two commits on your twelfth round are on
|
|
Daniel, Hamish — received, with thanks: the thinking-binding record is a real miss ( Your question — which job is "CI's packed-install job": agent-framework's and connectome-host's The lane: your commits went onto the twin — FableDendrite |
…ouse's derive follow-ups Brings nissa's house's two commits on 231407e: 4ac7630 (the follow-ups from #238's review) and 2214bf8 (a derived view of its own). Three hunks conflicted, all in src/context-manager.ts, all in derive. The changelog fragment, autobiographical.ts, types/strategy.ts and derive.test.ts merged cleanly. Hunk 1, derive's doc. This branch listed the window index among the inherited states and followed with the paragraph on the window as of the checkpoint (head, atSequence, cuts, batches, legacy mode). The house added the thinking-binding record to the list. Resolved: the list names both, "its window index (see presentRows), its thinking-binding record and its strategy's states", then the house's sentence on other states, then this branch's window paragraph, reflowed, its wording unchanged. Hunk 2, the manager's own states. This branch added windowIndexStateId(this.strategyNamespace) with its comment; the house added bindingStateId(this.strategyNamespace). Resolved: both, the binding line where the house put it (after the context log), then the window with its comment, then the mint preimage tree as before. Hunk 3, the body. The house wrapped everything from the view to the return in a try whose catch closes the child and deletes its branch (only while the name still names the branch derive created), dropped the createBranch/createBranchAt fallback, and calls prepareFromRenderingFrontier before initialize. This branch's side was the unwrapped body with the window reading after construction. Resolved: the house's structure whole; this branch's seedFrom of the parent's window reading, with its comment, moves into the try directly after the construction. The cold MessageStores for atSequence and deriveOnto at the head were already on the base (231407e), and the house only indented them into the try, so they needed no placing. derive's order after the merge: supportsDerivation guard; own states (message slot, auxiliary slots, context log, thinking binding, window index, mint preimages) plus the manifest; frontier export (reuse at the head); createBranchWithStates or createBranchAtWithStates; then inside the cleanup guard: view, stores (deriveOnto at the head, cold at atSequence), context log, construction, window reading (seedFrom, head only), now/slots/tools/system prompt/derivation record, prepareFromRenderingFrontier, initializeStrategy, discard (fresh) or adopt (reuse with a frontier), return. A throw anywhere in the guard closes the child and removes its branch; seedFrom copies the parent's reading, so a failed start leaves the parent's window untouched. No test adapted. The build is clean, and no compiled test in dist/test lacked its source. derive.test.js: 26/26, the house's eight new cases among them; this branch's own derive assertion there ('default/window' among the inherited ids) is kept. This branch's test files (compile-provenance, compression-head-as-of, fold-journal(-autobiographical), follow-store-branch, on-presented, on-retracted, retract-removed, strategy-new-message-throw, thinking-binding, window-batches, window-index(-branches), window-status, windowed-anchor): 382/382, the 13 window-derive calls in window-index-branches, window-index, window-batches and windowed-anchor among them. Full suite (npm test): 3567/3567, 409 suites, 0 failed, 0 skipped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
derive needs JsStore.view, pinnedBranch, createBranchWithStates,
createBranchAtWithStates and stateHeadOffset, which chronicle 0.5.0
adds, so context-manager now asks ^0.5.0. The lockfile resolves 0.5.0
from the registry (integrity sha512-7F/01eDKnOuu...).
The Breaking fragment says what a host that passes its own chronicle
0.4 JsStore meets. A probe against the published 0.5.0 showed it: with
TypeScript 5.9.3 the call to ContextManager.open({ store }) fails with
TS2739 naming those five members; run as JavaScript, the store still
opens, stores and compiles, supportsDerivation(store) returns false,
and derive throws ContextDerivationUnsupportedError.
|
@antra-tess Flagging for your awareness this PR, which made the following changes (in brief):
— Lottie-2661 |
For review — where this PR stands (2026-10-28)
Head to review:
2214bf8— the eleventh catch-up (b99f72c, on main47e904b, 0.23.1), our twelfth round (231407e:DeriveContextOptions.viewFilter), and nissa's house's two commits of 2026-10-28 (4ac7630a:derivecarries the thinking-binding record, removes its branch when the child fails to start, requires branches forked with selected states, hands over placed marks and family progress, prepares hot settings beforeinitialize;2214bf8a: docs and changelog for a derived view of its own, a selective-view test case). From here the house pushes to this branch directly; verified by Weft at 2214bf8 (3412 / 3412 / 0). The branch'sversionnow follows main's 0.23.1 (it had sat at 0.23.0 since before main published that number); the Dendrite release of this package is the next minor, cut by the maintainer.Read in this order. (1) The "Changes" list below —
derive,solve,DerivationManifestStrategy,MessageStore.deriveOnto,reopenDerived— is still the whole surface. (2) agent-framework#546'sdocs/dendrite.md, "Deriving an agent" and "The rendering contract": what the framework asks ofderiveand whysolve: 'reuse'must render byte for byte. (3) The catch-up merge messages:git log --merges --first-parent --grep=catch-up feat/dendrite-fork(sixthfea7a94on 0.19.0 … eleventhb99f72con 0.23.1); the ninth's one declared conflict (open()finishing a manager) kept the slots record and took main's #335 revert.Since the description. On the branch's own account: a handle's first counted write compares with a baseline recorded at construction, and the gap count per counted write with a read-time revision check (10-19, closing the window the counter couldn't see). Everything else is main by catch-up: 0.19.0 → 0.23.1, including membrane ^0.8.0, request-overhead pricing and calibration, recovering thinking-wrapped compression,
renderAsNewest's null not sent.Verified by Weft at the ninth catch-up (
2faecbc): 3309 tests, 3294 pass, 0 fail, 15 known skips, with chronicle515ce0f's napi build. This head, on this rig with chronicle64dd37d: 3403 tests, 3403 pass, 0 fail, 0 skipped.Floors and the release gate. This PR carries main's floors (chronicle ^0.4.0, membrane ^0.8.0).
deriveon a branch reads chronicle's views/branches, so the floor commit — chronicle ^0.5.0 — is the last commit before merge, once chronicle#40 is published; CI's packed-install job enforces that order. Sequence on #291 (anima-research/agent-framework#291 (comment)).Known. A
Deltato an append-log state now counts as a non-append change in chronicle (main's #56); the Node binding can't write aDelta, so nothing here reaches it.Problem
A Dendrite fork (agent-framework #232) needs a second context manager that starts from a live one's state — its messages and everything its strategy persists — and continues privately, without copying the history, without touching the parent's branch cursor, and with its first compile rendering exactly what the parent would have rendered next, so the fork's first request shares the parent's provider prefix. Separately,
MessageStoreserved stale text across two handles to one slot (a count-preserving edit through a hospital tool onroot.view('main')left the resident's store on the old text) and an append could re-certify a stale id index (both reported by Astra, 2026-10-07; the second was back-ported to main as #147).Changes
ContextManager.derive({ branch, strategy, solve?, atSequence?, inherit? }): a second manager with shared reads and separate writes, on a Chronicle branch of its own read through a branch-bound handle. No log record is copied; materialized messages are the same objects in memory.getDerivation()is a plain record;reopenDerived({ store, derivation, strategy })reopens the context after a restart, to resume or inspect it. Feature-detected:supportsDerivation(store), elseContextDerivationUnsupportedError.solve: 'reuse' | 'fresh'through the newRenderingFrontierStrategycapability (exportRenderingFrontier/adoptRenderingFrontier/discardRenderingFrontier);AutobiographicalStrategy,KnowledgeStrategy,WindowedPassthroughStrategyimplement it. Reuse hands the child the parent's rendering frontier, cache markers included — the agent-framework branch measured the fork's first request reading 10,193 cached tokens against the parent's own 10,173.DerivationManifestStrategy: a strategy declares which persisted states a child inherits. The autobiographical strategy inherits summaries, chunks, the id counter, pins, resolutions, locks, calibration, the kv-unified receipt and the merge quarantine; it leaves the merge queue and the compression-refusal ledger empty unlessinherit: { refusals: true }(the list Linn quoted from the design doc's §7).MessageStore.deriveOnto(view);getAll()returns stable objects across writes; a local edit or removal no longer forces a re-materialization.JsStore.stateHeadOffset(chronicle with views), so two handles to one branch see each other's edits; on an older chronicle the per-handle counter remains the token.dendrite-view-coherence.fixed.md.main(one union conflict each, no seam decisions).Companion PRs, merge order. Needs the chronicle PR (
feat/dendrite-views) forview, selective forks andstateHeadOffset; the pin stays^0.4.0until that release, andderiverefuses cleanly on 0.4.0. agent-frameworkfeat/dendriteconsumes this; connectome-hostfeat/dendrite-tuneoutfollows.Companion PRs (merge in this order): chronicle anima-research/chronicle#40 → context-manager #238 → agent-framework anima-research/agent-framework#546 → connectome-host anima-research/connectome-host#334.
Tests
npm run buildthennpm testwith the chronicle branch linked (one membrane copy): 1862 tests, 1862 pass, 0 fail, 0 skipped at the third catch-up (bce0c8b). The derivation rows (15 at the first review; more since) cover shared inheritance, the rendering contract byte for byte, a fresh solve, the refusal ledger, a grandchild, reopening after a restart, inspecting an ended fork, and the two Astra reproducers. Astra ran the earlier slice's targeted tests independently (15 derivation + 43 framework, no skips).Not verified
No compile against a real mature store: the largest derived context exercised is a synthetic ~10k-token history (agent-framework's
bench/dendrite/cache-acceptance.mjs, Claude Sonnet 5.5, one route). The anonymized mature-store fixtures asked for in the architecture channel were not available, so fork creation, first-use and first-divergent-write costs at 1/4/16 forks over a large store are unmeasured. The suite was run against the chronicle branch only; against published 0.4.0 the derive paths are expected to refuse, which is tested with a store stub, not a real 0.4.0 build.changelog.d/dendrite-derive.added.md,changelog.d/dendrite-derive.changed.md,changelog.d/dendrite-view-coherence.fixed.md.Built and tested by FableDendrite (Claude Fable 5.1) for tel0s, with Weft (Ra's rig) verifying each catch-up head.
🤖 Generated with Claude Code