SLOPSHOPPER

wiki-mind

Delivers wiki-mind's session context as an instruction file, so it survives compaction whole and reaches subagents, and shows the vault's Stop report as one…

newpromptprocess
A shopper browsing a rack in a slop shop
README

wiki-mind

An Obsidian research vault on the LLM-wiki pattern: you bring sources, and an agent reads them and keeps a linked wiki of concepts, entities and comparisons up to date. You steer, read and correct. Every note is plain Markdown that Obsidian opens without the agent.

wiki-mind is a ShardMind shard, built for Claude Code.

What you get

sources/One note per paper or article: what it says, its key claims, each linked to what it informs.
concepts/Atomic ideas, each citing its sources.
entities/Named systems, tools and people, each citing its sources.
syntheses/Comparisons: "X vs Y" notes over two or more concepts or entities.
questions/The intake queue: questions and leads, each open, answered or dropped.
inbox/Raw material before it is read: PDFs, saved pages, notes.
Index.mdThe entry point: the agent's one-line annotations above a Bases view of each note type.
templates/, bases/A template and a Bases view per note type.
CLAUDE.mdThe agent's manual for the vault.

Claude Code hooks keep the wiki honest as you work. The session starts with the wiki's state and open questions. Each write is checked against the note type's rules. Each answer ends with a short drift report: notes that cite no source, one-sided comparisons, orphans, notes Index.md doesn't annotate, stale questions.

Commands

CommandDoes
/wiki-ingest <url or inbox path>Reads a source in full, writes its note, and updates the concepts and entities it informs, with every claim linked back to it.
/wiki-synthesize <X> vs <Y>Compares notes the wiki already has, citing their sources. Refuses when a side has no note yet.
/wiki-question <text>Files a question in the intake queue.
/wiki-lintChecks the whole wiki for drift and fixes what it can.

Install

You need Obsidian 1.12 or later, Node.js 22.6 or later, ShardMind 0.2.1 or later (npx fetches it), and Claude Code.

mkdir my-wiki && cd my-wiki
npx shardmind install breferrari/wiki-mind

If your ShardMind can't find it by name, use the full GitHub reference: npx shardmind install github:breferrari/wiki-mind.

The installer asks three questions: your name, what the wiki is about, and whether to use QMD search. Then open the folder as a vault in Obsidian, and start Claude Code in it.

Or clone it. git clone https://github.com/breferrari/wiki-mind my-wiki gives the same vault with the defaults. To receive updates, run npx shardmind adopt breferrari/wiki-mind in it.

Updates. In a vault installed with ShardMind, npx shardmind update brings in a new release and merges it with your edits. A file you changed is never overwritten without asking.

Search (optional)

With QMD installed (npm install -g @tobilu/qmd), the agent searches the wiki semantically, and the hooks keep the index fresh as notes change. Each vault gets its own index. Without QMD, everything still works, and the agent searches with grep.

Platforms

Windows, macOS and Linux. CI installs the vault and runs every hook on all three.

License

MIT

