Skip to content

Threads

Steven Enamakel edited this page Sep 1, 2026 · 2 revisions

Threads

A desk is not a flat channel. Messages hang off roots, and two things follow from that: what one viewer should see of a busy desk, and how an agent that has been away finds out what is going on.

Projection is thread aware

project_session behaves differently depending on what the query asks for.

A thread-scoped query returns the root and its direct replies. That is the obvious case.

A channel-level query returns every root and each root's first reply. An agent reading a desk sees the answers rather than a run of unanswered questions, and it still never reads a second thread's interior.

The narrowing cannot be a fold a caller applies afterwards. Picking each root's first reply needs the parent link and chronological order, and the walk runs newest first, so it happens inside the projection where both are available.

Two rules follow from the bounded scan:

  • A reply whose parent is itself a reply is a thread interior and is never promoted.
  • A reply whose parent fell outside the scan cannot be shown to belong to a root, so it is dropped rather than flattened into the channel.

The sharing delta keeps the narrower roots-only rule, because it decides row by row and the promotion needs the whole slice. A first reply reaches an agent on the next re-seed rather than the next tick.

See docs/specs/thread-scoped-conversations.md.

The thread index

An agent that has just cold-started, or that has been away, gets asked "what are we doing?" and can only answer from whatever fit its window. That window is a recency bound over rows, so a thread quiet for a page is invisible even when it is the one that matters.

The index answers the question directly: the roots in this desk, what each one opened with, how much reply traffic it drew, and when it was last touched.

pub struct ThreadLine {
    pub root: Sequence,
    pub opening: String,
    pub replies: usize,
    pub latest: Sequence,
    pub landed: Option<String>,
}
item what it is
fold_thread_index(rows, limit) the pure fold over a chronological slice of one desk
read_thread_index(log, conversation, limit) the fold plus its bounded read
THREAD_INDEX_LIMIT rows described to a viewer by default, 5
THREAD_OPENING_CHARS characters of a root kept as its opening, 60
THREAD_INDEX_SCAN maximum raw rows inspected for one index, 256

The fold is separate from the read on purpose. It is the part worth testing and it needs no fixture, which is the charter's rule applied inside one module rather than across two crates.

Constraints worth knowing

landed belongs to the host. Where a thread's work ended up is board state this crate does not hold and must not learn, so the fold always returns None and the host fills it in afterwards. Nothing else on the row needs the host.

The scan bound is deliberately small: 256 rows against SCAN_LIMIT's 2048. The index is a recency view, and paying a full scan to surface a thread nobody has touched in two thousand messages is the wrong trade. The visible consequence is that a thread whose root fell outside the bound is absent even when its replies are recent, because a reply alone cannot supply an opening.

A blank root is not a thread. The row exists to say what a thread is about, and a blank one says nothing, so neither it nor its replies are indexed. This differs from the channel-level projection, where a blank root still anchors its first reply: there the root's job is to be an anchor rather than to be read.

There is no index inside a thread. read_thread_index returns empty, without reading, for a conversation that already has a thread_root. A viewer inside a thread is not choosing between them.

Ordering is total: latest descending, then root descending. Sequences are unique per thread, so the tie-break is unreachable through the fold and exists to keep the ordering total by construction.

Where the index goes

Into SessionContext, beside whatever the host adds:

pub struct SessionContext {
    pub threads: Vec<ThreadLine>,
    pub notes: Vec<BriefingNote>,
}

A BriefingNote is one host-supplied block this crate cannot compute: board state, open work, attachments. It carries a heading and lines in the host's own order.

Both are carried beside the operator's message and never appended to it, so nothing downstream has to cut them back off before reasoning about what was actually asked. Both also stay separate from SessionInitialization::history, which is the log and only the log.

pub struct SessionInitialization {
    pub briefing: TeamBriefing,     // ephemeral team context, not in the log
    pub context: SessionContext,    // threads and host notes, typed
    pub history: Vec<SessionMessage>, // chronological, attributed, from the log
}

initialize_session_with_context is the entry point that populates all three.

Clone this wiki locally