Skip to content

feat(view): blocks with an audience, and the reader a manager compiles for - #364

Open
nissa-seru wants to merge 5 commits into
anima-research:mainfrom
nissa-seru:hamish/cm-audience-up
Open

nissa-seru wants to merge 5 commits into
anima-research:mainfrom
nissa-seru:hamish/cm-audience-up

Conversation

@nissa-seru

@nissa-seru nissa-seru commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

This lets a content block name the readers it's for, in a block-level audience field, and lets ContextManager.open take a viewReader, the reader a manager compiles for. Managers over one message slot can then each compile only the blocks meant for their reader. On main at fb4cf1046, such a block reaches every manager's compile with the field on it. With this change, a manager with a viewReader keeps the blocks with no audience and those whose audience names its reader, without the field. A manager opened with no viewReader compiles for the reader its namespace records, and one that compiles for no reader keeps no block that has an audience. An editMessage that may narrow a block's audience is refused wherever compression may have read the block. For a host that sets no viewReader and writes no audience field, the byte check's hashes of every compile and summariser request, in ten dumps from three probe harnesses, were the same as main's (Tests says where they ran and what the hashes leave out), and under AutobiographicalStrategy each write makes 9 calls to MessageStore.mayHoldAudience(). "What changes for callers" lists what else a caller can meet.

@antra-tess, we're asking you to review this before we merge it, since it adds API: the viewReader option of ContextManager.open, a read-only ContextManager.viewReader getter, the block-level audience field and the ContentBlockWithAudience type for it, the contentForReader export, the optional marksCompressionReads field of ContextStrategy and markCompressionRead method of StrategyContext, and a public MessageStore.mayHoldAudience() method.

Problem

A host whose agents share one message slot may need to write a content block meant for some of them only, such as a line naming a message's channel the way one agent's tools take it. On main at fb4cf1046, such a block reaches every manager's compile with its audience field on it. A run there opens two managers over one store, each in its own namespace and with PassthroughStrategy, and adds one message holding a block whose audience names ann, one whose audience names bob, and a plain block. Each manager's compile held all three blocks, and both audience blocks still carried the field:

PROBE ann's compile: ["[for ann]","[for bob]","Hello, both."]; blocks with an audience field: [["[for ann]",["ann"]],["[for bob]",["bob"]]]
PROBE bob's compile: ["[for ann]","[for bob]","Hello, both."]; blocks with an audience field: [["[for ann]",["ann"]],["[for bob]",["bob"]]]

The probe is under "The run at fb4cf1046" below.

Changes

The reader's view

  • A content block may carry audience, a list of the readers it's for. addMessage and editMessage take such blocks as ContentBlockWithAudience. ContextManager.open takes viewReader. The strategy-facing view, after viewFilter, keeps a block with no audience or one whose audience names the reader, without the field, and leaves out a message whose every block is someone else's. A manager opened with no viewReader compiles for the reader its namespace records ("A namespace's reader"), and one that compiles for no reader keeps no block that has an audience. An audience that isn't a list of strings names no one. Tests in test/audience-view.test.ts:
    • "reaches only its readers' compiles, without its audience, and a manager with no reader sees none";
    • "leaves a message whose every block is someone else's out of the view, sending no empty message";
    • "applies the reader's view after the view filter, which sees the message as stored";
    • "treats an audience that isn't a list of strings as naming no one".
  • What a compile reads or reports for a reader is that reader's view: summaries, pinned ranges and renderAsNewest; the message the strategy's onNewMessage is handed; compile provenance's rawBodies and completeness; the thinking binding's fallback; and the raw replay forms a hidden block shares with its message's other blocks. Tests:
    • "reaches only its reader's summaries, pinned ranges and renderAsNewest";
    • "hands the strategy's onNewMessage the reader's view of a message, and none it sees nothing of";
    • "reports raw bodies and their completeness by the reader's view, with a viewFilter set as without";
    • "takes a hidden block's raw replay form off the blocks that share it";
    • in test/thinking-binding.test.ts, "vouches for a row holding another reader’s block by the reader’s view of it, as passthrough sends it";
    • in test/compile-provenance.test.ts, "counts a shard every block of which is another reader's as no part of this reader's body, and no other empty shard".
  • An edit's copies in the context log are written as the reader sees them, and dropped where the reader sees nothing ("writes an edit's copies in the context log as the reader sees them, and drops them where it sees nothing").
  • Reads of the store. For a manager with a viewReader, getAllMessages returns a message with all three of its blocks, one of them another reader's (test/audience-view.test.ts:93, in "reaches only its readers' compiles, without its audience, and a manager with no reader sees none"). The README now says "getMessage, getAllMessages and the queries return messages as stored".
  • Where the view stops. The viewReader doc says: "The view holds through the manager: a strategy whose select or previewContext is called directly reads whatever view and options its caller hands it." (src/context-manager.ts:224–:227).

Where an audience is refused

  • A write may put an audience only on a message's own blocks other than tool calls, tool results, system changes and inline media. A write that puts one on any of those, in a block inside another block or in a context entry, or one that isn't a list of reader names, is refused, saying why. A field named audience elsewhere, such as in a tool call's input or in MCP's annotations, is stored as written. Tests: "refuses an audience at write wherever a write may not put one: a tool call or result, a system change, a block inside another", and "refuses an audience in a context entry, where no view reads one".
  • A compile given an audience in an injection or in opts.messageInjections is refused (MalformedContentError), naming the place, before the strategy selects anything, and so is a previewContext given one in opts.messageInjections. A compile whose injection has no audience still places it. Tests: "refuses a compile given an injection that declares an audience, before it selects anything", "refuses a preview given select options whose messageInjections declare an audience" and "finds the same nested audiences at write and in a compile's injection".
  • A message holding a block with an audience is stored whole, never split into shards at ingress ("keeps a message that holds an audience block whole, never sharded at ingress").

A namespace's reader

  • A namespace records the first viewReader it's opened with, in <namespace>/reader. An open, a switchBranch, a fork or a setStrategy for another reader is refused, naming both. A refused open registers and writes nothing, a refused fork leaves no branch behind, and a refused switchBranch leaves the manager on the branch it was on. A record that isn't a reader's name refuses a fork, a setStrategy and a switchBranch by a manager with a reader, saying so. An empty viewReader is refused. An empty namespace and an omitted one share one record, and so do an omitted one and 'default'. A start that fails keeps the claim, whether an open, a switch or a fork wrote it. Tests, under "a namespace's reader record" and "a reader's compile, provenance, binding and namespace", include:

    • "refuses a second reader opening while the first one's strategy is still starting";
    • "refuses another reader before registering anything, auxiliary slots included";
    • "refuses a switch to a branch whose namespace compiles for another reader, and stays where it was";
    • "refuses setStrategy on a branch whose namespace compiles for another reader";
    • "keeps one record for an empty namespace and an omitted one, which share a context log";
    • "keeps summaries written before a reader claims the namespace, and that reader's compiles use them";
    • "refuses a fork when the head records another reader, before it makes a branch";
    • "refuses a fork, a setStrategy and a switch on a record that isn't a reader's name, naming it as such";
    • "keeps one record for an omitted namespace and 'default'";
    • "keeps the claim a switch or a fork wrote when the strategy then fails to start there".
  • A manager opened with no viewReader, on a namespace that records a reader, compiles for that reader, and so does its compression. After such a manager compresses ann's namespace, each of her 20 audience blocks is in her compile, raw or in a summary, and the two managers' compiles are the same. A manager that compiles for no reader is refused a switchBranch, a fork or a setStrategy onto a branch where its namespace records a reader, and the fork is refused before it makes its branch. Tests:

    • "records a namespace's reader and refuses another, while an open with no reader compiles for the namespace's";
    • "compiles and compresses for the namespace's reader when opened with no reader, so it neither reads her material as no one nor thins her memory";
    • "refuses a manager that compiles for no reader a switch, fork or setStrategy where its namespace records one".
  • A manager's reads through its views, and its compile, previewContext, tick and editMessage, check the namespace's record. The views a manager reads its messages through, the one it hands its strategy and the one compile reads raw bodies through, call the check at each read (getAll, get, sequenceOf, getFrom, getTail and length), and compile, previewContext and tick call it on entry, since at those three calls the manager hands a strategy its view, and a host's own strategy might write before it reads. editMessage calls it on entry too. The check refuses when the namespace's record on the store's current branch names a reader the manager doesn't compile for, or names any reader for a manager that compiles for none. Tests:

    • "refuses a compile, preview, tick or edit once the namespace records a reader the manager doesn't compile for", for a manager opened with no reader before another manager claimed the namespace, and for one whose store was switched to a branch where another reader is recorded;
    • "refuses a compile before a host strategy's select runs, once the namespace records a reader the manager doesn't compile for";
    • "refuses every read through a view its strategy holds, once the namespace records a reader the manager doesn't compile for": a manager opened with no reader compiles once under a strategy that keeps the view its select gets, and each of the six reads through that view is answered before a manager for ann claims the namespace and refused after it;
    • "summarises nothing more of a namespace another manager claimed, through a drain that began before the claim": that manager's strategy, opened with no reader, has its first summary request out when another manager claims the namespace for ann, then asks its model nothing more, and the refusal is logged.
  • The summary reads. A probe at this head (Tests) asks searchSummaries, getSummariesInRange and getSummary in two cases. In the first, a manager is opened with no reader on a namespace, and then a manager for ann claims it, adds messages, some with a block whose audience names her, and compresses. The first manager's three reads are answered, and none of them holds a marker from her blocks. In the second, a manager for ann is opened on another namespace, the store is switched to a branch where a manager for bob claims that namespace and compresses, and ann's manager's three reads are refused by its strategy. It prints:

    PROBE {"nobodyAfterClaim":{"search":false,"range":false,"getSummary":false},"annTOnBobsBranch":{"search":"refused: AutobiographicalStrategy.searchSummaries requires reinitialization for the current branch generation","range":"refused: AutobiographicalStrategy.listSummariesInRange requires reinitialization for the current branch generation","getSummary":"refused: AutobiographicalStrategy.getSummary requires reinitialization for the current branch generation"},"annSummaries":5,"bobSummaries":5}
    
  • Why the record refuses another reader. A namespace's summaries and context log are written from its reader's view ("The reader's view"), and the refusal says so: "its summaries and context log hold that reader's view, so another reader's would read them. Give each reader a namespace of its own." (src/context-manager.ts:308–:309).

  • The record is a write to the namespace's state, and so is each rise in the namespace's record of what compression has read ("Narrowing an audience after compression"). A message stored on a branch after one namespace's reader is recorded there carries a sequence number one higher than with no reader, and after two namespaces' readers are recorded, two higher (Tests). In a full dump of 62 compiles over two stores, every compile's provenance and raw bodies with one reader differ from main's only by such numbers, each higher by 1 to 8, with the fields Tests names left out.

  • derive records on the child's branch the reader the parent compiles for. A child of a manager that compiles for no reader records what the parent's head records, a reader or none, whatever the checkpoint recorded. The child compiles for the reader its branch records, and reopenDerived reopens it for that reader. A head that records another reader refuses a derive by a manager that compiles for a reader, and a record that isn't a reader's name refuses any derive, each before the child's branch is written. Tests in test/derive.test.ts, such as "derives a child that compiles for the parent's reader, records it on its branch, and reopens with it", "derives, from a manager opened with no reader on a claimed namespace, a child that compiles for the namespace's reader, live and reopened", "records the namespace's reader on an inspection's child from before the first record", "records on a child what the namespace records at the parent's head, a cleared record included", "refuses a derive whose head another reader holds, before it writes the child's branch" and "refuses a derive over a head record that isn't a reader's name, with a reader and without one".

  • viewReader, a read-only getter on ContextManager, answers the reader a manager compiles for: the viewReader it was opened with or, opened with none, the reader its namespace records; for a child derive or reopenDerived made, the reader its branch records; or undefined. Assertions at :726, :742, :787, :892, :928 and :929 of test/derive.test.ts, in four tests, and at :420, :878, :949 and :971 of test/audience-view.test.ts, in four, ask it. At :787, a manager opened with no reader on a namespace that records ann, its live child and that child reopened each answer 'ann'.

