brainiac-cli 0.18.0__tar.gz → 0.18.2__tar.gz

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 (88) hide show
  1. {brainiac_cli-0.18.0/src/brainiac_cli.egg-info → brainiac_cli-0.18.2}/PKG-INFO +1 -1
  2. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/pyproject.toml +1 -1
  3. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/_assets/scripts/brain-brief.sh +8 -0
  4. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/_version.py +1 -1
  5. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/core.py +22 -0
  6. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/cos.py +68 -1
  7. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/embed.py +36 -6
  8. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/spine.py +14 -1
  9. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2/src/brainiac_cli.egg-info}/PKG-INFO +1 -1
  10. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/LICENSE +0 -0
  11. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/MANIFEST.in +0 -0
  12. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/README.md +0 -0
  13. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/setup.cfg +0 -0
  14. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/__init__.py +0 -0
  15. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/__main__.py +0 -0
  16. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/_assets/AGENTS.md +0 -0
  17. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/_assets/overlay/template/brand/brand-guide.md +0 -0
  18. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/_assets/overlay/template/cos/priorities.md +0 -0
  19. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/_assets/overlay/template/keywords/glossary.md +0 -0
  20. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/_assets/overlay/template/people/roster.md +0 -0
  21. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/_assets/overlay/template/voice/voice-profile.md +0 -0
  22. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/_assets/routines/manifest.json +0 -0
  23. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/_assets/scripts/brain-brief-mac.plist +0 -0
  24. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/_assets/scripts/brain-synthesis-mac.plist +0 -0
  25. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/_assets/scripts/brain-synthesis.sh +0 -0
  26. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/_assets/scripts/install-brief-mac.sh +0 -0
  27. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/_assets/scripts/install-brief-windows.ps1 +0 -0
  28. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/_assets/scripts/register_tasks.py +0 -0
  29. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/_assets/templates/company.md +0 -0
  30. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/_assets/templates/concept.md +0 -0
  31. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/_assets/templates/daily.md +0 -0
  32. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/_assets/templates/decision.md +0 -0
  33. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/_assets/templates/meeting.md +0 -0
  34. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/_assets/templates/person.md +0 -0
  35. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/_assets/templates/project.md +0 -0
  36. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/_assets/templates/state-moc.md +0 -0
  37. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/anchor.py +0 -0
  38. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/audit.py +0 -0
  39. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/backup.py +0 -0
  40. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/brief.py +0 -0
  41. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/capture.py +0 -0
  42. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/chunk.py +0 -0
  43. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/classification.py +0 -0
  44. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/cli.py +0 -0
  45. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/config.py +0 -0
  46. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/connect.py +0 -0
  47. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/context.py +0 -0
  48. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/doctor.py +0 -0
  49. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/egress.py +0 -0
  50. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/encryption.py +0 -0
  51. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/frontmatter.py +0 -0
  52. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/golden_probe.py +0 -0
  53. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/graph.py +0 -0
  54. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/graphify.py +0 -0
  55. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/inbox.py +0 -0
  56. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/index.py +0 -0
  57. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/ingest/__init__.py +0 -0
  58. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/ingest/handlers/__init__.py +0 -0
  59. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/ingest/handlers/base.py +0 -0
  60. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/ingest/handlers/docx.py +0 -0
  61. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/ingest/handlers/email.py +0 -0
  62. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/ingest/handlers/html.py +0 -0
  63. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/ingest/handlers/image.py +0 -0
  64. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/ingest/handlers/pdf.py +0 -0
  65. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/ingest/handlers/pptx.py +0 -0
  66. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/ingest/handlers/tables.py +0 -0
  67. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/ingest/handlers/text.py +0 -0
  68. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/ingest/handlers/xlsx.py +0 -0
  69. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/ingest/handlers/zip.py +0 -0
  70. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/ingest/pipeline.py +0 -0
  71. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/ingest/transcript.py +0 -0
  72. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/init.py +0 -0
  73. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/maintenance.py +0 -0
  74. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/mcp_adapter.py +0 -0
  75. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/multihop.py +0 -0
  76. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/notes.py +0 -0
  77. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/overlay.py +0 -0
  78. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/projection.py +0 -0
  79. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/rerank.py +0 -0
  80. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/retro.py +0 -0
  81. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/snapshot.py +0 -0
  82. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/update.py +0 -0
  83. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brain/vectors.py +0 -0
  84. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brainiac_cli.egg-info/SOURCES.txt +0 -0
  85. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brainiac_cli.egg-info/dependency_links.txt +0 -0
  86. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brainiac_cli.egg-info/entry_points.txt +0 -0
  87. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brainiac_cli.egg-info/requires.txt +0 -0
  88. {brainiac_cli-0.18.0 → brainiac_cli-0.18.2}/src/brainiac_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: brainiac-cli
