Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,21 @@ jobs:
bun-version: "1.3.8"
- run: bun run tests/tool-error/run.ts

credential:
name: capture credential resolver
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.8"
# The stub servers run as a node child process, because the suite drives the
# hooks through spawnSync and would otherwise deadlock against itself.
- uses: actions/setup-node@v4
with:
node-version: "22"
- run: bun run tests/credential/run.ts

generated-artifact-guard:
name: generated-artifact + delivery guard
runs-on: ubuntu-latest
Expand Down
38 changes: 24 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,14 @@ keep. Confirm with `codex mcp list`: brains should read **OAuth**.

Restart the ChatGPT desktop app or start a new Codex thread. The first time the
plugin loads, open `/hooks` and trust the bundled brains hooks — that is what
runs automatic recall and error feedback. Capture and inbox delivery also need a
capture credential — normally the token below.
runs automatic recall and error feedback. Capture and inbox delivery use the
sign-in above as their credential, so there is nothing further to set.

Capture and the inbox are **macOS only** for Codex: they read the sign-in from
the macOS keychain, so on Linux there is none for them to read and they stay
off. The tools and recall are unaffected. The optional capture token below does
drive capture there, but Codex on Linux is not a configuration brains supports
or tests.

Everyday reading and writing is covered by default. For admin-gated tools or
performance insights, sign in asking for them explicitly (both also need the
Expand All @@ -56,12 +62,15 @@ codex plugin add brains@brains
codex mcp login brains
```

### Optional: conversation capture and the inbox
### Optional: an explicit capture token

You do not need this. Capture and the inbox read the credential `codex mcp login
brains` already stored, so the sign-in above is all they need. To check what has
been captured, ask brains which chats it has, or run
`list_pages type=chat_session`.

The tools above work without this. Capture and the inbox are shell hooks that
authenticate separately from the MCP server and cannot read the credential Codex
keeps internally, so they need a brains API token of their own — find it in your
brains account settings. Without one they simply stay off.
Set a token to capture into a different brains account, or to reach an endpoint
your sign-in does not cover — find it in your brains account settings:

```sh
export BRAINS_API_TOKEN="<your token>"
Expand Down Expand Up @@ -105,7 +114,8 @@ command, update Claude Code.

Restart Claude Code or start a new session. The first time the plugin loads, trust the bundled
brains hooks — that is what runs automatic recall and error feedback. Capture and inbox delivery
also need the token below.
use the sign-in above as their credential, so there is normally nothing further to set. If
`list_pages type=chat_session` shows nothing after a few turns, set the token below.

For a local checkout under development:

Expand All @@ -115,14 +125,14 @@ claude plugin install brains@brains
claude mcp login plugin:brains:brains
```

### Optional: conversation capture and the inbox
### Optional: an explicit capture token

The tools above work without this. Capture and the inbox are shell hooks that authenticate
separately from the MCP server and cannot read the credential Claude Code keeps internally, so
they need a brains API token of their own — find it in your brains account settings. Without one
they simply stay off.
You do not need this. Capture and the inbox read the credential `claude mcp login
plugin:brains:brains` already stored, so the sign-in above is all they need. To check what has
been captured, ask brains which chats it has, or run `list_pages type=chat_session`.

Set it when you install:
Set a token only to capture into a different brains account, or to reach an endpoint your sign-in
does not cover — find it in your brains account settings:

```sh
claude plugin install brains@brains --config token="<your token>"
Expand Down
4 changes: 2 additions & 2 deletions plugins/brains/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "brains",
"description": "Your memory layer: Gmail, Calendar, Drive, and prior Claude conversations as queryable pages, with reflexive recall, hook-driven turn-by-turn capture and inbox delivery, and boards/automations/workflows on top.",
"version": "2.9.1",
"version": "2.9.2",
"author": {
"name": "brains (ssvlabs)"
},
Expand All @@ -11,7 +11,7 @@
"token": {
"type": "string",
"title": "brains API token (optional)",
"description": "Optional. Enables conversation capture and the inbox, which authenticate separately from the MCP server. NOT how the brains tools authenticate — that is `claude mcp login plugin:brains:brains`. Find it in your brains account settings; without one, capture and the inbox simply stay off.",
"description": "Optional override. Conversation capture and the inbox follow your MCP sign-in — `claude mcp login plugin:brains:brains` — and need nothing set here. Use this only to capture into a different brains account, or to reach a self-hosted endpoint your sign-in does not cover. Find it in your brains account settings.",
"sensitive": true,
"required": false
},
Expand Down
2 changes: 1 addition & 1 deletion plugins/brains/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "brains",
"version": "2.9.1",
"version": "2.9.2",
"description": "Your personal memory layer for Codex: query Gmail, Calendar, Drive, and prior conversations, then build boards, automations, and workflows.",
"author": {
"name": "brains (ssvlabs)",
Expand Down
15 changes: 9 additions & 6 deletions plugins/brains/core.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<!-- brains:core:start v=6 -->
<!-- brains:core:start v=7 -->
# brains — your memory layer

You have a memory layer called **brains** (the `brains` MCP server). It holds the
Expand All @@ -22,11 +22,14 @@ For schedules and agendas, use `list_calendar_events start=… end=…`; calenda
page update time is not event time. Name the source page's `title` and `type`,
and never invent slugs or IDs.

**Capture.** In Codex and Claude Code the ingest hook saves each turn, but only
where a capture credential resolves — so never promise capture and never deny
it; `list_pages type=chat_session` is the only way to know. Don't call
`save_chat_session` routinely there; do call it when asked, and where the hooks
don't run (claude.ai web) it is the only path.
**Capture.** In Codex and Claude Code the ingest hook saves each turn, using the
same sign-in that authenticates these tools. A `brains:capture` note means it is
OFF and is authoritative: surface it once and, if the user agrees, do exactly the
one step it names — a command or a setting — never a command it did not name.
Without a note, never promise capture and never deny it; `list_pages
type=chat_session` is the only confirmation. Don't call `save_chat_session`
routinely there; do call it when asked, and where the hooks don't run (claude.ai
web) it is the only path.

**The skills carry the detail** — load the one that fits the moment:
`brains-read` (querying memory), `brains-write` (sending/creating via
Expand Down
10 changes: 10 additions & 0 deletions plugins/brains/hooks/brains-end.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,14 @@ HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LIB="$HOOK_DIR/lib/brains-inbox.sh"
[ -x "$LIB" ] && "$LIB" stop "$SESSION"

# Sweep credential work directories orphaned by SIGKILL, which is untrappable so
# nothing else can have cleaned up after it. Pruning here as well as at session
# start is what keeps that backstop from meaning "until someone starts a new
# session". Best effort, never fatal, no output.
CRED_LIB="$HOOK_DIR/lib/brains-credential.sh"
if [ -r "$CRED_LIB" ]; then
# shellcheck source=lib/brains-credential.sh
. "$CRED_LIB" 2>/dev/null && brains_cred_prune_tmp
fi

exit 0
20 changes: 19 additions & 1 deletion plugins/brains/hooks/brains-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,25 @@ fi
# hook that runs automatically with the user's token in env. Removed until
# we have an out-of-repo allowlist / fingerprint mechanism.)

# 4. Inbox engine (device report + full inbox + ack). Emits its own context.
# 4. Housekeeping. Per-session marker files (now-*, toolerr-seen-*, capok-*)
# are written by the turn and tool-error hooks and nothing ever removed
# them, so the data dir grew without bound. Also sweeps read directories
# orphaned by SIGKILL, which is untrappable and so leaves no other cleanup.
# Best effort, never fatal, no output.
CRED_LIB="$HOOK_DIR/lib/brains-credential.sh"
STATE_DIR="${BRAINS_STATE_DIR:-${PLUGIN_DATA:-${CLAUDE_PLUGIN_DATA:-$HOME/.claude/brains}}}"
if [ -d "$STATE_DIR" ]; then
find "$STATE_DIR" -maxdepth 1 -type f \
\( -name 'now-*' -o -name 'toolerr-seen-*' -o -name 'capok-*' \) \
-mtime +7 -delete 2>/dev/null
fi
if [ -r "$CRED_LIB" ]; then
# shellcheck source=lib/brains-credential.sh
. "$CRED_LIB" 2>/dev/null && brains_cred_prune_tmp
fi

# 5. Inbox engine (device report + full inbox + ack). Emits its own context,
# including the one-time capture-off signal when no credential resolves.
[ -x "$LIB" ] && "$LIB" startup "$SESSION"

exit 0
137 changes: 99 additions & 38 deletions plugins/brains/hooks/brains-turn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@
# mode (notifications only, no
# stdout). Fires AFTER the turn.
#
# Ingest is the capture path WHERE IT RUNS, and it is credential-gated: with a
# TOKEN (see the gate below) every turn POSTs to /ingest/claude and the server
# builds the chat_session page. Without one this hook exits silently, and where
# the hooks do not run at all (claude.ai web) save_chat_session is the only path.
# Ingest is the capture path WHERE IT RUNS. It needs a credential, which now
# comes from lib/brains-credential.sh: an explicitly configured token if there
# is one, otherwise the client's own MCP OAuth store. Where the hooks do not run
# at all (claude.ai web) save_chat_session is the only path.
#
# Claude keeps the existing fire-and-forget delivery. Codex waits for its
# assistant POST during Stop so the hook process cannot finish before the
# response has been handed to the ingest endpoint.
Expand All @@ -29,26 +30,12 @@ set -u
CLIENT="claude"
[ -n "${PLUGIN_ROOT:-}" ] && CLIENT="codex"

TOKEN="${CLAUDE_PLUGIN_OPTION_TOKEN:-${BRAINS_API_TOKEN:-${BRAINS_INBOX_TOKEN:-}}}"
# Desktop-launched Codex receives BRAINS_API_TOKEN directly. A standalone
# Codex CLI can instead have an authenticated MCP transport with a persisted
# Authorization header, so reuse that same credential for automatic capture.
# `codex mcp get` is a local config read; its output is never logged.
if [ -z "$TOKEN" ] && [ "$CLIENT" = "codex" ] && command -v codex >/dev/null 2>&1; then
AUTH_HEADER=$(codex mcp get brains --json 2>/dev/null \
| jq -r '.transport.http_headers.Authorization // .transport.http_headers.authorization // empty' 2>/dev/null)
case "$AUTH_HEADER" in
"Bearer "*) TOKEN="${AUTH_HEADER#Bearer }" ;;
esac
unset AUTH_HEADER
fi
[ -z "$TOKEN" ] && exit 0
BASE="${CLAUDE_PLUGIN_OPTION_ENDPOINT:-${BRAINS_ENDPOINT:-https://mcp.mybrains.ai}}"
BASE="${BASE%/}"
INGEST="${BRAINS_INGEST_URL:-$BASE/ingest/claude}"

HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LIB="$HOOK_DIR/lib/brains-inbox.sh"
CRED_LIB="$HOOK_DIR/lib/brains-credential.sh"

INPUT=$(cat)
SESSION=$(printf '%s' "$INPUT" | jq -r '.session_id // empty' 2>/dev/null)
Expand All @@ -58,36 +45,110 @@ PROMPT=$(printf '%s' "$INPUT" | jq -r '.prompt // empty' 2>/dev/null)
TRANSCRIPT=$(printf '%s' "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null)
LAST_ASSISTANT=$(printf '%s' "$INPUT" | jq -r '.last_assistant_message // empty' 2>/dev/null)

STATE_DIR="${BRAINS_STATE_DIR:-${PLUGIN_DATA:-${CLAUDE_PLUGIN_DATA:-$HOME/.claude/brains}}}"

# ---- current time, ahead of the capture gate --------------------------------
# Inject the current LOCAL time, but at MOST once per clock-hour per session
# (first turn of a session + whenever the hour rolls over) — not every turn, so
# the model has an accurate "now" without per-turn noise.
#
# This runs BEFORE any credential work on purpose. It has nothing to do with
# capture, and while it sat behind the credential gate a user without a token
# silently lost accurate time injection as collateral.
if [ -n "$PROMPT" ]; then
_now_key=$(date '+%Y%m%d%H')
_now_file="$STATE_DIR/now-$SESSION"
if [ "$_now_key" != "$(cat "$_now_file" 2>/dev/null)" ]; then
mkdir -p "$(dirname "$_now_file")" 2>/dev/null && printf '%s' "$_now_key" > "$_now_file" 2>/dev/null
printf '<!-- brains:now -->now: %s<!-- /brains:now -->\n' "$(date '+%a %Y-%m-%d %H:%M %Z (%z)')"
fi
fi

# ---- capture credential -----------------------------------------------------
[ -r "$CRED_LIB" ] || exit 0
# shellcheck source=lib/brains-credential.sh
. "$CRED_LIB" || exit 0

BRAINS_CRED_CLIENT="$CLIENT"
# The endpoint set comes from the resolver so both hooks agree on which URLs
# exist; health is keyed by the origin each one resolves to.
brains_resolve_endpoints "$BASE"

LOG="$STATE_DIR/brains.log"
_mode="turn"
[ -n "$PROMPT" ] || _mode="stop"
log() { mkdir -p "$STATE_DIR" 2>/dev/null; printf '[%s] [%s] %s\n' "$(date -u +%FT%TZ)" "$_mode" "$*" >> "$LOG" 2>/dev/null; }

if ! brains_resolve_credential "$BASE"; then
# No credential, or more than one that could be the right one. Record it so
# the session-start hook can say so once, then behave exactly as before:
# no request, no error, no noise on this turn.
brains_health_note ingest "$BRAINS_URL_INGEST" "$BRAINS_CRED_STATE"
exit 0
fi

# One line per outcome, and a healthy one only the first time in a session, so
# the log answers "is capture working" without growing by two lines a turn.
# Before this the log had never carried a single line about capture, which is
# why a credential that stopped working went unnoticed for eleven days.
capture_log() { # role, outcome
local role outcome marker
role="$1"; outcome="$2"
if [ "$outcome" = "ok" ]; then
marker="$STATE_DIR/capok-$SESSION"
[ -f "$marker" ] && return 0
printf '%s' '1' > "$marker" 2>/dev/null
fi
log "capture $role: $outcome status=${BRAINS_HTTP_CODE:-none} source=$BRAINS_CRED_SOURCE"
}

ingest_once() { # role, content
local role payload outcome
role="$1"; payload="$2"
# An explicit ceiling on top of the resolver's default, because this is the
# one call Codex makes SYNCHRONOUSLY: the Stop hook waits for the assistant
# POST, so a server that accepts the connection and then never answers would
# hold up the turn. On Claude the request is backgrounded and an unbounded one
# would linger instead of exiting.
if brains_request ingest "$BRAINS_URL_INGEST" --max-time 5 \
-X POST -H "Content-Type: application/json" -d "$payload"; then
outcome="ok"
elif [ "$BRAINS_HTTP_BLOCKED" = "1" ]; then
outcome="blocked"
else
case "${BRAINS_HTTP_CODE:-}" in
401|403) outcome="rejected" ;;
''|000) outcome="unreachable" ;;
*) outcome="error" ;;
esac
fi
capture_log "$role" "$outcome"
}