Narrowing an audience after compression

  • An editMessage that may narrow a block's audience is refused wherever compression may have read the block. Narrowing is hiding a block in place from a reader who saw it: a block with no audience given one, or a name taken out of one. Blocks are compared by position. The refusal says why: nothing takes a block out of a summary already written or a request already sent, a removal included. An edit that hides nothing, or widens an audience, goes through, and so does one away from the manager's own reader on a message newer than anything compression has read. Tests in test/audience-view.test.ts, under "an edit that narrows a block's audience", include:
    • "is refused away from the reader once compression has read the message, and the message stays as it was";
    • "is refused once a chunk holding the message has closed, before any request reads it";
    • "is refused while a request that may carry the message is out";
    • "goes through away from the reader on a message newer than anything compression has read".
  • Away from the manager's own reader, the edit is refused once a chunk holding the message, or a later one, has closed, or a compression request has read it. Each namespace records on each branch the newest store sequence compression may have read, in <namespace>/narrowing-floor. A strategy raises it through the new StrategyContext.markCompressionRead before the read, and it never lowers. AutobiographicalStrategy raises it as chunks close, before a merge, and before a transition summary reads the head window. Whichever strategy or manager on the namespace did the reading, the record refuses the edit, across a reopen, a switch to a passthrough strategy, a fork and a derive. Tests:
    • "is refused where another manager on the namespace has read the message for compression";
    • "is refused after a switch to a strategy that keeps no summaries, where compression read the message before";
    • "is refused after a request that wrote no summary, after a reopen too";
    • "is refused on a branch forked after compression read the message, and decided on each branch by its own compression", which also edits through a derived child;
    • "is refused once a chunk holding the message has closed as its strategy starts, over messages another manager added", and the same "in a compile" and "as a compression hold is released";
    • "is refused for a message in a closed chunk held back from compression, and for a head message older than a closed chunk";
    • "is refused for a message in a chunk that closed in a rebuild that then failed";
    • "is refused for a message a merge has read, where nothing else recorded it";
    • "keeps its record of what compression has read when a later mark names an older message".
  • While a raise fails, the strategy sends no compression request and its chunks wait, pending. Once a raise succeeds, requests go out. A merge whose raise fails counts as no attempt, and runs once a raise succeeds. A transition summary whose read can't be recorded makes no request. Tests: "sends no request whose messages the record couldn't first cover, and later requests read the messages as they stand", "counts nothing against a merge whose read can't first be recorded, and runs it once one can" and "makes no transition request when what it reads can't first be recorded".
  • Away from any other reader, the edit is always refused. So is one away from the manager's own reader while another namespace on the branch records the same reader, or records something that can't be read, since one manager can't see what another's compression has read. A manager that compiles for no reader is refused a narrowing as one away from another reader, and may still widen. An edit away from the manager's own reader is refused, too, when its namespace's record of what compression has read can't be read. Tests: "is refused away from another reader, and by a manager compiling for no reader, whatever their summaries", "is refused away from the reader where another namespace on the branch records the same reader", "is refused away from the reader where another namespace's reader record can't be read" and "is refused when the record of what compression has read can't be read".
  • A manager that compiles for a reader refuses to start a strategy that doesn't declare marksCompressionReads, a new optional field of ContextStrategy. Its doc says the strategy calls StrategyContext.markCompressionRead before it reads a message for a summary or a model request, or makes no such read. The check comes before the strategy is handed anything: in setStrategy, which then leaves the manager on the strategy it had, in derive, and as a strategy starts for open, fork, switchBranch and reopenDerived. PassthroughStrategy, WindowedPassthroughStrategy and AutobiographicalStrategy declare it (src/strategies/passthrough.ts:35, src/strategies/windowed-passthrough.ts:262, src/strategies/autobiographical.ts:3268), and KnowledgeStrategy extends AutobiographicalStrategy. A manager that compiles for no reader opens such a strategy. Test: "refuses to start a strategy that doesn't say it records what compression reads, for a manager that compiles for a reader, before handing it anything".
  • A manager that compiles for no reader writes no record ("writes no record of what compression has read for a manager that compiles for no reader").
  • resetHeadWindow() without text, under a manager that compiles for a reader, stores the transition summary it makes from that reader's view as a block whose audience names that reader, and stores text a host gives as given ("keeps a transition summary made from a reader's view to that reader, and stores a host's own transition text as given").

A store with no audience block

  • The view asks at each listing read (getAll, getFrom, getTail, length) and at sequenceOf whether a slot the manager reads may hold a block with an audience, through MessageStore.mayHoldAudience(). While none may, it answers as the slots do. Tests under "a store whose slots hold no audience block", such as "reads its slot through the plain view on every write, until a block with an audience is stored" and "takes the reader's view once a manager on the same store object stores a block with an audience".
  • contentForReader is exported beside filterMessageStoreView.

What changes for callers

  • A block with an audience field in a place the view reads is left out of the view of a manager that compiles for a reader it doesn't name, and of every manager that compiles for no reader. One in a write or an injection that "Where an audience is refused" lists is refused. An editMessage that may narrow one is refused where "Narrowing an audience after compression" says, and always by a manager that compiles for no reader. On main at fb4cf1046, such a field is stored and compiled as written (Problem). In @animalabs/context-manager 0.24.2, addMessage takes @animalabs/membrane's ContentBlock[], and none of the block types in membrane 0.8.0's or 0.9.0's ContentBlock declares an audience field.

  • With viewReader set, messages stored on a branch after a reader is recorded there carry higher sequence numbers: one higher with one namespace's reader recorded there, and two higher with two ("A namespace's reader"), and one more each time the namespace's record of what compression has read rises ("Narrowing an audience after compression").

  • A strategy a host runs under a viewReader is refused by open, setStrategy, derive, fork, switchBranch and reopenDerived unless it declares marksCompressionReads ("Narrowing an audience after compression"). A manager with no viewReader runs one that doesn't.

  • A host with no reader makes 9 mayHoldAudience calls per write under AutobiographicalStrategy, the same at 2,000 and 20,000 messages. Tests gives the count and a CPU profile. This head's AutobiographicalStrategy also walks every chunk's messages for the newest sequence each time rebuildAndMark rebuilds its chunks (src/strategies/autobiographical.ts:3281–:3291), whether or not the manager has a reader, and with no reader the manager's markCompressionRead returns before it writes anything (src/context-manager.ts:1080). Tests says why this description gives no timing figure.

  • A derive after close(), or after a strategy start that failed, makes the child's branch, and with a viewReader set it now also writes the reader record there, while fork refuses both. This change adds no check for a closed manager or a failed start to derive. A probe at this head (Tests) opens two managers with viewReader: 'ann', each in its own namespace over a store a third manager opened, closes the first and fails the second's setStrategy, then forks and derives from each:

    PROBE closed, reader ann, fork: threw: ContextManager.fork after close()
    PROBE closed, reader ann, derive: resolved (after-close-derive)
    PROBE after-close-derive's agents/x/reader: "ann"
    PROBE reader ann, failed setStrategy: threw: initialize failed (probe)
    PROBE reader ann, fenced fork: threw: ContextManager.fork requires successful strategy initialization (selected branch main). Inspect currentBranch() and recover with switchBranch() or setStrategy().
    PROBE reader ann, fenced derive: resolved (fenced-derive)
    PROBE fenced-derive's agents/y/reader: "ann"
    

With #350

@tel-0s, #350 (4d04b425ae) changes src/context-manager.ts, src/strategies/autobiographical.ts and README.md too. On October 30 at 22:01 UTC, git merge-tree of #350 with main at fb4cf1046 gives no conflict. With main and this change merged, it conflicts in README.md, one block, in src/context-manager.ts, eleven blocks, and in src/strategies/autobiographical.ts, one block. Of the other fifteen pull requests open then, #140 is below, and the other fourteen merge with main and this change as they do with main alone.

With #140

@antra-tess, #140 (a4b4054ae1) changes compile's doc comment in src/context-manager.ts, where this change adds a paragraph. On October 30 at 22:01 UTC, git merge-tree of #140 with main at fb4cf1046 gives one conflict block there. With main and this change merged it gives one block in the same place, which differs only in that this change's side also holds that paragraph: "An injection's block that declares an audience, at its top level or in a tool result's content, is refused (MalformedContentError) before anything is selected, and so is one in opts.messageInjections: no view reads an audience there."

Tests

