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/init.py ADDED
@@ -0,0 +1,870 @@
1
+ """`brain init --full` — full first-run install orchestration (INS-02 / s09).
2
+
3
+ This EXTENDS the minimal PER-02 slice (`brain init --validate-overlay`, in
4
+ `brain.overlay` + `brain.cli`) into the single first-run command the installer
5
+ (`tools/cowork_workspace_install.sh` + the Intune package, INS-01) calls last:
6
+
7
+ 1. **Detect the client** from the trust role (host vs Cowork/VM).
8
+ 2. **Set up + validate the personalization overlay** — scaffold the generic
9
+ `overlay/{voice,brand,keywords,people}/` layer from the shipped template
10
+ when a category is empty (idempotent: never clobbers filled files), then
11
+ run the same shape validator the `--validate-overlay` slice uses.
12
+ 3. **Drive per-client scheduled-task registration** through the s07 registrar
13
+ (`scripts/register_tasks.py`):
14
+ - **host** → register the ONE sanctioned OS task directly (launchd /
15
+ Task Scheduler) via the registrar's host leg (read-only probe by
16
+ default; `--apply` actually invokes the idempotent installer script).
17
+ - **Cowork/VM** → PRINT the idempotent paste-prompt (the VM leg can
18
+ never write/register from its read+draft role — persistence-budget.md
19
+ locks the VM OS-scheduled count at 0), optionally saving it to a file.
20
+
21
+ Like `brain.overlay`, this module is **filesystem + subprocess only**: it never
22
+ constructs a `BrainCore` and never opens the index, so it works on a brand-new
23
+ install before any index exists. The `brain init` dispatch in `brain.cli` runs
24
+ BEFORE `BrainCore` construction for exactly this reason.
25
+
26
+ The s07 registrar is loaded by *file path* (importlib) rather than a package
27
+ import because `scripts/` is not part of the installed `brain` package. When the
28
+ registrar cannot be located (e.g. the bundled binary running far from the repo),
29
+ task registration degrades to a clear ``registrar_unavailable`` note rather than
30
+ crashing — overlay setup still completes, and the manifest path is surfaced so a
31
+ human can run the registrar by hand.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import datetime as _dt
36
+ import importlib.util
37
+ import os
38
+ import shutil
39
+ import subprocess
40
+ import sys
41
+ from pathlib import Path
42
+ from typing import Any
43
+
44
+ from . import config
45
+ from . import overlay as ov
46
+
47
+
48
+ # --------------------------------------------------------------------------
49
+ # repo / registrar / manifest / template discovery
50
+ # --------------------------------------------------------------------------
51
+
52
+ def packaged_assets_root() -> Path | None:
53
+ """The wheel-shipped scaffold/registration assets (PYP-02).
54
+
55
+ ``src/brain/_assets/`` MIRRORS the repo-root layout (``AGENTS.md``,
56
+ ``templates/``, ``overlay/template/``, ``routines/manifest.json``,
57
+ ``scripts/register_tasks.py`` + installer scripts), so every root-relative
58
+ resolution below works identically against a checkout or the installed
59
+ wheel. Synced by ``tools/package_clients.py`` — never hand-edited.
60
+ """
61
+ try:
62
+ from importlib.resources import files
63
+ root = Path(str(files("brain") / "_assets"))
64
+ except Exception: # pragma: no cover - stdlib present on >=3.9
65
+ return None
66
+ # ponytail: zipimport-backed installs (str() not a real path) fall through
67
+ # to the checkout; pip installs wheels unpacked, so this is the normal path.
68
+ if (root / "scripts" / "register_tasks.py").is_file():
69
+ return root
70
+ return None
71
+
72
+
73
+ def discover_repo_root() -> Path | None:
74
+ """Best-effort locate the scaffold/registration asset root.
75
+
76
+ Precedence: ``$BRAIN_REPO_ROOT`` (explicit override) > the wheel-shipped
77
+ ``brain/_assets`` mirror (importlib.resources — PYP-02 resolution order:
78
+ package resources first) > first ancestor of this file that carries
79
+ ``scripts/register_tasks.py`` (plain source checkout on ``sys.path``) >
80
+ ``None``.
81
+ """
82
+ env = os.environ.get("BRAIN_REPO_ROOT")
83
+ if env:
84
+ p = Path(env).expanduser()
85
+ if p.is_dir():
86
+ return p.resolve()
87
+ packaged = packaged_assets_root()
88
+ if packaged is not None:
89
+ return packaged
90
+ for parent in Path(__file__).resolve().parents:
91
+ if (parent / "scripts" / "register_tasks.py").exists():
92
+ return parent
93
+ return None
94
+
95
+
96
+ def load_registrar(repo_root: Path | None):
97
+ """Load ``scripts/register_tasks.py`` as a module (or ``None`` if absent)."""
98
+ if repo_root is None:
99
+ return None
100
+ path = repo_root / "scripts" / "register_tasks.py"
101
+ if not path.exists():
102
+ return None
103
+ spec = importlib.util.spec_from_file_location("brain._register_tasks", path)
104
+ if spec is None or spec.loader is None: # pragma: no cover - defensive
105
+ return None
106
+ mod = importlib.util.module_from_spec(spec)
107
+ spec.loader.exec_module(mod)
108
+ return mod
109
+
110
+
111
+ def resolve_manifest_path(
112
+ explicit: str | os.PathLike[str] | None,
113
+ repo_root: Path | None,
114
+ vault: str | os.PathLike[str] | None,
115
+ ) -> Path | None:
116
+ """Resolve the task manifest: explicit > ``$BRAIN_ROUTINES_MANIFEST`` >
117
+ ``<vault>/.brain/routines/manifest.json`` (installer-landed) >
118
+ ``<repo>/routines/manifest.json`` > ``None``."""
119
+ if explicit:
120
+ p = Path(explicit).expanduser()
121
+ return p if p.exists() else None
122
+ env = os.environ.get("BRAIN_ROUTINES_MANIFEST")
123
+ if env:
124
+ p = Path(env).expanduser()
125
+ if p.exists():
126
+ return p
127
+ try:
128
+ installed = config.brain_runtime_dir(vault) / "routines" / "manifest.json"
129
+ except Exception: # pragma: no cover - vault_root is stable
130
+ installed = None
131
+ if installed and installed.exists():
132
+ return installed
133
+ if repo_root is not None:
134
+ repo_manifest = repo_root / "routines" / "manifest.json"
135
+ if repo_manifest.exists():
136
+ return repo_manifest
137
+ return None
138
+
139
+
140
+ def resolve_template_dir(
141
+ explicit: str | os.PathLike[str] | None,
142
+ repo_root: Path | None,
143
+ ) -> Path | None:
144
+ """Resolve the overlay template dir: explicit > ``<repo>/overlay/template`` >
145
+ ``None`` (cannot scaffold)."""
146
+ if explicit:
147
+ p = Path(explicit).expanduser()
148
+ return p if p.is_dir() else None
149
+ if repo_root is not None:
150
+ tmpl = repo_root / "overlay" / "template"
151
+ if tmpl.is_dir():
152
+ return tmpl
153
+ return None
154
+
155
+
156
+ # --------------------------------------------------------------------------
157
+ # overlay scaffold (idempotent — never clobbers a filled category)
158
+ # --------------------------------------------------------------------------
159
+
160
+ def scaffold_overlay(overlay_dir: Path, template_dir: Path | None) -> dict[str, Any]:
161
+ """Copy template files into any EMPTY overlay category. Idempotent.
162
+
163
+ A category that already has ``*.md`` files is left untouched (``skipped``);
164
+ an empty/missing category is filled from ``template_dir/<category>/*.md``
165
+ (``created``). Returns a report; ``performed`` is False when no template is
166
+ available (scaffolding is impossible, not an error — a user may fill the
167
+ overlay by hand).
168
+ """
169
+ if template_dir is None:
170
+ return {"performed": False, "reason": "no template dir available",
171
+ "created": [], "skipped": []}
172
+ created: list[str] = []
173
+ skipped: list[str] = []
174
+ for cat in ov.CATEGORIES:
175
+ dst = overlay_dir / cat
176
+ existing = list(dst.glob("*.md")) if dst.is_dir() else []
177
+ if existing:
178
+ skipped.append(cat)
179
+ continue
180
+ src = template_dir / cat
181
+ if not src.is_dir():
182
+ continue
183
+ dst.mkdir(parents=True, exist_ok=True)
184
+ for f in sorted(src.glob("*.md")):
185
+ target = dst / f.name
186
+ if not target.exists():
187
+ shutil.copy2(f, target)
188
+ created.append(f"{cat}/{f.name}")
189
+ return {"performed": True, "template_dir": str(template_dir),
190
+ "created": created, "skipped": skipped}
191
+
192
+
193
+ # --------------------------------------------------------------------------
194
+ # ONB-02: seed a brand-new (empty) vault with generic sample notes
195
+ # --------------------------------------------------------------------------
196
+ # Fully generic content -- zero proper nouns (the release contamination scan
197
+ # is a hard gate). Plain filesystem writes, same posture as scaffold_overlay
198
+ # above: installer scaffolding, not captured content, so it never needs to go
199
+ # through the audited write_note path (a hand-authored note added directly to
200
+ # vault/brain/ is always valid -- Markdown+YAML is the substrate's single
201
+ # source of truth, the index is a derived cache). Host-only: the VM leg never
202
+ # writes directly into vault/brain/ even out-of-band (AGENTS.md §6 write
203
+ # split) -- run_full_init below skips this on client == "cowork".
204
+ _GENERATED_BRAIN_FILENAMES = {"backlinks.md", "catalog.md"}
205
+
206
+
207
+ def _existing_brain_note_count(vault: str | os.PathLike[str] | None) -> int:
208
+ """Notes under ``vault/brain/`` excluding the top-level index.md and any
209
+ generated file (backlinks.md, catalog.md) -- the "is this vault actually
210
+ empty" check ``seed_sample_notes`` gates on."""
211
+ brain_dir = config.vault_root(vault) / "brain"
212
+ if not brain_dir.is_dir():
213
+ return 0
214
+ count = 0
215
+ for p in brain_dir.rglob("*.md"):
216
+ if p.name in _GENERATED_BRAIN_FILENAMES:
217
+ continue
218
+ if p.name == "index.md" and p.parent == brain_dir:
219
+ continue
220
+ count += 1
221
+ return count
222
+
223
+
224
+ def _sample_notes(today: str) -> dict[str, str]:
225
+ """``id -> full Markdown content`` for the 3 seeded sample notes: a
226
+ welcome note (shape), a ``concept`` note (type + Counter-Arguments
227
+ section), and their wikilinked partner (the "linked pair")."""
228
+ return {
229
+ "welcome-to-your-second-brain": f"""---
230
+ id: welcome-to-your-second-brain
231
+ title: "Welcome to your second brain"
232
+ type: note
233
+ classification: Internal
234
+ created: {today}
235
+ updated: {today}
236
+ tags: []
237
+ ---
238
+
239
+ # Welcome to your second brain
240
+
241
+ This is a sample note showing the note shape every file under `vault/brain/`
242
+ follows: YAML frontmatter (an `id`, a `title`, a `type`, a `classification`,
243
+ and `created`/`updated` dates) up top, then a Markdown body below the second
244
+ `---`.
245
+
246
+ Notes stay flat inside their PARA folder (`projects/`, `areas/`,
247
+ `resources/`, `archive/`) -- no nesting, no numbering. Structure comes from
248
+ **wikilinks**, not folders or tags: see [[example-linked-note]] for a small
249
+ worked pair, and [[example-concept]] for the `concept` note type.
250
+
251
+ Delete these three sample notes whenever you like -- they exist only to show
252
+ the shape before you write your own.
253
+ """,
254
+ "example-concept": f"""---
255
+ id: example-concept
256
+ title: "Example concept note"
257
+ type: concept
258
+ classification: Internal
259
+ created: {today}
260
+ updated: {today}
261
+ tags: []
262
+ ---
263
+
264
+ # Example concept note
265
+
266
+ ## Definition
267
+
268
+ A `concept` note captures one idea worth naming and reusing across other
269
+ notes -- a definition, a mental model, a recurring pattern.
270
+
271
+ ## Context & Application
272
+
273
+ Link to a concept from wherever the idea applies, instead of re-explaining it
274
+ each time. See [[welcome-to-your-second-brain]] for the note-shape overview
275
+ this sample set demonstrates.
276
+
277
+ ## Counter-Arguments
278
+
279
+ Reasons this concept might be wrong, incomplete, or context-dependent -- a
280
+ concept note without this section is warn-flagged by the validator as a
281
+ quality nudge. This sample note's own counter-argument: it isn't a real
282
+ concept, just a placeholder.
283
+
284
+ ## Related Concepts
285
+
286
+ [[example-linked-note]]
287
+
288
+ ## Sources
289
+ """,
290
+ "example-linked-note": f"""---
291
+ id: example-linked-note
292
+ title: "Example linked note"
293
+ type: note
294
+ classification: Internal
295
+ created: {today}
296
+ updated: {today}
297
+ tags: []
298
+ ---
299
+
300
+ # Example linked note
301
+
302
+ This note and [[welcome-to-your-second-brain]] link to each other -- a small
303
+ worked example of the wikilink-first structure this vault uses instead of
304
+ folders or tags. It also links to [[example-concept]] to show a `note`
305
+ pointing at a `concept`.
306
+ """,
307
+ }
308
+
309
+
310
+ def _sample_index(today: str) -> str:
311
+ """A minimal top-level ``brain/index.md`` -- ``tools/validate.py`` hard-
312
+ requires this file to exist (``vault/brain/index.md missing`` is an
313
+ error, not a warning), and nothing else in the install path creates it
314
+ for a genuinely brand-new vault, so seeding is the one place that can
315
+ satisfy that gate. Create-if-absent only (see ``seed_sample_notes``) --
316
+ never overwrites an owner's own index.md."""
317
+ return f"""---
318
+ id: index
319
+ title: "Index"
320
+ type: index
321
+ classification: Internal
322
+ created: {today}
323
+ updated: {today}
324
+ tags: []
325
+ ---
326
+
327
+ # Index
328
+
329
+ Map of this vault. Start here, then follow the wikilinks.
330
+
331
+ ## Sample notes
332
+
333
+ - [[welcome-to-your-second-brain]] -- the note shape (frontmatter, PARA folders, wikilinks)
334
+ - [[example-concept]] -- the `concept` note type
335
+ - [[example-linked-note]] -- a small wikilinked pair
336
+ """
337
+
338
+
339
+ def seed_sample_notes(vault: str | os.PathLike[str] | None) -> dict[str, Any]:
340
+ """Write the 3 sample notes into ``vault/brain/resources/`` -- ONLY when
341
+ the vault carries no real notes yet (idempotent: a second run against a
342
+ now-populated vault is always a no-op, never a clobber). Also writes a
343
+ minimal top-level ``brain/index.md`` create-if-absent (never overwrites
344
+ an existing one) so the freshly seeded vault passes
345
+ ``tools/validate.py``'s hard ``index.md missing`` gate."""
346
+ v = config.vault_root(vault)
347
+ existing = _existing_brain_note_count(v)
348
+ if existing > 0:
349
+ return {"performed": False,
350
+ "reason": f"vault/brain/ already has {existing} note(s)",
351
+ "created": []}
352
+ today = _dt.date.today().isoformat()
353
+ brain_dir = v / "brain"
354
+ dest_dir = brain_dir / "resources"
355
+ dest_dir.mkdir(parents=True, exist_ok=True)
356
+ # tools/validate.py hard-requires vault/raw/ to exist too (`vault/raw/
357
+ # missing` is an error) -- an empty dir is a no-op to create and nothing
358
+ # else in the install path creates it for a genuinely brand-new vault.
359
+ (v / "raw").mkdir(parents=True, exist_ok=True)
360
+ created: list[str] = []
361
+
362
+ index_path = brain_dir / "index.md"
363
+ if not index_path.exists():
364
+ index_path.write_text(_sample_index(today), encoding="utf-8")
365
+ created.append("index.md")
366
+
367
+ for note_id, content in _sample_notes(today).items():
368
+ path = dest_dir / f"{note_id}.md"
369
+ if path.exists(): # defensive: the emptiness gate above already
370
+ continue # implies these shouldn't exist yet
371
+ path.write_text(content, encoding="utf-8")
372
+ created.append(f"resources/{note_id}.md")
373
+ return {"performed": True, "created": created}
374
+
375
+
376
+ # --------------------------------------------------------------------------
377
+ # ONB-01: brain init --full --import-from <dir> -- guided first ingest
378
+ # --------------------------------------------------------------------------
379
+ # Stages an external folder (e.g. an existing Obsidian vault) into
380
+ # vault/inbox/ and drives the STANDARD host ingest drain
381
+ # (brain.ingest.pipeline.run_ingest via BrainCore.ingest_dropzone) -- reuses
382
+ # the existing pipeline verbatim, never forks it. Host-only: refused
383
+ # (role_forbidden) before any filesystem side effect -- ingest_dropzone would
384
+ # refuse a VM leg anyway (BrainCore._require_host), but the check here runs
385
+ # BEFORE even the read-only dry-run scan, so a VM leg never touches the
386
+ # import folder at all.
387
+ #
388
+ # [HARDENED:codex] import safety: realpath-resolved overlap check in BOTH
389
+ # directions, symlinks never followed, a dry-run manifest gate (file count +
390
+ # bytes + per-extension breakdown) that requires explicit confirmation before
391
+ # anything is staged, and a default file-count/byte-size cap.
392
+ DEFAULT_IMPORT_FILE_CAP = 5000
393
+ DEFAULT_IMPORT_BYTES_CAP = 500 * 1024 * 1024
394
+
395
+
396
+ class ImportSafetyError(ValueError):
397
+ """``--import-from`` failed a pre-flight safety check; nothing was staged."""
398
+
399
+
400
+ def _realpath(p: str | os.PathLike[str]) -> Path:
401
+ return Path(os.path.realpath(str(Path(p).expanduser())))
402
+
403
+
404
+ def validate_import_overlap(
405
+ import_dir: str | os.PathLike[str], vault: str | os.PathLike[str] | None,
406
+ ) -> None:
407
+ """Reject either direction of overlap between ``import_dir`` and the
408
+ resolved vault root.
409
+
410
+ - ``import_dir`` inside (or equal to) the vault: would re-ingest the
411
+ vault's own content (including its own ``inbox/``).
412
+ - the vault inside ``import_dir``: the self-copy bomb -- the moment
413
+ staging starts writing into ``vault/inbox/``, that new content becomes
414
+ part of the very traversal source being walked.
415
+ """
416
+ imp = _realpath(import_dir)
417
+ vlt = _realpath(config.vault_root(vault))
418
+ if not imp.is_dir():
419
+ raise ImportSafetyError(f"--import-from {imp} is not a directory")
420
+ try:
421
+ imp.relative_to(vlt)
422
+ except ValueError:
423
+ pass
424
+ else:
425
+ raise ImportSafetyError(
426
+ f"--import-from {imp} is inside (or equal to) the vault {vlt}; refusing")
427
+ try:
428
+ vlt.relative_to(imp)
429
+ except ValueError:
430
+ pass
431
+ else:
432
+ raise ImportSafetyError(
433
+ f"the vault {vlt} is inside --import-from {imp}; refusing -- this "
434
+ "is the self-copy bomb (vault/inbox/ would become part of the "
435
+ "traversal source once staging starts writing into it)")
436
+
437
+
438
+ def scan_import_dir(import_dir: str | os.PathLike[str]) -> dict[str, Any]:
439
+ """Read-only walk of ``import_dir``: never follows a symlinked file or
440
+ directory (HARDENED:codex). Returns a dry-run manifest -- file count,
441
+ total bytes, per-extension breakdown -- plus the internal file list
442
+ ``stage_import_files`` consumes to actually copy."""
443
+ imp = Path(import_dir)
444
+ files: list[tuple[Path, int]] = []
445
+ total_bytes = 0
446
+ by_extension: dict[str, int] = {}
447
+ for root, dirnames, filenames in os.walk(imp, followlinks=False):
448
+ root_path = Path(root)
449
+ dirnames[:] = [d for d in dirnames if not (root_path / d).is_symlink()]
450
+ for name in filenames:
451
+ fp = root_path / name
452
+ if fp.is_symlink():
453
+ continue
454
+ try:
455
+ size = fp.stat().st_size
456
+ except OSError:
457
+ continue
458
+ rel = fp.relative_to(imp)
459
+ files.append((rel, size))
460
+ total_bytes += size
461
+ ext = fp.suffix.lower() or "(none)"
462
+ by_extension[ext] = by_extension.get(ext, 0) + 1
463
+ return {
464
+ "import_dir": str(imp), "file_count": len(files), "total_bytes": total_bytes,
465
+ "by_extension": by_extension, "_files": files,
466
+ }
467
+
468
+
469
+ def check_import_caps(
470
+ manifest: dict[str, Any], *, force: bool,
471
+ file_cap: int | None = None, bytes_cap: int | None = None,
472
+ ) -> None:
473
+ # Resolved from the module globals AT CALL TIME (not as bound default
474
+ # values) so a caller (or a test) can monkeypatch
475
+ # DEFAULT_IMPORT_FILE_CAP/DEFAULT_IMPORT_BYTES_CAP and have it take effect.
476
+ if file_cap is None:
477
+ file_cap = DEFAULT_IMPORT_FILE_CAP
478
+ if bytes_cap is None:
479
+ bytes_cap = DEFAULT_IMPORT_BYTES_CAP
480
+ if force:
481
+ return
482
+ if manifest["file_count"] > file_cap:
483
+ raise ImportSafetyError(
484
+ f"{manifest['file_count']} files exceeds the default cap ({file_cap}); "
485
+ "pass --import-force to override")
486
+ if manifest["total_bytes"] > bytes_cap:
487
+ raise ImportSafetyError(
488
+ f"{manifest['total_bytes']} bytes exceeds the default cap ({bytes_cap}); "
489
+ "pass --import-force to override")
490
+
491
+
492
+ def build_import_dry_run(
493
+ import_from: str | os.PathLike[str], vault: str | os.PathLike[str] | None,
494
+ *, force: bool = False,
495
+ ) -> dict[str, Any]:
496
+ """Pre-flight: overlap + symlink-safe scan + cap check. Pure read-only
497
+ filesystem inspection -- never stages or ingests anything."""
498
+ validate_import_overlap(import_from, vault)
499
+ manifest = scan_import_dir(import_from)
500
+ check_import_caps(manifest, force=force)
501
+ return manifest
502
+
503
+
504
+ def _flatten_relpath(rel: Path) -> str:
505
+ """The ingest drain only scans the inbox ROOT (never recurses), so a
506
+ nested import (e.g. an Obsidian vault's subfolders) is flattened into one
507
+ filename per file -- joined with '__' so the original path stays visible
508
+ and collisions across sibling subfolders are vanishingly unlikely (the
509
+ dest-uniquification below is the actual guarantee)."""
510
+ return "__".join(rel.parts) if len(rel.parts) > 1 else rel.parts[0]
511
+
512
+
513
+ def _unique_inbox_dest(inbox: Path, name: str) -> Path:
514
+ stem, suffix = Path(name).stem, Path(name).suffix
515
+ dest = inbox / name
516
+ i = 0
517
+ while dest.exists():
518
+ i += 1
519
+ dest = inbox / f"{stem}.{i}{suffix}"
520
+ return dest
521
+
522
+
523
+ def stage_import_files(
524
+ manifest: dict[str, Any], import_from: str | os.PathLike[str],
525
+ vault: str | os.PathLike[str] | None,
526
+ ) -> list[str]:
527
+ """Copy (never move) every file the dry-run manifest found into
528
+ ``vault/inbox/``. The user's original folder is never touched."""
529
+ v = config.vault_root(vault)
530
+ imp = Path(import_from)
531
+ inbox = v / "inbox"
532
+ inbox.mkdir(parents=True, exist_ok=True)
533
+ staged: list[str] = []
534
+ for rel, _size in manifest["_files"]:
535
+ src = imp / rel
536
+ if src.is_symlink():
537
+ continue
538
+ dest = _unique_inbox_dest(inbox, _flatten_relpath(rel))
539
+ shutil.copy2(src, dest)
540
+ staged.append(dest.name)
541
+ return staged
542
+
543
+
544
+ def stage_and_ingest_import(
545
+ import_from: str | os.PathLike[str], vault: str | os.PathLike[str] | None,
546
+ role: str, *, force: bool = False,
547
+ ) -> dict[str, Any]:
548
+ """Stage ``import_from`` into ``vault/inbox/`` then run the STANDARD host
549
+ ingest drain (``BrainCore.ingest_dropzone`` -> ``ingest.pipeline.run_ingest``).
550
+
551
+ Refused on ``role != host`` BEFORE any filesystem side effect --
552
+ ``ingest_dropzone`` would refuse a VM leg on its own
553
+ (``BrainCore._require_host``), but that only fires after staging already
554
+ copied bytes into ``inbox/``; this check runs first so a VM leg never
555
+ touches the import folder or the vault at all (same fail-closed shape as
556
+ every other host-broker verb).
557
+ """
558
+ if role != config.ROLE_HOST:
559
+ raise PermissionError(
560
+ f"role={role!r} may not import + ingest a folder; this is a "
561
+ "host-broker privilege (the VM leg is read + draft only). "
562
+ "Run on the host.")
563
+ manifest = build_import_dry_run(import_from, vault, force=force)
564
+ staged = stage_import_files(manifest, import_from, vault)
565
+
566
+ from .core import BrainCore
567
+
568
+ core = BrainCore(vault=vault, role=role)
569
+ ingest_report = core.ingest_dropzone()
570
+ return {
571
+ "import_dir": manifest["import_dir"], "file_count": manifest["file_count"],
572
+ "total_bytes": manifest["total_bytes"], "by_extension": manifest["by_extension"],
573
+ "staged": len(staged), "ingest": ingest_report,
574
+ }
575
+
576
+
577
+ def render_import_dry_run(manifest: dict[str, Any]) -> str:
578
+ lines = [
579
+ f"import dry-run: {manifest['import_dir']}",
580
+ f" {manifest['file_count']} file(s), {manifest['total_bytes']} bytes total",
581
+ ]
582
+ for ext, n in sorted(manifest["by_extension"].items()):
583
+ lines.append(f" {ext}: {n}")
584
+ lines.append("re-run with --yes to stage into vault/inbox/ and run the ingest drain")
585
+ return "\n".join(lines)
586
+
587
+
588
+ # --------------------------------------------------------------------------
589
+ # orchestration
590
+ # --------------------------------------------------------------------------
591
+
592
+ def detect_client(role: str) -> str:
593
+ """Map the trust role to a client label. VM/Cowork is the read+draft leg."""
594
+ return "cowork" if role == config.ROLE_VM else "host"
595
+
596
+
597
+ def _register_tasks(
598
+ *, client: str, registrar, manifest_path: Path | None, apply: bool,
599
+ save_cowork_prompt: str | os.PathLike[str] | None,
600
+ ) -> dict[str, Any]:
601
+ """Drive per-client registration through the s07 registrar (or degrade)."""
602
+ if manifest_path is None:
603
+ return {"registrar": "skipped", "reason": "no task manifest found",
604
+ "hint": "pass --manifest <path> or land routines/manifest.json"}
605
+ if registrar is None:
606
+ return {"registrar": "unavailable",
607
+ "manifest": str(manifest_path),
608
+ "reason": "scripts/register_tasks.py not found from this install; "
609
+ "run it by hand against the manifest above",
610
+ "hint": f"python3 scripts/register_tasks.py "
611
+ f"--client {client} --manifest {manifest_path}"}
612
+
613
+ manifest = registrar.load_manifest(manifest_path)
614
+ out: dict[str, Any] = {"registrar": "available", "manifest": str(manifest_path),
615
+ "client": client}
616
+ if client == "host":
617
+ out["host"] = registrar.register_host_leg(manifest, apply=apply)
618
+ out["apply"] = apply
619
+ else: # cowork / vm — paste-prompt only, never host mutation
620
+ prompt = registrar.build_cowork_prompt(manifest)
621
+ out["cowork"] = {
622
+ "vm_eligible_tasks": [t["id"] for t in manifest["tasks"] if t.get("vm_eligible")],
623
+ "prompt": prompt,
624
+ }
625
+ if save_cowork_prompt:
626
+ dest = Path(save_cowork_prompt)
627
+ dest.parent.mkdir(parents=True, exist_ok=True)
628
+ dest.write_text(prompt, encoding="utf-8")
629
+ out["cowork"]["saved_to"] = str(dest)
630
+ return out
631
+
632
+
633
+ def _build_index(vault: str | os.PathLike[str] | None) -> dict[str, Any]:
634
+ """Build the derived index for a freshly-seeded vault via a subprocess.
635
+
636
+ ONB fix (2026-07-11): ``seed_sample_notes`` writes notes to ``vault/brain/``
637
+ but nothing indexed them, so `brain init --full --apply` left a vault where
638
+ the very first `brain search` returned zero hits (the documented "init then
639
+ search" quickstart was broken). We shell out to `brain rebuild` rather than
640
+ constructing a ``BrainCore`` here so this module stays index-free and light
641
+ (importing BrainCore would pull the embedder into every `brain init`, even a
642
+ dry-run/scaffold) — matching the module's "filesystem + subprocess only"
643
+ contract. Invoked via ``python -m brain`` (see ``brain/__main__.py``) so it
644
+ is PATH-independent. Soft-fails: a rebuild error is reported, never aborts
645
+ init (a box without the embedder can still scaffold; the user reruns
646
+ `brain rebuild` once the engine is whole).
647
+ """
648
+ argv = [sys.executable, "-m", "brain"]
649
+ if vault is not None:
650
+ argv += ["--vault", str(vault)]
651
+ argv.append("rebuild")
652
+ try:
653
+ proc = subprocess.run(argv, capture_output=True, text=True, timeout=600)
654
+ except Exception as exc: # subprocess spawn failure, timeout, etc.
655
+ return {"performed": False, "ok": False,
656
+ "reason": f"{type(exc).__name__}: {exc}"}
657
+ if proc.returncode == 0:
658
+ return {"performed": True, "ok": True}
659
+ return {"performed": True, "ok": False,
660
+ "reason": f"rebuild exit {proc.returncode}",
661
+ "stderr": (proc.stderr or "").strip()[-500:]}
662
+
663
+
664
+ def run_full_init(
665
+ *,
666
+ vault: str | os.PathLike[str] | None,
667
+ overlay_dir: str | os.PathLike[str] | None,
668
+ role: str,
669
+ scaffold: bool = True,
670
+ template_dir: str | os.PathLike[str] | None = None,
671
+ register_tasks: bool = True,
672
+ apply: bool = False,
673
+ manifest: str | os.PathLike[str] | None = None,
674
+ save_cowork_prompt: str | os.PathLike[str] | None = None,
675
+ seed_vault: bool = True,
676
+ ) -> dict[str, Any]:
677
+ """Full `brain init` orchestration. Filesystem + subprocess only.
678
+
679
+ Returns a report dict with an ``ok`` verdict (overlay valid AND no hard
680
+ task-registration failure). Never raises on a malformed overlay or a missing
681
+ manifest — those surface as ``ok: false`` / a task note.
682
+ """
683
+ repo_root = discover_repo_root()
684
+ client = detect_client(role)
685
+ ol_dir = ov.overlay_dir(vault, overlay_dir)
686
+ tmpl = resolve_template_dir(template_dir, repo_root)
687
+
688
+ steps: list[str] = [f"client detected: {client} (role={role})"]
689
+
690
+ # ONB-02: seed 2-3 generic sample notes on a genuinely EMPTY vault, host
691
+ # only (the VM leg never writes directly into vault/brain/ -- AGENTS.md
692
+ # §6). Runs before the overlay scaffold; order between the two doesn't
693
+ # matter, both are idempotent filesystem-only steps.
694
+ if seed_vault and client == "host":
695
+ seed_report = seed_sample_notes(vault)
696
+ else:
697
+ seed_report = {"performed": False,
698
+ "reason": "disabled (--no-seed-vault)" if not seed_vault
699
+ else "seeding is host-only (vm role never writes "
700
+ "directly into vault/brain/)",
701
+ "created": []}
702
+ if seed_report["performed"]:
703
+ steps.append(f"vault seed: wrote {len(seed_report['created'])} sample note(s)")
704
+ else:
705
+ steps.append(f"vault seed: skipped ({seed_report['reason']})")
706
+
707
+ # ONB fix: a freshly SEEDED vault must be indexed on a real `--apply`
708
+ # install, or the first `brain search` returns nothing (the documented
709
+ # "init --apply then search" quickstart). Gated on --apply + host + seed
710
+ # actually performed: --apply is the "really install this" signal (a bare
711
+ # `brain init --full` stays a lighter scaffold — its docs carry an explicit
712
+ # `brain rebuild`), a dry-run builds no index, and a non-empty vault (seed
713
+ # skipped) keeps its own index rather than eating a surprise full re-embed
714
+ # on a re-run. Index build is a subprocess (keeps this module BrainCore-free)
715
+ # and soft-fails — a box without the embedder still inits, with a note to
716
+ # rerun `brain rebuild`.
717
+ if apply and client == "host" and seed_report["performed"]:
718
+ index_report = _build_index(vault)
719
+ if index_report["ok"]:
720
+ steps.append("index build: rebuilt (seeded notes are searchable)")
721
+ else:
722
+ steps.append(f"index build: FAILED ({index_report['reason']}) "
723
+ "— run `brain rebuild` once the engine is available")
724
+ else:
725
+ index_report = {"performed": False,
726
+ "reason": "no seeded notes to index" if apply
727
+ else "dry-run (no --apply)"}
728
+
729
+ overlay_report: dict[str, Any] = {"overlay_dir": str(ol_dir)}
730
+ if scaffold:
731
+ sc = scaffold_overlay(ol_dir, tmpl)
732
+ overlay_report["scaffold"] = sc
733
+ if sc["performed"]:
734
+ steps.append(
735
+ f"overlay scaffold: created {len(sc['created'])} file(s), "
736
+ f"skipped {len(sc['skipped'])} filled category(ies)")
737
+ else:
738
+ steps.append(f"overlay scaffold: skipped ({sc.get('reason')})")
739
+ else:
740
+ overlay_report["scaffold"] = {"performed": False, "reason": "disabled (--no-scaffold-overlay)",
741
+ "created": [], "skipped": []}
742
+ steps.append("overlay scaffold: disabled")
743
+
744
+ validation = ov.validate_overlay(ol_dir)
745
+ overlay_report["validation"] = validation
746
+ steps.append(f"overlay validation: {'valid' if validation['valid'] else 'INVALID'}")
747
+
748
+ # Provision the audit signing key BEFORE task registration so the nightly
749
+ # plist render resolves a real key instead of MISSING_KEY_DRAIN_WILL_SKIP.
750
+ # Create-if-absent only — provision_signing_key() never rotates. Host-only;
751
+ # soft-fails (reported, never aborts init) so a storeless CI box still inits.
752
+ key_report: dict[str, Any]
753
+ if client == "host" and apply:
754
+ from . import audit
755
+ try:
756
+ key_report = audit.provision_signing_key()
757
+ except Exception as exc:
758
+ key_report = {"status": "unavailable", "error": str(exc)}
759
+ steps.append(f"audit key: {key_report['status']}")
760
+ else:
761
+ key_report = {"status": "skipped (vm role or dry-run)"}
762
+
763
+ tasks_report: dict[str, Any]
764
+ if register_tasks:
765
+ manifest_path = resolve_manifest_path(manifest, repo_root, vault)
766
+ registrar = load_registrar(repo_root)
767
+ tasks_report = _register_tasks(
768
+ client=client, registrar=registrar, manifest_path=manifest_path,
769
+ apply=apply, save_cowork_prompt=save_cowork_prompt)
770
+ steps.append(f"task registration ({client}): registrar={tasks_report.get('registrar')}")
771
+ else:
772
+ tasks_report = {"registrar": "disabled"}
773
+ steps.append("task registration: disabled (--no-register-tasks)")
774
+
775
+ # `ok` is driven by overlay validity + a HARD task failure only. Registrar
776
+ # "unavailable"/"skipped" is a SOFT degradation (the report carries a hint to
777
+ # finish registration by hand) — the common case for the bundled binary
778
+ # running far from the repo, and NOT a reason to fail the whole install.
779
+ host_leg = tasks_report.get("host") or {}
780
+ apply_result = host_leg.get("apply_result")
781
+ task_hard_fail = (
782
+ isinstance(apply_result, dict) and apply_result.get("exit_code") not in (0, None)
783
+ )
784
+ ok = bool(validation["valid"]) and not task_hard_fail
785
+
786
+ return {
787
+ "action": "init-full",
788
+ "ok": ok,
789
+ "client": client,
790
+ "role": role,
791
+ "repo_root": str(repo_root) if repo_root else None,
792
+ "seed": seed_report,
793
+ "index": index_report,
794
+ "overlay": overlay_report,
795
+ "audit_key": key_report,
796
+ "tasks": tasks_report,
797
+ "steps": steps,
798
+ }
799
+
800
+
801
+ def render_human(report: dict[str, Any]) -> str:
802
+ """Compact human rendering of a run_full_init report."""
803
+ lines = [
804
+ f"brain init (full) — client={report['client']} role={report['role']}",
805
+ f"ok: {report['ok']}",
806
+ "",
807
+ ]
808
+ seed = report.get("seed") or {}
809
+ if seed.get("performed"):
810
+ lines.append(f"seed: wrote {len(seed['created'])} sample note(s)")
811
+ for c in seed["created"]:
812
+ lines.append(f" + {c}")
813
+ else:
814
+ lines.append(f"seed: not performed ({seed.get('reason')})")
815
+ imp = report.get("import")
816
+ if imp:
817
+ lines.append("")
818
+ lines.append(f"import: {imp['import_dir']}")
819
+ lines.append(f" staged {imp['staged']}/{imp['file_count']} file(s), "
820
+ f"{imp['total_bytes']} bytes")
821
+ ing = imp.get("ingest", {})
822
+ lines.append(f" ingest: {len(ing.get('processed', []))} processed, "
823
+ f"{len(ing.get('duplicates', []))} duplicate(s), "
824
+ f"{len(ing.get('quarantined', []))} quarantined")
825
+ lines.append("")
826
+ lines.append(f"overlay: {report['overlay']['overlay_dir']}")
827
+ sc = report["overlay"].get("scaffold", {})
828
+ if sc.get("performed"):
829
+ lines.append(f" scaffold: +{len(sc['created'])} created, "
830
+ f"{len(sc['skipped'])} category(ies) already filled")
831
+ for c in sc["created"]:
832
+ lines.append(f" + {c}")
833
+ else:
834
+ lines.append(f" scaffold: not performed ({sc.get('reason')})")
835
+ val = report["overlay"]["validation"]
836
+ lines.append(f" valid: {val['valid']}")
837
+ for cat, info in val["categories"].items():
838
+ status = "ok" if not info["issues"] else "ISSUES"
839
+ lines.append(f" {cat}/: {status} ({info['file_count']} file(s))")
840
+ for issue in info["issues"]:
841
+ lines.append(f" - {issue}")
842
+
843
+ t = report["tasks"]
844
+ lines.append("")
845
+ lines.append(f"tasks: registrar={t.get('registrar')}")
846
+ if t.get("manifest"):
847
+ lines.append(f" manifest: {t['manifest']}")
848
+ if "host" in t:
849
+ h = t["host"]
850
+ lines.append(f" host leg ({h.get('detected_os')}): task={h.get('task_id')} "
851
+ f"action={h.get('action')} apply={t.get('apply')}")
852
+ lines.append(f" already_registered: {h.get('already_registered')}")
853
+ lines.append(f" result: {h.get('apply_result')}")
854
+ s = h.get("synthesis")
855
+ if s:
856
+ lines.append(f" host leg task 2/2: task={s.get('task_id')}")
857
+ lines.append(f" already_registered: {s.get('already_registered')} "
858
+ f"({s.get('probe_detail')})")
859
+ if "cowork" in t:
860
+ c = t["cowork"]
861
+ lines.append(f" cowork leg: {len(c['vm_eligible_tasks'])} poke-only "
862
+ f"trigger(s) to register: {', '.join(c['vm_eligible_tasks'])}")
863
+ if c.get("saved_to"):
864
+ lines.append(f" paste-prompt saved to: {c['saved_to']}")
865
+ else:
866
+ lines.append(" (paste-prompt in the --json report; re-run with "
867
+ "--save-cowork-prompt <path> to write it out)")
868
+ if t.get("hint"):
869
+ lines.append(f" hint: {t['hint']}")
870
+ return "\n".join(lines)