diff --git a/arborist_viz/__init__.py b/arborist_viz/__init__.py
index e38cdb2..0f40ebe 100644
--- a/arborist_viz/__init__.py
+++ b/arborist_viz/__init__.py
@@ -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")
diff --git a/arborist_viz/routes.py b/arborist_viz/routes.py
index 2c0a1d0..10c1a32 100644
--- a/arborist_viz/routes.py
+++ b/arborist_viz/routes.py
@@ -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}")
diff --git a/arborist_viz/static/css/viz.css b/arborist_viz/static/css/viz.css
index 0332fcb..9a96e25 100644
--- a/arborist_viz/static/css/viz.css
+++ b/arborist_viz/static/css/viz.css
@@ -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); }
diff --git a/arborist_viz/storage.py b/arborist_viz/storage.py
index 5ee265f..a6ae70f 100644
--- a/arborist_viz/storage.py
+++ b/arborist_viz/storage.py
@@ -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
diff --git a/arborist_viz/templates/sessions_find.jinja2 b/arborist_viz/templates/sessions_find.jinja2
new file mode 100644
index 0000000..e1ee4e7
--- /dev/null
+++ b/arborist_viz/templates/sessions_find.jinja2
@@ -0,0 +1,44 @@
+{% extends "base.jinja2" %}
+{% block body %}
+ {{ hits|length }} match(es) for no matches
+ 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) ·
+ No sessions yet. Run Search sessions
+
+
+ {{ query }}
+
+
+ {% else %}
+
+
+
+
+ {% for n in hits %}
+ bates
+ session
+ audit
+ question
+ cited titles
+
+
+ {% endfor %}
+
+
+ {{ n.bates }}
+ {{ n.sid }}{{ n.audit_mode }}
+ {{ (n.question or '')[:120] }}
+ {% for t in n.cited_titles %}{{ t }}{% endfor %}
+ Sessions
+ {{ sessions_stats.path }}
+ {% else %}
+ no sessions shard configured
+ {% endif %}
+
+
+
+ {% else %}
+
+
+
+
+ {% for s in rows %}
+ sid
+ nodes
+ session_root
+ current_bates
+ updated
+ label
+
+
+ {% endfor %}
+
+
+ {{ s.sid }}{{ s.n_nodes }}
+
+ {{ s.session_root }}
+ {{ s.current_bates }}{{ s.updated_at }}
+ {{ s.label or '' }}
+ make session to start one.
{{ node.bates }}
+ {{ node.audit_mode }}
+ {% if node.sid != this_sid %}[from {{ node.sid }}]{% endif %}
+ {% if node.label %}{{ '{' + node.label + '}' }}{% endif %}
+ {{ (node.question or '')[:120] }}
+ {{ session.sid }}
+ {{ session.n_nodes }} node(s) · {{ branches|length }} branch point(s) ·
+ root={{ session.session_root }} ·
+ current → {{ session.current_bates }}
+
empty session
+ {% endif %} +