ingest() { # role, content
local role="$1" content="$2"
local role content payload
role="$1"; content="$2"
[ -z "$content" ] && return 0
local payload
payload=$(jq -nc --arg s "$SESSION" --arg r "$role" --arg c "$content" --arg client "$CLIENT" \
'{session_id:$s, role:$r, content:$c, client:$client, client_type:"cli"}')
if [ "$CLIENT" = "codex" ] && [ "$role" = "assistant" ]; then
curl -s --max-time 5 -X POST "$INGEST" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "$payload" >/dev/null 2>&1 || true
# Synchronous: Codex Stop must not finish before the response is delivered.
ingest_once "$role" "$payload"
else
( curl -s --max-time 5 -X POST "$INGEST" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "$payload" >/dev/null 2>&1 || true ) &
# Fire-and-forget, so this request outlives the hook. It takes its own lease
# on the credential first — the hook's exit would otherwise remove the
# config while curl was still starting up, and the POST would go out
# unauthenticated.
_lease=$(brains_cred_lease) || return 0
( BRAINS_CRED_CONFIG="$_lease"
trap 'brains_cred_return "$_lease"' EXIT
ingest_once "$role" "$payload"
brains_cred_return "$_lease" ) &
fi
}

if [ -n "$PROMPT" ]; then
# ---- UserPromptSubmit: ingest user message, light inbox + user hooks -----
# Inject the current LOCAL time, but at MOST once per clock-hour per session
# (first turn of a session + whenever the hour rolls over) — not every turn, so
# the model has an accurate "now" without per-turn noise.
_now_key=$(date '+%Y%m%d%H')
_now_file="${BRAINS_STATE_DIR:-$HOME/.brains}/now-$SESSION"
if [ "$_now_key" != "$(cat "$_now_file" 2>/dev/null)" ]; then
mkdir -p "$(dirname "$_now_file")" 2>/dev/null && printf '%s' "$_now_key" > "$_now_file" 2>/dev/null
printf '<!-- brains:now -->now: %s<!-- /brains:now -->\n' "$(date '+%a %Y-%m-%d %H:%M %Z (%z)')"
fi
ingest user "$PROMPT"

[ -x "$LIB" ] && "$LIB" prompt "$SESSION"
Expand Down
Loading
Loading