SLOPSHOPPER

second-brain

Knowledge management for Obsidian vaults and structured markdown repositories

commandtoastmodeltimer
A shopper browsing a rack in a slop shop
README

Second Brain Plugin

Knowledge management for Obsidian vaults. Capture insights from conversations, process voice transcriptions, and connect repos to your vault.

Installation

Requires the pickled-claude-plugins marketplace. Then:

/plugin install second-brain@pickled-claude-plugins

Quick Start

  1. Configure your vault: `` /second-brain:setup ` This detects your Obsidian settings and creates ~/.claude/second-brain.md`.
  1. Capture an insight: `` /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).
  1. Devlog runs on its own: No command to run. 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.

Commands

CommandDescriptionWorks From
/second-brain:setupConfigure vault path, detect settingsAnywhere
/second-brain:insightCapture single insight to inboxAnywhere
/second-brain:process-dailyClean voice transcriptions in daily noteVault only
/second-brain:link-projectSymlink repo folder to vaultAny repo

Workflow

┌─────────────────────────────────────────────────────────────┐
│                     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                  │
└─────────────────────────────────────────────────────────────┘

Devlog sidechain (experimental)

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_modeSessionStart nudgeSidechain mod
nudge (default)onoff
sidechainsilenton
bothonon (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.

KnobDefaultMeaning
DEVLOG_SIDECHAIN_EVERY4Check after this many qualifying turns
DEVLOG_SIDECHAIN_MIN_MS15000Turns shorter than this don't count
DEVLOG_SIDECHAIN_DIR~/.local/state/devlog-sidechainWhere 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.

Configuration

Global: ~/.claude/second-brain.md

Created by /second-brain:setup:

# Second Brain Configuration

## Vaults

- primary: ~/Vaults/my-vault/

Default: primary

Vault Symlinks: ~/.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: {vault}/CLAUDE.md

Scaffolded by setup command with detected settings:

  • Folder structure (inbox, daily notes, templates)
  • Routing rules (PARA-based)
  • Naming conventions (Zettelkasten)

Project: .claude/second-brain.local.md

Created by /second-brain:link-project:

# Second Brain Connection

Vault: primary

Symlinks:
- docs/notes/ → Areas/my-project/

Skills & References

capture — read a source, write a note

The front door for "read this and make a note for it." Runs the whole loop as one arc so no step gets silently skipped:

  1. Search first via the qmd MCP tools, showing what already exists before anything is written
  2. Read the primary source (lightpanda / WebFetch / xtweet / Read / Notion), never a snippet
  3. Create through sb note create, which owns the inbox path, timestamp, and filename slug
  4. Leave it in the inbox — routing is offered, not performed
  5. Offer connections plus a daily-note breadcrumb

Also 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).

ReferenceContent
skills/capture/references/note-format.mdFrontmatter and body shape for a fresh capture

obsidian — vault mechanics

ReferenceContent
references/para.mdPARA methodology (Projects, Areas, Resources, Archive)
references/zettelkasten.mdTimestamp naming convention
references/note-patterns.mdTemplates for person, meeting, insight, investigation notes
references/sb-cli.mdThe sb CLI command surface

Note Format

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*

License

MIT

Source 1 files
hooks/devlog-sidechain/register.ts 139 lines
1import 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