At a clean checkout of a4c31d4cf, on Linux with Node 24.22.2 and npm 11.19.1, after npm ci (@animalabs/chronicle 0.5.1, @animalabs/membrane 0.9.0, @types/node 22.19.15, typescript 7.0.2), npx tsc exits 0.

  • Seven test files, the four this change adds or edits and three others: audience-view 80, derive 35, compile-provenance 38, thinking-binding 62, malformed-content 22, window-edge-summaries 34 and overbudget-diagnostics 158. Every test passes, and none is skipped.

  • The read count, from the test file below, added for the run: a manager under AutobiographicalStrategy builds N messages, then counts calls through MessageStore.prototype over 100 more writes and one compile. mayHoldAudience is called 9 times per write and 24 times per compile, and getAll 6 times per write and 18 times per compile, at N = 2,000 and 20,000, with no reader and with one.

  • Sequence numbers with two readers, from the second test file below, added for the run: two managers open over one store, in agents/ann and then agents/bob, each with PassthroughStrategy, and ann's adds one message. Its sequence number is 1 with no reader, 2 with ann's alone and 3 with both.

  • derive and fork with a reader, after close() and after a failed setStrategy, from the third test file below, added for the run. It prints the lines quoted under "What changes for callers".

  • The getter. With the built getter changed to return undefined, derive fails exactly three tests, at :726, :787 and :929, and audience-view three, at :420, :878 and :949. Changed to return 'ann', derive fails three, at :742, :892 and :928, and audience-view one, at :971.

  • The narrowing tests. In another run at a clean checkout of this head, tsc exits 0, and 41 test files pass 1,501 tests of 1,501. With each of 18 mutants of the code "Narrowing an audience after compression" describes applied in turn, audience-view fails one or two of its 80 tests, each one that section names, and passes the rest.

  • The summary reads, from the fourth test file below, added for the run. It prints the line quoted under "A namespace's reader".

  • Tool blocks stored with an audience before this change, from the fifth and sixth test files below. At a clean checkout of fb4cf1046, after npm ci and npx tsc with the fifth file in test/ (each exit 0), the fifth writes a store. This head's build, with the sixth file added, compiles it for ann, for bob and for no reader, under PassthroughStrategy, WindowedPassthroughStrategy and AutobiographicalStrategy. They print the lines quoted under "What this leaves".

  • The byte check. Three probe harnesses, kept outside this repository, build stores and write, in order, a hash of the JSON of every compile's messages and of every summariser request, and a hash of every compile's provenance and raw bodies with every field named id, messageId, sourceMessageId, compileId, timestamp, createdAt or created left out, each Date written as 'date' and each Map reduced to its values:

    • an edge-window harness at summary sizes of 0, 1,500 and 3,000 characters, under AutobiographicalStrategy and KnowledgeStrategy, which makes six dumps;
    • a growth harness over three stores, and a thinking-growth harness, each under both strategies, which make four.

    They ran at b061700ba, this head's parent, which has this head's src, with no viewReader and with one, and twice with fb4cf1046's versions of the twelve src files this change edits. That build's tsc exits 2 on six errors, all in test files that use this change's API, and still emits.

    • With no viewReader, every dump is byte for byte main's, and main run twice is identical: 15,790 compiles and 6,001 summariser requests.
    • With viewReader set, every dump's message and summariser-request hashes equal main's, and its provenance and raw-body hashes differ.
    • In a full dump of 62 compiles over two of the edge-window harness's stores at size 0 under AutobiographicalStrategy, with the same fields left out, 5,200 values differ. Every one is main's value plus a number from 1 to 8: the message sequence numbers in raw bodies and provenance, layout.members, and the number in each layout.memberIds id.
  • A CPU profile of a store with no reader. On a build of 4c4a6f2e3, the 0.24.1 release, with an earlier version of this change applied, one node --cpu-prof pass of zz-len-cost.test.ts, a test kept outside this repository, sampled 174,074.5 ms. The test builds 20,000 messages under AutobiographicalStrategy with no reader, makes 400 more writes, 2,101 length() reads of the strategy-facing view and a compile, then closes the manager, opens it again on the same store and compiles again. A like pass ran with 4c4a6f2e3's versions of the nine src files. That build's src differs from this head's in the reader checks this head adds to switchBranch, fork, setStrategy, derive, compile, previewContext and tick, and to each read through the view a manager hands its strategy; in the reader that a manager opened with no viewReader, and a derived child, compile for; in how the record is read; in the refusal of an injection with an audience that this head adds to compile and previewContext, through a helper that a write's check of nested audiences now calls too; in the exports; in doc comments; in the viewReader getter and the name of the private field it returns; by main's perf(autobiographical): group the stored summaries by id once when resolving the persisted canonical summaries #362, a change to src/strategies/autobiographical.ts that this head's base carries; in what "Narrowing an audience after compression" describes; and in every other change on main from 3383f8dfd to fb4cf1046, which this head merges.

    • Functions sampled only in this change's pass, in message-store, message-view, context-manager and well-formed-content, came to 1,161.6 ms: mayHoldAudience 1,033.1 ms, message-view.js's 109.1 ms, currentGapCount 11.2 ms, and others 8.1 ms. currentGapCount, revisionLeftByLastCountedWrite and finish, 0.6 ms of the others, are main's functions. mayHoldAudience calls the first two (src/message-store.ts:1268 and :1274).
    • Adding slotRevision's rise in self time over main's pass, 440.2 ms against 195.5 ms, gives 1,406.3 ms, 0.81% of this change's pass. That rise compares two passes, and both ran beside other jobs on the machine.
    • Counted against the pass's 20,400 addMessage calls alone, 1,406.3 ms is 68.9 µs per write. Sampled times for small functions that run very often are likely overstated.
  • No timing figures. Timed comparisons of main and this change were run, on a machine busy throughout, each beside other jobs on it. So this description gives no figure from them.

test/zz-read-count.test.ts
// How many times the read-time check runs per write and per compile, on a store with no audience
// block: a count, not a timing. AutobiographicalStrategy as in zz-len-cost, N messages first.
import { test, mock } from 'node:test';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { AutobiographicalStrategy, ContextManager, MessageStore } from '../src/index.js';

const N = Number(process.env.N ?? 2000);
const WRITES = Number(process.env.WRITES ?? 100);
const READER = process.env.VIEW_READER || undefined;

test(`read-time checks per write and per compile at N=${N}`, async () => {
  const dir = mkdtempSync(join(tmpdir(), 'cm-read-count-'));
  const membrane = {
    complete: async () => ({ stopReason: 'end_turn', content: [{ type: 'text', text: 'summary' }], usage: { input_tokens: 1, output_tokens: 1 } }),
  } as never;
  const strategy = new AutobiographicalStrategy({ compressionModel: 'm', headWindowTokens: 0, recentWindowTokens: 1_000_000_000 });
  const cm = await ContextManager.open({ path: join(dir, 'store'), strategy, membrane, ...(READER ? { viewReader: READER } : {}) });
  const row = (i: number) => [{ type: 'text' as const, text: `row ${i} ${'word '.repeat(34)}` }];
  for (let i = 0; i < N; i++) cm.addMessage(i % 2 ? 'agent' : 'user', row(i));
  const checks = mock.method(MessageStore.prototype, 'mayHoldAudience');
  const getAlls = mock.method(MessageStore.prototype, 'getAll');
  for (let k = 0; k < WRITES; k++) cm.addMessage(k % 2 ? 'agent' : 'user', row(N + k));
  const perWrite = { checks: checks.mock.callCount() / WRITES, getAll: getAlls.mock.callCount() / WRITES };
  checks.mock.resetCalls();
  getAlls.mock.resetCalls();
  await cm.compile({ maxTokens: 2_000_000_000, reserveForResponse: 0 });
  const perCompile = { checks: checks.mock.callCount(), getAll: getAlls.mock.callCount() };
  console.log('COUNT ' + JSON.stringify({ reader: READER ?? null, N, writes: WRITES, perWrite, perCompile }));
  checks.mock.restore();
  getAlls.mock.restore();
  await cm.close();
  rmSync(dir, { recursive: true, force: true });
});
test/zz-seq-probe.test.ts
// How many sequence numbers a message stored after two managers open over one store, in two
// namespaces, sits above the same message with no reader: no readers, ann's only, and both.
import { test } from 'node:test';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { ContextManager, PassthroughStrategy } from '../src/index.js';

for (const [arm, annReader, bobReader] of [['none', undefined, undefined], ['ann only', 'ann', undefined], ['both', 'ann', 'bob']] as const) {
  test(`sequence after two opens: ${arm}`, async () => {
    const dir = mkdtempSync(join(tmpdir(), 'seq-probe-'));
    const ann = await ContextManager.open({ path: join(dir, 'store'), namespace: 'agents/ann', strategy: new PassthroughStrategy(), ...(annReader ? { viewReader: annReader } : {}) });
    const bob = await ContextManager.open({ store: ann.getStore(), namespace: 'agents/bob', strategy: new PassthroughStrategy(), ...(bobReader ? { viewReader: bobReader } : {}) });
    try {
      const store = ann.getStore() as unknown as { currentSequence(): number; currentBranch?(): unknown };
      const before = store.currentSequence();
      ann.addMessage('user', [{ type: 'text', text: 'Hello, both.' }]);
      const after = store.currentSequence();
      const stored = ann.getAllMessages() as unknown as Array<Record<string, unknown>>;
      console.log('SEQ ' + JSON.stringify({ arm, before, after, lastMessageKeys: Object.keys(stored.at(-1) ?? {}), lastMessage: JSON.stringify(stored.at(-1)).slice(0, 300) }));
    } finally {
      await bob.close();
      await ann.close();
      rmSync(dir, { recursive: true, force: true });
    }
  });
}
test/zz-derive-fence-reader.test.ts
// Probe: with a viewReader set, does derive after close(), or while initialization has failed,
// still make the child's branch and write the reader record on it? fork beside it as the control.
// Prints one line per case; asserts nothing.
import { test } from 'node:test';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { JsStore } from '@animalabs/chronicle';
import { ContextManager, PassthroughStrategy } from '../src/index.js';

type ViewStore = JsStore & { view(branch: string): JsStore };

const outcome = async (f: () => Promise<unknown>): Promise<string> => {
  try { const v = await f(); return `resolved${typeof v === 'string' ? ` (${v})` : ''}`; } catch (e) { return `threw: ${String((e as Error)?.message ?? e).split('\n')[0]}`; }
};
class Failing extends PassthroughStrategy {
  async initialize(): Promise<void> { throw new Error('initialize failed (probe)'); }
}

test('derive and fork with a viewReader, after close() and after a failed setStrategy', async () => {
  const dir = mkdtempSync(join(tmpdir(), 'derive-fence-reader-'));
  try {
    const owner = await ContextManager.open({ path: join(dir, 'b'), strategy: new PassthroughStrategy() });
    owner.addMessage('user', [{ type: 'text', text: 'Hello.' }]);
    const store = owner.getStore() as unknown as ViewStore;
    const recordOn = (branch: string, id: string): string => {
      if (!owner.listBranches().some((b) => b.name === branch)) return 'no such branch';
      const view = store.view(branch);
      return view.listStates().some((s) => s.id === id) ? JSON.stringify(view.getStateJson(id)) : 'not registered';
    };

    const shared = await ContextManager.open({ store: owner.getStore(), namespace: 'agents/x', strategy: new PassthroughStrategy(), viewReader: 'ann' });
    shared.addMessage('user', [{ type: 'text', text: 'Hello again.' }]);
    await shared.close();
    console.log(`PROBE closed, reader ann, fork: ${await outcome(() => shared.fork('after-close-fork'))}`);
    console.log(`PROBE closed, reader ann, derive: ${await outcome(async () => { const c = await shared.derive({ branch: 'after-close-derive', strategy: new PassthroughStrategy() }); const n = c.getDerivation()?.branch; await c.close(); return n; })}`);
    console.log(`PROBE after-close-derive's agents/x/reader: ${recordOn('after-close-derive', 'agents/x/reader')}`);

    const fenced = await ContextManager.open({ store: owner.getStore(), namespace: 'agents/y', strategy: new PassthroughStrategy(), viewReader: 'ann' });
    fenced.addMessage('user', [{ type: 'text', text: 'Before.' }]);
    console.log(`PROBE reader ann, failed setStrategy: ${await outcome(() => fenced.setStrategy(new Failing()))}`);
    console.log(`PROBE reader ann, fenced fork: ${await outcome(() => fenced.fork('fenced-fork'))}`);
    console.log(`PROBE reader ann, fenced derive: ${await outcome(async () => { const c = await fenced.derive({ branch: 'fenced-derive', strategy: new PassthroughStrategy() }); const n = c.getDerivation()?.branch; await c.close(); return n; })}`);
    console.log(`PROBE fenced-derive's agents/y/reader: ${recordOn('fenced-derive', 'agents/y/reader')}`);
    await fenced.close();
    await owner.close();
  } finally {
    rmSync(dir, { recursive: true, force: true });
  }
});
test/zz-probe-summary-reads.test.ts
import { it } 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 type { ContentBlock } from '@animalabs/membrane';
import { AutobiographicalStrategy, ContextManager, PassthroughStrategy } from '../src/index.js';

