session: single-shard forest + FTS5 search + cross-session forks
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.
This commit is contained in:
parent
5f07d9ece2
commit
45a348b4f0
5 changed files with 868 additions and 781 deletions
14
Makefile
14
Makefile
|
|
@ -224,7 +224,7 @@ session: bootstrap ## interactive multi-turn Q&A REPL [SID=... LLM=qwen|hermes A
|
|||
@echo "# llm: $(SESSION_LLM_ENDPOINT) / $(SESSION_LLM_MODEL)" >&2
|
||||
$(ARBORIST) --shards-dir $(SHARDS_DIR) session $(if $(SID),--sid $(SID),) --top-k $(QUERY_TOP_K) --endpoint $(SESSION_LLM_ENDPOINT) --model $(SESSION_LLM_MODEL) $(if $(ANSWER_MODE),--answer-mode $(ANSWER_MODE),) $(if $(BURN),--burn,)
|
||||
|
||||
session-list: bootstrap ## list all session dbs in ~/.arborist/sessions/
|
||||
session-list: bootstrap ## list all sessions in the single shard
|
||||
$(ARBORIST) session --list $(if $(JSON),--json,)
|
||||
|
||||
session-tree: bootstrap ## print one session's tree [SID=...]
|
||||
|
|
@ -233,12 +233,14 @@ session-tree: bootstrap ## print one session's tree [SID=...]
|
|||
fi
|
||||
$(ARBORIST) session --sid $(SID) --tree $(if $(JSON),--json,)
|
||||
|
||||
session-chain-check: bootstrap ## verify session audit-chain integrity [SID=... (default: all)]
|
||||
$(ARBORIST) session --chain-check $(if $(SID),--sid $(SID),)
|
||||
session-chain-check: bootstrap ## verify the single shard's global audit chain
|
||||
$(ARBORIST) session --chain-check
|
||||
|
||||
SESSION_KEEP ?= 100
|
||||
session-gc: bootstrap ## delete old session dbs, keep $(SESSION_KEEP) most recent [SESSION_KEEP=N]
|
||||
$(ARBORIST) session --gc $(SESSION_KEEP)
|
||||
session-find: bootstrap ## FTS5 search over all session turns [Q="..." LIMIT=N JSON=1]
|
||||
@if [ -z "$$Q" ] && [ -z "$(Q)" ]; then \
|
||||
echo "usage: make session-find Q=\"query text\" [LIMIT=10] [JSON=1]"; exit 2; \
|
||||
fi
|
||||
$(ARBORIST) session --find "$(Q)" $(if $(LIMIT),--limit $(LIMIT),) $(if $(JSON),--json,)
|
||||
|
||||
query-dry: bootstrap ## like 'make query' but skip the LLM call (dry-run) [JSON=1 BURN=1 ANSWER_MODE=... BROAD=1 REJECT_BROAD=1 ALLOW_BROAD=1 XLANG=1 XLANG_MT=1]
|
||||
@if [ -z "$$Q" ] && [ -z "$(Q)" ]; then \
|
||||
|
|
|
|||
365
arborist/cli.py
365
arborist/cli.py
|
|
@ -7460,8 +7460,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
help="resume an existing session id (default: new session)",
|
||||
)
|
||||
session_cmd.add_argument(
|
||||
"--sessions-dir", default=None,
|
||||
help="directory containing session dbs (default ~/.arborist/sessions/)",
|
||||
"--sessions-db", default=None,
|
||||
help="path to the single sessions shard "
|
||||
"(default ~/.arborist/sessions.db)",
|
||||
)
|
||||
session_cmd.add_argument(
|
||||
"--list", action="store_true",
|
||||
|
|
@ -7473,11 +7474,16 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
)
|
||||
session_cmd.add_argument(
|
||||
"--chain-check", action="store_true",
|
||||
help="verify session audit chain integrity (all sessions if no --sid)",
|
||||
help="verify the global audit chain (one shard for the user)",
|
||||
)
|
||||
session_cmd.add_argument(
|
||||
"--gc", type=int, default=None, metavar="N",
|
||||
help="keep the N most-recent sessions, delete older db files",
|
||||
"--find", default=None, metavar="QUERY",
|
||||
help="FTS5 search over all session nodes "
|
||||
"(question + answer + cited titles)",
|
||||
)
|
||||
session_cmd.add_argument(
|
||||
"--limit", type=int, default=10,
|
||||
help="--find result count cap (default 10)",
|
||||
)
|
||||
session_cmd.add_argument(
|
||||
"--json", action="store_true",
|
||||
|
|
@ -7514,151 +7520,130 @@ def _ignore(fn, *a, **kw):
|
|||
def _cmd_session(args: argparse.Namespace) -> int:
|
||||
"""Merkle-rooted multi-turn Q&A REPL with forks.
|
||||
|
||||
Subcommands flagged on the parser:
|
||||
--list list sessions
|
||||
--tree print tree (requires --sid)
|
||||
--chain-check verify audit chain
|
||||
--gc N keep N most recent sessions
|
||||
|
||||
Default (no flag): start an interactive REPL on a new session
|
||||
(or --sid to resume). REPL commands:
|
||||
/tree print tree
|
||||
/branches list branch-point Bates (≥2 children)
|
||||
/cd <ref> set current to <bates|seq|label>
|
||||
/back cd to parent of current
|
||||
/label <name> label the current node
|
||||
/show <ref> show stored cache_key + audit for that node
|
||||
/sid print session id
|
||||
/root print session_root
|
||||
/help print this help
|
||||
/quit exit (or Ctrl-D)
|
||||
<anything else> new question, child of current node
|
||||
Single shard at ~/.arborist/sessions.db holds every turn from
|
||||
every session. FTS5 over question + answer + cited titles makes
|
||||
prior turns findable + forkable across all sessions.
|
||||
"""
|
||||
from pathlib import Path as _Path
|
||||
|
||||
from arborist.qa.session import (
|
||||
Session, default_sessions_dir, list_sessions, render_tree,
|
||||
session_path,
|
||||
SessionStore, default_db_path, render_tree,
|
||||
)
|
||||
|
||||
sessions_dir = (
|
||||
_Path(args.sessions_dir) if args.sessions_dir
|
||||
else default_sessions_dir()
|
||||
db_path = (
|
||||
_Path(args.sessions_db) if args.sessions_db
|
||||
else default_db_path()
|
||||
)
|
||||
store = SessionStore.open(db_path)
|
||||
|
||||
# --list
|
||||
if getattr(args, "list", False):
|
||||
listing = list_sessions(sessions_dir)
|
||||
if args.json:
|
||||
print(json.dumps(listing, indent=2))
|
||||
else:
|
||||
if not listing:
|
||||
print(f"no sessions in {sessions_dir}")
|
||||
return 0
|
||||
print(f"{len(listing)} session(s) in {sessions_dir}:")
|
||||
for s in listing:
|
||||
print(
|
||||
f" {s['sid']:32s} nodes={s['n_nodes']:3d} "
|
||||
f"root={s['session_root']} "
|
||||
f"updated={s['updated_at']}"
|
||||
)
|
||||
return 0
|
||||
try:
|
||||
# --list
|
||||
if getattr(args, "list", False):
|
||||
listing = store.list_sessions()
|
||||
if args.json:
|
||||
print(json.dumps(listing, indent=2))
|
||||
else:
|
||||
if not listing:
|
||||
print(f"no sessions in {db_path}")
|
||||
else:
|
||||
print(f"{len(listing)} session(s) in {db_path}:")
|
||||
for s in listing:
|
||||
label = f" [{s['label']}]" if s["label"] else ""
|
||||
print(
|
||||
f" {s['sid']:32s} nodes={s['n_nodes']:3d} "
|
||||
f"root={s['session_root']} "
|
||||
f"updated={s['updated_at']}{label}"
|
||||
)
|
||||
return 0
|
||||
|
||||
# --gc
|
||||
if args.gc is not None:
|
||||
listing = list_sessions(sessions_dir)
|
||||
listing.sort(key=lambda s: s["updated_at"], reverse=True)
|
||||
keep, drop = listing[: args.gc], listing[args.gc:]
|
||||
for s in drop:
|
||||
try:
|
||||
_Path(s["path"]).unlink()
|
||||
except Exception:
|
||||
pass
|
||||
print(
|
||||
f"kept {len(keep)}, deleted {len(drop)} "
|
||||
f"session db(s) in {sessions_dir}"
|
||||
)
|
||||
return 0
|
||||
# --chain-check
|
||||
if args.chain_check:
|
||||
intact, breaks = store.chain_check()
|
||||
print(f"{db_path} intact={intact} breaks={breaks}")
|
||||
return 1 if breaks else 0
|
||||
|
||||
# --chain-check
|
||||
if args.chain_check:
|
||||
if args.sid:
|
||||
paths = [session_path(args.sid, sessions_dir)]
|
||||
else:
|
||||
paths = sorted(sessions_dir.glob("*.db"))
|
||||
any_break = False
|
||||
for p in paths:
|
||||
if not p.is_file():
|
||||
print(f"missing: {p}")
|
||||
any_break = True
|
||||
continue
|
||||
sess = Session.open(p)
|
||||
intact, breaks = sess.chain_check()
|
||||
sess.close()
|
||||
print(f"{p.name} intact={intact} breaks={breaks}")
|
||||
if breaks:
|
||||
any_break = True
|
||||
return 1 if any_break else 0
|
||||
|
||||
# --tree
|
||||
if args.tree:
|
||||
if not args.sid:
|
||||
print("--tree requires --sid", file=sys.stderr)
|
||||
return 2
|
||||
p = session_path(args.sid, sessions_dir)
|
||||
if not p.is_file():
|
||||
print(f"no such session: {args.sid}", file=sys.stderr)
|
||||
return 2
|
||||
sess = Session.open(p)
|
||||
if args.json:
|
||||
nodes = [
|
||||
{
|
||||
"bates": n.bates, "seq": n.seq,
|
||||
# --find
|
||||
if args.find:
|
||||
hits = store.find(args.find, limit=args.limit)
|
||||
if args.json:
|
||||
print(json.dumps([{
|
||||
"bates": n.bates, "sid": n.sid,
|
||||
"parent_bates": n.parent_bates,
|
||||
"question": n.question, "cache_key": n.cache_key,
|
||||
"question": n.question,
|
||||
"answer_text": n.answer_text,
|
||||
"audit_mode": n.audit_mode,
|
||||
"cited_titles": n.cited_titles,
|
||||
"node_hash": n.node_hash,
|
||||
"subtree_hash": n.subtree_hash,
|
||||
"created_at": n.created_at,
|
||||
} for n in hits], indent=2))
|
||||
else:
|
||||
if not hits:
|
||||
print("no matches")
|
||||
else:
|
||||
for n in hits:
|
||||
print(
|
||||
f"[{n.bates}] {n.audit_mode} "
|
||||
f"{(n.question or '')[:80]}"
|
||||
)
|
||||
return 0
|
||||
|
||||
# --tree
|
||||
if args.tree:
|
||||
if not args.sid:
|
||||
print("--tree requires --sid", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
sess = store.open_session(args.sid)
|
||||
except ValueError:
|
||||
print(f"no such session: {args.sid}", file=sys.stderr)
|
||||
return 2
|
||||
if args.json:
|
||||
nodes = [{
|
||||
"bates": n.bates, "sid": n.sid, "seq": n.seq,
|
||||
"parent_bates": n.parent_bates,
|
||||
"question": n.question,
|
||||
"answer_text": n.answer_text,
|
||||
"cache_key": n.cache_key,
|
||||
"audit_mode": n.audit_mode,
|
||||
"cited_titles": n.cited_titles,
|
||||
"created_at": n.created_at, "label": n.label,
|
||||
"node_hash": n.node_hash,
|
||||
"subtree_hash": n.subtree_hash,
|
||||
}
|
||||
for n in sess.all_nodes()
|
||||
]
|
||||
print(json.dumps({
|
||||
"sid": sess.sid,
|
||||
"session_root": sess.session_root,
|
||||
"current_bates": sess.current_bates,
|
||||
"branches": sess.branches(),
|
||||
"nodes": nodes,
|
||||
}, indent=2))
|
||||
} for n in sess.all_nodes()]
|
||||
print(json.dumps({
|
||||
"sid": sess.sid,
|
||||
"session_root": sess.session_root,
|
||||
"current_bates": sess.current_bates,
|
||||
"branches": sess.branches(),
|
||||
"nodes": nodes,
|
||||
}, indent=2))
|
||||
else:
|
||||
print(render_tree(sess))
|
||||
return 0
|
||||
|
||||
# Default: interactive REPL.
|
||||
if args.sid:
|
||||
try:
|
||||
sess = store.open_session(args.sid)
|
||||
except ValueError:
|
||||
print(f"no such session: {args.sid}", file=sys.stderr)
|
||||
return 2
|
||||
else:
|
||||
print(render_tree(sess))
|
||||
sess.close()
|
||||
return 0
|
||||
|
||||
# Default: interactive REPL.
|
||||
if args.sid:
|
||||
p = session_path(args.sid, sessions_dir)
|
||||
sess = Session.open(p, sid=args.sid)
|
||||
else:
|
||||
# Mint a fresh sid via Session.open's default generator.
|
||||
from arborist.qa.session import _gen_sid
|
||||
sid = _gen_sid()
|
||||
p = session_path(sid, sessions_dir)
|
||||
sess = Session.open(p, sid=sid)
|
||||
print(f"# session {sess.sid}", file=sys.stderr)
|
||||
print(
|
||||
f"# db {p} · root {sess.session_root}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"# /help for commands · Ctrl-D to exit · "
|
||||
"any other line asks a question",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
rc = _session_repl(args, sess)
|
||||
sess.close()
|
||||
return rc
|
||||
sess = store.create_session()
|
||||
print(f"# session {sess.sid}", file=sys.stderr)
|
||||
print(
|
||||
f"# db {db_path} · root {sess.session_root}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"# /help for commands · Ctrl-D to exit · "
|
||||
"any other line asks a question",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return _session_repl(args, sess)
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
|
||||
def _session_repl(args: argparse.Namespace, sess) -> int:
|
||||
|
|
@ -7736,16 +7721,17 @@ def _session_repl(args: argparse.Namespace, sess) -> int:
|
|||
policy = {"answer_mode": args.answer_mode}
|
||||
|
||||
help_text = (
|
||||
"/tree show the tree\n"
|
||||
"/branches list branch points\n"
|
||||
"/cd <bates|seq|label> move current pointer\n"
|
||||
"/back cd to parent of current\n"
|
||||
"/label <name> label current node\n"
|
||||
"/show <ref> show stored info for a node\n"
|
||||
"/sid print session id\n"
|
||||
"/root print session_root\n"
|
||||
"/help this help\n"
|
||||
"/quit exit"
|
||||
"/tree show this session's tree\n"
|
||||
"/branches list branch points (≥2 children)\n"
|
||||
"/find <query> FTS5 search across ALL sessions\n"
|
||||
"/cd <bates|seq|label> move current pointer (cross-session OK)\n"
|
||||
"/back cd to parent of current\n"
|
||||
"/label <name> label current node\n"
|
||||
"/show [ref] show stored info for a node\n"
|
||||
"/sid print session id\n"
|
||||
"/root print session_root\n"
|
||||
"/help this help\n"
|
||||
"/quit exit"
|
||||
)
|
||||
|
||||
try:
|
||||
|
|
@ -7789,6 +7775,21 @@ def _session_repl(args: argparse.Namespace, sess) -> int:
|
|||
else:
|
||||
print("already at root")
|
||||
continue
|
||||
if line.startswith("/find "):
|
||||
q = line[6:].strip()
|
||||
hits = sess.find(q, limit=args.limit)
|
||||
if not hits:
|
||||
print("no matches")
|
||||
else:
|
||||
for n in hits:
|
||||
sid_tag = (
|
||||
f" [from {n.sid}]" if n.sid != sess.sid else ""
|
||||
)
|
||||
print(
|
||||
f"[{n.bates}] {n.audit_mode} "
|
||||
f"{(n.question or '')[:80]}{sid_tag}"
|
||||
)
|
||||
continue
|
||||
if line.startswith("/cd "):
|
||||
ref = line[4:].strip()
|
||||
tgt = sess.resolve(ref)
|
||||
|
|
@ -7800,7 +7801,7 @@ def _session_repl(args: argparse.Namespace, sess) -> int:
|
|||
continue
|
||||
if line.startswith("/label "):
|
||||
name = line[7:].strip()
|
||||
sess.label(sess.current_bates, name)
|
||||
sess.label_current(name)
|
||||
print(f"labeled {sess.current_bates} as {name!r}")
|
||||
continue
|
||||
if line.startswith("/show"):
|
||||
|
|
@ -7811,9 +7812,13 @@ def _session_repl(args: argparse.Namespace, sess) -> int:
|
|||
print(f"unknown ref: {ref}")
|
||||
continue
|
||||
print(json.dumps({
|
||||
"bates": n.bates, "parent_bates": n.parent_bates,
|
||||
"question": n.question, "cache_key": n.cache_key,
|
||||
"bates": n.bates, "sid": n.sid,
|
||||
"parent_bates": n.parent_bates,
|
||||
"question": n.question,
|
||||
"answer_text": n.answer_text,
|
||||
"cache_key": n.cache_key,
|
||||
"audit_mode": n.audit_mode,
|
||||
"cited_titles": n.cited_titles,
|
||||
"label": n.label,
|
||||
"node_hash": n.node_hash,
|
||||
"subtree_hash": n.subtree_hash,
|
||||
|
|
@ -7824,14 +7829,19 @@ def _session_repl(args: argparse.Namespace, sess) -> int:
|
|||
print(f"unknown command: {line} (try /help)")
|
||||
continue
|
||||
|
||||
# Non-command line → new question. Pull ancestor cited
|
||||
# titles into retrieval_keywords so context flows down the
|
||||
# branch (verifier-blind; same discipline as the existing
|
||||
# --retrieval-keywords flag, see #000001).
|
||||
ancestor_keywords = _session_ancestor_keywords(sess, qa_db)
|
||||
# Non-command line → new question. Read cited titles
|
||||
# directly from ancestor node rows (already materialized at
|
||||
# insert time) and pass as retrieval_keywords. Verifier-
|
||||
# blind; same discipline as the legacy --retrieval-keywords
|
||||
# flag (#000001).
|
||||
seen_titles: list[str] = []
|
||||
for n in sess.path_to_root(sess.current_bates)[1:]:
|
||||
for t in n.cited_titles:
|
||||
if t and t not in seen_titles:
|
||||
seen_titles.append(t)
|
||||
policy_local = dict(policy)
|
||||
if ancestor_keywords:
|
||||
policy_local["retrieval_keywords"] = ancestor_keywords
|
||||
if seen_titles:
|
||||
policy_local["retrieval_keywords"] = " ".join(seen_titles[:8])
|
||||
|
||||
try:
|
||||
result = providence_query(
|
||||
|
|
@ -7848,8 +7858,8 @@ def _session_repl(args: argparse.Namespace, sess) -> int:
|
|||
|
||||
audit_mode = result.get("audit_mode", "UNGROUNDED")
|
||||
ckey = result.get("cache_key", "")
|
||||
n = sess.add_node(line, ckey, audit_mode)
|
||||
ans = (result.get("answer_text") or "").strip()
|
||||
n = sess.add_node(line, ckey, audit_mode, answer_text=ans)
|
||||
print(f"\n[{n.bates}] {audit_mode}")
|
||||
print(ans)
|
||||
print(
|
||||
|
|
@ -7868,51 +7878,6 @@ def _session_repl(args: argparse.Namespace, sess) -> int:
|
|||
return 0
|
||||
|
||||
|
||||
def _session_ancestor_keywords(sess, qa_db: Path) -> str:
|
||||
"""Gather cited source titles along the path-to-root → flat
|
||||
keyword string suitable for retrieval_keywords. Verifier-blind:
|
||||
these terms widen retrieval, never change governance_policy_hash.
|
||||
|
||||
Reads the ancestor nodes' stored answer_text from providence_cache
|
||||
and parses ``[E# | <title> | <chunk_prefix>: …`` pointer-lines.
|
||||
Only titles the model actually cited count — exactly what the
|
||||
user just saw on the prior turn — so retrieval flows along the
|
||||
visible thread of the conversation, not the wider retrieval pool.
|
||||
"""
|
||||
import re
|
||||
pointer_re = re.compile(r"\[E\d+\s*\|\s*([^|]+?)\s*\|")
|
||||
try:
|
||||
import sqlite3 as _sql
|
||||
conn = _sql.connect(f"file:{qa_db}?mode=ro", uri=True)
|
||||
conn.row_factory = _sql.Row
|
||||
except Exception:
|
||||
return ""
|
||||
seen_titles: list[str] = []
|
||||
try:
|
||||
cur = sess.current_bates
|
||||
ancestors = sess.path_to_root(cur)[1:] # skip current
|
||||
for n in ancestors:
|
||||
if not n.cache_key:
|
||||
continue
|
||||
r = conn.execute(
|
||||
"SELECT answer_text FROM providence_cache "
|
||||
"WHERE cache_key=? AND falsification_state='live' LIMIT 1",
|
||||
(n.cache_key,),
|
||||
).fetchone()
|
||||
if not r:
|
||||
continue
|
||||
for m in pointer_re.finditer(r["answer_text"] or ""):
|
||||
t = m.group(1).strip()
|
||||
if t and t not in seen_titles:
|
||||
seen_titles.append(t)
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
return " ".join(seen_titles[:8]) # cap to avoid keyword sprawl
|
||||
|
||||
|
||||
def _cmd_serve(args: argparse.Namespace) -> int:
|
||||
"""Start the HTTP wallet server. Blocks until SIGINT/SIGTERM."""
|
||||
from arborist.wallet.server import WalletServer, serve
|
||||
|
|
|
|||
|
|
@ -1,39 +1,44 @@
|
|||
"""Session: Merkle-rooted conversation tree over providence_cache.
|
||||
"""Sessions — single-shard Merkle-rooted conversation forest.
|
||||
|
||||
A session is a tree of Q&A nodes. Each turn (or fork) mints a new node
|
||||
under a chosen parent. Every node carries a stable Bates identifier
|
||||
(``<sid>-<6-digit>``) AND a content-addressed ``node_hash``. The tree
|
||||
maintains a Merkle ``subtree_hash`` per node; the root node's
|
||||
``subtree_hash`` is the ``session_root``.
|
||||
One SQLite shard at ``~/.arborist/sessions.db`` holds every turn from
|
||||
every session a user has ever started. Each session is a "viewport"
|
||||
onto this shared forest: a session has its own root node and current
|
||||
pointer, but ``nodes.parent_bates`` can cross sessions, so forking from
|
||||
a historical turn (yesterday's, last week's) just lands a new child
|
||||
under that turn — your present session gains a branch into the past.
|
||||
|
||||
When a new node is inserted under parent P:
|
||||
1. Compute ``node_hash = sha256(canonical(parent_node_hash || bates ||
|
||||
question || cache_key || audit_mode || created_at || label))``
|
||||
2. Set ``subtree_hash = leaf_hash(node_hash)`` (new node is a leaf).
|
||||
3. Walk N → P → P.parent → … → root, recomputing each ancestor's
|
||||
``subtree_hash`` to fold in the new child.
|
||||
4. Update ``session_meta.session_root`` to the root's new ``subtree_hash``.
|
||||
5. Append one ``session_audit_events`` row whose ``event_hash`` chains
|
||||
off the previous event.
|
||||
Bates discipline (``<sid>-<6-digit>``) stays globally unique: ``sid``
|
||||
is timestamp+random, never reused; ``seq`` is monotonic per ``sid``.
|
||||
``node_hash`` is content-addressed and folds the parent's
|
||||
``node_hash``; ``subtree_hash`` is the Merkle hash over the node + all
|
||||
its descendants (regardless of which session minted them).
|
||||
``session_root`` is the live ``subtree_hash`` of a session's root node
|
||||
— recomputed lazily on read since cross-session forks can ripple
|
||||
through it.
|
||||
|
||||
This is O(depth) per insert. Caching for page-refresh works the way it
|
||||
does in any Merkle tree: a UI client stores (bates → subtree_hash, body)
|
||||
and on refresh fetches only nodes whose subtree_hash changed. Sibling
|
||||
subtrees that didn't change keep their hash → no re-fetch.
|
||||
Why one shard instead of per-session files: queries become first-class
|
||||
(``/find spider-man`` walks FTS5 over every prior question + answer
|
||||
across every session) and forks-from-history are a single cross-shard
|
||||
parent_bates pointer instead of a copy. Privacy: local-only for now;
|
||||
gossip-trust sharing is future work — this layer's contract is
|
||||
"private to the operator, journal of everything they asked."
|
||||
|
||||
The session db is self-contained at ``~/.arborist/sessions/<sid>.db`` —
|
||||
no foreign keys into the main store. Cross-session verification:
|
||||
``arborist session --chain-check SID``.
|
||||
Schema highlights:
|
||||
- nodes — every turn ever, with FTS5 over question+answer+titles
|
||||
- sessions — per-sid metadata (root_bates, current_bates, label)
|
||||
- audit_events — one global hash-chain over every state change
|
||||
|
||||
Bates discipline: never reassigned, never re-used. ``seq=0`` is a
|
||||
synthetic root with empty question; all real questions are descendants.
|
||||
This matches arborist's broader "every fact has a stable name" identity
|
||||
philosophy.
|
||||
Two integrity invariants:
|
||||
- subtree_hash recompute path is O(depth) per insert, walks
|
||||
parent_bates regardless of session
|
||||
- audit_events.event_hash = sha256(prev_event_hash || canonical(body)),
|
||||
one chain across all sessions — tampering is detectable globally
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
|
@ -44,7 +49,6 @@ from arborist.merkle import hash_combine, hash_leaf
|
|||
|
||||
|
||||
def _canonical(body: dict) -> bytes:
|
||||
"""Stable canonical JSON: sorted keys, no whitespace, UTF-8."""
|
||||
return json.dumps(
|
||||
body, sort_keys=True, separators=(",", ":"), ensure_ascii=False
|
||||
).encode("utf-8", errors="surrogatepass")
|
||||
|
|
@ -62,45 +66,78 @@ def _now_iso() -> str:
|
|||
|
||||
|
||||
def _gen_sid() -> str:
|
||||
"""Generate a session id: YYYYMMDD-HHMMSS-<4-hex>."""
|
||||
import secrets
|
||||
return time.strftime("%Y%m%d-%H%M%S", time.gmtime()) + "-" + secrets.token_hex(2)
|
||||
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS session_meta (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
sid TEXT PRIMARY KEY,
|
||||
root_bates TEXT NOT NULL,
|
||||
session_root TEXT NOT NULL,
|
||||
current_bates TEXT NOT NULL,
|
||||
next_seq INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
updated_at TEXT NOT NULL,
|
||||
label TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS nodes (
|
||||
bates TEXT PRIMARY KEY,
|
||||
seq INTEGER NOT NULL,
|
||||
parent_bates TEXT REFERENCES nodes(bates),
|
||||
question TEXT NOT NULL,
|
||||
cache_key TEXT NOT NULL,
|
||||
audit_mode TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
label TEXT,
|
||||
node_hash TEXT NOT NULL,
|
||||
subtree_hash TEXT NOT NULL
|
||||
bates TEXT PRIMARY KEY,
|
||||
sid TEXT NOT NULL REFERENCES sessions(sid),
|
||||
seq INTEGER NOT NULL,
|
||||
parent_bates TEXT REFERENCES nodes(bates),
|
||||
question TEXT NOT NULL,
|
||||
answer_text TEXT NOT NULL DEFAULT '',
|
||||
cache_key TEXT NOT NULL,
|
||||
audit_mode TEXT NOT NULL,
|
||||
cited_titles_json TEXT NOT NULL DEFAULT '[]',
|
||||
n_cited_sources INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
label TEXT,
|
||||
node_hash TEXT NOT NULL,
|
||||
subtree_hash TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_parent ON nodes(parent_bates);
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_label ON nodes(label);
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_sid ON nodes(sid);
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_parent ON nodes(parent_bates);
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_label ON nodes(label);
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_ckey ON nodes(cache_key);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS session_audit_events (
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(
|
||||
question, answer_text, cited_titles,
|
||||
content='nodes',
|
||||
content_rowid='rowid',
|
||||
tokenize='unicode61'
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS nodes_fts_ai AFTER INSERT ON nodes BEGIN
|
||||
INSERT INTO nodes_fts(rowid, question, answer_text, cited_titles)
|
||||
VALUES (NEW.rowid, NEW.question, NEW.answer_text,
|
||||
REPLACE(REPLACE(REPLACE(NEW.cited_titles_json,'[',''),']',''),'"',''));
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS nodes_fts_au AFTER UPDATE ON nodes BEGIN
|
||||
INSERT INTO nodes_fts(nodes_fts, rowid, question, answer_text, cited_titles)
|
||||
VALUES ('delete', OLD.rowid, OLD.question, OLD.answer_text,
|
||||
REPLACE(REPLACE(REPLACE(OLD.cited_titles_json,'[',''),']',''),'"',''));
|
||||
INSERT INTO nodes_fts(rowid, question, answer_text, cited_titles)
|
||||
VALUES (NEW.rowid, NEW.question, NEW.answer_text,
|
||||
REPLACE(REPLACE(REPLACE(NEW.cited_titles_json,'[',''),']',''),'"',''));
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS nodes_fts_ad AFTER DELETE ON nodes BEGIN
|
||||
INSERT INTO nodes_fts(nodes_fts, rowid, question, answer_text, cited_titles)
|
||||
VALUES ('delete', OLD.rowid, OLD.question, OLD.answer_text,
|
||||
REPLACE(REPLACE(REPLACE(OLD.cited_titles_json,'[',''),']',''),'"',''));
|
||||
END;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_events (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_type TEXT NOT NULL,
|
||||
sid TEXT,
|
||||
bates TEXT,
|
||||
prev_root TEXT NOT NULL,
|
||||
session_root TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
prev_event_hash TEXT,
|
||||
event_hash TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
|
|
@ -109,11 +146,15 @@ CREATE TABLE IF NOT EXISTS session_audit_events (
|
|||
@dataclass(frozen=True)
|
||||
class Node:
|
||||
bates: str
|
||||
sid: str
|
||||
seq: int
|
||||
parent_bates: Optional[str]
|
||||
question: str
|
||||
answer_text: str
|
||||
cache_key: str
|
||||
audit_mode: str
|
||||
cited_titles: list[str]
|
||||
n_cited_sources: int
|
||||
created_at: str
|
||||
label: Optional[str]
|
||||
node_hash: str
|
||||
|
|
@ -146,20 +187,9 @@ def _compute_node_hash(
|
|||
|
||||
|
||||
def _compute_subtree_hash(node_hash: str, children_subtree_hashes: list[str]) -> str:
|
||||
"""Merkle hash over node + sorted children.
|
||||
|
||||
Children are sorted by their own subtree_hash (deterministic). Two
|
||||
leaves combine via ``HashCombine`` to keep the prefix-discipline
|
||||
consistent with the rest of the repo. A node with N children folds
|
||||
them pairwise as a balanced tree (left-leaning self-duplicate on
|
||||
odd counts, same as ``arborist.merkle.MerkleTree``).
|
||||
"""
|
||||
leaf = hash_leaf(bytes.fromhex(node_hash))
|
||||
if not children_subtree_hashes:
|
||||
return leaf.hex()
|
||||
# Sort children for determinism — Bates is monotonic but inserts can
|
||||
# arrive out of order under future concurrent writers; subtree_hash
|
||||
# is content-addressed so sorting on it is the natural canonical form.
|
||||
levels: list[bytes] = sorted(bytes.fromhex(h) for h in children_subtree_hashes)
|
||||
while len(levels) > 1:
|
||||
nxt: list[bytes] = []
|
||||
|
|
@ -167,49 +197,86 @@ def _compute_subtree_hash(node_hash: str, children_subtree_hashes: list[str]) ->
|
|||
if i + 1 < len(levels):
|
||||
nxt.append(hash_combine(levels[i], levels[i + 1]))
|
||||
else:
|
||||
# Odd-count rule: self-duplicate.
|
||||
nxt.append(hash_combine(levels[i], levels[i]))
|
||||
levels = nxt
|
||||
children_root = levels[0]
|
||||
return hash_combine(leaf, children_root).hex()
|
||||
return hash_combine(leaf, levels[0]).hex()
|
||||
|
||||
|
||||
class Session:
|
||||
"""Open Merkle-rooted conversation-tree store on disk."""
|
||||
_POINTER_TITLE_RE = re.compile(r"\[E\d+\s*\|\s*([^|]+?)\s*\|")
|
||||
|
||||
|
||||
def _extract_cited_titles(answer_text: str) -> list[str]:
|
||||
"""Pull source titles from claim-lattice pointer-line answers
|
||||
(``[E# | <title> | <chunk_prefix>: …``). Order-preserving dedupe.
|
||||
"""
|
||||
out: list[str] = []
|
||||
for m in _POINTER_TITLE_RE.finditer(answer_text or ""):
|
||||
t = m.group(1).strip()
|
||||
if t and t not in out:
|
||||
out.append(t)
|
||||
return out
|
||||
|
||||
|
||||
def default_db_path() -> Path:
|
||||
return Path.home() / ".arborist" / "sessions.db"
|
||||
|
||||
|
||||
class SessionStore:
|
||||
"""Open the single sessions shard. Per-user, local-private."""
|
||||
|
||||
def __init__(self, conn: sqlite3.Connection):
|
||||
self._conn = conn
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
|
||||
@classmethod
|
||||
def open(cls, db_path: Optional[Path] = None) -> "SessionStore":
|
||||
db_path = Path(db_path) if db_path else default_db_path()
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.executescript(SCHEMA)
|
||||
return cls(conn)
|
||||
|
||||
@property
|
||||
def conn(self) -> sqlite3.Connection:
|
||||
return self._conn
|
||||
|
||||
# ---- factory ----
|
||||
def close(self) -> None:
|
||||
try:
|
||||
self._conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def open(cls, db_path: Path, sid: Optional[str] = None) -> "Session":
|
||||
"""Open or create a session at db_path. ``sid`` is required only
|
||||
when creating a NEW session db; for existing dbs we read the
|
||||
single session_meta row to discover the sid.
|
||||
"""
|
||||
db_path = Path(db_path)
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.executescript(SCHEMA)
|
||||
sess = cls(conn)
|
||||
existing = conn.execute(
|
||||
"SELECT session_id FROM session_meta LIMIT 1"
|
||||
).fetchone()
|
||||
if existing is None:
|
||||
sess._init_new(sid or _gen_sid())
|
||||
return sess
|
||||
# ---- session-level ----
|
||||
|
||||
def _init_new(self, sid: str) -> None:
|
||||
"""Create the synthetic root node (seq=0, empty question)."""
|
||||
def list_sessions(self) -> list[dict]:
|
||||
out: list[dict] = []
|
||||
for r in self._conn.execute(
|
||||
"SELECT s.sid, s.root_bates, s.current_bates, s.label, "
|
||||
" s.created_at, s.updated_at, "
|
||||
" (SELECT COUNT(*) FROM nodes n WHERE n.sid=s.sid) AS n_nodes "
|
||||
"FROM sessions s ORDER BY s.updated_at DESC"
|
||||
):
|
||||
out.append({
|
||||
"sid": r["sid"],
|
||||
"root_bates": r["root_bates"],
|
||||
"current_bates": r["current_bates"],
|
||||
"label": r["label"],
|
||||
"n_nodes": r["n_nodes"],
|
||||
"session_root": self.session_root(r["sid"]),
|
||||
"created_at": r["created_at"],
|
||||
"updated_at": r["updated_at"],
|
||||
})
|
||||
return out
|
||||
|
||||
def create_session(self, sid: Optional[str] = None,
|
||||
label: Optional[str] = None) -> "Session":
|
||||
sid = sid or _gen_sid()
|
||||
if self._conn.execute(
|
||||
"SELECT 1 FROM sessions WHERE sid=?", (sid,)
|
||||
).fetchone():
|
||||
raise ValueError(f"session already exists: {sid}")
|
||||
now = _now_iso()
|
||||
root_bates = _bates(sid, 0)
|
||||
# Synthetic root has no parent → parent_node_hash="" (empty).
|
||||
nh = _compute_node_hash(
|
||||
parent_node_hash="",
|
||||
bates=root_bates,
|
||||
|
|
@ -222,102 +289,84 @@ class Session:
|
|||
sh = _compute_subtree_hash(nh, [])
|
||||
with self._conn:
|
||||
self._conn.execute(
|
||||
"INSERT INTO nodes (bates, seq, parent_bates, question, "
|
||||
"cache_key, audit_mode, created_at, label, node_hash, "
|
||||
"subtree_hash) VALUES (?, ?, NULL, '', '', '', ?, NULL, ?, ?)",
|
||||
(root_bates, 0, now, nh, sh),
|
||||
"INSERT INTO sessions (sid, root_bates, current_bates, "
|
||||
"next_seq, created_at, updated_at, label) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(sid, root_bates, root_bates, 1, now, now, label),
|
||||
)
|
||||
self._conn.execute(
|
||||
"INSERT INTO session_meta (session_id, root_bates, "
|
||||
"session_root, current_bates, next_seq, created_at, "
|
||||
"updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(sid, root_bates, sh, root_bates, 1, now, now),
|
||||
"INSERT INTO nodes (bates, sid, seq, parent_bates, "
|
||||
"question, answer_text, cache_key, audit_mode, "
|
||||
"cited_titles_json, n_cited_sources, created_at, "
|
||||
"label, node_hash, subtree_hash) "
|
||||
"VALUES (?, ?, 0, NULL, '', '', '', '', '[]', 0, ?, "
|
||||
"NULL, ?, ?)",
|
||||
(root_bates, sid, now, nh, sh),
|
||||
)
|
||||
self._append_event("session_init", root_bates, prev_root="",
|
||||
session_root=sh,
|
||||
body={"sid": sid, "root_bates": root_bates})
|
||||
self._append_event(
|
||||
"session_init", sid=sid, bates=root_bates,
|
||||
body={"sid": sid, "root_bates": root_bates},
|
||||
)
|
||||
return self.open_session(sid)
|
||||
|
||||
# ---- properties ----
|
||||
|
||||
@property
|
||||
def sid(self) -> str:
|
||||
def open_session(self, sid: str) -> "Session":
|
||||
r = self._conn.execute(
|
||||
"SELECT session_id FROM session_meta"
|
||||
"SELECT * FROM sessions WHERE sid=?", (sid,)
|
||||
).fetchone()
|
||||
return r["session_id"] if r else ""
|
||||
if r is None:
|
||||
raise ValueError(f"session not found: {sid}")
|
||||
return Session(self, sid)
|
||||
|
||||
@property
|
||||
def root_bates(self) -> str:
|
||||
r = self._conn.execute("SELECT root_bates FROM session_meta").fetchone()
|
||||
return r["root_bates"] if r else ""
|
||||
def session_root(self, sid: str) -> str:
|
||||
"""Live subtree_hash of the session's root node."""
|
||||
r = self._conn.execute(
|
||||
"SELECT n.subtree_hash FROM sessions s "
|
||||
"JOIN nodes n ON n.bates = s.root_bates "
|
||||
"WHERE s.sid=?", (sid,),
|
||||
).fetchone()
|
||||
return r["subtree_hash"] if r else ""
|
||||
|
||||
@property
|
||||
def session_root(self) -> str:
|
||||
r = self._conn.execute("SELECT session_root FROM session_meta").fetchone()
|
||||
return r["session_root"] if r else ""
|
||||
# ---- node-level ----
|
||||
|
||||
@property
|
||||
def current_bates(self) -> str:
|
||||
r = self._conn.execute("SELECT current_bates FROM session_meta").fetchone()
|
||||
return r["current_bates"] if r else ""
|
||||
|
||||
# ---- queries ----
|
||||
def _row_to_node(self, r) -> Node:
|
||||
try:
|
||||
titles = json.loads(r["cited_titles_json"] or "[]")
|
||||
if not isinstance(titles, list):
|
||||
titles = []
|
||||
except Exception:
|
||||
titles = []
|
||||
return Node(
|
||||
bates=r["bates"], sid=r["sid"], seq=r["seq"],
|
||||
parent_bates=r["parent_bates"],
|
||||
question=r["question"], answer_text=r["answer_text"],
|
||||
cache_key=r["cache_key"], audit_mode=r["audit_mode"],
|
||||
cited_titles=titles, n_cited_sources=r["n_cited_sources"],
|
||||
created_at=r["created_at"], label=r["label"],
|
||||
node_hash=r["node_hash"], subtree_hash=r["subtree_hash"],
|
||||
)
|
||||
|
||||
def get_node(self, bates: str) -> Optional[Node]:
|
||||
r = self._conn.execute(
|
||||
"SELECT * FROM nodes WHERE bates=?", (bates,)
|
||||
).fetchone()
|
||||
if not r:
|
||||
return None
|
||||
return Node(
|
||||
bates=r["bates"], seq=r["seq"],
|
||||
parent_bates=r["parent_bates"],
|
||||
question=r["question"], cache_key=r["cache_key"],
|
||||
audit_mode=r["audit_mode"], created_at=r["created_at"],
|
||||
label=r["label"], node_hash=r["node_hash"],
|
||||
subtree_hash=r["subtree_hash"],
|
||||
)
|
||||
|
||||
def resolve(self, ref: str) -> Optional[str]:
|
||||
"""Resolve a user-facing reference to a Bates.
|
||||
|
||||
Accepts: full Bates (``<sid>-<6digit>``), short seq (``23``),
|
||||
or a label. Returns the canonical Bates or None.
|
||||
"""
|
||||
sid = self.sid
|
||||
# Full Bates direct match.
|
||||
if "-" in ref and self.get_node(ref) is not None:
|
||||
return ref
|
||||
# Bare numeric → prefix with sid.
|
||||
try:
|
||||
n = int(ref)
|
||||
cand = _bates(sid, n)
|
||||
if self.get_node(cand):
|
||||
return cand
|
||||
except ValueError:
|
||||
pass
|
||||
# Label lookup.
|
||||
r = self._conn.execute(
|
||||
"SELECT bates FROM nodes WHERE label=?", (ref,)
|
||||
).fetchone()
|
||||
if r:
|
||||
return r["bates"]
|
||||
return None
|
||||
return self._row_to_node(r) if r else None
|
||||
|
||||
def children_of(self, bates: str) -> list[Node]:
|
||||
return [
|
||||
self.get_node(r["bates"]) # type: ignore[misc]
|
||||
self._row_to_node(r)
|
||||
for r in self._conn.execute(
|
||||
"SELECT bates FROM nodes WHERE parent_bates=? ORDER BY seq",
|
||||
"SELECT * FROM nodes WHERE parent_bates=? "
|
||||
"ORDER BY created_at, seq",
|
||||
(bates,),
|
||||
)
|
||||
]
|
||||
|
||||
def path_to_root(self, bates: str) -> list[Node]:
|
||||
"""Return [bates, parent, ..., root]. Root last."""
|
||||
out: list[Node] = []
|
||||
cur: Optional[str] = bates
|
||||
while cur is not None:
|
||||
seen: set[str] = set()
|
||||
while cur is not None and cur not in seen:
|
||||
seen.add(cur)
|
||||
n = self.get_node(cur)
|
||||
if n is None:
|
||||
break
|
||||
|
|
@ -325,53 +374,89 @@ class Session:
|
|||
cur = n.parent_bates
|
||||
return out
|
||||
|
||||
def branches(self) -> list[str]:
|
||||
"""Bates with ≥2 children — explicit branch points."""
|
||||
def branches_in_session(self, sid: str) -> list[str]:
|
||||
"""Bates with ≥2 children, scoped to nodes owned by this sid.
|
||||
Cross-session children DO count toward the branch-point status
|
||||
of a node owned by this sid — your branches grow when others
|
||||
fork off your nodes.
|
||||
"""
|
||||
return [
|
||||
r["parent_bates"]
|
||||
for r in self._conn.execute(
|
||||
"SELECT parent_bates FROM nodes WHERE parent_bates IS NOT NULL "
|
||||
"GROUP BY parent_bates HAVING COUNT(*) >= 2 "
|
||||
"ORDER BY MIN(seq)"
|
||||
"SELECT n.parent_bates FROM nodes n "
|
||||
"JOIN nodes p ON p.bates = n.parent_bates "
|
||||
"WHERE p.sid = ? "
|
||||
"GROUP BY n.parent_bates "
|
||||
"HAVING COUNT(*) >= 2 "
|
||||
"ORDER BY MIN(n.seq)",
|
||||
(sid,),
|
||||
)
|
||||
]
|
||||
|
||||
def all_nodes(self) -> list[Node]:
|
||||
def all_nodes_in_session(self, sid: str) -> list[Node]:
|
||||
return [
|
||||
Node(
|
||||
bates=r["bates"], seq=r["seq"],
|
||||
parent_bates=r["parent_bates"],
|
||||
question=r["question"], cache_key=r["cache_key"],
|
||||
audit_mode=r["audit_mode"], created_at=r["created_at"],
|
||||
label=r["label"], node_hash=r["node_hash"],
|
||||
subtree_hash=r["subtree_hash"],
|
||||
)
|
||||
self._row_to_node(r)
|
||||
for r in self._conn.execute(
|
||||
"SELECT * FROM nodes ORDER BY seq"
|
||||
"SELECT * FROM nodes WHERE sid=? ORDER BY seq", (sid,),
|
||||
)
|
||||
]
|
||||
|
||||
# ---- search ----
|
||||
|
||||
def find(self, query: str, limit: int = 10) -> list[Node]:
|
||||
"""FTS5 search over question + answer_text + cited_titles.
|
||||
Order by bm25 ascending (best first). Spans all sessions.
|
||||
Skips synthetic-root nodes (seq=0, empty question).
|
||||
"""
|
||||
try:
|
||||
from arborist.wallet.bucket import _to_fts5
|
||||
match_expr = _to_fts5(query)
|
||||
except Exception:
|
||||
match_expr = query
|
||||
if not match_expr.strip():
|
||||
return []
|
||||
rows = list(self._conn.execute(
|
||||
"SELECT nodes.*, bm25(nodes_fts) AS score "
|
||||
"FROM nodes_fts "
|
||||
"JOIN nodes ON nodes.rowid = nodes_fts.rowid "
|
||||
"WHERE nodes_fts MATCH ? AND nodes.seq > 0 "
|
||||
"ORDER BY score ASC LIMIT ?",
|
||||
(match_expr, limit),
|
||||
))
|
||||
return [self._row_to_node(r) for r in rows]
|
||||
|
||||
def find_by_cache_key(self, cache_key: str) -> list[Node]:
|
||||
return [
|
||||
self._row_to_node(r)
|
||||
for r in self._conn.execute(
|
||||
"SELECT * FROM nodes WHERE cache_key=? ORDER BY created_at",
|
||||
(cache_key,),
|
||||
)
|
||||
]
|
||||
|
||||
# ---- mutations ----
|
||||
|
||||
def add_node(
|
||||
self,
|
||||
question: str,
|
||||
cache_key: str,
|
||||
audit_mode: str,
|
||||
self, sid: str, *,
|
||||
question: str, cache_key: str, audit_mode: str,
|
||||
answer_text: str = "",
|
||||
parent_bates: Optional[str] = None,
|
||||
) -> Node:
|
||||
"""Mint a new node under parent (or current_bates). Recompute
|
||||
subtree_hashes from new node up to root, append audit event."""
|
||||
parent_bates = parent_bates or self.current_bates
|
||||
"""Mint a node in session ``sid``. ``parent_bates`` may belong
|
||||
to ANY session (cross-session forks land a child here).
|
||||
Defaults to the session's current_bates pointer."""
|
||||
sess_row = self._conn.execute(
|
||||
"SELECT next_seq, current_bates FROM sessions WHERE sid=?",
|
||||
(sid,),
|
||||
).fetchone()
|
||||
if sess_row is None:
|
||||
raise ValueError(f"session not found: {sid}")
|
||||
parent_bates = parent_bates or sess_row["current_bates"]
|
||||
parent = self.get_node(parent_bates)
|
||||
if parent is None:
|
||||
raise ValueError(f"parent node not found: {parent_bates}")
|
||||
meta = self._conn.execute(
|
||||
"SELECT session_id, next_seq, session_root FROM session_meta"
|
||||
).fetchone()
|
||||
sid = meta["session_id"]
|
||||
seq = meta["next_seq"]
|
||||
prev_root = meta["session_root"]
|
||||
|
||||
seq = sess_row["next_seq"]
|
||||
bates = _bates(sid, seq)
|
||||
created_at = _now_iso()
|
||||
node_hash = _compute_node_hash(
|
||||
|
|
@ -380,124 +465,136 @@ class Session:
|
|||
audit_mode=audit_mode, created_at=created_at, label=None,
|
||||
)
|
||||
subtree_hash = _compute_subtree_hash(node_hash, [])
|
||||
titles = _extract_cited_titles(answer_text)
|
||||
|
||||
with self._conn:
|
||||
self._conn.execute(
|
||||
"INSERT INTO nodes (bates, seq, parent_bates, question, "
|
||||
"cache_key, audit_mode, created_at, label, node_hash, "
|
||||
"subtree_hash) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)",
|
||||
(bates, seq, parent_bates, question, cache_key,
|
||||
audit_mode, created_at, node_hash, subtree_hash),
|
||||
"INSERT INTO nodes (bates, sid, seq, parent_bates, "
|
||||
"question, answer_text, cache_key, audit_mode, "
|
||||
"cited_titles_json, n_cited_sources, created_at, "
|
||||
"label, node_hash, subtree_hash) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)",
|
||||
(bates, sid, seq, parent_bates, question, answer_text,
|
||||
cache_key, audit_mode, json.dumps(titles), len(titles),
|
||||
created_at, node_hash, subtree_hash),
|
||||
)
|
||||
# Walk parent → root, recompute each ancestor's subtree_hash.
|
||||
new_root = self._recompute_path(parent_bates)
|
||||
# Walk parent → root recomputing each ancestor's
|
||||
# subtree_hash. Crosses session boundaries naturally —
|
||||
# parent_bates can point anywhere.
|
||||
self._recompute_path(parent_bates)
|
||||
self._conn.execute(
|
||||
"UPDATE session_meta SET session_root=?, current_bates=?, "
|
||||
"next_seq=?, updated_at=?",
|
||||
(new_root, bates, seq + 1, created_at),
|
||||
"UPDATE sessions SET current_bates=?, next_seq=?, "
|
||||
"updated_at=? WHERE sid=?",
|
||||
(bates, seq + 1, created_at, sid),
|
||||
)
|
||||
self._append_event(
|
||||
"node_added", bates, prev_root=prev_root,
|
||||
session_root=new_root,
|
||||
"node_added", sid=sid, bates=bates,
|
||||
body={
|
||||
"bates": bates, "parent_bates": parent_bates,
|
||||
"bates": bates, "sid": sid,
|
||||
"parent_bates": parent_bates,
|
||||
"question": question, "cache_key": cache_key,
|
||||
"audit_mode": audit_mode, "node_hash": node_hash,
|
||||
"audit_mode": audit_mode,
|
||||
"node_hash": node_hash,
|
||||
"subtree_hash": subtree_hash,
|
||||
},
|
||||
)
|
||||
return self.get_node(bates) # type: ignore[return-value]
|
||||
|
||||
def _recompute_path(self, start_bates: str) -> str:
|
||||
"""Recompute subtree_hash from start_bates up to root. Returns
|
||||
the (possibly new) root subtree_hash."""
|
||||
def _recompute_path(self, start_bates: str) -> None:
|
||||
cur: Optional[str] = start_bates
|
||||
last_subtree = ""
|
||||
while cur is not None:
|
||||
seen: set[str] = set()
|
||||
while cur is not None and cur not in seen:
|
||||
seen.add(cur)
|
||||
n = self.get_node(cur)
|
||||
if n is None:
|
||||
break
|
||||
child_hashes = [
|
||||
c.subtree_hash for c in self.children_of(cur)
|
||||
]
|
||||
child_hashes = [c.subtree_hash for c in self.children_of(cur)]
|
||||
sh = _compute_subtree_hash(n.node_hash, child_hashes)
|
||||
self._conn.execute(
|
||||
"UPDATE nodes SET subtree_hash=? WHERE bates=?",
|
||||
(sh, cur),
|
||||
)
|
||||
last_subtree = sh
|
||||
cur = n.parent_bates
|
||||
return last_subtree
|
||||
|
||||
def cd(self, bates: str) -> None:
|
||||
def set_current(self, sid: str, bates: str) -> None:
|
||||
if self.get_node(bates) is None:
|
||||
raise ValueError(f"node not found: {bates}")
|
||||
with self._conn:
|
||||
self._conn.execute(
|
||||
"UPDATE session_meta SET current_bates=?, updated_at=?",
|
||||
(bates, _now_iso()),
|
||||
"UPDATE sessions SET current_bates=?, updated_at=? "
|
||||
"WHERE sid=?",
|
||||
(bates, _now_iso(), sid),
|
||||
)
|
||||
|
||||
def label(self, bates: str, name: str) -> None:
|
||||
def label_node(self, bates: str, name: str) -> None:
|
||||
if self.get_node(bates) is None:
|
||||
raise ValueError(f"node not found: {bates}")
|
||||
# Labels participate in node_hash on the next recompute —
|
||||
# however changing a label retroactively would invalidate
|
||||
# downstream subtree_hashes. Keep labels OUT of node_hash for
|
||||
# now (they're a UI affordance, not a forensic field). Stored
|
||||
# in nodes.label but not folded into the hash chain.
|
||||
with self._conn:
|
||||
self._conn.execute(
|
||||
"UPDATE nodes SET label=? WHERE bates=?", (name, bates),
|
||||
)
|
||||
|
||||
def label_session(self, sid: str, name: str) -> None:
|
||||
with self._conn:
|
||||
self._conn.execute(
|
||||
"UPDATE session_meta SET updated_at=?", (_now_iso(),)
|
||||
"UPDATE sessions SET label=?, updated_at=? WHERE sid=?",
|
||||
(name, _now_iso(), sid),
|
||||
)
|
||||
|
||||
def resolve(self, ref: str, sid: Optional[str] = None) -> Optional[str]:
|
||||
"""Resolve user-facing ref → canonical Bates.
|
||||
|
||||
Accepts: full Bates (any session), bare seq (auto-prefixed
|
||||
with ``sid``), or a label (matches any session's node labels).
|
||||
"""
|
||||
if "-" in ref and self.get_node(ref) is not None:
|
||||
return ref
|
||||
if sid is not None:
|
||||
try:
|
||||
n = int(ref)
|
||||
cand = _bates(sid, n)
|
||||
if self.get_node(cand):
|
||||
return cand
|
||||
except ValueError:
|
||||
pass
|
||||
r = self._conn.execute(
|
||||
"SELECT bates FROM nodes WHERE label=? LIMIT 1", (ref,)
|
||||
).fetchone()
|
||||
return r["bates"] if r else None
|
||||
|
||||
# ---- audit chain ----
|
||||
|
||||
def _append_event(
|
||||
self, event_type: str, bates: Optional[str], *,
|
||||
prev_root: str, session_root: str, body: dict,
|
||||
self, event_type: str, *,
|
||||
sid: Optional[str], bates: Optional[str], body: dict,
|
||||
) -> str:
|
||||
# Chain off the previous event's event_hash, NOT the
|
||||
# session_root. session_root tells you "what does the tree
|
||||
# look like now"; event_hash tells you "every state change in
|
||||
# order is intact." Both are independent integrity signals.
|
||||
prev_event = self._conn.execute(
|
||||
"SELECT event_hash FROM session_audit_events "
|
||||
"ORDER BY seq DESC LIMIT 1"
|
||||
prev = self._conn.execute(
|
||||
"SELECT event_hash FROM audit_events ORDER BY seq DESC LIMIT 1"
|
||||
).fetchone()
|
||||
prev_hash_hex = prev_event["event_hash"] if prev_event else ""
|
||||
full_body = {
|
||||
"event_type": event_type,
|
||||
"bates": bates,
|
||||
"prev_root": prev_root,
|
||||
"session_root": session_root,
|
||||
**body,
|
||||
}
|
||||
prev_hex = prev["event_hash"] if prev else ""
|
||||
full_body = {"event_type": event_type, "sid": sid,
|
||||
"bates": bates, **body}
|
||||
body_canonical = _canonical(full_body)
|
||||
h = hashlib.sha256()
|
||||
if prev_hash_hex:
|
||||
h.update(bytes.fromhex(prev_hash_hex))
|
||||
if prev_hex:
|
||||
h.update(bytes.fromhex(prev_hex))
|
||||
h.update(body_canonical)
|
||||
event_hash = h.hexdigest()
|
||||
eh = h.hexdigest()
|
||||
self._conn.execute(
|
||||
"INSERT INTO session_audit_events (event_type, bates, "
|
||||
"prev_root, session_root, body, event_hash, created_at) "
|
||||
"INSERT INTO audit_events (event_type, sid, bates, "
|
||||
"prev_event_hash, event_hash, body, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(event_type, bates, prev_root, session_root,
|
||||
body_canonical.decode("utf-8"), event_hash, _now_iso()),
|
||||
(event_type, sid, bates, prev_hex, eh,
|
||||
body_canonical.decode("utf-8"), _now_iso()),
|
||||
)
|
||||
return event_hash
|
||||
return eh
|
||||
|
||||
def chain_check(self) -> tuple[int, int]:
|
||||
"""Walk audit events in order, recompute each event_hash,
|
||||
compare to stored. Returns (intact_count, break_count)."""
|
||||
intact = 0
|
||||
breaks = 0
|
||||
prev_hex = ""
|
||||
for r in self._conn.execute(
|
||||
"SELECT body, event_hash FROM session_audit_events ORDER BY seq"
|
||||
"SELECT body, event_hash FROM audit_events ORDER BY seq"
|
||||
):
|
||||
body_bytes = r["body"].encode("utf-8", errors="surrogatepass")
|
||||
h = hashlib.sha256()
|
||||
|
|
@ -512,71 +609,91 @@ class Session:
|
|||
prev_hex = r["event_hash"]
|
||||
return intact, breaks
|
||||
|
||||
def close(self) -> None:
|
||||
try:
|
||||
self._conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
class Session:
|
||||
"""Viewport onto SessionStore for a specific sid. Thin facade over
|
||||
SessionStore that scopes mutations to one session."""
|
||||
|
||||
# ---- top-level helpers (dir-level operations) ----
|
||||
def __init__(self, store: SessionStore, sid: str):
|
||||
self._store = store
|
||||
self._sid = sid
|
||||
|
||||
def default_sessions_dir() -> Path:
|
||||
return Path.home() / ".arborist" / "sessions"
|
||||
@property
|
||||
def store(self) -> SessionStore:
|
||||
return self._store
|
||||
|
||||
@property
|
||||
def sid(self) -> str:
|
||||
return self._sid
|
||||
|
||||
def list_sessions(sessions_dir: Optional[Path] = None) -> list[dict]:
|
||||
"""List all session dbs in the directory. Returns one dict per
|
||||
session with sid, root, n_nodes, updated_at, current_bates."""
|
||||
sessions_dir = sessions_dir or default_sessions_dir()
|
||||
if not sessions_dir.is_dir():
|
||||
return []
|
||||
out: list[dict] = []
|
||||
for p in sorted(sessions_dir.glob("*.db")):
|
||||
try:
|
||||
conn = sqlite3.connect(str(p))
|
||||
conn.row_factory = sqlite3.Row
|
||||
m = conn.execute(
|
||||
"SELECT * FROM session_meta LIMIT 1"
|
||||
).fetchone()
|
||||
if not m:
|
||||
conn.close()
|
||||
continue
|
||||
n_nodes = conn.execute(
|
||||
"SELECT COUNT(*) AS c FROM nodes"
|
||||
).fetchone()["c"]
|
||||
out.append({
|
||||
"sid": m["session_id"],
|
||||
"path": str(p),
|
||||
"session_root": m["session_root"],
|
||||
"current_bates": m["current_bates"],
|
||||
"n_nodes": n_nodes,
|
||||
"updated_at": m["updated_at"],
|
||||
"created_at": m["created_at"],
|
||||
})
|
||||
conn.close()
|
||||
except Exception:
|
||||
continue
|
||||
return out
|
||||
@property
|
||||
def root_bates(self) -> str:
|
||||
r = self._store.conn.execute(
|
||||
"SELECT root_bates FROM sessions WHERE sid=?", (self._sid,)
|
||||
).fetchone()
|
||||
return r["root_bates"] if r else ""
|
||||
|
||||
@property
|
||||
def session_root(self) -> str:
|
||||
return self._store.session_root(self._sid)
|
||||
|
||||
def session_path(sid: str, sessions_dir: Optional[Path] = None) -> Path:
|
||||
sessions_dir = sessions_dir or default_sessions_dir()
|
||||
return sessions_dir / f"{sid}.db"
|
||||
@property
|
||||
def current_bates(self) -> str:
|
||||
r = self._store.conn.execute(
|
||||
"SELECT current_bates FROM sessions WHERE sid=?", (self._sid,)
|
||||
).fetchone()
|
||||
return r["current_bates"] if r else ""
|
||||
|
||||
def add_node(self, question: str, cache_key: str, audit_mode: str,
|
||||
answer_text: str = "",
|
||||
parent_bates: Optional[str] = None) -> Node:
|
||||
return self._store.add_node(
|
||||
self._sid, question=question, cache_key=cache_key,
|
||||
audit_mode=audit_mode, answer_text=answer_text,
|
||||
parent_bates=parent_bates,
|
||||
)
|
||||
|
||||
def cd(self, bates: str) -> None:
|
||||
self._store.set_current(self._sid, bates)
|
||||
|
||||
def label_current(self, name: str) -> None:
|
||||
self._store.label_node(self.current_bates, name)
|
||||
|
||||
def label(self, name: str) -> None:
|
||||
self._store.label_session(self._sid, name)
|
||||
|
||||
def get_node(self, bates: str) -> Optional[Node]:
|
||||
return self._store.get_node(bates)
|
||||
|
||||
def children_of(self, bates: str) -> list[Node]:
|
||||
return self._store.children_of(bates)
|
||||
|
||||
def path_to_root(self, bates: str) -> list[Node]:
|
||||
return self._store.path_to_root(bates)
|
||||
|
||||
def branches(self) -> list[str]:
|
||||
return self._store.branches_in_session(self._sid)
|
||||
|
||||
def all_nodes(self) -> list[Node]:
|
||||
return self._store.all_nodes_in_session(self._sid)
|
||||
|
||||
def find(self, query: str, limit: int = 10) -> list[Node]:
|
||||
return self._store.find(query, limit)
|
||||
|
||||
def resolve(self, ref: str) -> Optional[str]:
|
||||
return self._store.resolve(ref, sid=self._sid)
|
||||
|
||||
|
||||
def render_tree(sess: Session) -> str:
|
||||
"""Render the session tree as a text outline.
|
||||
|
||||
Synthetic root is collapsed to a header. Current node is marked with
|
||||
a trailing ← current.
|
||||
"""
|
||||
"""Render this session's tree. Cross-session children show their
|
||||
minting sid in brackets so the operator can tell when a branch
|
||||
came from a different session (currently always self; gossip-
|
||||
shared sessions are future work)."""
|
||||
cur = sess.current_bates
|
||||
sid = sess.sid
|
||||
n_nodes = len(sess.all_nodes()) - 1 # exclude synthetic root
|
||||
bps = sess.branches()
|
||||
lines: list[str] = [
|
||||
f"session {sid} ({n_nodes} nodes · {len(bps)} branch points · "
|
||||
f"session {sess.sid} ({n_nodes} nodes · {len(bps)} branch points · "
|
||||
f"root={sess.session_root} · current → {cur})",
|
||||
]
|
||||
root = sess.root_bates
|
||||
|
|
@ -586,7 +703,6 @@ def render_tree(sess: Session) -> str:
|
|||
if n is None:
|
||||
return
|
||||
if is_root:
|
||||
# Don't print the synthetic root; jump straight to children.
|
||||
kids = sess.children_of(bates)
|
||||
for i, c in enumerate(kids):
|
||||
_walk(c.bates, "", i == len(kids) - 1, False)
|
||||
|
|
@ -594,10 +710,11 @@ def render_tree(sess: Session) -> str:
|
|||
marker = "└── " if is_last else "├── "
|
||||
cur_tag = " ← current" if n.bates == cur else ""
|
||||
label_tag = f" {{{n.label}}}" if n.label else ""
|
||||
sid_tag = f" [from {n.sid}]" if n.sid != sess.sid else ""
|
||||
q = (n.question or "")[:60]
|
||||
lines.append(
|
||||
f"{prefix}{marker}[{n.bates}] {q}{label_tag} "
|
||||
f"[{n.audit_mode}]{cur_tag}"
|
||||
f"[{n.audit_mode}]{sid_tag}{cur_tag}"
|
||||
)
|
||||
nxt_prefix = prefix + (" " if is_last else "│ ")
|
||||
kids = sess.children_of(bates)
|
||||
|
|
|
|||
242
docs/sessions.md
242
docs/sessions.md
|
|
@ -1,65 +1,77 @@
|
|||
# Sessions — Merkle-rooted conversation trees
|
||||
# Sessions — single-shard Merkle conversation forest
|
||||
|
||||
Multi-turn Q&A with branching, where the tree itself is Merkle-rooted.
|
||||
Each turn (or fork) mints a node with a stable Bates id and folds into
|
||||
a per-session subtree-hash chain; the root node's `subtree_hash` is the
|
||||
`session_root`, a single hash that pins the entire conversation state.
|
||||
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
|
||||
|
||||
One SQLite file per session at `~/.arborist/sessions/<sid>.db`:
|
||||
|
||||
```sql
|
||||
CREATE TABLE session_meta (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
root_bates TEXT NOT NULL, -- always seq=0
|
||||
session_root TEXT NOT NULL, -- subtree_hash of root node
|
||||
current_bates TEXT NOT NULL, -- REPL "you are here" pointer
|
||||
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
|
||||
updated_at TEXT NOT NULL,
|
||||
label TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE nodes (
|
||||
bates TEXT PRIMARY KEY, -- "<sid>-<6-digit>"
|
||||
seq INTEGER NOT NULL, -- monotonic per session
|
||||
parent_bates TEXT REFERENCES nodes(bates),
|
||||
question TEXT NOT NULL,
|
||||
cache_key TEXT NOT NULL, -- → providence_cache
|
||||
audit_mode TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
label TEXT, -- optional human name
|
||||
node_hash TEXT NOT NULL, -- sha256 of canonical body
|
||||
subtree_hash TEXT NOT NULL -- Merkle hash over self + children
|
||||
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 TABLE session_audit_events (
|
||||
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, -- session_init | node_added
|
||||
event_type TEXT NOT NULL,
|
||||
sid TEXT,
|
||||
bates TEXT,
|
||||
prev_root TEXT NOT NULL,
|
||||
session_root TEXT NOT NULL,
|
||||
body TEXT NOT NULL, -- canonical JSON
|
||||
event_hash TEXT NOT NULL, -- sha256(prev_hash || body)
|
||||
prev_event_hash TEXT,
|
||||
event_hash TEXT NOT NULL, -- sha256(prev || canonical body)
|
||||
body TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
The session db is **self-contained**: no foreign keys into the main
|
||||
store. Answers live in `~/.arborist/qa.db:providence_cache` keyed by
|
||||
`cache_key`; the session only carries the **shape** of the conversation
|
||||
(parent links, Bates, hashes). Cache hits across sessions stay live.
|
||||
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 `YYYYMMDD-HHMMSS-<4-hex>`; `seq` is
|
||||
monotonic per session, never reassigned, never re-used. `seq=0` is a
|
||||
synthetic root with empty question — collapsed in tree rendering, kept
|
||||
so all real questions descend from a single node.
|
||||
`<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 hashes
|
||||
|
||||
Two derived hashes per node:
|
||||
## Merkle properties
|
||||
|
||||
```
|
||||
node_hash = sha256(canonical_json({
|
||||
|
|
@ -69,137 +81,105 @@ node_hash = sha256(canonical_json({
|
|||
|
||||
subtree_hash = HashCombine(
|
||||
leaf_hash(node_hash),
|
||||
children_root,
|
||||
HashCombine over sorted-by-hash children_subtree_hashes,
|
||||
)
|
||||
```
|
||||
|
||||
Where `children_root` is built bottom-up by `HashCombine` over the
|
||||
children's `subtree_hash` values, sorted by hash (deterministic),
|
||||
with the standard arborist odd-element rule (self-duplicate).
|
||||
|
||||
Sorting children by `subtree_hash` (content-addressed) keeps the
|
||||
combine order independent of insertion timing — two sessions that
|
||||
land the same nodes in different order produce the same root.
|
||||
|
||||
## Insert path is O(depth)
|
||||
|
||||
When a new node `N` is inserted under parent `P`:
|
||||
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)` (new node is a leaf).
|
||||
3. Walk `N → P → P.parent → … → root` recomputing each ancestor's
|
||||
`subtree_hash`.
|
||||
4. Update `session_meta.session_root` to root's new `subtree_hash`.
|
||||
5. Append one `session_audit_events` row whose `event_hash` chains
|
||||
off the previous event.
|
||||
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.
|
||||
|
||||
A sibling subtree that didn't change keeps its `subtree_hash`. This
|
||||
is the property that makes page-refresh caching cheap.
|
||||
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.
|
||||
|
||||
## Caching for page refresh
|
||||
## Cross-session forks
|
||||
|
||||
A UI client tracks `(bates → subtree_hash, body)` locally. On refresh:
|
||||
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.
|
||||
|
||||
1. Client sends current `session_root`.
|
||||
2. Server returns either "still current" or the new root + a list of
|
||||
`subtree_hash` deltas along the changed paths.
|
||||
3. Client walks top-down, fetching only nodes whose `subtree_hash`
|
||||
differs from cache.
|
||||
|
||||
Same shape as git's pack-protocol or IPFS MFS: content-addressed
|
||||
sibling subtrees are byte-identical → cache-equivalent.
|
||||
|
||||
## Audit chain
|
||||
|
||||
`session_audit_events` is a hash-chain mirror of `arborist.audit_events`,
|
||||
scoped per session. Every state change emits one event whose `event_hash`
|
||||
is `sha256(prev_event_hash || canonical_body)`. Verify with
|
||||
`arborist session --chain-check [--sid SID]` (or
|
||||
`make session-chain-check`).
|
||||
|
||||
`event_hash` and `session_root` are independent integrity signals:
|
||||
`session_root` answers "what does the tree look like now?";
|
||||
`event_hash` answers "is every state change in order intact?". Both
|
||||
moves on every insert, but for different reasons.
|
||||
|
||||
## Forking
|
||||
|
||||
There is no `/fork` verb. Forking is implicit: `/cd <bates>` moves the
|
||||
"current" pointer to any node in the tree; the next question becomes a
|
||||
child of that node. Branch points are nodes with ≥2 children — listed
|
||||
by `/branches` or in the tree header.
|
||||
|
||||
## Retrieval-side context flow
|
||||
|
||||
When a turn is asked under parent P, the REPL gathers the source titles
|
||||
the LLM **cited** in P and P's ancestors (parsed from each ancestor's
|
||||
stored `answer_text` for `[E# | <title> | …]` pointers) and passes them
|
||||
into `providence_query` via `policy["retrieval_keywords"]`. The keyword
|
||||
string augments FTS5 retrieval ONLY — it never enters `cache_key`,
|
||||
`question_hash`, or `governance_policy_hash` (mirrors the discipline of
|
||||
the legacy `--retrieval-keywords` flag, see ticket #000001).
|
||||
|
||||
This makes pronoun follow-ups ("who created him?") still surface the
|
||||
prior turn's primary source. **It does not solve the LLM-side antecedent
|
||||
problem** — the model receives only the new question as the user
|
||||
message, so a bare pronoun question may still fail to ground. Threading
|
||||
prior Q&A pairs as `conversation_history` into the LLM prompt (+ folding
|
||||
that history into `conversation_hash`) is a future enhancement.
|
||||
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] # list sessions
|
||||
arborist session --tree --sid <id> # print tree
|
||||
arborist session --chain-check # verify audit chain (all)
|
||||
arborist session --gc N # keep N most recent
|
||||
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 the tree |
|
||||
| `/branches` | list branch points (Bates with ≥2 children) |
|
||||
| `/cd <bates\|seq\|label>` | move current pointer |
|
||||
| `/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 |
|
||||
| `/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 |
|
||||
|
||||
Make targets:
|
||||
Makefile:
|
||||
|
||||
```
|
||||
make session [SID=...] # interactive REPL
|
||||
make session-list # list all
|
||||
make session-tree SID=... # print one tree
|
||||
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
|
||||
make session-gc # keep SESSION_KEEP=100 (default) most recent
|
||||
```
|
||||
|
||||
## Bounded growth
|
||||
## Retrieval flow down a branch
|
||||
|
||||
`make session-gc` (or `arborist session --gc N`) keeps the N most-recent
|
||||
sessions, deletes the rest. Each session is a single SQLite file →
|
||||
trivially deletable, shareable, archivable.
|
||||
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 on-disk schema has no FK dependency on the main arborist store.
|
||||
A non-Python consumer that wants to read a session db needs only:
|
||||
The shard's schema has no FK dependency on the main arborist store.
|
||||
A non-Python consumer reading it needs:
|
||||
|
||||
1. SQLite3 with FTS5 (not required — sessions don't use FTS).
|
||||
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)`
|
||||
|
||||
Those are the same conventions as the proxy.unturf.com Go merkle and
|
||||
the rest of arborist (see `arborist/merkle.py`).
|
||||
Same conventions as `proxy.unturf.com` Go merkle and the rest of
|
||||
arborist (see `arborist/merkle.py`).
|
||||
|
|
|
|||
|
|
@ -1,211 +1,234 @@
|
|||
"""Tests for arborist.qa.session — Merkle-rooted conversation tree."""
|
||||
"""Tests for arborist.qa.session — single-shard Merkle conversation forest."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from arborist.qa.session import (
|
||||
SessionStore,
|
||||
Session,
|
||||
list_sessions,
|
||||
render_tree,
|
||||
session_path,
|
||||
_compute_node_hash,
|
||||
_compute_subtree_hash,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_db(tmp_path: Path) -> Path:
|
||||
return tmp_path / "test-sid.db"
|
||||
def store_path(tmp_path: Path) -> Path:
|
||||
return tmp_path / "sessions.db"
|
||||
|
||||
|
||||
def test_open_creates_synthetic_root(session_db: Path):
|
||||
sess = Session.open(session_db, sid="sid001")
|
||||
assert sess.sid == "sid001"
|
||||
assert sess.root_bates == "sid001-000000"
|
||||
root = sess.get_node("sid001-000000")
|
||||
@pytest.fixture
|
||||
def store(store_path: Path) -> SessionStore:
|
||||
return SessionStore.open(store_path)
|
||||
|
||||
|
||||
def test_create_session_mints_synthetic_root(store: SessionStore):
|
||||
sess = store.create_session(sid="sidA")
|
||||
assert sess.sid == "sidA"
|
||||
assert sess.root_bates == "sidA-000000"
|
||||
root = sess.get_node(sess.root_bates)
|
||||
assert root is not None
|
||||
assert root.question == ""
|
||||
assert root.parent_bates is None
|
||||
assert root.subtree_hash == sess.session_root
|
||||
assert sess.current_bates == sess.root_bates
|
||||
sess.close()
|
||||
|
||||
|
||||
def test_open_resumes_existing_session(session_db: Path):
|
||||
s1 = Session.open(session_db, sid="sid002")
|
||||
s1.add_node("first question", "cache-key-1", "STRICT")
|
||||
root_after_1 = s1.session_root
|
||||
s1.close()
|
||||
s2 = Session.open(session_db) # no sid → resume
|
||||
assert s2.sid == "sid002"
|
||||
assert s2.session_root == root_after_1
|
||||
assert s2.current_bates == "sid002-000001"
|
||||
s2.close()
|
||||
|
||||
|
||||
def test_add_node_mints_bates_and_recomputes_root(session_db: Path):
|
||||
sess = Session.open(session_db, sid="s")
|
||||
root_root = sess.session_root
|
||||
def test_add_node_mints_bates_and_updates_root(store: SessionStore):
|
||||
sess = store.create_session(sid="s")
|
||||
r0 = sess.session_root
|
||||
n1 = sess.add_node("q1", "ck1", "STRICT")
|
||||
assert n1.bates == "s-000001"
|
||||
assert sess.current_bates == "s-000001"
|
||||
assert sess.session_root != root_root # tree changed → root changed
|
||||
n2 = sess.add_node("q2", "ck2", "HYBRID")
|
||||
assert n2.bates == "s-000002"
|
||||
assert n2.parent_bates == "s-000001"
|
||||
sess.close()
|
||||
assert sess.current_bates == n1.bates
|
||||
assert sess.session_root != r0
|
||||
|
||||
|
||||
def test_fork_via_cd_creates_sibling(session_db: Path):
|
||||
sess = Session.open(session_db, sid="s")
|
||||
def test_fork_via_cd_creates_sibling(store: SessionStore):
|
||||
sess = store.create_session(sid="s")
|
||||
n1 = sess.add_node("q1", "ck1", "STRICT")
|
||||
n2 = sess.add_node("q2", "ck2", "STRICT")
|
||||
# Fork: cd back to n1, ask q3 — q3 is a sibling of q2 under n1.
|
||||
sess.cd(n1.bates)
|
||||
n3 = sess.add_node("q3", "ck3", "STRICT")
|
||||
assert n3.parent_bates == n1.bates
|
||||
assert sess.current_bates == n3.bates
|
||||
kids = sess.children_of(n1.bates)
|
||||
bates_set = {k.bates for k in kids}
|
||||
assert n2.bates in bates_set
|
||||
assert n3.bates in bates_set
|
||||
sess.close()
|
||||
kids = {k.bates for k in sess.children_of(n1.bates)}
|
||||
assert kids == {n2.bates, n3.bates}
|
||||
|
||||
|
||||
def test_branches_lists_branch_points(session_db: Path):
|
||||
sess = Session.open(session_db, sid="s")
|
||||
n1 = sess.add_node("q1", "ck1", "STRICT")
|
||||
sess.add_node("q2", "ck2", "STRICT") # child of n1
|
||||
sess.cd(n1.bates)
|
||||
sess.add_node("q3", "ck3", "STRICT") # second child of n1 → branch
|
||||
bps = sess.branches()
|
||||
assert n1.bates in bps
|
||||
assert sess.root_bates not in bps # root only has 1 child so far
|
||||
sess.close()
|
||||
def test_cross_session_fork(store: SessionStore):
|
||||
"""Fork from a node in another session — parent_bates crosses sids.
|
||||
The cross-session parent's subtree_hash recomputes to include the
|
||||
new child (Merkle ripples through the global forest)."""
|
||||
sess_a = store.create_session(sid="A")
|
||||
a1 = sess_a.add_node("hello from A", "ck-A1", "STRICT")
|
||||
a1_subtree_before = store.get_node(a1.bates).subtree_hash
|
||||
|
||||
sess_b = store.create_session(sid="B")
|
||||
# cd session B's pointer to A's node, then ask in B.
|
||||
sess_b.cd(a1.bates)
|
||||
b1 = sess_b.add_node("fork from A", "ck-B1", "STRICT")
|
||||
assert b1.parent_bates == a1.bates
|
||||
assert b1.sid == "B"
|
||||
# A's node now has a child (B's node); subtree_hash changed.
|
||||
a1_subtree_after = store.get_node(a1.bates).subtree_hash
|
||||
assert a1_subtree_after != a1_subtree_before
|
||||
# A's session_root also changed (ripple up).
|
||||
a_root_subtree = store.session_root("A")
|
||||
assert a_root_subtree != ""
|
||||
|
||||
|
||||
def test_session_root_changes_per_insert(session_db: Path):
|
||||
sess = Session.open(session_db, sid="s")
|
||||
roots = [sess.session_root]
|
||||
sess.add_node("q1", "ck1", "STRICT")
|
||||
roots.append(sess.session_root)
|
||||
sess.add_node("q2", "ck2", "STRICT")
|
||||
roots.append(sess.session_root)
|
||||
assert len(set(roots)) == 3 # all distinct
|
||||
sess.close()
|
||||
def test_find_fts_search_across_sessions(store: SessionStore):
|
||||
sess_a = store.create_session(sid="A")
|
||||
sess_a.add_node("spider man origin story",
|
||||
"ck1", "STRICT",
|
||||
answer_text="Spider-Man was created by Stan Lee.")
|
||||
sess_b = store.create_session(sid="B")
|
||||
sess_b.add_node("how do you cook risotto?",
|
||||
"ck2", "STRICT",
|
||||
answer_text="Stir constantly while adding broth.")
|
||||
|
||||
hits = store.find("spider")
|
||||
assert len(hits) == 1
|
||||
assert hits[0].sid == "A"
|
||||
assert "spider" in hits[0].question.lower()
|
||||
|
||||
hits = store.find("risotto")
|
||||
assert len(hits) == 1
|
||||
assert hits[0].sid == "B"
|
||||
|
||||
|
||||
def test_session_root_deterministic(tmp_path: Path):
|
||||
"""Two sessions with the same insert sequence produce the same root.
|
||||
Note: timestamps differ in real wall-clock — we fix them by
|
||||
passing the same created_at via mocking. Here we just confirm the
|
||||
HASH formula is deterministic when inputs match.
|
||||
"""
|
||||
from arborist.qa.session import _compute_node_hash, _compute_subtree_hash
|
||||
nh = _compute_node_hash("", "s-000001", "q", "ck", "STRICT",
|
||||
"2026-06-01T00:00:00Z", None)
|
||||
sh = _compute_subtree_hash(nh, [])
|
||||
# Same inputs → same hash.
|
||||
nh2 = _compute_node_hash("", "s-000001", "q", "ck", "STRICT",
|
||||
"2026-06-01T00:00:00Z", None)
|
||||
sh2 = _compute_subtree_hash(nh2, [])
|
||||
assert nh == nh2
|
||||
assert sh == sh2
|
||||
def test_find_skips_synthetic_root(store: SessionStore):
|
||||
"""The synthetic root (seq=0, empty question) must not surface in
|
||||
FTS5 search — its question is empty so it wouldn't match anyway,
|
||||
but the WHERE seq>0 guard is an explicit second line of defense."""
|
||||
store.create_session(sid="A")
|
||||
hits = store.find("anything")
|
||||
assert hits == []
|
||||
|
||||
|
||||
def test_path_to_root_walks_ancestors(session_db: Path):
|
||||
sess = Session.open(session_db, sid="s")
|
||||
n1 = sess.add_node("q1", "ck1", "STRICT")
|
||||
n2 = sess.add_node("q2", "ck2", "STRICT")
|
||||
path = sess.path_to_root(n2.bates)
|
||||
assert [n.bates for n in path] == [n2.bates, n1.bates, sess.root_bates]
|
||||
sess.close()
|
||||
def test_find_by_cache_key_lists_all_uses(store: SessionStore):
|
||||
sess_a = store.create_session(sid="A")
|
||||
sess_b = store.create_session(sid="B")
|
||||
sess_a.add_node("q1", "shared-ck", "STRICT")
|
||||
sess_b.add_node("q2", "shared-ck", "STRICT")
|
||||
hits = store.find_by_cache_key("shared-ck")
|
||||
assert {h.sid for h in hits} == {"A", "B"}
|
||||
|
||||
|
||||
def test_audit_chain_intact_after_inserts(session_db: Path):
|
||||
sess = Session.open(session_db, sid="s")
|
||||
def test_path_to_root_crosses_sessions(store: SessionStore):
|
||||
sess_a = store.create_session(sid="A")
|
||||
a1 = sess_a.add_node("q1", "ck1", "STRICT")
|
||||
sess_b = store.create_session(sid="B")
|
||||
sess_b.cd(a1.bates)
|
||||
b1 = sess_b.add_node("q2", "ck2", "STRICT")
|
||||
path = store.path_to_root(b1.bates)
|
||||
sids_in_path = [n.sid for n in path]
|
||||
# b1 (B) → a1 (A) → A's root (A)
|
||||
assert sids_in_path == ["B", "A", "A"]
|
||||
|
||||
|
||||
def test_audit_chain_intact_after_inserts(store: SessionStore):
|
||||
sess = store.create_session(sid="A")
|
||||
sess.add_node("q1", "ck1", "STRICT")
|
||||
sess.add_node("q2", "ck2", "STRICT")
|
||||
sess.add_node("q3", "ck3", "HYBRID")
|
||||
intact, breaks = sess.chain_check()
|
||||
sess2 = store.create_session(sid="B")
|
||||
sess2.add_node("q3", "ck3", "HYBRID")
|
||||
intact, breaks = store.chain_check()
|
||||
assert breaks == 0
|
||||
assert intact >= 4 # session_init + 3 node_added
|
||||
sess.close()
|
||||
assert intact == 5 # 2 session_init + 3 node_added
|
||||
|
||||
|
||||
def test_audit_chain_detects_tampered_event(session_db: Path):
|
||||
sess = Session.open(session_db, sid="s")
|
||||
def test_audit_chain_detects_tampering(store: SessionStore):
|
||||
sess = store.create_session(sid="A")
|
||||
sess.add_node("q1", "ck1", "STRICT")
|
||||
sess.add_node("q2", "ck2", "STRICT")
|
||||
# Tamper with one event's body.
|
||||
sess.conn.execute(
|
||||
"UPDATE session_audit_events SET body=? "
|
||||
"WHERE event_type='node_added' LIMIT 1",
|
||||
store.conn.execute(
|
||||
"UPDATE audit_events SET body=? WHERE event_type='node_added' "
|
||||
"ORDER BY seq DESC LIMIT 1",
|
||||
('{"tampered": true}',),
|
||||
)
|
||||
sess.conn.commit()
|
||||
intact, breaks = sess.chain_check()
|
||||
store.conn.commit()
|
||||
intact, breaks = store.chain_check()
|
||||
assert breaks >= 1
|
||||
sess.close()
|
||||
|
||||
|
||||
def test_resolve_accepts_bates_seq_and_label(session_db: Path):
|
||||
sess = Session.open(session_db, sid="sid")
|
||||
def test_resolve_accepts_bates_seq_and_label(store: SessionStore):
|
||||
sess = store.create_session(sid="sid")
|
||||
n1 = sess.add_node("q1", "ck1", "STRICT")
|
||||
sess.label(n1.bates, "alpha")
|
||||
sess.label_current("alpha")
|
||||
assert sess.resolve(n1.bates) == n1.bates
|
||||
assert sess.resolve("1") == n1.bates
|
||||
assert sess.resolve("alpha") == n1.bates
|
||||
assert sess.resolve("nope") is None
|
||||
sess.close()
|
||||
|
||||
|
||||
def test_list_sessions_finds_db_in_dir(tmp_path: Path):
|
||||
p1 = tmp_path / "s1.db"
|
||||
p2 = tmp_path / "s2.db"
|
||||
Session.open(p1, sid="s1").close()
|
||||
Session.open(p2, sid="s2").close()
|
||||
listing = list_sessions(tmp_path)
|
||||
sids = {s["sid"] for s in listing}
|
||||
assert sids == {"s1", "s2"}
|
||||
def test_list_sessions(store: SessionStore):
|
||||
store.create_session(sid="A")
|
||||
store.create_session(sid="B")
|
||||
lst = store.list_sessions()
|
||||
sids = {s["sid"] for s in lst}
|
||||
assert sids == {"A", "B"}
|
||||
|
||||
|
||||
def test_render_tree_includes_current_marker(session_db: Path):
|
||||
sess = Session.open(session_db, sid="s")
|
||||
sess.add_node("first", "ck1", "STRICT")
|
||||
out = render_tree(sess)
|
||||
assert "first" in out
|
||||
assert "← current" in out
|
||||
sess.close()
|
||||
def test_render_tree_shows_cross_session_tag(store: SessionStore):
|
||||
sess_a = store.create_session(sid="A")
|
||||
a1 = sess_a.add_node("question A", "ck1", "STRICT")
|
||||
sess_b = store.create_session(sid="B")
|
||||
sess_b.cd(a1.bates)
|
||||
sess_b.add_node("forked into B", "ck2", "STRICT")
|
||||
# Render session A's tree — should include the B-minted child
|
||||
# with a [from B] tag.
|
||||
out = render_tree(sess_a)
|
||||
assert "question A" in out
|
||||
assert "forked into B" in out
|
||||
assert "[from B]" in out
|
||||
|
||||
|
||||
def test_subtree_hash_propagates_up(session_db: Path):
|
||||
"""Inserting a deep node changes the root's subtree_hash."""
|
||||
sess = Session.open(session_db, sid="s")
|
||||
n1 = sess.add_node("q1", "ck1", "STRICT")
|
||||
root_after_1 = sess.session_root
|
||||
# Add a deep child of n1.
|
||||
sess.cd(n1.bates)
|
||||
sess.add_node("q1.child", "ck2", "STRICT")
|
||||
root_after_2 = sess.session_root
|
||||
assert root_after_1 != root_after_2
|
||||
sess.close()
|
||||
def test_subtree_hash_propagates_across_sessions(store: SessionStore):
|
||||
"""Inserting a node in session B under a node in session A
|
||||
changes that A-node's subtree_hash AND A's session_root."""
|
||||
sess_a = store.create_session(sid="A")
|
||||
a1 = sess_a.add_node("anchor in A", "ck1", "STRICT")
|
||||
a_root_before = sess_a.session_root
|
||||
|
||||
sess_b = store.create_session(sid="B")
|
||||
sess_b.cd(a1.bates)
|
||||
sess_b.add_node("child in B", "ck2", "STRICT")
|
||||
|
||||
a_root_after = store.session_root("A")
|
||||
assert a_root_after != a_root_before
|
||||
|
||||
|
||||
def test_sibling_insert_changes_only_changed_paths(session_db: Path):
|
||||
"""When a node is added under parent P, P's subtree_hash AND root
|
||||
change, but unrelated siblings' subtree_hash stays the same."""
|
||||
sess = Session.open(session_db, sid="s")
|
||||
a = sess.add_node("a", "ck-a", "STRICT")
|
||||
sess.cd(sess.root_bates)
|
||||
b = sess.add_node("b", "ck-b", "STRICT")
|
||||
a_hash_before = sess.get_node(a.bates).subtree_hash
|
||||
b_hash_before = sess.get_node(b.bates).subtree_hash
|
||||
# Add child under a.
|
||||
sess.cd(a.bates)
|
||||
sess.add_node("a.child", "ck-ac", "STRICT")
|
||||
# a's subtree_hash changed; b's didn't.
|
||||
assert sess.get_node(a.bates).subtree_hash != a_hash_before
|
||||
assert sess.get_node(b.bates).subtree_hash == b_hash_before
|
||||
sess.close()
|
||||
def test_compute_hash_helpers_deterministic():
|
||||
nh = _compute_node_hash("", "s-000001", "q", "ck", "STRICT",
|
||||
"2026-06-01T00:00:00Z", None)
|
||||
nh2 = _compute_node_hash("", "s-000001", "q", "ck", "STRICT",
|
||||
"2026-06-01T00:00:00Z", None)
|
||||
assert nh == nh2
|
||||
sh = _compute_subtree_hash(nh, [])
|
||||
sh2 = _compute_subtree_hash(nh2, [])
|
||||
assert sh == sh2
|
||||
|
||||
|
||||
def test_cited_titles_extracted_from_answer(store: SessionStore):
|
||||
sess = store.create_session(sid="A")
|
||||
ans = (
|
||||
'Spider-Man was created by Stan Lee.\n'
|
||||
' [E1 | Spider-Man | abc123: "..."]\n'
|
||||
' [E2 | Stan Lee | def456: "..."]'
|
||||
)
|
||||
n = sess.add_node("who created spider man?",
|
||||
"ck1", "STRICT", answer_text=ans)
|
||||
assert n.cited_titles == ["Spider-Man", "Stan Lee"]
|
||||
assert n.n_cited_sources == 2
|
||||
|
||||
|
||||
def test_fts_finds_by_cited_title(store: SessionStore):
|
||||
sess = store.create_session(sid="A")
|
||||
ans = '...\n [E1 | Sesame Street | abc123: "..."]'
|
||||
sess.add_node("when did the show start?", "ck1", "STRICT",
|
||||
answer_text=ans)
|
||||
hits = store.find("sesame")
|
||||
assert len(hits) == 1
|
||||
assert "sesame" in hits[0].cited_titles[0].lower()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue