brainiac-cli 0.16.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. brain/__init__.py +39 -0
  2. brain/__main__.py +14 -0
  3. brain/_assets/AGENTS.md +564 -0
  4. brain/_assets/overlay/template/brand/brand-guide.md +24 -0
  5. brain/_assets/overlay/template/keywords/glossary.md +15 -0
  6. brain/_assets/overlay/template/people/roster.md +14 -0
  7. brain/_assets/overlay/template/voice/voice-profile.md +34 -0
  8. brain/_assets/routines/manifest.json +257 -0
  9. brain/_assets/scripts/brain-brief-mac.plist +59 -0
  10. brain/_assets/scripts/brain-brief.sh +74 -0
  11. brain/_assets/scripts/brain-synthesis-mac.plist +48 -0
  12. brain/_assets/scripts/brain-synthesis.sh +214 -0
  13. brain/_assets/scripts/install-brief-mac.sh +152 -0
  14. brain/_assets/scripts/install-brief-windows.ps1 +97 -0
  15. brain/_assets/scripts/register_tasks.py +386 -0
  16. brain/_assets/templates/company.md +21 -0
  17. brain/_assets/templates/concept.md +27 -0
  18. brain/_assets/templates/daily.md +20 -0
  19. brain/_assets/templates/decision.md +42 -0
  20. brain/_assets/templates/meeting.md +33 -0
  21. brain/_assets/templates/person.md +20 -0
  22. brain/_assets/templates/project.md +23 -0
  23. brain/_assets/templates/state-moc.md +40 -0
  24. brain/_version.py +12 -0
  25. brain/anchor.py +121 -0
  26. brain/audit.py +422 -0
  27. brain/backup.py +210 -0
  28. brain/brief.py +417 -0
  29. brain/capture.py +117 -0
  30. brain/chunk.py +249 -0
  31. brain/classification.py +134 -0
  32. brain/cli.py +1906 -0
  33. brain/config.py +368 -0
  34. brain/connect.py +362 -0
  35. brain/context.py +108 -0
  36. brain/core.py +3018 -0
  37. brain/doctor.py +1161 -0
  38. brain/egress.py +148 -0
  39. brain/embed.py +857 -0
  40. brain/encryption.py +217 -0
  41. brain/frontmatter.py +102 -0
  42. brain/golden_probe.py +678 -0
  43. brain/graph.py +369 -0
  44. brain/graphify.py +352 -0
  45. brain/index.py +1576 -0
  46. brain/ingest/__init__.py +19 -0
  47. brain/ingest/handlers/__init__.py +43 -0
  48. brain/ingest/handlers/base.py +95 -0
  49. brain/ingest/handlers/docx.py +78 -0
  50. brain/ingest/handlers/email.py +228 -0
  51. brain/ingest/handlers/html.py +142 -0
  52. brain/ingest/handlers/image.py +91 -0
  53. brain/ingest/handlers/pdf.py +99 -0
  54. brain/ingest/handlers/pptx.py +69 -0
  55. brain/ingest/handlers/tables.py +41 -0
  56. brain/ingest/handlers/text.py +43 -0
  57. brain/ingest/handlers/xlsx.py +100 -0
  58. brain/ingest/handlers/zip.py +163 -0
  59. brain/ingest/pipeline.py +839 -0
  60. brain/ingest/transcript.py +158 -0
  61. brain/init.py +870 -0
  62. brain/maintenance.py +2266 -0
  63. brain/mcp_adapter.py +217 -0
  64. brain/multihop.py +232 -0
  65. brain/notes.py +195 -0
  66. brain/overlay.py +183 -0
  67. brain/projection.py +79 -0
  68. brain/rerank.py +425 -0
  69. brain/snapshot.py +231 -0
  70. brain/update.py +743 -0
  71. brain/vectors.py +225 -0
  72. brainiac_cli-0.16.0.dist-info/METADATA +306 -0
  73. brainiac_cli-0.16.0.dist-info/RECORD +77 -0
  74. brainiac_cli-0.16.0.dist-info/WHEEL +5 -0
  75. brainiac_cli-0.16.0.dist-info/entry_points.txt +4 -0
  76. brainiac_cli-0.16.0.dist-info/licenses/LICENSE +202 -0
  77. brainiac_cli-0.16.0.dist-info/top_level.txt +1 -0