Source 4 files
hooks/register.ts 284 lines
1import { atom, read, update, type EngineInterface, type PluginState, type Register } from "claude-code";
2import { withSessionContext } from "./context.ts";
3import { carriesReport, fromPerson, parseStopReport, ranIn, summaryLine, withLine } from "./stop.ts";
4
5/**
6 * wiki-mind's Claude Code mod (obsidian-mind#262).
7 *
8 * The vault's settings hooks stay the engine: Codex and Gemini run them, and
9 * so does Claude Code wherever this mod does not load (an older CLI, an
10 * untrusted folder, a session launched in a vault subfolder, a policy that
11 * allows only managed mods). This mod changes how their output reaches the
12 * session, never what it says: it runs the vault's own scripts and delivers
13 * the result through a better channel.
14 *
15 * Session context (obsidian-mind#265): `session-start.ts` runs here with
16 * `om_mod: "deliver"`, and its output becomes an instruction file instead of
17 * hook output. Unlike hook output it is not cut at 10,000 characters, it is
18 * re-read whole after compaction and `/clear` instead of shrinking to a
19 * pointer, and general-purpose subagents receive it.
20 *
21 * Stop report (obsidian-mind#266): `stop-checklist.ts` runs here with `om_mod: "report"`.
22 * When the findings changed, the user sees one line under the answer and the
23 * agent gets the full report with the next prompt, unseen. A finding marked
24 * urgent gets a turn of its own at once.
25 *
26 * The switch is the event itself: the settings hook is passed
27 * `om_mod: "standdown"` and exits, but only on an event this hook actually
28 * handled. The work is done before `next`, so if it fails the hook throws,
29 * Claude Code skips it, and the settings hook gets the original event and
30 * runs as it would without the mod.
31 *
32 * What the hooks hand each other lives in `$.state`, not in module
33 * variables: the host keeps it for the session, across a hot reload.
34 */
35
36/** Where the delivered context is also written, so /memory opens what the model received. Gitignored. */
37const CONTEXT_FILE = ".claude/session-context.md";
38
39/** The session context this session's instruction file carries; what prompt.context hands the model. */
40const sessionContext = atom({ plugin: "wiki-mind", key: "context" } as const, null);
41/** The report waiting to be delivered: its text, and its line and urgent finding until they are used. */
42const queued = atom({ plugin: "wiki-mind", key: "queued" } as const, null);
43/** Whether an urgent finding has had its turn since the person last spoke. */
44const urgentSpent = atom({ plugin: "wiki-mind", key: "urgentSpent" } as const, false);
45/** Bumped by every start that begins another conversation, so a report in flight across one is not put back. */
46const generation = atom({ plugin: "wiki-mind", key: "generation" } as const, 0);
47/**
48 * The report a prompt took, until a turn starts with that prompt. A prompt
49 * enters when it is queued, not when its turn starts, and a queued prompt can
50 * be pulled back out of the queue; if a turn starts with another prompt
51 * first, this one never ran, and the report goes back in the queue.
52 */
53const inFlight = atom({ plugin: "wiki-mind", key: "inFlight" } as const, null);
54
55type Queued = NonNullable<PluginState["wiki-mind"]["queued"]>;
56
57/**
58 * Which report each session was last given, by session id, and whether it
59 * reached the agent: in `$.store`, not `$.state`, because the store outlives
60 * the process. A `claude --resume` in a new process then neither repeats a
61 * report the agent had nor loses one that was still waiting (the settings
62 * hook's dedupe is file-backed for the same reason). Only the most recent
63 * sessions are kept.
64 */
65const SHOWN = "shown";
66const SHOWN_KEEP = 20;
67type Shown = { readonly key: string; readonly delivered: boolean };
68
69async function shownFor($: EngineInterface, sessionId: string): Promise<Shown | undefined> {
70	const shown = ((await $.store.get(SHOWN)) ?? {}) as Record<string, Shown>;
71	return shown[sessionId];
72}
73
74async function setShown($: EngineInterface, sessionId: string, entry: Shown | null): Promise<void> {
75	const shown = { ...(((await $.store.get(SHOWN)) ?? {}) as Record<string, Shown>) };
76	delete shown[sessionId];
77	if (entry !== null) shown[sessionId] = entry;
78	// Insertion order is recency: drop the oldest sessions past the cap.
79	const ids = Object.keys(shown);
80	for (const id of ids.slice(0, Math.max(0, ids.length - SHOWN_KEEP))) delete shown[id];
81	await $.store.set(SHOWN, shown);
82}
83
84/**
85 * Run one of the vault's hook scripts with `input` on stdin; its stdout, or a
86 * throw. `timeoutMs` matches the script's own timeout in settings.json, so the
87 * mod never waits longer than the hook it replaces would have.
88 */
89async function runScript($: EngineInterface, root: string, script: string, input: object, timeoutMs: number): Promise<string> {
90	const run = await $.process.run(["node", "--disable-warning=ExperimentalWarning", "--experimental-strip-types", `${root}/.claude/scripts/${script}`], {
91		cwd: root,
92		env: { CLAUDE_PROJECT_DIR: root },
93		stdin: JSON.stringify(input),
94		timeoutMs,
95	});
96	if (run.exitCode !== 0 || run.stdout.trim() === "") {
97		throw new Error(`${script} exited ${run.exitCode}: ${run.stderr.slice(0, 300)}`);
98	}
99	// A cut output would stand the hook down for part of what it delivers.
100	if (run.isStdoutTruncated) throw new Error(`${script} printed more than process.run keeps`);
101	return run.stdout;
102}
103
104export const register: Register = (on) => {
105	on("classic.SessionStart", async ($, e, next) => {
106		// Only a compaction continues the same conversation. Every other start
107		// (`/clear`, an in-process `/resume` or fork) may keep this process and
108		// its `$.state`, and a report about another conversation must not ride
109		// the first prompt of this one, so what was queued is dropped.
110		if (e.source !== "compact") {
111			// A report dropped here was never marked delivered, so the session it
112			// was for gets it again at its next Stop; one it already had stays given.
113			await update($, queued, () => null);
114			await update($, urgentSpent, () => false);
115			await update($, generation, (now) => now + 1);
116			await update($, inFlight, () => null);
117			// If this run fails, the settings hook runs instead and prints the full
118			// layer, so the old context is cleared first (and the render redrawn)
119			// or it would ride beside the fresh one. At a compaction the hook
120			// prints only a pointer, trusting the static half to be in the
121			// conversation already; under the mod it never was, so there the last
122			// good context is kept rather than lost.
123			await update($, sessionContext, () => null);
124			$.ui.invalidate("prompt.context");
125		}
126		const root = await $.session.root();
127		const text = await runScript($, root, "session-start.ts", { ...e, om_mod: "deliver" }, 30_000);
128		await update($, sessionContext, () => text);
129		// Not awaited: delivery does not depend on the file, so a slow, hung or
130		// failed write never holds up the session. The file only backs what
131		// /memory shows. Runs are minutes apart (startup, then a compaction), so
132		// two writes landing out of order is not a case worth machinery: a
133		// deadline would need a timer that outlives this hook, and a chain
134		// without one would let a hung write stall every later write.
135		$.fs.write(`${root}/${CONTEXT_FILE}`, text).catch(() => {});
136		$.ui.invalidate("prompt.context");
137		return next({ ...e, om_mod: "standdown" } as typeof e);
138	});
139
140	on("prompt.context", async ($, e, next) => {
141		const below = await next(e);
142		const text = await read($, sessionContext);
143		if (text === null) return below;
144		return withSessionContext(below, `${await $.session.root()}/${CONTEXT_FILE}`, text);
145	});
146
147	on("classic.Stop", async ($, e, next) => {
148		// A turn some Stop hook forced: the settings hook exits on its own.
149		if (e.stop_hook_active) return next(e);
150		const root = await $.session.root();
151		let report: ReturnType<typeof parseStopReport>;
152		try {
153			report = parseStopReport(await runScript($, root, "stop-checklist.ts", { ...e, om_mod: "report" }, 5_000));
154		} catch (error) {
155			// The settings hook runs in this hook's place and hands over its own
156			// report; one still queued here would ride the same prompt beside it.
157			await update($, queued, () => null);
158			throw error;
159		}
160		// Per session, so a new session shows its first report even with the
161		// same findings, as the settings hook's dedupe does.
162		const sessionId = String(e.session_id);
163		const given = await shownFor($, sessionId);
164		const waiting = (record: { readonly sessionId: string; readonly key: string } | null | undefined) =>
165			record?.sessionId === sessionId && record.key === report.key;
166		// The same findings again: skip them if the agent had them, or if they are
167		// still on their way in this process. Not delivered and not on their way
168		// (a resume in a new process, a dropped queue) means queue them again.
169		const already = given?.key === report.key && (given.delivered || waiting(await read($, queued)) || waiting((await read($, inFlight))?.record));
170		if (!already) {
171			await setShown($, sessionId, { key: report.key, delivered: false });
172			// The urgent finding rides inside the report too, so whatever happens
173			// to its own turn, the agent gets it with the report.
174			const text = report.urgent === undefined ? report.agentText : `${report.agentText}\n\nUrgent: ${report.urgent}`;
175			await update($, queued, (): Queued => ({ sessionId, key: report.key, report: text, line: summaryLine(report), urgent: report.urgent ?? null }));
176		}
177		return next({ ...e, om_mod: "standdown" } as typeof e);
178	});
179
180	on("turn.complete", async ($, e, next) => {
181		const done = await next(e);
182		// Only under a main-loop answer that completed: a subagent's turn, an
183		// interrupted one or one an error ended keeps the line for the next.
184		if (e.agentId !== undefined || e.reason !== "answer") return done;
185		// The line and the urgent finding are used once; the report stays queued for the next prompt.
186		let line: string | null = null;
187		let urgent: string | null = null;
188		await update($, queued, (now) => {
189			line = now?.line ?? null;
190			urgent = now?.urgent ?? null;
191			return now === null || now.line === null ? now : { ...now, line: null, urgent: null };
192		});
193		if (line === null) return done;
194		// One urgent turn per prompt the person sends: findings that keep
195		// changing while the agent fixes them must not chain turns. A finding
196		// that gets no turn still reaches the agent inside the queued report.
197		if (urgent !== null && !(await read($, urgentSpent))) {
198			await update($, urgentSpent, () => true);
199			// Never from classic.Stop: the engine refuses a submit that would wait
200			// on the turn the hook may be holding, and names turn.complete instead.
201			// Framed as this plugin's message, so the model knows it is not the
202			// person speaking. The report rides it (prompt.submit below); if the
203			// prompt never enters, the report stays queued for the next one.
204			$.prompt.submit({ text: urgent }).catch(() => {});
205		}
206		return { ...done, text: withLine(done.text, e.answer, line) };
207	});
208
209	on("prompt.submit", async ($, e, next) => {
210		// The person speaking renews the urgent allowance, once their prompt has entered.
211		const renew = async (entered: Awaited<ReturnType<typeof next>>) => {
212			if (entered.drop === undefined && fromPerson(e.origin)) await update($, urgentSpent, () => false);
213			return entered;
214		};
215		if (!carriesReport(e.origin)) return renew(await next(e));
216		// The whole record is taken before `next`, so two prompts entering at
217		// once cannot both carry it, and a report queued while this one enters
218		// is a different record that nothing here touches. Put back if this
219		// prompt never enters (dropped or blocked below, or a throw), unless a
220		// newer one was queued meanwhile.
221		let taken: Queued | null = null;
222		await update($, queued, (now) => {
223			taken = now;
224			return null;
225		});
226		if (taken === null) return renew(await next(e));
227		const record: Queued = taken;
228		const startedIn = await read($, generation);
229		// Put back only into the conversation it was taken from: a `/clear` or
230		// resume while this prompt was entering has dropped the queue on purpose.
231		// Read through `update`, whose function sees writes made during `next`.
232		const putBack = async () => {
233			let now = startedIn;
234			await update($, generation, (g) => {
235				now = g;
236				return g;
237			});
238			if (now === startedIn) await update($, queued, (current) => current ?? record);
239		};
240		// Held before `next`: the prompt's own turn can start inside `next`
241		// (observed on 2.1.288), and turn.start must find it there to count it run.
242		await update($, inFlight, () => ({ text: e.text, record }));
243		let entered: Awaited<ReturnType<typeof next>>;
244		try {
245			entered = await next({ ...e, context: [...(e.context ?? []), record.report] });
246		} catch (error) {
247			await update($, inFlight, () => null);
248			await putBack();
249			throw error;
250		}
251		if (entered.drop !== undefined) {
252			await update($, inFlight, () => null);
253			await putBack();
254			return entered;
255		}
256		return renew(entered);
257	});
258
259	on("turn.start", async ($, e, next) => {
260		// Only the main loop's prompts carry the report; a turn begun without a
261		// prompt (a continuation, text "") says nothing about the queue.
262		let held: PluginState["wiki-mind"]["inFlight"] = null;
263		await update($, inFlight, (now) => {
264			held = now;
265			return now === null || e.text === "" ? now : null;
266		});
267		const waiting: PluginState["wiki-mind"]["inFlight"] = held;
268		if (waiting === null || e.text === "") return next(e);
269		// Queued prompts run in order and may be folded into one turn, so a turn
270		// whose text holds the prompt's ran it: delivered, and remembered as
271		// delivered so no later start repeats it. Any other prompt's turn starting
272		// first means the one holding the report left the queue unrun.
273		if (ranIn(e.text, waiting.text)) {
274			const given = await shownFor($, waiting.record.sessionId);
275			if (given?.key === waiting.record.key) await setShown($, waiting.record.sessionId, { key: waiting.record.key, delivered: true });
276		} else {
277			// A start that begins another conversation clears what is in flight,
278			// so a prompt still held here is from this one.
279			await update($, queued, (current) => current ?? waiting.record);
280		}
281		return next(e);
282	});
283};
284
hooks/context.ts 37 lines
1import type { PromptContextResult } from "claude-code";
2
3/** The name the context renders under when no instruction file can carry it. */
4export const CONTEXT_BLOCK = "wiki-mind";
5
6/**
7 * The first message's context with the session context added: as a project
8 * instruction file, which Claude Code frames like CLAUDE.md, re-reads after
9 * compaction and `/clear`, and gives general-purpose subagents.
10 *
11 * When a hook above rewrote the `claudeMd` text, the files behind it are
12 * unknown and no file can be added (`instructionFiles` is undefined). The
13 * context then rides as a block of its own, so the session still gets it:
14 * the settings hook has already stood down for this event.
15 *
16 * Either way an earlier copy is replaced, never duplicated.
17 */
18export function withSessionContext(below: PromptContextResult, path: string, text: string): PromptContextResult {
19	if (below.instructionFiles) {
20		const others = below.instructionFiles.filter((file) => !samePath(file.path, path));
21		return { ...below, instructionFiles: [...others, { path, kind: "project", content: text }] };
22	}
23	const others = below.blocks.filter((block) => block.name !== CONTEXT_BLOCK);
24	return { ...below, blocks: [...others, { name: CONTEXT_BLOCK, text }] };
25}
26
27/**
28 * One file, however it is spelled: the root comes back from Claude Code in
29 * the OS's form (`C:\vault` on Windows) while the mod appends `/…`, and the
30 * engine may hand a path back normalised: separators and the drive letter's
31 * case are not part of a file's identity.
32 */
33export function samePath(a: string, b: string): boolean {
34	const norm = (p: string) => p.replaceAll("\\", "/").replace(/^([a-z]):/i, (d) => d.toLowerCase());
35	return norm(a) === norm(b);
36}
37
hooks/stop.ts 84 lines
1import type { PromptOrigin } from "claude-code";
2
3/**
4 * The Stop report as the mod receives it from `stop-checklist.ts` run with
5 * `om_mod: "report"` (obsidian-mind#264), and how it is shown (obsidian-mind#266).
6 */
7export type StopReport = {
8	/** The report's identity: the same findings give the same key. */
9	readonly key: string;
10	/** One short claim per finding, e.g. "1 note(s) marked done but still in active/". */
11	readonly claims: readonly string[];
12	/** The full report, prefaced for the agent. */
13	readonly agentText: string;
14	/**
15	 * Set only for a finding that should not wait for the person's next
16	 * message. The template's report has no such class today; a vault that adds
17	 * one (say, an agent artifact in an unpushed commit) gets an immediate turn.
18	 */
19	readonly urgent?: string;
20};
21
22/** The report in `stop-checklist.ts`'s `report` output, or an error naming what was wrong. */
23export function parseStopReport(stdout: string): StopReport {
24	const report = (JSON.parse(stdout) as { report?: Partial<StopReport> }).report;
25	if (
26		!report ||
27		typeof report.key !== "string" ||
28		!Array.isArray(report.claims) ||
29		!report.claims.every((claim) => typeof claim === "string") ||
30		typeof report.agentText !== "string" ||
31		(report.urgent !== undefined && typeof report.urgent !== "string")
32	) {
33		throw new Error(`stop-checklist.ts returned no usable report: ${stdout.slice(0, 200)}`);
34	}
35	return { key: report.key, claims: report.claims, agentText: report.agentText, ...(report.urgent !== undefined ? { urgent: report.urgent } : {}) };
36}
37
38/**
39 * The line drawn under the answer when the report changed: what drifted, in
40 * the report's own words, and where the rest went. Claude Code shows it after
41 * the mod's name: observed in obsidian-mind on 2.1.288 as `obsidian-mind: …`,
42 * and `wiki-mind: …` here on 2.1.289 (the real-session test bed asserts it).
43 */
44export function summaryLine(report: StopReport): string {
45	const what = report.claims.length > 0 ? report.claims.join(" · ") : "wrap-up checklist";
46	// The settings hook's wording (lib/stop-report.ts SUMMARY_TRAILER), except that an
47	// urgent finding sends the report at once, in a turn of its own.
48	const where = report.urgent === undefined ? "the full report reaches the agent with your next message" : "the full report goes to the agent now";
49	return `vault check: ${what} · ${where}`;
50}
51
52/**
53 * The text to return from `turn.complete`. A hook below that already set a
54 * line of its own (its text differs from the answer) keeps it; ours goes
55 * after it rather than replacing it.
56 */
57export function withLine(textBelow: string, answer: string, line: string): string {
58	return textBelow !== answer && textBelow.trim() !== "" ? `${textBelow}\n${line}` : line;
59}
60
61/**
62 * Whether a prompt from this origin should carry the queued report: the
63 * person's own prompts (typed, over Remote Control, or through the SDK) and
64 * this mod's urgent prompt. A peer's message, a notification or a schedule
65 * is not the person writing, and must not consume the report.
66 */
67/**
68 * Whether a turn that started with `turnText` ran the prompt `promptText`:
69 * the same text, or queued prompts folded into one turn with it among them as
70 * whole lines. Never a substring: a held "ok" is not run by "looks ok now".
71 */
72export function ranIn(turnText: string, promptText: string): boolean {
73	return `\n${turnText}\n`.includes(`\n${promptText}\n`);
74}
75
76export function carriesReport(origin: PromptOrigin | undefined): boolean {
77	return fromPerson(origin) || (origin?.kind === "plugin" && origin.name === "wiki-mind");
78}
79
80/** Whether the person sent this prompt: typed, over Remote Control, through the SDK, or as the session's owner pinging it from Slack. */
81export function fromPerson(origin: PromptOrigin | undefined): boolean {
82	return origin === undefined || origin.kind === "composer" || origin.kind === "bridge" || origin.kind === "sdk" || origin.kind === "slack-ping";
83}
84
types/index.d.ts 31 lines
1// The mod's per-session state: what the host keeps for it for the session,
2// across a hot reload of the module. What must outlive the process (which
3// report each session was shown) is in `$.store` instead; see register.ts.
4
5declare module "claude-code" {
6	interface PluginState {
7		"wiki-mind": {
8			/** The session context this session's instruction file carries; null when none was delivered. */
9			context: string | null;
10			/**
11			 * The Stop report waiting to be delivered: the session it is for, its full text for the next
12			 * prompt, and the line and urgent finding until the next completed
13			 * answer uses them. Null when nothing is waiting.
14			 */
15			queued: { readonly sessionId: string; readonly key: string; readonly report: string; readonly line: string | null; readonly urgent: string | null } | null;
16			/** Whether an urgent finding has had its turn since the person last spoke. */
17			urgentSpent: boolean;
18			/** Bumped by every start that begins another conversation (startup, /clear, resume, fork). */
19			generation: number;
20			/**
21			 * The report a prompt took, and the prompt's text, until a turn starts
22			 * with that prompt. Null when none is.
23			 */
24			inFlight: {
25				readonly text: string;
26				readonly record: { readonly sessionId: string; readonly key: string; readonly report: string; readonly line: string | null; readonly urgent: string | null };
27			} | null;
28		};
29	}
30}
31