3
- Version: 0.18.0
3
+ Version: 0.18.2
4
4
  Summary: Brainiac — local any-LLM second-brain core engine + brain CLI (Markdown truth, derived SQLite index).
5
5
  License-Expression: Apache-2.0
6
6
  Requires-Python: >=3.9
@@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta"
8
8
  # check. The IMPORT package stays `brain` and the console script stays
9
9
  # `brain` — only the distribution/project name changes.
10
10
  name = "brainiac-cli"
11
- version = "0.18.0"
11
+ version = "0.18.2"
12
12
  description = "Brainiac — local any-LLM second-brain core engine + brain CLI (Markdown truth, derived SQLite index)."
13
13
  readme = "README.md"
14
14
  license = "Apache-2.0"
@@ -63,6 +63,14 @@ fi
63
63
 
64
64
  {
65
65
  echo "=== brain-daily-brief / brain-nightly $(date -u +%Y-%m-%dT%H:%M:%SZ) ==="
66
+ # cos-broker FIRST: the host broker fold claims VM proposal drops, releases due
67
+ # holds, runs auto-capture, enqueues the owner batch, and renders
68
+ # shared/spine-summary.md. Nothing else schedules it -- without this line the
69
+ # VM proposes into a drop dir no one drains (measured 2026-07-16: spine DB empty,
70
+ # candidates unclaimed ~29h) . It runs BEFORE maintain so notes it signs are
71
+ # indexed + published by the same run's sync. Host-only, idempotent, and
72
+ # NON-FATAL: a broker failure must never cost the vault its capture-drain floor.
73
+ "$BRAIN_BIN" cos-broker --json || echo "brain-brief: cos-broker fold failed (non-fatal), continuing to maintain"
66
74
  # maintain: sync --publish (drain pending captures + reconcile index +
67
75
  # republish snapshot) + brief, THEN whichever of health/integrity/digest is
68
76
  # due today (date-gated). graphify is documented-only (no build invoked --
@@ -9,4 +9,4 @@ pip-installed host, importlib.metadata stays primary; brain/__init__.py reads
9
9
  this file only as the fallback before 0.0.0+unknown.
10
10
  """
11
11
 
12
- __version__ = "0.18.0"
12
+ __version__ = "0.18.2"
@@ -482,6 +482,28 @@ class BrainCore:
482
482
  except ValueError as exc:
483
483
  skipped.append({"draft": draft.name, "reason": f"unsafe-id (fail-closed): {exc}"})
484
484
  continue
485
+ # COS owner-gate integrity: never sign a draft whose id is still
486
+ # awaiting the owner's accept/reject in the proposal pipeline.
487
+ # The gated copy is authoritative; only the owner's answer may
488
+ # promote it (consume_answers moves it here itself on accept).
489
+ # Quarantined rather than skipped-in-place — see
490
+ # cos.quarantine_gate_bypass for why leaving it would let a
491
+ # later REJECT still leak the content on the next drain.
492
+ try:
493
+ from . import cos as _cos_mod
494
+ if nid in _cos_mod.undecided_proposal_ids(self.vault):
495
+ dest = _cos_mod.quarantine_gate_bypass(self.vault, draft)
496
+ skipped.append({
497
+ "draft": draft.name,
498
+ "reason": f"gate-bypass: {nid!r} is awaiting the owner's "
499
+ f"accept/reject — quarantined to {dest.parent.name}/{dest.name}",
500
+ })
501
+ continue
502
+ except Exception as exc: # noqa: BLE001 — never break the drain floor
503
+ skipped.append({"draft": draft.name,
504
+ "reason": f"gate-check-failed (fail-closed, left in place): "
505
+ f"{type(exc).__name__}: {exc}"})
506
+ continue
485
507
  # raw source -> raw/<id>.md ; otherwise a brain note -> resources/.
486
508
  if note.type == "source" or note.zone == "raw":
487
509
  rel, subtree = f"raw/{nid}.md", "raw"
@@ -413,6 +413,49 @@ def claim_drops(vault, now: _dt.datetime | None = None) -> dict[str, Any]:
413
413
 
414
414
 
415
415
  # -- proposal state helpers ----------------------------------------------------
416
+ def undecided_proposal_ids(vault) -> set[str]:
417
+ """Candidate ids the owner has NOT yet ruled on — staged in the VM's
418
+ proposal drop, or claimed into host ``pending/``. Both states mean "the
419
+ owner's answer is still outstanding".
420
+
421
+ A capture draft carrying one of these ids is a GATE BYPASS: the same
422
+ content is simultaneously travelling the gated route (cos-propose ->
423
+ broker -> owner batch -> selective commit) and the UNGATED one
424
+ (draft-capture -> capture-inbox -> signed on the next drain). The ungated
425
+ one always wins the race, so the owner gets asked to approve a note that is
426
+ already authoritative in the vault, and a "reject" has nothing to reject.
427
+
428
+ Measured 2026-07-16 (run 14/15): the COS skill forbids SUBSTITUTING
429
+ draft-capture for cos-propose in Phase 1.6, but Phase 5 separately requires
430
+ draft-capture for anything the owner must see. A finding that is also an
431
+ ingestion candidate satisfies both rules and bypasses the gate — no rule
432
+ violated. Policy cannot fix a collision between two obeyed rules; the
433
+ engine must.
434
+ """
435
+ ids: set[str] = set()
436
+ for d in (proposal_drop_dir(vault), proposals_dir(vault) / "pending"):
437
+ if d.is_dir():
438
+ ids.update(p.stem for p in d.glob("*.md"))
439
+ return ids
440
+
441
+
442
+ def quarantine_gate_bypass(vault, draft: Path, *, now: _dt.datetime | None = None) -> Path:
443
+ """Move a bypassing capture draft out of the drain's path, reversibly.
444
+
445
+ NOT deleted (recoverable, auditable) and NOT left in place: leaving it
446
+ would re-offer it to every subsequent drain, and — the real hazard — a
447
+ later owner REJECT clears the gated copy out of ``pending/``, after which
448
+ the leftover draft no longer matches an undecided id and the next drain
449
+ would sign the very content the owner just rejected.
450
+ """
451
+ dest_dir = host_dir(vault) / "gate-bypass"
452
+ dest_dir.mkdir(parents=True, exist_ok=True)
453
+ stamp = (now or _utcnow()).strftime("%Y%m%dT%H%M%S")
454
+ dest = dest_dir / f"{stamp}-{draft.name}"
455
+ shutil.move(str(draft), dest)
456
+ return dest
457
+
458
+
416
459
  def _pending_metas(vault) -> list[dict[str, Any]]:
417
460
  pending = proposals_dir(vault) / "pending"
418
461
  out = []
@@ -1587,7 +1630,16 @@ def ingest_sweep(vault, *, downloads_dir: Path | str | None = None,
1587
1630
  continue
1588
1631
  fname = next((n for n in safe if (ddir / n).exists()), None)
1589
1632
  if fname is None:
1590
- report["unmatched"].extend(safe) # unclaimed — retry next run
1633
+ # WHY matters: "not in the downloads dir at all" is a different
1634
+ # problem from "present but rejected by a guard", and the old
1635
+ # bare-name list could not tell them apart — the 2026-07-16
1636
+ # field read was "sweeper stalled ~32h" when the sweeper was in
1637
+ # fact correctly refusing stale namesakes. Reasons are additive:
1638
+ # the plain names stay in `unmatched` for compatibility.
1639
+ report["unmatched"].extend(safe)
1640
+ report.setdefault("unmatched_reasons", []).extend(
1641
+ {"filename": n, "reason": "not present in the downloads dir"}
1642
+ for n in safe)
1591
1643
  continue
1592
1644
  cand = ddir / fname
1593
1645
  if cand.is_symlink() or not cand.is_file():
@@ -1605,11 +1657,26 @@ def ingest_sweep(vault, *, downloads_dir: Path | str | None = None,
1605
1657
  if isinstance(want, int) and want > 0:
1606
1658
  if abs(size - want) > max(want * INGEST_SWEEP_SIZE_TOLERANCE, 4096):
1607
1659
  report["unmatched"].append(fname) # wrong file — leave it
1660
+ report.setdefault("unmatched_reasons", []).append({
1661
+ "filename": fname,
1662
+ "reason": f"size mismatch: on disk {size}B, manifest expects "
1663
+ f"{want}B (tolerance "
1664
+ f"{int(max(want * INGEST_SWEEP_SIZE_TOLERANCE, 4096))}B) "
1665
+ f"— a DIFFERENT file of the same name",
1666
+ })
1608
1667
  continue
1609
1668
  ets = _parse_ts(str(entry.get("ts", "")))
1610
1669
  if ets is not None and cand.stat().st_mtime < (
1611
1670
  ets.timestamp() - INGEST_SWEEP_SKEW_SECONDS):
1612
1671
  report["unmatched"].append(fname) # older than the download
1672
+ age_h = (ets.timestamp() - cand.stat().st_mtime) / 3600.0
1673
+ report.setdefault("unmatched_reasons", []).append({
1674
+ "filename": fname,
1675
+ "reason": f"stale namesake: file mtime is {age_h:.1f}h OLDER than the "
1676
+ f"manifest's download ts {ets.isoformat()} (skew allowance "
1677
+ f"{INGEST_SWEEP_SKEW_SECONDS}s) — the VM's download did not "
1678
+ f"land; this is a pre-existing file with the same name",
1679
+ })
1613
1680
  continue
1614
1681
  dest = _unique_dest(inbox, fname)
1615
1682
  if not dry_run:
@@ -210,8 +210,8 @@ class ArcticEmbedder:
210
210
  from fastembed import TextEmbedding
211
211
  except Exception as exc: # pragma: no cover - exercised when absent
212
212
  raise EmbedderUnavailable(
213
- "fastembed/onnxruntime not importable; install the 'embed' "
214
- "extra or bundle the model"
213
+ f"fastembed/onnxruntime not importable; install the 'embed' "
214
+ f"extra or bundle the model: {type(exc).__name__}: {exc}"
215
215
  ) from exc
216
216
  self._model = TextEmbedding(
217
217
  model_name=self.model_id, cache_dir=self._cache_dir,
@@ -315,7 +315,8 @@ class CatalogEmbedder:
315
315
  from fastembed import TextEmbedding
316
316
  except Exception as exc: # pragma: no cover
317
317
  raise EmbedderUnavailable(
318
- "fastembed/onnxruntime not importable for CatalogEmbedder"
318
+ f"fastembed/onnxruntime not importable for CatalogEmbedder: "
319
+ f"{type(exc).__name__}: {exc}"
319
320
  ) from exc
320
321
  self._register_custom()
321
322
  self._model = TextEmbedding(
@@ -470,7 +471,8 @@ class OnnxEmbedder:
470
471
  from tokenizers import Tokenizer
471
472
  except Exception as exc: # pragma: no cover
472
473
  raise EmbedderUnavailable(
473
- "onnxruntime/tokenizers not importable for OnnxEmbedder"
474
+ f"onnxruntime/tokenizers not importable for OnnxEmbedder: "
475
+ f"{type(exc).__name__}: {exc}"
474
476
  ) from exc
475
477
  try:
476
478
  onnx_path, base = self._resolve_model_files()
@@ -721,14 +723,42 @@ _HASH_FALLBACK_MSG = (
721
723
  )
722
724
 
723
725
 
726
+ def embedder_unavailable_reason() -> str:
727
+ """Probe WHY no real embedder resolved and report the ACTUAL error.
728
+
729
+ The old message guessed ("onnxruntime/tokenizers missing OR the model is
730
+ absent") because both ``available()`` and the raise sites discard the
731
+ exception. Field cost (2026-07-16): the Cowork VM reported the identical
732
+ opaque line for SEVEN consecutive runs — every one grounding on lexical
733
+ search only, every dedup `inconclusive` — and no operator could tell a
734
+ missing package from an ABI mismatch from a missing model without shell
735
+ access to the VM. An error that cannot distinguish its own causes cannot
736
+ be fixed remotely; that is the defect this repairs.
737
+
738
+ Carries the interpreter version deliberately: the vendored VM wheels are
739
+ built for one CPython minor (`vendor_semantic_deps.py` pins cp311), so an
740
+ image whose python moved yields `undefined symbol` / `No module named` —
741
+ indistinguishable from "never installed" unless the version is printed.
742
+ """
743
+ parts: list[str] = [f"python {sys.version.split()[0]}"]
744
+ for mod in ("onnxruntime", "tokenizers"):
745
+ try:
746
+ __import__(mod)
747
+ except Exception as exc: # noqa: BLE001 — the reason IS the payload
748
+ parts.append(f"{mod}: {type(exc).__name__}: {exc}")
749
+ else:
750
+ parts.append(f"{mod}: ok")
751
+ return "; ".join(parts)
752
+
753
+
724
754
  def _implicit_hash_fallback() -> Embedder:
725
755
  """Return HashEmbedder for the IMPLICIT auto-path, but never silently:
726
756
  fail closed when the operator demanded a real embedder, else warn loudly."""
727
757
  if os.environ.get("BRAIN_REQUIRE_REAL_EMBEDDER"):
728
758
  raise EmbedderUnavailable(
729
759
  "BRAIN_REQUIRE_REAL_EMBEDDER is set but no real semantic embedder is "
730
- "available (onnxruntime/tokenizers missing or e5-small model absent). "
731
- "Refusing to degrade to the non-semantic HashEmbedder."
760
+ "available. Refusing to degrade to the non-semantic HashEmbedder. "
761
+ f"Probe: {embedder_unavailable_reason()}"
732
762
  )
733
763
  print(_HASH_FALLBACK_MSG, file=sys.stderr, flush=True)
734
764
  return HashEmbedder()
@@ -55,6 +55,19 @@ def _utcnow() -> _dt.datetime:
55
55
  return _dt.datetime.now(_dt.timezone.utc)
56
56
 
57
57
 
58
+ def _json_safe(v: Any) -> Any:
59
+ """The event log is JSON text, but callers hand us values straight out of
60
+ YAML frontmatter, where an unquoted `due: 2026-07-17` parses as a
61
+ `datetime.date` — not JSON-serializable. Normalize dates to ISO at this one
62
+ boundary: every source (ingestion candidates, `brain cos-spine record`,
63
+ calendar follow-ups) routes through record_event, and the reducer/radar
64
+ read `due` back as ISO text either way.
65
+ """
66
+ if isinstance(v, (_dt.date, _dt.datetime)):
67
+ return v.isoformat()
68
+ return v
69
+
70
+
58
71
  def _ts(dt: _dt.datetime | None = None) -> str:
59
72
  return (dt or _utcnow()).strftime("%Y-%m-%dT%H:%M:%SZ")
60
73
 
@@ -227,7 +240,7 @@ def record_event(vault, *, event: str, direction: str | None = None,
227
240
  ("text", text), ("topic", _topic_of(text or "", topic) if text or topic else None),
228
241
  ("due", due), ("source_ref", source_ref), ("note", note)):
229
242
  if v is not None:
230
- evidence[k] = v
243
+ evidence[k] = _json_safe(v)
231
244
 
232
245
  conn = _conn(vault)
233
246
  try:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: brainiac-cli
3
- Version: 0.18.0
3
+ Version: 0.18.2
4
4
  Summary: Brainiac — local any-LLM second-brain core engine + brain CLI (Markdown truth, derived SQLite index).
5
5
  License-Expression: Apache-2.0
6
6
  Requires-Python: >=3.9
File without changes
File without changes
File without changes
File without changes