brain/mcp_adapter.py ADDED
@@ -0,0 +1,217 @@
1
+ """OPTIONAL, DELETABLE ~50-line MCP adapter for the pure Claude Desktop CHAT tab.
2
+
3
+ This is the ONE surface that cannot run a shell command — so it gets a thin MCP
4
+ bridge. Every OTHER harness (Codex, Claude Code, Gemini CLI, the Desktop Code
5
+ tab, the Cowork VM) calls the ``brain`` CLI directly; MCP is NEVER the
6
+ foundation. Delete this file and nothing else breaks.
7
+
8
+ The bridge wraps the SAME ``BrainCore`` + the SAME deny-by-default
9
+ ``ClassificationFilter`` the CLI applies at stdout — there is no second egress
10
+ path to keep in sync. It exposes ONLY the read verbs (``search`` / ``get`` /
11
+ ``recent``); it never exposes write/draft/host-broker commands. ``dispatch`` is
12
+ the pure, importable, testable core; ``serve`` is the (optional) stdio transport
13
+ glue requiring ``pip install 'brainiac-cli[mcp]'``.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ from typing import Any
19
+
20
+ from . import classification as cls
21
+ from . import egress
22
+ from .core import BrainCore
23
+
24
+ READ_TOOLS = ("search", "get", "recent", "bases_query", "dossier")
25
+
26
+ # Server-side egress ceiling (SEC-01 hardening). A caller-supplied ``max_tier``
27
+ # was previously honored unbounded — an MCP client could simply ASK for
28
+ # ``max_tier="MNPI"`` and receive it. That is a human-gated elevation on the
29
+ # CLI (an explicit ``--max-tier`` flag someone typed), but the MCP transport has
30
+ # no equivalent "a person is watching this" signal, so the adapter now clamps
31
+ # EVERY request to a ceiling the operator configures out-of-band. A caller may
32
+ # still request something NARROWER than the ceiling (always honored); it can
33
+ # never request higher.
34
+ EGRESS_CEILING_ENV_VAR = "BRAIN_MAX_EGRESS_TIER"
35
+ DEFAULT_EGRESS_CEILING_TIER = "MNPI" # matches cls.DEFAULT_MAX_TIER: the MCP
36
+ # adapter is a HOST-side surface (Chat tab / Cowork MCP-on-host), so it gets
37
+ # the same full-vault default as the host CLI (owner decision, 2026-07-10).
38
+ # Operators who want a hard server-side cap set $BRAIN_MAX_EGRESS_TIER; the
39
+ # clamp mechanism below is unchanged and still narrows every request to it.
40
+
41
+
42
+ def _egress_ceiling_tier() -> str:
43
+ """The operator-configured hard ceiling for MCP egress. Unset or an
44
+ unrecognised value falls back to the conservative default — fail-closed,
45
+ never fail-open on a typo'd env var."""
46
+ raw = os.environ.get(EGRESS_CEILING_ENV_VAR, DEFAULT_EGRESS_CEILING_TIER).strip()
47
+ return raw if raw in cls.RANK else DEFAULT_EGRESS_CEILING_TIER
48
+
49
+
50
+ def _clamp_max_tier(requested_tier: str) -> str:
51
+ """Clamp a caller-supplied ``max_tier`` to ``min(requested_rank, ceiling_rank)``.
52
+
53
+ An unrecognised ``requested_tier`` is passed through UNCHANGED so the
54
+ existing ``ClassificationFilter`` validation (``egress.apply_gate`` ->
55
+ ``cls.ClassificationFilter.__post_init__``) still raises its normal, clear
56
+ "unknown max_tier" error — this clamp only ever narrows a VALID request, it
57
+ never manufactures or swallows a validation error.
58
+ """
59
+ requested = requested_tier.strip()
60
+ if requested not in cls.RANK:
61
+ return requested_tier
62
+ ceiling_rank = cls.RANK[_egress_ceiling_tier()]
63
+ clamped_rank = min(cls.RANK[requested], ceiling_rank)
64
+ return cls.TIERS[clamped_rank]
65
+
66
+
67
+ def _filtered(items: list[dict], max_tier: str) -> tuple[list[dict], dict]:
68
+ # Same single egress chokepoint as the CLI (SEC-01) — no second egress path.
69
+ return egress.apply_gate(items, max_tier)
70
+
71
+
72
+ def dispatch(tool: str, args: dict[str, Any], *, core: BrainCore | None = None,
73
+ vault: str | None = None) -> dict[str, Any]:
74
+ """Run one read tool through the SAME egress gate as the CLI. Pure + testable.
75
+
76
+ ``max_tier`` is clamped server-side to the ``BRAIN_MAX_EGRESS_TIER`` ceiling
77
+ (default "Internal") BEFORE it reaches the classification filter — a caller
78
+ cannot self-elevate past the configured ceiling by simply asking.
79
+ """
80
+ core = core or BrainCore(vault=vault) # host or vm; reads only either way
81
+ max_tier = _clamp_max_tier(str(args.get("max_tier", cls.DEFAULT_MAX_TIER)))
82
+ if tool in ("search", "hybrid-search"):
83
+ hits = [h.to_dict() for h in core.hybrid_search(str(args["query"]), k=int(args.get("k", 10)))]
84
+ surfaced, report = _filtered(hits, max_tier)
85
+ out: dict[str, Any] = {"results": surfaced, "egress": report}
86
+ # RET-09 freshness signal — same contract as the CLI (see
87
+ # cli._freshness_block): tells the agent when the vault continues
88
+ # past its newest hit, so "latest/current" answers don't silently
89
+ # ground on stale-but-coherent material.
90
+ dates = [h.get("date", "") for h in surfaced if h.get("date")]
91
+ if dates:
92
+ try:
93
+ fresh = core.source_freshness(max(dates), max_tier)
94
+ except Exception: # noqa: BLE001 — freshness must never break search
95
+ fresh = None
96
+ if fresh and fresh.get("newer_count", 0) > 0:
97
+ fresh["hint"] = (
98
+ f"{fresh['newer_count']} note(s)/source(s) are newer than your "
99
+ f"newest hit ({fresh['newest_hit_date']}; vault newest "
100
+ f"{fresh['vault_newest']}) — for 'latest/current' questions, "
101
+ f"probe past these hits (recent, bases_query latest_only=True, "
102
+ f"or a narrower search) before treating this as current.")
103
+ if fresh:
104
+ out["freshness"] = fresh
105
+ return out
106
+ if tool in ("get", "read"):
107
+ note = core.get(str(args["id"]))
108
+ surfaced, report = _filtered([note] if note else [], max_tier)
109
+ return {"result": surfaced[0] if surfaced else None, "egress": report}
110
+ if tool == "recent":
111
+ surfaced, report = _filtered(core.recent(limit=int(args.get("n", 10))), max_tier)
112
+ return {"results": surfaced, "egress": report}
113
+ if tool == "dossier":
114
+ res = core.dossier(str(args["query"]), k=int(args.get("k", 12)))
115
+ decisions, drep = _filtered(res["decisions"], max_tier)
116
+ sources, srep = _filtered(res["sources"], max_tier)
117
+ # Merge the two egress reports by NAMED keys — a naive comprehension
118
+ # KeyErrors on conditional keys (casing_mismatch_warnings appears
119
+ # only when a wrong-case tier exists in that half).
120
+ report: dict[str, Any] = {
121
+ k2: drep[k2] + srep[k2]
122
+ for k2 in ("total", "surfaced", "withheld",
123
+ "withheld_unlabelled_default_deny")
124
+ }
125
+ report["max_tier"] = drep["max_tier"]
126
+ casing = sorted(set(drep.get("casing_mismatch_warnings", []))
127
+ | set(srep.get("casing_mismatch_warnings", [])))
128
+ if casing:
129
+ report["casing_mismatch_warnings"] = casing
130
+ return {"query": res["query"], "decisions": decisions,
131
+ "sources": sources,
132
+ "retired_excluded": res["retired_excluded"], "egress": report}
133
+ if tool in ("bases-query", "bases_query"):
134
+ filters = dict(args.get("where") or {})
135
+ items = core.bases_query(
136
+ filters, k=int(args.get("k", 50)),
137
+ latest_only=bool(args.get("latest_only", False)),
138
+ as_of=args.get("as_of") or None)
139
+ surfaced, report = _filtered(items, max_tier)
140
+ return {"results": surfaced, "egress": report}
141
+ raise ValueError(f"unknown / non-read tool {tool!r}; MCP adapter exposes only {READ_TOOLS}")
142
+
143
+
144
+ def serve(vault: str | None = None) -> None: # pragma: no cover - transport glue
145
+ """Optional stdio MCP server for the Chat tab. Requires the `mcp` package."""
146
+ from mcp.server.fastmcp import FastMCP
147
+
148
+ core = BrainCore(vault=vault)
149
+ server = FastMCP("brain")
150
+
151
+ @server.tool()
152
+ def search(query: str, k: int = 10, max_tier: str = cls.DEFAULT_MAX_TIER) -> dict:
153
+ """Hybrid (BM25+dense) retrieval over the vault, egress-filtered.
154
+
155
+ Every hit carries `date` (its valid time), `is_latest_version`, and
156
+ `type` — the AUTHORITY signal: a `type: decision` hit IS the
157
+ recorded decision layer; a `type: source` hit (memos, decks,
158
+ drafts) is material under consideration and NEVER overturns a
159
+ decision on its own — if a newer source conflicts with a decision
160
+ note, report the tension, don't promote the proposal. READ THE
161
+ `freshness` BLOCK: when it reports sources newer than your newest
162
+ hit, the vault continues past what you just retrieved — for
163
+ 'latest/current' questions, probe further (recent, bases_query with
164
+ latest_only=True, or a narrower search) before treating the result
165
+ as the current state. If `egress.hint` reports withheld notes,
166
+ re-call with a higher max_tier instead of concluding the vault is
167
+ empty. Curated notes (zone brain/) are synthesis; zone raw/ holds
168
+ the newest unprocessed sources — check both for recency-sensitive
169
+ questions."""
170
+ return dispatch("search", {"query": query, "k": k, "max_tier": max_tier}, core=core)
171
+
172
+ @server.tool()
173
+ def get(id: str, max_tier: str = cls.DEFAULT_MAX_TIER) -> dict:
174
+ """Fetch one full note by id, egress-filtered. Inspect
175
+ `superseded_by` / `previous_version` / `is_latest_version` on the
176
+ result to walk a version chain ("previous version", "what replaced
177
+ this")."""
178
+ return dispatch("get", {"id": id, "max_tier": max_tier}, core=core)
179
+
180
+ @server.tool()
181
+ def recent(n: int = 10, max_tier: str = cls.DEFAULT_MAX_TIER) -> dict:
182
+ """List the most recently created/updated notes, egress-filtered —
183
+ the cheapest way to see what entered the vault lately (use after a
184
+ search whose freshness block reported newer sources)."""
185
+ return dispatch("recent", {"n": n, "max_tier": max_tier}, core=core)
186
+
187
+ @server.tool()
188
+ def dossier(query: str, k: int = 12, max_tier: str = cls.DEFAULT_MAX_TIER) -> dict:
189
+ """THE ONE-CALL SWEEP for decision-state questions ("what have we
190
+ decided", "latest decisions", "current state of X"). Returns the
191
+ decision layer and the sources under consideration SEPARATED, with
192
+ each decision carrying a `tensions` list — newer sources that
193
+ post-date it (report the tension, never promote the proposal) —
194
+ and retired versions already excluded. Prefer this over plain
195
+ search for decision-state questions; fall back to search/get for
196
+ everything else."""
197
+ return dispatch("dossier", {"query": query, "k": k, "max_tier": max_tier}, core=core)
198
+
199
+ @server.tool()
200
+ def bases_query(where: dict | None = None, k: int = 50,
201
+ latest_only: bool = False, as_of: str = "",
202
+ max_tier: str = cls.DEFAULT_MAX_TIER) -> dict:
203
+ """Structured frontmatter query (no embedding), egress-filtered.
204
+ `where` filters exact frontmatter keys (e.g. {"type": "decision"}).
205
+ TEMPORAL ROUTING: for "what's current/latest" use latest_only=True
206
+ (excludes superseded notes); for "as of <date>" pass
207
+ as_of="YYYY-MM-DD" (point-in-time view). Prefer this over semantic
208
+ search when the question is really about time or note metadata."""
209
+ return dispatch("bases_query", {
210
+ "where": where or {}, "k": k, "latest_only": latest_only,
211
+ "as_of": as_of or None, "max_tier": max_tier}, core=core)
212
+
213
+ server.run()
214
+
215
+
216
+ if __name__ == "__main__": # pragma: no cover
217
+ serve()
brain/multihop.py ADDED
@@ -0,0 +1,232 @@
1
+ """Graph-augmented multi-hop retrieval (RET-06).
2
+
3
+ Flat top-k retrieval answers "which single note is most similar to this query".
4
+ A *multi-hop* question — "how does Alex Silva's exit connect to the org-transition
5
+ programme", "Contoso runs S/4HANA while Northwind runs a different SAP estate, what
6
+ does the JV do" — asks about a RELATIONSHIP between two or more named entities.
7
+ The relevant evidence is often spread across several notes linked by
8
+ ``[[wikilinks]]``; no single note is top-k-similar to the whole question, so
9
+ flat dense/lexical retrieval misses the corroborating notes in the tail.
10
+
11
+ This module adds a *gated* graph-expansion step on top of ``hybrid_search``:
12
+
13
+ 1. **Gate (query-intrinsic).** Detect whether the query is "multi-hop-shaped"
14
+ — it names **>= 2 distinct entities** (people / companies / decisions /
15
+ projects / concepts) that exist as notes. A ubiquitous hub entity
16
+ (``contoso`` — mentioned by almost every query in this corpus) does NOT count
17
+ toward the threshold, though it may still seed the walk. Single-hop
18
+ queries fail the gate and are returned by flat ``hybrid_search`` UNCHANGED
19
+ — so single-hop latency and quality can never regress (the graph code is
20
+ never even reached).
21
+
22
+ 2. **Seed.** Seeds = the entity notes named in the query, UNION the top few
23
+ flat hits (the notes flat retrieval already judged relevant).
24
+
25
+ 3. **Expand.** Wikilink-BFS + Personalized-PageRank from the seeds
26
+ (``brain.graph``) — DISCOVERY-ONLY (RET-03): the derived graph is never
27
+ authoritative, it only nominates candidate note ids.
28
+
29
+ 4. **Re-rank (flat-dominant weighted RRF).** Fuse the flat ranking with the
30
+ graph ranking. The flat list carries weight 1.0; the graph list a smaller
31
+ ``graph_weight`` (<= 1). This is deliberately conservative: the top flat
32
+ hit can never be displaced by a discovery-only candidate, and a graph-only
33
+ note (one flat never retrieved) lands in the tail — but a note that flat
34
+ retrieved *weakly* (rank 11-30) AND the graph corroborates gets promoted
35
+ into the top-k. That promotion of a graph-corroborated flat-tail note is
36
+ the multi-hop recall/nDCG mechanism.
37
+
38
+ The graph and the entity lexicon are built once and cached on the owning index
39
+ (both are derived from the immutable ``notes`` table), so the per-query cost of
40
+ the multi-hop path is a walk over an in-memory graph, and the per-query cost of
41
+ the single-hop path is one cheap regex scan over ~250 entity surface forms.
42
+ """
43
+ from __future__ import annotations
44
+
45
+ import re
46
+ import sqlite3
47
+ import unicodedata
48
+ from dataclasses import dataclass
49
+
50
+ from .graph import (
51
+ LinkGraph,
52
+ personalized_pagerank,
53
+ wikilink_bfs,
54
+ )
55
+
56
+ # Entity-typed notes whose titles are the multi-hop "hop" vocabulary. These are
57
+ # the ``type:`` frontmatter values the projection assigns to People / Companies
58
+ # / Decisions / Projects / Concepts notes.
59
+ ENTITY_TYPES: tuple[str, ...] = ("person", "company", "decision", "project", "concept")
60
+
61
+ # Ubiquitous hub entities: mentioned by nearly every query in this corpus, so
62
+ # they are not DISCRIMINATING evidence of multi-hop intent. They may still seed
63
+ # the graph walk, but they do not count toward the >= 2 gate.
64
+ HUB_STOPLIST: frozenset[str] = frozenset({"contoso"})
65
+
66
+ # Minimum surface-form length — avoids matching 2-3 char acronyms that collide
67
+ # with common substrings (word-boundary anchored, but still noisy when short).
68
+ _MIN_FORM_LEN = 4
69
+
70
+ # Generic single-word concept titles that are too common to be entity evidence.
71
+ _GENERIC_FORMS: frozenset[str] = frozenset(
72
+ {"governance", "legal", "budget", "roadmap", "strategy", "security",
73
+ "compliance", "risk", "data", "cloud", "digital"}
74
+ )
75
+
76
+
77
+ def _norm(s: str | None) -> str:
78
+ return unicodedata.normalize("NFC", (s or "")).lower().strip()
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class EntityMention:
83
+ note_id: str
84
+ surface: str
85
+ etype: str
86
+ is_hub: bool
87
+
88
+
89
+ class EntityLexicon:
90
+ """Query -> entity-note mentions, built from the entity-typed notes.
91
+
92
+ Cheap to query (regex scan over the compiled surface forms), built once."""
93
+
94
+ __slots__ = ("_forms",)
95
+
96
+ def __init__(self, forms: list[tuple[re.Pattern[str], str, str, str, bool]]):
97
+ self._forms = forms
98
+
99
+ @classmethod
100
+ def build(cls, conn: sqlite3.Connection) -> "EntityLexicon":
101
+ placeholders = ",".join("?" * len(ENTITY_TYPES))
102
+ rows = conn.execute(
103
+ f"SELECT id, title, type FROM notes WHERE type IN ({placeholders})",
104
+ ENTITY_TYPES,
105
+ ).fetchall()
106
+ forms: list[tuple[re.Pattern[str], str, str, str, bool]] = []
107
+ for nid, title, etype in rows:
108
+ surface = _norm(title)
109
+ if len(surface) < _MIN_FORM_LEN or surface in _GENERIC_FORMS:
110
+ continue
111
+ pat = re.compile(r"(?<!\w)" + re.escape(surface) + r"(?!\w)")
112
+ forms.append((pat, nid, surface, etype, surface in HUB_STOPLIST))
113
+ # Longest surface first so a specific "s/4hana" is preferred over a bare
114
+ # substring when both would match (mentions() de-dups by note id anyway).
115
+ forms.sort(key=lambda t: -len(t[2]))
116
+ return cls(forms)
117
+
118
+ def mentions(self, query: str) -> list[EntityMention]:
119
+ """Distinct entity notes whose surface form appears in the query."""
120
+ q = _norm(query)
121
+ seen: dict[str, EntityMention] = {}
122
+ for pat, nid, surface, etype, is_hub in self._forms:
123
+ if nid in seen:
124
+ continue
125
+ if pat.search(q):
126
+ seen[nid] = EntityMention(nid, surface, etype, is_hub)
127
+ return list(seen.values())
128
+
129
+
130
+ def is_multihop_shaped(mentions: list[EntityMention]) -> bool:
131
+ """True iff the query names >= 2 distinct NON-HUB entity notes.
132
+
133
+ This is the deployable, query-intrinsic gate: a multi-hop question is one
134
+ that references at least two specific entities whose relationship the graph
135
+ can traverse. Hub entities (``contoso``) are excluded from the count so that
136
+ "<hub> + one incidental entity" single-hop queries do not trip the gate."""
137
+ non_hub = {m.note_id for m in mentions if not m.is_hub}
138
+ return len(non_hub) >= 2
139
+
140
+
141
+ def rank_graph_candidates(
142
+ graph: LinkGraph,
143
+ seeds: list[str],
144
+ *,
145
+ depth: int = 2,
146
+ limit: int = 30,
147
+ ) -> list[str]:
148
+ """Ordered discovery-only candidate note ids from a PREBUILT graph.
149
+
150
+ Mirrors ``graph.graph_expand`` but takes an already-built graph so the eval
151
+ harness can reuse one graph across every query. Ranks by
152
+ ``(ppr desc, hops asc, id)`` and drops the seeds themselves."""
153
+ known = [s for s in dict.fromkeys(seeds) if s in graph.nodes]
154
+ if not known:
155
+ return []
156
+ bfs = wikilink_bfs(graph, known, depth=depth)
157
+ hops = {d["id"]: d["hops"] for d in bfs}
158
+ ppr = personalized_pagerank(graph, known)
159
+ seed_set = set(known)
160
+ cands = (set(hops) | {n for n, s in ppr.items() if s > 0.0}) - seed_set
161
+ ranked = sorted(cands, key=lambda n: (-ppr.get(n, 0.0), hops.get(n, 99), n))
162
+ return ranked[:limit]
163
+
164
+
165
+ def fuse_flat_and_graph(
166
+ flat_ids: list[str],
167
+ graph_ids: list[str],
168
+ *,
169
+ rrf_k: int = 60,
170
+ graph_weight: float = 0.5,
171
+ ) -> list[tuple[str, float]]:
172
+ """Flat-dominant weighted Reciprocal Rank Fusion.
173
+
174
+ ``score(id) = 1/(rrf_k + rank_flat) + graph_weight * 1/(rrf_k + rank_graph)``
175
+
176
+ With ``graph_weight <= 1`` the flat list dominates: the flat rank-1 note
177
+ (contribution ``1/(rrf_k+1)``) can never be overtaken by a graph-ONLY note
178
+ (max contribution ``graph_weight/(rrf_k+1)``). A note present in BOTH lists
179
+ accumulates from both and is promoted. Returns ``[(id, score)]`` descending;
180
+ ties broken by flat order (stable)."""
181
+ scores: dict[str, float] = {}
182
+ order: dict[str, int] = {}
183
+ for rank, nid in enumerate(flat_ids, start=1):
184
+ scores[nid] = scores.get(nid, 0.0) + 1.0 / (rrf_k + rank)
185
+ order.setdefault(nid, rank)
186
+ for rank, nid in enumerate(graph_ids, start=1):
187
+ scores[nid] = scores.get(nid, 0.0) + graph_weight * (1.0 / (rrf_k + rank))
188
+ order.setdefault(nid, 10_000 + rank)
189
+ return sorted(scores.items(), key=lambda kv: (-kv[1], order[kv[0]]))
190
+
191
+
192
+ def graph_augmented_ranking(
193
+ query: str,
194
+ flat_ids: list[str],
195
+ lexicon: EntityLexicon,
196
+ graph: LinkGraph,
197
+ *,
198
+ depth: int = 2,
199
+ graph_weight: float = 0.5,
200
+ rrf_k: int = 60,
201
+ seed_flat_top: int = 3,
202
+ candidate_limit: int = 30,
203
+ ) -> tuple[bool, list[str], dict]:
204
+ """Return ``(fired, ranked_ids, trace)``.
205
+
206
+ ``fired`` is False for single-hop queries — then ``ranked_ids == flat_ids``
207
+ unchanged. When fired, seeds = named entities UNION the top ``seed_flat_top``
208
+ flat hits; graph candidates are fused into the flat ranking (flat-dominant)."""
209
+ mentions = lexicon.mentions(query)
210
+ fired = is_multihop_shaped(mentions)
211
+ trace: dict = {
212
+ "fired": fired,
213
+ "entities": [m.surface for m in mentions],
214
+ "non_hub_entities": [m.surface for m in mentions if not m.is_hub],
215
+ }
216
+ if not fired:
217
+ return False, list(flat_ids), trace
218
+
219
+ entity_seeds = [m.note_id for m in mentions]
220
+ seeds = list(dict.fromkeys(entity_seeds + flat_ids[:seed_flat_top]))
221
+ graph_ids = rank_graph_candidates(
222
+ graph, seeds, depth=depth, limit=candidate_limit
223
+ )
224
+ trace["seeds"] = seeds
225
+ trace["graph_candidates"] = graph_ids
226
+ if not graph_ids:
227
+ # No reachable neighbourhood — nothing to fuse; flat order stands.
228
+ return True, list(flat_ids), trace
229
+ fused = fuse_flat_and_graph(
230
+ flat_ids, graph_ids, rrf_k=rrf_k, graph_weight=graph_weight
231
+ )
232
+ return True, [nid for nid, _ in fused], trace
brain/notes.py ADDED
@@ -0,0 +1,195 @@
1
+ """Note model + vault scanning. Markdown files are the single source of truth."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import re
6
+ import unicodedata
7
+ import warnings
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import Any, Iterator
11
+
12
+ from . import frontmatter
13
+
14
+
15
+ def sha256_text(text: str) -> str:
16
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
17
+
18
+
19
+ # TMP-02: bitemporal frontmatter (ADR-0003 Ruling 2) — mirrors the id-resolution
20
+ # tools/validate.py already does (link_id) so a "[[id]]"/"[[id|alias]]" wikilink
21
+ # value indexes the same as a bare id.
22
+ _WIKILINK = re.compile(r"^\[\[([^\]|]+)(?:\|[^\]]*)?\]\]$")
23
+
24
+
25
+ def _bitemporal_link(val: object) -> str:
26
+ if not isinstance(val, str) or not val.strip():
27
+ return ""
28
+ m = _WIKILINK.match(val.strip())
29
+ return m.group(1).strip() if m else val.strip()
30
+
31
+
32
+ def _bitemporal_bool(val: object) -> str:
33
+ """Normalize a raw frontmatter bool to "true"/"false"/"" (unset) for the
34
+ index column — never a Python bool, so SQL equality stays trivial."""
35
+ if isinstance(val, bool):
36
+ return "true" if val else "false"
37
+ if isinstance(val, str):
38
+ v = val.strip().lower()
39
+ if v in ("true", "yes"):
40
+ return "true"
41
+ if v in ("false", "no"):
42
+ return "false"
43
+ return ""
44
+
45
+
46
+ # Bare-slug charset for note ids that become filesystem paths (kebab slugs like
47
+ # `arctic-embed-choice`, `2026-06-27-arctic-benchmark`, `draft-ab12cd34ef56`).
48
+ # No separators, no leading dot, and '..' is rejected explicitly below.
49
+ _SLUG_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*")
50
+
51
+
52
+ def safe_slug(ident: object) -> str:
53
+ """Validate an (untrusted) note id as a bare, path-safe slug — fail closed.
54
+
55
+ Trust-boundary guard (C-1/C-2): an id from --id or untrusted YAML
56
+ frontmatter becomes ``<dir>/<id>.md``, so anything but a bare slug
57
+ (separators, '..', absolute paths, control chars, empty) is a traversal
58
+ vector and is REFUSED with ValueError — never silently renamed.
59
+
60
+ The id is NFC-normalized BEFORE validation so a decomposed variant cannot
61
+ reconstitute '..' or '/' after the check (fullwidth forms are not in the
62
+ allowed charset, so they are rejected outright).
63
+ """
64
+ s = unicodedata.normalize("NFC", str(ident))
65
+ if not s or ".." in s or _SLUG_RE.fullmatch(s) is None:
66
+ raise ValueError(
67
+ f"unsafe note id {ident!r}: must be a bare slug "
68
+ "([A-Za-z0-9._-], no leading '.', no '/', no '..')"
69
+ )
70
+ return s
71
+
72
+
73
+ @dataclass
74
+ class Note:
75
+ id: str
76
+ title: str
77
+ type: str
78
+ classification: str # RAW frontmatter value (may be missing -> "" ; filter applies default-deny)
79
+ zone: str # "raw" | "brain"
80
+ path: Path
81
+ body: str
82
+ meta: dict[str, Any] = field(default_factory=dict)
83
+ created: str = ""
84
+ updated: str = ""
85
+ sha256: str = ""
86
+ content_hash: str = "" # sha256 of the FULL on-disk file text (change detection)
87
+ # TMP-02 bitemporal keys (ADR-0003 Ruling 2) — all optional, "" when absent.
88
+ document_date: str = ""
89
+ effective_date: str = ""
90
+ superseded_date: str = ""
91
+ is_latest_version: str = "" # "true" | "false" | "" (unset -> treated as current)
92
+ superseded_by: str = ""
93
+ previous_version: str = "" # previous_version, falling back to `replaces`
94
+
95
+ def to_row(self) -> dict[str, Any]:
96
+ return {
97
+ "id": self.id,
98
+ "title": self.title,
99
+ "type": self.type,
100
+ "classification": self.classification,
101
+ "zone": self.zone,
102
+ "path": self.path.as_posix(),
103
+ "created": self.created,
104
+ "updated": self.updated,
105
+ "sha256": self.sha256,
106
+ "content_hash": self.content_hash,
107
+ "body": self.body,
108
+ "document_date": self.document_date,
109
+ "effective_date": self.effective_date,
110
+ "superseded_date": self.superseded_date,
111
+ "is_latest_version": self.is_latest_version,
112
+ "superseded_by": self.superseded_by,
113
+ "previous_version": self.previous_version,
114
+ }
115
+
116
+
117
+ def _zone_of(path: Path, vault: Path) -> str:
118
+ try:
119
+ rel = path.relative_to(vault)
120
+ except ValueError:
121
+ return "brain"
122
+ return rel.parts[0] if rel.parts else "brain"
123
+
124
+
125
+ def load_note(path: Path, vault: Path) -> Note | None:
126
+ try:
127
+ text = path.read_text(encoding="utf-8")
128
+ except (UnicodeDecodeError, OSError) as exc:
129
+ # H-3: a single bad-encoding (or unreadable) file must not abort a
130
+ # whole-vault rebuild/sync. Skip it with a warning rather than
131
+ # silently mangling its content (no errors="replace") — an excluded
132
+ # note is honest; a mojibake-indexed one is not.
133
+ warnings.warn(f"skipping unreadable note {path}: {exc}", stacklevel=2)
134
+ return None
135
+ meta, body = frontmatter.parse_text(text)
136
+ if not meta:
137
+ return None
138
+ zone = _zone_of(path, vault)
139
+ nid = str(meta.get("id") or path.stem)
140
+ return Note(
141
+ content_hash=sha256_text(text),
142
+ id=nid,
143
+ title=str(meta.get("title") or nid),
144
+ type=str(meta.get("type") or ("source" if zone == "raw" else "note")),
145
+ classification=str(meta.get("classification") or ""),
146
+ zone=zone,
147
+ path=path,
148
+ body=body,
149
+ meta=meta,
150
+ created=str(meta.get("created") or meta.get("captured") or ""),
151
+ updated=str(meta.get("updated") or meta.get("captured") or ""),
152
+ sha256=str(meta.get("sha256") or sha256_text(body)),
153
+ document_date=str(meta.get("document_date") or ""),
154
+ effective_date=str(meta.get("effective_date") or ""),
155
+ superseded_date=str(meta.get("superseded_date") or ""),
156
+ is_latest_version=_bitemporal_bool(meta.get("is_latest_version")),
157
+ superseded_by=_bitemporal_link(meta.get("superseded_by")),
158
+ previous_version=(_bitemporal_link(meta.get("previous_version"))
159
+ or _bitemporal_link(meta.get("replaces"))),
160
+ )
161
+
162
+
163
+ def scan_vault(vault: Path) -> Iterator[Note]:
164
+ """Yield every note under vault/, skipping the .brain/ runtime cache, the
165
+ top-level inbox/ drop zone, archived ingestion originals under
166
+ raw/originals/, and the generated backlinks.md."""
167
+ for p in sorted(vault.rglob("*.md")):
168
+ sp = p.as_posix()
169
+ if "/.brain/" in sp:
170
+ continue
171
+ try:
172
+ rel_parts = p.relative_to(vault).parts
173
+ except ValueError:
174
+ rel_parts = ()
175
+ # ADR-0003 Ruling 1: the ingestion drop zone is a visible top-level dir
176
+ # (not hidden like .brain/), but is never indexed — only the
177
+ # extracted raw/ source a handler promotes is a real note.
178
+ # C4: anchored to the vault-relative TOP-LEVEL path segment only — a
179
+ # prior unanchored "/inbox/" substring match wrongly excluded any note
180
+ # under a directory named "inbox" at ANY depth (e.g.
181
+ # brain/resources/inbox/reading-list.md), silently dropping it from
182
+ # the index.
183
+ if rel_parts and rel_parts[0] == "inbox":
184
+ continue
185
+ # C5: raw/originals/ holds the archived, immutable ORIGINAL file a
186
+ # handler claimed during ingestion (e.g. a promoted .md's own source
187
+ # copy) — it is evidence, never a real note, and must not be
188
+ # double-indexed alongside the promoted raw/<slug>.md note.
189
+ if rel_parts[:2] == ("raw", "originals"):
190
+ continue
191
+ if p.name == "backlinks.md":
192
+ continue
193
+ note = load_note(p, vault)
194
+ if note is not None:
195
+ yield note