Refactor from per-session sqlite files to one shared shard at ~/.arborist/sessions.db. Three things that didn't work before now do: 1. Queries are first-class members of the tree. nodes_fts (FTS5 over question + answer_text + cited_titles) lets /find <query> walk every prior turn across every session. Cached answers and threads become findable, surface in the REPL as `[<bates>] <audit> <question>` lines. 2. Forking from history works the same as forking from a sibling. parent_bates can cross sids. After /find returns a hit from last week's session, /cd <bates> + ask = your next question lands as a child under that historical turn. The cross-session parent's subtree_hash ripples up its session_root. 3. One global audit chain instead of per-file. audit_events.event_hash = sha256(prev || canonical body), one chain over every state change in the shard. `make session-chain-check` is now a single pass; tampering anywhere in the operator's history breaks the chain. Wire: - arborist/qa/session.py — drop file-per-session SessionStore class; Session becomes a viewport on SessionStore. cited_titles_json + n_cited_sources materialized at insert time so FTS5 doesn't need a join into providence_cache. - arborist/cli.py — `session` subcommand swaps --gc for --find; REPL adds /find. Ancestor-keyword extraction now reads nodes.cited_titles_json directly (no qa.db roundtrip). - Makefile — `make session-find Q="..." [LIMIT=N JSON=1]`; `make session-gc` retired (no per-session files to GC). - docs/sessions.md — rewritten for the single-shard shape. - tests/test_session.py — 17 tests: create, add, fork (incl. cross-session), find (FTS5 + by_cache_key), path_to_root crossing sessions, audit chain (intact + tampered), cited-title extraction, subtree_hash ripple across sessions. Migration: pre-existing per-session dbs at ~/.arborist/sessions/*.db become orphaned. None lost data — test sessions only. Operator can rm -rf ~/.arborist/sessions/ (or rename to sessions-old/) at leisure. 126 session+providence+verify+inspect tests pass.
185 lines
6.7 KiB
Markdown
185 lines
6.7 KiB
Markdown
# Sessions — single-shard Merkle conversation forest
|
|
|
|
Multi-turn Q&A with branching. **One** SQLite shard at
|
|
`~/.arborist/sessions.db` holds every turn from every session a user
|
|
has ever started. Sessions are *viewports* onto a shared forest —
|
|
nodes can fork across sessions (yesterday's turn → today's child) via
|
|
a single `parent_bates` pointer.
|
|
|
|
Privacy: this layer is **local-private** for now. Gossip-trust
|
|
sharing across users is future work; the shard's contract today is
|
|
"private journal of everything the operator asked, plus the answers."
|
|
|
|
## On-disk shape
|
|
|
|
```sql
|
|
CREATE TABLE sessions (
|
|
sid TEXT PRIMARY KEY,
|
|
root_bates TEXT NOT NULL, -- always seq=0, empty question
|
|
current_bates TEXT NOT NULL, -- REPL "you are here"
|
|
next_seq INTEGER NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
label TEXT
|
|
);
|
|
|
|
CREATE TABLE nodes (
|
|
bates TEXT PRIMARY KEY, -- "<sid>-<6-digit>"
|
|
sid TEXT NOT NULL, -- session that minted this node
|
|
seq INTEGER NOT NULL,
|
|
parent_bates TEXT REFERENCES nodes(bates), -- can cross sids
|
|
question TEXT NOT NULL,
|
|
answer_text TEXT NOT NULL, -- materialized at insert time
|
|
cache_key TEXT NOT NULL, -- → providence_cache
|
|
audit_mode TEXT NOT NULL,
|
|
cited_titles_json TEXT NOT NULL, -- parsed from [E# | <title> | …]
|
|
n_cited_sources INTEGER NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
label TEXT,
|
|
node_hash TEXT NOT NULL, -- content-addressed
|
|
subtree_hash TEXT NOT NULL -- Merkle over self + all descendants
|
|
);
|
|
|
|
CREATE VIRTUAL TABLE nodes_fts USING fts5( -- search across all sessions
|
|
question, answer_text, cited_titles
|
|
);
|
|
|
|
CREATE TABLE audit_events ( -- one global hash chain
|
|
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
event_type TEXT NOT NULL,
|
|
sid TEXT,
|
|
bates TEXT,
|
|
prev_event_hash TEXT,
|
|
event_hash TEXT NOT NULL, -- sha256(prev || canonical body)
|
|
body TEXT NOT NULL,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
```
|
|
|
|
Three integrity surfaces:
|
|
- **`node_hash`** — content-addressed, never changes after insert.
|
|
- **`subtree_hash`** — recomputed when a node gains a child (anywhere
|
|
in the forest); ripples up the parent chain regardless of session
|
|
boundaries.
|
|
- **`audit_events.event_hash`** — single global chain; tampering with
|
|
any state-change record breaks it.
|
|
|
|
## Bates discipline
|
|
|
|
`<sid>-<6-digit-seq>`. `sid` is timestamp+random, never reused; `seq`
|
|
is per-sid monotonic, never reassigned. seq=0 is a synthetic root with
|
|
empty question (collapsed in `/tree` rendering, kept so every real
|
|
turn has a parent).
|
|
|
|
## Merkle properties
|
|
|
|
```
|
|
node_hash = sha256(canonical_json({
|
|
parent_node_hash, bates, question, cache_key,
|
|
audit_mode, created_at, label,
|
|
}))
|
|
|
|
subtree_hash = HashCombine(
|
|
leaf_hash(node_hash),
|
|
HashCombine over sorted-by-hash children_subtree_hashes,
|
|
)
|
|
```
|
|
|
|
Children sorted by `subtree_hash` (content-addressed) keep the combine
|
|
order deterministic — two stores that land the same nodes in different
|
|
order produce the same root.
|
|
|
|
Insert path is O(depth). On insert of N under P:
|
|
1. Compute `N.node_hash`.
|
|
2. `N.subtree_hash = leaf_hash(N.node_hash)`.
|
|
3. Walk `N → P → … → root` (crossing sessions if parent_bates does)
|
|
recomputing each ancestor's `subtree_hash`.
|
|
4. Append `audit_events` row.
|
|
|
|
Sibling subtrees that didn't change keep their `subtree_hash`. That's
|
|
the property page-refresh caching exploits — a UI client tracks
|
|
`(bates → subtree_hash, body)` and only refetches paths whose hash
|
|
differs.
|
|
|
|
## Cross-session forks
|
|
|
|
Forking is implicit. Any turn from any session is `/find`-able and
|
|
`/cd`-able. After `/cd <bates>`, the next non-command question lands
|
|
as a child of that node. If the node belongs to a different session,
|
|
the new child inherits the original parent's `node_hash` via Merkle —
|
|
that session's `subtree_hash` ripples up to include the cross-session
|
|
child.
|
|
|
|
In `/tree` rendering, cross-session children are tagged
|
|
`[from <sid>]`. (With local-only storage that's always self today;
|
|
gossip-shared sessions will surface external sids the same way.)
|
|
|
|
## CLI
|
|
|
|
```
|
|
arborist session # new session, REPL
|
|
arborist session --sid <id> # resume
|
|
arborist session --list [--json] # all sessions
|
|
arborist session --tree --sid <id> # one session's tree
|
|
arborist session --find "spider man" # FTS5 across ALL sessions
|
|
arborist session --chain-check # verify global audit chain
|
|
arborist session --sessions-db PATH # override shard location
|
|
```
|
|
|
|
REPL commands:
|
|
|
|
| command | effect |
|
|
|---|---|
|
|
| `/tree` | print this session's tree |
|
|
| `/branches` | list branch-point Bates (≥2 children) |
|
|
| `/find <query>` | FTS5 search across ALL sessions |
|
|
| `/cd <bates\|seq\|label>` | move current pointer (cross-session OK) |
|
|
| `/back` | cd to parent of current |
|
|
| `/label <name>` | name current node |
|
|
| `/show [ref]` | dump stored info for a node |
|
|
| `/sid` | print session id |
|
|
| `/root` | print session_root |
|
|
| `/help` | help |
|
|
| `/quit` | exit (or Ctrl-D) |
|
|
| anything else | new question, child of current |
|
|
|
|
Makefile:
|
|
|
|
```
|
|
make session [SID=...] # interactive REPL (defaults LLM=qwen)
|
|
make session LLM=hermes # use Hermes-3-8B instead
|
|
make session-list # list sessions
|
|
make session-tree SID=... # one tree
|
|
make session-find Q="..." # search [LIMIT=N JSON=1]
|
|
make session-chain-check # verify audit chain
|
|
```
|
|
|
|
## Retrieval flow down a branch
|
|
|
|
When you ask a follow-up turn under parent P, the REPL gathers cited
|
|
titles from P and all ancestors (already materialized in
|
|
`nodes.cited_titles_json` at insert time) and passes them to
|
|
`providence_query` via `policy["retrieval_keywords"]`. The keyword
|
|
string augments FTS5 retrieval **only** — it never enters
|
|
`cache_key` / `question_hash` / `governance_policy_hash`. Same
|
|
discipline as the legacy `--retrieval-keywords` flag (#000001).
|
|
|
|
This makes pronoun follow-ups ("who created him?") surface the prior
|
|
turn's primary source. It does **not** thread prior Q&A into the LLM
|
|
prompt — that's a separate enhancement (would fold into
|
|
`conversation_hash`).
|
|
|
|
## Portability
|
|
|
|
The shard's schema has no FK dependency on the main arborist store.
|
|
A non-Python consumer reading it needs:
|
|
|
|
1. SQLite3 with FTS5 (for the search index).
|
|
2. The canonical-JSON convention: sorted keys, no whitespace, UTF-8.
|
|
3. Three hash primitives:
|
|
- `sha256(bytes) → hex`
|
|
- `leaf_hash(bytes) = sha256(0x00 || bytes)`
|
|
- `hash_combine(L, R) = sha256(0x03 || L || R)`
|
|
|
|
Same conventions as `proxy.unturf.com` Go merkle and the rest of
|
|
arborist (see `arborist/merkle.py`).
|