brain-framework 18.1.2__tar.gz → 18.1.4__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 (64) hide show
  1. {brain_framework-18.1.2 → brain_framework-18.1.4}/PKG-INFO +5 -5
  2. {brain_framework-18.1.2 → brain_framework-18.1.4}/README.md +4 -4
  3. {brain_framework-18.1.2 → brain_framework-18.1.4}/pyproject.toml +3 -1
  4. {brain_framework-18.1.2 → brain_framework-18.1.4}/pyproject.toml.orig +4 -1
  5. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/__init__.py +1 -1
  6. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/cli.py +12 -3
  7. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/config.py +55 -36
  8. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/health.py +4 -5
  9. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/index.py +6 -3
  10. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/markdown.py +4 -1
  11. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/mcp.py +2 -0
  12. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/models.py +6 -3
  13. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/pages.py +3 -0
  14. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/retrieve.py +2 -2
  15. {brain_framework-18.1.2 → brain_framework-18.1.4}/LICENSE +0 -0
  16. {brain_framework-18.1.2 → brain_framework-18.1.4}/THIRD_PARTY_NOTICES.md +0 -0
  17. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/__main__.py +0 -0
  18. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/collect.py +0 -0
  19. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/evaluate.py +0 -0
  20. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/graph.py +0 -0
  21. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/history.py +0 -0
  22. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/install.py +0 -0
  23. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/links.py +0 -0
  24. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/ontology.py +0 -0
  25. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/py.typed +0 -0
  26. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/records.py +0 -0
  27. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/replies.py +0 -0
  28. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/schedule.py +0 -0
  29. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/schemas.py +0 -0
  30. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/skills/README.md +0 -0
  31. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/skills/bf-maintain/SKILL.md +0 -0
  32. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/skills/bf-maintain/references/conflicts.md +0 -0
  33. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/skills/bf-maintain/references/integrations.md +0 -0
  34. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/skills/bf-maintain/references/operations.md +0 -0
  35. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/skills/bf-setup/SKILL.md +0 -0
  36. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/skills/bf-setup/references/discovery.md +0 -0
  37. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/skills/bf-setup/references/import.md +0 -0
  38. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/skills/bf-setup/references/scan.md +0 -0
  39. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/skills/bf-setup/scripts/inventory.py +0 -0
  40. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/skills/bf-use/SKILL.md +0 -0
  41. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/skills/bf-use/references/actions.md +0 -0
  42. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/skills/bf-use/references/consolidate.md +0 -0
  43. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/skills/bf-use/references/context.md +0 -0
  44. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/skills/bf-use/references/decisions.md +0 -0
  45. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/skills/bf-use/references/evidence.md +0 -0
  46. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/skills/bf-use/references/handoff.md +0 -0
  47. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/skills/bf-use/references/learn.md +0 -0
  48. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/skills/bf-use/references/links.md +0 -0
  49. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/skills/bf-use/references/retrieval.md +0 -0
  50. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/skills/bf-use/references/review.md +0 -0
  51. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/skills/bf-use/references/share.md +0 -0
  52. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/skills/bf-use/scripts/check-handoff.py +0 -0
  53. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/skills/bf-use/scripts/evidence.py +0 -0
  54. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/skills/bf-use/scripts/guarded-write.py +0 -0
  55. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/skills/bf-use/scripts/new-action.py +0 -0
  56. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/skills/bf-use/templates/action.md +0 -0
  57. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/skills/bf-use/templates/concept.md +0 -0
  58. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/skills/bf-use/templates/project.md +0 -0
  59. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/storage.py +0 -0
  60. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/update.py +0 -0
  61. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/usage.py +0 -0
  62. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/validate.py +0 -0
  63. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/watch.py +0 -0
  64. {brain_framework-18.1.2 → brain_framework-18.1.4}/src/bf/watch_settings.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: brain-framework
3
- Version: 18.1.2
3
+ Version: 18.1.4
4
4
  Summary: 🧠 Brain Framework: from information to informed actions.
