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
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# brain-synthesis — the SECOND sanctioned host scheduled task (weekly).
|
|
3
|
+
#
|
|
4
|
+
# Owner decision 2026-07-11: metadata automation (the nightly maintain folds)
|
|
5
|
+
# closed every organization gap EXCEPT synthesis — state/MOC notes drift
|
|
6
|
+
# behind raw/ because writing prose needs a model, which the engine
|
|
7
|
+
# deliberately does not hold (no API keys in brain). This task closes that
|
|
8
|
+
# gap: once a week it runs a HEADLESS kb-curator session (Claude Code CLI)
|
|
9
|
+
# against EVERY vault registered in ~/.brainiac/workspaces.json — vault-
|
|
10
|
+
# generic by construction, never hardcoded to one vault.
|
|
11
|
+
#
|
|
12
|
+
# Task name: brain-synthesis (launchd label com.brainiac.synthesis)
|
|
13
|
+
# Schedule: weekly, Sunday 08:00 local (one hour after the 07:00 nightly,
|
|
14
|
+
# so the week's promote-scan + digest output is already fresh)
|
|
15
|
+
# Budget: routines/manifest.json locked_counts.host_os_scheduled == 2
|
|
16
|
+
# (amended 2026-07-11 for exactly this task — THE LOCK rule
|
|
17
|
+
# says amend the manifest first; this is that amendment's task)
|
|
18
|
+
# Runtime: host only. Each session is bounded (--max-turns) and scoped
|
|
19
|
+
# to the workspace folder; it uses the same `brain` CLI +
|
|
20
|
+
# skills the interactive sessions use.
|
|
21
|
+
# Logs: $BRAIN_LOG_DIR/synthesis-YYYY-MM-DD.log (30-day rotation)
|
|
22
|
+
# Skip rules: no claude CLI -> skip loudly; a workspace without a
|
|
23
|
+
# kb-curator skill -> skipped and logged, never an error.
|
|
24
|
+
set -u
|
|
25
|
+
|
|
26
|
+
LOG_DIR="${BRAIN_LOG_DIR:-$HOME/.brain/logs}"
|
|
27
|
+
mkdir -p "$LOG_DIR"
|
|
28
|
+
LOG="$LOG_DIR/synthesis-$(date +%F).log"
|
|
29
|
+
REGISTRY="${BRAIN_WORKSPACES_JSON:-$HOME/.brainiac/workspaces.json}"
|
|
30
|
+
CLAUDE_BIN="${BRAIN_CLAUDE_BIN:-$(command -v claude || echo "$HOME/.local/bin/claude")}"
|
|
31
|
+
MAX_TURNS="${BRAIN_SYNTHESIS_MAX_TURNS:-60}"
|
|
32
|
+
|
|
33
|
+
log() { printf '%s %s\n' "$(date '+%F %T')" "$*" >> "$LOG"; }
|
|
34
|
+
|
|
35
|
+
# 30-day log rotation (same posture as brain-brief.sh)
|
|
36
|
+
find "$LOG_DIR" -name 'synthesis-*.log' -mtime +30 -delete 2>/dev/null
|
|
37
|
+
|
|
38
|
+
if [ ! -x "$CLAUDE_BIN" ]; then
|
|
39
|
+
log "SKIP-ALL: claude CLI not found ($CLAUDE_BIN) — synthesis needs a model"
|
|
40
|
+
exit 0
|
|
41
|
+
fi
|
|
42
|
+
if [ ! -f "$REGISTRY" ]; then
|
|
43
|
+
log "SKIP-ALL: no workspace registry at $REGISTRY"
|
|
44
|
+
exit 0
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
PROMPT="$(cat <<'EOF'
|
|
48
|
+
Run the kb-curator skill for this vault, weekly synthesis pass. FIRST run
|
|
49
|
+
`brain status --json` and `brain doctor`: if the maintain heartbeat is stale
|
|
50
|
+
(daily branch > 48h) or any required surface is flagged, queue a dated entry
|
|
51
|
+
to .brain/memory/hot.md describing what is broken — the scheduled umbrella
|
|
52
|
+
cannot report its own death, so this weekly session is its watchdog. Then
|
|
53
|
+
the synthesis scope:
|
|
54
|
+
(1) refresh any state/MOC note whose content lags this week's raw/ ingests
|
|
55
|
+
(check `brain recent` and the freshness signal on `brain search`); supersede
|
|
56
|
+
rather than edit when the old claim was true-then; (2) review the Sunday
|
|
57
|
+
promote-scan candidates in .brain/memory/hot.md and promote the ones that
|
|
58
|
+
meet the one-idea-per-note bar into typed brain/ notes; (3) update index.md
|
|
59
|
+
zone stamps. Follow AGENTS.md conventions exactly (frontmatter, wikilinks,
|
|
60
|
+
classification explicit on every new note). Finish with `brain sync --publish`
|
|
61
|
+
and a one-paragraph summary of what changed. Stay inside this workspace.
|
|
62
|
+
EOF
|
|
63
|
+
)"
|
|
64
|
+
|
|
65
|
+
# Iterate REGISTERED host workspaces (dedup by vault_path) — vault-generic:
|
|
66
|
+
# a new `brainiac-install`/`brainiac-cowork-setup` registration is picked up
|
|
67
|
+
# on the next Sunday with zero extra wiring.
|
|
68
|
+
python3 - "$REGISTRY" <<'PY' | while IFS=$'\t' read -r VAULT WS; do
|
|
69
|
+
import json, sys
|
|
70
|
+
seen = set()
|
|
71
|
+
for e in json.load(open(sys.argv[1])).get("entries", []):
|
|
72
|
+
if e.get("target") != "host":
|
|
73
|
+
continue
|
|
74
|
+
v, w = e.get("vault_path", ""), e.get("workspace_path", "")
|
|
75
|
+
if not v or v in seen:
|
|
76
|
+
continue
|
|
77
|
+
seen.add(v)
|
|
78
|
+
print(f"{v}\t{w or v}")
|
|
79
|
+
PY
|
|
80
|
+
if [ ! -d "$WS/.claude/skills/kb-curator" ] && [ ! -d "$WS/.agents/skills/kb-curator" ]; then
|
|
81
|
+
log "SKIP $VAULT: no kb-curator skill in $WS"
|
|
82
|
+
continue
|
|
83
|
+
fi
|
|
84
|
+
log "START synthesis: vault=$VAULT workspace=$WS"
|
|
85
|
+
# OBS-04: capture the CLI's `--output-format stream-json` NDJSON to OUT_JSON
|
|
86
|
+
# via a DIRECT redirect — NEVER a `tee` pipe (finding [6]: piping claude's
|
|
87
|
+
# stdout couples its liveness to the log sink, so a full/unwritable $LOG
|
|
88
|
+
# would SIGPIPE-kill an otherwise-healthy run and trip a false "synthesis
|
|
89
|
+
# died" watchdog). The direct-to-file NDJSON is durable and survives a kill
|
|
90
|
+
# (each line is flushed as emitted); cost/duration/usage are lifted below
|
|
91
|
+
# from its single `"type":"result"` line — STRUCTURED data only, never
|
|
92
|
+
# scraped human text (a scraped format drifts silently to a wrong zero —
|
|
93
|
+
# HARDENED correction 4). The CLI requires --verbose alongside stream-json.
|
|
94
|
+
# A per-run unique name (date + pid + two $RANDOM draws) avoids the
|
|
95
|
+
# same-second collision the old date-second+pid name allowed (finding [9]).
|
|
96
|
+
OUT_JSON="$LOG_DIR/synthesis-out-$(date +%F)-$$-${RANDOM}${RANDOM}.json"
|
|
97
|
+
( cd "$WS" && BRAIN_VAULT="$VAULT" "$CLAUDE_BIN" -p "$PROMPT" \
|
|
98
|
+
--max-turns "$MAX_TURNS" \
|
|
99
|
+
--permission-mode acceptEdits \
|
|
100
|
+
--output-format stream-json \
|
|
101
|
+
--verbose \
|
|
102
|
+
--allowedTools "Read,Grep,Glob,Edit,Write,Bash(brain *),Bash(python3 tools/*)" \
|
|
103
|
+
) > "$OUT_JSON" 2>>"$LOG"
|
|
104
|
+
RC=$?
|
|
105
|
+
log "END synthesis: vault=$VAULT rc=$RC"
|
|
106
|
+
# Render a human-readable transcript from the structured NDJSON into $LOG
|
|
107
|
+
# (finding [5]): the prior `tee`'d raw NDJSON REPLACED the readable session
|
|
108
|
+
# transcript the weekly-watchdog prompt tells an operator to open. Structured
|
|
109
|
+
# fields only (assistant text turns + the final result) — never scraped
|
|
110
|
+
# prose. A killed run renders whatever streamed before the kill; the full
|
|
111
|
+
# machine NDJSON stays in $OUT_JSON (30-day retention) regardless.
|
|
112
|
+
python3 - "$OUT_JSON" >> "$LOG" 2>>"$LOG" <<'PY' || true
|
|
113
|
+
import json, sys
|
|
114
|
+
try:
|
|
115
|
+
with open(sys.argv[1]) as fh:
|
|
116
|
+
for raw in fh:
|
|
117
|
+
raw = raw.strip()
|
|
118
|
+
if not raw:
|
|
119
|
+
continue
|
|
120
|
+
try:
|
|
121
|
+
obj = json.loads(raw)
|
|
122
|
+
except Exception:
|
|
123
|
+
continue
|
|
124
|
+
if not isinstance(obj, dict):
|
|
125
|
+
continue
|
|
126
|
+
if obj.get("type") == "assistant":
|
|
127
|
+
for b in (obj.get("message") or {}).get("content", []) or []:
|
|
128
|
+
if isinstance(b, dict) and b.get("type") == "text":
|
|
129
|
+
t = (b.get("text") or "").strip()
|
|
130
|
+
if t:
|
|
131
|
+
print(t)
|
|
132
|
+
elif obj.get("type") == "result":
|
|
133
|
+
r = (obj.get("result") or "").strip()
|
|
134
|
+
if r:
|
|
135
|
+
print("--- result ---")
|
|
136
|
+
print(r)
|
|
137
|
+
except Exception:
|
|
138
|
+
pass
|
|
139
|
+
PY
|
|
140
|
+
# Heartbeat (WATCHDOG-01): the hourly maintain umbrella reads this file and
|
|
141
|
+
# raises action_required when the last successful synthesis is > 8 days old
|
|
142
|
+
# — nobody should have to read synthesis logs to learn the task died.
|
|
143
|
+
# OBS-04: the same entry is extended with {tokens, duration_s, est_cost_usd}
|
|
144
|
+
# so src/brain/maintenance.py's collect_health_metrics can lift the latest
|
|
145
|
+
# metered cost into health-history.jsonl. Honors $BRAIN_SYNTHESIS_STATE (the
|
|
146
|
+
# SAME override the Python readers consult — finding [3]: the writer used to
|
|
147
|
+
# hardcode $HOME, so a set env silently split writer and reader and the
|
|
148
|
+
# watchdog never fired).
|
|
149
|
+
STATE="${BRAIN_SYNTHESIS_STATE:-$HOME/.brain/synthesis-state.json}"
|
|
150
|
+
# Finding [3] companion: create the state file's parent dir before writing —
|
|
151
|
+
# a $BRAIN_SYNTHESIS_STATE override can point at a not-yet-existing dir, and
|
|
152
|
+
# a silent write failure there would freeze last_success and trip a FALSE
|
|
153
|
+
# "synthesis stale" watchdog even though synthesis ran fine.
|
|
154
|
+
mkdir -p "$(dirname "$STATE")" 2>>"$LOG" || true
|
|
155
|
+
python3 - "$STATE" "$VAULT" "$RC" "$OUT_JSON" <<'PY'
|
|
156
|
+
import json, sys, datetime
|
|
157
|
+
path, vault, rc, out_json = sys.argv[1], sys.argv[2], int(sys.argv[3]), sys.argv[4]
|
|
158
|
+
try:
|
|
159
|
+
state = json.load(open(path))
|
|
160
|
+
except Exception:
|
|
161
|
+
state = {}
|
|
162
|
+
entry = state.setdefault(vault, {})
|
|
163
|
+
entry["last_attempt"] = datetime.date.today().isoformat()
|
|
164
|
+
entry["rc"] = rc
|
|
165
|
+
if rc == 0:
|
|
166
|
+
entry["last_success"] = entry["last_attempt"]
|
|
167
|
+
|
|
168
|
+
# OBS-04 — meter from the structured --output-format stream-json NDJSON
|
|
169
|
+
# stream ONLY: scan every line, keep the (last) line with "type":"result" —
|
|
170
|
+
# that's the CLI's own final usage/cost summary for the run. Absent/zero/
|
|
171
|
+
# unparseable all record as null (never a scraped guess, never a bare 0
|
|
172
|
+
# masquerading as "measured").
|
|
173
|
+
cost = tokens = duration_s = None
|
|
174
|
+
try:
|
|
175
|
+
result_obj = None
|
|
176
|
+
with open(out_json) as fh:
|
|
177
|
+
for raw_line in fh:
|
|
178
|
+
raw_line = raw_line.strip()
|
|
179
|
+
if not raw_line:
|
|
180
|
+
continue
|
|
181
|
+
try:
|
|
182
|
+
obj = json.loads(raw_line)
|
|
183
|
+
except Exception:
|
|
184
|
+
continue
|
|
185
|
+
if isinstance(obj, dict) and obj.get("type") == "result":
|
|
186
|
+
result_obj = obj
|
|
187
|
+
if result_obj is not None:
|
|
188
|
+
c = result_obj.get("total_cost_usd")
|
|
189
|
+
if isinstance(c, (int, float)) and c > 0:
|
|
190
|
+
cost = round(c, 4)
|
|
191
|
+
dm = result_obj.get("duration_ms")
|
|
192
|
+
if isinstance(dm, (int, float)):
|
|
193
|
+
duration_s = round(dm / 1000, 1)
|
|
194
|
+
usage = result_obj.get("usage") or {}
|
|
195
|
+
parts = [usage.get(k) for k in (
|
|
196
|
+
"input_tokens", "output_tokens",
|
|
197
|
+
"cache_creation_input_tokens", "cache_read_input_tokens")]
|
|
198
|
+
nums = [p for p in parts if isinstance(p, (int, float))]
|
|
199
|
+
if nums:
|
|
200
|
+
tokens = int(sum(nums))
|
|
201
|
+
except Exception:
|
|
202
|
+
pass
|
|
203
|
+
entry["tokens"] = tokens
|
|
204
|
+
entry["duration_s"] = duration_s
|
|
205
|
+
entry["est_cost_usd"] = cost
|
|
206
|
+
json.dump(state, open(path, "w"), indent=1)
|
|
207
|
+
PY
|
|
208
|
+
done
|
|
209
|
+
|
|
210
|
+
# 30-day rotation for the per-run usage-json captures (same posture as logs).
|
|
211
|
+
find "$LOG_DIR" -name 'synthesis-out-*.json' -mtime +30 -delete 2>/dev/null
|
|
212
|
+
|
|
213
|
+
log "synthesis run complete"
|
|
214
|
+
exit 0
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# install-brief-mac.sh — install brain-daily-brief as a macOS launchd LaunchAgent (UX-02)
|
|
3
|
+
#
|
|
4
|
+
# Usage:
|
|
5
|
+
# BRAIN_VAULT=/path/to/vault bash scripts/install-brief-mac.sh
|
|
6
|
+
#
|
|
7
|
+
# Prerequisites:
|
|
8
|
+
# - brain binary must be on PATH (test: brain --version)
|
|
9
|
+
# - BRAIN_AUDIT_KEY_PEM must be set OR the key stored in the macOS Keychain
|
|
10
|
+
# under service "profile-a-brain-audit-key" (security find-generic-password -s profile-a-brain-audit-key -w)
|
|
11
|
+
#
|
|
12
|
+
# Uninstall (per-vault label — the script prints the exact paths on install):
|
|
13
|
+
# launchctl unload ~/Library/LaunchAgents/com.brainiac.nightly.<id>.plist
|
|
14
|
+
# rm ~/Library/LaunchAgents/com.brainiac.nightly.<id>.plist
|
|
15
|
+
set -euo pipefail
|
|
16
|
+
|
|
17
|
+
VAULT="${BRAIN_VAULT:?BRAIN_VAULT must be set (path to your brain vault)}"
|
|
18
|
+
SCRIPTS="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
19
|
+
PLIST_SRC="$SCRIPTS/brain-brief-mac.plist"
|
|
20
|
+
|
|
21
|
+
# Per-vault launchd label (SINGLE SOURCE OF TRUTH: brain.config.nightly_label),
|
|
22
|
+
# so two registered vaults never install to one shared label and clobber each
|
|
23
|
+
# other's nightly job. Prefer the installed package; fall back to a shell
|
|
24
|
+
# reimplementation of vault_slug8 (sha256 of the resolved vault path, first 8).
|
|
25
|
+
LABEL=""
|
|
26
|
+
BRAIN_BIN="$(command -v brain || true)"
|
|
27
|
+
if [ -n "$BRAIN_BIN" ]; then
|
|
28
|
+
VENV_PY="$(dirname "$BRAIN_BIN")/python3"
|
|
29
|
+
[ -x "$VENV_PY" ] || VENV_PY="python3"
|
|
30
|
+
LABEL="$(BRAIN_VAULT="$VAULT" "$VENV_PY" - <<'PY' 2>/dev/null || true
|
|
31
|
+
import os
|
|
32
|
+
from brain.config import nightly_label
|
|
33
|
+
print(nightly_label(os.environ["BRAIN_VAULT"]))
|
|
34
|
+
PY
|
|
35
|
+
)"
|
|
36
|
+
fi
|
|
37
|
+
if [ -z "$LABEL" ]; then
|
|
38
|
+
_vr="$(python3 -c 'import os,sys; print(os.path.realpath(os.path.expanduser(sys.argv[1])))' "$VAULT" 2>/dev/null || echo "$VAULT")"
|
|
39
|
+
_h="$(printf '%s' "$_vr" | shasum -a 256 | cut -c1-8)"
|
|
40
|
+
LABEL="com.brainiac.nightly.$_h"
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
LEGACY_LABEL="com.profile-a-brain.daily-brief"
|
|
44
|
+
LEGACY_PLIST="$HOME/Library/LaunchAgents/$LEGACY_LABEL.plist"
|
|
45
|
+
PLIST_DST="$HOME/Library/LaunchAgents/$LABEL.plist"
|
|
46
|
+
LOGDIR="${BRAIN_LOG_DIR:-$HOME/.brain/logs}"
|
|
47
|
+
|
|
48
|
+
# Audit signing key: the drain resolves it from the macOS Keychain AT RUNTIME
|
|
49
|
+
# (src/brain/audit.py resolve_signing_key(), env -> keychain fallthrough), so
|
|
50
|
+
# the plist does NOT carry the key material. Baking the PEM into the plist
|
|
51
|
+
# (the old behavior) left the private key in a plaintext file under
|
|
52
|
+
# ~/Library/LaunchAgents. We only bake a value when the operator explicitly
|
|
53
|
+
# injected BRAIN_AUDIT_KEY_PEM (custom custody / unattended box) — and the
|
|
54
|
+
# plist is chmod 600 below either way.
|
|
55
|
+
KEYCHAIN_SERVICE="${BRAIN_AUDIT_KEYCHAIN_SERVICE:-profile-a-brain-audit-key}"
|
|
56
|
+
AUDIT_KEY="${BRAIN_AUDIT_KEY_PEM:-}"
|
|
57
|
+
if [ -z "$AUDIT_KEY" ]; then
|
|
58
|
+
if ! security find-generic-password -s "$KEYCHAIN_SERVICE" -a "$USER" >/dev/null 2>&1; then
|
|
59
|
+
echo "WARNING: No BRAIN_AUDIT_KEY_PEM and no Keychain entry for '$KEYCHAIN_SERVICE'." >&2
|
|
60
|
+
echo " Captures will not be signed (drain fails closed). Run 'brain audit-key'" >&2
|
|
61
|
+
echo " (create-if-absent, never rotates) — no reinstall needed after." >&2
|
|
62
|
+
fi
|
|
63
|
+
fi
|
|
64
|
+
|
|
65
|
+
mkdir -p "$LOGDIR" "$HOME/Library/LaunchAgents"
|
|
66
|
+
|
|
67
|
+
# Render the plist to a scratch file first so we can diff against whatever is
|
|
68
|
+
# already installed — the nightly-diff contract (docs/install/plugin-distribution.md
|
|
69
|
+
# §3): reinstall/reload iff the rendered body actually changed, otherwise report
|
|
70
|
+
# unchanged and touch nothing (idempotent re-runs, no needless launchctl churn).
|
|
71
|
+
PLIST_NEW="$(mktemp "${TMPDIR:-/tmp}/brain-brief-mac.plist.XXXXXX")"
|
|
72
|
+
trap 'rm -f "$PLIST_NEW"' EXIT
|
|
73
|
+
|
|
74
|
+
sed -e "s|LABEL_PLACEHOLDER|$LABEL|g" \
|
|
75
|
+
-e "s|SCRIPTS_DIR|$SCRIPTS|g" \
|
|
76
|
+
-e "s|VAULT_PATH|$VAULT|g" \
|
|
77
|
+
-e "s|HOME_DIR|$HOME|g" \
|
|
78
|
+
-e "s|AUDIT_KEY_PEM_PLACEHOLDER|$AUDIT_KEY|g" \
|
|
79
|
+
-e "s|SWEEP_DIRS_PLACEHOLDER|${BRAIN_WORKSPACE_SWEEP_DIRS:-}|g" \
|
|
80
|
+
"$PLIST_SRC" > "$PLIST_NEW"
|
|
81
|
+
|
|
82
|
+
# ponytail: real launchctl talks to the login launchd domain by uid, not by
|
|
83
|
+
# $HOME — a sandboxed HOME does NOT sandbox launchctl. BRAIN_LAUNCHD_DRY_RUN=1
|
|
84
|
+
# (or a non-default HOME, detected below) skips the real bootstrap/load calls
|
|
85
|
+
# so test/sandbox runs never touch the real user's launchd domain.
|
|
86
|
+
if [ -z "${BRAIN_LAUNCHD_DRY_RUN:-}" ] && [ "$HOME" != "$(eval echo ~"$USER")" ]; then
|
|
87
|
+
BRAIN_LAUNCHD_DRY_RUN=1
|
|
88
|
+
fi
|
|
89
|
+
|
|
90
|
+
# One-time migration off the legacy SHARED label: if the old single-label plist
|
|
91
|
+
# exists (and isn't this vault's per-vault plist), unload + remove it so the two
|
|
92
|
+
# host vaults stop competing for one launchd job. Idempotent — absent after the
|
|
93
|
+
# first migrated install.
|
|
94
|
+
if [ "$LEGACY_PLIST" != "$PLIST_DST" ] && [ -f "$LEGACY_PLIST" ]; then
|
|
95
|
+
if [ "${BRAIN_LAUNCHD_DRY_RUN:-0}" = "1" ]; then
|
|
96
|
+
echo "migration DRY-RUN: would retire legacy shared label $LEGACY_LABEL"
|
|
97
|
+
else
|
|
98
|
+
launchctl unload "$LEGACY_PLIST" 2>/dev/null || true
|
|
99
|
+
rm -f "$LEGACY_PLIST"
|
|
100
|
+
echo "migrated: retired legacy shared nightly label $LEGACY_LABEL (now per-vault)"
|
|
101
|
+
fi
|
|
102
|
+
fi
|
|
103
|
+
|
|
104
|
+
if [ -f "$PLIST_DST" ] && cmp -s "$PLIST_NEW" "$PLIST_DST"; then
|
|
105
|
+
echo "nightly: unchanged (label: $LABEL) — nothing to reload"
|
|
106
|
+
echo " Status: launchctl list $LABEL"
|
|
107
|
+
exit 0
|
|
108
|
+
fi
|
|
109
|
+
|
|
110
|
+
cp "$PLIST_NEW" "$PLIST_DST"
|
|
111
|
+
chmod 600 "$PLIST_DST"
|
|
112
|
+
|
|
113
|
+
if [ "${BRAIN_LAUNCHD_DRY_RUN:-0}" = "1" ]; then
|
|
114
|
+
echo "DRY-RUN (sandboxed HOME or BRAIN_LAUNCHD_DRY_RUN=1) — plist written, launchd NOT reloaded. Would run:"
|
|
115
|
+
echo " launchctl unload '$PLIST_DST'"
|
|
116
|
+
echo " launchctl load -w '$PLIST_DST'"
|
|
117
|
+
echo " To load it for real, re-run this from a normal (non-sandboxed) terminal, or run the two commands above."
|
|
118
|
+
else
|
|
119
|
+
# (Re)load the agent.
|
|
120
|
+
launchctl unload "$PLIST_DST" 2>/dev/null || true
|
|
121
|
+
launchctl load -w "$PLIST_DST"
|
|
122
|
+
fi
|
|
123
|
+
|
|
124
|
+
echo "✓ Installed: $LABEL (hourly umbrella, vault: $VAULT)"
|
|
125
|
+
|
|
126
|
+
# --- brain-synthesis (the SECOND sanctioned task; THE LOCK host=2) ---------
|
|
127
|
+
# One per HOST (label has no vault id): the script iterates every registered
|
|
128
|
+
# vault in ~/.brainiac/workspaces.json, so re-running this installer for a
|
|
129
|
+
# second vault just refreshes the same host-level agent. Weekly Sun 08:00.
|
|
130
|
+
SYNTH_SRC="$SCRIPTS/brain-synthesis-mac.plist"
|
|
131
|
+
SYNTH_DST="$HOME/Library/LaunchAgents/com.brainiac.synthesis.plist"
|
|
132
|
+
if [ -f "$SYNTH_SRC" ]; then
|
|
133
|
+
SYNTH_NEW="$(mktemp "${TMPDIR:-/tmp}/brain-synthesis-mac.plist.XXXXXX")"
|
|
134
|
+
sed -e "s|SCRIPTS_DIR|$SCRIPTS|g" -e "s|HOME_DIR|$HOME|g" \
|
|
135
|
+
"$SYNTH_SRC" > "$SYNTH_NEW"
|
|
136
|
+
if ! cmp -s "$SYNTH_NEW" "$SYNTH_DST" 2>/dev/null; then
|
|
137
|
+
cp "$SYNTH_NEW" "$SYNTH_DST"
|
|
138
|
+
chmod 600 "$SYNTH_DST"
|
|
139
|
+
if [ "${BRAIN_LAUNCHD_DRY_RUN:-0}" = "1" ]; then
|
|
140
|
+
echo "DRY-RUN — synthesis plist written, launchd NOT reloaded."
|
|
141
|
+
else
|
|
142
|
+
launchctl unload "$SYNTH_DST" 2>/dev/null || true
|
|
143
|
+
launchctl load -w "$SYNTH_DST"
|
|
144
|
+
fi
|
|
145
|
+
echo "✓ Installed: com.brainiac.synthesis (weekly Sun 08:00, all registered vaults)"
|
|
146
|
+
fi
|
|
147
|
+
rm -f "$SYNTH_NEW"
|
|
148
|
+
fi
|
|
149
|
+
echo " Logs: $LOGDIR/brief-YYYY-MM-DD.log"
|
|
150
|
+
echo " Status: launchctl list $LABEL"
|
|
151
|
+
echo " Dry-run: launchctl start $LABEL"
|
|
152
|
+
echo " Uninstall: launchctl unload '$PLIST_DST' && rm '$PLIST_DST'"
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# install-brief-windows.ps1 — install brain-daily-brief as a Windows Scheduled Task (UX-02)
|
|
2
|
+
#
|
|
3
|
+
# Usage (run as the same user who will run brain, NOT as Administrator):
|
|
4
|
+
# .\scripts\install-brief-windows.ps1 -VaultPath C:\Users\you\brain\vault
|
|
5
|
+
#
|
|
6
|
+
# Prerequisites:
|
|
7
|
+
# - brain.exe must be on PATH (test: brain --version)
|
|
8
|
+
#
|
|
9
|
+
# Uninstall:
|
|
10
|
+
# Unregister-ScheduledTask -TaskName "brain-daily-brief" -Confirm:$false
|
|
11
|
+
#
|
|
12
|
+
# Threat model: the signing key lives in Windows Credential Manager (service
|
|
13
|
+
# profile-a-brain-audit-key) and is resolved AT RUNTIME by the brain process
|
|
14
|
+
# itself (audit.py resolve_signing_key(), env -> keyring fallthrough). It is
|
|
15
|
+
# NEVER embedded in this script, the task action, or the task XML. See
|
|
16
|
+
# docs/operations/s09-evidence.md § Scheduled-task threat model.
|
|
17
|
+
param(
|
|
18
|
+
[Parameter(Mandatory=$true)]
|
|
19
|
+
[string]$VaultPath,
|
|
20
|
+
|
|
21
|
+
[string]$BrainExe = "brain",
|
|
22
|
+
[string]$TaskName = "",
|
|
23
|
+
[string]$LogDir = "$env:USERPROFILE\.brain\logs"
|
|
24
|
+
)
|
|
25
|
+
$ErrorActionPreference = "Stop"
|
|
26
|
+
|
|
27
|
+
# Per-vault task name (mirrors brain.config.nightly_label's sha256(resolved-vault)[:8]
|
|
28
|
+
# slug) so two registered vaults don't install to one shared Scheduled Task and
|
|
29
|
+
# clobber each other's job. Pass -TaskName explicitly to override.
|
|
30
|
+
$LegacyTask = "brain-daily-brief"
|
|
31
|
+
if ([string]::IsNullOrEmpty($TaskName)) {
|
|
32
|
+
$resolved = Resolve-Path -LiteralPath $VaultPath -ErrorAction SilentlyContinue
|
|
33
|
+
$vpath = if ($resolved) { $resolved.Path } else { $VaultPath }
|
|
34
|
+
$bytes = [System.Text.Encoding]::UTF8.GetBytes($vpath)
|
|
35
|
+
$hash = ([System.Security.Cryptography.SHA256]::Create().ComputeHash($bytes) |
|
|
36
|
+
ForEach-Object { $_.ToString('x2') }) -join ''
|
|
37
|
+
$TaskName = "brain-daily-brief-" + $hash.Substring(0, 8)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# One-time migration off the legacy SHARED task name.
|
|
41
|
+
if ($TaskName -ne $LegacyTask) {
|
|
42
|
+
$existing = Get-ScheduledTask -TaskName $LegacyTask -ErrorAction SilentlyContinue
|
|
43
|
+
if ($existing) {
|
|
44
|
+
Unregister-ScheduledTask -TaskName $LegacyTask -Confirm:$false
|
|
45
|
+
Write-Host "migrated: retired legacy shared task $LegacyTask (now per-vault)"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
New-Item -ItemType Directory -Force -Path $LogDir | Out-Null
|
|
50
|
+
|
|
51
|
+
# Audit signing key: provision create-if-absent (never rotates) into the
|
|
52
|
+
# Credential Manager via the engine; the drain resolves it at runtime, so
|
|
53
|
+
# nothing key-shaped goes into the task action below.
|
|
54
|
+
try {
|
|
55
|
+
& $BrainExe audit-key
|
|
56
|
+
} catch {
|
|
57
|
+
Write-Warning "audit-key provisioning failed: $_"
|
|
58
|
+
Write-Warning "Captures will not be drained (drain fails closed, unsigned)."
|
|
59
|
+
Write-Warning "Run '$BrainExe audit-key' manually later — no reinstall needed."
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
$logFile = "$LogDir\brief-$(Get-Date -Format 'yyyy-MM-dd').log"
|
|
63
|
+
|
|
64
|
+
# Task action: the `maintain` umbrella -- sync --publish + brief, PLUS the
|
|
65
|
+
# date-gated branches (Mon=health, Tue=integrity, Sun=digest,
|
|
66
|
+
# 1st=graphify-documented-only). This is THE single sanctioned OS task
|
|
67
|
+
# (`brain-nightly`, persistence-budget.md THE LOCK) -- see
|
|
68
|
+
# src/brain/core.py BrainCore.maintain. routines/manifest.json id "brain-nightly".
|
|
69
|
+
$scriptBlock = @"
|
|
70
|
+
`$env:BRAIN_VAULT = '$VaultPath'
|
|
71
|
+
`$log = '$LogDir\brief-' + (Get-Date -Format 'yyyy-MM-dd') + '.log'
|
|
72
|
+
'=== brain-daily-brief / brain-nightly ' + (Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ') + ' ===' | Out-File -Append -FilePath `$log
|
|
73
|
+
& '$BrainExe' maintain --json 2>&1 | Out-File -Append -FilePath `$log
|
|
74
|
+
# Rotate logs older than 30 days
|
|
75
|
+
Get-ChildItem '$LogDir\brief-*.log' | Where-Object { `$_.LastWriteTime -lt (Get-Date).AddDays(-30) } | Remove-Item -Force
|
|
76
|
+
"@
|
|
77
|
+
|
|
78
|
+
$action = New-ScheduledTaskAction -Execute "powershell.exe" `
|
|
79
|
+
-Argument "-NonInteractive -WindowStyle Hidden -Command `"$scriptBlock`""
|
|
80
|
+
# HOURLY (owner decision 2026-07-11, parity with the macOS installer):
|
|
81
|
+
# ingestion is frequent — every firing runs the incremental/idempotent work;
|
|
82
|
+
# weekly/monthly branches stay date-gated inside `brain maintain`.
|
|
83
|
+
$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).Date `
|
|
84
|
+
-RepetitionInterval (New-TimeSpan -Hours 1)
|
|
85
|
+
$settings = New-ScheduledTaskSettingsSet `
|
|
86
|
+
-RunOnlyIfNetworkAvailable:$false `
|
|
87
|
+
-StartWhenAvailable `
|
|
88
|
+
-ExecutionTimeLimit (New-TimeSpan -Minutes 10)
|
|
89
|
+
|
|
90
|
+
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger `
|
|
91
|
+
-Settings $settings -RunLevel Limited -Force | Out-Null
|
|
92
|
+
|
|
93
|
+
Write-Host "Installed: $TaskName (hourly umbrella)"
|
|
94
|
+
Write-Host " Logs: $LogDir\brief-YYYY-MM-DD.log"
|
|
95
|
+
Write-Host " Status: Get-ScheduledTask -TaskName '$TaskName'"
|
|
96
|
+
Write-Host " Dry-run: Start-ScheduledTask -TaskName '$TaskName'"
|
|
97
|
+
Write-Host " Uninstall: Unregister-ScheduledTask -TaskName '$TaskName' -Confirm:`$false"
|