Files
ar9av__obsidian-wiki/obsidian_wiki/session_index.py
ar9av 4bf87a8d19 feat(sessions): add session brain; restructure README behind docs/
Two strands of work.

Session brain — a topic graph over raw agent session history, so you can
find the session where something happened instead of remembering its
UUID. Reads every Claude transcript plus the pruned sessions that survive
only as prompts in history.jsonl, clusters them with local TF-IDF and
community detection (no embeddings, no API calls, no new dependencies),
and writes a sidecar at ~/.claude/session-brain/ — the vault is never
touched. Ships five modules, the sessions-* CLI commands, an interactive
graph.html, and the /session-brain and /wiki-sessions skills. This is
retrieval, deliberately distinct from wiki-history-ingest, which distils
sessions into permanent vault pages.

Docs — README.md was 598 lines and put trust-check flags and a 17-row
agent matrix in front of anyone evaluating the project. Cut it to a 129-
line landing page (problem statement, 60-second install, four blocks of
runnable commands, graph screenshot, why-not-a-notes-folder) and move the
reference material into docs/: installation, agents, skills, cli,
configuration, architecture, session-brain, contributing. README_TW.md
mirrors the new structure; docs/ stays English-only for now.

The restructure also closed real drift:

- Ten skills existed but appeared in no reference — session-brain,
  session-search, wiki-dedup, wiki-digest, wiki-import, wiki-context-pack,
  wiki-stage-commit, wiki-switch, vault-skill-factory, and
  obsidian-layout-adjustment.
- SETUP.md listed 12 of 39 skills and still claimed "no scripts, no
  dependencies" despite the pip package. It is now a signpost into docs/.
- Eight config variables were undocumented anywhere, including
  WIKI_STAGED_WRITES, which wiki-setup prompts for and three skills read.
  Added to .env.example and docs/configuration.md.
- sessions-name and a number of CLI flags were missing from the docs.

Two details worth noting. README links are absolute GitHub URLs because
pyproject sets readme = "README.md" as the PyPI long_description, and PyPI
resolves relative hrefs against pypi.org rather than the repo — docs/
links would have 404'd on the package page. And three doc-drift guards
asserted their facts against README.md; since those facts moved rather
than disappeared, the assertions were repointed at the page that now owns
each one instead of padding content back into the README.

Also gitignore /vault/ (a local knowledge vault, not a fixture) and the
packed browser extension.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 12:07:48 -07:00

417 lines
18 KiB
Python

