The grounded knowledge base: a notebook that answers only from the sources put into it, a vault that holds the notes distilled from those answers, and one…

Grounded knowledge work for one person: a NotebookLM notebook holds your sources and answers with citations, and a markdown vault holds the notes you write from those answers.
Knowledge gathered in one session is lost by the next, because the conversation's memory ends with the session and a web search returns pages nobody can reopen as a set. qsm-knowledge puts it where it can be asked and cited: NotebookLM holds the sources and answers with citations, the Obsidian vault holds the notes written from those answers. It takes the stance that a note is worth writing only when it is grounded in a source someone can open. It refuses to be a scratchpad, a bookmark pile, or a second memory for the agent. When a session can be grounded in our sources without either tool, this plugin has no reason to exist.
| Kind | Name | What it does |
|---|---|---|
| Skill | source-notebook | Decides whether a topic earns a notebook, adds sources, asks it cited questions, keeps it current. |
| Skill | grounded-research | Runs a deep topic in three passes against the notebook, then closes gaps with outside research. |
| Skill | vault-note | Writes one note with the frontmatter the vault needs, links it and validates it. |
| Agent | synthesis-pass | Turns a closed research stage's findings into note-ready drafts. Never fetches a source. |
| CLI | qsm-knowledge | 2 verbs: validate and doctor, see USAGE.md. |
claude plugin install qsm-knowledge@qsm-marketplace
bun plugins/qsm-knowledge/scripts/install.ts
nlm auth
qsm-knowledge doctor
Install compiles qsm-knowledge into $QSM_BIN_DIR (default ~/.local/bin) and builds the notebook binary nlm with go install when it is missing (needs Go). Brings qsm-essentials with it: the research flow dispatches the web-research worker for a gap the notebook cannot close. See INSTALL.md.
Ask the agent: "make a notebook for these three papers and ask what they say about retries", or "write this to my vault as a TIL".
qsm-knowledge validate ~/vault/concepts/strangler-fig.md
qsm-knowledge doctor
validate fails (exit 1) on a missing title, created, type or tags, an unknown type, a date not YYYY-MM-DD, or tags not lowercase-hyphenated. A note without a [[wikilink]] is only a warning.
nlm is unofficial. It scrapes the web app with your session (<https://github.com/tmc/nlm>) and breaks when the app changes or the session expires: run nlm refresh, then nlm auth.hooks/register.tsx 10 lines1// qsm-knowledge's function-hooks module.
2import type { On } from "claude-code";
3import { registerNoun } from "./noun.generated.ts";
4
5export function register(on: On): void {
6 // Adds $.knowledge: one method per qsm-knowledge verb, generated by
7 // lib/generate-noun.ts. Never hand-edit noun.generated.ts or types/index.d.ts.
8 registerNoun(on);
9}
10hooks/noun.generated.ts 126 lines1// Generated by lib/generate-noun.ts from qsm.json. Do not hand-edit; run
2// `bun lib/generate-noun.ts plugins/<name>` again after changing qsm.json.
3import type { Caught, EngineInterface, On } from "claude-code";
4import type { Knowledge } from "../types/index.d.ts";
5
6/** How one option becomes argv: a positional value (`flag` null), a bare
7 * boolean flag, or a flag and its value; a list repeats it per element. */
8export interface VerbArg {
9 readonly name: string;
10 readonly flag: string | null;
11 readonly boolean?: true;
12}
13
14export interface VerbRow {
15 readonly words: readonly string[];
16 readonly args: readonly VerbArg[];
17 readonly passthrough?: true;
18}
19
20export const verbs: Readonly<Record<string, VerbRow>> = {
21 validate: { words: ["qsm-knowledge","validate"], args: [{ name: "note", flag: null }] },
22 doctor: { words: ["qsm-knowledge","doctor"], args: [] },
23};
24
25/** One verb's argv: its words, each option in declaration order, the
26 * passthrough rest after `--`, and `--json` last. */
27export function argvOf(row: VerbRow, options: Readonly<Record<string, unknown>> = {}): string[] {
28 const argv = [...row.words];
29 for (const arg of row.args) {
30 const value = options[arg.name];
31 const values = Array.isArray(value) ? value : [value];
32 for (const one of values) {
33 if (one === undefined || one === false) {
34 continue;
35 }
36 argv.push(...(arg.flag === null ? [] : [arg.flag]), ...(arg.boolean ? [] : [String(one)]));
37 }
38 }
39 const rest = options.rest;
40 if (row.passthrough && Array.isArray(rest)) {
41 argv.push("--", ...rest.map(String));
42 }
43 argv.push("--json");
44 return argv;
45}
46
47/** The binary prints one outcome envelope on stdout under --json:
48 * { code, exit, reason, message, warnings, data }. Data on ok; otherwise the
49 * code and message become the error. A stale or misbehaving binary that
50 * prints something else (not JSON, or JSON with no numeric `exit`) throws
51 * loud here instead of a raw JSON.parse SyntaxError or a silent `undefined`. */
52export function unwrap(
53 argv: readonly string[],
54 result: { readonly exitCode: number; readonly stdout: string; readonly stderr: string },
55 label = argv.slice(0, 2).join(" "),
56): unknown {
57 const text = result.stdout.trim();
58 const stderr = result.stderr.trim().slice(0, 200);
59 const cause = `(exit ${result.exitCode}): ${stderr.length > 0 ? stderr : "no stderr"}`;
60 if (text.length === 0) {
61 throw new Error(`${argv.join(" ")}: printed no envelope ${cause}`);
62 }
63 let parsed: unknown;
64 try {
65 parsed = JSON.parse(text);
66 } catch {
67 parsed = undefined;
68 }
69 if (typeof parsed !== "object" || parsed === null || typeof (parsed as { exit?: unknown }).exit !== "number") {
70 throw new Error(`${argv.join(" ")}: stdout is not an outcome envelope ${cause}: ${text.slice(0, 200)}`);
71 }
72 const envelope = parsed as { code: string; exit: number; message: string; data: unknown };
73 if (envelope.exit !== 0) {
74 throw new Error(`${label}: ${envelope.code}: ${envelope.message}`);
75 }
76 return envelope.data;
77}
78
79/** What every `.catch` does first: a toast and a log for the person. The toast says `fixedReason`
80 * when the cause may carry something the toast must not show, else the cause; the cause goes to
81 * the log and comes back for the caller's own policy. */
82export function failure(
83 $: EngineInterface,
84 event: string,
85 error: Caught["error"],
86 fixedReason?: string,
87): string {
88 const said = error.message ?? error.kind;
89 $.ui.toast(`qsm-knowledge ERROR: ${event}: ${(fixedReason ?? said).slice(0, 120)}`);
90 $.ui.log(`${event} failed (${error.kind}): ${said}`);
91 return said;
92}
93
94/** Adds $.knowledge to the engine. Call once from the plugin's own
95 * `register(on, options)` — never call the engine.create hook's own
96 * `next(e)` result from anywhere but here. */
97export function registerNoun(on: On): void {
98 on("engine.create", async (_$, e, next) => {
99 const beneath = await next(e);
100 const run = async (row: VerbRow, options?: Readonly<Record<string, unknown>>): Promise<unknown> => {
101 const argv = argvOf(row, options);
102 let result: Awaited<ReturnType<typeof beneath.process.run>>;
103 try {
104 result = await beneath.process.run(argv);
105 } catch (error) {
106 // A spawn in a deleted directory fails as ENOENT naming the binary; name the directory. A
107 // check that cannot run leaves the spawn's own error as the cause.
108 const gone = await beneath.session
109 .cwd()
110 .then(async (cwd) => ((await beneath.fs.exists(cwd)) ? null : cwd))
111 .catch(() => null);
112 if (gone !== null) {
113 throw new Error(`${row.words.join(" ")}: the session's working directory ${gone} no longer exists; cd to one that does`);
114 }
115 throw error;
116 }
117 return unwrap(argv, result, row.words.join(" "));
118 };
119 const methods = Object.entries(verbs).map(([method, row]) => [
120 method,
121 (options?: Readonly<Record<string, unknown>>) => run(row, options),
122 ]);
123 return { ...beneath, knowledge: Object.fromEntries(methods) as Knowledge };
124 });
125}
126types/index.d.ts 16 lines1// Generated by lib/generate-noun.ts from qsm.json. Do not hand-edit; run
2// `bun lib/generate-noun.ts plugins/<name>` again after changing qsm.json.
3
4export type Knowledge = {
5 validate(options: {
6 note: string;
7 }): Promise<unknown>;
8 doctor(): Promise<unknown>;
9};
10
11declare module 'claude-code' {
12 interface EngineInterface {
13 knowledge: Knowledge;
14 }
15}
16