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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ serde = { version = "1", features = ["derive"] }
# Pins the exact JSON object shape and required fields of host-facing DTOs in
# unit tests without adding JSON handling to the runtime dependency graph.
serde_json = "1"
# Optional, and off by default. Powers the `regex` feature of the `select`
# module: a search that cannot be spelled as a substring is spelled as an
# expression instead. Default features are off because a picker needs matching
# and case folding, not the whole crate's optional syntax surface.
regex = { version = "1", default-features = false, features = ["std", "perf", "unicode-case", "unicode-perl"] }
# Drives only the runtime crate's async unit tests. The library itself remains
# executor-neutral and exposes boxed futures at its host port.
tokio = { version = "1", features = ["macros", "rt", "sync"] }
Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,34 @@ agent A's words as its own.
One log, one sequence numbering, two histories. Every line a viewer did not
write arrives as somebody else's, named.

## The window is a budget, so the transcript is queryable

That projection is bounded — about thirty messages — and the log behind it is
not. The obvious fix is to show more, and it is the wrong one:
[Lost in the Middle](https://arxiv.org/abs/2307.03172) finds a fact in the
middle of a long context is used less reliably than the same fact at the edge
of a short one, so a bigger window mostly relocates the problem into its own
middle. It also charges every participant on every turn.

So tinyhivemind does what
[Recursive Language Models](https://arxiv.org/abs/2512.24601) do to a long
prompt — treat the context as an environment to interrogate rather than a
prefix to swallow — except the environment is a shared log rather than one
model's REPL, and the interrogation is a pure fold rather than a recursive
model call.

**Search it.** One ranking for every picker — agents, people, desks, threads,
messages — with an optional regular expression, over the same log port
everything else uses. No index, no embeddings, no background job.

**Pin what must not be lost.** `!pin` folds out of the transcript, not into a
second store, and the board rides into every turn whether or not anybody
searched for it.

**State the budget.** The briefing tells an agent what a message costs the room
it is written into. Reported, never enforced: nothing here rewrites what
somebody said.

## An episode ends for a reason you can name

Every step walks the same ladder in the same order, and the first rung that
Expand Down Expand Up @@ -408,6 +436,7 @@ see what the thing actually does.
| [Quick start](https://github.com/tinyhumansai/tinyhivemind/wiki/Quick-start) | pin it, resolve a mention, read a deliberation |
| [Architecture](https://github.com/tinyhumansai/tinyhivemind/wiki/Architecture) | the three crates and why they are split that way |
| [Threads](https://github.com/tinyhumansai/tinyhivemind/wiki/Threads) | thread-scoped projection, and finding your way back into a busy desk |
| [Recall](https://github.com/tinyhumansai/tinyhivemind/wiki/Recall) | searching the transcript, pinning what must not be lost, and the message budget |
| [Hive episodes](https://github.com/tinyhumansai/tinyhivemind/wiki/Hive-episodes) | salience, quorum, cross-inhibition, and the attention market |
| [Trace grammar](https://github.com/tinyhumansai/tinyhivemind/wiki/Trace-grammar) | what a marker deposits, and what real models get wrong |
| [Episode policy](https://github.com/tinyhumansai/tinyhivemind/wiki/Episode-policy) | every setting, and how to tune it to the size of a desk |
Expand Down
9 changes: 9 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@ dependency direction is enforced by construction.
| P11 | `SessionMessage.parent` and the structured trace sidecar | planned |
| P12 | Per-conversation read state | planned |
| P13 | Digests and supersession | planned |
| P14 | Recall: one selection ranking, roster and desk pickers, bounded transcript search with optional regular expressions, pinning as a fold, and a stated per-message budget | **done** |

P14 is out of order on purpose. It is not a wire-format change and does not
wait on P10 through P13: it answers the same pressure they do — a bounded
window over an unbounded log — with the two mechanisms that need no new port
and no new stored state. Search makes the transcript queryable rather than
something a turn must hold, pinning keeps a small working set arriving whether
or not anybody asked, and `BrevityPolicy` states the budget every message is
spending out of. See [`docs/specs/recall.md`](docs/specs/recall.md).

The next work is the paired OpenCompany adapter integration, followed by a
gated live-provider verification in which two agents exchange an attributed
Expand Down
6 changes: 6 additions & 0 deletions crates/tinyhivemind-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ publish = false
# `.github/scripts/assert-pure.sh` asserts it.
#
[dependencies]
# Optional: compiles `select::Pattern::Regex`, which lets a caller search by a
# compiled expression instead of a literal query. Off in the default build,
# because the pure algebra on the hot path of every turn does not need it.
regex = { workspace = true, optional = true }
serde = { workspace = true }
thiserror = { workspace = true }

Expand All @@ -29,6 +33,8 @@ serde_json = { workspace = true }

[features]
default = []
# Adds `select::Pattern::Regex` and regular-expression scoring.
regex = ["dep:regex"]
# Compiles opt-in tests that can call external model providers. Live execution
# still requires the explicit environment switch documented in docs/testing.md.
e2e = []
Expand Down
120 changes: 120 additions & 0 deletions crates/tinyhivemind-core/src/find/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
//! Finding an agent, a person or a desk by name, over borrowed snapshots.
//!
//! These are the two pickers that need no host read: everything they search is
//! already in the roster and desk snapshots the caller holds for the turn.
//! Searching a *transcript* has to wait on a log, so it lives behind the
//! session port in the `tinyhivemind` crate — the same split the rest of this
//! workspace uses.
//!
//! Every function here is [`rank`](crate::select::rank) over candidates read off a
//! snapshot, so the ordering is one ordering, described once in
//! [`select`](crate::select).
//!
//! # Example
//!
//! ```
//! use tinyhivemind_core::{
//! find,
//! roster::{Roster, RosterMember},
//! select::SELECT_LIMIT,
//! };
//!
//! let members = [
//! RosterMember { id: "alice".into(), name: Some("Alice Nakamura".into()) },
//! RosterMember { id: "bob".into(), name: Some("Bob Ferrante".into()) },
//! ];
//! let roster = Roster::new(&members, &[], &[]);
//! let hits = find::agents("naka", &roster, SELECT_LIMIT);
//! assert_eq!(hits[0].id, "alice");
//! ```

#[cfg(test)]
mod test;

use crate::{
desk::DeskSet,
roster::Roster,
select::{Candidate, Hit, Pattern, rank_pattern},
};

/// Find active agents whose id or display name matches a query.
///
/// Retired agents are never offered: a picker exists to start something, and
/// nothing can be started with a retired agent.
#[must_use]
pub fn agents<'a>(query: &str, roster: &Roster<'a>, limit: usize) -> Vec<Hit<'a>> {
agents_matching(&Pattern::Text(query), roster, limit)
}

/// Find active agents matching a pattern; see [`agents`].
#[must_use]
pub fn agents_matching<'a>(
pattern: &Pattern<'_>,
roster: &Roster<'a>,
limit: usize,
) -> Vec<Hit<'a>> {
let candidates: Vec<Candidate<'a>> = roster
.active_members()
.map(|member| {
Candidate::new(
member.id.as_str(),
member.name.as_deref().unwrap_or(member.id.as_str()),
)
})
.collect();
rank_pattern(pattern, &candidates, limit)
}

/// Find people whose id or label matches a query.
#[must_use]
pub fn people<'a>(query: &str, roster: &Roster<'a>, limit: usize) -> Vec<Hit<'a>> {
people_matching(&Pattern::Text(query), roster, limit)
}

/// Find people matching a pattern; see [`people`].
#[must_use]
pub fn people_matching<'a>(
pattern: &Pattern<'_>,
roster: &Roster<'a>,
limit: usize,
) -> Vec<Hit<'a>> {
let candidates: Vec<Candidate<'a>> = roster
.people()
.map(|person| Candidate::new(person.id.as_str(), person.label.as_str()))
.collect();
rank_pattern(pattern, &candidates, limit)
}

/// Find desks whose id, name or description matches a query.
///
/// A desk's description is supporting text and is scored at half weight, so a
/// desk named for the query always outranks one that merely mentions it. A
/// desk id declared twice is offered once, in its first declared position.
#[must_use]
pub fn desks<'a>(query: &str, desks: &DeskSet<'a>, limit: usize) -> Vec<Hit<'a>> {
desks_matching(&Pattern::Text(query), desks, limit)
}

/// Find desks matching a pattern; see [`desks`].
#[must_use]
pub fn desks_matching<'a>(
pattern: &Pattern<'_>,
desks: &DeskSet<'a>,
limit: usize,
) -> Vec<Hit<'a>> {
let mut candidates: Vec<Candidate<'a>> = Vec::new();
for desk in desks.iter() {
if candidates
.iter()
.any(|candidate| candidate.id == desk.id.as_str())
{
continue;
}
let candidate = Candidate::new(desk.id.as_str(), desk.name.as_str());
candidates.push(match desk.description.as_deref() {
Some(description) => candidate.with_detail(description),
None => candidate,
});
}
rank_pattern(pattern, &candidates, limit)
}
Loading