memoir-cli 3.11.3 → 3.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,20 +25,29 @@ Your coding tools are starting to remember you — Claude Code, Cursor, and Copi
25
25
 
26
26
  memoir is the [MCP memory server](https://modelcontextprotocol.io) that breaks it out. **One memory, shared across every tool and synced to every machine — E2E-encrypted in the cloud, plain readable markdown on your disk.** Your AI searches, saves, and recalls context automatically, everywhere you work.
27
27
 
28
- It's built on an **open, published format** — [the memoir format, v0.1](docs/SPEC.md) — so your AI's accumulated context is never trapped in this tool either. Six entry types, normative merge semantics, JSON Schemas, and a validator (`npx memoir-cli validate`). Any tool can implement it; [critique welcome](https://github.com/camgitt/memoir/issues).
28
+ It's built on an **open, published format** — [the memoir format, v0.1.1](docs/SPEC.md) — so your AI's accumulated context is never trapped in this tool either. Six entry types, normative merge semantics, JSON Schemas, and a validator (`npx memoir-cli validate`). Any tool can implement it; [critique welcome](https://github.com/camgitt/memoir/issues).
29
29
 
30
30
  ```
31
31
  you: how does auth work in this project?
32
32
 
33
33
  memoir_recall("auth setup architecture")
34
- Found 3 memories matching "auth"
34
+ Found 3 memories matching "auth setup architecture":
35
+
36
+ ── 1. Claude CLI / memory/reference_auth_flow.md ──
37
+ reference · How authentication works — JWT + refresh, middleware location, state choice
38
+ Server components must call getUser(), never getSession(). The session
39
+ cookie name is derived from the Supabase URL host, so previews differ.
40
+
41
+ Chose Zustand over Redux for auth state (decided March 12).
35
42
 
36
43
  claude: Based on your previous sessions: this project uses JWT auth
37
44
  with refresh tokens, the middleware is in src/middleware/auth.ts,
38
45
  and you chose Zustand over Redux for auth state (decided March 12).
39
46
  ```
40
47
 
41
- No re-explaining. memoir remembered.
48
+ No re-explaining. memoir remembered — and handed back the *passage*, not a file header.
49
+
50
+ Recall ranks by how well a file covers **all** your words (aliases, names, and descriptions weigh more than prose), folds plurals/-ing/-ed, prefix-matches from 4 characters (`auth` → `authentication`), and caches parses so a long-lived session doesn't re-read your disk on every question. It does not do semantic matching — that's what `aliases:` in the frontmatter is for: the model that *saves* a memory writes down what else it might be called, and recall weights that field heaviest. Try it from the terminal: `memoir recall "what you'd ask"` shows exactly what your AI would see.
42
51
 
43
52
  ## How it's different
44
53
 
@@ -62,12 +71,12 @@ npx memoir-cli
62
71
 
63
72
  That's it. memoir detects your AI tools, configures MCP, and activates memory. No global install needed.
64
73
 
65
- Your AI gets 14 memory tools:
74
+ Your AI gets 15 memory tools:
66
75
 
67
76
  | MCP Tool | What it does |
68
77
  |----------|-------------|
69
- | `memoir_recall` | Search across all your AI memories |
70
- | `memoir_remember` | Save context for future sessions |
78
+ | `memoir_recall` | Search across all your AI memories — returns matched passages, ranked by coverage |
79
+ | `memoir_remember` | Save context for future sessions (pass `aliases` so it's findable under other names) |
71
80
  | `memoir_list` | Browse all memory files by tool |
72
81
  | `memoir_read` | Read a specific memory in full |
73
82
  | `memoir_consolidate` | Analyze memories for duplicates, staleness, and bloat |
@@ -80,6 +89,7 @@ Your AI gets 14 memory tools:
80
89
  | `memoir_ask` | Capture an open question for later |
81
90
  | `memoir_session` | Show goals, next actions, decisions, and recent sessions |
82
91
  | `memoir_why` | Look up why a past decision was made |
92
+ | `memoir_forget` | Retract a decision — permanent tombstone on every machine; `purge` redacts the text |
83
93
 
84
94
  ## Why memoir
85
95
 
@@ -146,6 +156,10 @@ memoir share # create encrypted shareable link
146
156
  | `memoir cloud restore` | Restore from memoir cloud |
147
157
  | `memoir share` | Create encrypted shareable link |
148
158
  | `memoir consolidate` | Find duplicates, stale memories, and bloat |
159
+ | `memoir recall` | Search memory exactly the way your AI does — see what it would be handed |
160
+ | `memoir why` | Look up decisions: what, why, what was rejected |
161
+ | `memoir forget` | Retract a decision (`--purge` to redact a leaked secret in place); refuses if ambiguous |
162
+ | `memoir validate` | Check session state + entry files against the format spec |
149
163
  | `memoir doctor` | Diagnose issues |
150
164
  | `memoir diff` | Show changes since last backup |
151
165
  | `memoir view` | Preview what's in your backup |
package/bin/memoir.js CHANGED
@@ -33,6 +33,8 @@ import {
33
33
  } from '../src/commands/session.js';
34
34
  import { autopushCommand } from '../src/commands/autopush.js';
35
35
  import { whyCommand } from '../src/commands/why.js';
36
+ import { forgetCommand } from '../src/commands/forget.js';
37
+ import { recallCommand } from '../src/commands/recall.js';
36
38
  import { autoRefreshCommand } from '../src/commands/auto-refresh.js';
37
39
  import { validateCommand } from '../src/commands/validate.js';
38
40
  import { hooksInstallCommand, hooksUninstallCommand, hooksStatusCommand } from '../src/commands/hooks.js';
@@ -220,6 +222,26 @@ program
220
222
  catch (err) { console.error(chalk.red('\n✖ Error:'), err.message); process.exit(1); }
221
223
  });
222
224
 
225
+ program
226
+ .command('forget <text...>')
227
+ .description('Forget a decision — hides it everywhere, permanently (substring match; refuses if ambiguous)')
228
+ .option('--purge', 'Also redact the text in place (for leaked secrets); keeps only a hash so the tombstone still syncs')
229
+ .option('-y, --yes', 'Skip the confirmation prompt')
230
+ .action(async (text, options) => {
231
+ try { await forgetCommand(text.join(' '), options); }
232
+ catch (err) { console.error(chalk.red('\n✖ Error:'), err.message); process.exit(1); }
233
+ });
234
+
235
+ program
236
+ .command('recall <query...>')
237
+ .description('Search your AI memory the way memoir_recall does — see exactly what your AI would be handed')
238
+ .option('--limit <n>', 'Max results (default 10)')
239
+ .option('--json', 'Machine-readable output')
240
+ .action(async (query, options) => {
241
+ try { await recallCommand(query.join(' '), options); }
242
+ catch (err) { console.error(chalk.red('\n✖ Error:'), err.message); process.exit(1); }
243
+ });
244
+
223
245
  program
224
246
  .command('session')
225
247
  .description('Show the current session state')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memoir-cli",
3
- "version": "3.11.3",
3
+ "version": "3.12.0",
4
4
  "mcpName": "io.github.camgitt/memoir",
5
5
  "description": "Private, portable AI memory: synced across every coding tool and machine, end-to-end encrypted, free. One memory for Claude Code, Cursor, Copilot, Gemini + more — MCP-native, zero-knowledge, open source.",
6
6
  "main": "src/index.js",
package/src/cloud/auth.js CHANGED
@@ -22,16 +22,18 @@ async function supaFetch(endpoint, options = {}) {
22
22
  return res;
23
23
  }
24
24
 
25
+ // GoTrue's REST API takes the post-email landing URL as a `redirect_to`
26
+ // QUERY PARAM. `options.emailRedirectTo` / `options.redirectTo` in the JSON
27
+ // body is supabase-js's client shape — sent raw it is silently ignored and
28
+ // the email links fall back to the project's Site URL. Both URLs below must
29
+ // also be on the Auth → URL Configuration → Redirect URLs allow-list.
30
+ const CONFIRMED_URL = 'https://memoir.sh/confirmed';
31
+ const RESET_URL = 'https://memoir.sh/reset-password';
32
+
25
33
  export async function signUp(email, password) {
26
- const res = await supaFetch('/auth/v1/signup', {
34
+ const res = await supaFetch(`/auth/v1/signup?redirect_to=${encodeURIComponent(CONFIRMED_URL)}`, {
27
35
  method: 'POST',
28
- body: JSON.stringify({
29
- email,
30
- password,
31
- options: {
32
- emailRedirectTo: 'https://memoir.sh/confirmed',
33
- },
34
- }),
36
+ body: JSON.stringify({ email, password }),
35
37
  });
36
38
  const data = await res.json();
37
39
  if (!res.ok) throw new Error(data.error_description || data.msg || 'Sign up failed');
@@ -116,14 +118,9 @@ export async function getSubscription(session) {
116
118
  }
117
119
 
118
120
  export async function resetPassword(email) {
119
- const res = await supaFetch('/auth/v1/recover', {
121
+ const res = await supaFetch(`/auth/v1/recover?redirect_to=${encodeURIComponent(RESET_URL)}`, {
120
122
  method: 'POST',
121
- body: JSON.stringify({
122
- email,
123
- options: {
124
- redirectTo: 'https://memoir.sh/reset-password',
125
- },
126
- }),
123
+ body: JSON.stringify({ email }),
127
124
  });
128
125
  if (!res.ok) {
129
126
  const data = await res.json();
@@ -1,5 +1,9 @@
1
1
  export const SUPABASE_URL = process.env.MEMOIR_SUPABASE_URL || 'https://oqrkxytbahfwjhcbyzrx.supabase.co';
2
2
  export const SUPABASE_ANON_KEY = process.env.MEMOIR_SUPABASE_ANON_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9xcmt4eXRiYWhmd2poY2J5enJ4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzMyMTQ4MzMsImV4cCI6MjA4ODc5MDgzM30.jOKOi73OJgIgi1zj0VOIQkGp0xqS3ee4gfCjpdqCnvM';
3
3
  export const STORAGE_BUCKET = 'memoir-backups';
4
- export const MAX_BACKUPS_FREE = 100;
5
- export const MAX_BACKUPS_PRO = 50;
4
+ // Cloud backup retention (cleanupOldBackups prunes beyond these, oldest first).
5
+ // Through 3.11 these were FREE=100 / PRO=50 — Pro pruned twice as aggressively
6
+ // as free on a destructive path, while the upsell copy promised Pro more.
7
+ // Free is a safety net; Pro is "full version history" (memoir.sh pricing).
8
+ export const MAX_BACKUPS_FREE = 10;
9
+ export const MAX_BACKUPS_PRO = 100;
@@ -24,9 +24,24 @@ const MEMOIR_BLOCK = `${BLOCK_START}
24
24
  <!-- Cross-session memory for AI tools — https://memoir.sh -->
25
25
  <!-- Install: npm i -g memoir-cli -->
26
26
  Use memoir_recall to search past context before answering project questions.
27
- Use memoir_remember to save important decisions, architecture choices, or context worth keeping.
27
+ Use memoir_remember to save important decisions, architecture choices, or context worth keeping — always pass aliases (other names/phrasings it might be searched under) so it stays findable.
28
+ Use memoir_note for a decision with its why; memoir_forget if a recorded decision is wrong or must be retracted.
28
29
  ${BLOCK_END}`;
29
30
 
31
+ /**
32
+ * Replace an existing (older) memoir block with the current template.
33
+ * Blocks were only ever injected once, so an install from before a template
34
+ * change kept the old instructions forever. Idempotent: identical → unchanged.
35
+ */
36
+ function upgradeBlock(content) {
37
+ const startIdx = content.indexOf(BLOCK_START);
38
+ const endIdx = content.indexOf(BLOCK_END);
39
+ if (startIdx === -1 || endIdx === -1 || endIdx < startIdx) return content;
40
+ const existing = content.slice(startIdx, endIdx + BLOCK_END.length);
41
+ if (existing === MEMOIR_BLOCK) return content;
42
+ return content.slice(0, startIdx) + MEMOIR_BLOCK + content.slice(endIdx + BLOCK_END.length);
43
+ }
44
+
30
45
  /**
31
46
  * Detect which instruction files exist in the current project.
32
47
  * Returns array of { file, tool, fullPath, exists }
@@ -54,6 +69,11 @@ async function injectBlock(filePath) {
54
69
  if (await fs.pathExists(filePath)) {
55
70
  const content = await fs.readFile(filePath, 'utf-8');
56
71
  if (hasMemoir(content)) {
72
+ const upgraded = upgradeBlock(content);
73
+ if (upgraded !== content) {
74
+ await fs.writeFile(filePath, upgraded);
75
+ return 'upgraded';
76
+ }
57
77
  return 'already';
58
78
  }
59
79
  // Append with spacing
@@ -79,7 +99,7 @@ export async function ensureRecallInstruction() {
79
99
  for (const target of Object.values(detectAvailableTargets())) {
80
100
  try {
81
101
  const res = await injectBlock(target);
82
- if (res === 'appended' || res === 'created') added++;
102
+ if (res === 'appended' || res === 'created' || res === 'upgraded') added++;
83
103
  } catch {}
84
104
  }
85
105
  return { added };
@@ -176,6 +196,9 @@ export async function activateCommand(options = {}) {
176
196
  } else if (result === 'created') {
177
197
  console.log(chalk.green(` ✔ Created ${file} with memoir instructions`) + chalk.gray(` (${tool})`));
178
198
  injected++;
199
+ } else if (result === 'upgraded') {
200
+ console.log(chalk.green(` ✔ Updated memoir instructions in ${file}`) + chalk.gray(` (${tool})`));
201
+ injected++;
179
202
  } else if (result === 'already') {
180
203
  console.log(chalk.gray(` · ${file} already has memoir`) + chalk.gray(` (${tool})`));
181
204
  }
@@ -32,7 +32,7 @@ export async function cloudPushCommand(options = {}) {
32
32
  chalk.yellow('Free plan limit reached') + '\n\n' +
33
33
  chalk.white(`You have ${existing.length}/${MAX_BACKUPS_FREE} backups.`) + '\n' +
34
34
  chalk.white('Oldest backup will be replaced.') + '\n\n' +
35
- chalk.gray('Upgrade to Pro for 50 backups + version history.'),
35
+ chalk.gray('Upgrade to Pro for 100 backups + full version history.'),
36
36
  { padding: 1, borderStyle: 'round', borderColor: 'yellow' }
37
37
  ) + '\n');
38
38
  }
@@ -0,0 +1,100 @@
1
+ // `memoir forget <text>` — retract a decision.
2
+ //
3
+ // Sets the SPEC.md 5.3.1 absolute tombstone (hidden + hidden_at). Before
4
+ // 3.12 the ONLY thing that could do this was an unshipped dev script, so a
5
+ // user who auto-captured junk — or a secret — into the pinned block had no
6
+ // way to take it back. Now they do.
7
+ //
8
+ // Two rules that make this safe:
9
+ // • Ambiguity is refused. Substring matching is convenient (same as
10
+ // `memoir done`) but hiding is permanent by spec — hidden is monotonic
11
+ // across every replica — so if more than one visible decision matches
12
+ // we list them and ask for a more specific string, never guess.
13
+ // • Interactive runs confirm before hiding. --yes skips that for scripts.
14
+ //
15
+ // --purge additionally redacts the text in place (keeps a sha256 identity
16
+ // so the tombstone still merges). Use it when the text itself must leave
17
+ // the file — a pasted key, a client name — not just leave the render.
18
+
19
+ import chalk from 'chalk';
20
+ import boxen from 'boxen';
21
+ import readline from 'readline';
22
+ import { readSession, matchDecisions, hideDecision } from '../session/state.js';
23
+ import { renderSession } from '../session/render.js';
24
+ import { injectInto, detectAvailableTargets } from '../session/inject.js';
25
+
26
+ async function refreshPinned() {
27
+ const state = await readSession();
28
+ const rendered = renderSession(state);
29
+ const targets = detectAvailableTargets();
30
+ for (const target of Object.values(targets)) {
31
+ try { await injectInto(target, rendered); } catch {}
32
+ }
33
+ }
34
+
35
+ function describe(d) {
36
+ const lines = [chalk.white.bold(` ${d.text}`)];
37
+ if (d.why) lines.push(chalk.gray(' why: ') + chalk.white(d.why));
38
+ if (d.rejected) lines.push(chalk.gray(' rejected: ') + chalk.white(d.rejected));
39
+ if (d.date) lines.push(chalk.gray(` ${String(d.date).slice(0, 10)}`));
40
+ return lines.join('\n');
41
+ }
42
+
43
+ async function confirm(question) {
44
+ if (!process.stdin.isTTY) return false;
45
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
46
+ const answer = await new Promise((resolve) => rl.question(question, resolve));
47
+ rl.close();
48
+ return /^y(es)?$/i.test(String(answer).trim());
49
+ }
50
+
51
+ export async function forgetCommand(text, options = {}) {
52
+ const query = String(text || '').trim();
53
+ if (!query) {
54
+ console.log(chalk.yellow('\nUsage: ') + chalk.cyan('memoir forget "substring of the decision" [--purge] [--yes]\n'));
55
+ return;
56
+ }
57
+
58
+ const state = await readSession();
59
+ const matches = matchDecisions(state, query);
60
+
61
+ if (matches.length === 0) {
62
+ console.log('\n' + boxen(
63
+ chalk.yellow(`No visible decision matches "${query}".`) + '\n\n' +
64
+ chalk.gray('See what is recorded with: ') + chalk.cyan('memoir why'),
65
+ { padding: 1, borderStyle: 'round', borderColor: 'yellow' }
66
+ ) + '\n');
67
+ return;
68
+ }
69
+
70
+ if (matches.length > 1) {
71
+ console.log('\n' + chalk.yellow(` "${query}" matches ${matches.length} decisions — be more specific, forgetting is permanent:`) + '\n');
72
+ for (const d of matches) console.log(describe(d) + '\n');
73
+ return;
74
+ }
75
+
76
+ const [target] = matches;
77
+ console.log('\n' + chalk.cyan.bold(options.purge ? ' About to forget AND purge:' : ' About to forget:') + '\n');
78
+ console.log(describe(target) + '\n');
79
+ console.log(chalk.gray(options.purge
80
+ ? ' The text will be redacted in session.json on this machine and, after sync, on every other machine. This cannot be undone.'
81
+ : ' It will be hidden from the pinned block, memoir why, and MCP lookups on every machine after sync. This cannot be undone.'
82
+ ) + '\n');
83
+
84
+ if (!options.yes) {
85
+ const ok = await confirm(chalk.white(' Forget it? [y/N] '));
86
+ if (!ok) {
87
+ console.log(chalk.gray('\n Left as is.\n'));
88
+ return;
89
+ }
90
+ }
91
+
92
+ const res = await hideDecision(target.text, { purge: !!options.purge });
93
+ if (!res.hidden) {
94
+ console.log(chalk.yellow('\n Nothing changed — it may have been forgotten by another process meanwhile.\n'));
95
+ return;
96
+ }
97
+ await refreshPinned();
98
+ console.log('\n' + chalk.green(res.purged ? ' ✓ Forgotten and purged.' : ' ✓ Forgotten.') +
99
+ chalk.gray(' Run ') + chalk.cyan('memoir push') + chalk.gray(' to propagate the tombstone.\n'));
100
+ }
@@ -242,7 +242,15 @@ export async function pushCommand(options = {}) {
242
242
  for (const d of qualityDecisions.slice(0, 10)) {
243
243
  const text = decisionText(d);
244
244
  if (existingTexts.has(text.toLowerCase())) continue;
245
- await addNote(text, { why: d.context ? `auto-captured: ${d.context.slice(0, 80)}` : undefined });
245
+ // A `why` that merely restates the text is not a rationale —
246
+ // for rename/tech captures decisionText() IS d.context, so the
247
+ // old line produced `why: "auto-captured: switch to Sonnet"`
248
+ // under text "switch to Sonnet". Content-free, and it made
249
+ // auto-captures indistinguishable from real reasoning in the
250
+ // pinned block. Emit no why rather than a fake one.
251
+ const ctx = String(d.context || '').trim();
252
+ const restates = !ctx || ctx.toLowerCase() === text.trim().toLowerCase();
253
+ await addNote(text, { why: restates ? undefined : `auto-captured: ${ctx.slice(0, 80)}` });
246
254
  }
247
255
  // Record a session summary in history for "recent sessions" section
248
256
  const filesList = Array.from(parsed.filesWritten || []).slice(0, 10);
@@ -0,0 +1,42 @@
1
+ // `memoir recall <query>` — the same search the memoir_recall MCP tool runs,
2
+ // from the terminal. Exists so a human can see exactly what their AI would
3
+ // be handed for a question ("what would it see if it asked about X?"),
4
+ // which is the fastest way to notice a memory that was never written, or
5
+ // one that needs an `aliases:` line to be findable.
6
+
7
+ import chalk from 'chalk';
8
+ import { searchMemories } from '../memory/search.js';
9
+
10
+ export async function recallCommand(query, options = {}) {
11
+ const q = String(query || '').trim();
12
+ if (!q) {
13
+ console.log(chalk.yellow('\nUsage: ') + chalk.cyan('memoir recall "what you want to find" [--limit N] [--json]\n'));
14
+ return;
15
+ }
16
+ const limit = Math.max(1, Math.min(50, parseInt(options.limit, 10) || 10));
17
+ const t0 = Date.now();
18
+ const res = await searchMemories(q, { limit });
19
+ const ms = Date.now() - t0;
20
+
21
+ if (options.json) {
22
+ console.log(JSON.stringify({ query: q, terms: res.terms, total: res.total, results: res.results }, null, 2));
23
+ return;
24
+ }
25
+
26
+ if (!res.results.length) {
27
+ console.log('\n' + chalk.yellow(` No memories match "${q}"`) +
28
+ (res.terms.length ? chalk.gray(` (searched: ${res.terms.join(', ')})`) : '') + '\n');
29
+ return;
30
+ }
31
+
32
+ console.log('\n' + chalk.cyan.bold(` ${res.total} match${res.total === 1 ? '' : 'es'} for "${q}"`) +
33
+ chalk.gray(` · top ${res.results.length} · ${ms}ms · terms: ${res.terms.join(', ')}`) + '\n');
34
+ res.results.forEach((r, i) => {
35
+ const cov = r.matched < res.terms.length ? chalk.gray(` · ${r.matched}/${res.terms.length} terms`) : '';
36
+ console.log(chalk.green(` ${i + 1}. `) + chalk.white.bold(`${r.tool} / ${r.path}`) + cov);
37
+ const meta = [r.type, r.description].filter(Boolean).join(' · ');
38
+ if (meta) console.log(chalk.gray(` ${meta}`));
39
+ for (const line of r.passage.split('\n')) console.log(chalk.white(' ' + line));
40
+ console.log('');
41
+ });
42
+ }
@@ -63,7 +63,7 @@ export async function upgradeCommand() {
63
63
  const sep = chalk.gray('─'.repeat(col1 + col2 + 18));
64
64
 
65
65
  const rows = [
66
- [chalk.gray('100 cloud backups'), chalk.white('Unlimited backups'), chalk.white('Unlimited backups')],
66
+ [chalk.gray('10 cloud backups'), chalk.white('100 backups'), chalk.white('100 backups')],
67
67
  [chalk.gray('Local only'), chalk.white('Unlimited machines'), chalk.white('Shared team context')],
68
68
  [chalk.gray('Manual snapshots'), chalk.white('Auto snapshots'), chalk.white('Team dashboard')],
69
69
  [chalk.gray('Community support'), chalk.white('Priority support'), chalk.white('Audit log')],
@@ -119,7 +119,7 @@ export async function upgradeCommand() {
119
119
  );
120
120
  } catch (err) {
121
121
  spinner.fail(chalk.red(' ' + err.message));
122
- console.log(chalk.gray('\n Fallback: visit ') + chalk.cyan('https://memoir.sh/pricing') + '\n');
122
+ console.log(chalk.gray('\n Fallback: visit ') + chalk.cyan('https://memoir.sh/#pricing') + '\n');
123
123
  }
124
124
  } else if (!session) {
125
125
  console.log('\n' + chalk.gray(' Run ') + chalk.cyan('memoir login') + chalk.gray(' to create an account, then ') + chalk.cyan('memoir upgrade') + chalk.gray(' to subscribe.') + '\n');
@@ -277,6 +277,19 @@ export function validateSessionObject(obj) {
277
277
  if (d && d.hidden === true && !isIsoDateString(d.hidden_at)) {
278
278
  errors.push(`current.decisions[${i}] hidden: true without a valid hidden_at (SPEC.md 5.3.1)`);
279
279
  }
280
+ // Purged form: text_hash only makes sense on a hidden tombstone whose
281
+ // text is the [purged] literal; a hash on a live decision is not an identity.
282
+ if (d && d.text_hash != null) {
283
+ if (!/^[0-9a-f]{64}$/.test(String(d.text_hash))) {
284
+ errors.push(`current.decisions[${i}] text_hash must be lowercase hex SHA-256 (SPEC.md 5.3.1)`);
285
+ }
286
+ if (d.hidden !== true) {
287
+ errors.push(`current.decisions[${i}] text_hash without hidden: true — purge implies hide (SPEC.md 5.3.1)`);
288
+ }
289
+ if (d.text !== '[purged]') {
290
+ warnings.push(`current.decisions[${i}] carries text_hash but text is not "[purged]" — the redacted text may still be present`);
291
+ }
292
+ }
280
293
  });
281
294
  // completed_actions is optional (absent = empty), but when present its
282
295
  // tombstones must be well-formed or the temporal merge rule breaks.
@@ -19,8 +19,16 @@ export function findClaudeSessions() {
19
19
  for (const entry of entries) {
20
20
  const full = path.join(dir, entry.name);
21
21
  if (entry.isDirectory()) {
22
+ // Skip Claude Code's per-session side directories. `subagents/`
23
+ // holds agent-*.jsonl transcripts whose FIRST user message is the
24
+ // orchestrator's prompt ("You are a software architect. Note that
25
+ // ...") — the filename check below never caught them (the file is
26
+ // agent-<id>.jsonl, not *subagent*), so USER_NOTE_RE minted
27
+ // decisions out of system prompts. Live proof: three of the
28
+ // author's own pinned decisions were subagent-prompt fragments.
29
+ if (entry.name === 'subagents' || entry.name === 'workflows' || entry.name === 'tool-results') continue;
22
30
  scanDir(full);
23
- } else if (entry.name.endsWith('.jsonl') && !entry.name.includes('subagent')) {
31
+ } else if (entry.name.endsWith('.jsonl') && !entry.name.includes('subagent') && !entry.name.startsWith('agent-')) {
24
32
  try {
25
33
  const stat = fs.statSync(full);
26
34
  // Skip files older than 7 days for performance