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/connect.py ADDED
@@ -0,0 +1,362 @@
1
+ """``brain connect`` (SUI-02) — universal per-client wirer.
2
+
3
+ ``brain connect --client claude-code|claude-desktop|codex|gemini`` writes
4
+ that client's wiring itself instead of the human hand-copying four different
5
+ JSON/Markdown snippets from docs. HOST-ONLY (refused on ``role=vm`` at the
6
+ same CLI trust gate as ``supersede``/``ingest`` — see ``cli.py``
7
+ ``VM_ALLOWED``): this module never touches ``BrainCore`` at all, so there is
8
+ nothing role-gated inside it beyond that refusal.
9
+
10
+ Every client transform is a pure ``plan_*`` function (old text -> new text ->
11
+ unified diff -> ``already_connected`` flag) so it is unit-testable against
12
+ fixture files with zero writes to the real home directory. ``apply_*``
13
+ performs the actual write + first-mutation backup. The interactive
14
+ diff-then-confirm loop and ``--yes``/non-interactive exit-nonzero contract
15
+ live in ``cli.py`` (same shape as the existing ``init --import-from`` dry-run
16
+ gate), not here.
17
+
18
+ Two writers for the Desktop MCP stanza would be a bug (the hardening addendum
19
+ for this session is explicit: verified there is currently NO Desktop-config
20
+ writer anywhere in this codebase — ``mcp-config`` only prints). This module
21
+ is that ONE writer; ``cli.py``'s ``mcp-config`` stays print-only and unchanged.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import difflib
26
+ import json
27
+ import platform
28
+ import re
29
+ import shutil
30
+ import subprocess
31
+ import time
32
+ from dataclasses import dataclass, field
33
+ from pathlib import Path
34
+ from typing import Any, Callable, Optional
35
+
36
+ CLIENTS = ("claude-code", "claude-desktop", "codex", "gemini")
37
+
38
+ MARKETPLACE_NAME = "brainiac"
39
+ KERNEL_PLUGIN = "brainiac-kernel"
40
+ DEFAULT_MARKETPLACE_SOURCE = "Autopsias/brainiac" # README's documented add-source
41
+
42
+ # Marked block used for the two Markdown-append clients (claude-code's
43
+ # CLAUDE.md, codex's AGENTS.md) so a re-run detects "already connected" and
44
+ # --remove can strip exactly this block without touching anything else a
45
+ # human wrote in the file.
46
+ BLOCK_START = "<!-- brainiac:connect:begin -->"
47
+ BLOCK_END = "<!-- brainiac:connect:end -->"
48
+
49
+ BRAIN_USAGE_PARAGRAPH = (
50
+ "## Brain usage\n\n"
51
+ "Retrieval, capture, and indexing in this project are owned by the "
52
+ "**`brain` CLI** — call it from your native shell, never via MCP. Read "
53
+ "tools: `brain search \"<q>\" --json`, `brain get <id> --json`, "
54
+ "`brain recent --json`; capture with `brain draft-capture`. Every read "
55
+ "applies a classification egress filter before stdout. Run "
56
+ "`brain --help` for the always-current contract."
57
+ )
58
+
59
+
60
+ def _marked_block(body: str) -> str:
61
+ return f"{BLOCK_START}\n{body}\n{BLOCK_END}"
62
+
63
+
64
+ _BLOCK_RE = re.compile(
65
+ re.escape(BLOCK_START) + r"\n.*?\n" + re.escape(BLOCK_END) + r"\n?",
66
+ re.DOTALL,
67
+ )
68
+
69
+
70
+ # --------------------------------------------------------------------------
71
+ # Plan result — the diff-first contract every client transform returns
72
+ # --------------------------------------------------------------------------
73
+
74
+ @dataclass
75
+ class ConnectPlan:
76
+ client: str
77
+ target_path: Path
78
+ action: str # "noop" | "create" | "append" | "merge" | "remove"
79
+ old_text: Optional[str]
80
+ new_text: Optional[str]
81
+ already_connected: bool
82
+ diff: str
83
+ kind: str = "file" # "file" | "subprocess" (claude-code plugin install)
84
+ extra: dict = field(default_factory=dict)
85
+
86
+ @property
87
+ def has_changes(self) -> bool:
88
+ return self.action not in ("noop",)
89
+
90
+
91
+ def _diff(old: str, new: str, path: Path) -> str:
92
+ return "".join(difflib.unified_diff(
93
+ old.splitlines(keepends=True), new.splitlines(keepends=True),
94
+ fromfile=f"{path} (before)", tofile=f"{path} (after)"))
95
+
96
+
97
+ def _backup_path(path: Path) -> Path:
98
+ stamp = time.strftime("%Y%m%d", time.gmtime())
99
+ return path.with_name(path.name + f".bak-{stamp}")
100
+
101
+
102
+ def backup_original(path: Path) -> Optional[Path]:
103
+ """Back up ``path`` alongside itself on FIRST mutation only — a backup
104
+ that already exists (today's stamp) is left untouched so the original
105
+ pre-connect content is never clobbered by a second run's backup."""
106
+ if not path.exists():
107
+ return None
108
+ bak = _backup_path(path)
109
+ if not bak.exists():
110
+ shutil.copy2(path, bak)
111
+ return bak
112
+
113
+
114
+ def write_text(path: Path, text: str) -> None:
115
+ path.parent.mkdir(parents=True, exist_ok=True)
116
+ path.write_text(text, encoding="utf-8")
117
+
118
+
119
+ # --------------------------------------------------------------------------
120
+ # Markdown marked-block clients: claude-code's project CLAUDE.md, codex's
121
+ # project AGENTS.md
122
+ # --------------------------------------------------------------------------
123
+
124
+ def plan_marked_block(target_path: Path, body: str = BRAIN_USAGE_PARAGRAPH) -> ConnectPlan:
125
+ old_text = target_path.read_text(encoding="utf-8") if target_path.exists() else None
126
+ block = _marked_block(body)
127
+ if old_text is not None and BLOCK_START in old_text:
128
+ return ConnectPlan(
129
+ client="", target_path=target_path, action="noop",
130
+ old_text=old_text, new_text=old_text, already_connected=True, diff="")
131
+ base = old_text or ""
132
+ sep = "" if not base or base.endswith("\n\n") else ("\n" if base.endswith("\n") else "\n\n")
133
+ new_text = base + sep + block + "\n"
134
+ return ConnectPlan(
135
+ client="", target_path=target_path,
136
+ action="append" if old_text is not None else "create",
137
+ old_text=old_text, new_text=new_text, already_connected=False,
138
+ diff=_diff(old_text or "", new_text, target_path))
139
+
140
+
141
+ def apply_marked_block(plan: ConnectPlan) -> None:
142
+ if plan.old_text is not None:
143
+ backup_original(plan.target_path)
144
+ write_text(plan.target_path, plan.new_text or "")
145
+
146
+
147
+ def plan_remove_marked_block(target_path: Path) -> ConnectPlan:
148
+ if not target_path.exists():
149
+ return ConnectPlan(client="", target_path=target_path, action="noop",
150
+ old_text=None, new_text=None, already_connected=False, diff="")
151
+ old_text = target_path.read_text(encoding="utf-8")
152
+ new_text = _BLOCK_RE.sub("", old_text)
153
+ # collapse a leftover blank-line gap left by the removed separator
154
+ new_text = re.sub(r"\n{3,}\Z", "\n", new_text)
155
+ if new_text == old_text:
156
+ return ConnectPlan(client="", target_path=target_path, action="noop",
157
+ old_text=old_text, new_text=old_text,
158
+ already_connected=False, diff="")
159
+ return ConnectPlan(client="", target_path=target_path, action="remove",
160
+ old_text=old_text, new_text=new_text,
161
+ already_connected=False,
162
+ diff=_diff(old_text, new_text, target_path))
163
+
164
+
165
+ def apply_remove_marked_block(plan: ConnectPlan) -> None:
166
+ if plan.action != "remove":
167
+ return
168
+ backup_original(plan.target_path)
169
+ write_text(plan.target_path, plan.new_text or "")
170
+
171
+
172
+ # --------------------------------------------------------------------------
173
+ # JSON merge clients: claude-desktop's claude_desktop_config.json,
174
+ # gemini's .gemini/settings.json. Merge, never replace — preserve unknown
175
+ # top-level keys AND unrelated mcpServers entries.
176
+ # --------------------------------------------------------------------------
177
+
178
+ def _load_json(path: Path) -> dict:
179
+ if not path.exists():
180
+ return {}
181
+ text = path.read_text(encoding="utf-8").strip()
182
+ return json.loads(text) if text else {}
183
+
184
+
185
+ def _dump_json(data: dict) -> str:
186
+ return json.dumps(data, indent=2) + "\n"
187
+
188
+
189
+ def mcp_server_entry(vault: str, name: str = "brainiac", max_tier: str = "MNPI") -> dict:
190
+ """The SAME entry shape ``brain mcp-config`` prints (cli.py) — the one
191
+ place this shape is built; both the print-only command and this writer
192
+ call it so they can never drift apart."""
193
+ import sys as _sys
194
+
195
+ brain_mcp = shutil.which("brain-mcp") or str(Path(_sys.executable).parent / "brain-mcp")
196
+ env = {"BRAIN_VAULT": str(vault), "BRAIN_MAX_EGRESS_TIER": max_tier}
197
+ model_dir = Path(vault) / ".brain" / "model"
198
+ if model_dir.is_dir():
199
+ env["BRAIN_MODEL_CACHE"] = str(model_dir)
200
+ return {name: {"command": brain_mcp, "env": env}}
201
+
202
+
203
+ def claude_desktop_config_path() -> Path:
204
+ system = platform.system()
205
+ if system == "Darwin":
206
+ return Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json"
207
+ if system == "Windows":
208
+ import os
209
+
210
+ appdata = Path(os.environ.get("APPDATA", str(Path.home() / "AppData" / "Roaming")))
211
+ return appdata / "Claude" / "claude_desktop_config.json"
212
+ return Path.home() / ".config" / "Claude" / "claude_desktop_config.json" # linux fallback
213
+
214
+
215
+ def plan_claude_desktop(config_path: Path, vault: str, name: str = "brainiac",
216
+ max_tier: str = "MNPI") -> ConnectPlan:
217
+ old_data = _load_json(config_path)
218
+ old_text = json.dumps(old_data, indent=2, sort_keys=True) if config_path.exists() else None
219
+ entry = mcp_server_entry(vault, name, max_tier)
220
+ new_data = dict(old_data)
221
+ servers = dict(new_data.get("mcpServers") or {})
222
+ already = servers.get(name) == entry[name]
223
+ servers[name] = entry[name]
224
+ new_data["mcpServers"] = servers
225
+ new_text = _dump_json(new_data)
226
+ old_rendered = _dump_json(old_data) if config_path.exists() else ""
227
+ if already:
228
+ return ConnectPlan(client="claude-desktop", target_path=config_path,
229
+ action="noop", old_text=old_rendered, new_text=old_rendered,
230
+ already_connected=True, diff="")
231
+ return ConnectPlan(
232
+ client="claude-desktop", target_path=config_path,
233
+ action="merge" if config_path.exists() else "create",
234
+ old_text=old_rendered or None, new_text=new_text, already_connected=False,
235
+ diff=_diff(old_rendered, new_text, config_path))
236
+
237
+
238
+ def apply_json_merge(plan: ConnectPlan) -> None:
239
+ if plan.old_text:
240
+ backup_original(plan.target_path)
241
+ write_text(plan.target_path, plan.new_text or "")
242
+
243
+
244
+ def plan_gemini(settings_path: Path) -> ConnectPlan:
245
+ old_data = _load_json(settings_path)
246
+ old_rendered = _dump_json(old_data) if settings_path.exists() else ""
247
+ if old_data.get("contextFileName") == "AGENTS.md":
248
+ return ConnectPlan(client="gemini", target_path=settings_path, action="noop",
249
+ old_text=old_rendered, new_text=old_rendered,
250
+ already_connected=True, diff="")
251
+ new_data = dict(old_data)
252
+ new_data["contextFileName"] = "AGENTS.md"
253
+ new_text = _dump_json(new_data)
254
+ return ConnectPlan(
255
+ client="gemini", target_path=settings_path,
256
+ action="merge" if settings_path.exists() else "create",
257
+ old_text=old_rendered or None, new_text=new_text, already_connected=False,
258
+ diff=_diff(old_rendered, new_text, settings_path))
259
+
260
+
261
+ def plan_restore_from_backup(target_path: Path) -> dict:
262
+ """--remove for the two JSON-merge clients: restore the FIRST backup
263
+ (file.bak-<oldest date found>) rather than the marked-block strip used
264
+ for the Markdown clients — there's no marker inside JSON to strip."""
265
+ candidates = sorted(target_path.parent.glob(target_path.name + ".bak-*"))
266
+ if not candidates:
267
+ return {"ok": False, "reason": "no backup found; nothing to unwire",
268
+ "target": str(target_path)}
269
+ bak = candidates[0] # earliest stamp = closest to pre-connect original
270
+ return {"ok": True, "backup": str(bak), "target": str(target_path)}
271
+
272
+
273
+ def apply_restore_from_backup(target_path: Path, backup: Path) -> None:
274
+ shutil.copy2(backup, target_path)
275
+
276
+
277
+ # --------------------------------------------------------------------------
278
+ # claude-code: real plugin-CLI mutation (verified non-interactive in this
279
+ # session's recon — `claude plugin marketplace add` / `claude plugin install`
280
+ # both exist), THEN the CLAUDE.md marked-block append. If the `claude`
281
+ # binary/subcommand surface is missing, callers fall back to printing the
282
+ # two commands (see cli.py) instead of claiming a mutation that didn't happen.
283
+ # --------------------------------------------------------------------------
284
+
285
+ Runner = Callable[..., "subprocess.CompletedProcess[str]"]
286
+
287
+
288
+ def _default_runner(cmd: list[str], **kwargs: Any) -> "subprocess.CompletedProcess[str]":
289
+ kwargs.setdefault("capture_output", True)
290
+ kwargs.setdefault("text", True)
291
+ kwargs.setdefault("timeout", 120)
292
+ return subprocess.run(cmd, **kwargs)
293
+
294
+
295
+ def claude_code_plugin_commands(marketplace_source: str = DEFAULT_MARKETPLACE_SOURCE,
296
+ plugin: str = KERNEL_PLUGIN) -> list[list[str]]:
297
+ claude_bin = shutil.which("claude") or "claude"
298
+ return [
299
+ [claude_bin, "plugin", "marketplace", "add", marketplace_source],
300
+ [claude_bin, "plugin", "install", f"{plugin}@{MARKETPLACE_NAME}"],
301
+ ]
302
+
303
+
304
+ def claude_plugin_cli_available(run: Runner = _default_runner) -> bool:
305
+ if shutil.which("claude") is None:
306
+ return False
307
+ try:
308
+ out = run([shutil.which("claude"), "plugin", "--help"])
309
+ except Exception: # noqa: BLE001 — any probe failure means "not available"
310
+ return False
311
+ text = ((out.stdout or "") + (out.stderr or "")).lower()
312
+ return "marketplace" in text and "install" in text
313
+
314
+
315
+ def is_plugin_installed(claude_home: Path, plugin: str = KERNEL_PLUGIN) -> bool:
316
+ installed_json = claude_home / "plugins" / "installed_plugins.json"
317
+ if not installed_json.exists():
318
+ return False
319
+ try:
320
+ data = json.loads(installed_json.read_text(encoding="utf-8"))
321
+ except (json.JSONDecodeError, OSError):
322
+ return False
323
+ return bool((data.get("plugins") or {}).get(f"{plugin}@{MARKETPLACE_NAME}"))
324
+
325
+
326
+ def run_claude_code_plugin_install(
327
+ marketplace_source: str = DEFAULT_MARKETPLACE_SOURCE, plugin: str = KERNEL_PLUGIN,
328
+ run: Runner = _default_runner, claude_home: Optional[Path] = None,
329
+ ) -> dict:
330
+ claude_home = claude_home or (Path.home() / ".claude")
331
+ cmds = claude_code_plugin_commands(marketplace_source, plugin)
332
+ results = []
333
+ for cmd in cmds:
334
+ try:
335
+ out = run(cmd)
336
+ results.append({"cmd": cmd, "returncode": out.returncode,
337
+ "stdout": out.stdout, "stderr": out.stderr})
338
+ if out.returncode != 0:
339
+ return {"ok": False, "steps": results,
340
+ "reason": f"`{' '.join(cmd)}` exited {out.returncode}"}
341
+ except Exception as exc: # noqa: BLE001
342
+ results.append({"cmd": cmd, "error": f"{type(exc).__name__}: {exc}"})
343
+ return {"ok": False, "steps": results, "reason": str(exc)}
344
+ verified = is_plugin_installed(claude_home, plugin)
345
+ return {"ok": verified, "steps": results, "verified_installed": verified}
346
+
347
+
348
+ def run_claude_code_plugin_uninstall(
349
+ plugin: str = KERNEL_PLUGIN, run: Runner = _default_runner,
350
+ claude_home: Optional[Path] = None,
351
+ ) -> dict:
352
+ claude_home = claude_home or (Path.home() / ".claude")
353
+ claude_bin = shutil.which("claude") or "claude"
354
+ cmd = [claude_bin, "plugin", "uninstall", f"{plugin}@{MARKETPLACE_NAME}"]
355
+ try:
356
+ out = run(cmd)
357
+ except Exception as exc: # noqa: BLE001
358
+ return {"ok": False, "cmd": cmd, "error": f"{type(exc).__name__}: {exc}"}
359
+ still_installed = is_plugin_installed(claude_home, plugin)
360
+ return {"ok": out.returncode == 0, "cmd": cmd, "returncode": out.returncode,
361
+ "stdout": out.stdout, "stderr": out.stderr,
362
+ "verified_removed": not still_installed}
brain/context.py ADDED
@@ -0,0 +1,108 @@
1
+ """UPG-04 — Contextual Retrieval (Anthropic-style, index-time).
2
+
3
+ For each note, generate a ≤1-sentence document-level context that situates the
4
+ note within the corpus, and prepend it to every chunk before embedding (and to
5
+ the BM25 text). This is the technique Anthropic published (Sep 2024) as
6
+ "Contextual Retrieval": they reported a −49% retrieval-failure-rate reduction
7
+ for contextual embeddings alone, and −67% stacked with a cross-encoder reranker.
8
+
9
+ The context is generated ONCE per note at INDEX TIME by a small local CPU LLM
10
+ (Qwen2.5-0.5B, GGUF, via llama-cpp-python — no PyTorch, no GPU, no network).
11
+ Zero added query latency. Negligible index-size growth (one sentence per note).
12
+
13
+ This targets brain's documented failure modes (S10): atomic markdown notes and
14
+ cross-lingual entity-only chunks that are reachable by EN queries but lose the
15
+ note-level context that disambiguates them. The template-based prefix in
16
+ ``brain.chunk`` already does a lighter version of this (title + zone + heading);
17
+ UPG-04 adds the LLM's semantic summary of what the note is ABOUT.
18
+
19
+ GATE (revert if not met): retrieval-failure-rate drops on the 135-q blind set
20
+ with NO regression on the identifier/temporal strata (those queries are already
21
+ lexical-exact and should not be perturbed by added context). Gate-off if it
22
+ regresses.
23
+
24
+ OFFLINE / NO-LLM FALLBACK: if ``llama-cpp-python`` is not importable or no model
25
+ is bundled, ``doc_context`` returns "" and the index degrades cleanly to the
26
+ template-prefix-only path (the S10 behaviour). Contextual Retrieval is an
27
+ OPT-IN upgrade via ``$BRAIN_CONTEXTUAL_LLM`` (path to a GGUF); without it, the
28
+ feature is inert.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import os
33
+ from functools import lru_cache
34
+
35
+ # The prompt asks the LLM for a single-sentence summary of what the note is about,
36
+ # in the note's own language. The summary is prepended to every chunk at embed time.
37
+ # Kept deliberately short (Anthropic found 50-100 tokens optimal; we cap tighter).
38
+ _PROMPT_TMPL = (
39
+ "<|im_start|>system\nYou write a single concise sentence (max 30 words) "
40
+ "summarising what this document is about, for retrieval. Reply in the "
41
+ "document's language. Output ONLY the summary sentence, nothing else."
42
+ "<|im_end|>\n<|im_start|>user\nDocument title: {title}\nDocument zone: {zone}\n\n"
43
+ "Document content:\n{body}<|im_end|>\n<|im_start|>assistant\n"
44
+ )
45
+
46
+
47
+ @lru_cache(maxsize=1)
48
+ def _llm():
49
+ """Lazily load the local CPU LLM. Returns None if unavailable (clean fallback).
50
+
51
+ Resolves the model path from ``$BRAIN_CONTEXTUAL_LLM`` (a GGUF file). Without
52
+ it, contextual retrieval is inert (returns "") and the index degrades to the
53
+ template-prefix-only S10 path — never an error.
54
+ """
55
+ model_path = os.environ.get("BRAIN_CONTEXTUAL_LLM")
56
+ if not model_path:
57
+ return None
58
+ try:
59
+ from llama_cpp import Llama
60
+ except ImportError:
61
+ return None
62
+ try:
63
+ return Llama(
64
+ model_path=model_path,
65
+ n_ctx=2048, # notes are chunked; 2K ctx is enough for the body slice
66
+ n_gpu_layers=0, # CPU-only by design
67
+ verbose=False,
68
+ n_threads=max(1, (os.cpu_count() or 4) // 2), # don't saturate all cores
69
+ )
70
+ except Exception:
71
+ return None
72
+
73
+
74
+ def doc_context(title: str, zone: str, body: str, *, max_body_chars: int = 1600) -> str:
75
+ """Generate a ≤1-sentence document-level context for ``Contextual Retrieval``.
76
+
77
+ Returns "" when the LLM is unavailable (the clean no-op fallback). The body
78
+ is truncated to ``max_body_chars`` to bound the LLM prompt — the first ~1.6K
79
+ chars (title + abstract + first sections) carry enough signal for a one-line
80
+ summary, and this keeps the per-note generation cost to a few seconds on CPU.
81
+ """
82
+ llm = _llm()
83
+ if llm is None:
84
+ return ""
85
+ body_slice = (body or "")[:max_body_chars]
86
+ prompt = _PROMPT_TMPL.format(
87
+ title=(title or "(untitled)")[:120],
88
+ zone=(zone or "brain"),
89
+ body=body_slice,
90
+ )
91
+ try:
92
+ out = llm(
93
+ prompt,
94
+ max_tokens=60, # ~one sentence
95
+ temperature=0.0, # deterministic summary
96
+ stop=["<|im_end|>", "\n\n", "."], # stop at first sentence end
97
+ )
98
+ text = out["choices"][0]["text"].strip().strip('"').strip()
99
+ # collapse to a single line and cap length
100
+ text = " ".join(text.split())[:200]
101
+ return text + "." if text and not text.endswith(".") else text
102
+ except Exception:
103
+ return ""
104
+
105
+
106
+ def contextual_available() -> bool:
107
+ """True iff the contextual LLM is configured and loadable."""
108
+ return _llm() is not None