SLOPSHOPPER

qsm-knowledge

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…

newprocess
A shopper browsing a rack in a slop shop
README

qsm-knowledge

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.

Why it exists

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.

What you get

KindNameWhat it does
Skillsource-notebookDecides whether a topic earns a notebook, adds sources, asks it cited questions, keeps it current.
Skillgrounded-researchRuns a deep topic in three passes against the notebook, then closes gaps with outside research.
Skillvault-noteWrites one note with the frontmatter the vault needs, links it and validates it.
Agentsynthesis-passTurns a closed research stage's findings into note-ready drafts. Never fetches a source.
CLIqsm-knowledge2 verbs: validate and doctor, see USAGE.md.

Install

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.

Use

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.

Caveats

  • 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.
  • The vault path is asked every time and stored nowhere. Validation checks the note, not where it lives.
  • A failed query is never an answer: retry or sign in again, do not fill the gap from the model.
Source 3 files
hooks/register.tsx 10 lines
1// 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}
10
hooks/noun.generated.ts 126 lines
1// 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}
126
types/index.d.ts 16 lines
1// 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