const t = (text: string): ContentBlock => ({ type: 'text', text });
const forReaders = (text: string, ...audience: string[]): ContentBlock => ({ type: 'text', text, audience } as ContentBlock);
const markers = () => ({
  membrane: {
    complete: async (request: { messages: Array<{ content: ContentBlock[] }> }) => {
      const input = request.messages.flatMap((m) => m.content).map((b) => (b as { text?: string }).text ?? '').join('\n');
      const found = input.match(/(ANN|BOB)-ONLY-\d+/g) ?? [];
      return { stopReason: 'end_turn', content: [{ type: 'text', text: `SUMMARY ${found.join(' ')} ${'x '.repeat(40)}` }], usage: { input_tokens: Math.ceil(input.length / 4), output_tokens: 25 } };
    },
  } as never,
});
const memory = () => new AutobiographicalStrategy({ compressionModel: 'test-compression-model', targetChunkTokens: 300, headWindowTokens: 0, recentWindowTokens: 0, hierarchical: true });
async function drain(manager: ContextManager): Promise<void> {
  for (let i = 0; i < 500; i++) { if (manager.isReady()) return; await manager.tick(); }
  throw new Error('strategy never became ready');
}
const tried = (f: () => unknown, re: RegExp): boolean | string => {
  try { return re.test(JSON.stringify(f())); } catch (e) { return `refused: ${(e as Error).message}`; }
};
const reads = (m: ContextManager, re: RegExp, id: string | undefined) => ({
  search: tried(() => m.searchSummaries({ regex: re, limit: 1000 }), re),
  range: tried(() => m.getSummariesInRange({}), re),
  getSummary: tried(() => (id === undefined ? null : m.getSummary(id)), re),
});
const leaked = (r: Record<string, boolean | string>) => Object.values(r).some((v) => v === true);

it('probe: a stale manager\'s summary reads hold nothing written for another reader', async () => {
  const dir = mkdtempSync(join(tmpdir(), 'cm-probe-'));
  const host = await ContextManager.open({ path: join(dir, 'store'), namespace: 'host', strategy: new PassthroughStrategy() });
  try {
    const store = host.getStore();
    const home = store.currentBranch().name;
    store.createBranchAt('bobs', home, store.currentSequence());
    const nobody = await ContextManager.open({ store, namespace: 'agents/s', strategy: memory(), membrane: markers().membrane });
    const ann = await ContextManager.open({ store, namespace: 'agents/s', strategy: memory(), membrane: markers().membrane, viewReader: 'ann' });
    for (let i = 0; i < 30; i++) {
      const words = t(`Message ${i}. ${'filler words '.repeat(20)}`);
      ann.addMessage('user', i % 3 === 0 ? [forReaders(`ANN-ONLY-${i / 3}`, 'ann'), words] : [words]);
    }
    await drain(ann);
    const annIds = ann.searchSummaries({ regex: /ANN-ONLY/, limit: 1000 }).map((r) => r.summary.id);
    assert.ok(annIds.length > 0, 'the premise: ann has summaries holding her blocks');
    const r1 = reads(nobody, /ANN-ONLY/, annIds[0]);
    const annT = await ContextManager.open({ store, namespace: 'agents/t', strategy: memory(), membrane: markers().membrane, viewReader: 'ann' });
    store.switchBranch('bobs');
    const bob = await ContextManager.open({ store, namespace: 'agents/t', strategy: memory(), membrane: markers().membrane, viewReader: 'bob' });
    for (let i = 0; i < 30; i++) {
      const words = t(`Message ${i}. ${'filler words '.repeat(20)}`);
      bob.addMessage('user', i % 3 === 0 ? [forReaders(`BOB-ONLY-${i / 3}`, 'bob'), words] : [words]);
    }
    await drain(bob);
    const bobIds = bob.searchSummaries({ regex: /BOB-ONLY/, limit: 1000 }).map((r) => r.summary.id);
    assert.ok(bobIds.length > 0, 'the premise: bob has summaries holding his blocks');
    const r2 = reads(annT, /BOB-ONLY/, bobIds[0]);
    console.log('PROBE', JSON.stringify({ nobodyAfterClaim: r1, annTOnBobsBranch: r2, annSummaries: annIds.length, bobSummaries: bobIds.length }));
    assert.ok(!leaked(r1), 'nobody reads none of ann\'s');
    assert.ok(!leaked(r2), 'annT reads none of bob\'s');
    await bob.close();
    store.switchBranch(home);
    await annT.close();
    await ann.close();
    await nobody.close();
  } finally {
    await host.close();
    rmSync(dir, { recursive: true, force: true });
  }
});
test/zz-r14-write.test.ts
// Run on fb4cf1046's build: a manager with no reader stores, at STORE_DIR, three tool cycles with an
// `audience` where this change's writes refuse one: (A) in a tool result's content, (B) on a tool call,
// (C) on a tool result itself. Each names bob.
import { test } from 'node:test';
import { join } from 'node:path';
import type { ContentBlock } from '@animalabs/membrane';
import { ContextManager, PassthroughStrategy } from '../src/index.js';

const blocks = (content: unknown[]): ContentBlock[] => content as ContentBlock[];

test('store tool blocks with an audience where this change refuses one', async () => {
  const writer = await ContextManager.open({ path: join(process.env.STORE_DIR!, 'store'), namespace: 'agents/writer', strategy: new PassthroughStrategy() });
  writer.addMessage('user', blocks([{ type: 'text', text: 'Ask.' }]));
  writer.addMessage('agent', blocks([{ type: 'text', text: 'Calling A.' }, { type: 'tool_use', id: 'a1', name: 'f', input: {} }]));
  writer.addMessage('user', blocks([{ type: 'tool_result', toolUseId: 'a1', content: [{ type: 'text', text: 'A-RESULT-FOR-BOB', audience: ['bob'] }] }]));
  writer.addMessage('agent', blocks([{ type: 'text', text: 'Calling B.' }, { type: 'tool_use', id: 'b1', name: 'f', input: {}, audience: ['bob'] }]));
  writer.addMessage('user', blocks([{ type: 'tool_result', toolUseId: 'b1', content: [{ type: 'text', text: 'B-RESULT' }] }]));
  writer.addMessage('agent', blocks([{ type: 'text', text: 'Calling C.' }, { type: 'tool_use', id: 'c1', name: 'f', input: {} }]));
  writer.addMessage('user', blocks([{ type: 'tool_result', toolUseId: 'c1', content: [{ type: 'text', text: 'C-RESULT-FOR-BOB' }], audience: ['bob'] }]));
  writer.addMessage('agent', blocks([{ type: 'text', text: 'Done.' }]));
  console.log(`STORED ${writer.getAllMessages().length}`);
  await writer.close();
});
test/zz-r14-read.test.ts
// Run on this change's build over the store the write half made at STORE_DIR: managers for ann, for
// bob and with no reader, each in its own namespace, under three strategies, compile; the probe prints
// each compiled message's role and blocks, as the host would hand them to its provider.
import { test } from 'node:test';
import { join } from 'node:path';
import { AutobiographicalStrategy, ContextManager, PassthroughStrategy, WindowedPassthroughStrategy } from '../src/index.js';

const BUDGET = { maxTokens: 1_000_000, reserveForResponse: 0 };
type Block = { type: string; text?: string; id?: string; toolUseId?: string; audience?: unknown; content?: unknown };
const show = (b: Block): string => {
  const aud = 'audience' in b ? '+aud' : '';
  if (b.type === 'text') return `T:${b.text}${aud}`;
  if (b.type === 'tool_use') return `U:${b.id}${aud}`;
  if (b.type === 'tool_result') {
    const inner = Array.isArray(b.content) ? (b.content as Block[]).map(show).join('|') : typeof b.content === 'string' ? `"${String(b.content)}"` : '';
    return `R:${b.toolUseId}(${inner})${aud}`;
  }
  return `${b.type}${aud}`;
};
const strategies = {
  passthrough: () => new PassthroughStrategy(),
  windowed: () => new WindowedPassthroughStrategy(),
  autobiographical: () => new AutobiographicalStrategy({ compressionModel: 'none', headWindowTokens: 0, recentWindowTokens: 1_000_000 }),
} as const;

test('compile tool cycles stored with an audience before this change', async () => {
  const path = join(process.env.STORE_DIR!, 'store');
  for (const [sname, make] of Object.entries(strategies)) {
    const ann = await ContextManager.open({ path, namespace: `${sname}/ann`, strategy: make(), viewReader: 'ann' });
    const bob = await ContextManager.open({ store: ann.getStore(), namespace: `${sname}/bob`, strategy: make(), viewReader: 'bob' });
    const none = await ContextManager.open({ store: ann.getStore(), namespace: `${sname}/none`, strategy: make() });
    try {
      for (const [name, manager] of [['ann', ann], ['bob', bob], ['none', none]] as const) {
        try {
          const { messages } = await manager.compile(BUDGET);
          console.log(`PROBE ${sname} ${name}: ${messages.map((m) => `${(m as { participant?: string; role?: string }).participant ?? (m as { role?: string }).role}[${(m.content as unknown as Block[]).map(show).join(', ')}]`).join(' ')}`);
        } catch (error) {
          console.log(`PROBE ${sname} ${name} threw: ${String((error as Error)?.message ?? error).split('\n')[0]}`);
        }
      }
    } finally {
      await none.close();
      await bob.close();
      await ann.close();
    }
  }
});

The run at fb4cf1046

At a clean checkout of fb4cf1046, on Linux with Node 24.22.2 and npm 11.19.1, after npm ci (@animalabs/chronicle 0.5.1, @animalabs/membrane 0.9.0, @types/node 22.19.15, typescript 7.0.2), with the file below in test/ as audience-at-base.test.ts, npx tsc exits 0 and node --test dist/test/audience-at-base.test.js prints the two lines quoted in Problem.

test/audience-at-base.test.ts
// Two managers over one store, each in its own namespace, both compiling with
// PassthroughStrategy. One message holds a text block with an `audience`
// field naming ann, one naming bob, and a plain text block. The probe prints
// what each manager's compile holds, and whether each compiled block still
// carries an `audience` field.
import { test } from 'node:test';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { ContentBlock } from '@animalabs/membrane';
import { ContextManager, PassthroughStrategy } from '../src/index.js';

const BUDGET = { maxTokens: 1_000_000, reserveForResponse: 0 };
const block = (text: string, audience?: string[]): ContentBlock =>
  (audience ? { type: 'text', text, audience } : { type: 'text', text }) as ContentBlock;

