Skip to content

Commit 2f3633e

Browse files
committed
Merge remote-tracking branch 'origin/327-claude-code-hooks' into fix/327-transcript-source-guards
# Conflicts: # CHANGELOG.md
2 parents c1cf5cd + 394733d commit 2f3633e

13 files changed

Lines changed: 1821 additions & 373 deletions

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ All notable changes to Second Brain are documented here. Version numbers match `
3030

3131
**Claude Code hooks (#327)**
3232

33+
- SessionStart recall sent `?q=`; the route reads `query`. The hook printed nothing on every session start since it shipped.
34+
- SessionEnd parsed stdin as the transcript; Claude Code sends a `transcript_path`. Sessions were never captured. The hook now reads the JSONL transcript, keeps only human-readable turns, and captures behind a content gate.
35+
- Hooks now exit 1 with one stderr line on any failure; Claude Code hides stderr from exit-0 hooks.
36+
- `install.sh` reconciles instead of appending, refuses a malformed settings.json, sets the SessionEnd `timeout` the 1.5 s hook budget requires, and keeps credentials in `~/.config/second-brain/config.json` rather than the hook command line.
37+
- New: `install.sh --check` and `--uninstall`.
38+
- Session capture redacts credentials from the body before sending it — your own token, `Bearer` values, `sk-`/`ghp_`/`github_pat_`/`xoxb-`/`AKIA`/`AIza` key shapes, PEM private keys and `TOKEN=`-style assignments — while leaving UUIDs, commit SHAs, paths and ordinary prose intact.
39+
- SessionStart caches the block it printed and re-emits it on compaction, so compaction costs no recall at all; it falls back to a live recall when there is no cache or it is over 24 h old.
40+
- New: `install.ps1`, a PowerShell installer for Windows machines where Claude Code runs hooks under PowerShell rather than Git Bash.
3341
- Worker: a Claude Code transcript is never merged into, never replaces, and never deprecates a memory written by any other source; it is stored as a duplicate-candidate or a draft instead. Transcripts are excluded from insight synthesis.
3442
- Worker: capturing a near-duplicate of a protected memory (importance ≥ 4 or canonical) now stores the newcomer as a duplicate-candidate. It used to report success with an id that did not exist.
3543

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ A successful response looks like `{"ok":true,"id":"..."}`.
175175
- **Notes:** [Second Brain Sync for Obsidian](https://community.obsidian.md/plugins/second-brain-sync) and Notion
176176
- **Calendar and email:** Google, Outlook, iCloud, and Gmail integrations
177177
- **iPhone and iPad:** Voice, text, and share-sheet shortcuts in [`integrations/ios-shortcuts/`](integrations/ios-shortcuts/)
178+
- **Claude Code:** session hooks that recall project context on start and save the conversation on exit — [`integrations/claude-code-hooks/`](integrations/claude-code-hooks/)
178179
- **Dashboard:** Capture, recall, browse, graph, share, back up, and restore from the built-in web interface
179180

180181
See [Capture from Anywhere](wiki/Capture-from-Anywhere) for setup and usage instructions.
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
# Claude Code hooks
2+
3+
Two hooks that connect a Claude Code session to your Second Brain: one recalls
4+
project context when a session opens, one saves the conversation when it closes.
5+
6+
They are independent of the MCP server. Use either, or both.
7+
8+
## What the hooks do
9+
10+
| Event | Runs on | Action | Cost |
11+
|---|---|---|---|
12+
| `SessionStart` | `startup`, `clear`, `compact` | `GET /recall` for this project, prints up to 5 memories into the session | one recall (~1 s), none on compaction |
13+
| `SessionEnd` | every reason (`clear`, `resume`, `logout`, `prompt_input_exit`, `other`) | `POST /capture` with the tail of the conversation | one capture (embedding + often a model call), 30 s hook timeout |
14+
15+
`resume` and `fork` are skipped on start: those transcripts already contain the
16+
earlier injection. `compact` is not skipped — compaction discards it.
17+
18+
On `startup` and `clear` the block that was printed is cached under
19+
`$XDG_CACHE_HOME/second-brain/session-<session_id>.txt` (`~/.cache/…` by
20+
default). Compaction re-prints that file verbatim and makes no request at all:
21+
the session id survives compaction and rotates on `/clear`, so a cached block is
22+
always the current session's context. With no cache, or one older than 24 h,
23+
compaction falls back to a live recall.
24+
25+
## Install, upgrade, check, uninstall
26+
27+
```bash
28+
bash install.sh https://your-worker.workers.dev your-token # install or upgrade
29+
bash install.sh # reuse existing credentials, or prompt
30+
bash install.sh --check # prove the hooks reach the Worker
31+
bash install.sh --uninstall # remove only our entries
32+
```
33+
34+
PowerShell, for Windows without Git Bash — same behaviour, same guarantees:
35+
36+
```powershell
37+
.\install.ps1 -WorkerUrl https://your-worker.workers.dev -Token your-token
38+
.\install.ps1 # reuse existing credentials, or prompt
39+
.\install.ps1 -Check
40+
.\install.ps1 -Uninstall
41+
```
42+
43+
Re-running is safe: the installer replaces its own entries in
44+
`~/.claude/settings.json` and preserves everything else. It refuses to write a
45+
settings file that is not valid JSON rather than overwriting it.
46+
47+
**Restart any session that is already open.** Claude Code snapshots the hook
48+
config at startup, so a running session keeps the old wiring.
49+
50+
## Where credentials live
51+
52+
`~/.config/second-brain/config.json` (mode 600) — the same file the CLI and the
53+
desktop app use:
54+
55+
```json
56+
{ "workerUrl": "https://your-worker.workers.dev", "authToken": "" }
57+
```
58+
59+
Nothing is written into `settings.json` and nothing is passed on the hook
60+
command line, so the token never appears in Claude Code's settings or in `ps`.
61+
`SECOND_BRAIN_URL` and `SECOND_BRAIN_TOKEN` in the environment take precedence
62+
when set.
63+
64+
## What is sent
65+
66+
Recall:
67+
68+
```
69+
GET /recall?query=<project>+decisions+and+context&topK=5&workspace=personal&tag=<project>
70+
```
71+
72+
with a `tag`-less second attempt if the tagged one returns nothing. With no
73+
project (a session opened in `$HOME`), one generic query limited to the last 14
74+
days is sent instead.
75+
76+
Capture:
77+
78+
```json
79+
{
80+
"content": "Claude Code session <id> — <project>@<branch> — <date> (<reason>)\n\nUser: …\n\nAssistant: …",
81+
"source": "claude-code",
82+
"tags": ["<project>"],
83+
"workspace": "personal"
84+
}
85+
```
86+
87+
Before it is sent, the formatted body — header included — is scanned for
88+
credentials, and each one is replaced with `[redacted]`: your own configured
89+
token wherever it appears, `Bearer <token>` values, provider key shapes (`sk-`,
90+
`ghp_`/`gho_`, `github_pat_`, `xoxb-`/`xoxp-`, AWS `AKIA…`, Google `AIza…`),
91+
whole PEM private-key blocks, and `TOKEN=`/`SECRET=`/`PASSWORD=`/`API_KEY=`
92+
style assignments. Only those shapes: a UUID, a commit SHA, a file path and
93+
ordinary prose are left exactly as they were, because a memory redacted into
94+
uselessness is worse than no memory. Tool output — where secrets usually live —
95+
never reaches the body in the first place.
96+
97+
The transcript is read backwards from the end until three human turns are in
98+
hand (1 MB ceiling), and only human-readable turns survive: `tool_use`,
99+
`tool_result` and `thinking` blocks, sidechain (subagent) lines, `isMeta` lines,
100+
compaction summaries and harness noise such as `<system-reminder>` or
101+
`<command-name>` are all dropped. The body is capped at 2000 characters, newest
102+
turns first.
103+
104+
Set `SECOND_BRAIN_WORKSPACE=company` to write to the shared layer instead.
105+
Set `SECOND_BRAIN_DRY_RUN=1` to print the capture body instead of sending it.
106+
107+
## The gate, and the Worker version
108+
109+
A session is captured only when it contains at least one human turn of 40+
110+
characters and 200+ characters of conversation (the header does not count) — a
111+
two-word prompt and a wall of tool output is not a session worth keeping.
112+
113+
Capture also requires **Worker 3.0 or newer** (`GET /health` reports the
114+
version, cached for 24 h). Against an older brain, recall still works and the
115+
capture is skipped with one notice per day. Deploy the Worker, then use the
116+
hooks.
117+
118+
Opt out of either half:
119+
120+
```bash
121+
SECOND_BRAIN_HOOK_RECALL=0 # no recall on session start
122+
SECOND_BRAIN_HOOK_CAPTURE=0 # no capture on session end
123+
```
124+
125+
## Overlap with the MCP instructions
126+
127+
`AI_Instructions/CLAUDE_INSTRUCTIONS.md` already tells the model to call `recall`
128+
at the start of every conversation. If you use those instructions with the MCP
129+
server, the SessionStart hook is a second, unprompted recall on the same topic.
130+
It is still useful — it runs before the first token and cannot be skipped — but
131+
if you would rather have only one, set `SECOND_BRAIN_HOOK_RECALL=0` and leave the
132+
MCP rule in place.
133+
134+
The SessionEnd capture has no MCP equivalent and does not overlap with anything.
135+
136+
## Failure lines you will see
137+
138+
Hooks report failures on stderr and exit non-zero; Claude Code hides stderr from
139+
a hook that exits 0, which is why nothing is silent any more.
140+
141+
| Line | Meaning |
142+
|---|---|
143+
| `[Second Brain] recall failed: HTTP 401 unauthorized — token rejected…` | the token is wrong or was rotated — re-run `install.sh` |
144+
| `[Second Brain] recall failed: HTTP 404 — is SECOND_BRAIN_URL / workerUrl the Worker origin?` | the URL points at something that is not the Worker root |
145+
| `[Second Brain] recall failed: no reply within 15s` | the Worker did not answer in time |
146+
| `[Second Brain] session capture failed: …` | same causes, on the capture call |
147+
| `SessionEnd hook [<cmd>] failed: …` | Claude Code's own wrapper around the line above |
148+
| `[Second Brain] session capture needs Worker 3.0+ …` | the brain has not been redeployed to v3; shown once a day |
149+
150+
Nothing here blocks the session. A failed hook costs you the recall or the
151+
capture, not the conversation.
152+
153+
## Windows
154+
155+
The hooks run under Git Bash if it is installed; without it Claude Code falls
156+
back to PowerShell, where `install.sh` will not run. Use `install.ps1` there —
157+
it writes the same credentials file and the same `settings.json` entries, and
158+
Node does the JSON editing in both installers so the two cannot drift. The hook
159+
scripts themselves are plain Node and work either way once they are in
160+
`settings.json`.
161+
162+
The credentials file is written with mode 600, which NTFS ignores; on Windows it
163+
is protected by the permissions of your user profile directory like any other
164+
file under `%USERPROFILE%`.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
#!/usr/bin/env node
2+
'use strict';
3+
// `install.sh --check`: prove the hooks can reach the Worker and show what they would do.
4+
const path = require('node:path');
5+
const { loadCredentials, fetchWithTimeout, CONFIG_PATH } = require('./common');
6+
const start = require('./session-start');
7+
const end = require('./session-end');
8+
9+
async function main() {
10+
const creds = loadCredentials();
11+
if (!creds) { console.error(`No credentials: set SECOND_BRAIN_URL/SECOND_BRAIN_TOKEN or write ${CONFIG_PATH}`); process.exit(1); }
12+
const res = await fetchWithTimeout(`${creds.baseUrl}/health`, { headers: { Authorization: `Bearer ${creds.token}` } }, 10000);
13+
if (!res.ok) { console.error(`GET /health → HTTP ${res.status}. Token or URL is wrong.`); process.exit(1); }
14+
const health = await res.json();
15+
const major = parseInt(String(health.version ?? '').split('.')[0], 10);
16+
console.log(`Worker ${health.version} at ${creds.baseUrl} — recall: on; session capture: ${major >= 3 ? 'on' : 'off (needs 3.0+)'}`);
17+
18+
console.log('\n— session-start against this brain —');
19+
await start.main();
20+
21+
console.log('\n— session-end dry run against the bundled sample transcript —');
22+
process.env.SECOND_BRAIN_DRY_RUN = '1';
23+
const fixture = path.join(__dirname, 'fixtures', 'sample-transcript.jsonl');
24+
const fs = require('node:fs');
25+
const turns = end.readTranscriptTail(fixture);
26+
const body = end.buildCaptureBody(turns, { project: 'sample', sessionId: 'sample', reason: 'other', workspace: 'personal' });
27+
console.log(JSON.stringify({ wouldCapture: end.shouldCapture(turns), tags: body.tags, contentPreview: body.content.slice(0, 200) }, null, 2));
28+
if (process.exitCode) process.exit(process.exitCode);
29+
}
30+
31+
main().catch((e) => { console.error(e?.message ?? e); process.exit(1); });
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
'use strict';
2+
// Shared by session-start.js, session-end.js and check.js. CommonJS on purpose:
3+
// the repo's package.json has no "type", and vitest can require() this file
4+
// (test/ui/graph-clusters.test.ts does the same with public/utils.js).
5+
const fs = require('node:fs');
6+
const os = require('node:os');
7+
const path = require('node:path');
8+
const crypto = require('node:crypto');
9+
const { execFileSync } = require('node:child_process');
10+
11+
const HOME = os.homedir();
12+
const CONFIG_PATH = path.join(HOME, '.config', 'second-brain', 'config.json');
13+
const CACHE_DIR = path.join(process.env.XDG_CACHE_HOME || path.join(HOME, '.cache'), 'second-brain');
14+
const HEALTH_TTL_MS = 24 * 60 * 60 * 1000;
15+
16+
/**
17+
* Credentials: env first (what the tests and `--check` use), then the file the
18+
* CLI and the desktop installer already share. Nothing is ever read from the
19+
* hook command line, so the token is not in settings.json and not in `ps`.
20+
*/
21+
function loadCredentials(env = process.env, configPath = CONFIG_PATH) {
22+
const url = (env.SECOND_BRAIN_URL || '').trim();
23+
const token = (env.SECOND_BRAIN_TOKEN || '').trim();
24+
if (url && token) return { baseUrl: stripSlash(url), token };
25+
try {
26+
const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
27+
if (cfg && typeof cfg.workerUrl === 'string' && typeof cfg.authToken === 'string' && cfg.workerUrl && cfg.authToken) {
28+
return { baseUrl: stripSlash(cfg.workerUrl), token: cfg.authToken };
29+
}
30+
} catch { /* absent or malformed: the hook has nothing to do */ }
31+
return null;
32+
}
33+
34+
function stripSlash(u) { return String(u).trim().replace(/\/+$/, ''); }
35+
36+
/** "personal" unless the user explicitly asks for the shared layer. Anything else is personal. */
37+
function resolveWorkspace(env = process.env) {
38+
return (env.SECOND_BRAIN_WORKSPACE || '').trim() === 'company' ? 'company' : 'personal';
39+
}
40+
41+
/**
42+
* Claude Code writes the hook payload to stdin and closes it. A TTY (someone
43+
* running the script by hand) or a pipe that never closes (execFile in a test)
44+
* must not hang the hook, so the read races a short timer.
45+
*/
46+
function readStdinJson(timeoutMs = 1500) {
47+
if (process.stdin.isTTY) return Promise.resolve(null);
48+
return new Promise((resolve) => {
49+
let raw = '';
50+
let done = false;
51+
const finish = (value) => { if (!done) { done = true; clearTimeout(timer); resolve(value); } };
52+
const timer = setTimeout(() => finish(parse(raw)), timeoutMs);
53+
process.stdin.setEncoding('utf8');
54+
process.stdin.on('data', (c) => { raw += c; if (raw.length > 65536) finish(parse(raw)); });
55+
process.stdin.on('end', () => finish(parse(raw)));
56+
process.stdin.on('error', () => finish(null));
57+
});
58+
function parse(s) { try { return s.trim() ? JSON.parse(s) : null; } catch { return null; } }
59+
}
60+
61+
/** basename of the origin remote (without .git), else basename of cwd, else null for $HOME and /. */
62+
function parseProjectName(remoteUrl, cwd, home = HOME) {
63+
const clean = (s) => s.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
64+
if (remoteUrl) {
65+
const base = remoteUrl.trim().replace(/[/:]+$/, '').split(/[/:]/).pop().replace(/\.git$/i, '');
66+
if (base) return clean(base) || null;
67+
}
68+
if (!cwd) return null;
69+
const resolved = path.resolve(cwd);
70+
if (resolved === path.resolve(home) || resolved === path.parse(resolved).root) return null;
71+
return clean(path.basename(resolved)) || null;
72+
}
73+
74+
function gitRemoteUrl(cwd) {
75+
try {
76+
return execFileSync('git', ['-C', cwd, 'remote', 'get-url', 'origin'], {
77+
stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000, encoding: 'utf8',
78+
}).trim() || null;
79+
} catch { return null; }
80+
}
81+
82+
function fetchWithTimeout(url, init, ms) {
83+
return fetch(url, { ...init, signal: AbortSignal.timeout(ms) });
84+
}
85+
86+
/** The one visible channel: stderr + exit 1. Claude Code drops stderr from an exit-0 hook. */
87+
function fail(message) {
88+
process.stderr.write(`[Second Brain] ${message}\n`);
89+
process.exitCode = 1;
90+
}
91+
92+
function hintFor(status) {
93+
if (status === 401 || status === 403) return ' — token rejected; re-run integrations/claude-code-hooks/install.sh';
94+
if (status === 404) return ' — is SECOND_BRAIN_URL / workerUrl the Worker origin?';
95+
return '';
96+
}
97+
98+
/** `dir` is only ever passed by tests, so nothing writes to the real cache during a run. */
99+
function cachePath(name, dir = CACHE_DIR) {
100+
fs.mkdirSync(dir, { recursive: true });
101+
return path.join(dir, name);
102+
}
103+
104+
/** Worker major version from GET /health, cached per origin for 24 h. null when unknown. */
105+
async function workerMajorVersion({ baseUrl, token }, now = Date.now()) {
106+
const file = cachePath(`health-${crypto.createHash('sha1').update(baseUrl).digest('hex').slice(0, 12)}.json`);
107+
try {
108+
const cached = JSON.parse(fs.readFileSync(file, 'utf8'));
109+
if (cached && now - cached.checkedAt < HEALTH_TTL_MS && Number.isInteger(cached.major)) return cached.major;
110+
} catch { /* no cache yet */ }
111+
try {
112+
const res = await fetchWithTimeout(`${baseUrl}/health`, { headers: { Authorization: `Bearer ${token}` } }, 5000);
113+
if (!res.ok) return null;
114+
const body = await res.json();
115+
const major = parseInt(String(body?.version ?? '').split('.')[0], 10);
116+
if (!Number.isInteger(major)) return null;
117+
fs.writeFileSync(file, JSON.stringify({ major, version: body.version, checkedAt: now }));
118+
return major;
119+
} catch { return null; }
120+
}
121+
122+
/** Emit `message` via fail() at most once per 24 h per key. Returns true when it fired. */
123+
function noticeOncePerDay(key, message, now = Date.now()) {
124+
const file = cachePath(`notice-${key}`);
125+
try {
126+
if (now - fs.statSync(file).mtimeMs < HEALTH_TTL_MS) return false;
127+
} catch { /* first time */ }
128+
fs.writeFileSync(file, String(now));
129+
fail(message);
130+
return true;
131+
}
132+
133+
module.exports = {
134+
CONFIG_PATH, CACHE_DIR, HEALTH_TTL_MS,
135+
loadCredentials, resolveWorkspace, readStdinJson, parseProjectName, gitRemoteUrl,
136+
fetchWithTimeout, fail, hintFor, cachePath, workerMajorVersion, noticeOncePerDay,
137+
};

0 commit comments

Comments
 (0)