Knowledge management for Obsidian vaults and structured markdown repositories

Knowledge management for Obsidian vaults. Capture insights from conversations, process voice transcriptions, and connect repos to your vault.
Requires the pickled-claude-plugins marketplace. Then:
/plugin install second-brain@pickled-claude-plugins
/second-brain:setup ` This detects your Obsidian settings and creates ~/.claude/second-brain.md`. /second-brain:insight Redis is better than Memcached for sessions because it supports TTL per key `` Writes to your vault's inbox with provenance (repo, branch, commit).devlog writes a point-in-time entry to the current session's note whenever you finish something notable — a feature, a bug fix, a gotcha worth remembering. Nothing to remember, nothing to invoke.| Command | Description | Works From |
|---|---|---|
/second-brain:setup | Configure vault path, detect settings | Anywhere |
/second-brain:insight | Capture single insight to inbox | Anywhere |
/second-brain:process-daily | Clean voice transcriptions in daily note | Vault only |
/second-brain:link-project | Symlink repo folder to vault | Any repo |
┌─────────────────────────────────────────────────────────────┐
│ Any Repository │
│ │
│ Working on code... │
│ ↓ │
│ /second-brain:insight "key learning" │
│ ↓ │
│ Writes to vault inbox with repo/branch/commit context │
│ │
│ Finished something notable... │
│ ↓ │
│ devlog writes an entry automatically, no command needed │
│ ↓ │
│ Later: /second-brain:process-inbox routes, connects, links │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Inside Vault │
│ │
│ Voice transcription in daily note... │
│ ↓ │
│ /second-brain:process-daily │
│ ↓ │
│ 1. Correct transcription errors (batched) │
│ 2. Clean prose (preview before applying) │
│ 3. Restructure to template │
│ 4. Suggest extractions to permanent notes │
└─────────────────────────────────────────────────────────────┘
A Claude Code mod (hooks/devlog-sidechain/register.ts, loaded through the modules entry in hooks/hooks.json) that judges devlog-worthiness outside the turn. Off by default: the devlog_mode plugin setting picks who writes entries, so the two never double-log:
devlog_mode | SessionStart nudge | Sidechain mod |
|---|---|---|
nudge (default) | on | off |
sidechain | silent | on |
both | on | on (for comparing) |
After a long turn it forks the session transcript with $.model.fork, asks "did the assistant just finish something or hit a non-obvious gotcha?", and appends an entry if so. The main agent never spends a turn on it. The fork rides the session's prompt cache, so each check costs a cache read plus a few output tokens.
It does not write to the vault yet. Entries land in ~/.local/state/devlog-sidechain/<session-id>.md, and every check (including none outcomes) logs token usage to <session-id>.usage.jsonl beside it.
| Knob | Default | Meaning |
|---|---|---|
DEVLOG_SIDECHAIN_EVERY | 4 | Check after this many qualifying turns |
DEVLOG_SIDECHAIN_MIN_MS | 15000 | Turns shorter than this don't count |
DEVLOG_SIDECHAIN_DIR | ~/.local/state/devlog-sidechain | Where entries and usage logs go |
To switch modes, run /plugin configure second-brain@pickled-claude-plugins in Claude Code, or from a shell:
echo '{"devlog_mode":"sidechain"}' | claude plugin configure second-brain@pickled-claude-plugins --values-stdin
It's stored as pluginConfigs["second-brain@pickled-claude-plugins"].options.devlog_mode in settings, and takes effect on the next session start. Until it's set, the mode is nudge.
/devlog-check [turns] forces a check now and reports the outcome and token counts. Needs a Claude Code build with mods (2.1.300+). Run the module's tests with claude plugin test plugins/second-brain.
~/.claude/second-brain.mdCreated by /second-brain:setup:
# Second Brain Configuration
## Vaults
- primary: ~/Vaults/my-vault/
Default: primary
~/.claude/vaults/Setup creates symlinks at ~/.claude/vaults/{name} pointing to actual vault paths. This provides predictable paths for permissions and access:
~/.claude/vaults/primary -> ~/Vaults/my-vault/
{vault}/CLAUDE.mdScaffolded by setup command with detected settings:
.claude/second-brain.local.mdCreated by /second-brain:link-project:
# Second Brain Connection
Vault: primary
Symlinks:
- docs/notes/ → Areas/my-project/
capture — read a source, write a noteThe front door for "read this and make a note for it." Runs the whole loop as one arc so no step gets silently skipped:
sb note create, which owns the inbox path, timestamp, and filename slugAlso handles "do we have a note about X already?" lookups, including the recall fallbacks for when semantic search misses (basename search across the vault, checking both inbox folders).
| Reference | Content |
|---|---|
skills/capture/references/note-format.md | Frontmatter and body shape for a fresh capture |
obsidian — vault mechanics| Reference | Content |
|---|---|
references/para.md | PARA methodology (Projects, Areas, Resources, Archive) |
references/zettelkasten.md | Timestamp naming convention |
references/note-patterns.md | Templates for person, meeting, insight, investigation notes |
references/sb-cli.md | The sb CLI command surface |
Insights are captured with provenance:
---
captured: 2026-01-21T14:30:00Z
source: claude-conversation
repo: my-project
branch: feature/auth
commit: abc1234
---
# Redis Over Memcached for Sessions
Redis supports per-key TTL, making it better suited for session storage
where different sessions may have different expiration requirements.
## Context
Captured while debugging session expiration issues in the auth system.
---
*Captured via /second-brain:insight*
MIT
hooks/devlog-sidechain/register.ts 139 lines1import type { EngineInterface, ModelForkResult, Register } from 'claude-code'
2
3// Each fork re-reads the whole transcript (from cache, but still billed), so
4// by default only check every Nth substantial turn and judge them together.
5export const CHECK_EVERY = 4
6// Turns shorter than this are chit-chat; not worth counting.
7const MIN_TURN_MS = 15_000
8
9type Config = { every: number; minMs: number; dir: string }
10
11async function config($: EngineInterface): Promise<Config> {
12 // $.env.get must take a literal, so the validator can list what's read.
13 const num = (raw: string | undefined, fallback: number) => {
14 const n = raw === undefined || raw === '' ? NaN : Number(raw)
15 return Number.isFinite(n) && n >= 0 ? n : fallback
16 }
17 return {
18 every: Math.max(1, num(await $.env.get('DEVLOG_SIDECHAIN_EVERY'), CHECK_EVERY)),
19 minMs: num(await $.env.get('DEVLOG_SIDECHAIN_MIN_MS'), MIN_TURN_MS),
20 dir: (await $.env.get('DEVLOG_SIDECHAIN_DIR')) ?? `${await $.env.get('HOME')}/.local/state/devlog-sidechain`,
21 }
22}
23
24export function prompt(turns: number): string {
25 return `[devlog-sidechain] Side question, not part of the conversation. Do not answer the user or continue the work.
26
27Look only at the most recent ${turns === 1 ? 'turn' : `${turns} turns`}. Did the assistant's own work in ${turns === 1 ? 'it' : 'them'} do any of these:
28- finish a feature or fix a notable bug
29- discover something non-obvious (a gotcha, undocumented behavior, a workaround that took several tries, a constraint only noticed after hitting it)
30
31Not worth logging: the user stating a fact the assistant didn't act on, acknowledgements, questions, plans not yet carried out, routine edits. If you would describe the turn as "nothing new happened", or you're unsure, the answer is NONE.
32
33If not, reply with exactly: NONE
34
35If so, reply with one line starting "ENTRY: " followed by one to three sentences of prose: what happened, why, anything surprising. Standalone enough to make sense without the conversation. No markdown, no title.`
36}
37
38export function parseEntry(text: string): string | undefined {
39 const line = text.trim().split('\n').find(l => l.startsWith('ENTRY:'))
40 const entry = line?.slice('ENTRY:'.length).trim()
41 return entry ? entry : undefined
42}
43
44function hhmm(ms: number): string {
45 const d = new Date(ms)
46 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
47}
48
49async function appendTo($: EngineInterface, path: string, text: string) {
50 const existing = await $.fs.read(path).catch(() => '')
51 await $.fs.write(path, existing + text)
52}
53
54type Judgment = {
55 outcome: string
56 entry: string | undefined
57 usage: Extract<ModelForkResult, { usage: unknown }>['usage'] | null
58}
59
60async function judge($: EngineInterface, sessionId: string, turns: number, via: string): Promise<Judgment> {
61 const { dir } = await config($)
62 const base = `${dir}/${sessionId.slice(0, 8)}`
63 const startedAt = await $.clock.now()
64 const r = await $.model.fork({ prompt: prompt(turns) })
65 const entry = r.isAnswered ? parseEntry(r.text) : undefined
66 const judgment: Judgment = {
67 outcome: r.isAnswered ? (entry ? 'entry' : 'none') : r.reason,
68 entry,
69 usage: 'usage' in r ? r.usage : null,
70 }
71
72 const record = { at: startedAt, via, turns, ms: (await $.clock.now()) - startedAt, ...judgment }
73 await appendTo($, `${base}.usage.jsonl`, `${JSON.stringify(record)}\n`)
74 if (entry !== undefined) {
75 const existing = await $.fs.read(`${base}.md`).catch(() => '')
76 const header = existing === '' ? `# devlog-sidechain: session ${sessionId}\n\n` : ''
77 await $.fs.write(`${base}.md`, `${existing}${header}- ${hhmm(await $.clock.now())} ${entry}\n`)
78 }
79 return judgment
80}
81
82async function judgeInBackground($: EngineInterface, sessionId: string, turns: number, inFlight: Set<string>) {
83 try {
84 const { entry } = await judge($, sessionId, turns, 'turn')
85 if (entry !== undefined) $.ui.toast(`devlog: ${entry.slice(0, 60)}`)
86 } finally {
87 inFlight.delete(sessionId)
88 }
89}
90
91export const register: Register = (on, options) => {
92 // devlog_mode picks who writes devlog entries: the SessionStart nudge (default), this
93 // sidechain, or both. The nudge script reads the same setting from its environment.
94 if ((options.devlog_mode ?? 'nudge') === 'nudge') return
95
96 const inFlight = new Set<string>()
97 const pending = new Map<string, number>()
98
99 on('session.start', async ($, e, next) => {
100 await $.command.register({
101 name: 'devlog-check',
102 description: 'Ask the devlog side fork now whether the last N turns (default 1) are devlog-worthy',
103 argumentHint: '[turns]',
104 })
105 return next(e)
106 })
107
108 on('command.run', { command: 'devlog-check' }, async ($, e) => {
109 const turns = Math.max(1, Number.parseInt(e.args, 10) || 1)
110 const j = await judge($, await $.session.id(), turns, 'command')
111 const tokens = j.usage ? ` (cache read ${j.usage.cache_read_input_tokens}, in ${j.usage.input_tokens}, out ${j.usage.output_tokens})` : ''
112 return { text: `devlog-check: ${j.outcome}${j.entry ? `: ${j.entry}` : ''}${tokens}` }
113 })
114
115 on('turn.complete', async ($, e, next) => {
116 const result = await next(e)
117 if (e.agentId !== undefined || e.isAborted) return result
118 const { every, minMs } = await config($)
119 if (e.durationMs < minMs) return result
120
121 const sessionId = await $.session.id()
122 const turns = (pending.get(sessionId) ?? 0) + 1
123 if (turns < every || inFlight.has(sessionId)) {
124 pending.set(sessionId, turns)
125 return result
126 }
127 pending.set(sessionId, 0)
128 inFlight.add(sessionId)
129
130 // Off the dispatch: a timer outlives this hook, so the fork never holds
131 // the turn open and isn't aborted when the next prompt arrives. Firing
132 // right away also lands while the main thread's prompt cache is warm.
133 $.clock.after(0, () => {
134 void judgeInBackground($, sessionId, turns, inFlight).catch(() => {})
135 })
136 return result
137 })
138}
139