test('a block with an audience field, compiled by two managers over one store', async () => {
  const dir = mkdtempSync(join(tmpdir(), 'audience-at-base-'));
  const ann = await ContextManager.open({ path: join(dir, 'store'), namespace: 'agents/ann', strategy: new PassthroughStrategy() });
  const bob = await ContextManager.open({ store: ann.getStore(), namespace: 'agents/bob', strategy: new PassthroughStrategy() });
  try {
    ann.addMessage('user', [block('[for ann]', ['ann']), block('[for bob]', ['bob']), block('Hello, both.')]);
    for (const [name, manager] of [['ann', ann], ['bob', bob]] as const) {
      const { messages } = await manager.compile(BUDGET);
      const blocks = messages.flatMap((m) => m.content as Array<{ text?: string; audience?: unknown }>);
      console.log(`PROBE ${name}'s compile: ${JSON.stringify(blocks.map((b) => b.text))}; blocks with an audience field: ${JSON.stringify(blocks.filter((b) => 'audience' in b).map((b) => [b.text, b.audience]))}`);
    }
  } finally {
    await bob.close();
    await ann.close();
    rmSync(dir, { recursive: true, force: true });
  }
});

Not verified

  • The whole suite. No run here ran every test file at this head; this repository's CI does.
  • A real long-lived store with a reader set. The stores in these runs are built by test harnesses, the largest with 20,000 synthetic messages.
  • A host that sets viewReader. No run here used a host outside this repository's tests.
  • Timing on a quiet machine. No timed comparison here ran without other jobs beside it, so none is given.
  • Platforms other than Linux.
  • A crash between a rise in the record of what compression has read and the read it covers. markCompressionRead's doc says "The record is a state write made before the read, so in the store's log it comes before every record derived from the read, and a crash that loses it loses those too", citing chronicle 0.5.0's README (src/types/strategy.ts:142–:145). No run here crashes a store there.
  • The narrowing check on a large real store. No run here timed editMessage's check.
  • A request with an unpaired tool block. No run here sends a compile like the PassthroughStrategy and WindowedPassthroughStrategy ones under "What this leaves" through membrane or to a provider. stripUnpairedToolBlocks's doc comment says "the Anthropic API requires every tool_use to be followed by its tool_result in the immediately-next message (and every tool_result to follow its tool_use)" (src/normalize-tool-messages.ts:81–83).

What this leaves

  • derive resolves on a closed manager whose store another manager owns, and after a failed strategy start, where fork refuses #366. By its run at 3383f8dfd, ContextManager.derive resolves on a closed manager whose store another manager owns, and after a failed strategy start, where fork refuses both. At this head, with a viewReader set, derive still resolves in both cases and writes the reader record, as the probe under "What changes for callers" shows.

  • A compression already waiting on its model when another manager claims the namespace. The viewReader doc says "a compression already waiting on its model when the claim is made writes the summary it was making" (src/context-manager.ts:213–:215). The test "summarises nothing more of a namespace another manager claimed, through a drain that began before the claim" has such a compression out when the claim is made, and asserts that the drain then asks its model nothing more. No test or run here checks whether that compression writes its summary.

  • A message holding a block with an audience is stored whole. With adaptiveResolution on and targetChunkTokens at 100, a long message with no such block is stored as several messages, and the same text with an audience block beside it is stored as one ("keeps a message that holds an audience block whole, never sharded at ingress", test/audience-view.test.ts:192). connectome-host's src/framework-strategy.ts:164–169, at bf21143bdf, sets adaptiveResolution to true for its autobiographical and frontdesk strategy types when it's undefined there. No run here covers how such a message is compressed or selected.

  • Content stored before this change with an audience where a write now refuses one. In the probe under Tests, fb4cf1046's build stores three tool cycles, each with an audience naming bob: (A) on a text block inside a tool result's content, (B) on a tool call, and (C) on a tool result. At this head, every call and result is paired in bob's compiles. In ann's and in the compiles for no reader:

    • under PassthroughStrategy and WindowedPassthroughStrategy, B's call is left out and its result stays, with no call before it, and C's result is left out and its call stays, with no result after it;
    • under AutobiographicalStrategy, the message that held B's result holds the text "[tool call omitted]" instead, and C's call is followed by a result that reads "[this call's result isn't shown in this context. If you need its output and the tool only reads, call it again; you'll get what it returns now.]";
    • A's block reaches every compile with its audience field.

    The probe prints each compiled message's participant and blocks: T: for a text block, U: for a tool call, R: for a tool result with its content in brackets, and +aud where a block keeps an audience:

    PROBE passthrough ann: user[T:Ask.] agent[T:Calling A., U:a1] user[R:a1(T:A-RESULT-FOR-BOB+aud)] agent[T:Calling B.] user[R:b1(T:B-RESULT)] agent[T:Calling C., U:c1] agent[T:Done.]
    PROBE passthrough bob: user[T:Ask.] agent[T:Calling A., U:a1] user[R:a1(T:A-RESULT-FOR-BOB+aud)] agent[T:Calling B., U:b1] user[R:b1(T:B-RESULT)] agent[T:Calling C., U:c1] user[R:c1(T:C-RESULT-FOR-BOB)] agent[T:Done.]
    PROBE passthrough none: user[T:Ask.] agent[T:Calling A., U:a1] user[R:a1(T:A-RESULT-FOR-BOB+aud)] agent[T:Calling B.] user[R:b1(T:B-RESULT)] agent[T:Calling C., U:c1] agent[T:Done.]
    PROBE windowed ann: user[T:Ask.] agent[T:Calling A., U:a1] user[R:a1(T:A-RESULT-FOR-BOB+aud)] agent[T:Calling B.] user[R:b1(T:B-RESULT)] agent[T:Calling C., U:c1] agent[T:Done.]
    PROBE windowed bob: user[T:Ask.] agent[T:Calling A., U:a1] user[R:a1(T:A-RESULT-FOR-BOB+aud)] agent[T:Calling B., U:b1] user[R:b1(T:B-RESULT)] agent[T:Calling C., U:c1] user[R:c1(T:C-RESULT-FOR-BOB)] agent[T:Done.]
    PROBE windowed none: user[T:Ask.] agent[T:Calling A., U:a1] user[R:a1(T:A-RESULT-FOR-BOB+aud)] agent[T:Calling B.] user[R:b1(T:B-RESULT)] agent[T:Calling C., U:c1] agent[T:Done.]
    PROBE autobiographical ann: user[T:Ask.] agent[T:Calling A., U:a1] user[R:a1(T:A-RESULT-FOR-BOB+aud)] agent[T:Calling B.] user[T:[tool call omitted]] agent[T:Calling C., U:c1] user[R:c1("[this call's result isn't shown in this context. If you need its output and the tool only reads, call it again; you'll get what it returns now.]")] agent[T:Done.]
    PROBE autobiographical bob: user[T:Ask.] agent[T:Calling A., U:a1] user[R:a1(T:A-RESULT-FOR-BOB+aud)] agent[T:Calling B., U:b1] user[R:b1(T:B-RESULT)] agent[T:Calling C., U:c1] user[R:c1(T:C-RESULT-FOR-BOB)] agent[T:Done.]
    PROBE autobiographical none: user[T:Ask.] agent[T:Calling A., U:a1] user[R:a1(T:A-RESULT-FOR-BOB+aud)] agent[T:Calling B.] user[T:[tool call omitted]] agent[T:Calling C., U:c1] user[R:c1("[this call's result isn't shown in this context. If you need its output and the tool only reads, call it again; you'll get what it returns now.]")] agent[T:Done.]
    

  • Changelog fragments added: changelog.d/audience-blocks.added.md and changelog.d/audience-blocks.changed.md.

Hamish-2699 made this change, and Oscar-1005 made its design calls. Alfred-2570 found the case "Narrowing an audience after compression" fixes, and Gideon-2613 compared an earlier round of the fix with a design of his own, written before he read it. Desmond-2579 read every round of it. Beth-2389 wrote the three probe harnesses. Our internal review read fifteen rounds of it. Hamish-2699 ran the narrowing tests and their mutants. Rupert-2634 wrote the two-reader probe, and Hamish-2699 the summary-reads probe and the probe of content stored before this change. Daniel-2612 ran the base probe, the tests, the read count, the two-reader, derive and summary-reads probes, the probe of content stored before this change and the byte check at clean checkouts, and wrote this description.

…s for

A host whose agents share one message slot may need to write a content
block meant for some of them only, such as a line naming a message's
channel the way one agent's tools take it. Keeping that block inside its
message's own row gives it the message's lifetime everywhere (removal,
shards, windows, summaries, edits); what's needed is that each reader's
view shows it only to the readers it's for.

A content block may now carry `audience`, the readers it's for, and
`viewReader` names the reader a manager compiles for. The
strategy-facing view, after `viewFilter`, keeps a block with no
`audience` or one whose `audience` names the reader, without the field,
and leaves out a message whose every block is someone else's, as a
filtered message is left out. With no `viewReader` it keeps no block
that has an `audience`. An `audience` that isn't a list of strings, such
as a list with a number in it, names no one. Hosts write such blocks as
`ContentBlockWithAudience`, which `addMessage` and `editMessage` take.

Everything a compile reads or reports for a reader is that reader's
view. Every strategy read that reaches content goes through the view:
select, renderAsNewest, the summary listing, render stats, the ticks
that chunk and summarize, and onNewMessage's message, which is the
reader's view of the new message (whatever the view filter says, as on
main) and isn't offered when the reader sees nothing of it; pins hold
ids and are rendered at select, and the view's sequenceOf answers as its
get does. Compile attribution judges raw bodies by the reader's view of
the unfiltered store, so rawBodies holds the reader's objects and a
block another reader's audience hides isn't counted as not carried; a
message the reader sees none of stays there with no content, so a body's
identity and membership remain the stored group's, and a shard the
reader's view emptied counts as present, being no part of this reader's
body, while any other empty shard still counts as missing. The thinking
binding's fallback for a strategy without renderAsNewest takes a row's
digest from the reader's view. A block the reader doesn't see gives up
the raw replay forms it shares with the message's other blocks (a
Responses item holding several text parts), so a replay can't carry it.
A context entry is its namespace's reader's already, so a write that
puts an `audience` in one is refused; so is a compile given an injection
holding one, at its top level or in a tool result's content, or in
the select options' messageInjections, and so is a preview given one
in messageInjections, since an injection goes into the request as given and no view
reads it. audienceDeclarations finds every audience in such content;
audiencePlaces, which finds only those a message's own blocks can't
carry, takes its nested places from it, so one walk decides where a
nested audience is found for a write and for a compile alike. The view holds through the manager's compile and previewContext:
a strategy whose select or previewContext is called directly reads
whatever view and options its caller hands it. An edit's propagation
into the
context log writes the reader's view of the new content, and removes the
copies of a message the edit leaves the reader nothing of. The other
reads outside the view are identity and existence checks.

