The per-machine archive of Claude Code sessions: a SessionEnd hook saves each main session a person typed in, as text, into one SQLite database, full-text…

A per-machine archive of your Claude Code sessions that the agent and you can search, with short notes that carry work to the next session.
Claude Code's own memory keeps a model-written summary of what mattered and throws the sessions themselves away within weeks, so the only true record of what was tried, what failed and why a decision went the way it did is gone, and the summary drifts from the session it came from. qsm-memory replaces that default: the sessions stay on the machine they happened on, stripped to what was said and searchable in one place, because the transcript is the memory and anything distilled from it is a copy. It takes the stance that no agent writes long-term memory: a rule belongs in the instruction file that loads it, unfinished work in the task tool, and the one thing worth writing by hand is a short note for the next session that expires. It refuses to be a knowledge base, a shared store, or a place where facts are graded and promoted. When the harness keeps every transcript for good and searches them itself, this plugin has no reason to exist.
| Kind | Name | What it does |
|---|---|---|
| Skill | memory | Search the archive before asking you again, act on the hit, write one note at a handoff. |
| Agent | recall | Answers one question about earlier sessions, every claim cited as <session-id> #<turn> <date>. |
| Hook | session.start, session.end | Save main sessions the archive lacks, in a background process. |
| Hook | prompt.submit, session.compact | Give the first prompt of a session, the first after a compaction, and the first after /clear, a briefing of at most 1500 characters. |
| MCP | qsm-memory | search, show, list, notes and note as tools. |
| CLI | qsm-memory | 9 verbs, see USAGE.md. |
claude plugin install qsm-memory@qsm-marketplace
bun plugins/qsm-memory/scripts/install.ts
Install compiles qsm-memory and links it into $QSM_BIN_DIR (default ~/.local/bin). The hooks call it from there. Brings qsm-tasks with it: the briefing lists the work in progress from the task tool. Details are in INSTALL.md.
Ask the agent: "did we already try the semgrep pre-push hook?" The skill searches the archive first.
qsm-memory list --since 7d
qsm-memory search "semgrep pre-push"
qsm-memory show 2009f5dd # the saved text
qsm-memory show 2009f5dd --full # the transcript file: tool calls and results too
qsm-memory note "qsm-abc: gate red on typecheck, fix first"
qsm-memory orient # the session-start briefing
Every verb takes --json. A note expires in 14 days unless you pass --keep.
~/.local/share/qsm-memory/memory.db (override with QSM_MEMORY_DB), owner-only. It holds what you typed: never sync it.qsm-setup activate qsm-memory -- --memory qsm sets autoMemoryEnabled: false and cleanupPeriodDays: 7 in ~/.claude/settings.json. With 7 days, --resume stops working for older sessions, and show --full fails as missing once Claude Code deletes the transcript. show still prints the saved text.save.log beside the archive, each line with its date and time.qsm-memory list --unsaved shows the rest. list prints at most 20 lines unless you pass --limit.PATH fails the briefing as missing.hooks/register.tsx 85 lines1// qsm-memory's function-hooks module: the noun, and the native events that call its verbs. Every
2// rule is the verb's (lib/); the saves detach inside the verb, so the session-end chain's 1.5 s
3// covers only starting one.
4import type { Caught, EngineInterface, On } from "claude-code";
5import { registerNoun } from "./noun.generated.ts";
6
7/** Whether the next prompt is owed the briefing: a session start, a /clear and a compaction set
8 * it, the first prompt after clears it. The engine has no hidden-context event at those moments. */
9let briefingOwed = false;
10
11/** The `text` of the orient verb's data; anything else is a failure. */
12function briefingText(data: unknown): string {
13 if (
14 typeof data === "object" &&
15 data !== null &&
16 "text" in data &&
17 typeof data.text === "string"
18 ) {
19 return data.text;
20 }
21 throw new Error("qsm-memory orient answered no text");
22}
23
24/** Why a hook failed, in words: the thrown message (it names the verb, its exit and its stderr or
25 * outcome code), or for a timeout the budget it outran, which the engine gives no message for. */
26function causeOf(error: Caught["error"]): string {
27 return error.message ?? `${error.kind}: ran past its ${error.budget} ms budget`;
28}
29
30/** What every `.catch` here does before the event goes on: a toast and a log for the person. The
31 * toast keeps the cause up to 240 characters, the log all of it. */
32function failed($: EngineInterface, event: string, error: Caught["error"]): void {
33 const cause = causeOf(error);
34 $.ui.toast(`qsm-memory ERROR: ${event}: ${cause.slice(0, 240)}`);
35 $.ui.log(`${event} failed open (${error.kind}): ${cause}`);
36}
37
38/** Saves this session and any an earlier end missed, in a detached child. */
39function saveCatchUp($: EngineInterface) {
40 return $.memory.save({ catchUp: true, detach: true });
41}
42
43export function register(on: On): void {
44 // Adds $.memory: one method per qsm-memory verb, generated by
45 // lib/generate-noun.ts. Never hand-edit noun.generated.ts or types/index.d.ts.
46 registerNoun(on);
47
48 on("session.start", async ($, e, next) => {
49 briefingOwed = true;
50 await saveCatchUp($);
51 return next(e);
52 }).catch(($, e, next) => {
53 failed($, "session.start", next.error);
54 return next(e);
55 });
56
57 on("session.end", async ($, e, next) => {
58 briefingOwed = e.reason === "clear";
59 await saveCatchUp($);
60 return next(e);
61 }).catch(($, e, next) => {
62 failed($, "session.end", next.error);
63 return next(e);
64 });
65
66 on("session.compact", async (_$, e, next) => {
67 const r = await next(e);
68 // A precompute installs nothing, and a skip leaves the conversation as it was.
69 briefingOwed ||= e.trigger !== "precompute" && r.skip === undefined;
70 return r;
71 });
72
73 on("prompt.submit", async ($, e, next) => {
74 if (!briefingOwed) {
75 return next(e);
76 }
77 briefingOwed = false;
78 const briefing = briefingText(await $.memory.orient({ cwd: await $.session.cwd() }));
79 return next({ ...e, context: [...(e.context ?? []), briefing] });
80 }).catch(($, e, next) => {
81 failed($, "prompt.submit", next.error);
82 return next(e);
83 });
84}
85hooks/noun.generated.ts 134 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 { Memory } 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 save: { words: ["qsm-memory","save"], args: [{ name: "transcript", flag: null }, { name: "catchUp", flag: "--catch-up", boolean: true }, { name: "cwd", flag: "--cwd" }, { name: "detach", flag: "--detach", boolean: true }, { name: "log", flag: "--log", boolean: true }] },
22 search: { words: ["qsm-memory","search"], args: [{ name: "query", flag: null }, { name: "limit", flag: "--limit" }, { name: "since", flag: "--since" }, { name: "project", flag: "--project" }] },
23 show: { words: ["qsm-memory","show"], args: [{ name: "session", flag: null }, { name: "full", flag: "--full", boolean: true }, { name: "lines", flag: "--lines" }] },
24 list: { words: ["qsm-memory","list"], args: [{ name: "since", flag: "--since" }, { name: "project", flag: "--project" }, { name: "unsaved", flag: "--unsaved", boolean: true }, { name: "limit", flag: "--limit" }] },
25 blobPut: { words: ["qsm-memory","blob","put"], args: [{ name: "file", flag: null }] },
26 blobPath: { words: ["qsm-memory","blob","path"], args: [{ name: "sha256", flag: null }] },
27 note: { words: ["qsm-memory","note"], args: [{ name: "text", flag: null }, { name: "expires", flag: "--expires" }, { name: "keep", flag: "--keep", boolean: true }, { name: "global", flag: "--global", boolean: true }, { name: "drop", flag: "--drop" }] },
28 notes: { words: ["qsm-memory","notes"], args: [{ name: "all", flag: "--all", boolean: true }] },
29 orient: { words: ["qsm-memory","orient"], args: [{ name: "cwd", flag: "--cwd" }] },
30 doctor: { words: ["qsm-memory","doctor"], args: [] },
31};
32
33/** One verb's argv: its words, each option in declaration order, the
34 * passthrough rest after `--`, and `--json` last. */
35export function argvOf(row: VerbRow, options: Readonly<Record<string, unknown>> = {}): string[] {
36 const argv = [...row.words];
37 for (const arg of row.args) {
38 const value = options[arg.name];
39 const values = Array.isArray(value) ? value : [value];
40 for (const one of values) {
41 if (one === undefined || one === false) {
42 continue;
43 }
44 argv.push(...(arg.flag === null ? [] : [arg.flag]), ...(arg.boolean ? [] : [String(one)]));
45 }
46 }
47 const rest = options.rest;
48 if (row.passthrough && Array.isArray(rest)) {
49 argv.push("--", ...rest.map(String));
50 }
51 argv.push("--json");
52 return argv;
53}
54
55/** The binary prints one outcome envelope on stdout under --json:
56 * { code, exit, reason, message, warnings, data }. Data on ok; otherwise the
57 * code and message become the error. A stale or misbehaving binary that
58 * prints something else (not JSON, or JSON with no numeric `exit`) throws
59 * loud here instead of a raw JSON.parse SyntaxError or a silent `undefined`. */
60export function unwrap(
61 argv: readonly string[],
62 result: { readonly exitCode: number; readonly stdout: string; readonly stderr: string },
63 label = argv.slice(0, 2).join(" "),
64): unknown {
65 const text = result.stdout.trim();
66 const stderr = result.stderr.trim().slice(0, 200);
67 const cause = `(exit ${result.exitCode}): ${stderr.length > 0 ? stderr : "no stderr"}`;
68 if (text.length === 0) {
69 throw new Error(`${argv.join(" ")}: printed no envelope ${cause}`);
70 }
71 let parsed: unknown;
72 try {
73 parsed = JSON.parse(text);
74 } catch {
75 parsed = undefined;
76 }
77 if (typeof parsed !== "object" || parsed === null || typeof (parsed as { exit?: unknown }).exit !== "number") {
78 throw new Error(`${argv.join(" ")}: stdout is not an outcome envelope ${cause}: ${text.slice(0, 200)}`);
79 }
80 const envelope = parsed as { code: string; exit: number; message: string; data: unknown };
81 if (envelope.exit !== 0) {
82 throw new Error(`${label}: ${envelope.code}: ${envelope.message}`);
83 }
84 return envelope.data;
85}
86
87/** What every `.catch` does first: a toast and a log for the person. The toast says `fixedReason`
88 * when the cause may carry something the toast must not show, else the cause; the cause goes to
89 * the log and comes back for the caller's own policy. */
90export function failure(
91 $: EngineInterface,
92 event: string,
93 error: Caught["error"],
94 fixedReason?: string,
95): string {
96 const said = error.message ?? error.kind;
97 $.ui.toast(`qsm-memory ERROR: ${event}: ${(fixedReason ?? said).slice(0, 120)}`);
98 $.ui.log(`${event} failed (${error.kind}): ${said}`);
99 return said;
100}
101
102/** Adds $.memory to the engine. Call once from the plugin's own
103 * `register(on, options)` — never call the engine.create hook's own
104 * `next(e)` result from anywhere but here. */
105export function registerNoun(on: On): void {
106 on("engine.create", async (_$, e, next) => {
107 const beneath = await next(e);
108 const run = async (row: VerbRow, options?: Readonly<Record<string, unknown>>): Promise<unknown> => {
109 const argv = argvOf(row, options);
110 let result: Awaited<ReturnType<typeof beneath.process.run>>;
111 try {
112 result = await beneath.process.run(argv);
113 } catch (error) {
114 // A spawn in a deleted directory fails as ENOENT naming the binary; name the directory. A
115 // check that cannot run leaves the spawn's own error as the cause.
116 const gone = await beneath.session
117 .cwd()
118 .then(async (cwd) => ((await beneath.fs.exists(cwd)) ? null : cwd))
119 .catch(() => null);
120 if (gone !== null) {
121 throw new Error(`${row.words.join(" ")}: the session's working directory ${gone} no longer exists; cd to one that does`);
122 }
123 throw error;
124 }
125 return unwrap(argv, result, row.words.join(" "));
126 };
127 const methods = Object.entries(verbs).map(([method, row]) => [
128 method,
129 (options?: Readonly<Record<string, unknown>>) => run(row, options),
130 ]);
131 return { ...beneath, memory: Object.fromEntries(methods) as Memory };
132 });
133}
134types/index.d.ts 56 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 Memory = {
5 save(options: {
6 transcript?: string;
7 catchUp?: boolean;
8 cwd?: string;
9 detach?: boolean;
10 log?: boolean;
11 }): Promise<unknown>;
12 search(options: {
13 query: string;
14 limit?: number;
15 since?: string;
16 project?: string;
17 }): Promise<unknown>;
18 show(options: {
19 session: string;
20 full?: boolean;
21 lines?: string;
22 }): Promise<unknown>;
23 list(options: {
24 since?: string;
25 project?: string;
26 unsaved?: boolean;
27 limit?: number;
28 }): Promise<unknown>;
29 blobPut(options: {
30 file: string;
31 }): Promise<unknown>;
32 blobPath(options: {
33 sha256: string;
34 }): Promise<unknown>;
35 note(options: {
36 text?: string;
37 expires?: number;
38 keep?: boolean;
39 global?: boolean;
40 drop?: string;
41 }): Promise<unknown>;
42 notes(options: {
43 all?: boolean;
44 }): Promise<unknown>;
45 orient(options: {
46 cwd?: string;
47 }): Promise<unknown>;
48 doctor(): Promise<unknown>;
49};
50
51declare module 'claude-code' {
52 interface EngineInterface {
53 memory: Memory;
54 }
55}
56