5
5
  Keywords: ai,ai-agents,agent-memory,context-engineering,knowledge-management,local-first,mcp,model-context-protocol,python,second-brain
6
6
  Author: Médéric Hurier
@@ -29,7 +29,7 @@ Description-Content-Type: text/markdown
29
29
  <p align="center">
30
30
  <img
31
31
  src="https://raw.githubusercontent.com/fmind/brain-framework/main/docs/assets/brain-framework.svg"
32
- alt="Brain Framework: an exposed brain in an opening shell above twin aperture eyes"
32
+ alt="Brain Framework: a robot head whose shell opens on a brain, above twin aperture eyes"
33
33
  width="256"
34
34
  height="256"
35
35
  >
@@ -62,7 +62,7 @@ Brain Framework (BF) changes the starting point. Create a brain, a plain folder,
62
62
  <a href="https://raw.githubusercontent.com/fmind/brain-framework/main/docs/assets/blank-vs-brain.svg">
63
63
  <img
64
64
  src="https://raw.githubusercontent.com/fmind/brain-framework/main/docs/assets/blank-vs-brain.svg"
65
- alt="Two fictional sessions ask whether the new website can launch. Opened anywhere, the agent asks you to paste Jira, GitHub and Google Cloud. Opened in a brain, it reads project:new-website and answers: not yet, Jira still blocks launch on accessibility review, citing each record."
65
+ alt="Two fictional sessions ask whether the new website can launch. Opened anywhere, the agent asks you to paste Jira, GitHub and Google Cloud. Opened in a brain, it reads project:new-website and answers: not yet, Jira still blocks launch on accessibility review, citing each record, then notes the launch hold in the project note."
66
66
  width="960"
67
67
  >
68
68
  </a>
@@ -181,7 +181,7 @@ Reading files gives you one document at a time, and grep finds words, not connec
181
181
  bf read project:new-website
182
182
  ```
183
183
 
184
- The reply holds the project note and its backlinks, grouped by relation: four records from four tools, side by side, in the shared vocabulary (trimmed here to refs and fields):
184
+ The reply holds the project note and its backlinks, grouped by relation: four records from four tools, side by side, in the shared vocabulary (trimmed here to refs, kinds and statuses):
185
185
 
186
186
  ```json
