brainiac-cli 0.18.2__tar.gz → 0.19.0__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.
- {brainiac_cli-0.18.2/src/brainiac_cli.egg-info → brainiac_cli-0.19.0}/PKG-INFO +1 -1
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/pyproject.toml +1 -1
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/AGENTS.md +5 -1
- brainiac_cli-0.19.0/src/brain/_assets/overlay/template/cos/drafts.md +25 -0
- brainiac_cli-0.19.0/src/brain/_assets/overlay/template/cos/priorities.md +42 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/scripts/brain-brief.sh +8 -6
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_version.py +1 -1
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/classification.py +25 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/cli.py +24 -2
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/cos.py +159 -3
- brainiac_cli-0.19.0/src/brain/cos_chips.py +361 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/index.py +50 -1
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0/src/brainiac_cli.egg-info}/PKG-INFO +1 -1
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brainiac_cli.egg-info/SOURCES.txt +2 -0
- brainiac_cli-0.18.2/src/brain/_assets/overlay/template/cos/priorities.md +0 -19
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/LICENSE +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/MANIFEST.in +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/README.md +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/setup.cfg +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/__init__.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/__main__.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/overlay/template/brand/brand-guide.md +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/overlay/template/keywords/glossary.md +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/overlay/template/people/roster.md +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/overlay/template/voice/voice-profile.md +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/routines/manifest.json +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/scripts/brain-brief-mac.plist +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/scripts/brain-synthesis-mac.plist +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/scripts/brain-synthesis.sh +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/scripts/install-brief-mac.sh +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/scripts/install-brief-windows.ps1 +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/scripts/register_tasks.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/templates/company.md +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/templates/concept.md +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/templates/daily.md +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/templates/decision.md +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/templates/meeting.md +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/templates/person.md +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/templates/project.md +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/templates/state-moc.md +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/anchor.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/audit.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/backup.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/brief.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/capture.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/chunk.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/config.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/connect.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/context.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/core.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/doctor.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/egress.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/embed.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/encryption.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/frontmatter.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/golden_probe.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/graph.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/graphify.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/inbox.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/ingest/__init__.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/ingest/handlers/__init__.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/ingest/handlers/base.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/ingest/handlers/docx.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/ingest/handlers/email.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/ingest/handlers/html.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/ingest/handlers/image.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/ingest/handlers/pdf.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/ingest/handlers/pptx.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/ingest/handlers/tables.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/ingest/handlers/text.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/ingest/handlers/xlsx.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/ingest/handlers/zip.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/ingest/pipeline.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/ingest/transcript.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/init.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/maintenance.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/mcp_adapter.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/multihop.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/notes.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/overlay.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/projection.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/rerank.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/retro.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/snapshot.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/spine.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/update.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/vectors.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brainiac_cli.egg-info/dependency_links.txt +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brainiac_cli.egg-info/entry_points.txt +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brainiac_cli.egg-info/requires.txt +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brainiac_cli.egg-info/top_level.txt +0 -0
|
@@ -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.
|
|
11
|
+
version = "0.19.0"
|
|
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"
|
|
@@ -356,7 +356,11 @@ rules, in order:
|
|
|
356
356
|
`-- N withheld …` line in text): when you see it, **re-run with
|
|
357
357
|
`--max-tier Restricted`** (or `MNPI` for the most sensitive) — the
|
|
358
358
|
human-gated elevation — instead of concluding the vault has nothing and
|
|
359
|
-
web-searching to compensate.
|
|
359
|
+
web-searching to compensate. **On `--role vm` this elevation is NOT
|
|
360
|
+
self-serve:** the VM leg clamps `--max-tier` to a hard ceiling
|
|
361
|
+
(`$BRAIN_VM_MAX_EGRESS_TIER`, default `Internal`), so a typed higher tier is
|
|
362
|
+
silently capped and the elevation hint is suppressed — raising a VM's ceiling
|
|
363
|
+
is a host-operator action, not something the model does on its own.
|
|
360
364
|
|
|
361
365
|
3. **Never leak internal topics into a web search.** A web query for a
|
|
362
366
|
Confidential-or-above subject — a deal codename, a counterparty, an internal
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
---
|
|
2
|
+
overlay_type: cos
|
|
3
|
+
title: "Reply drafts — owner setting"
|
|
4
|
+
setting: drafts
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
enabled: true
|
|
8
|
+
|
|
9
|
+
## What this controls
|
|
10
|
+
|
|
11
|
+
Whether the nightly chief-of-staff composes reply drafts into your Drafts
|
|
12
|
+
folder for threads that warrant a response. It NEVER sends — sending is yours,
|
|
13
|
+
structurally.
|
|
14
|
+
|
|
15
|
+
- `enabled: true` (or this file absent) — compose drafts, cap 10/run.
|
|
16
|
+
- `enabled: false` — no drafts; the run spends that budget on battlecards.
|
|
17
|
+
|
|
18
|
+
## Why this file exists
|
|
19
|
+
|
|
20
|
+
Without it a run has nowhere to read your answer, so a low draft-engagement
|
|
21
|
+
count invites it to quietly stop drafting and ask you again every night. Your
|
|
22
|
+
ruling lives here instead: durable, and outside the rolling skill-memory cap.
|
|
23
|
+
|
|
24
|
+
Engagement (did you send/edit a draft?) is still measured and reported. It is
|
|
25
|
+
DATA, not a verdict — an untouched draft may simply mean you have not looked.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
---
|
|
2
|
+
overlay_type: cos
|
|
3
|
+
title: "COS priority overrides — starter template"
|
|
4
|
+
updated: 2026-07-13
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# COS priority overrides
|
|
8
|
+
|
|
9
|
+
OPTIONAL category (CUT-01E). `brain cos-priority-map` generates the
|
|
10
|
+
VM-readable priority map from your `type: person` / `type: company` notes;
|
|
11
|
+
list ids below to override the computed placement. One list line per note:
|
|
12
|
+
|
|
13
|
+
`- <note-id>: high|normal|low|exclude`
|
|
14
|
+
|
|
15
|
+
<!-- examples (replace with your own note ids):
|
|
16
|
+
- contoso-acquisition-lead: high
|
|
17
|
+
- northwind-vendor-contact: low
|
|
18
|
+
- retired-supplier-x: exclude
|
|
19
|
+
-->
|
|
20
|
+
|
|
21
|
+
## Priority-chip taxonomy (chief-of-staff kernel v4.6 — companion block)
|
|
22
|
+
|
|
23
|
+
The nightly run projects the act queue onto exactly three Outlook
|
|
24
|
+
categories (recorded verbatim — category names are IMMUTABLE once created;
|
|
25
|
+
a rename means delete + recreate + re-chip every tagged message):
|
|
26
|
+
|
|
27
|
+
- `P0 · Now` — red
|
|
28
|
+
- `P1 · Today` — orange
|
|
29
|
+
- `P2 · This week` — blue
|
|
30
|
+
|
|
31
|
+
`chips_confirmed` is the RUNTIME chip gate the nightly reads: chips are
|
|
32
|
+
withheld (the legacy flat Action mark continues) until the owner's recorded
|
|
33
|
+
YES to the queued name/color confirmation question, at which point the
|
|
34
|
+
answering session sets:
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
chips_confirmed: false
|
|
38
|
+
chips_confirmed_date:
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
(The answering session flips the value to `true` and dates it. Never
|
|
42
|
+
pre-fill `true` — an uncommented `true` line IS the gate opening.)
|
|
@@ -63,12 +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
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
66
|
+
# cos-broker first — CORRECTION (2026-07-17): `brain maintain` ALSO runs the
|
|
67
|
+
# fold + the ingest sweep (core.py maintain, the CUT-01E block); the original
|
|
68
|
+
# claim here that "nothing else schedules it" was WRONG. The 2026-07-16 field
|
|
69
|
+
# failure (spine DB empty, candidates unclaimed ~29h) was actually the plist
|
|
70
|
+
# pinning a 0.17.0 engine whose fold predates the spine stage — maintain was
|
|
71
|
+
# calling the fold hourly all along, at a version that couldn't render it.
|
|
72
|
+
# This explicit call stays as defense-in-depth: idempotent, cheap, and it
|
|
73
|
+
# keeps the drain floor if maintain's internal block ever regresses.
|
|
72
74
|
# NON-FATAL: a broker failure must never cost the vault its capture-drain floor.
|
|
73
75
|
"$BRAIN_BIN" cos-broker --json || echo "brain-brief: cos-broker fold failed (non-fatal), continuing to maintain"
|
|
74
76
|
# maintain: sync --publish (drain pending captures + reconcile index +
|
|
@@ -44,6 +44,31 @@ if DEFAULT_MAX_TIER not in RANK:
|
|
|
44
44
|
# Public + Internal only unless a human explicitly elevates with --max-tier.
|
|
45
45
|
VM_DEFAULT_MAX_TIER = "Internal"
|
|
46
46
|
|
|
47
|
+
# Hard server-side ceiling for the untrusted VM leg (codex 2026-07-19). On
|
|
48
|
+
# role=vm, --max-tier is a caller-controlled argument the LLM can set itself;
|
|
49
|
+
# without a clamp the egress-starvation hint ("re-run with --max-tier
|
|
50
|
+
# Restricted") turns the intended HUMAN-gated elevation into an instruction the
|
|
51
|
+
# model follows autonomously. This is the CLI analogue of the MCP adapter's
|
|
52
|
+
# BRAIN_MAX_EGRESS_TIER: a VM caller can never exceed this ceiling regardless of
|
|
53
|
+
# what it types. The host operator raises it deliberately via the env var (the
|
|
54
|
+
# real human gate); an unrecognised value falls back to VM_DEFAULT_MAX_TIER.
|
|
55
|
+
VM_EGRESS_CEILING_ENV = "BRAIN_VM_MAX_EGRESS_TIER"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def vm_egress_ceiling() -> str:
|
|
59
|
+
"""The tier a role=vm caller may never exceed. Operator-settable via
|
|
60
|
+
$BRAIN_VM_MAX_EGRESS_TIER; defaults to the conservative VM cap."""
|
|
61
|
+
val = os.environ.get(VM_EGRESS_CEILING_ENV, "").strip()
|
|
62
|
+
return val if val in RANK else VM_DEFAULT_MAX_TIER
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def clamp_to(requested: str, ceiling: str) -> str:
|
|
66
|
+
"""Return the lower (less-sensitive) of two tiers by rank — a caller can
|
|
67
|
+
never widen egress past the ceiling, only narrow within it."""
|
|
68
|
+
req = requested if requested in RANK else DEFAULT_DENY_TIER
|
|
69
|
+
ceil = ceiling if ceiling in RANK else VM_DEFAULT_MAX_TIER
|
|
70
|
+
return req if RANK[req] <= RANK[ceil] else ceil
|
|
71
|
+
|
|
47
72
|
|
|
48
73
|
def normalize(value: object) -> str:
|
|
49
74
|
"""Map a raw frontmatter value to a recognised tier, default-deny on miss."""
|
|
@@ -164,6 +164,12 @@ def _emit(obj: Any, as_json: bool, human: str | None = None) -> None:
|
|
|
164
164
|
sys.stdout.write((human if human is not None else str(obj)) + "\n")
|
|
165
165
|
|
|
166
166
|
|
|
167
|
+
# Set True by main() on role=vm: the untrusted leg must not be told to
|
|
168
|
+
# "re-run with --max-tier Restricted" — that hint is the self-elevation nudge
|
|
169
|
+
# codex flagged, and the VM ceiling clamp makes the instruction a no-op anyway.
|
|
170
|
+
_SUPPRESS_ELEVATION_HINT = False
|
|
171
|
+
|
|
172
|
+
|
|
167
173
|
def _filter_dicts(items: list[dict], max_tier: str) -> tuple[list[dict], dict]:
|
|
168
174
|
# THE single egress chokepoint — every content-returning subcommand routes
|
|
169
175
|
# through egress.apply_gate so a new content path cannot silently bypass the
|
|
@@ -174,7 +180,8 @@ def _filter_dicts(items: list[dict], max_tier: str) -> tuple[list[dict], dict]:
|
|
|
174
180
|
# web search — leaking internal topics outward. Say WHY it's thin and HOW to
|
|
175
181
|
# elevate, in the report dict so it surfaces in BOTH --json (agent-facing)
|
|
176
182
|
# and the text footer. The tier stays the human gate; this only signposts it.
|
|
177
|
-
if report.get("withheld", 0) > 0 and max_tier != cls.TIERS[-1]
|
|
183
|
+
if (report.get("withheld", 0) > 0 and max_tier != cls.TIERS[-1]
|
|
184
|
+
and not _SUPPRESS_ELEVATION_HINT):
|
|
178
185
|
report["hint"] = (
|
|
179
186
|
f"{report['withheld']} note(s) withheld above the {max_tier} cap — "
|
|
180
187
|
f"re-run with --max-tier Restricted (or MNPI for the most sensitive) "
|
|
@@ -1080,6 +1087,7 @@ VM_ALLOWED = frozenset({
|
|
|
1080
1087
|
|
|
1081
1088
|
|
|
1082
1089
|
def main(argv: list[str] | None = None) -> int:
|
|
1090
|
+
global _SUPPRESS_ELEVATION_HINT
|
|
1083
1091
|
try:
|
|
1084
1092
|
return _main(argv)
|
|
1085
1093
|
except Exception as exc: # H-4: top-level guard -- never a raw traceback
|
|
@@ -1088,6 +1096,11 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1088
1096
|
_emit({"error": type(exc).__name__, "detail": str(exc)} if as_json
|
|
1089
1097
|
else f"{exc.__class__.__name__}: {exc}", as_json)
|
|
1090
1098
|
return 3
|
|
1099
|
+
finally:
|
|
1100
|
+
# The VM hint-suppression flag is INVOCATION-scoped (set by _main per
|
|
1101
|
+
# role): reset it here so it never leaks into a later main() call or a
|
|
1102
|
+
# direct _filter_dicts caller in the same process (e.g. across tests).
|
|
1103
|
+
_SUPPRESS_ELEVATION_HINT = False
|
|
1091
1104
|
|
|
1092
1105
|
|
|
1093
1106
|
def _main(argv: list[str] | None = None) -> int:
|
|
@@ -1103,6 +1116,14 @@ def _main(argv: list[str] | None = None) -> int:
|
|
|
1103
1116
|
if getattr(args, "max_tier", "unset") is None:
|
|
1104
1117
|
args.max_tier = (cls.VM_DEFAULT_MAX_TIER if role == config.ROLE_VM
|
|
1105
1118
|
else cls.DEFAULT_MAX_TIER)
|
|
1119
|
+
# VM egress ceiling (codex 2026-07-19): on the untrusted leg, --max-tier is
|
|
1120
|
+
# a value the LLM can set itself, and the starvation hint nudges it to. Clamp
|
|
1121
|
+
# any VM-side max_tier to the operator-set ceiling so a typed --max-tier MNPI
|
|
1122
|
+
# cannot self-elevate past it; the hint is also suppressed for role=vm below.
|
|
1123
|
+
global _SUPPRESS_ELEVATION_HINT
|
|
1124
|
+
_SUPPRESS_ELEVATION_HINT = (role == config.ROLE_VM) # deterministic per call
|
|
1125
|
+
if role == config.ROLE_VM and hasattr(args, "max_tier"):
|
|
1126
|
+
args.max_tier = cls.clamp_to(str(args.max_tier), cls.vm_egress_ceiling())
|
|
1106
1127
|
# DV-03: the VM leg fails closed on a dead embedder rather than silently
|
|
1107
1128
|
# answering semantic queries with random hash vectors (no-op when a real
|
|
1108
1129
|
# embedder is present or hash was chosen explicitly).
|
|
@@ -1371,7 +1392,8 @@ def _main(argv: list[str] | None = None) -> int:
|
|
|
1371
1392
|
+ srep["withheld_unlabelled_default_deny"],
|
|
1372
1393
|
"max_tier": args.max_tier,
|
|
1373
1394
|
}
|
|
1374
|
-
if report["withheld"] > 0 and args.max_tier != cls.TIERS[-1]
|
|
1395
|
+
if (report["withheld"] > 0 and args.max_tier != cls.TIERS[-1]
|
|
1396
|
+
and not _SUPPRESS_ELEVATION_HINT):
|
|
1375
1397
|
report["hint"] = (
|
|
1376
1398
|
f"{report['withheld']} note(s) withheld above the "
|
|
1377
1399
|
f"{args.max_tier} cap — re-run with a higher --max-tier.")
|
|
@@ -963,6 +963,108 @@ def shadow_ledger_entries(vault) -> list[dict[str, Any]]:
|
|
|
963
963
|
return list(by_key.values())
|
|
964
964
|
|
|
965
965
|
|
|
966
|
+
# -- behavioural grading (2026-07-17, owner decision) --------------------------
|
|
967
|
+
# The corrections-based calibration required ~10 mornings of the owner grading
|
|
968
|
+
# email by hand; across 6 rounds / 308 verdicts he filed ZERO — the ritual will
|
|
969
|
+
# never happen, so auto-archive was permanently gated on evidence that could
|
|
970
|
+
# not accrue. Behavioural grading replaces stated preference with revealed
|
|
971
|
+
# preference: the VM already reads the mailbox nightly, so it OBSERVES what the
|
|
972
|
+
# owner actually did to previously-verdicted mail (read / replied / flagged /
|
|
973
|
+
# archived himself / untouched) and drops raw observations; THIS module grades
|
|
974
|
+
# them, in one testable place. Owner ruling: also mine his own archive actions
|
|
975
|
+
# as pattern evidence — "albeit not exclusively".
|
|
976
|
+
|
|
977
|
+
BEHAVIOUR_OBSERVATIONS = ("owner_replied", "owner_flagged", "owner_read",
|
|
978
|
+
"owner_archived", "untouched")
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
def grade_behaviour(bucket: str, observed: str, *,
|
|
982
|
+
auto_archived: bool = False) -> str:
|
|
983
|
+
"""One verdict row + one observed behaviour -> a grade.
|
|
984
|
+
|
|
985
|
+
The error that matters for auto-archive is exactly one: "would have
|
|
986
|
+
archived mail the owner needed". So a NOISE verdict the owner then replied
|
|
987
|
+
to or flagged is `contradicted` — the hard, gate-relevant failure. A noise
|
|
988
|
+
row he merely opened is `read_anyway`: a weak signal (people open
|
|
989
|
+
newsletters), reported but never gated on. Noise he left untouched or
|
|
990
|
+
archived himself is `consistent`. An act/read row he archived without
|
|
991
|
+
engaging is `overcalled` — over-caution, harmless, informational.
|
|
992
|
+
"""
|
|
993
|
+
b, o = str(bucket).lower(), str(observed).lower()
|
|
994
|
+
# Aged-read lane (owner policy 2026-07-17): priority-list mail may be
|
|
995
|
+
# auto-archived when read + no-action + >7d old. Those rows are bucket
|
|
996
|
+
# `read`, not `noise` — so the drift contradiction must key on the ACTION
|
|
997
|
+
# (we auto-archived it), not the bucket: the owner replying to or flagging
|
|
998
|
+
# ANY row we auto-archived is the gate error, whichever lane moved it.
|
|
999
|
+
if auto_archived and o in ("owner_replied", "owner_flagged"):
|
|
1000
|
+
return "contradicted"
|
|
1001
|
+
if b == "noise":
|
|
1002
|
+
if o in ("owner_replied", "owner_flagged"):
|
|
1003
|
+
return "contradicted"
|
|
1004
|
+
if o == "owner_read":
|
|
1005
|
+
return "read_anyway"
|
|
1006
|
+
if o in ("untouched", "owner_archived"):
|
|
1007
|
+
return "consistent"
|
|
1008
|
+
elif b in ("act", "read") and o == "owner_archived":
|
|
1009
|
+
return "overcalled"
|
|
1010
|
+
return "neutral"
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def behaviour_entries(vault) -> list[dict[str, Any]]:
|
|
1014
|
+
"""Raw behaviour observations from the VM drop (``behaviour-*.jsonl``),
|
|
1015
|
+
deduped by (round, msg_key) — last write wins, same idempotency shape as
|
|
1016
|
+
the shadow ledger. Rows are VM-authored and untrusted: consumed as data."""
|
|
1017
|
+
vdir = verdict_drop_dir(vault)
|
|
1018
|
+
files = sorted(vdir.glob("behaviour-*.jsonl")) if vdir.is_dir() else []
|
|
1019
|
+
by_key: dict[tuple[int, str], dict[str, Any]] = {}
|
|
1020
|
+
for f in files:
|
|
1021
|
+
for e in _read_jsonl(f):
|
|
1022
|
+
r, k = e.get("round"), e.get("msg_key")
|
|
1023
|
+
if isinstance(r, int) and isinstance(k, str):
|
|
1024
|
+
by_key[(r, k)] = e
|
|
1025
|
+
return list(by_key.values())
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
def behaviour_report(vault) -> dict[str, Any]:
|
|
1029
|
+
"""Aggregate observed-behaviour evidence: per-bucket grade counts, the
|
|
1030
|
+
noise-safety numbers an auto-archive re-arm decision needs, and the
|
|
1031
|
+
owner's own archive patterns (top senders he archives himself — evidence
|
|
1032
|
+
for FUTURE noise-signals, never an actuator by itself)."""
|
|
1033
|
+
entries = behaviour_entries(vault)
|
|
1034
|
+
per_bucket: dict[str, dict[str, int]] = {}
|
|
1035
|
+
contradicted_rows: list[dict[str, Any]] = []
|
|
1036
|
+
owner_archive_patterns: dict[str, int] = {}
|
|
1037
|
+
rounds: set[int] = set()
|
|
1038
|
+
for e in entries:
|
|
1039
|
+
b = str(e.get("bucket", "?")).lower()
|
|
1040
|
+
o = str(e.get("observed", "?")).lower()
|
|
1041
|
+
g = grade_behaviour(b, o, auto_archived=bool(e.get("auto_archived")))
|
|
1042
|
+
per_bucket.setdefault(b, {})[g] = per_bucket.setdefault(b, {}).get(g, 0) + 1
|
|
1043
|
+
rounds.add(int(e["round"]))
|
|
1044
|
+
if g == "contradicted":
|
|
1045
|
+
contradicted_rows.append(
|
|
1046
|
+
{k: e.get(k) for k in ("round", "msg_key", "sender", "subject",
|
|
1047
|
+
"observed")})
|
|
1048
|
+
if o == "owner_archived":
|
|
1049
|
+
key = str(e.get("sender") or e.get("sender_domain") or "unknown").lower()
|
|
1050
|
+
owner_archive_patterns[key] = owner_archive_patterns.get(key, 0) + 1
|
|
1051
|
+
noise = per_bucket.get("noise", {})
|
|
1052
|
+
noise_observed = sum(noise.values())
|
|
1053
|
+
contradicted = noise.get("contradicted", 0)
|
|
1054
|
+
return {
|
|
1055
|
+
"observations": len(entries),
|
|
1056
|
+
"rounds_observed": len(rounds),
|
|
1057
|
+
"per_bucket": per_bucket,
|
|
1058
|
+
"noise_observed": noise_observed,
|
|
1059
|
+
"noise_contradicted": contradicted,
|
|
1060
|
+
"noise_consistency": (round((noise_observed - contradicted) / noise_observed, 4)
|
|
1061
|
+
if noise_observed else None),
|
|
1062
|
+
"contradicted_rows": contradicted_rows[:20],
|
|
1063
|
+
"owner_archive_patterns": dict(sorted(owner_archive_patterns.items(),
|
|
1064
|
+
key=lambda kv: -kv[1])[:20]),
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
|
|
966
1068
|
def calibration_report(vault) -> dict[str, Any]:
|
|
967
1069
|
"""Shadow-mode trust-gate report: calibration = reduce(verdicts,
|
|
968
1070
|
correction_events). A verdict is bucket-correct when no correction exists
|
|
@@ -998,6 +1100,10 @@ def calibration_report(vault) -> dict[str, Any]:
|
|
|
998
1100
|
"overall_bucket_precision": (round(bucket_correct / total, 4)
|
|
999
1101
|
if total else None),
|
|
1000
1102
|
"per_bucket": buckets,
|
|
1103
|
+
# revealed preference alongside stated preference: the corrections
|
|
1104
|
+
# count above stays authoritative where it exists, but 0 corrections
|
|
1105
|
+
# no longer means 0 evidence.
|
|
1106
|
+
"behaviour": behaviour_report(vault),
|
|
1001
1107
|
}
|
|
1002
1108
|
|
|
1003
1109
|
|
|
@@ -1451,7 +1557,18 @@ def _is_keeper_counterparty(vault, counterparty: str | None) -> bool:
|
|
|
1451
1557
|
if not counterparty:
|
|
1452
1558
|
return False
|
|
1453
1559
|
overrides = load_priority_overrides(vault)
|
|
1454
|
-
|
|
1560
|
+
name = str(counterparty).lower()
|
|
1561
|
+
if overrides.get(name) == "high":
|
|
1562
|
+
return True
|
|
1563
|
+
# Override keys are NOTE-ID SLUGS (the only form _OVERRIDE_LINE_RE parses),
|
|
1564
|
+
# but a commitment's counterparty is a display name from mail — e.g. a name
|
|
1565
|
+
# like "Renée Dûval" could never equal "renee-duval", so keeper detection
|
|
1566
|
+
# silently never fired (found 2026-07-17, the day the first real roster was
|
|
1567
|
+
# written). Compare in slug space, accents folded.
|
|
1568
|
+
import unicodedata
|
|
1569
|
+
folded = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode()
|
|
1570
|
+
slug = re.sub(r"[^a-z0-9]+", "-", folded).strip("-")
|
|
1571
|
+
return overrides.get(slug) == "high"
|
|
1455
1572
|
|
|
1456
1573
|
|
|
1457
1574
|
def _spine_ingest_commitment(vault, meta: dict[str, Any], *, source_ref: str,
|
|
@@ -1527,6 +1644,13 @@ DEFAULT_INGEST_SWEEP_MAX_BYTES = 200 * 1024 * 1024
|
|
|
1527
1644
|
INGEST_SWEEP_DOWNLOADS_ENV = "BRAIN_COS_DOWNLOADS_DIR"
|
|
1528
1645
|
INGEST_SWEEP_SKEW_SECONDS = 300 # manifest ts vs file mtime clock skew
|
|
1529
1646
|
INGEST_SWEEP_SIZE_TOLERANCE = 0.10 # when the manifest carries a size
|
|
1647
|
+
# Host-observed freshness floor (codex 2026-07-19): a candidate whose mtime is
|
|
1648
|
+
# older than this relative to the sweep's OWN clock is a pre-existing host file,
|
|
1649
|
+
# not something the VM just downloaded — the un-forgeable provenance anchor. The
|
|
1650
|
+
# maintain umbrella fires hourly; a 6h window tolerates a few missed runs / a
|
|
1651
|
+
# sleeping Mac while still excluding files that have sat in Downloads for days.
|
|
1652
|
+
INGEST_SWEEP_RECENCY_ENV = "BRAIN_COS_SWEEP_RECENCY_SECONDS"
|
|
1653
|
+
DEFAULT_INGEST_SWEEP_RECENCY_SECONDS = 6 * 3600
|
|
1530
1654
|
|
|
1531
1655
|
|
|
1532
1656
|
def ingest_manifest_dir(vault=None) -> Path:
|
|
@@ -1550,6 +1674,14 @@ def _sweep_max_bytes() -> int:
|
|
|
1550
1674
|
return DEFAULT_INGEST_SWEEP_MAX_BYTES
|
|
1551
1675
|
|
|
1552
1676
|
|
|
1677
|
+
def _sweep_recency_seconds() -> int:
|
|
1678
|
+
try:
|
|
1679
|
+
return int(os.environ.get(INGEST_SWEEP_RECENCY_ENV,
|
|
1680
|
+
DEFAULT_INGEST_SWEEP_RECENCY_SECONDS))
|
|
1681
|
+
except ValueError:
|
|
1682
|
+
return DEFAULT_INGEST_SWEEP_RECENCY_SECONDS
|
|
1683
|
+
|
|
1684
|
+
|
|
1553
1685
|
def _unique_dest(inbox: Path, filename: str) -> Path:
|
|
1554
1686
|
dest = inbox / filename
|
|
1555
1687
|
if not dest.exists():
|
|
@@ -1572,8 +1704,12 @@ def ingest_sweep(vault, *, downloads_dir: Path | str | None = None,
|
|
|
1572
1704
|
- filenames are basename-only — any path separator / ``..`` is refused;
|
|
1573
1705
|
- a symlinked candidate (or manifest file) is refused, never followed;
|
|
1574
1706
|
- files above the size cap (default 200 MB) are refused;
|
|
1575
|
-
- a
|
|
1576
|
-
|
|
1707
|
+
- a candidate must be a FRESH download — mtime within the recency window of
|
|
1708
|
+
the sweep's own clock — so a VM manifest cannot claim a pre-existing host
|
|
1709
|
+
Downloads file it did not just download (codex 2026-07-19, VM/host
|
|
1710
|
+
boundary): the mtime is the one provenance signal the VM cannot forge;
|
|
1711
|
+
- a match requires exact filename, size within tolerance when the manifest
|
|
1712
|
+
carries one, and mtime newer than the manifest ts minus skew;
|
|
1577
1713
|
- claims are append-only, one per manifest line — a re-run never
|
|
1578
1714
|
double-moves (idempotent);
|
|
1579
1715
|
- NOTHING the manifest does not name is ever touched, moved, or deleted.
|
|
@@ -1653,6 +1789,26 @@ def ingest_sweep(vault, *, downloads_dir: Path | str | None = None,
|
|
|
1653
1789
|
report["refused"].append({"filename": fname,
|
|
1654
1790
|
"reason": "over size cap"})
|
|
1655
1791
|
continue
|
|
1792
|
+
# SECURITY (codex 2026-07-19, VM/host boundary): the manifest is
|
|
1793
|
+
# VM-writable, so its own fields cannot prove provenance — the VM
|
|
1794
|
+
# can name any basename and BACKDATE `ts` to defeat the staleness
|
|
1795
|
+
# check on an old host file. The one thing a VM-side attacker CANNOT
|
|
1796
|
+
# forge is a host file's mtime, so the sweep's OWN clock is the
|
|
1797
|
+
# anchor: only a file whose mtime is recent relative to `now` — i.e.
|
|
1798
|
+
# one genuinely downloaded within this maintenance window — is
|
|
1799
|
+
# eligible. A guessed sensitive file that has been sitting in
|
|
1800
|
+
# ~/Downloads (tax.pdf, statement.pdf …) is refused regardless of
|
|
1801
|
+
# what the manifest claims. Widen the window via env for slow hosts.
|
|
1802
|
+
age = now.timestamp() - cand.stat().st_mtime
|
|
1803
|
+
if age > _sweep_recency_seconds():
|
|
1804
|
+
report["unmatched"].append(fname)
|
|
1805
|
+
report.setdefault("unmatched_reasons", []).append({
|
|
1806
|
+
"filename": fname,
|
|
1807
|
+
"reason": f"not a fresh download: host mtime is {age / 3600.0:.1f}h "
|
|
1808
|
+
f"old (recency window {_sweep_recency_seconds() // 3600}h) "
|
|
1809
|
+
f"— a pre-existing host file the VM manifest cannot claim",
|
|
1810
|
+
})
|
|
1811
|
+
continue
|
|
1656
1812
|
want = entry.get("approx_size_bytes")
|
|
1657
1813
|
if isinstance(want, int) and want > 0:
|
|
1658
1814
|
if abs(size - want) > max(want * INGEST_SWEEP_SIZE_TOLERANCE, 4096):
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"""Priority-chip projection (chief-of-staff kernel v4.7) — tested reference
|
|
2
|
+
implementation for the executable halves of the P-taxonomy contract.
|
|
3
|
+
|
|
4
|
+
The nightly chief-of-staff run is executed by a model reading SKILL.md; the
|
|
5
|
+
behaviours here are the parts that must never drift on a prompt rewrite, so
|
|
6
|
+
they live as engine code with contract tests (``tests/test_cos_chips.py``,
|
|
7
|
+
fake mailbox adapter + fault injection):
|
|
8
|
+
|
|
9
|
+
- ``assign_chip`` — the authoritative P0/P1/P2 assignment rules
|
|
10
|
+
- ``desired_categories``— category-set PRESERVATION diff (never a bare-set write)
|
|
11
|
+
- ``verify_write`` — whole-set server-state verification
|
|
12
|
+
- ``apply_chip_to_conversation`` — message-level apply + journal, per-row verified
|
|
13
|
+
- ``recover_from_journal`` — journal recovery after a partial/failed pass
|
|
14
|
+
- ``lease_state`` — the cos-ops/_mutation_lease.json semantics
|
|
15
|
+
- ``desired_chip_and_trigger`` — v4.7 LIF-01/02 nightly reconciliation (auto-clear +
|
|
16
|
+
re-level), full-evidence desired-state diff, never rule-ordering
|
|
17
|
+
- ``apply_relevel_to_conversation`` — v4.7 add-before-remove re-level apply
|
|
18
|
+
- ``dedupe_automated_p2`` — v4.7 recurring-automated-sender P2 collapse (s02 finding)
|
|
19
|
+
|
|
20
|
+
Chips are a PROJECTION of the act queue — never a verdict. Classification
|
|
21
|
+
(Phase 1.5 buckets/tiers) is untouched by this module.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from datetime import datetime, timezone
|
|
26
|
+
from typing import Any, Iterable, Protocol
|
|
27
|
+
|
|
28
|
+
CHIP_P0 = "P0 · Now" # red
|
|
29
|
+
CHIP_P1 = "P1 · Today" # orange
|
|
30
|
+
CHIP_P2 = "P2 · This week" # blue
|
|
31
|
+
CHIPS = (CHIP_P0, CHIP_P1, CHIP_P2)
|
|
32
|
+
LEGACY_ACTION = "Action"
|
|
33
|
+
#: every category this projection owns and may add/remove; anything else on a
|
|
34
|
+
#: message belongs to the owner and must survive every write untouched.
|
|
35
|
+
MANAGED_CATEGORIES = frozenset(CHIPS) | {LEGACY_ACTION}
|
|
36
|
+
|
|
37
|
+
CHIP_COLORS = {CHIP_P0: "red", CHIP_P1: "orange", CHIP_P2: "blue"}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# --------------------------------------------------------------------------
|
|
41
|
+
# Assignment (PRJ-01 hardened rules — roster tier alone never makes P0)
|
|
42
|
+
# --------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
def assign_chip(
|
|
45
|
+
*,
|
|
46
|
+
bucket: str,
|
|
47
|
+
roster_high: bool = False,
|
|
48
|
+
direct_ask: bool = False,
|
|
49
|
+
deadline_hours: float | None = None,
|
|
50
|
+
blocking_others: bool = False,
|
|
51
|
+
) -> str | None:
|
|
52
|
+
"""Return the ONE chip for a conversation, or ``None`` for non-``act``.
|
|
53
|
+
|
|
54
|
+
P0 · Now = (roster-high AND (direct ask OR any stated deadline))
|
|
55
|
+
OR hard deadline < 48h OR blocking others.
|
|
56
|
+
P1 · Today = a direct ask on the owner.
|
|
57
|
+
P2 · This week = every other act row.
|
|
58
|
+
Roster tier alone NEVER makes P0.
|
|
59
|
+
"""
|
|
60
|
+
if bucket != "act":
|
|
61
|
+
return None
|
|
62
|
+
has_deadline = deadline_hours is not None
|
|
63
|
+
if (
|
|
64
|
+
(roster_high and (direct_ask or has_deadline))
|
|
65
|
+
or (has_deadline and deadline_hours < 48)
|
|
66
|
+
or blocking_others
|
|
67
|
+
):
|
|
68
|
+
return CHIP_P0
|
|
69
|
+
if direct_ask:
|
|
70
|
+
return CHIP_P1
|
|
71
|
+
return CHIP_P2
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# --------------------------------------------------------------------------
|
|
75
|
+
# Desired-state category diff (codex-r2 i — preservation, never a bare set)
|
|
76
|
+
# --------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
def desired_categories(existing: Iterable[str], chip: str | None) -> list[str]:
|
|
79
|
+
"""``existing − MANAGED + {chip}`` with the owner's categories preserved
|
|
80
|
+
in their original order; ``chip=None`` strips every managed category."""
|
|
81
|
+
if chip is not None and chip not in CHIPS:
|
|
82
|
+
raise ValueError(f"not a managed P-chip: {chip!r}")
|
|
83
|
+
kept = [c for c in existing if c not in MANAGED_CATEGORIES]
|
|
84
|
+
return kept + ([chip] if chip else [])
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def verify_write(
|
|
88
|
+
pre_categories: Iterable[str], post_categories: Iterable[str], chip: str | None
|
|
89
|
+
) -> bool:
|
|
90
|
+
"""A chip write verifies ONLY when the entire post-write set is right:
|
|
91
|
+
the chip present (or absent, on removal) AND the non-managed subset
|
|
92
|
+
unchanged. Order of the owner's categories is not significant."""
|
|
93
|
+
pre_kept = sorted(c for c in pre_categories if c not in MANAGED_CATEGORIES)
|
|
94
|
+
post = list(post_categories)
|
|
95
|
+
post_kept = sorted(c for c in post if c not in MANAGED_CATEGORIES)
|
|
96
|
+
if pre_kept != post_kept:
|
|
97
|
+
return False
|
|
98
|
+
managed_present = [c for c in post if c in MANAGED_CATEGORIES]
|
|
99
|
+
return managed_present == ([chip] if chip else [])
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# --------------------------------------------------------------------------
|
|
103
|
+
# Message-level apply under the conversation abstraction + journal recovery
|
|
104
|
+
# --------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
class MailboxAdapter(Protocol):
|
|
107
|
+
"""The minimal mutation surface (fake in tests, Chrome/REST in life)."""
|
|
108
|
+
|
|
109
|
+
def inbox_message_ids(self, conversation_id: str) -> list[str]: ...
|
|
110
|
+
def get_categories(self, message_id: str) -> list[str]: ...
|
|
111
|
+
def set_categories(self, message_id: str, categories: list[str]) -> None: ...
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def apply_chip_to_conversation(
|
|
115
|
+
mailbox: MailboxAdapter, conversation_id: str, chip: str | None
|
|
116
|
+
) -> list[dict[str, Any]]:
|
|
117
|
+
"""Apply (or, with ``chip=None``, remove) the conversation's chip on EVERY
|
|
118
|
+
message currently in Inbox. Per message: compute the preserved desired
|
|
119
|
+
set, write it, RE-READ SERVER STATE, and journal the row with its
|
|
120
|
+
verification result. A failure on one message never aborts the rest.
|
|
121
|
+
|
|
122
|
+
Journal rows: ``{conversation_id, message_id, chip, verification}`` with
|
|
123
|
+
verification ∈ ``response-confirmed`` (server re-read verified) ·
|
|
124
|
+
``verified-failed`` (write landed wrong / silently dropped) ·
|
|
125
|
+
``error:<msg>`` (the mutation call raised).
|
|
126
|
+
"""
|
|
127
|
+
journal: list[dict[str, Any]] = []
|
|
128
|
+
for mid in mailbox.inbox_message_ids(conversation_id):
|
|
129
|
+
row = {"conversation_id": conversation_id, "message_id": mid, "chip": chip}
|
|
130
|
+
try:
|
|
131
|
+
pre = list(mailbox.get_categories(mid))
|
|
132
|
+
mailbox.set_categories(mid, desired_categories(pre, chip))
|
|
133
|
+
post = list(mailbox.get_categories(mid)) # server re-read, never trust the write
|
|
134
|
+
row["verification"] = (
|
|
135
|
+
"response-confirmed" if verify_write(pre, post, chip) else "verified-failed"
|
|
136
|
+
)
|
|
137
|
+
except Exception as exc: # fault-injection boundary: journal, continue
|
|
138
|
+
row["verification"] = f"error:{exc}"
|
|
139
|
+
journal.append(row)
|
|
140
|
+
return journal
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def rows_needing_recovery(journal: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
144
|
+
"""Journal rows whose write is NOT server-confirmed — the re-apply set."""
|
|
145
|
+
return [r for r in journal if r.get("verification") != "response-confirmed"]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def recover_from_journal(
|
|
149
|
+
mailbox: MailboxAdapter, journal: Iterable[dict[str, Any]]
|
|
150
|
+
) -> list[dict[str, Any]]:
|
|
151
|
+
"""Re-drive every unconfirmed journal row, idempotently: a row whose
|
|
152
|
+
server state already matches the desired set verifies as
|
|
153
|
+
``already-applied`` without a write; otherwise re-apply + re-verify.
|
|
154
|
+
Returns the recovery journal (same row shape)."""
|
|
155
|
+
out: list[dict[str, Any]] = []
|
|
156
|
+
for r in rows_needing_recovery(journal):
|
|
157
|
+
mid, chip = r["message_id"], r["chip"]
|
|
158
|
+
row = {**r}
|
|
159
|
+
try:
|
|
160
|
+
current = list(mailbox.get_categories(mid))
|
|
161
|
+
if verify_write(current, current, chip):
|
|
162
|
+
row["verification"] = "already-applied"
|
|
163
|
+
else:
|
|
164
|
+
mailbox.set_categories(mid, desired_categories(current, chip))
|
|
165
|
+
post = list(mailbox.get_categories(mid))
|
|
166
|
+
row["verification"] = (
|
|
167
|
+
"response-confirmed" if verify_write(current, post, chip)
|
|
168
|
+
else "verified-failed"
|
|
169
|
+
)
|
|
170
|
+
except Exception as exc:
|
|
171
|
+
row["verification"] = f"error:{exc}"
|
|
172
|
+
out.append(row)
|
|
173
|
+
return out
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# --------------------------------------------------------------------------
|
|
177
|
+
# Lifecycle v4.7 (LIF-01/02/03) — desired-state reconciliation, never
|
|
178
|
+
# rule-ordering: every conversation's desired chip is recomputed from the
|
|
179
|
+
# FULL current evidence each night, so a reply followed by NEW actionable
|
|
180
|
+
# inbound still chips (a stale rule-order clear-on-reply would silently drop
|
|
181
|
+
# it under the "no chip = don't look" runbook rule — codex-r2 (i)).
|
|
182
|
+
# --------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
#: the ONLY trigger that clears a chip outright — every other signal below
|
|
185
|
+
#: (thread-closed / meeting-passed / handled-by-others) may only de-escalate.
|
|
186
|
+
CLEAR_TRIGGER = "owner_reply_is_latest_no_open_items"
|
|
187
|
+
DEESCALATE_TRIGGERS = frozenset({"thread_closed", "meeting_passed", "handled_by_others"})
|
|
188
|
+
_DEESCALATE_STEP = {CHIP_P0: CHIP_P1, CHIP_P1: CHIP_P2, CHIP_P2: CHIP_P2}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def desired_chip_and_trigger(
|
|
192
|
+
*,
|
|
193
|
+
bucket: str,
|
|
194
|
+
roster_high: bool = False,
|
|
195
|
+
direct_ask: bool = False,
|
|
196
|
+
deadline_hours: float | None = None,
|
|
197
|
+
blocking_others: bool = False,
|
|
198
|
+
owner_reply_is_latest: bool = False,
|
|
199
|
+
has_unsent_draft: bool = False,
|
|
200
|
+
has_flag: bool = False,
|
|
201
|
+
has_open_commitment: bool = False,
|
|
202
|
+
has_pending_deadline: bool = False,
|
|
203
|
+
thread_closed: bool = False,
|
|
204
|
+
meeting_passed: bool = False,
|
|
205
|
+
handled_by_others: bool = False,
|
|
206
|
+
) -> tuple[str | None, str]:
|
|
207
|
+
"""Nightly desired-state reconciliation for ONE conversation, over the
|
|
208
|
+
FULL current evidence (LIF-01/02, hardened (i)/(ii)).
|
|
209
|
+
|
|
210
|
+
Clear (chip=None) fires ONLY on the closed enum: owner's reply is the
|
|
211
|
+
LATEST message (no later inbound), AND no unsent draft, no flag, no open
|
|
212
|
+
spine commitment, no pending deadline — every clear ledgers this trigger
|
|
213
|
+
verbatim. 'thread_closed' / 'meeting_passed' / 'handled_by_others' NEVER
|
|
214
|
+
clear alone; at most they de-escalate one level (P0->P1->P2, floor P2).
|
|
215
|
+
"""
|
|
216
|
+
if (
|
|
217
|
+
owner_reply_is_latest
|
|
218
|
+
and not has_unsent_draft
|
|
219
|
+
and not has_flag
|
|
220
|
+
and not has_open_commitment
|
|
221
|
+
and not has_pending_deadline
|
|
222
|
+
):
|
|
223
|
+
return None, CLEAR_TRIGGER
|
|
224
|
+
|
|
225
|
+
chip = assign_chip(
|
|
226
|
+
bucket=bucket, roster_high=roster_high, direct_ask=direct_ask,
|
|
227
|
+
deadline_hours=deadline_hours, blocking_others=blocking_others,
|
|
228
|
+
)
|
|
229
|
+
if chip is None:
|
|
230
|
+
return None, "not-act"
|
|
231
|
+
|
|
232
|
+
if handled_by_others or thread_closed or meeting_passed:
|
|
233
|
+
trigger = ("handled_by_others" if handled_by_others
|
|
234
|
+
else "thread_closed" if thread_closed else "meeting_passed")
|
|
235
|
+
return _DEESCALATE_STEP[chip], trigger
|
|
236
|
+
|
|
237
|
+
return chip, "assignment"
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def reconcile_action(existing_chip: str | None, desired: str | None) -> str:
|
|
241
|
+
"""Diff existing vs. desired -> the ledger action bucket."""
|
|
242
|
+
if existing_chip == desired:
|
|
243
|
+
return "none"
|
|
244
|
+
if existing_chip is None:
|
|
245
|
+
return "added"
|
|
246
|
+
if desired is None:
|
|
247
|
+
return "cleared"
|
|
248
|
+
return "re-leveled"
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def ledger_entry(
|
|
252
|
+
conversation_id: str, *, existing_chip: str | None, desired: str | None, trigger: str,
|
|
253
|
+
) -> dict[str, Any]:
|
|
254
|
+
"""One CHIP LEDGER row (LIF-03) — added/re-leveled/cleared, with reason."""
|
|
255
|
+
return {
|
|
256
|
+
"conversation_id": conversation_id,
|
|
257
|
+
"action": reconcile_action(existing_chip, desired),
|
|
258
|
+
"from": existing_chip,
|
|
259
|
+
"to": desired,
|
|
260
|
+
"trigger": trigger,
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def apply_relevel_to_conversation(
|
|
265
|
+
mailbox: MailboxAdapter, conversation_id: str, old_chip: str, new_chip: str,
|
|
266
|
+
) -> list[dict[str, Any]]:
|
|
267
|
+
"""Re-level a conversation ADD-BEFORE-REMOVE (hardened (b)): every
|
|
268
|
+
message first gets BOTH chips written (transient two-chip state is
|
|
269
|
+
acceptable), then the old chip is stripped in a second pass. A zero-chip
|
|
270
|
+
gap never occurs even under a mid-pass failure — the next nightly
|
|
271
|
+
reconciliation heals any partial state, since desired-state diff is
|
|
272
|
+
idempotent (add step re-detects "already has new_chip", remove step
|
|
273
|
+
re-detects "already single-chip")."""
|
|
274
|
+
journal: list[dict[str, Any]] = []
|
|
275
|
+
mids = mailbox.inbox_message_ids(conversation_id)
|
|
276
|
+
for mid in mids:
|
|
277
|
+
row = {"conversation_id": conversation_id, "message_id": mid,
|
|
278
|
+
"chip": new_chip, "step": "add-new"}
|
|
279
|
+
try:
|
|
280
|
+
pre = list(mailbox.get_categories(mid))
|
|
281
|
+
kept = [c for c in pre if c not in MANAGED_CATEGORIES]
|
|
282
|
+
both = kept + [c for c in (old_chip, new_chip) if c not in kept]
|
|
283
|
+
mailbox.set_categories(mid, both)
|
|
284
|
+
post = list(mailbox.get_categories(mid))
|
|
285
|
+
row["verification"] = (
|
|
286
|
+
"response-confirmed" if new_chip in post and old_chip in post
|
|
287
|
+
else "verified-failed"
|
|
288
|
+
)
|
|
289
|
+
except Exception as exc:
|
|
290
|
+
row["verification"] = f"error:{exc}"
|
|
291
|
+
journal.append(row)
|
|
292
|
+
for mid in mids:
|
|
293
|
+
row = {"conversation_id": conversation_id, "message_id": mid,
|
|
294
|
+
"chip": new_chip, "step": "remove-old"}
|
|
295
|
+
try:
|
|
296
|
+
pre = list(mailbox.get_categories(mid))
|
|
297
|
+
mailbox.set_categories(mid, desired_categories(pre, new_chip))
|
|
298
|
+
post = list(mailbox.get_categories(mid))
|
|
299
|
+
row["verification"] = (
|
|
300
|
+
"response-confirmed" if verify_write(pre, post, new_chip) else "verified-failed"
|
|
301
|
+
)
|
|
302
|
+
except Exception as exc:
|
|
303
|
+
row["verification"] = f"error:{exc}"
|
|
304
|
+
journal.append(row)
|
|
305
|
+
return journal
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# --------------------------------------------------------------------------
|
|
309
|
+
# Automated-sender P2 dedupe (s02 finding, folded into v4.7 assignment)
|
|
310
|
+
# --------------------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
def dedupe_automated_p2(rows: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
313
|
+
"""Collapse recurring-automated-sender act rows to ONE P2 chip per
|
|
314
|
+
sender per cycle. ``rows``: dicts carrying ``chip`` and ``automated_sender``
|
|
315
|
+
(the overlay-driven flag naming a known recurring bulk sender, e.g. an
|
|
316
|
+
automated notification system) and ``sender``. Only P2 rows from a
|
|
317
|
+
flagged automated sender are collapsed — P0/P1 rows (a direct ask or
|
|
318
|
+
deadline from that sender) are never suppressed."""
|
|
319
|
+
seen: set[str] = set()
|
|
320
|
+
out: list[dict[str, Any]] = []
|
|
321
|
+
for r in rows:
|
|
322
|
+
if r.get("chip") == CHIP_P2 and r.get("automated_sender"):
|
|
323
|
+
key = r["sender"]
|
|
324
|
+
if key in seen:
|
|
325
|
+
continue
|
|
326
|
+
seen.add(key)
|
|
327
|
+
out.append(r)
|
|
328
|
+
return out
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# --------------------------------------------------------------------------
|
|
332
|
+
# Mutation lease (codex-r3 — one mutator at a time)
|
|
333
|
+
# --------------------------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
def lease_state(
|
|
336
|
+
lease: dict[str, Any] | None, *, now: datetime, run_id: str
|
|
337
|
+
) -> tuple[str, str | None]:
|
|
338
|
+
"""Judge ``cos-ops/_mutation_lease.json`` content for a run.
|
|
339
|
+
|
|
340
|
+
Returns ``(state, holder)`` — state ∈:
|
|
341
|
+
``clear`` no lease / our own lease ⇒ mutations allowed;
|
|
342
|
+
``held`` unexpired foreign lease (or malformed — unreadable intent
|
|
343
|
+
fails closed) ⇒ ZERO mailbox mutations this pass;
|
|
344
|
+
``expired`` stale foreign lease ⇒ ignored but REPORTED in the banner.
|
|
345
|
+
"""
|
|
346
|
+
if lease is None:
|
|
347
|
+
return "clear", None
|
|
348
|
+
holder = lease.get("owner") or lease.get("run_id") or "unknown"
|
|
349
|
+
try:
|
|
350
|
+
if not isinstance(lease, dict) or not lease.get("run_id"):
|
|
351
|
+
return "held", str(holder)
|
|
352
|
+
if lease["run_id"] == run_id:
|
|
353
|
+
return "clear", str(holder)
|
|
354
|
+
exp = datetime.fromisoformat(str(lease["ttl_expires"]))
|
|
355
|
+
if exp.tzinfo is None:
|
|
356
|
+
exp = exp.replace(tzinfo=timezone.utc)
|
|
357
|
+
except Exception:
|
|
358
|
+
return "held", str(holder) # malformed ⇒ fail closed, reported
|
|
359
|
+
if exp <= now:
|
|
360
|
+
return "expired", str(holder)
|
|
361
|
+
return "held", str(holder)
|
|
@@ -457,7 +457,56 @@ class BrainIndex:
|
|
|
457
457
|
|
|
458
458
|
# -- build (full) -----------------------------------------------------
|
|
459
459
|
def rebuild(self, vault: Path) -> dict[str, Any]:
|
|
460
|
-
"""
|
|
460
|
+
"""Rebuild the entire index from vault/ — crash-safe.
|
|
461
|
+
|
|
462
|
+
Builds into a TEMP db file and atomically swaps it into place on
|
|
463
|
+
success (same posture as ``snapshot.publish_snapshot``), so a killed
|
|
464
|
+
rebuild leaves the prior live index untouched. Before this fix,
|
|
465
|
+
``_create_schema`` dropped the live tables FIRST and inserts landed at
|
|
466
|
+
the end — a kill mid-rebuild (a full re-embed of a large vault can run
|
|
467
|
+
an hour+) left the live index wiped (field report 2026-07-16: a killed
|
|
468
|
+
rebuild on a 2,312-note vault left 0 notes / 0 chunks).
|
|
469
|
+
|
|
470
|
+
In-memory DBs (``:memory:``, test-only) build in place — there is
|
|
471
|
+
nothing to swap."""
|
|
472
|
+
if self.db_path == Path(":memory:"):
|
|
473
|
+
return self._rebuild_impl(vault)
|
|
474
|
+
|
|
475
|
+
tmp_path = self.db_path.with_name(self.db_path.name + f".rebuild.tmp.{os.getpid()}")
|
|
476
|
+
for p in (tmp_path, Path(str(tmp_path) + "-wal"), Path(str(tmp_path) + "-shm")):
|
|
477
|
+
p.unlink(missing_ok=True)
|
|
478
|
+
|
|
479
|
+
orig_db_path = self.db_path
|
|
480
|
+
self.close()
|
|
481
|
+
self.db_path = tmp_path
|
|
482
|
+
try:
|
|
483
|
+
result = self._rebuild_impl(vault)
|
|
484
|
+
# Checkpoint the temp DB's WAL into its main file so the file we
|
|
485
|
+
# swap in is fully self-contained (mirrors snapshot.py's
|
|
486
|
+
# wal_checkpoint(TRUNCATE) before publish).
|
|
487
|
+
self.conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
488
|
+
self.close()
|
|
489
|
+
os.replace(tmp_path, orig_db_path)
|
|
490
|
+
except BaseException:
|
|
491
|
+
self.close()
|
|
492
|
+
self.db_path = orig_db_path
|
|
493
|
+
for p in (tmp_path, Path(str(tmp_path) + "-wal"), Path(str(tmp_path) + "-shm")):
|
|
494
|
+
p.unlink(missing_ok=True)
|
|
495
|
+
raise
|
|
496
|
+
finally:
|
|
497
|
+
for p in (tmp_path, Path(str(tmp_path) + "-wal"), Path(str(tmp_path) + "-shm")):
|
|
498
|
+
p.unlink(missing_ok=True)
|
|
499
|
+
# The just-replaced live DB has no matching sidecars of its own yet
|
|
500
|
+
# (the temp ones were checkpointed away above) — drop any stale
|
|
501
|
+
# leftovers from the PRIOR live DB so the next open starts clean.
|
|
502
|
+
for suffix in ("-wal", "-shm"):
|
|
503
|
+
Path(str(orig_db_path) + suffix).unlink(missing_ok=True)
|
|
504
|
+
self.db_path = orig_db_path
|
|
505
|
+
result["db"] = str(self.db_path)
|
|
506
|
+
return result
|
|
507
|
+
|
|
508
|
+
def _rebuild_impl(self, vault: Path) -> dict[str, Any]:
|
|
509
|
+
"""Drop and rebuild the tables at ``self.db_path`` in place.
|
|
461
510
|
|
|
462
511
|
S11 indexing speed fix: chunking / embedding / writing are now THREE
|
|
463
512
|
separate passes. Previously ``_insert_note`` was called once per note
|
|
@@ -18,6 +18,7 @@ src/brain/connect.py
|
|
|
18
18
|
src/brain/context.py
|
|
19
19
|
src/brain/core.py
|
|
20
20
|
src/brain/cos.py
|
|
21
|
+
src/brain/cos_chips.py
|
|
21
22
|
src/brain/doctor.py
|
|
22
23
|
src/brain/egress.py
|
|
23
24
|
src/brain/embed.py
|
|
@@ -43,6 +44,7 @@ src/brain/update.py
|
|
|
43
44
|
src/brain/vectors.py
|
|
44
45
|
src/brain/_assets/AGENTS.md
|
|
45
46
|
src/brain/_assets/overlay/template/brand/brand-guide.md
|
|
47
|
+
src/brain/_assets/overlay/template/cos/drafts.md
|
|
46
48
|
src/brain/_assets/overlay/template/cos/priorities.md
|
|
47
49
|
src/brain/_assets/overlay/template/keywords/glossary.md
|
|
48
50
|
src/brain/_assets/overlay/template/people/roster.md
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
overlay_type: cos
|
|
3
|
-
title: "COS priority overrides — starter template"
|
|
4
|
-
updated: 2026-07-13
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
# COS priority overrides
|
|
8
|
-
|
|
9
|
-
OPTIONAL category (CUT-01E). `brain cos-priority-map` generates the
|
|
10
|
-
VM-readable priority map from your `type: person` / `type: company` notes;
|
|
11
|
-
list ids below to override the computed placement. One list line per note:
|
|
12
|
-
|
|
13
|
-
`- <note-id>: high|normal|low|exclude`
|
|
14
|
-
|
|
15
|
-
<!-- examples (replace with your own note ids):
|
|
16
|
-
- contoso-acquisition-lead: high
|
|
17
|
-
- northwind-vendor-contact: low
|
|
18
|
-
- retired-supplier-x: exclude
|
|
19
|
-
-->
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/overlay/template/brand/brand-guide.md
RENAMED
|
File without changes
|
{brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/overlay/template/keywords/glossary.md
RENAMED
|
File without changes
|
{brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/overlay/template/people/roster.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/scripts/brain-synthesis-mac.plist
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/scripts/install-brief-windows.ps1
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|