Summaries and the context log are kept under each manager's namespace,
so managers over one slot compiling for different readers each need
their own: a namespace records the first `viewReader` it's opened with,
in `<namespace>/reader`. An open, a fork or a derive for another reader
is refused before it writes anything, naming both, since each reads the
record on the head it starts from. Every path that initializes the strategy
(open, switchBranch, fork, derive and setStrategy) claims the manager's
reader on the branch it initializes, in the same synchronous turn as the
check and before the strategy's first await, so two opens for different
readers can't both start; a switchBranch that meets another reader
switches back and refuses, naming both, and the record stays when
a strategy fails to start on an open, a switch, a fork or a setStrategy
(a derive whose child fails to start removes the child's branch, record
and all), since the start may have written that
reader's material there, so only that reader can open the namespace
again until an operator sets the record to null through the store on
each branch it should open on (the record is branch state), which counts
as never claimed. An empty namespace, an omitted one and `'default'`
share one record, `default/reader`, since an omitted namespace shares
its context log with the empty one and its strategy state with
`'default'`; the cost is a refusal between them, which fails closed. An
empty `viewReader` is refused at open, and an empty recorded name counts
as damaged. That record is a write to the namespace's state, so messages
stored on a branch after it is first written there carry sequence
numbers one higher than they would without it, as other state writes
already move them. An open with no reader is allowed, as inspection
tools do; its view holds no audience block, so if it compresses, its
summaries hold none. Summaries written before a reader claims a
namespace are kept, and that reader's compiles use them as written. An
`audience` is read on a message's own blocks other than tool calls, tool
results, system changes and inline media, and a write that puts one
anywhere else, or one that isn't a list of reader names, is refused,
saying why: on a tool call or result a reader's view would hold one half
of the pair, a system change goes to every reader, and a block inside a
tool result's content, the one place blocks nest, isn't read by the
view, at any depth; and the store keeps inline media as a blob
reference, which carries no audience. A message holding a block with an
`audience` is stored whole, never split into shards at ingress, since a
shard joins its blocks' text into new blocks that carry none. A field
named `audience` anywhere else, such as in a tool call's input or MCP's
`annotations`, is no audience of the view's and is stored as before.

A manager derives a child that compiles for its parent's `viewReader`,
as it carries its view filter: a derived context continues its parent's
context for the same reader. The child records its parent's
`viewReader`; a child of a manager with no `viewReader` records what the
parent's head records, a reader or none where an operator cleared it
there. Neither takes what the checkpoint recorded, an earlier reader
before a clear included, since an operator's clear opens the namespace's
earlier material to whichever reader claims next and a child carries
part of that same history; a child started from a checkpoint before the
namespace first recorded a reader is no exception. A parent with a
reader records it even where an operator cleared the head's record while
the parent was open: the child compiles for that reader, so its branch
must record it, or another reader could open the branch over its view.
A head another reader holds is different: the parent's own reader would
relabel that reader's branch state, so the derive refuses, as an open
does, before it writes the child's branch.
`<namespace>/reader` joins the manager's own states that `derive`
carries whenever the namespace has one, beside the thinking-binding
record, so the child's branch keeps the namespace's reader under any
strategy and an open of it for another is refused. `reopenDerived` compiles for the reader the branch recorded,
which, unlike a view filter, is data the branch keeps, and refuses a
record that isn't a reader's name, as every open with a reader does,
naming it as
such. A child derived by a manager with no reader, as an inspection is,
compiles for none, while its branch records the namespace's reader,
which a reopen compiles it for. A manager whose namespace records no
reader derives as before.

A store that never declares an `audience` reads and compiles exactly as
before, and its writes don't list the slot for the view. The view asks
at each listing read (getAll, getFrom, getTail, length) and at
sequenceOf whether a slot the manager reads may hold a block with an
`audience`, and while none may, it answers as the slots do, sequenceOf
from the store's own index; get is decided by the message's own blocks.
Both are decided at each read, not when a view is built, so a holder
that keeps a view, such as the strategy's speculative drain across its
ticks, reads the reader's view whenever it reads. Each MessageStore
knows whether its slot may hold one from the content, malformed content
included, storing nothing new: a write through any MessageStore on the
same store object that stores such a block marks the slot for all of
them, as the write versions are shared, and the slot is read for one at
the first call and again only on another branch or after a write no
MessageStore there counted, the caches' gap test. A slot stays marked
after its block is edited away, so its view stays the reader's. Its
branch-and-revision check is like the one getAllInternal keys its own
cache on. No test can make the branch alone decide it: chronicle's slot
revision is the offset of the slot's newest record in the one log every
branch writes to, so two branches report the same revision only while
they share the same unchanged value. A slot once marked stays marked for
its store object, so a block edited away still counts. Within the view,
a message with no `audience` block passes through as the view's own
object, and a listing with none as its own array; a message that has one
is one derived object per reader while its stored object lives, so the
estimate cache and the strategy's per-listing memos hold. getTail reads
the underlying view's window and maps it, falling back to the listing
only where the window holds a message the reader sees none of; getFrom
reads the window at index 0 and the listing elsewhere, as a filtered
view does, since a message left out before the index moves it.

Tests: two readers over one slot, each one's compile and summaries, and
the first one's pinned range and renderAsNewest, holding only its own
blocks and no `audience` field; a manager with no reader holding none; a
message every block of which is another reader's left out with no empty
message; an edit's context-log copies; rawBodies and completeness with
and without a viewFilter; the binding vouching for an audience row under
passthrough; a namespace recording its reader, refusing another and
allowing none, an open refused by its checks leaving no record, and one
whose strategy fails to start keeping its claim until the record is set
to null through the store; a second reader's open refused while the
first one's strategy is still starting; a refused open registering and
writing nothing; the record written once, a reopen for the same reader
leaving it as it was; a switch to a branch with no record recording the
reader there; setStrategy refused on a branch whose namespace records
another reader, leaving the manager refusing work, and claiming an
unclaimed branch before its strategy starts; an empty namespace and an
omitted one sharing one record; the record kept across a close and an
open by path, and an empty reader refused; the view filter seeing a
message as stored; summaries written before the first claim in that
reader's compile; an audience refused in a context entry at append, edit
and replaceAll; a switchBranch to a branch recorded for another reader
refused, staying where it was; a long message holding an audience block
stored whole where one without is sharded; onNewMessage handed the
reader's view; a hidden block's raw replay form taken off the blocks
sharing it; sequenceOf for a message the reader sees nothing of; an
audience refused at write on tool blocks, a system change, inline media,
a malformed or sparse list and a block nested in tool results, three
deep and seventy deep, and at edit on a tool call, a block nested in a
tool result and a system change, and MCP's annotations.audience and a
tool call's input holding one stored as written; a shard with no content
for the reader counted present, and an empty shard the view didn't make
counted missing; a write path that never lists the slot while no block
has an audience, and the view taken once a co-resident on the same store
object, an edit, a write through another handle (alone, or followed by a
counted write), an auxiliary slot, or a switch to another branch brings
one; sequenceOf from the index while no slot may hold one; a view held
across the first such write, through all six reads, and a speculative
drain begun before it, whose later summariser requests hold none of
another reader's blocks; the view's identity for listings and messages,
a block replaced in place, and a tail read through the window; a derived
child and a reopened one compiling for the parent's reader, the child's
branch refusing an open for another, a child from a checkpoint before
the first record compiling and reopening for the parent's reader, an
inspection's child compiling for none while its branch records the
namespace's reader for a reopen, from the head or from a checkpoint
before the first record, a damaged record refused at reopen, a child
from before an operator's clear taking the head's cleared record, and a
manager with no reader deriving no reader record.

The test file's helpers each stand for setup its tests would otherwise
repeat: withReaders, three managers over one store (ann's, bob's and one
with no reader), each with a summariser that records its requests,
serves seven tests; memory, the compressing strategy, is its default and
serves the test of summaries written before the first claim; and drain,
which ticks a manager until its strategy is ready, serves two. In the
derive tests, spyBranchWrites spies on the store's two branch-writing
calls for the two refusals that must come before either.
A read-only `viewReader` getter on ContextManager answers the reader the
manager compiles for: the `viewReader` it was opened with; for a context
`derive` made, its parent's; for one `reopenDerived` made, the reader its
branch records; undefined when it compiles for no reader. A host that
filters stored content for an agent (a history tool, a fallback that reads
stored rows) can then ask the manager rather than work the reader out
again from its own records, which can disagree with what the manager
compiles for once a name is reused. The private field is renamed so the
getter can take the name; nothing else changes.

Tests: the reader derive tests ask each manager's getter: the parent, its
child and the reopened child answer the parent's reader; a manager with no
reader and its child answer none; and a child derived by a manager with no
reader on a head that records ann answers none live and ann reopened, as it
compiles in each.

@Anarchid Anarchid left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nightly dual review · round R1 at 6dfbd9c · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged and every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: CHANGES_REQUESTED (the formal review event is left to the maintainer). Finding ids R1-n are stable; cite them in fix commits.

Reviewed at 6dfbd9c against main 9c587a8

Verdict: changes requested. One major, four minors, no blockers.

The reader view is well built. Every path by which the manager hands a strategy messages goes through it, no compiled block carries an audience field in any probe we ran, and the change merges cleanly into today's main with the whole suite green. The problem is in what the namespace record guards. It refuses a different reader but lets a manager open with no reader. That manager reads the claimed reader's summaries, which hold that reader's own blocks. If it compresses, it writes summaries built without those blocks into that reader's namespace, and they stay there. Leaving the option out is the likeliest way to misconfigure this feature, and it's the case the record lets through. The change also has no consumer yet, and it overlaps #350 in ways the description checks only textually. See "Before a host sets viewReader" below.

Earlier reviews: none of ours on this PR. This is round 1.

Blockers

None.

Major

R1-1. A manager with no viewReader, opened on a namespace that records a reader, reads that reader's summaries and writes reader-blind ones into it.

  • Evidence. ContextManager.open checks the record only when viewReader is set (src/context-manager.ts:674). initializeStrategy claims only when it's set (:2746). The doc accepts this, "as for inspection, and if it compresses, its summaries hold no block that has an audience, and the namespace's reader compiles from them as written" (:205–208). reopenDerived behaves differently: it resolves an omitted reader from the record (:1610). derive writes the head's reader onto the child's branch (:1455) but builds the live child with the parent's absent reader (:1533). test/derive.test.ts:785 asserts the result: the live child compiles for no reader, and the same child reopened compiles for 'ann'.

  • Probe at this head. Setup: AutobiographicalStrategy with targetChunkTokens: 300 and recentWindowTokens: 0, and a summariser that reports which ANN-ONLY-n markers it was shown. We wrote 60 messages; every third holds a text block with audience: ['ann'].

    • (a) Ann opens agents/ann, compresses and closes. Then a manager opens agents/ann with no viewReader. Its compile holds Ann's summary, and that summary names her ann-only blocks. The README says "A manager with no viewReader holds no block that has an audience". That is true of raw blocks and false of what was summarised from them.
    • (b) Ann opens agents/ann, which claims it, writes the same messages and closes before anything compresses. A no-reader open of agents/ann then compresses, and Ann reopens. The summary in her compile was written from the no-reader view: 8 of her 20 ann-only blocks are now in nothing she compiles. The damage is persisted.
  • Failure scenario. Someone writes a restart, recovery or tooling path that rebuilds the open options and leaves out viewReader. Nothing refuses it. Its compiles carry the reader's compressed private material, and its compression permanently thins that reader's memory. The record exists to prevent exactly this ("its summaries and context log hold that reader's view, so another reader's would read them", :278).

  • Fix. Make an omitted viewReader mean "the reader this namespace records" everywhere:

    • in open, as reopenDerived already does;
    • for derive's live child, so live and reopened children agree.

    An inspection open then compiles the recorded reader's view, which is the faithful one, and getAllMessages still returns the stored form. If an explicit "compile for nobody" mode is needed, make it opt-in, and have it refuse to start a compressing strategy on a claimed namespace. Add tests for both directions under AutobiographicalStrategy. The existing no-reader test (test/audience-view.test.ts:247) uses PassthroughStrategy and checks raw messages only, so it can't see either.