187
187
  {
@@ -308,7 +308,7 @@ After reviewing your sensors, run `bf watch` to keep them refreshed and see fail
308
308
  <img
309
309
  src="https://raw.githubusercontent.com/fmind/brain-framework/main/docs/assets/watch.svg"
310
310
  alt="Watch dashboard for the fictional offline demo: five sensors sorted by state, with a failed sensor's details beside their last success, next due time, item count and record changes."
311
- width="720"
311
+ width="960"
312
312
  >
313
313
  </a>
314
314
 
@@ -1,7 +1,7 @@
1
1
  <p align="center">
2
2
  <img
3
3
  src="https://raw.githubusercontent.com/fmind/brain-framework/main/docs/assets/brain-framework.svg"
4
- alt="Brain Framework: an exposed brain in an opening shell above twin aperture eyes"
4
+ alt="Brain Framework: a robot head whose shell opens on a brain, above twin aperture eyes"
5
5
  width="256"
6
6
  height="256"
7
7
  >
@@ -34,7 +34,7 @@ Brain Framework (BF) changes the starting point. Create a brain, a plain folder,
34
34
  <a href="https://raw.githubusercontent.com/fmind/brain-framework/main/docs/assets/blank-vs-brain.svg">
35
35
  <img
36
36
  src="https://raw.githubusercontent.com/fmind/brain-framework/main/docs/assets/blank-vs-brain.svg"
37
- alt="Two fictional sessions ask whether the new website can launch. Opened anywhere, the agent asks you to paste Jira, GitHub and Google Cloud. Opened in a brain, it reads project:new-website and answers: not yet, Jira still blocks launch on accessibility review, citing each record."
37
+ alt="Two fictional sessions ask whether the new website can launch. Opened anywhere, the agent asks you to paste Jira, GitHub and Google Cloud. Opened in a brain, it reads project:new-website and answers: not yet, Jira still blocks launch on accessibility review, citing each record, then notes the launch hold in the project note."
38
38
  width="960"
39
39
  >
40
40
  </a>
@@ -153,7 +153,7 @@ Reading files gives you one document at a time, and grep finds words, not connec
153
153
  bf read project:new-website
154
154
  ```
155
155
 
156
- The reply holds the project note and its backlinks, grouped by relation: four records from four tools, side by side, in the shared vocabulary (trimmed here to refs and fields):
156
+ The reply holds the project note and its backlinks, grouped by relation: four records from four tools, side by side, in the shared vocabulary (trimmed here to refs, kinds and statuses):
157
157
 
158
158
  ```json
159
159
  {
@@ -280,7 +280,7 @@ After reviewing your sensors, run `bf watch` to keep them refreshed and see fail
280
280
  <img
281
281
  src="https://raw.githubusercontent.com/fmind/brain-framework/main/docs/assets/watch.svg"
282
282
  alt="Watch dashboard for the fictional offline demo: five sensors sorted by state, with a failed sensor's details beside their last success, next due time, item count and record changes."
283
- width="720"
283
+ width="960"
284
284
  >
285
285
  </a>
286
286
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "brain-framework"
3
- version = "18.1.2"
3
+ version = "18.1.4"
4
4
  description = "🧠 Brain Framework: from information to informed actions."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.14"
@@ -146,6 +146,8 @@ skip_empty = true
146
146
  [dependency-groups]
147
147
  dev = [
148
148
  "jsonschema>=4.26.0,<5",
149
+ "markdown>=3.11",
150
+ "packaging>=26.3",
149
151
  "pytest-cov>=7.1.0",
150
152
  "pytest-watcher>=0.6.3",
151
153
  "pytest>=9.1.1",
@@ -1,7 +1,7 @@
1
1
  # https://packaging.python.org/en/latest/specifications/pyproject-toml/
2
2
  [project]
3
3
  name = "brain-framework"
4
- version = "18.1.2"
4
+ version = "18.1.4"
5
5
  description = "🧠 Brain Framework: from information to informed actions."
6
6
  readme = "README.md"
7
7
  requires-python = ">=3.14"
@@ -56,6 +56,9 @@ module-name = "bf"
56
56
  [dependency-groups]
57
57
  dev = [
58
58
  "jsonschema>=4.26.0,<5",
59
+ # Imported directly by tests/test_readme.py, as Zensical renders headings, and scripts/notices.py.
60
+ "markdown>=3.11",
61
+ "packaging>=26.3",
59
62
  "pytest-cov>=7.1.0",
60
63
  "pytest-watcher>=0.6.3",
61
64
  "pytest>=9.1.1",
@@ -2,7 +2,7 @@
2
2
 
3
3
  import os
4
4
 
5
- __version__ = "18.1.2"
5
+ __version__ = "18.1.4"
6
6
 
7
7
 
8
8
  def main() -> None:
@@ -591,6 +591,8 @@ def perform(
591
591
  if hook:
592
592
  _option("--hook", _slug, hook)
593
593
  names, args = (), values
594
+ elif values and not values[0].strip():
595
+ raise typer.BadParameter("give a routine name; an empty value names none", param_hint="ROUTINE")
594
596
  elif values:
595
597
  names, args = values[:1], values[1:]
596
598
  else:
@@ -855,14 +857,20 @@ def _input() -> bytes:
855
857
  return data
856
858
 
857
859
 
858
- # Values that choose where a command acts, as help names them. Given empty, as an unset variable gives them, they
859
- # would silently choose another brain or a directory, so they are invalid input; an omitted one keeps its default.
860
+ # Values that choose where or on what a command acts, as help names them. Given empty, as an unset variable gives
861
+ # them, they would silently choose another brain, directory, window or operation, so they are invalid input; an
862
+ # omitted one keeps its default.
860
863
  _GIVEN = {
861
864
  "--brain": "give a brain name or path; an empty value would select another brain",
862
865
  "--hook": "give an event name; an empty value would run the first argument as a routine",
863
866
  "--output": "give a directory; an empty value would name the brain's root",
867
+ "--reproject": "give a sensor name; an empty value would rebuild the cache instead",
868
+ "--routine": "give a routine name; an empty value names none",
869
+ "--sensor": "give a sensor name; an empty value names none",
870
+ "--since": "give a start time; an empty value would collect the default window",
864
871
  "DIR": "give a directory; an empty value would name the working directory",
865
872
  "PATH": "give a directory; an empty value would name the working directory",
873
+ "SENSOR": "give a sensor name from bf.yaml",
866
874
  }
867
875
 
868
876
 
@@ -903,7 +911,8 @@ class _Command(TyperCommand):
903
911
  for param in self.params:
904
912
  value = given.get(param.name)
905
913
  reason = _GIVEN.get(_named(param.get_error_hint(ctx)))
906
- if reason and isinstance(value, str) and not value.strip():
914
+ values = value if isinstance(value, list) else [value]
915
+ if reason and any(isinstance(item, str) and not item.strip() for item in values):
907
916
  return typer.BadParameter(reason, ctx, param)
908
917
  return None
909
918
 
@@ -212,20 +212,17 @@ def register(store: Store) -> dict[str, object]:
212
212
  replaced = ""
213
213
  if (existing := registry.brains.get(name)) and existing.path != str(store.root):
214
214
  try:
215
- registered = Store(Path(existing.path).expanduser())
215
+ registered = _registered(existing)
216
216
  except Error as error:
217
- # Only a missing directory frees the name: an unreachable one may still hold that brain.
218
- if not isinstance(error.__cause__, FileNotFoundError):
219
- raise Error(
220
- f"cannot reach the brain registered as {name} at {existing.path}; restore access to it, or "
221
- f"remove that entry from {path}"
222
- ) from error
223
- else:
224
- if registered.identity != store.identity:
225
- raise Error(
226
- f"another brain is already registered as {name} at {existing.path}; remove that entry from "
227
- f"{path}, or rename one of the brains in its bf.yaml"
228
- )
217
+ raise Error(
218
+ f"cannot reach the brain registered as {name} at {existing.path}; restore access to it, or "
219
+ f"remove that entry from {path}"
220
+ ) from error
221
+ if registered is not None and registered.identity != store.identity:
222
+ raise Error(
223
+ f"another brain is already registered as {name} at {existing.path}; remove that entry from "
224
+ f"{path}, or rename one of the brains in its bf.yaml"
225
+ )
229
226
  replaced = existing.path
230
227
  registry.brains[name] = Registration(path=str(store.root))
231
228
  document = {"brains": {key: value.model_dump() for key, value in sorted(registry.brains.items())}}
@@ -254,12 +251,30 @@ ABSENT = (
254
251
  )
255
252
 
256
253
 
254
+ UNREACHABLE = "registered brain directory exists but cannot be reached; restore access to it"
255
+
256
+
257
+ def _registered(entry: Registration) -> Store | None:
258
+ """A registered brain, or None when its directory is absent: missing, or no longer a directory.
259
+
260
+ One that exists but cannot be reached, such as a locked folder or a disconnected mount, may still hold that
261
+ brain and return: it fails with UNREACHABLE, so neither selection nor bf register treats it as moved.
262
+ """
263
+ try:
264
+ return Store(Path(entry.path).expanduser())
265
+ except Error as error:
266
+ cause = error.__cause__
267
+ if isinstance(cause, OSError) and not isinstance(cause, FileNotFoundError | NotADirectoryError):
268
+ raise Error(UNREACHABLE) from cause
269
+ return None
270
+
271
+
257
272
  class Selection(list[Store]):
258
- """Selected roots, plus the names of registered brains that are absent on this machine."""
273
+ """Selected roots, plus each registered brain this machine cannot use, by name, with its error."""
259
274
 
260
- def __init__(self, stores: Iterable[Store] = (), absent: Iterable[str] = ()) -> None:
275
+ def __init__(self, stores: Iterable[Store] = (), unavailable: dict[str, str] | None = None) -> None:
261
276
  super().__init__(stores)
262
- self.absent = tuple(absent)
277
+ self.unavailable = unavailable or {}
263
278
 
264
279
 
265
280
  def related(roots: list[Store]) -> tuple[list[Store], list[dict[str, object]]]:
@@ -272,7 +287,8 @@ def related(roots: list[Store]) -> tuple[list[Store], list[dict[str, object]]]:
272
287
  candidates.setdefault(store.identity, store)
273
288
  names: dict[str, set[str]] = {}
274
289
  problems: list[dict[str, object]] = [
275
- {"brain": name, "error": ABSENT} for name in (roots.absent if isinstance(roots, Selection) else ())
290
+ {"brain": name, "error": error}
291
+ for name, error in (roots.unavailable.items() if isinstance(roots, Selection) else ())
276
292
  ]
277
293
  for store in roots:
278
294
  try:
@@ -342,14 +358,11 @@ def _located(value: str, *, execute: bool = False) -> Store:
342
358
  local, referenced = _claim(value, registered=entry is not None)
343
359
  if entry is not None:
344
360
  try:
345
- store = Store(Path(entry.path).expanduser())
346
- except Error as error:
347
- # As bf register decides: only a missing directory is absent; an unreachable one may return.
348
- if isinstance(error.__cause__, OSError) and not isinstance(
349
- error.__cause__, FileNotFoundError | NotADirectoryError
350
- ):
351
- raise Error(f"cannot reach the brain registered as {value}; restore access to its directory") from None
352
- raise Error(f"{value}: {ABSENT}") from None
361
+ store = _registered(entry)
362
+ except Error:
363
+ raise Error(f"cannot reach the brain registered as {value}; restore access to its directory") from None
364
+ if store is None:
365
+ raise Error(f"{value}: {ABSENT}")
353
366
  if local is not None and local.identity != store.identity:
354
367
  raise Error(
355
368
  f"ambiguous brain name {value}: the registry and the enclosing brain name different directories; "
@@ -405,26 +418,32 @@ def select(value: str = "") -> list[Store]:
405
418
  return [_located(chosen)]
406
419
  if nearest := _nearest():
407
420
  return [nearest]
408
- # Retrieval reports registered brains absent on this machine instead of silently answering without them.
409
- stores, names, absent = [], [], []
421
+ # Retrieval reports registered brains this machine cannot use instead of silently answering without them.
422
+ stores: list[Store] = []
423
+ names: list[str] = []
424
+ unavailable: dict[str, str] = {}
410
425
  for name, entry in sorted(user_config().brains.items()):
411
- path = Path(entry.path).expanduser()
412
- if path.is_dir():
413
- stores.append(Store(path))
414
- names.append(name)
426
+ try:
427
+ store = _registered(entry)
428
+ except Error as error:
429
+ unavailable[name] = str(error)
430
+ continue
431
+ if store is None:
432
+ unavailable[name] = ABSENT
415
433
  else:
416
- absent.append(name)
417
- if absent and not stores:
434
+ stores.append(store)
435
+ names.append(name)
436
+ if unavailable and not stores:
418
437
  raise Error(
419
- f"registered brains are absent on this machine: {', '.join(absent)}; restore them, run bf register at "
420
- "their new place, or remove their registry entries"
438
+ f"registered brains are unavailable on this machine: {', '.join(unavailable)}; restore them or access to "
439
+ "them, run bf register at their new place, or remove their registry entries"
421
440
  )
422
441
  if not stores:
423
442
  raise Error("no brain selected; pass --brain, run inside a brain, or register one with bf register")
424
443
  # The only registered brain may have lost its bf.yaml: name that before a command reads or caches it. Of several,
425
444
  # retrieval reports each one whose bf.yaml does not load.
426
445
  only = [_configured(stores[0], f"registered brain {names[0]}")] if len(stores) == 1 else stores
427
- return Selection(only, absent)
446
+ return Selection(only, unavailable)
428
447
 
429
448
 
430
449
  def brain_name(store: Store) -> str:
@@ -8,7 +8,7 @@ from datetime import UTC, datetime, timedelta
8
8
  from typing import cast
9
9
 
10
10
  from bf import index, usage
11
- from bf.config import ABSENT, Selection, brain_name, load, related
11
+ from bf.config import Selection, brain_name, load, related
12
12
  from bf.history import ROUTINES, log_path, state
13
13
  from bf.models import CACHE, Config, Error, Program
14
14
  from bf.storage import Store
@@ -126,9 +126,8 @@ def attention(store: Store, now: datetime | None = None) -> list[dict[str, objec
126
126
  def report(stores: list[Store], now: datetime | None = None) -> dict[str, object]:
127
127
  """Per brain: cache, notes, records, source and routine freshness, errors, logs and usage."""
128
128
  now = now or datetime.now(UTC)
129
- brains: list[dict[str, object]] = [
130
- {"brain": name, "error": ABSENT} for name in (stores.absent if isinstance(stores, Selection) else ())
131
- ]
129
+ unavailable = stores.unavailable if isinstance(stores, Selection) else {}
130
+ brains: list[dict[str, object]] = [{"brain": name, "error": error} for name, error in unavailable.items()]
132
131
  healthy = not brains
133
132
  for store in stores:
134
133
  try:
@@ -185,7 +184,7 @@ def report(stores: list[Store], now: datetime | None = None) -> dict[str, object
185
184
  )
186
185
  # A reference that retrieval cannot include makes every search and read incomplete, like a skipped file.
187
186
  for problem in related(stores)[1]:
188
- if problem["error"] == ABSENT:
187
+ if problem["brain"] in unavailable:
189
188
  continue
190
189
  healthy = False
191
190
  owners = [entry for entry in brains if entry["brain"] == problem["brain"] and "problems" in entry]
@@ -39,7 +39,7 @@ from bf.models import (
39
39
  )
40
40
  from bf.storage import UNNAMED, BusyError, Store, building, generation, reader, unnamed, writer
41
41
 
42
- SCHEMA = 32
42
+ SCHEMA = 33
43
43
  CACHE = ".bf/index.sqlite"
44
44
  # A full build fills this file beside the live cache, then renames it over CACHE.
45
45
  BUILD = CACHE + ".new"
@@ -1060,9 +1060,10 @@ def _verbatim(snippet: str, original: str) -> str:
1060
1060
  """The original words behind a snippet of a folded passage, so `m²` or `…` never reads as `m2` or `...`.
1061
1061
 
1062
1062
  Folding each whitespace-separated run yields the folded passage, so every snippet position maps to one run.
1063
+ ASCII is already folded, which keeps a large passage with a single `²` or no-break space fast.
1063
1064
  """
1064
1065
  runs = re.findall(r"\s+|\S+", original)
1065
- forms = [fold(run) for run in runs]
1066
+ forms = [run if run.isascii() else fold(run) for run in runs]
1066
1067
  head = _ELLIPSIS if snippet.startswith(_ELLIPSIS) else ""
1067
1068
  tail = _ELLIPSIS if snippet.endswith(_ELLIPSIS) else ""
1068
1069
  window = snippet.removeprefix(head).removesuffix(tail)
@@ -1071,7 +1072,9 @@ def _verbatim(snippet: str, original: str) -> str:
1071
1072
  return snippet
1072
1073
  end, position, kept = start + len(window), 0, []
1073
1074
  for run, form in zip(runs, forms, strict=True):
1074
- if start < position + len(form) and position < end:
1075
+ if position >= end:
1076
+ break
1077
+ if start < position + len(form):
1075
1078
  kept.append(run)
1076
1079
  position += len(form)
1077
1080
  return head + "".join(kept) + tail
@@ -457,9 +457,12 @@ def note(path: str, data: bytes | Markdown) -> Note:
457
457
  # H2+ sections are separate passages, so a search can land on the answering section.
458
458
  # Headings rank through each passage's title; its text, and so its excerpt, starts below them.
459
459
  # A section also ranks under its parents: `## Vega` then `### Budget` answers "Vega budget".
460
+ # An H1 after the first section, as in a copied document, is one too, even the title's: it ends the section
461
+ # above it, as an exact read of that section's ref does, so search never lands on a section without the match.
460
462
  source_lines = lines(markdown.text)
461
- sections = [h for h in markdown.headings if h.level >= 2]
462
463
  titled = next((h for h in markdown.headings if h.level == 1 and h.title == title), None)
464
+ first = next((n for n, h in enumerate(markdown.headings) if h.level >= 2), len(markdown.headings))
465
+ sections = markdown.headings[first:]
463
466
  hidden = range(titled.line, titled.end) if titled else range(0)
464
467
  opening = range(markdown.offset, sections[0].line if sections else len(source_lines))
465
468
  introduction = "".join(source_lines[n] for n in opening if n not in hidden)
@@ -193,6 +193,8 @@ def server(stores: list[Store] | Callable[[], list[Store]]) -> MCPServer:
193
193
  title="Brain Framework",
194
194
  version=__version__,
195
195
  instructions=INSTRUCTIONS,
196
+ # The SDK logs each rejected or failed call at INFO on stderr, in its own format: replies already carry them.
197
+ log_level="WARNING",
196
198
  tools=[
197
199
  _strict(Tool.from_function(function, name=name, title=title, annotations=annotations), output)
198
200
  for name, title, function, output in (
@@ -452,9 +452,12 @@ class Record(Model):
452
452
  return value
453
453
 
454
454
  def readable(self) -> Record:
455
- """An exact read pages only `text`: everything else must fit its first page within the reply limit."""
456
- size = sum(map(len, (self.id, self.title, self.url, *self.links, *self.aliases)))
457
- if size > MAX_FIELDS or size + len(encode(self.attributes)) + len(encode(self.fields)) > MAX_FIELDS:
455
+ """An exact read pages only `text`: everything else must fit its first page within the reply limit.
456
+
457
+ Counted in encoded bytes, as the reply limit is: non-ASCII or escaped characters take more than one.
458
+ """
459
+ others = [self.id, self.title, self.url, self.links, self.aliases, self.attributes, self.fields]
460
+ if len(encode(others)) > MAX_FIELDS:
458
461
  raise ValueError("fields other than text exceed 2 MiB; keep bulky content in text")
459
462
  return self
460
463
 
@@ -102,6 +102,9 @@ def period(value: str, now: datetime | None = None) -> Period | None:
102
102
  if value in {"today", "yesterday"}:
103
103
  day = now.date() - timedelta(days=value == "yesterday")
104
104
  elif value[-1] in "hdw":
105
+ # An empty window holds nothing: `0d` would be a page no search scope could match.
106
+ if not int(value[:-1]):
107
+ raise ValueError
105
108
  start = now - timedelta(hours=int(value[:-1]) * {"h": 1, "d": 24, "w": 168}[value[-1]])
106
109
  return Period(timestamp(start.isoformat()), timestamp(now.isoformat()))
107
110
  elif ".." in value:
@@ -406,8 +406,8 @@ def _holders(
406
406
  for store in selected:
407
407
  try:
408
408
  value = _read(store, ref)
409
- except InputError, NotFoundError:
410
- # Malformed input and a missing section are the ref's problems, not a brain's.
409
+ except InputError:
410
+ # Malformed input is the ref's problem, not a brain's. A copy without the section is that brain's.
411
411
  raise
412
412
  except (Error, OSError, sqlite3.DatabaseError) as error:
413
413
  if len(selected) == 1: