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.
- brain/__init__.py +39 -0
- brain/__main__.py +14 -0
- brain/_assets/AGENTS.md +564 -0
- brain/_assets/overlay/template/brand/brand-guide.md +24 -0
- brain/_assets/overlay/template/keywords/glossary.md +15 -0
- brain/_assets/overlay/template/people/roster.md +14 -0
- brain/_assets/overlay/template/voice/voice-profile.md +34 -0
- brain/_assets/routines/manifest.json +257 -0
- brain/_assets/scripts/brain-brief-mac.plist +59 -0
- brain/_assets/scripts/brain-brief.sh +74 -0
- brain/_assets/scripts/brain-synthesis-mac.plist +48 -0
- brain/_assets/scripts/brain-synthesis.sh +214 -0
- brain/_assets/scripts/install-brief-mac.sh +152 -0
- brain/_assets/scripts/install-brief-windows.ps1 +97 -0
- brain/_assets/scripts/register_tasks.py +386 -0
- brain/_assets/templates/company.md +21 -0
- brain/_assets/templates/concept.md +27 -0
- brain/_assets/templates/daily.md +20 -0
- brain/_assets/templates/decision.md +42 -0
- brain/_assets/templates/meeting.md +33 -0
- brain/_assets/templates/person.md +20 -0
- brain/_assets/templates/project.md +23 -0
- brain/_assets/templates/state-moc.md +40 -0
- brain/_version.py +12 -0
- brain/anchor.py +121 -0
- brain/audit.py +422 -0
- brain/backup.py +210 -0
- brain/brief.py +417 -0
- brain/capture.py +117 -0
- brain/chunk.py +249 -0
- brain/classification.py +134 -0
- brain/cli.py +1906 -0
- brain/config.py +368 -0
- brain/connect.py +362 -0
- brain/context.py +108 -0
- brain/core.py +3018 -0
- brain/doctor.py +1161 -0
- brain/egress.py +148 -0
- brain/embed.py +857 -0
- brain/encryption.py +217 -0
- brain/frontmatter.py +102 -0
- brain/golden_probe.py +678 -0
- brain/graph.py +369 -0
- brain/graphify.py +352 -0
- brain/index.py +1576 -0
- brain/ingest/__init__.py +19 -0
- brain/ingest/handlers/__init__.py +43 -0
- brain/ingest/handlers/base.py +95 -0
- brain/ingest/handlers/docx.py +78 -0
- brain/ingest/handlers/email.py +228 -0
- brain/ingest/handlers/html.py +142 -0
- brain/ingest/handlers/image.py +91 -0
- brain/ingest/handlers/pdf.py +99 -0
- brain/ingest/handlers/pptx.py +69 -0
- brain/ingest/handlers/tables.py +41 -0
- brain/ingest/handlers/text.py +43 -0
- brain/ingest/handlers/xlsx.py +100 -0
- brain/ingest/handlers/zip.py +163 -0
- brain/ingest/pipeline.py +839 -0
- brain/ingest/transcript.py +158 -0
- brain/init.py +870 -0
- brain/maintenance.py +2266 -0
- brain/mcp_adapter.py +217 -0
- brain/multihop.py +232 -0
- brain/notes.py +195 -0
- brain/overlay.py +183 -0
- brain/projection.py +79 -0
- brain/rerank.py +425 -0
- brain/snapshot.py +231 -0
- brain/update.py +743 -0
- brain/vectors.py +225 -0
- brainiac_cli-0.16.0.dist-info/METADATA +306 -0
- brainiac_cli-0.16.0.dist-info/RECORD +77 -0
- brainiac_cli-0.16.0.dist-info/WHEEL +5 -0
- brainiac_cli-0.16.0.dist-info/entry_points.txt +4 -0
- brainiac_cli-0.16.0.dist-info/licenses/LICENSE +202 -0
- brainiac_cli-0.16.0.dist-info/top_level.txt +1 -0
brain/capture.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Capture-path frontmatter enforcement (UX-01).
|
|
2
|
+
|
|
3
|
+
Both host-native clients (Codex/Claude Code/Gemini CLI) and sandboxed VM clients
|
|
4
|
+
(Cowork) route through enforce() to guarantee frontmatter before any write.
|
|
5
|
+
The host signs and indexes; the VM drops to capture-inbox/ unsigned and unindexed.
|
|
6
|
+
|
|
7
|
+
host: enforce() → write_note() → incremental sync → snapshot
|
|
8
|
+
VM: enforce() → draft_capture() (capture-inbox/; unsigned, unindexed)
|
|
9
|
+
host drain-on-invoke picks it up on the next brain run
|
|
10
|
+
|
|
11
|
+
No signing key is ever read or resolved here.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import datetime
|
|
16
|
+
import hashlib
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from . import frontmatter as fm
|
|
20
|
+
|
|
21
|
+
REQUIRED_KEYS: tuple[str, ...] = ("id", "type", "classification", "created")
|
|
22
|
+
_CAPTURE_CLASSIFICATION_DEFAULT = "Internal"
|
|
23
|
+
_CAPTURE_TYPE_DEFAULT = "note"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _today() -> str:
|
|
27
|
+
return datetime.date.today().isoformat()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _derive_id(body: str) -> str:
|
|
31
|
+
h = hashlib.sha256(body.encode("utf-8")).hexdigest()[:12]
|
|
32
|
+
return f"capture-{h}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def enforce(content: str, *, override: dict[str, Any] | None = None) -> str:
|
|
36
|
+
"""Return content with all required capture frontmatter guaranteed.
|
|
37
|
+
|
|
38
|
+
Rules:
|
|
39
|
+
- Existing keys are NEVER overwritten (additive only).
|
|
40
|
+
- ``override`` keys take precedence over both existing and defaults.
|
|
41
|
+
- Missing ``classification`` defaults to ``Internal`` so a captured note
|
|
42
|
+
is usable by default without requiring --max-tier elevation.
|
|
43
|
+
- Always sets ``status: draft`` and ``provenance.trust: untrusted`` so the
|
|
44
|
+
host drain treats it as untrusted input during ingest validation.
|
|
45
|
+
- Preserves all other existing frontmatter keys.
|
|
46
|
+
|
|
47
|
+
The result is STILL UNTRUSTED from the host's perspective until drain-on-invoke
|
|
48
|
+
signs it — the host promote step validates, signs, indexes, and updates status.
|
|
49
|
+
"""
|
|
50
|
+
override = override or {}
|
|
51
|
+
meta, body = fm.parse_text(content)
|
|
52
|
+
|
|
53
|
+
nid = override.get("id") or meta.get("id") or _derive_id(body)
|
|
54
|
+
ntype = override.get("type") or meta.get("type") or _CAPTURE_TYPE_DEFAULT
|
|
55
|
+
ncls = (
|
|
56
|
+
override.get("classification")
|
|
57
|
+
or meta.get("classification")
|
|
58
|
+
or _CAPTURE_CLASSIFICATION_DEFAULT
|
|
59
|
+
)
|
|
60
|
+
ncreated = override.get("created") or meta.get("created") or _today()
|
|
61
|
+
nupdated = override.get("updated") or meta.get("updated") or _today()
|
|
62
|
+
ntitle = override.get("title") or meta.get("title") or str(nid)
|
|
63
|
+
|
|
64
|
+
block: dict[str, Any] = {
|
|
65
|
+
"id": nid,
|
|
66
|
+
"title": ntitle,
|
|
67
|
+
"type": ntype,
|
|
68
|
+
"classification": ncls,
|
|
69
|
+
"created": ncreated,
|
|
70
|
+
"updated": nupdated,
|
|
71
|
+
"status": "draft",
|
|
72
|
+
"provenance.trust": "untrusted",
|
|
73
|
+
}
|
|
74
|
+
# Preserve any other keys from the original frontmatter (non-clobbering).
|
|
75
|
+
for k, v in meta.items():
|
|
76
|
+
if k not in block:
|
|
77
|
+
block[k] = v
|
|
78
|
+
|
|
79
|
+
lines = ["---"]
|
|
80
|
+
for k, v in block.items():
|
|
81
|
+
sv = str(v)
|
|
82
|
+
# Quote values containing YAML-special characters.
|
|
83
|
+
if any(c in sv for c in (":", "#", "[", "]", "{", "}", ",")):
|
|
84
|
+
sv = f'"{sv}"'
|
|
85
|
+
lines.append(f"{k}: {sv}")
|
|
86
|
+
lines.append("---")
|
|
87
|
+
lines.append("")
|
|
88
|
+
lines.append(body.lstrip("\n"))
|
|
89
|
+
|
|
90
|
+
return "\n".join(lines)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def validate(content: str) -> list[str]:
|
|
94
|
+
"""Return a list of validation errors (empty list = valid).
|
|
95
|
+
|
|
96
|
+
Called by the host drain before signing to validate untrusted capture
|
|
97
|
+
content. Checks that required keys are present and classification is a
|
|
98
|
+
known tier.
|
|
99
|
+
"""
|
|
100
|
+
from .classification import TIERS
|
|
101
|
+
|
|
102
|
+
meta, _body = fm.parse_text(content)
|
|
103
|
+
errors: list[str] = []
|
|
104
|
+
|
|
105
|
+
if not meta:
|
|
106
|
+
errors.append("no frontmatter")
|
|
107
|
+
return errors
|
|
108
|
+
|
|
109
|
+
for key in REQUIRED_KEYS:
|
|
110
|
+
if key not in meta:
|
|
111
|
+
errors.append(f"missing required key: {key}")
|
|
112
|
+
|
|
113
|
+
cls_val = str(meta.get("classification", ""))
|
|
114
|
+
if cls_val and cls_val not in TIERS:
|
|
115
|
+
errors.append(f"unknown classification: {cls_val!r} (valid: {list(TIERS)})")
|
|
116
|
+
|
|
117
|
+
return errors
|
brain/chunk.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Block/section chunking + Anthropic-style in-language contextual prefix (IDX-02).
|
|
2
|
+
|
|
3
|
+
Two ideas, both lifting retrieval recall:
|
|
4
|
+
|
|
5
|
+
1. **Chunk, don't embed whole notes.** A 4 KB note embedded as one vector blurs
|
|
6
|
+
every section together; a query that matches one paragraph competes against
|
|
7
|
+
the whole-note average. We split the body at Markdown section/block level
|
|
8
|
+
(headings first, then paragraph blocks, with a soft size budget) so each
|
|
9
|
+
chunk is a coherent unit.
|
|
10
|
+
|
|
11
|
+
2. **Contextual prefix (Anthropic "Contextual Retrieval").** Each chunk is
|
|
12
|
+
prepended — BEFORE embedding — with a short blurb situating it inside its
|
|
13
|
+
note ("From note 'X' …; section: Y"). This restores the context a bare chunk
|
|
14
|
+
loses and sharply cuts misses. The blurb is written **in the note's own
|
|
15
|
+
language** (a Portuguese note gets a Portuguese blurb) so it does not pollute
|
|
16
|
+
the chunk's language; the cross-lingual bridge happens at *query* time in
|
|
17
|
+
Arctic-embed's shared multilingual vector space.
|
|
18
|
+
|
|
19
|
+
The canonical task prefix (``query:`` / ``passage:``) is a model control token
|
|
20
|
+
and is applied by the embedder (``brain.embed``), OUTSIDE this contextual prefix
|
|
21
|
+
— and is never translated. The contextual prefix here is content, not a token.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
|
|
29
|
+
# Soft target / hard ceiling for a chunk, in characters. Blocks are merged up to
|
|
30
|
+
# the target and a single oversized block is split at the ceiling.
|
|
31
|
+
TARGET_CHARS = 900
|
|
32
|
+
MAX_CHARS = 1400
|
|
33
|
+
|
|
34
|
+
# Dual-granularity threshold (RET-03) — DISABLED BY DEFAULT (= 0) after the S10
|
|
35
|
+
# A/B falsified it for this corpus + model. The hypothesis: a short curated note
|
|
36
|
+
# split per-section scatters its cross-lingual signal across competing vectors, so
|
|
37
|
+
# indexing it as ONE whole-note chunk should restore the edge Smart Connections
|
|
38
|
+
# (whole-note, same e5-small) has on monolingual PT. EMPIRICAL RESULT (full e5-small
|
|
39
|
+
# re-index, 2026-06-28): it did NOT help and slightly REGRESSED — monolingual_pt
|
|
40
|
+
# 0.653 → 0.611, overall 0.573 → 0.553 vs the chunk index at the same zone weight.
|
|
41
|
+
# Mashing a note's sections into one 450-token vector DILUTES the specific matching
|
|
42
|
+
# section rather than concentrating it (the opposite of the literature's claim for
|
|
43
|
+
# small models), and the corpus had few multi-section short notes anyway (only ~824
|
|
44
|
+
# of 83k chunks merged). Kept as an env-gated capability for other corpora, but the
|
|
45
|
+
# DEFAULT IS 0 (pure section chunking) because it is the better config here.
|
|
46
|
+
# Set BRAIN_WHOLENOTE_MAX_CHARS=<chars> to re-enable. Evidence:
|
|
47
|
+
# docs/operations/s10-agentic-retrieval-analysis.md.
|
|
48
|
+
WHOLE_NOTE_MAX_CHARS = 0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _whole_note_max() -> int:
|
|
52
|
+
try:
|
|
53
|
+
return int(os.environ.get("BRAIN_WHOLENOTE_MAX_CHARS", "") or WHOLE_NOTE_MAX_CHARS)
|
|
54
|
+
except ValueError:
|
|
55
|
+
return WHOLE_NOTE_MAX_CHARS
|
|
56
|
+
|
|
57
|
+
_HEADING = re.compile(r"^(#{1,6})\s+(.*)$")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class Chunk:
|
|
62
|
+
ordinal: int
|
|
63
|
+
heading: str # nearest enclosing heading text ("" if none / preamble)
|
|
64
|
+
text: str # the raw chunk body (no prefixes)
|
|
65
|
+
lang: str # detected language of the chunk: "pt" | "es" | "en"
|
|
66
|
+
|
|
67
|
+
def embed_input(self, title: str, zone: str, doc_context: str = "") -> str:
|
|
68
|
+
"""The exact string handed to the embedder for this chunk: the
|
|
69
|
+
in-language contextual prefix + a blank line + the chunk text.
|
|
70
|
+
|
|
71
|
+
``doc_context`` (UPG-04, optional) is an LLM-generated ≤1-sentence
|
|
72
|
+
document-level summary that situates this chunk within its note. When
|
|
73
|
+
non-empty it is prepended (after the template prefix, before the chunk
|
|
74
|
+
text) so the embedding sees the note's overall meaning alongside the
|
|
75
|
+
chunk's specific content. Empty string = the S10 template-only path.
|
|
76
|
+
"""
|
|
77
|
+
prefix = contextual_prefix(title, zone, self.heading, self.lang)
|
|
78
|
+
if doc_context:
|
|
79
|
+
prefix = prefix + " " + doc_context
|
|
80
|
+
return prefix + "\n\n" + self.text
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# --- language detection (lightweight, dependency-free) ---------------------
|
|
84
|
+
# Stopword sets chosen to be discriminative between EN / PT / ES. Not a full
|
|
85
|
+
# language ID — good enough to pick the contextual-prefix language.
|
|
86
|
+
_PT = {
|
|
87
|
+
"de", "que", "não", "uma", "para", "com", "como", "mais", "está", "são",
|
|
88
|
+
"também", "já", "nós", "sobre", "será", "foi", "ção", "às", "então", "porque",
|
|
89
|
+
}
|
|
90
|
+
_ES = {
|
|
91
|
+
"de", "que", "no", "una", "para", "con", "como", "más", "está", "son",
|
|
92
|
+
"también", "ya", "nosotros", "sobre", "será", "fue", "ción", "pero", "porque",
|
|
93
|
+
"el", "los", "las", "una",
|
|
94
|
+
}
|
|
95
|
+
_EN = {
|
|
96
|
+
"the", "and", "of", "to", "in", "is", "are", "for", "with", "that", "this",
|
|
97
|
+
"as", "be", "on", "by", "an", "we", "it", "from", "will",
|
|
98
|
+
}
|
|
99
|
+
_WORD = re.compile(r"[A-Za-zÀ-ÿ]+")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def detect_language(text: str) -> str:
|
|
103
|
+
"""Return 'pt', 'es', or 'en' by discriminative-stopword frequency.
|
|
104
|
+
|
|
105
|
+
Defaults to 'en' on a tie or when there is too little signal. Accent marks
|
|
106
|
+
(ç, ã, õ, á, é) strongly bias toward PT/ES.
|
|
107
|
+
"""
|
|
108
|
+
words = [w.lower() for w in _WORD.findall(text)]
|
|
109
|
+
if not words:
|
|
110
|
+
return "en"
|
|
111
|
+
scores = {
|
|
112
|
+
"pt": sum(1 for w in words if w in _PT),
|
|
113
|
+
"es": sum(1 for w in words if w in _ES),
|
|
114
|
+
"en": sum(1 for w in words if w in _EN),
|
|
115
|
+
}
|
|
116
|
+
# Portuguese-specific orthography (ã, õ, ç) is a strong PT signal.
|
|
117
|
+
if re.search(r"[ãõç]", text):
|
|
118
|
+
scores["pt"] += 3
|
|
119
|
+
# Spanish-specific (ñ, ¿, ¡)
|
|
120
|
+
if re.search(r"[ñ¿¡]", text):
|
|
121
|
+
scores["es"] += 3
|
|
122
|
+
best = max(scores, key=lambda k: scores[k])
|
|
123
|
+
# Require the winner to actually beat English by a margin, else default EN
|
|
124
|
+
# (avoids flipping an English chunk to PT on a couple of shared tokens).
|
|
125
|
+
if best != "en" and scores[best] <= scores["en"]:
|
|
126
|
+
return "en"
|
|
127
|
+
return best
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# --- in-language contextual prefix -----------------------------------------
|
|
131
|
+
# Per-language templates. {title}=note title, {zone}=para zone, {heading}=section.
|
|
132
|
+
_TEMPLATES = {
|
|
133
|
+
"en": "Context: from the note '{title}' (zone: {zone}).{section}",
|
|
134
|
+
"pt": "Contexto: da nota '{title}' (zona: {zone}).{section}",
|
|
135
|
+
"es": "Contexto: de la nota '{title}' (zona: {zone}).{section}",
|
|
136
|
+
}
|
|
137
|
+
_SECTION = {
|
|
138
|
+
"en": " Section: {heading}.",
|
|
139
|
+
"pt": " Secção: {heading}.",
|
|
140
|
+
"es": " Sección: {heading}.",
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def contextual_prefix(title: str, zone: str, heading: str, lang: str) -> str:
|
|
145
|
+
lang = lang if lang in _TEMPLATES else "en"
|
|
146
|
+
section = _SECTION[lang].format(heading=heading) if heading else ""
|
|
147
|
+
return _TEMPLATES[lang].format(title=title or "(untitled)", zone=zone or "brain", section=section)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# --- the chunker ------------------------------------------------------------
|
|
151
|
+
def _split_blocks(body: str) -> list[tuple[str, str]]:
|
|
152
|
+
"""Walk the body line by line, tracking the nearest heading, and group
|
|
153
|
+
contiguous non-heading lines into blocks. Returns [(heading, block_text)]."""
|
|
154
|
+
blocks: list[tuple[str, str]] = []
|
|
155
|
+
cur_heading = ""
|
|
156
|
+
buf: list[str] = []
|
|
157
|
+
|
|
158
|
+
def flush() -> None:
|
|
159
|
+
text = "\n".join(buf).strip()
|
|
160
|
+
if text:
|
|
161
|
+
blocks.append((cur_heading, text))
|
|
162
|
+
buf.clear()
|
|
163
|
+
|
|
164
|
+
for line in body.splitlines():
|
|
165
|
+
m = _HEADING.match(line)
|
|
166
|
+
if m:
|
|
167
|
+
flush()
|
|
168
|
+
cur_heading = m.group(2).strip()
|
|
169
|
+
elif line.strip() == "" and buf:
|
|
170
|
+
# paragraph boundary inside a section
|
|
171
|
+
flush()
|
|
172
|
+
else:
|
|
173
|
+
buf.append(line)
|
|
174
|
+
flush()
|
|
175
|
+
return blocks
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _pack(heading: str, text: str, ordinal_start: int, lang_of) -> list[Chunk]:
|
|
179
|
+
"""Split one oversized block at the char ceiling into sub-chunks."""
|
|
180
|
+
out: list[Chunk] = []
|
|
181
|
+
o = ordinal_start
|
|
182
|
+
if len(text) <= MAX_CHARS:
|
|
183
|
+
out.append(Chunk(o, heading, text, lang_of(text)))
|
|
184
|
+
return out
|
|
185
|
+
# greedy word-wrap into <= MAX_CHARS slices
|
|
186
|
+
words = text.split()
|
|
187
|
+
cur: list[str] = []
|
|
188
|
+
size = 0
|
|
189
|
+
for w in words:
|
|
190
|
+
if size + len(w) + 1 > MAX_CHARS and cur:
|
|
191
|
+
piece = " ".join(cur)
|
|
192
|
+
out.append(Chunk(o, heading, piece, lang_of(piece)))
|
|
193
|
+
o += 1
|
|
194
|
+
cur, size = [], 0
|
|
195
|
+
cur.append(w)
|
|
196
|
+
size += len(w) + 1
|
|
197
|
+
if cur:
|
|
198
|
+
piece = " ".join(cur)
|
|
199
|
+
out.append(Chunk(o, heading, piece, lang_of(piece)))
|
|
200
|
+
return out
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def chunk_text(body: str, *, lang_of=detect_language) -> list[Chunk]:
|
|
204
|
+
"""Chunk a note body at section/block level with a soft size budget.
|
|
205
|
+
|
|
206
|
+
Strategy: split into heading-scoped blocks, then greedily merge adjacent
|
|
207
|
+
blocks under the same heading up to TARGET_CHARS, splitting any single block
|
|
208
|
+
over MAX_CHARS. Each chunk's language is detected on its own text.
|
|
209
|
+
|
|
210
|
+
Dual-granularity (RET-03): a short note (whole body ≤ ``_whole_note_max()``)
|
|
211
|
+
is returned as ONE whole-note chunk — its sections are NOT split — so its
|
|
212
|
+
cross-lingual signal stays concentrated in a single vector. See
|
|
213
|
+
``WHOLE_NOTE_MAX_CHARS``.
|
|
214
|
+
"""
|
|
215
|
+
whole_max = _whole_note_max()
|
|
216
|
+
stripped = body.strip()
|
|
217
|
+
if whole_max > 0 and stripped and len(stripped) <= whole_max:
|
|
218
|
+
return [Chunk(0, "", stripped, lang_of(stripped))]
|
|
219
|
+
blocks = _split_blocks(body)
|
|
220
|
+
if not blocks:
|
|
221
|
+
return []
|
|
222
|
+
chunks: list[Chunk] = []
|
|
223
|
+
ordinal = 0
|
|
224
|
+
pending_heading = blocks[0][0]
|
|
225
|
+
pending_text = ""
|
|
226
|
+
|
|
227
|
+
def emit(heading: str, text: str) -> None:
|
|
228
|
+
nonlocal ordinal
|
|
229
|
+
for ch in _pack(heading, text, ordinal, lang_of):
|
|
230
|
+
ch.ordinal = ordinal
|
|
231
|
+
chunks.append(ch)
|
|
232
|
+
ordinal += 1
|
|
233
|
+
|
|
234
|
+
for heading, text in blocks:
|
|
235
|
+
if heading != pending_heading:
|
|
236
|
+
if pending_text:
|
|
237
|
+
emit(pending_heading, pending_text)
|
|
238
|
+
pending_heading, pending_text = heading, text
|
|
239
|
+
continue
|
|
240
|
+
if not pending_text:
|
|
241
|
+
pending_text = text
|
|
242
|
+
elif len(pending_text) + len(text) + 2 <= TARGET_CHARS:
|
|
243
|
+
pending_text = pending_text + "\n\n" + text
|
|
244
|
+
else:
|
|
245
|
+
emit(pending_heading, pending_text)
|
|
246
|
+
pending_text = text
|
|
247
|
+
if pending_text:
|
|
248
|
+
emit(pending_heading, pending_text)
|
|
249
|
+
return chunks
|
brain/classification.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Classification tiers + the deny-by-default egress filter (CORE-02).
|
|
2
|
+
|
|
3
|
+
This is the *egress-decision mechanism*, NOT containment. It only decides what
|
|
4
|
+
the cooperative `brain` CLI is willing to print to stdout. Any file-capable
|
|
5
|
+
harness can read the Markdown directly and bypass it entirely — that is why real
|
|
6
|
+
containment of sensitive tiers is **workspace projection** (see
|
|
7
|
+
``brain.projection``) plus the host/VM trust split, not this filter. The
|
|
8
|
+
consensus-hardening tests (tests/test_direct_file_read.py) prove this distinction.
|
|
9
|
+
|
|
10
|
+
Tiers, low -> high sensitivity:
|
|
11
|
+
Public < Internal < Confidential < Restricted < MNPI
|
|
12
|
+
|
|
13
|
+
Default-deny (load-bearing): a note whose ``classification`` is missing, empty,
|
|
14
|
+
or unrecognised is treated as MNPI (rank 4, most restrictive) at every surfacing
|
|
15
|
+
boundary — fail-closed, never fail-open.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Iterable, Sequence
|
|
22
|
+
|
|
23
|
+
TIERS: tuple[str, ...] = ("Public", "Internal", "Confidential", "Restricted", "MNPI")
|
|
24
|
+
RANK: dict[str, int] = {t: i for i, t in enumerate(TIERS)}
|
|
25
|
+
|
|
26
|
+
# The tier an unlabelled / unrecognised note is treated as (most restrictive).
|
|
27
|
+
DEFAULT_DENY_TIER = "MNPI"
|
|
28
|
+
DEFAULT_DENY_RANK = RANK[DEFAULT_DENY_TIER]
|
|
29
|
+
|
|
30
|
+
# Default egress cap on the TRUSTED HOST: the full vault (owner decision,
|
|
31
|
+
# 2026-07-10 — the old Internal default starved every real query: a curated
|
|
32
|
+
# vault keeps its load-bearing notes at Confidential/Restricted, so the host
|
|
33
|
+
# surface answered from stale low-tier scraps while competitors read
|
|
34
|
+
# everything). $BRAIN_DEFAULT_MAX_TIER narrows it back (e.g. "Internal") for
|
|
35
|
+
# deployments that want the conservative gate; an unrecognised value falls
|
|
36
|
+
# back to the full-vault default. The untrusted VM leg does NOT inherit this:
|
|
37
|
+
# role=vm resolves to VM_DEFAULT_MAX_TIER below (the trifecta break lives at
|
|
38
|
+
# the role boundary, not on the owner's own host).
|
|
39
|
+
DEFAULT_MAX_TIER = os.environ.get("BRAIN_DEFAULT_MAX_TIER", "MNPI")
|
|
40
|
+
if DEFAULT_MAX_TIER not in RANK:
|
|
41
|
+
DEFAULT_MAX_TIER = "MNPI"
|
|
42
|
+
|
|
43
|
+
# Conservative default for the untrusted read+draft leg (role=vm): surface
|
|
44
|
+
# Public + Internal only unless a human explicitly elevates with --max-tier.
|
|
45
|
+
VM_DEFAULT_MAX_TIER = "Internal"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def normalize(value: object) -> str:
|
|
49
|
+
"""Map a raw frontmatter value to a recognised tier, default-deny on miss."""
|
|
50
|
+
if isinstance(value, str) and value.strip() in RANK:
|
|
51
|
+
return value.strip()
|
|
52
|
+
return DEFAULT_DENY_TIER
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def rank(value: object) -> int:
|
|
56
|
+
"""Effective sensitivity rank, default-deny (unlabelled -> MNPI rank)."""
|
|
57
|
+
return RANK[normalize(value)]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def is_default_denied(value: object) -> bool:
|
|
61
|
+
"""True iff the raw value would be coerced to the default-deny tier."""
|
|
62
|
+
return not (isinstance(value, str) and value.strip() in RANK)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# Lowercased tier -> canonical, for detecting casing mistakes (F-04).
|
|
66
|
+
_CANON_BY_LOWER = {t.lower(): t for t in TIERS}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def casing_mismatch(value: object) -> str | None:
|
|
70
|
+
"""If ``value`` is a KNOWN tier in the wrong case (e.g. 'internal'), return
|
|
71
|
+
its canonical form; else None.
|
|
72
|
+
|
|
73
|
+
DESIGN DECISION (F-04): the filter keeps STRICT matching — a non-canonical
|
|
74
|
+
value is default-denied (fail-closed), never silently up-ranked (which would
|
|
75
|
+
be fail-OPEN). But a wrong-case known tier is almost always an authoring slip
|
|
76
|
+
that would make the note invisible forever, so we surface it as a diagnostic
|
|
77
|
+
here (and in redaction_report) instead of letting it vanish silently. The
|
|
78
|
+
fix-at-source is tools/validate.py, which flags non-canonical casing.
|
|
79
|
+
"""
|
|
80
|
+
if isinstance(value, str):
|
|
81
|
+
v = value.strip()
|
|
82
|
+
if v not in RANK and v.lower() in _CANON_BY_LOWER:
|
|
83
|
+
return _CANON_BY_LOWER[v.lower()]
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class ClassificationFilter:
|
|
89
|
+
"""Deny-by-default egress filter applied as the FINAL stage before stdout.
|
|
90
|
+
|
|
91
|
+
A note is surfaceable iff its effective rank <= the caller's max-tier rank.
|
|
92
|
+
Unlabelled/unrecognised -> MNPI -> only surfaceable when max_tier is MNPI
|
|
93
|
+
(the explicit human-gated path).
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
max_tier: str = DEFAULT_MAX_TIER
|
|
97
|
+
|
|
98
|
+
def __post_init__(self) -> None:
|
|
99
|
+
if self.max_tier not in RANK:
|
|
100
|
+
raise ValueError(
|
|
101
|
+
f"unknown max_tier {self.max_tier!r}; expected one of {TIERS}"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def max_rank(self) -> int:
|
|
106
|
+
return RANK[self.max_tier]
|
|
107
|
+
|
|
108
|
+
def allows(self, classification: object) -> bool:
|
|
109
|
+
return rank(classification) <= self.max_rank
|
|
110
|
+
|
|
111
|
+
def filter(self, items: Iterable[dict], key: str = "classification") -> list[dict]:
|
|
112
|
+
"""Drop any item whose classification exceeds the cap. Pure; no mutation."""
|
|
113
|
+
return [it for it in items if self.allows(it.get(key))]
|
|
114
|
+
|
|
115
|
+
def redaction_report(self, items: Sequence[dict], key: str = "classification") -> dict:
|
|
116
|
+
"""How many items were withheld and why (for an honest CLI footer)."""
|
|
117
|
+
denied = [it for it in items if not self.allows(it.get(key))]
|
|
118
|
+
default_denied = sum(1 for it in denied if is_default_denied(it.get(key)))
|
|
119
|
+
# Surface wrong-case known tiers (F-04) so they don't vanish silently.
|
|
120
|
+
casing = sorted({
|
|
121
|
+
f"{it.get(key)!r}->{c}"
|
|
122
|
+
for it in items
|
|
123
|
+
if (c := casing_mismatch(it.get(key))) is not None
|
|
124
|
+
})
|
|
125
|
+
report = {
|
|
126
|
+
"total": len(items),
|
|
127
|
+
"surfaced": len(items) - len(denied),
|
|
128
|
+
"withheld": len(denied),
|
|
129
|
+
"withheld_unlabelled_default_deny": default_denied,
|
|
130
|
+
"max_tier": self.max_tier,
|
|
131
|
+
}
|
|
132
|
+
if casing:
|
|
133
|
+
report["casing_mismatch_warnings"] = casing
|
|
134
|
+
return report
|