sessions: list / tree / FTS5 search views via arborist.read.SessionsView

Mounts the sessions shard (~/.arborist/sessions.db by default) as a
read-only data source alongside the corpus shards. Imports stay
behind the arborist.read seam — viz never touches the schema directly.

Wire:
- storage.py: get_sessions(path) — process-cached SessionsView,
  parallel structure to get_shards(paths). close_all() now closes
  both caches.
- __init__.py: ARBORIST_VIZ_SESSIONS_DB env (or arborist.sessions_db
  ini) → request.sessions request property. Defaults to
  ~/.arborist/sessions.db.
- routes.py: /sessions (list), /sessions/{sid} (tree),
  /sessions/find (FTS5 search) + matching /api/sessions/... JSON.
- views/sessions.py: HTML + JSON handlers.
- templates/sessions_{list,tree,find}.jinja2: minimal views — table
  for list, recursive tree macro for tree, table for find results.
- static/css/viz.css: cmd-table / cmd-tree / cmd-audit-* styles.

Endpoint summary:
  /sessions                   list every session
  /sessions/{sid}             one session's tree (synthetic root
                              collapsed, cross-session children show
                              [from <sid>] tag)
  /sessions/find?q=...&limit= FTS5 across all sessions
  /api/sessions                       JSON list
  /api/sessions/{sid}                 JSON tree
  /api/sessions/find?q=...            JSON search

Audit_mode is rendered as a colored chip (strict/hybrid/ungrounded/
canonical_projection); cited titles surface as inline pills. Privacy
contract from base.jinja2 inherits unchanged — sessions shard is
local-private per ticket #000069 §14.
This commit is contained in:
russell@unturf.com 2026-06-01 17:41:24 -04:00
parent 44bec3503d
commit 60e8640d86
No known key found for this signature in database
8 changed files with 428 additions and 0 deletions

View file

@ -146,6 +146,24 @@ def main(global_config, **settings):
return _storage.get_shards(req.arborist_shards)
config.add_request_method(_shards, "shards", reify=True)
# Sessions shard — single-file conversation-forest journal. The
# corpus shards (Shards above) and the sessions shard are unrelated
# data layers, so they get separate request properties + separate
# process caches. Env var ARBORIST_VIZ_SESSIONS_DB overrides the
# .ini default ("arborist.sessions_db"); both default to
# ~/.arborist/sessions.db when unset.
sessions_db = (
os.environ.get("ARBORIST_VIZ_SESSIONS_DB")
or settings.get("arborist.sessions_db", "")
or str(os.path.expanduser("~/.arborist/sessions.db"))
)
config.add_request_method(
lambda r, _v=sessions_db: _v, "arborist_sessions_db", reify=True
)
def _sessions(req):
return _storage.get_sessions(req.arborist_sessions_db)
config.add_request_method(_sessions, "sessions", reify=True)
config.include(".routes")
config.scan("arborist_viz.views")

View file

@ -53,6 +53,16 @@ def includeme(config):
config.add_route("api_diff", "/api/diff/{root_a}/{root_b}")
config.add_route("api_graph_snapshot", "/api/graph/snapshot")
# sessions — Merkle conversation forest (single-shard, per-user
# journal at ~/.arborist/sessions.db). Read-only consumer of
# arborist.read.SessionsView.
config.add_route("sessions_list", "/sessions")
config.add_route("sessions_find", "/sessions/find")
config.add_route("sessions_tree", "/sessions/{sid}")
config.add_route("api_sessions_list", "/api/sessions")
config.add_route("api_sessions_find", "/api/sessions/find")
config.add_route("api_sessions_tree", "/api/sessions/{sid}")
# SSE — §3-corrected replaces WS routes with text/event-stream.
config.add_route("sse_audit_live", "/sse/audit/live")
config.add_route("sse_run", "/sse/run/{run_id}")

View file

@ -629,3 +629,47 @@ code, pre { font-family: var(--mono); font-size: 12px; }
white-space: pre-wrap;
word-break: keep-all;
}
/* ---- sessions views (ticket #000069 sessions surface) ---------------- */
.cmd-table { width: 100%; border-collapse: collapse; margin-top: 1em; }
.cmd-table th, .cmd-table td {
text-align: left; padding: 0.4em 0.8em;
border-bottom: 1px solid rgba(255,255,255,0.1);
font-size: 0.9em;
}
.cmd-table th { font-weight: 600; opacity: 0.8; }
.cmd-table code { font-size: 0.85em; }
.cmd-hash { word-break: break-all; }
.cmd-inline-form { display: inline-flex; gap: 0.5em; margin-top: 0.5em; }
.cmd-inline-form input[type=text] { min-width: 24em; }
.cmd-empty { opacity: 0.6; font-style: italic; padding: 2em 0; }
.cmd-tree { list-style: none; padding-left: 0; }
.cmd-tree-children { list-style: none; padding-left: 1.5em;
border-left: 1px solid rgba(255,255,255,0.15); margin-left: 0.4em; }
.cmd-tree-node { margin: 0.4em 0; }
.cmd-tree-line { display: flex; gap: 0.6em; align-items: baseline;
flex-wrap: wrap; }
.cmd-tree-bates { font-size: 0.85em; }
.cmd-tree-audit { font-size: 0.75em; padding: 0.05em 0.4em;
border-radius: 0.2em; font-weight: 600; }
.cmd-audit-strict { background: rgba(80, 200, 120, 0.25); }
.cmd-audit-hybrid { background: rgba(220, 180, 80, 0.25); }
.cmd-audit-ungrounded { background: rgba(220, 100, 100, 0.25); }
.cmd-audit-canonical_projection { background: rgba(120, 160, 220, 0.25); }
.cmd-tree-from { font-size: 0.75em; opacity: 0.6; font-style: italic; }
.cmd-tree-label { font-size: 0.85em; opacity: 0.7; }
.cmd-tree-q { font-size: 0.95em; }
.cmd-tree-current > .cmd-tree-line .cmd-tree-bates { font-weight: 700; }
.cmd-tree-current > .cmd-tree-line::after {
content: '← current'; font-size: 0.8em; opacity: 0.7;
}
.cmd-tree-branch > .cmd-tree-line .cmd-tree-bates { color: #8af; }
.cmd-tree-cites { display: flex; gap: 0.3em; flex-wrap: wrap;
padding-left: 1.5em; margin-top: 0.2em; }
.cmd-cite { font-size: 0.75em; opacity: 0.7;
padding: 0.05em 0.4em; border-radius: 0.2em;
background: rgba(255,255,255,0.06); }

View file

@ -29,6 +29,14 @@ except Exception: # pragma: no cover — fixture mode only
Shards = None # type: ignore
open_shards = None # type: ignore
try:
from arborist.read import SessionsView, open_sessions
HAVE_ARBORIST_SESSIONS = True
except Exception: # pragma: no cover
HAVE_ARBORIST_SESSIONS = False
SessionsView = None # type: ignore
open_sessions = None # type: ignore
log = logging.getLogger(__name__)
@ -63,3 +71,30 @@ def close_all() -> None:
except Exception:
pass
_HANDLES.clear()
for v in _SESSIONS_HANDLES.values():
try:
v.close()
except Exception:
pass
_SESSIONS_HANDLES.clear()
# ---- sessions (conversation forest, single per-user shard) ----------
_SESSIONS_HANDLES: dict[str, "SessionsView"] = {}
def get_sessions(path: str | None) -> Optional["SessionsView"]:
"""Return the cached :class:`arborist.read.SessionsView` for the
sessions shard at ``path``. When ``path`` is falsy or arborist
isn't importable, returns ``None`` — callers should render the
"no sessions shard" fallback state.
"""
if not HAVE_ARBORIST_SESSIONS or not path:
return None
v = _SESSIONS_HANDLES.get(path)
if v is None or not v.is_open:
v = open_sessions(path)
_SESSIONS_HANDLES[path] = v
log.info("arborist.read.sessions: opened %s", path)
return v

View file

@ -0,0 +1,44 @@
{% extends "base.jinja2" %}
{% block body %}
<section class="cmd-panel">
<header class="cmd-panel-head">
<h1>Search sessions</h1>
<form action="/sessions/find" method="get" class="cmd-inline-form">
<input type="text" name="q" value="{{ query or '' }}" placeholder="FTS5 across question + answer + cited titles" autocomplete="off" autofocus>
<input type="number" name="limit" value="{{ limit }}" min="1" max="100" style="width: 5em;">
<button type="submit">find</button>
</form>
<p><a href="/sessions">← all sessions</a></p>
</header>
{% if query %}
<p class="cmd-panel-sub">{{ hits|length }} match(es) for <code>{{ query }}</code></p>
{% if hits %}
<table class="cmd-table">
<thead>
<tr>
<th>bates</th>
<th>session</th>
<th>audit</th>
<th>question</th>
<th>cited titles</th>
</tr>
</thead>
<tbody>
{% for n in hits %}
<tr>
<td><a href="/sessions/{{ n.sid }}"><code>{{ n.bates }}</code></a></td>
<td><code>{{ n.sid }}</code></td>
<td><span class="cmd-tree-audit cmd-audit-{{ n.audit_mode|lower }}">{{ n.audit_mode }}</span></td>
<td>{{ (n.question or '')[:120] }}</td>
<td>{% for t in n.cited_titles %}<span class="cmd-cite">{{ t }}</span>{% endfor %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="cmd-empty">no matches</p>
{% endif %}
{% endif %}
</section>
{% endblock %}

View file

@ -0,0 +1,52 @@
{% extends "base.jinja2" %}
{% block body %}
<section class="cmd-panel">
<header class="cmd-panel-head">
<h1>Sessions</h1>
<p class="cmd-panel-sub">
Merkle conversation forest — single shard,
{% if sessions_stats.open %}
{{ sessions_stats.n_sessions }} session(s) ·
{{ sessions_stats.n_nodes }} node(s) ·
{{ sessions_stats.n_audit_events }} audit event(s) ·
<code>{{ sessions_stats.path }}</code>
{% else %}
no sessions shard configured
{% endif %}
</p>
<form action="/sessions/find" method="get" class="cmd-inline-form">
<input type="text" name="q" placeholder="search across all sessions (FTS5)" autocomplete="off">
<button type="submit">find</button>
</form>
</header>
{% if rows %}
<table class="cmd-table">
<thead>
<tr>
<th>sid</th>
<th>nodes</th>
<th>session_root</th>
<th>current_bates</th>
<th>updated</th>
<th>label</th>
</tr>
</thead>
<tbody>
{% for s in rows %}
<tr>
<td><a href="/sessions/{{ s.sid }}"><code>{{ s.sid }}</code></a></td>
<td>{{ s.n_nodes }}</td>
<td><code class="cmd-hash">{{ s.session_root }}</code></td>
<td><code>{{ s.current_bates }}</code></td>
<td>{{ s.updated_at }}</td>
<td>{{ s.label or '' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="cmd-empty">No sessions yet. Run <code>make session</code> to start one.</p>
{% endif %}
</section>
{% endblock %}

View file

@ -0,0 +1,52 @@
{% extends "base.jinja2" %}
{% macro render_node(node, kids_of, branch_set, current_bates, this_sid, depth=0) -%}
<li class="cmd-tree-node {% if node.bates == current_bates %}cmd-tree-current{% endif %} {% if node.bates in branch_set %}cmd-tree-branch{% endif %}">
<div class="cmd-tree-line">
<span class="cmd-tree-bates"><code>{{ node.bates }}</code></span>
<span class="cmd-tree-audit cmd-audit-{{ node.audit_mode|lower }}">{{ node.audit_mode }}</span>
{% if node.sid != this_sid %}<span class="cmd-tree-from">[from {{ node.sid }}]</span>{% endif %}
{% if node.label %}<span class="cmd-tree-label">{{ '{' + node.label + '}' }}</span>{% endif %}
<span class="cmd-tree-q">{{ (node.question or '')[:120] }}</span>
</div>
{% if node.cited_titles %}
<div class="cmd-tree-cites">
{% for t in node.cited_titles %}<span class="cmd-cite">{{ t }}</span>{% endfor %}
</div>
{% endif %}
{% set kids = kids_of.get(node.bates, []) %}
{% if kids %}
<ul class="cmd-tree-children">
{% for c in kids %}
{{ render_node(c, kids_of, branch_set, current_bates, this_sid, depth + 1) }}
{% endfor %}
</ul>
{% endif %}
</li>
{%- endmacro %}
{% block body %}
<section class="cmd-panel">
<header class="cmd-panel-head">
<h1>session <code>{{ session.sid }}</code></h1>
<p class="cmd-panel-sub">
{{ session.n_nodes }} node(s) · {{ branches|length }} branch point(s) ·
<code class="cmd-hash">root={{ session.session_root }}</code> ·
current → <code>{{ session.current_bates }}</code>
</p>
<p><a href="/sessions">← all sessions</a> · <a href="/api/sessions/{{ session.sid }}">JSON</a></p>
</header>
{% set kids_of = children %}
{% set root_node = nodes[0] if nodes and nodes[0].seq == 0 else None %}
{% if root_node %}
<ul class="cmd-tree">
{% for child in kids_of.get(root_node.bates, []) %}
{{ render_node(child, kids_of, branches, session.current_bates, session.sid) }}
{% endfor %}
</ul>
{% else %}
<p class="cmd-empty">empty session</p>
{% endif %}
</section>
{% endblock %}

View file

@ -0,0 +1,173 @@
"""Sessions — Merkle conversation forest dashboard.
Read-only view of the sessions shard (one file per user, at
``~/.arborist/sessions.db`` by default; overridable via env or .ini).
Three surfaces:
- /sessions list all sessions
- /sessions/{sid} one session's tree
- /sessions/find?q=... FTS5 search across every session
- /api/sessions JSON list
- /api/sessions/{sid} JSON tree
- /api/sessions/find?q=... JSON search
All schema knowledge lives behind :mod:`arborist.read.SessionsView`.
"""
from __future__ import annotations
from pyramid.httpexceptions import HTTPNotFound
from pyramid.view import view_config
from ..views.home import _common_ctx
def _sessions_ctx(request) -> dict:
"""Common template context with sessions stats."""
sessions = request.sessions
if sessions is None or not sessions.is_open:
s_stats = {"n_sessions": 0, "n_nodes": 0,
"n_audit_events": 0, "open": False}
else:
c = sessions.counts()
s_stats = {
"n_sessions": c.n_sessions,
"n_nodes": c.n_nodes,
"n_audit_events": c.n_audit_events,
"open": True,
"path": c.shard_path,
}
ctx = _common_ctx(request)
ctx["sessions_stats"] = s_stats
return ctx
@view_config(route_name="sessions_list", renderer="sessions_list.jinja2")
def sessions_list(request):
sessions = request.sessions
rows = sessions.list_sessions() if sessions else []
ctx = _sessions_ctx(request)
ctx["title"] = "Sessions — Arborist VIZ"
ctx["rows"] = rows
return ctx
@view_config(route_name="sessions_tree", renderer="sessions_tree.jinja2")
def sessions_tree(request):
sessions = request.sessions
if sessions is None:
raise HTTPNotFound("no sessions shard configured")
sid = request.matchdict["sid"]
meta = sessions.get_session(sid)
if meta is None:
raise HTTPNotFound(f"no such session: {sid}")
nodes = sessions.all_nodes_in_session(sid)
# Materialize a children-map per Bates so the template can render
# the tree without N round-trips into SessionsView.
children: dict[str, list] = {}
for n in nodes:
if n.parent_bates:
children.setdefault(n.parent_bates, []).append(n)
# Also include cross-session children whose parent_bates points
# into this session.
for parent_bates in list(children.keys()):
pass # already populated by the loop above
branches = sessions.branches_in_session(sid)
ctx = _sessions_ctx(request)
ctx["title"] = f"session {sid} — Arborist VIZ"
ctx["session"] = meta
ctx["nodes"] = nodes
ctx["children"] = children
ctx["branches"] = set(branches)
return ctx
@view_config(route_name="sessions_find", renderer="sessions_find.jinja2")
def sessions_find(request):
sessions = request.sessions
q = (request.params.get("q") or "").strip()
try:
limit = max(1, min(100, int(request.params.get("limit") or "20")))
except ValueError:
limit = 20
hits = sessions.find(q, limit=limit) if (sessions and q) else []
ctx = _sessions_ctx(request)
ctx["title"] = f"search — Arborist VIZ" + (f" — {q}" if q else "")
ctx["query"] = q
ctx["limit"] = limit
ctx["hits"] = hits
return ctx
# ---- JSON APIs ------------------------------------------------------
def _node_to_json(n) -> dict:
return {
"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,
"n_cited_sources": n.n_cited_sources,
"node_hash": n.node_hash,
"subtree_hash": n.subtree_hash,
"created_at": n.created_at,
"label": n.label,
}
@view_config(route_name="api_sessions_list", renderer="json")
def api_sessions_list(request):
sessions = request.sessions
if sessions is None:
return {"sessions": [], "open": False}
return {
"sessions": [
{
"sid": s.sid, "root_bates": s.root_bates,
"current_bates": s.current_bates,
"label": s.label, "n_nodes": s.n_nodes,
"session_root": s.session_root,
"created_at": s.created_at,
"updated_at": s.updated_at,
}
for s in sessions.list_sessions()
],
"open": True,
}
@view_config(route_name="api_sessions_tree", renderer="json")
def api_sessions_tree(request):
sessions = request.sessions
if sessions is None:
raise HTTPNotFound("no sessions shard")
sid = request.matchdict["sid"]
meta = sessions.get_session(sid)
if meta is None:
raise HTTPNotFound(f"no such session: {sid}")
nodes = sessions.all_nodes_in_session(sid)
return {
"sid": sid,
"session_root": meta.session_root,
"current_bates": meta.current_bates,
"branches": sessions.branches_in_session(sid),
"nodes": [_node_to_json(n) for n in nodes],
}
@view_config(route_name="api_sessions_find", renderer="json")
def api_sessions_find(request):
sessions = request.sessions
q = (request.params.get("q") or "").strip()
try:
limit = max(1, min(100, int(request.params.get("limit") or "20")))
except ValueError:
limit = 20
hits = sessions.find(q, limit=limit) if (sessions and q) else []
return {
"query": q, "limit": limit,
"hits": [_node_to_json(n) for n in hits],
}