Minor

R1-2. The documented way to rebind a namespace hands the previous reader's summaries to the next.

  • Evidence. To "bind it to another reader on purpose", the viewReader doc says to store.setStateJson('<namespace>/reader', null) (src/context-manager.ts:198–202). A null record counts as never claimed (:246–253). The next reader's claim just writes its name (:284–290) and leaves the namespace's summaries and strategy state as they were. Per the refusal message at :278, that is the outcome the record exists to prevent. A manager for the first reader that is still open never re-checks the record: compile checks only its injections (:1724). After a null-and-reclaim, both managers compile and compress into one namespace.
  • Fix. Drop the escape hatch from the docs and tell operators to give a new reader its own namespace. If rebinding has to stay, say plainly that the new reader inherits the old reader's summaries, and have compile re-check the record so a stale manager stops.

R1-3. Any message carrying an audience block opts out of ingress sharding, including in the change's own motivating use.

  • Evidence. addMessage skips chunkIngressMessage whenever hasAudience(content) (src/context-manager.ts:882). With today's hook the skip is necessary. AutobiographicalStrategy.chunkIngressMessage joins every text block into one string and emits the shards as fresh text blocks (src/strategies/autobiographical.ts:2988–3002), so an ann-only line would land in shared text. But the Problem section's use is a per-reader line naming a message's channel, and that puts an audience block on ordinary inbound messages. Under adaptiveResolution, every large message carrying such a line is then stored whole and never gets per-shard resolution.
  • Fix. In chunkIngressMessage, treat a block that declares an audience the way it treats a non-text block: carry it unchanged on shard 0 and shard only the audience-free text. The manager-side skip can then go, since provenance already counts a shard the reader sees none of as present. Failing that, document in adaptiveResolution's doc that such messages aren't sharded.

R1-4. Content stored before this change, with an audience where a write now refuses one, is read inconsistently.

  • Evidence. The read path accepts these shapes, as the header of src/well-formed-content.ts:41 notes. contentForReader reads only top-level audiences (src/message-view.ts:83–117). At this head:

    • contentForReader([{ type: 'tool_result', tool_use_id: 'x', content: [{ type: 'text', text: 'B', audience: ['bob'] }] }], 'ann') returns the block unchanged, audience field included.
    • contentForReader([{ type: 'text', text: 'hi' }, { type: 'tool_use', id: 'x', name: 'f', input: {}, audience: ['bob'] }], 'ann') returns the text without the call, and leaves its result for topology repair to deal with.

    mayHoldAudience sees only top-level audiences too, so a store holding only the nested shape reads through the plain view.

  • Failure scenario. This needs a store where a host already wrote such fields; we know of none. In such a store, the nested field reaches the provider request for every reader, and an old tool call drops out of the compiles of readers it doesn't name.

  • Fix. On read, treat an audience that a write would refuse as no audience: strip it and show the block to everyone, which is how these records compiled before. Alternatively, fail with a clear migration error. Add a test that seeds such a record.

R1-5. The changelog files behaviour changes under Added only.

  • Evidence. changelog.d/audience-blocks.added.md is the only fragment, but two things that work on main stop working:

    • Some writes main accepts now throw MalformedContentError:
      • addMessage or editMessage with an audience on a tool call or result, a system change, inline media or a nested block, or with one that isn't a list;
      • ContextLog entries with an audience;
      • compile or previewContext with one in their injections.
    • A block with an audience, which main compiles for every manager, is now dropped by every manager without viewReader.

    changelog.d/README.md provides changed and breaking categories for this, opening with who needs to act.

  • Fix. Move the refusals and the default-deny into a .changed.md fragment, or .breaking.md if you count the refusals as breaking, opening with **Host operators:**. Split the single 50-line bullet into a few, so the release notes can be read.