"""Pure-stdlib TF-IDF over sessions: tokenizing, weighting, and cosine kNN.
This module decides whether the session graph is meaningful. Coding-session text
is dominated by boilerplate — "let me check the file", "I'll run the tests",
"you're right, let me fix that" — which is both high-frequency and completely
uninformative. Left alone, every session looks like every other session and the
clustering collapses into one blob.
Four independent defences, in descending order of how much work they do:
1. Field weighting. Assistant prose is where boilerplate lives, so it carries
the lowest weight and is heavily truncated upstream. Titles, human prompts
and curated bookmark tags carry the topic and are weighted 3-6x.
2. An adaptive document-frequency ceiling. Any term appearing in more than
MAX_DF_RATIO of all sessions is deleted from the vocabulary outright. This
is corpus-adaptive, so it catches machine-specific boilerplate (a person's
main project name, their favourite framework) that no hand-written
stopword list could anticipate.
3. A hand-written agent-boilerplate stoplist, on top of English stopwords.
4. Sublinear TF plus per-document top-N pruning. A session's identity becomes
its most distinctive ~120 terms rather than its full boilerplate cloud.
This is what actually sharpens the cosine similarity.
No third-party dependencies: `obsidian-wiki` ships with `dependencies = []` and
this module keeps it that way.
"""
from __future__ import annotations
import math
import re
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Any, Iterable, Iterator
# --- Weighting --------------------------------------------------------------
# What a session is "about" is carried by what the human typed and what the
# session was named — not by the assistant's narration of its own tool calls.
FIELD_WEIGHTS: dict[str, float] = {
"title": 6.0,
"bookmark": 5.0,
"first_prompt": 4.0,
"prompt": 3.0,
"subagent": 3.0,
"repo": 3.0,
"project": 2.0,
"branch": 2.0,
"assistant": 1.0,
}
# A title derived from a truncated first prompt is far noisier than a generated
# one, so it does not get to outweigh the prompt it came from.
UNRELIABLE_TITLE_SOURCES = {"first-prompt", "history-display"}
UNRELIABLE_TITLE_WEIGHT = 4.0
MIN_DF = 3 # a term seen in fewer than 3 sessions is a typo or a one-off
MAX_DF_RATIO = 0.45 # a term in >45% of sessions carries no discriminating signal
SMALL_CORPUS_DOCS = 25 # below this the DF ceiling is not statistically meaningful
VOCAB_CAP = 30_000
TOP_TERMS_PER_DOC = 120
MAX_POSTINGS = 400 # bounds worst-case kNN cost on very common surviving terms
# A session reduced to two or three surviving terms (`ls`, `exit`, `run it`) has
# no topic. Linking it anyway would attach it to whatever shares its project
# token and inflate that cluster with noise, so it is left isolated instead.
MIN_TERMS_FOR_KNN = 5
MIN_TOKEN_LEN = 3
MAX_TOKEN_LEN = 40
# Namespaced tokens (proj:foo, repo:acme-widget) let a project anchor a cluster
# without colliding with the same word used as ordinary prose.
_META_RE = re.compile(r"\b(proj|repo|branch):([A-Za-z0-9_.\-]+)")
# Paths and dotted module names split for free here: neither `/` nor `.` is in
# the character class, so `app/api/telemetry/route.ts` yields its segments.
_WORD_RE = re.compile(r"[A-Za-z][A-Za-z0-9_]{1,39}")
_CAMEL_RE = re.compile(r"[A-Z]?[a-z]+|[A-Z]{2,}(?![a-z])")
_ENGLISH_STOP = """
a about above after again against all am an and any are aren as at be because been
before being below between both but by can cannot could couldn did didn do does
doesn doing don down during each few for from further had hadn has hasn have haven
having he her here hers herself him himself his how i if in into is isn it its
itself just let me more most must my myself no nor not now of off on once only or
other ought our ours ourselves out over own re same shan she should shouldn so some
such than that the their theirs them themselves then there these they this those
through to too under until up very was wasn we were weren what when where which
while who whom why will with won would wouldn you your yours yourself yourselves
""".split()
# Terms that are frequent in *every* agent transcript regardless of topic. The
# adaptive DF ceiling catches most of these on a large corpus, but this list
# keeps small corpora (and fresh installs) sane too.
_AGENT_STOP = """
actually add added adding additional again against ahead already also alternative
always another answer anything approach appropriate around ask asked asking assistant
attempt available back based basically before begin better bit block bottom break
brief bring build building built call called calling case cases catch caused change
changed changes changing check checked checking claude clean clear code codebase
column come command commands comment commit common complete completed completely
component config configuration confirm consider console content context continue
copy correct correctly could create created creating current currently cut data
default define definitely delete deleted description detail details determine
difference different directly directory discuss display document documentation does
doing done double down draft drop due each easier easy edit edited editing either
else end ensure enter entire error errors especially even every everything exact
exactly example examples exist existing expect expected explain fail failed failing
fails false feel field file files fill final finally find finding fine finish first
fix fixed fixes fixing folder follow following format found four full function
functions further general generate generated get gets getting give given go goes
going good got great group guess handle handled handling happen happens hard help
here high hit hold hope idea if implement implementation implemented implementing
import important include included includes including indeed information initial
inline input insert inside instead interesting into issue issues item items just
keep kept key kind know known large last later latest lead least leave left less
let level like likely line lines list listed little load local logic long look
looked looking looks lot made main make makes making manual many match matches
matter maybe mean means mention mentioned message method might mind minor miss
missing mode moment more most move moved much multiple name named names necessary
need needed needs never new next nice note noted nothing notice now number object
obvious okay old once one only open option options order original other output
outside over overall page pages part particular pass passed path paths pattern
perfect perhaps piece place plan please point possible potential prefer pretty
previous print probably problem proceed process produce project proper properly
provide provided pull push put question quick quickly quite ran range rather read
reading ready real really reason recent reference regular related relevant remain
remove removed removing rename replace replaced report request require required
result results return returns review right root rule run running runs said same
save saved saw say saying script scripts search second section see seem seems seen
send sense sent separate set sets setting several share short should show shown
shows side similar simple simply since single situation small solution solve some
something sometimes soon sorry sort sound source space specific specifically spot
stage standard start started starting state statement status step steps still stop
store straight string structure stuff style suggest suggestion supported suppose
sure switch system take taken takes taking talk tell term test tested testing tests
text than thank thanks thing things think thought three through time times tiny
today together told took top total track try trying turn two type types typical
understand unless update updated updating usage use used useful uses using
usually value values variable various version very view wait want wanted wants way
exit quit stop cancel abort resume continue retry rerun again ping hey hello thx ack
ok okay sure yep nope done next prev back forward here there this that
ways well went whatever whether whole why wide will wish within without wonder word
work worked working works worth would write writes writing written wrong yeah yes
yet
""".split()
STOPWORDS: frozenset[str] = frozenset(_ENGLISH_STOP) | frozenset(_AGENT_STOP)
_HEX_CHARS = frozenset("0123456789abcdef")
def _looks_like_id(token: str) -> bool:
"""True for uuid fragments, commit sha prefixes, and other opaque ids.
Transcripts are full of these (`d8f1c448e`, `deae261`) and they are pure
noise: high IDF, so they score well, but they can never connect two
sessions meaningfully. Requiring a digit keeps real hex-alphabet words
(`added`, `decade`, `facade`) in the vocabulary.
"""
if len(token) < 6:
return False
if not set(token) <= _HEX_CHARS:
return False
return any(c.isdigit() for c in token)
def _valid(token: str) -> bool:
if not (MIN_TOKEN_LEN <= len(token) <= MAX_TOKEN_LEN):
return False
if token.isdigit() or _looks_like_id(token):
return False
return token not in STOPWORDS
def tokenize(text: str) -> Iterator[str]:
"""Yield normalised tokens, emitting compound identifiers whole *and* split.
`getUserToken` yields `getusertoken`, `get`, `user`, `token`; `remote_policy`
yields `remote_policy`, `remote`, `policy`. Keeping both means an exact
identifier match scores strongly while a conceptual match ("policy") still
connects sessions that never used the same symbol.
"""
for prefix, value in _META_RE.findall(text):
token = f"{prefix}:{value.lower()}"
if len(token) <= MAX_TOKEN_LEN + 8:
yield token
for match in _WORD_RE.finditer(text):
word = match.group(0)
lowered = word.lower()
if _valid(lowered):
yield lowered
# Split compounds only when there is something to split.
if "_" in word or not word.islower():
for chunk in word.split("_"):
if not chunk:
continue
for part in _CAMEL_RE.findall(chunk):
part = part.lower()
if part != lowered and _valid(part):
yield part
def term_weights(doc: Any) -> dict[str, float]:
"""Accumulate weighted raw term counts for one SessionDoc.
Takes anything with `.fields` and `.title_source`, so `session_graph` can
replay cached term maps without reconstructing full documents.
"""
weights: dict[str, float] = defaultdict(float)
title_source = getattr(doc, "title_source", "")
for name, texts in (doc.fields or {}).items():
weight = FIELD_WEIGHTS.get(name, 1.0)
if name == "title" and title_source in UNRELIABLE_TITLE_SOURCES:
weight = UNRELIABLE_TITLE_WEIGHT
for text in texts:
for token in tokenize(text):
weights[token] += weight
return dict(weights)
# ---------------------------------------------------------------------------
# Index
# ---------------------------------------------------------------------------
@dataclass
class Index:
doc_ids: list[str] = field(default_factory=list)
idf: dict[str, float] = field(default_factory=dict)
vectors: list[dict[str, float]] = field(default_factory=list) # L2-normalised
postings: dict[str, list[tuple[int, float]]] = field(default_factory=dict)
def __len__(self) -> int:
return len(self.doc_ids)
def build_index(
term_maps: dict[str, dict[str, float]],
*,
min_df: int = MIN_DF,
max_df_ratio: float = MAX_DF_RATIO,
vocab_cap: int = VOCAB_CAP,
top_terms: int = TOP_TERMS_PER_DOC,
) -> Index:
"""Turn raw weighted term counts into a pruned, normalised TF-IDF index."""
doc_ids = sorted(term_maps)
n_docs = len(doc_ids)
index = Index(doc_ids=doc_ids)
if n_docs == 0:
return index
df: dict[str, int] = defaultdict(int)
for doc_id in doc_ids:
for term in term_maps[doc_id]:
df[term] += 1
# Small corpora cannot support a min_df of 3 — nothing would survive.
effective_min_df = min_df if n_docs >= 4 * min_df else 1
# "Appears in 45% of documents" is only evidence of boilerplate once there
# are enough documents for that to be a meaningful proportion. Across six
# sessions a genuine topic term legitimately appears in half of them, so
# below the threshold only terms present in *every* document are dropped.
if n_docs >= SMALL_CORPUS_DOCS:
df_ceiling = max(2, int(n_docs * max_df_ratio))
else:
df_ceiling = max(1, n_docs - 1)
vocab = {t: d for t, d in df.items() if effective_min_df <= d <= df_ceiling}
if len(vocab) > vocab_cap:
keep = sorted(vocab, key=lambda t: -vocab[t])[:vocab_cap]
vocab = {t: vocab[t] for t in keep}
idf = {t: math.log((n_docs + 1) / (d + 1)) + 1.0 for t, d in vocab.items()}
index.idf = idf
postings: dict[str, list[tuple[int, float]]] = defaultdict(list)
for i, doc_id in enumerate(doc_ids):
scored: list[tuple[str, float]] = []
for term, weight in term_maps[doc_id].items():
term_idf = idf.get(term)
if term_idf is None or weight <= 0:
continue
# Sublinear TF: the tenth mention of a term says much less than the
# first, and without this a long session outranks a focused one.
scored.append((term, (1.0 + math.log(weight)) * term_idf))
scored.sort(key=lambda kv: -kv[1])
scored = scored[:top_terms]
norm = math.sqrt(sum(v * v for _, v in scored))
vector = {t: v / norm for t, v in scored} if norm else {}
index.vectors.append(vector)
for term, value in vector.items():
postings[term].append((i, value))
# Cap posting lists so one surviving common term cannot dominate kNN cost.
index.postings = {
term: sorted(plist, key=lambda p: -p[1])[:MAX_POSTINGS] if len(plist) > MAX_POSTINGS else plist
for term, plist in postings.items()
}
return index
def vectorize_query(text: str, idf: dict[str, float]) -> dict[str, float]:
"""Vectorise free text against an existing IDF table."""
raw: dict[str, float] = defaultdict(float)
for token in tokenize(text):
raw[token] += 1.0
scored = {
t: (1.0 + math.log(w)) * idf[t]
for t, w in raw.items() if t in idf
}
norm = math.sqrt(sum(v * v for v in scored.values()))
return {t: v / norm for t, v in scored.items()} if norm else {}
def cosine(a: dict[str, float], b: dict[str, float]) -> float:
"""Cosine of two L2-normalised sparse vectors."""
if len(a) > len(b):
a, b = b, a
return sum(v * b.get(t, 0.0) for t, v in a.items())
def knn(
index: Index,
*,
k: int = 8,
min_sim: float = 0.08,
mutual: bool = False,
) -> list[tuple[int, int, float, list[str]]]:
"""Symmetric k-nearest-neighbour edges as `(i, j, similarity, shared_terms)`.
Scores are accumulated through the inverted index, so cost scales with the
pruned per-document term count rather than with the corpus size.
"""
neighbours: dict[int, dict[int, float]] = {}
for i, vector in enumerate(index.vectors):
if len(vector) < MIN_TERMS_FOR_KNN:
neighbours[i] = {}
continue
scores: dict[int, float] = defaultdict(float)
for term, value in vector.items():
for j, other in index.postings.get(term, ()):
if j != i:
scores[j] += value * other
top = sorted(scores.items(), key=lambda kv: -kv[1])[:k]
neighbours[i] = {j: s for j, s in top if s >= min_sim}
edges: list[tuple[int, int, float, list[str]]] = []
seen: set[tuple[int, int]] = set()
for i, nbrs in neighbours.items():
for j, sim in nbrs.items():
pair = (i, j) if i < j else (j, i)
if pair in seen:
continue
reciprocal = neighbours.get(j, {})
if mutual and i not in reciprocal:
continue
seen.add(pair)
weight = max(sim, reciprocal.get(i, 0.0))
edges.append((pair[0], pair[1], weight, shared_terms(index, pair[0], pair[1])))
edges.sort(key=lambda e: -e[2])
return edges
def shared_terms(index: Index, i: int, j: int, top_n: int = 5) -> list[str]:
"""The terms contributing most to the similarity of two documents."""
a, b = index.vectors[i], index.vectors[j]
if len(a) > len(b):
a, b = b, a
contributions = [(t, v * b[t]) for t, v in a.items() if t in b]
contributions.sort(key=lambda kv: -kv[1])
return [t for t, _ in contributions[:top_n]]
def length_prior(vector: dict[str, float]) -> float:
"""Saturating penalty for documents too short to be trustworthy.
L2 normalisation gives a one-term document all of its weight on that term,
so a session whose entire surviving content is "prismor" scores a near
perfect cosine against the query "prismor telemetry" — beating a real
session that actually discussed both. Scaling by how much evidence the
document has restores the ordering without discarding short sessions.
"""
return min(1.0, len(vector) / MIN_TERMS_FOR_KNN)
def search(index: Index, qvec: dict[str, float], *, top_n: int = 50) -> list[tuple[int, float, list[str]]]:
"""Rank documents against a query vector, returning `(doc_idx, sim, terms)`."""
if not qvec:
return []
scores: dict[int, float] = defaultdict(float)
matched: dict[int, list[tuple[str, float]]] = defaultdict(list)
for term, value in qvec.items():
for j, other in index.postings.get(term, ()):
contribution = value * other
scores[j] += contribution
matched[j].append((term, contribution))
for j in scores:
scores[j] *= length_prior(index.vectors[j])
ranked = sorted(scores.items(), key=lambda kv: -kv[1])[:top_n]
out = []
for j, score in ranked:
terms = [t for t, _ in sorted(matched[j], key=lambda kv: -kv[1])[:5]]
out.append((j, score, terms))
return out
def top_terms_for(index: Index, i: int, n: int = 10) -> list[str]:
"""The n highest-weight terms of a document — its readable fingerprint."""
return [t for t, _ in sorted(index.vectors[i].items(), key=lambda kv: -kv[1])[:n]]
def iter_term_maps(docs: Iterable[Any]) -> dict[str, dict[str, float]]:
return {doc.session_id: term_weights(doc) for doc in docs}