Local org-roam-style knowledge graph (SQLite) with paper-ingest + loop skills and an MCP server

Give an agent a goal and a place to remember. It loops — reading what's known, researching, writing back evidence-linked findings — and a knowledge graph grows itself across sessions. mindgap is that memory: a local, org-roam-style graph for research and project knowledge (concepts, definitions, software, repos, Confluence pages, arXiv papers, people, teams) that autonomous loop sessions read before they work and write after — markdown nodes densified with [[wiki-links]], every node carrying its source URLs, rendered live in 2D/3D. You can hand-curate via CLI + web UI too; the agents just never stop adding.
"LLMs are exceptionally good at looping until they meet specific goals. Don't tell it what to do — give it success criteria and watch it go." — Andrej Karpathy
An agent looping toward a goal needs somewhere to look before it starts and somewhere to put what it finds. mindgap is that somewhere. Each session reads the relevant subgraph for context, does the work — sweep arXiv, map a repo, mine the connections for buildable ideas — then ingests new nodes and edges with provenance. Nothing evaporates when the context window closes: the next run builds on the last, and knowledge compounds instead of being re-derived. You supply the goal and the success criteria; the graph is the durable, queryable memory the loop reads and writes.
pipx install git+https://github.com/grburgess/mindgap.git mindgap init # create ~/.mindgap/mindgap.db + a small demo graph mindgap serve # open the web UI at http://localhost:8765
The seeded graph is just a demo to show the shape. To start your own, clear it — the database recreates itself empty on next use:
rm ~/.mindgap/mindgap.db mindgap add --title "My first concept" --tags research # then grow it via the CLI, the web UI, or MCP
(Don't re-run mindgap init after clearing — that re-seeds the demo. Or point MINDGAP_DB at a new path to keep the demo and start a separate graph.)
Stdlib-only Python 3.10 — no pip installs. Data lives in a single SQLite file.
Pick one. All paths put mindgap (and mindgap-mcp) on your PATH and store data in ~/.mindgap (override with MINDGAP_HOME, or MINDGAP_DB for just the DB file).
pipx (recommended): pipx install git+https://github.com/grburgess/mindgap.git mindgap init # creates ~/.mindgap/mindgap.db and seeds it mindgap serve # web UI at http://localhost:8765
pip: pip install --user git+https://github.com/grburgess/mindgap.git mindgap init && mindgap serve # ensure ~/.local/bin is on PATH
From a clone (no install / development): git clone https://github.com/grburgess/mindgap.git && cd mindgap ./install.sh # self-locating: PATH + ~/.mindgap + seed mindgap serve
/plugin marketplace add grburgess/mindgap /plugin install mindgap Registers the mindgap MCP server and eleven skills: arxiv-explainer, deep-research, idea-court, inbox-to-mindmap, knowledge-capture, loop-distill, loop-system, paper-to-mindmap, papers-library, second-brain, and todo-mindmap. Register the MCP at user scope so every Claude Code session, in any directory, can reach the graph:
claude mcp add -s user mindgap mindgap-mcp # global; needs mindgap-mcp on PATH
-s user is what makes it global (the default scope is local/current-dir only). The launcher self-locates and the DB lives in ~/.mindgap, so it runs from anywhere. A source checkout also ships a project-scoped .mcp.json → ./bin/mindgap-mcp, active only inside this repo.
~/.mindgap/ — mindgap.db and snapshots/. MINDGAP_HOME relocates the whole dir; MINDGAP_DB points at a single DB file elsewhere.
Self-learning skills keep their ledgers in $MINDGAP_HOME/learning/<skill>/ (default ~/.mindgap/learning/<skill>/), outside the install. The directory is created on demand — the first loop session seeds each file from the skill's templates/*.stub.md, so a fresh install needs no setup, and upgrades never touch it.
These files are personal state: never commit, push, or sync them. To move them to another computer, copy by hand:
rsync -av ~/.mindgap/learning/ 'newmachine:${MINDGAP_HOME:-$HOME/.mindgap}/learning/'
This is not hypothetical. Before this release, loop-system appended its ledgers inside the plugin directory (skills/loop-system/references/{lessons,global-learnings}.md), where /plugin install overwrites them — so updating the plugin silently destroys them. If you have run loops on an earlier version, rescue them once, before updating:
PLUGIN_DIR=~/.claude/plugins/mindgap # wherever /plugin install put it DEST="${MINDGAP_HOME:-$HOME/.mindgap}"/learning/loop-system mkdir -p "$DEST" for f in lessons global-learnings; do SRC="$PLUGIN_DIR/skills/loop-system/references/$f.md" if [ -e "$SRC" ]; then mv "$SRC" "$DEST"/; fi done
Either file may be absent — the loop moves what you have and stays quiet about the rest, so it is safe to run before you know which ledgers exist, and safe to run twice.
After this the plugin directory holds only code, so updates are safe.
You don't drive this graph node-by-node — you point an agent at a goal and let it loop. Install the Claude Code plugin, then in any project just say:
"set up an arxiv-weekly loop watching <your topics> and run the first pass" "continue the <name> loop" "ideate buildable implementations from my <name> graph, and refute the ones that aren't feasible" "build a graph of the authors doing <your topics> work, with their github pages"
Every run follows the protocol in AGENTS.md — read the existing subgraph for context, research, then ingest new nodes/edges with provenance and [[wiki-links]], and export a snapshot. The agent reaches the graph through the MCP server (validated writes that can't silently desync) or the CLI below. Beyond loops, every Claude Code session can deposit what it learned automatically. Then watch it compound — and re-run the loop tomorrow to grow it further. The pieces:
SessionEnd hook that distills on-domain learnings from any session into the graph, unattended.mindgap add --title T [--id ID] [--type TYPE] [--body MD | --body-file F]
[--tags a,b] [--url KIND=URL ...] [--by AGENT]
mindgap link SRC DST [--rel REL] [--weight W] [--by AGENT]
mindgap ingest FILE|- # bulk JSON ('-' = stdin)
mindgap find QUERY [--type T] [--tag T] [--json]
mindgap show ID [--json] # node + neighbors + urls
mindgap context QUERY [--depth 1] # markdown digest (for agents)
mindgap rm ID
mindgap unlink SRC DST [--rel REL]
mindgap export [--out FILE] # JSON snapshot -> ~/.mindgap/snapshots/
mindgap stats
mindgap lint [--json] # graph health: orphans/stubs/dups/stale
mindgap serve [--port 8765] [--no-open]
mindgap serve opens a single-page graph viewer (dark editorial theme), in 2D or 3D:
localStorage: a dark-theme picker (Editorial, Midnight, Graphite, Aubergine, Carbon), repulsion, link distance and strength, collision, link opacity, arrows, label mode, and the cluster controls below. "Reset to defaults" restores everything.Cmd/Ctrl-O) fuzzy-jumps to any node. Orphans chip filters to disconnected nodes.The UI is vanilla JS with no build step, drawing force-graph/3d-force-graph, d3, marked, and dompurify from CDNs. Community detection and hull geometry live in web/cluster.js; the 3D topic glow (nebula orbs + orb-hover labels) and the twinkling parallax star field live in web/glow3d.js and web/starfield.js, which build sprites/points in the live Three.js scene using a version-pinned three ESM import (esm.sh/three@0.179) exposed as a global.
Topic clusters (2D). Color nodes by community, then flip on Topic repulsion — a cohesion force pulls each topic into its own region.

3D mode. The same graph in three dimensions — drag to orbit, scroll to zoom. A twinkling, world-space star field sits behind the scene, so the graph reads as a galaxy you drift through.

Dark themes. Five built-in dark themes — Editorial, Midnight, Graphite, Aubergine, Carbon — switched live.

Timeline. Scrub the playhead or hit ▶ to watch the graph grow over time — at day / week / month resolution, with a before / after toggle to show only what existed then or only what's new since.

Quick switcher. Cmd/Ctrl-O to fuzzy-jump to any node by title.

mindtop is a top-like view of the graph: open todos in one pane, newly added nodes in the other, both refreshing about once a second while it is idle.
mindtop # run it mindtop close <id> # mark a todo status:done and exit
j/k move and g/G jump; Tab switches pane; Enter opens a node, and inside that view Enter follows the selected link while Esc or q walks back one hop at a time. / filters both panes as you type, Enter commits the filter so you can act on what it found, and Esc clears it. c closes the selected todo behind a confirmation, o opens a node's first URL, r forces a refresh. q quits from the main screen, and Ctrl-C quits from anywhere.
It needs an interactive terminal — piping or redirecting its output prints a message saying so rather than failing obscurely.
install.sh builds it when a Rust toolchain is present and skips it otherwise: mindgap itself never requires cargo.
For agents, mindgap/mcp.py exposes the graph as an MCP server over stdio — stdlib-only (newline-delimited JSON-RPC 2.0, no pip deps). For all sessions everywhere, register it globally at user scope: claude mcp add -s user mindgap mindgap-mcp (needs mindgap-mcp on PATH; the launcher self-locates and the DB lives in ~/.mindgap, so it works from any directory). A source checkout also ships a project-scoped .mcp.json → ./bin/mindgap-mcp, active only inside the repo.
Ten tools wrap the same db layer as the CLI: mindgap_ingest (batch write), mindgap_add_node, mindgap_link, mindgap_unlink, mindgap_get_node, mindgap_find, mindgap_context, mindgap_stats, mindgap_export, mindgap_remove_node. Unlike the raw CLI, the write tools validate at the call boundary — mindgap_ingest rejects the whole payload (no partial commit) if any edge endpoint isn't in the DB or the payload, mindgap_link refuses to auto-stub a missing endpoint, created_by is required, and writes return the persisted rows so a caller can't claim a write that didn't land.
The graph is designed to be fed by recurring autonomous sessions that scan Confluence, GitHub, and arXiv. The protocol — read context first, ingest JSON with provenance (created_by, source URLs), wiki-link into the existing graph, export at session end — is defined in AGENTS.md. Sessions can drive the graph via the CLI or the MCP tools above (the MCP's validation makes it the safer path for unattended writes).
mindgap install registers two read-only Claude Code hooks in ~/.claude/settings.json (idempotent; an existing entry for the same script is kept; the file is backed up once to settings.json.bak; skip with --no-hooks):
bin/mindgap-recall-hook: nodes matching the folder name, plus the newest cross-project global-learning rows.bin/mindgap-prompt-recall-hook: nodes matching the words of the prompt (stemmed, title/tags/id, always at least two matching words, decisions and gotchas weighted up), up to 6. Session-start recall is newest-first, so in a busy project an old closed decision never makes the cut; this one ranks by the question.Both are database reads, no model call, ~150 ms, and always exit 0. Turn either off in ~/.mindgap/capture.json: "recall": {"enabled": false} (both) or {"prompt": false}.
Disabled by default. mindgap ships the capture engine off, with an empty domain. Nothing fires until you opt in.
Optionally, mindgap can learn from every Claude Code session: a SessionEnd hook runs a cheap deterministic pre-gate (no LLM) and, only when a session looks on-domain, fire-and-forgets a detached headless subagent that distills durable learnings and ingests them — following the knowledge-capture skill and AGENTS.md. Captured nodes carry created_by="capture:<repo>", confidence=0.6, and a urls entry pointing at the transcript, so they sit below hand-curated nodes and are trivially reversible.
To enable it:
mindgap init once — copies the packaged preset to ~/.mindgap/capture.json.~/.mindgap/capture.json: set "enabled": true and fill in domain (a description and keywords that define what counts as on-topic). Tune denylist_dirs/allowlist_dirs, min_transcript_bytes, and the capture/lint blocks as needed. (MINDGAP_CAPTURE_ENABLED env-overrides the flag.)~/.claude/settings.json under SessionEnd, pointing at mindgap-capture-hook (on PATH after install, or ./bin/mindgap-capture-hook from a source checkout).The pre-gate skips off-domain dirs, denylisted dirs, capture's own self-spawned sessions (MINDGAP_CAPTURE=1), too-small transcripts, and transcripts with no domain keywords — so the LLM subagent only ever runs on genuinely on-topic sessions. A best-effort lock (~/.mindgap/capture.lock) single-flights it. The hook never blocks session exit.
mindgap lint is the companion: a deterministic health report (orphans, dangling stubs, near-duplicate candidates, stale capture nodes) that never rewrites the graph.
The bundle ships self-improving loop templates that sweep arXiv for a topic and ingest findings into your graph with evidence-backed links — driven by the loop-system skill.
List what's available and scaffold one: mindgap loop list mindgap loop new arxiv-weekly --name my-watch --topics "your research area"
Then just tell Claude (in the project where you scaffolded it): "continue the my-watch loop"
Bundled templates:
CRON.md (launchd/cron).person-node graph of the researchers behind the work, with their resolved GitHub / homepage / Scholar links and co-author connections.Share a loop you've built (strips your accumulated state): mindgap loop export my-watch # -> ./my-watch-template/ mindgap loop import ./my-watch-template --name their-watch --topics "..."
Prompts you can hand to Claude directly (once the plugin is installed):
The bundled arxiv-explainer skill turns a paper into a richly animated, narrated HTML explainer — figures extracted from the PDF, a self-contained dark theme — and ingests it into your graph. Just tell Claude explain <arXiv link> (or point it at a local PDF).
Mine your Papers (ReadCube) reference library into the graph: export it to BibTeX or RIS (Papers → Settings → Export) and tell Claude:
"import my Papers library from <path-to-export.bib>"
The bundled papers-library skill parses the export (stdlib, no deps), ingests each paper as a node (deduped against the graph, evidence-linked), discovers related papers not yet in your library, and seeds ideas — handing off to the paper-links / implementation-ideation loops for depth.
Two tables:
nodes(id, title, type, body, tags, urls, confidence, created_by, created_at, updated_at) — id is a kebab-case slug; tags/urls are JSON arrays; types: concept|definition|software|repo|page|paper|person|team|stub.edges(src, dst, rel, weight, created_by, created_at) — rels: relates_to|defines|implements|depends_on|cites|part_of|mentions.[[wiki-links]] in a body sync to mentions edges automatically, creating stub nodes for missing targets. Upserts merge: scalar fields replace, tags/urls union.
The DB is gitignored; history is kept as JSON snapshots:
mindgap export # ~/.mindgap/snapshots/<utc>.json
mindgap export --out my.json
Commit snapshots for a durable, diffable record; re-ingest one with mindgap ingest FILE to restore.
python3 -m mindgap ... # run CLI from repo without install
python3 -m unittest discover tests
MIT — see LICENSE.
hooks/register.js 19 lines1import { shared } from './shared.js'
2import { register as recall } from './features/recall.js'
3import { register as liveview } from './features/liveview.js'
4import { register as capture } from './features/capture.js'
5import { register as commands } from './features/commands.js'
6import { register as activity } from './features/activity.js'
7import { register as guard } from './features/guard.js'
8import { register as routing } from './features/routing.js'
9
10export const register = (on, options) => {
11 recall(on, shared)
12 liveview(on, shared)
13 capture(on, shared)
14 commands(on, shared)
15 activity(on, shared)
16 guard(on, shared)
17 routing(on, shared)
18}
19hooks/shared.js 6 lines1// Cross-feature state shared by all feature modules.
2export const shared = {
3 counters: { recalled: 0, ingested: 0 },
4 captureCandidates: [],
5}
6hooks/features/recall.js 72 lines1// F1 recall pane: run `mindgap find <terms> --json` on session start and
2// each prompt, list recalled nodes in a pane, each droppable. Pane opens only
3// via /mm recall (commands.js), never unasked.
4
5
6export const PANE = 'mm-recall'
7const MAX = 10
8
9// Query terms: first few words >= 4 chars.
10export const terms = text =>
11 (text.toLowerCase().match(/[a-z0-9_-]{4,}/g) ?? []).slice(0, 3).join(' ')
12
13// Pure: new rows from `find --json` stdout not already recalled; null when nothing new.
14export function freshRows(shared, stdout) {
15 let rows
16 try { rows = JSON.parse(stdout) } catch { return null }
17 const seen = new Set(shared.recalled.map(n => n.id))
18 const fresh = rows.filter(n => !seen.has(n.id)).slice(0, MAX)
19 return fresh.length ? fresh.map(n => ({ id: n.id, title: n.title })) : null
20}
21
22export function register(on, shared) {
23 shared.recalled ??= []
24
25 on('session.start', { isInteractive: true }, async ($, e, next) => {
26 try {
27 const r = await $.process.run(['mindgap', 'find', 'project', '--json'])
28 const fresh = r.exitCode === 0 ? freshRows(shared, r.stdout) : null
29 if (fresh) {
30 shared.recalled = [...shared.recalled, ...fresh]
31 shared.counters.recalled += fresh.length
32 $.ui.invalidate('ui.render')
33 }
34 } catch {}
35 return next(e)
36 })
37
38 on('prompt.submit', async ($, e, next) => {
39 const q = terms(e.text)
40 if (q) {
41 const r = await $.process.run(['mindgap', 'find', q, '--json'])
42 const fresh = r.exitCode === 0 ? freshRows(shared, r.stdout) : null
43 if (fresh) {
44 shared.recalled = [...shared.recalled, ...fresh]
45 shared.counters.recalled += fresh.length
46 $.ui.invalidate('ui.render')
47 }
48 }
49 return next(e)
50 })
51
52 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
53 const { Box, Text, Button } = $.ui.resolve(e)
54 return h(Box, { flexDirection: 'column' },
55 shared.recalled.length === 0 && h(Text, { dimColor: true }, 'Nothing recalled.'),
56 ...shared.recalled.map(n =>
57 h(Box, { flexDirection: 'row', gap: 1 },
58 h(Text, { dimColor: true }, n.id),
59 h(Text, null, n.title),
60 h(Button, {
61 key: `drop-${n.id}`,
62 label: 'drop',
63 onPress: () => {
64 shared.recalled = shared.recalled.filter(x => x.id !== n.id)
65 $.ui.invalidate('ui.render')
66 },
67 }),
68 )),
69 )
70 })
71}
72hooks/features/liveview.js 57 lines1// F2 liveview: band above prompt (open run phase + verdict counts); mirror AskUserQuestion to the run.
2// No helper receives $: hooks call $ directly; helpers are pure.
3
4export const runIds = stdout => stdout.split('\n').map(l => l.split('\t')[0]).filter(Boolean).slice(0, 5)
5export const parseEvents = text => String(text).split('\n').filter(Boolean).flatMap(l => { try { return [JSON.parse(l)] } catch { return [] } })
6export const isOpen = events => !events.some(ev => ev.kind === 'run.end')
7const eventsPath = (home, id) => `${home}/.mindgap/runs/${id}/events.jsonl`
8
9export function summarize(events) {
10 let phase = null
11 const verdicts = {}
12 for (const ev of events) {
13 if (ev.kind === 'phase.start' && ev.phase) phase = ev.phase
14 if (ev.kind === 'verdict' && ev.data?.verdict) verdicts[ev.data.verdict] = (verdicts[ev.data.verdict] ?? 0) + 1
15 }
16 return { phase, verdicts }
17}
18
19export function register(on, shared) {
20 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
21 if (e.props.hasSurvey) return next(e)
22 const list = await $.process.run(['mindgap', 'run', 'list'])
23 if (list.exitCode !== 0) return next(e)
24 const home = await $.env.get('HOME')
25 let run = null
26 for (const id of runIds(list.stdout)) {
27 let text
28 try { text = await $.fs.read(eventsPath(home, id)) } catch { continue }
29 const events = parseEvents(text)
30 if (isOpen(events)) { run = { id, events }; break }
31 }
32 if (!run) return next(e)
33 const { phase, verdicts } = summarize(run.events)
34 const counts = Object.entries(verdicts).map(([k, n]) => `${k} ${n}`).join(' · ')
35 const { Box, Text } = $.ui.resolve(e)
36 return h(Box, null, h(Text, { key: 'liveview', dimColor: true },
37 `live ${run.id} · phase ${phase ?? '-'}${counts ? ' · ' + counts : ''}`))
38 })
39
40 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
41 try {
42 const list = await $.process.run(['mindgap', 'run', 'list'])
43 if (list.exitCode === 0) {
44 const home = await $.env.get('HOME')
45 for (const id of runIds(list.stdout)) {
46 let text
47 try { text = await $.fs.read(eventsPath(home, id)) } catch { continue }
48 if (!isOpen(parseEvents(text))) continue
49 await $.process.run(['mindgap', 'run', 'emit', id, 'question.ask', '--actor', 'claude', '--data', JSON.stringify({ questions: e.questions })])
50 break
51 }
52 }
53 } catch {}
54 return next(e)
55 })
56}
57hooks/features/capture.js 67 lines1// capture feature: collect turn answers as candidates; flush them to the graph via `mindgap ingest -`
2// on /mm flush, SessionEnd and PreCompact. Titles compressed by haiku (routing.js), raw on fallback.
3import { routingRequest, applyRouting } from './routing.js'
4
5const BY = 'mod:capture'
6const ARGV = ['mindgap', 'ingest', '--by', BY, '-']
7const summarize = (text) => text.replace(/\s+/g, ' ').trim().slice(0, 80)
8
9export function collectCandidate(shared, e) {
10 if (e.agentId || e.reason !== 'answer' || !e.answer?.trim()) return
11 shared.captureCandidates.push({
12 id: `capture-${e.turnId}`,
13 title: summarize(e.answer),
14 body: e.answer,
15 type: 'capture',
16 confidence: 0.5,
17 created_by: BY,
18 })
19}
20
21// After the CLI call: requeue on failure, count on success.
22function settle(shared, nodes, r) {
23 if (r.exitCode !== 0) { shared.captureCandidates.unshift(...nodes); return { flushed: 0, error: r.stderr } }
24 shared.counters.ingested += nodes.length
25 return { flushed: nodes.length }
26}
27
28export function register(on, shared) {
29 on('turn.complete', async ($, e, next) => { collectCandidate(shared, e); return next(e) })
30
31 // Idempotent: queue emptied before the CLI call; empty queue -> no call.
32 on('classic.SessionEnd', async ($, e, next) => {
33 const raw = shared.captureCandidates.splice(0)
34 if (raw.length) {
35 let routed
36 try { routed = await $.model.complete(routingRequest(raw)) } catch { routed = undefined }
37 const { nodes } = applyRouting(raw, routed)
38 const out = settle(shared, raw, await $.process.run(ARGV, { stdin: JSON.stringify({ nodes }) }))
39 if (out.flushed) $.ui.toast(`mindmap: ingested ${out.flushed} (${shared.counters.ingested} this session)`)
40 }
41 return next(e)
42 })
43 on('classic.PreCompact', async ($, e, next) => {
44 const raw = shared.captureCandidates.splice(0)
45 if (raw.length) {
46 let routed
47 try { routed = await $.model.complete(routingRequest(raw)) } catch { routed = undefined }
48 const { nodes } = applyRouting(raw, routed)
49 const out = settle(shared, raw, await $.process.run(ARGV, { stdin: JSON.stringify({ nodes }) }))
50 if (out.flushed) $.ui.toast(`mindmap: ingested ${out.flushed} (${shared.counters.ingested} this session)`)
51 }
52 return next(e)
53 })
54
55 on('command.run', { command: 'mm' }, async ($, e, next) => {
56 if ((e.args || '').trim().split(/\s+/)[0] !== 'flush') return next(e)
57 const raw = shared.captureCandidates.splice(0)
58 if (!raw.length) return { text: 'flushed: 0' }
59 let routed
60 try { routed = await $.model.complete(routingRequest(raw)) } catch { routed = undefined }
61 const { nodes, via } = applyRouting(raw, routed)
62 const out = settle(shared, raw, await $.process.run(ARGV, { stdin: JSON.stringify({ nodes }) }))
63 if (out.flushed) $.ui.toast(`mindmap: ingested ${out.flushed} (${shared.counters.ingested} this session)`)
64 return { text: out.flushed ? `flushed: ${out.flushed} (${via})` : `flush failed: ${out.error || 'unknown'}` }
65 })
66}
67hooks/features/commands.js 37 lines1// commands feature: /todo <text>, /mm find <q>, /mm recall, /mm flush. No Claude turn.
2import { PANE } from './recall.js'
3
4export function register(on, shared) {
5 on('session.start', async ($, e, next) => {
6 await $.command.register({ name: 'todo', description: 'Add an open todo to mindgap' })
7 await $.command.register({ name: 'mm', description: 'mindgap: find <q> | recall | flush' })
8 return next(e)
9 })
10
11 on('command.run', { command: 'todo' }, async ($, e) => {
12 const text = (e.args || '').trim()
13 if (!text) return { text: 'usage: /todo <text>' }
14 const r = await $.process.run(['mindgap', 'add', '--title', text, '--type', 'todo', '--tags', 'status:open', '--by', 'mod:todo'])
15 if (r.exitCode !== 0) return { text: `todo failed: ${r.stderr || r.stdout}` }
16 shared.counters.ingested += 1
17 $.ui.toast(`mindmap: ingested todo (${shared.counters.ingested} this session)`)
18 return { text: `todo added: ${(r.stdout || '').trim()}` }
19 })
20
21 on('command.run', { command: 'mm' }, async ($, e, next) => {
22 const [sub, ...rest] = (e.args || '').trim().split(/\s+/)
23 if (sub === 'find' && rest.length) {
24 const r = await $.process.run(['mindgap', 'find', rest.join(' ')])
25 return { text: r.exitCode === 0 ? (r.stdout || '').trim() || 'no matches' : `find failed: ${r.stderr || r.stdout}` }
26 }
27 if (sub === 'recall') {
28 await $.ui.open({ id: PANE, title: 'Recall' })
29 return { text: `recall pane: ${shared.recalled.length} node(s)` }
30 }
31 if (sub === 'flush') {
32 return next(e) // answered by capture.js's /mm flush hook
33 }
34 return { text: 'usage: /mm find <q> | /mm recall | /mm flush' }
35 })
36}
37hooks/features/activity.js 31 lines1// activity feature: spinner suffix with graph counters + toast on MCP ingest/add_node.
2// (capture flush and /todo toast inline where they ingest.)
3import { shared as state } from '../shared.js'
4
5const P = 'mcp__mindgap__mindgap_'
6
7export const activityText = () =>
8 ` · mindmap: ${state.counters.recalled} recalled · ${state.counters.ingested} ingested`
9
10export function register(on, shared) {
11 on('ui.render', { component: 'Spinner' }, ($, e, next) =>
12 next({ ...e, props: { ...e.props, suffix: activityText() } }))
13
14 on('tool.call', { tool: P + 'ingest' }, async ($, e, next) => {
15 const r = await next(e)
16 if (!r?.isError) {
17 state.counters.ingested += 1
18 $.ui.toast(`mindmap: ingested (${state.counters.ingested} this session)`)
19 }
20 return r
21 })
22 on('tool.call', { tool: P + 'add_node' }, async ($, e, next) => {
23 const r = await next(e)
24 if (!r?.isError) {
25 state.counters.ingested += 1
26 $.ui.toast(`mindmap: ingested (${state.counters.ingested} this session)`)
27 }
28 return r
29 })
30}
31hooks/features/guard.js 18 lines1// guard feature (F6): confirm destructive mindgap MCP calls via $.ui.ask.
2const P = 'mcp__mindgap__mindgap_'
3const OK = 'Confirm', NO = 'Cancel'
4
5export const needsConfirm = e =>
6 e.tool === P + 'remove_node' ||
7 ((e.tool === P + 'ingest' || e.tool === P + 'add_node') && e.replace === true)
8
9export function register(on, shared) {
10 on('tool.call', async ($, e, next) => {
11 if (!needsConfirm(e)) return next(e)
12 const what = e.tool.slice(P.length) + (e.id ? ` ${e.id}` : '')
13 let answer
14 try { answer = await $.ui.ask(`Allow destructive mindgap ${what}?`, [OK, NO]) } catch { answer = NO }
15 return answer === OK ? next(e) : { deny: `mindgap guard: user cancelled ${what}` }
16 })
17}
18hooks/features/routing.js 20 lines1// F7 routing: pure helpers for compressing capture titles via in-mod haiku (called inline in capture.js).
2export const PROMPT = 'Write one short title (max 80 chars) per numbered session capture candidate below. Output exactly one title per line, same order, no numbering.\n\n'
3
4export const routingRequest = (nodes) => ({
5 model: 'haiku',
6 prompt: PROMPT + nodes.map((n, i) => `${i + 1}. ${n.body}`).join('\n'),
7 effort: 'low',
8 timeoutMs: 20000,
9})
10
11// Haiku titles when answered with exactly one line per node; else raw nodes (fallback).
12export function applyRouting(nodes, r) {
13 if (!r || !r.isAnswered) return { nodes, via: 'raw' }
14 const titles = r.text.split('\n').map(s => s.trim()).filter(Boolean)
15 if (titles.length !== nodes.length) return { nodes, via: 'raw' }
16 return { nodes: nodes.map((n, i) => ({ ...n, title: titles[i].slice(0, 80) })), via: 'haiku' }
17}
18
19export function register(on, shared) {}
20