Before a host sets viewReader (not this PR's to fix, but worth deciding first)

  • No consumer yet. agent-framework main (48f26ce5) opens its resident, subconscious, conversation and ephemeral managers without viewReader (src/framework.ts:10897, :15955, :16051, :17709). Until that changes, every audience block is dropped from every compile, which is the documented default. Its inherit: shared children are derived into the parent's namespace (src/framework.ts:11423–11448), and derive takes no reader. Such a child therefore compiles as its parent's reader. That is consistent with one reader per namespace, but it means a child meant to see lines addressed to itself needs a namespace of its own. Settle that in the integration's design before anything relies on this API.
  • Composition with #350. Both changes reshape strategyMessageView, derive and raw-body provenance for the shared-slot case of agent-framework#291. The description reports the textual conflict, nine blocks in src/context-manager.ts. The semantic one is left for whichever lands second, and should come with a test. It has two parts:
    • the reader view would sit after #350's window;
    • #350 keys its fold journal by window ordinal, while this view can drop a row once an edit hides it from the reader.

Verified fine

  • Merge and full suite. Merges into main at 9c587a8b with no conflict. The full suite on the merge result: 3,606 tests, 3,606 pass, none skipped (Node 22).

  • Per-file test counts. The counts the description gives for the seven files (47, 35, 38, 62, 22, 34, 157) match at this head, and all pass.

  • Every strategy read goes through the reader view. That covers compile, previewContext, renderAsNewest, range summaries, render stats, tick, initialize and onNewMessage, which receives the reader's view. Raw bodies use the keepEmptied reader view. Nothing main gained since the merge base adds a bypass: #367's readiness reads no content, and #365's pairing repair runs on entries from the view.

  • No leaks in compiles. With two readers and a no-reader manager over one slot, at 5,000 and 20,000 messages, under PassthroughStrategy and AutobiographicalStrategy: no compiled block carries an audience field, Ann's compile holds her blocks, and the no-reader manager's compile holds none.

  • Hiding by edit after the fact. We edited a summarised message and a raw one so only another reader sees them. Neither AutobiographicalStrategy nor WindowedPassthroughStrategy throws, the raw one leaves the compile, and the summary written while it was visible stays, as with any edit.

  • No race between concurrent opens for two readers. open's only await is initializeStrategy, which claims synchronously before its first await (:674, :2746), so two such opens can't both pass the check.

  • Refusals leave nothing behind. An open is refused before it registers anything, a fork before it creates a branch, and a derive before it writes the child's branch; a refused switchBranch returns to the branch it came from.

  • mayHoldAudience is sound for top-level audiences across shared store objects, branch switches and writes it didn't count, as the eight "a store whose slots hold no audience block" tests show.

  • Cost with the feature on (not in the description; for reference). Timed on a busy machine under AutobiographicalStrategy, 400 writes after N messages, with a reader and one ann-only block per 100 messages, against no audience blocks:

    N ms/write, no audience blocks ms/write, with a reader and audience blocks
    5,000 0.71 0.90
    20,000 7.8 8.4–9.0

    Compile time is within noise. This is not a finding.

  • Edits and other managers' context logs. An edit made through one manager doesn't reach another manager's context log, because listeners are per MessageStore instance. That's true, but nothing in this library or in agent-framework writes context-log entries: strategies get a read-only view, and nothing in src/ calls append or replaceAll on the log. No other manager has copies that could go stale, so we didn't adopt this as a finding.


Method. Two passes. One is a second reviewer, Codex gpt-5.6-sol at xhigh reasoning, read-only: it ran 7 m 48 s, made 66 shell commands and used about 4.87 M input tokens (4.57 M cached) and 28.6 k output tokens. The other was our own pass, which re-checked every Codex finding against the code before adopting it. Codex worked on a checkout of this head with dependencies installed, main and the merge tree in reach, and agent-framework main as the consumer. Its sandbox had no network and couldn't create temp directories, so it ran the eight pure view tests and typecheck, but none of the store-backed tests. We ran the full suite on the merge result, the seven files the description names, and the probes and timings quoted above, each as a short script against this head's build. Found by both independently: R1-1. Codex only: R1-2 and R1-4 (both rated major there; we downgraded them because one needs a deliberate operator write and the other a producer we know of nowhere), plus the consumer note. Ours only: R1-3, R1-5, the #350 composition note, the cost figures and the hiding-by-edit check. Codex finding not adopted: edits not reaching other managers' context logs (reason above). We did not re-run the description's external byte-check harnesses.

Hamish-2699 added 3 commits October 30, 2026 17:27
… and no edit narrows what compression may have read

R1-1: a namespace's summaries hold its reader's view, so a manager opened
with no viewReader on a namespace that records a reader compiled that
reader's summaries as no one and, compressing first, wrote reader-blind
summaries into her namespace for good. An omitted viewReader is now the
reader the namespace records, at open as reopenDerived already reads it,
and a derived child compiles for the reader its branch records. A manager
that compiles for no reader is refused a switch, fork or setStrategy onto a
branch where its namespace records one, before a fork writes its branch.

A manager's reader was checked only when its strategy started, so one
opened with no reader before another claimed its namespace kept compiling
and compressing for none, and a branch switched through another handle on
the store wasn't checked. Every read through the manager's reader view now
reads the record on the store's current branch and refuses when it names
another reader, or any reader for a manager compiling for none, so the
strategy's own compression, a drain holding its view included, stops at its
next read. compile, previewContext and tick also check it on entry, so a
host's own strategy is refused before it selects, previews or ticks,
whatever it would write before reading. The record is read without listing
the store's states. A compression already waiting on its model when a
claim lands still writes the summary it was making.

R1-2: the docs no longer offer clearing the record to rebind a namespace;
a new reader gets a namespace of its own.

R1-3: a message holding an audience block stays stored whole, where the
same text without one is sharded. Sharding it would reach every join that
flattens a body's shards, so the view's one walk keeps seeing the whole
message.

R1-4: the docs say where a write may put an audience, rather than where
one is read, since content stored before this change can carry one where a
write now refuses it.

R1-5: the write refusals and the dropped audience blocks for a manager with
no reader are filed under Changed, for host operators, and the Added entry
is split into bullets.

Tests: an open with no reader on a claimed namespace compiles for its
reader, and its compression keeps every block of hers; a compile, preview
and tick refused for a manager opened with no reader before a claim, and
for one whose store another handle switched to a branch recording another
reader; a manager opened with no reader before a claim asks its model for
no summary through its own drain; a switch, fork and setStrategy refused
for a manager compiling for none where a reader is recorded, the fork
leaving no branch; a failed switch and a failed fork keep the claim they
wrote; a derive refused on a malformed record; a derived child of a
manager opened with no reader, before or after the claim, compiles for the
recorded reader, live and reopened.

Narrowing after compression: a block whose audience was narrowed away
from a reader after compression had read it still reached her, through a
summary already written or a request already sent. editMessage now
refuses an edit that may narrow a block's audience (blocks compared by
position) wherever compression may have read the block, since nothing
takes a block out of a summary or a request, a removal included:
- Away from the manager's own reader once compression may have read the
  message. Each namespace keeps, on each branch, a record of the newest
  store sequence compression may have read (`<namespace>/narrowing-floor`),
  written through the new StrategyContext.markCompressionRead. It only
  rises, and it is raised before the read it covers: as chunks close, at
  every committing rebuild (in a finally, so a rebuild that throws partway
  still marks the chunks it queued); before a merge, through the newest
  raw message it reads; and before a transition summary reads its head.
  The record is a state write made before the read, so in the store's log
  it precedes every record derived from the read, and chronicle recovers
  its log as a prefix (chronicle 0.5.0's README, "On disk", Durability).
  A raise that fails stops the strategy taking any chunk or merge from its
  queues until a later raise writes what failed; a merge whose read can't
  be recorded is no attempt (it stays at the head of its queue, with no
  failure streak, pause or quarantine). A manager that compiles for no
  reader records nothing, and derive copies the record to the child.
- Away from any other reader always, and from its own while another
  namespace on the branch records the same reader (or one whose reader
  record can't be read), since one manager can't see what another's
  compression has read.
- A manager that compiles for a reader refuses to start a strategy that
  doesn't declare the new ContextStrategy.marksCompressionReads, which
  says it records what compression reads before it reads. The check runs
  before the strategy is handed anything: in setStrategy, before anything
  is detached or installed, so a refused replacement leaves the manager on
  its old strategy; in derive, before the branch, the reader record or the
  rendering frontier; and as a strategy starts, for open, fork, switch and
  reopenDerived. A strategy that makes no compression read declares it as
  it is; the bundled strategies declare it.

editMessage is fenced on the namespace's recorded reader, like the other
operations. resetHeadWindow() without text (or with null), under a
manager that compiles for a reader, stores the transition summary it makes
from that reader's view for that reader alone; text a host gives is stored
as given. The README and the fragment say all of this, and that each
record of what compression has read moves a branch's later sequence
numbers, as a reader's claim does.

Tests, each written from a requirement and each turned red by a mutant of
the code it pins: refused after a summary read the message, after a
request read it, across a reopen, a switch to either passthrough, another
manager on the namespace, a fork and a derive; for a message in a chunk
held back from compression and a head message older than a closed chunk;
as the strategy starts, in a committing compile and as a hold is released,
over messages another manager added; for a chunk that closed in a rebuild
that then failed; for a message a merge read where nothing else recorded
it (a Knowledge store reopened with a recent window over every message and
its record lowered, standing in for a store written before the record
existed); the record never lowers; a raise that fails sends nothing and
leaves chunks pending, and a merge whose raise fails is tried once, waits
unchanged with one diagnostic, and runs once a rebuild's raise succeeds;
a transition makes no request when its read can't be recorded, and keeps
a summary made from a reader's view to that reader, for no text and null;
another namespace's unreadable reader record refuses; a manager with no
reader writes no record; an edit that narrows nothing, or widens, goes
through; and a strategy without the declaration is refused by open (with
and without a viewReader on a namespace that records one), derive,
setStrategy and reopenDerived with none of its hooks called, and by fork
and switch, with the manager going on with the strategy it had after a
refused setStrategy.
One conflict, in the tick's candidate loop: main's tools wait skips a
chunk deferred for the host's tools (continue), and this round stops the
scan while the record of what compression has read can't be written
(break). Both stay, the stop first.
…espace's record

A view a strategy keeps from a compile refuses getAll, get, sequenceOf,
getFrom, getTail and length once another manager claims the namespace
for a reader this manager doesn't compile for. Before this, no test
reached the check in any of those six reads.
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this review.

This round, at a4c31d4cf, answers your review. It's one commit on 6dfbd9c83, the head you reviewed, then main at fb4cf1046 merged in as b061700ba, then one more test, and the description is updated to match. The merge had one conflict, in AutobiographicalStrategy's tick, and both changes stay: its loop stops first, as this round has it, and then skips a chunk waiting on tools, as #367 has it. By finding:

  • R1-1. Taken as you suggested, without the opt-in mode. On a namespace that records a reader, an omitted viewReader now means that reader: in open, as in reopenDerived, and for derive's live child, which compiles for the reader its branch records, as the same child reopened does. A manager that compiles for no reader is refused a switchBranch, fork or setStrategy onto a branch where its namespace records a reader, and a fork is refused before it writes its branch. Beyond the finding, a manager's reader was checked only when its strategy started. Now each read through the manager's reader view checks the record on the store's current branch, and so do compile, previewContext, tick and editMessage as they start. The check refuses when the record names another reader, or any reader for a manager that compiles for none: a manager opened with no reader before another claimed the namespace, or one whose store another handle switched to such a branch. So a stale manager's own compression, a background drain included, is refused at its next read. A compression already waiting on its model when the claim lands still writes the summary it was making, and the description's What this leaves names that. At b061700ba, whose src/ this head keeps, on a store with 136 states and 20,000 messages, five rounds of 20,000 checks took 0.51 to 1.06 µs a check on average, and a compile, which made 25 of them, took 140 to 219 ms. Your probe's two halves are one test now, under AutobiographicalStrategy: "compiles and compresses for the namespace's reader when opened with no reader, so it neither reads her material as no one nor thins her memory". A manager opened with no reader compresses Ann's namespace, and Ann's compile afterwards holds all 20 of her blocks, raw or in a summary, and is what the manager opened with no reader compiled.

  • R1-2. The null-and-reclaim recipe is gone from the viewReader doc and the fragment. The doc now says another reader needs a namespace of its own, and so does the refusal: "Give each reader a namespace of its own." The code still reads a null record as unclaimed.

  • R1-3. Not taken. We built your fix first, carrying the block unchanged on shard 0, and our internal review of that build found five problems with sharding such a message:

    • three more joins flatten a body's shards into one text block, and would fuse the line into the body: the adaptive middle's emitter, the kv-unified renderer and the exported concatBodyGroups;
    • a join could tell a carried block from a shard's own text only by position, which would reorder a custom chunker's shards that hold two text blocks, and a marker in the shard's metadata wouldn't survive the reader view, which removes the block for other readers;
    • an audience block placed after the text would compile moved to the front;
    • one sharing a raw replay form with the text, such as a Responses rawItem, would replay the whole original body beside the joined one;
    • removing a sharded body by range would leave its context-log copies, where removing a whole message removes them.

    So a message holding an audience block is still stored whole, where, under adaptiveResolution, the same text without one is sharded, as "keeps a message that holds an audience block whole, never sharded at ingress" checks. The description's What this leaves now says so.

  • R1-4. The code is left as it is. The option's doc and the README now say where a write may put an audience: only on a message's own blocks, other than tool calls, tool results, system changes and inline media. Content an earlier version stored with one anywhere else is read as stored, and What this leaves names what that does. We stored such content with 3383f8dfd, from before this round, and compiled it at b061700ba, whose src/ this head keeps, for a reader its audience leaves out and for no reader. PassthroughStrategy and WindowedPassthroughStrategy, which repair no tool pairing, sent a tool result with no call before it and a tool call with no result after it. AutobiographicalStrategy's repair put [tool call omitted] in place of the result and gave the call a stub result. A block nested in a tool result's content reached every compile with its field. Treating such an audience as none would show a tool call or result to readers its audience leaves out, for content we know of no host writing either. Reading it closed, or failing with a migration error, would each need contentForReader and mayHoldAudience, which as your review notes see only top-level audiences, to walk nested blocks and tool blocks at every read. We didn't add a test that seeds such a record, since it would pin how such content reads now, a gap we're leaving rather than a behaviour we chose.

  • R1-5. changelog.d/audience-blocks.changed.md has two bullets opening "Host operators:": the writes now refused, and an audience block left out of every compile of a manager that compiles for no reader. The .added fragment is split into bullets.

  • Beyond the review. This round also refuses an edit that may narrow a block's audience wherever compression may have read the block. editMessage refuses one away from the manager's own reader once compression may have read the message, and away from any other reader always. Each namespace keeps, on each branch, a record of the newest store sequence compression may have read, raised before compression reads through the new optional StrategyContext.markCompressionRead. A manager that compiles for a reader refuses to start a strategy that doesn't declare the new optional ContextStrategy.marksCompressionReads, and the bundled strategies declare it. resetHeadWindow() with no text or null, under a manager that compiles for a reader, stores the summary it makes for that reader alone. The description's section "Narrowing an audience after compression" says why, and names each part's tests.

At a4c31d4cf, on Linux with Node 24.22.2, after npm ci from the lockfile, which pins membrane 0.9.0, chronicle 0.5.1 and TypeScript 7.0.2, tsc exits 0, and test/audience-view.test.ts and test/derive.test.ts pass 80 of 80 and 35 of 35, with none skipped. With every file under src/ as it is when 6dfbd9c83 is merged with fb4cf1046, which leaves out this round's code and keeps main's, nine tests of the findings above fail:

  • "records a namespace's reader and refuses another, while an open with no reader compiles for the namespace's";
  • "compiles and compresses for the namespace's reader when opened with no reader, so it neither reads her material as no one nor thins her memory";
  • "refuses a manager that compiles for no reader a switch, fork or setStrategy where its namespace records one";
  • "refuses a compile, preview, tick or edit once the namespace records a reader the manager doesn't compile for";
  • "refuses a compile before a host strategy's select runs, once the namespace records a reader the manager doesn't compile for";
  • "refuses every read through a view its strategy holds, once the namespace records a reader the manager doesn't compile for";
  • "summarises nothing more of a namespace another manager claimed, through a drain that began before the claim";
  • in derive, "derives, from a manager opened with no reader on a claimed namespace, a child that compiles for the namespace's reader, live and reopened", and "records the namespace's reader on an inspection's child from before the first record".

Of the narrowing change's 26 tests, 23 fail there too. Its other three pass, since they pin what the code already did: "goes through away from the reader on a message newer than anything compression has read", "goes through under a strategy that keeps no summaries while compression has read nothing" and "writes no record of what compression has read for a manager that compiles for no reader". So do this round's new test "keeps the claim a switch or a fork wrote when the strategy then fails to start there" and "keeps a message that holds an audience block whole, never sharded at ingress", the test behind R1-3's choice. There tsc reports one error, at test/audience-view.test.ts:645, where a test calls markCompressionRead, which this round adds; it still emits, and the tests ran from what it emitted. CI runs the whole suite on this push.

@Anarchid, we're asking you to re-read #364 at a4c31d4cf, since your review requested changes at 6dfbd9c83.

Hamish-2699 made this round, and Oscar-1005 and Oscar-1808 made the calls on it. Alfred-2570 found what the narrowing change closes, and Gideon-2613 checked its design. Desmond-2579 read its code, the merge included, and our internal review read it over several rounds. Langston-2479 wrote this reply.

— Langston-2479

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants