memoir-cli 3.11.2 → 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 +20 -6
- package/bin/memoir.js +22 -0
- package/package.json +1 -1
- package/src/cloud/auth.js +12 -15
- package/src/cloud/constants.js +6 -2
- package/src/commands/activate.js +25 -2
- package/src/commands/cloud.js +1 -1
- package/src/commands/forget.js +100 -0
- package/src/commands/push.js +31 -12
- package/src/commands/recall.js +42 -0
- package/src/commands/restore.js +12 -4
- package/src/commands/session.js +8 -3
- package/src/commands/upgrade.js +2 -2
- package/src/commands/validate.js +13 -0
- package/src/context/capture.js +14 -2
- package/src/mcp.js +54 -139
- package/src/memory/search.js +503 -0
- package/src/providers/index.js +21 -0
- package/src/security/scanner.js +12 -4
- package/src/session/lock.js +36 -2
- package/src/session/state.js +122 -11
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
|
|
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.
|
|
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(
|
|
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(
|
|
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();
|
package/src/cloud/constants.js
CHANGED
|
@@ -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
|
-
|
|
5
|
-
|
|
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;
|
package/src/commands/activate.js
CHANGED
|
@@ -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
|
}
|
package/src/commands/cloud.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/src/commands/push.js
CHANGED
|
@@ -211,7 +211,17 @@ export async function pushCommand(options = {}) {
|
|
|
211
211
|
// received the raw unfiltered list while only the session.json sink
|
|
212
212
|
// below filtered, so junk could reach session-decisions.md even after
|
|
213
213
|
// being rejected from session.json. Both sinks now agree on what's junk.
|
|
214
|
-
|
|
214
|
+
// Gate on the string each sink actually PERSISTS, not on d.value.
|
|
215
|
+
// For rename/tech captures d.value is a single whitespace-free
|
|
216
|
+
// token, so isQuality's words>=3 rule rejected 100% of them —
|
|
217
|
+
// two of the three advertised capture categories were dead code
|
|
218
|
+
// while persistDecisions would have written the clean d.context.
|
|
219
|
+
const decisionText = (d) => {
|
|
220
|
+
const v = String(d.value || '').trim();
|
|
221
|
+
const c = String(d.context || '').trim();
|
|
222
|
+
return (d.type === 'rename' || d.type === 'tech') && c ? c : v;
|
|
223
|
+
};
|
|
224
|
+
const qualityDecisions = parsed.decisions.filter(d => isQuality(decisionText(d)));
|
|
215
225
|
|
|
216
226
|
// Persist decisions to Claude's memory so they survive across sessions
|
|
217
227
|
let decisionCount = 0;
|
|
@@ -230,9 +240,17 @@ export async function pushCommand(options = {}) {
|
|
|
230
240
|
current.current.decisions.map(d => (d.text || '').trim().toLowerCase())
|
|
231
241
|
);
|
|
232
242
|
for (const d of qualityDecisions.slice(0, 10)) {
|
|
233
|
-
const text =
|
|
243
|
+
const text = decisionText(d);
|
|
234
244
|
if (existingTexts.has(text.toLowerCase())) continue;
|
|
235
|
-
|
|
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)}` });
|
|
236
254
|
}
|
|
237
255
|
// Record a session summary in history for "recent sessions" section
|
|
238
256
|
const filesList = Array.from(parsed.filesWritten || []).slice(0, 10);
|
|
@@ -321,15 +339,16 @@ export async function pushCommand(options = {}) {
|
|
|
321
339
|
preserveRemoteSession = true;
|
|
322
340
|
try { appendEvent('sync_degraded', { reason: 'remote_session_unreadable' }); } catch {}
|
|
323
341
|
} else {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
342
|
+
// Read AND merge AND write inside one lock. Reading outside it and
|
|
343
|
+
// locking only the write is a check-then-act: a concurrent MCP
|
|
344
|
+
// memoir_note in that window is silently dropped. This is the most
|
|
345
|
+
// reachable instance of that bug — it sits on the autopush path.
|
|
346
|
+
let merged;
|
|
347
|
+
await withSessionLock(sessionPaths.sessionLock, async () => {
|
|
348
|
+
const local = await readSession();
|
|
349
|
+
merged = remote ? mergeSessions(local, remote) : local;
|
|
350
|
+
if (remote) await writeSession(merged);
|
|
351
|
+
});
|
|
333
352
|
await fs.writeFile(path.join(stagingDir, 'session.json'), JSON.stringify(merged, null, 2));
|
|
334
353
|
sessionIncluded = true;
|
|
335
354
|
}
|
|
@@ -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
|
+
}
|
package/src/commands/restore.js
CHANGED
|
@@ -15,6 +15,7 @@ import { getSession } from '../cloud/auth.js';
|
|
|
15
15
|
import { unbundleToDir } from '../cloud/storage.js';
|
|
16
16
|
import { SUPABASE_URL, SUPABASE_ANON_KEY, STORAGE_BUCKET } from '../cloud/constants.js';
|
|
17
17
|
import { readSession, writeSession, mergeSessions, paths as sessionPaths } from '../session/state.js';
|
|
18
|
+
import { withSessionLock } from '../session/lock.js';
|
|
18
19
|
import { migrateSessionData } from '../session/migrations.js';
|
|
19
20
|
import { renderSession } from '../session/render.js';
|
|
20
21
|
import { injectInto, detectAvailableTargets } from '../session/inject.js';
|
|
@@ -136,10 +137,17 @@ export async function restoreCommand(options = {}) {
|
|
|
136
137
|
// ever touches it. Symmetric with the push-side fix in push.js.
|
|
137
138
|
const rawRemote = JSON.parse(await fs.readFile(remoteSessionPath, 'utf8'));
|
|
138
139
|
const { state: remote } = migrateSessionData(rawRemote);
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
140
|
+
// Read+merge+write inside ONE lock, like every state.js mutator.
|
|
141
|
+
// Reading outside the lock and locking only the write is a
|
|
142
|
+
// check-then-act: a concurrent MCP memoir_note landing in the window
|
|
143
|
+
// is silently discarded by our merge of the stale copy.
|
|
144
|
+
let merged, beforeMachines;
|
|
145
|
+
await withSessionLock(sessionPaths.sessionLock, async () => {
|
|
146
|
+
const local = await readSession();
|
|
147
|
+
beforeMachines = Object.keys(local.machines || {}).length;
|
|
148
|
+
merged = mergeSessions(local, remote);
|
|
149
|
+
await writeSession(merged);
|
|
150
|
+
});
|
|
143
151
|
// Re-render + inject into every detected tool so the pinned block
|
|
144
152
|
// reflects the merged state right away across Claude/Cursor/Windsurf/Gemini
|
|
145
153
|
try {
|
package/src/commands/session.js
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
getMachineId,
|
|
25
25
|
paths,
|
|
26
26
|
} from '../session/state.js';
|
|
27
|
+
import { withSessionLock } from '../session/lock.js';
|
|
27
28
|
import { renderSession } from '../session/render.js';
|
|
28
29
|
import { injectInto, detectAvailableTargets } from '../session/inject.js';
|
|
29
30
|
|
|
@@ -180,9 +181,13 @@ export async function sessionShowCommand() {
|
|
|
180
181
|
}
|
|
181
182
|
|
|
182
183
|
export async function sessionClearCommand() {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
await
|
|
184
|
+
// Took no lock at all — a concurrent MCP write between the read and the
|
|
185
|
+
// write was silently lost, and worse, could resurrect what was cleared.
|
|
186
|
+
await withSessionLock(paths.sessionLock, async () => {
|
|
187
|
+
const state = await readSession();
|
|
188
|
+
state.current = { goals: [], next_actions: [], open_questions: [], decisions: [] };
|
|
189
|
+
await writeSession(state);
|
|
190
|
+
});
|
|
186
191
|
await refreshPinned();
|
|
187
192
|
console.log('\n' + chalk.green(' ✓ Current session cleared.') + chalk.gray(' History retained.\n'));
|
|
188
193
|
}
|
package/src/commands/upgrade.js
CHANGED
|
@@ -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('
|
|
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
|
|
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');
|
package/src/commands/validate.js
CHANGED
|
@@ -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.
|
package/src/context/capture.js
CHANGED
|
@@ -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
|
|
@@ -225,7 +233,11 @@ function extractDecisions(userMessages, assistantTexts) {
|
|
|
225
233
|
{ regex: /(?:switch|migrate|move)\s+(?:from\s+\S+\s+)?to\s+([A-Z][a-zA-Z0-9_./-]+)/gi, type: 'tech' },
|
|
226
234
|
// Architecture / design — require an explicit decision verb and a capitalized
|
|
227
235
|
// target. Bare "pick/choose" caught conversational fragments as decisions.
|
|
228
|
-
|
|
236
|
+
// 'going' dropped from the bare alternation: "going on Monday to the
|
|
237
|
+
// office" minted a decision (live proof: "going on PostDash" in the real
|
|
238
|
+
// store). "going to go with/use" is still covered by the to-clause.
|
|
239
|
+
{ regex: /(?:decided|settled|chose|chosen)\s+(?:to\s+(?:go\s+with|use)|with|on)\s+([A-Z][\w .\/+-]{3,50}?)(?:\.|$|,|\n)/g, type: 'design' },
|
|
240
|
+
{ regex: /going\s+to\s+(?:go\s+with|use)\s+([A-Z][\w .\/+-]{3,50}?)(?:\.|$|,|\n)/g, type: 'design' },
|
|
229
241
|
// Stack choices — require a capitalized, tech-looking value, not a prose
|
|
230
242
|
// fragment ("backend is just throwing it away" used to leak through).
|
|
231
243
|
{ regex: /(?:stack|framework|database|backend|frontend|hosting|infra)\s+(?:is|will be|should be)\s+([A-Z][\w .\/+-]{2,40}?)(?:\.|$|,|\n)/g, type: 'stack' },
|