🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

memoir-cli

Package Overview
Dependencies
Maintainers
1
Versions
51
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

memoir-cli - npm Package Compare versions

Comparing version
3.11.2
to
3.11.3
+1
-1
package.json
{
"name": "memoir-cli",
"version": "3.11.2",
"version": "3.11.3",
"mcpName": "io.github.camgitt/memoir",

@@ -5,0 +5,0 @@ "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.",

@@ -214,3 +214,13 @@ import chalk from 'chalk';

// being rejected from session.json. Both sinks now agree on what's junk.
const qualityDecisions = parsed.decisions.filter(d => isQuality(String(d.value || '').trim()));
// Gate on the string each sink actually PERSISTS, not on d.value.
// For rename/tech captures d.value is a single whitespace-free
// token, so isQuality's words>=3 rule rejected 100% of them —
// two of the three advertised capture categories were dead code
// while persistDecisions would have written the clean d.context.
const decisionText = (d) => {
const v = String(d.value || '').trim();
const c = String(d.context || '').trim();
return (d.type === 'rename' || d.type === 'tech') && c ? c : v;
};
const qualityDecisions = parsed.decisions.filter(d => isQuality(decisionText(d)));

@@ -234,3 +244,3 @@ // Persist decisions to Claude's memory so they survive across sessions

for (const d of qualityDecisions.slice(0, 10)) {
const text = String(d.value || '').trim();
const text = decisionText(d);
if (existingTexts.has(text.toLowerCase())) continue;

@@ -326,11 +336,12 @@ await addNote(text, { why: d.context ? `auto-captured: ${d.context.slice(0, 80)}` : undefined });

} else {
const local = await readSession();
const merged = remote ? mergeSessions(local, remote) : local;
if (remote) {
// Persist the merge locally too, inside the same lock every other
// session.json read-modify-write cycle uses.
await withSessionLock(sessionPaths.sessionLock, async () => {
await writeSession(merged);
});
}
// Read AND merge AND write inside one lock. Reading outside it and
// locking only the write is a check-then-act: a concurrent MCP
// memoir_note in that window is silently dropped. This is the most
// reachable instance of that bug — it sits on the autopush path.
let merged;
await withSessionLock(sessionPaths.sessionLock, async () => {
const local = await readSession();
merged = remote ? mergeSessions(local, remote) : local;
if (remote) await writeSession(merged);
});
await fs.writeFile(path.join(stagingDir, 'session.json'), JSON.stringify(merged, null, 2));

@@ -337,0 +348,0 @@ sessionIncluded = true;

@@ -18,2 +18,3 @@ import chalk from 'chalk';

import { readSession, writeSession, mergeSessions, paths as sessionPaths } from '../session/state.js';
import { withSessionLock } from '../session/lock.js';
import { migrateSessionData } from '../session/migrations.js';

@@ -140,6 +141,13 @@ import { renderSession } from '../session/render.js';

const { state: remote } = migrateSessionData(rawRemote);
const local = await readSession();
const beforeMachines = Object.keys(local.machines || {}).length;
const merged = mergeSessions(local, remote);
await writeSession(merged);
// Read+merge+write inside ONE lock, like every state.js mutator.
// Reading outside the lock and locking only the write is a
// check-then-act: a concurrent MCP memoir_note landing in the window
// is silently discarded by our merge of the stale copy.
let merged, beforeMachines;
await withSessionLock(sessionPaths.sessionLock, async () => {
const local = await readSession();
beforeMachines = Object.keys(local.machines || {}).length;
merged = mergeSessions(local, remote);
await writeSession(merged);
});
// Re-render + inject into every detected tool so the pinned block

@@ -146,0 +154,0 @@ // reflects the merged state right away across Claude/Cursor/Windsurf/Gemini

@@ -27,2 +27,3 @@ // CLI commands for the session continuity feature.

} from '../session/state.js';
import { withSessionLock } from '../session/lock.js';
import { renderSession } from '../session/render.js';

@@ -184,7 +185,11 @@ import { injectInto, detectAvailableTargets } from '../session/inject.js';

export async function sessionClearCommand() {
const state = await readSession();
state.current = { goals: [], next_actions: [], open_questions: [], decisions: [] };
await writeSession(state);
// Took no lock at all — a concurrent MCP write between the read and the
// write was silently lost, and worse, could resurrect what was cleared.
await withSessionLock(paths.sessionLock, async () => {
const state = await readSession();
state.current = { goals: [], next_actions: [], open_questions: [], decisions: [] };
await writeSession(state);
});
await refreshPinned();
console.log('\n' + chalk.green(' ✓ Current session cleared.') + chalk.gray(' History retained.\n'));
}

@@ -228,3 +228,7 @@ import fs from 'fs-extra';

// target. Bare "pick/choose" caught conversational fragments as decisions.
{ regex: /(?:decided|settled|going|chose|chosen)\s+(?:to\s+(?:go\s+with|use)|with|on)\s+([A-Z][\w .\/+-]{3,50}?)(?:\.|$|,|\n)/g, type: 'design' },
// 'going' dropped from the bare alternation: "going on Monday to the
// office" minted a decision (live proof: "going on PostDash" in the real
// store). "going to go with/use" is still covered by the to-clause.
{ regex: /(?:decided|settled|chose|chosen)\s+(?:to\s+(?:go\s+with|use)|with|on)\s+([A-Z][\w .\/+-]{3,50}?)(?:\.|$|,|\n)/g, type: 'design' },
{ regex: /going\s+to\s+(?:go\s+with|use)\s+([A-Z][\w .\/+-]{3,50}?)(?:\.|$|,|\n)/g, type: 'design' },
// Stack choices — require a capitalized, tech-looking value, not a prose

@@ -231,0 +235,0 @@ // fragment ("backend is just throwing it away" used to leak through).

@@ -26,2 +26,23 @@ import fs from 'fs-extra';

await fs.copy(stagingDir, resolvedDest);
// Prune orphaned encrypted blobs. Each encrypted push derives a fresh salt
// and therefore fresh HMAC filenames, so without this every push leaves the
// previous push's data/*.enc behind forever and localPath grows without
// bound. Only runs for a full encrypted sync (manifest.enc present in what
// we just wrote) — `memoir snapshot` also calls syncToLocal with a staging
// dir of a single handoff file, and blanket-emptying the destination there
// would delete the user's backup.
try {
const stagedManifest = path.join(stagingDir, 'manifest.enc');
const destData = path.join(resolvedDest, 'data');
if (await fs.pathExists(stagedManifest) && await fs.pathExists(destData)) {
const keep = new Set(await fs.readdir(path.join(stagingDir, 'data')).catch(() => []));
for (const f of await fs.readdir(destData)) {
if (!keep.has(f)) await fs.remove(path.join(destData, f)).catch(() => {});
}
}
} catch {
// Pruning is housekeeping — never fail a completed backup over it.
}
spinner.succeed(chalk.green('Sync complete! ') + chalk.gray(`(Saved to ${resolvedDest})`));

@@ -28,0 +49,0 @@ await appendEvent('sync_pushed', { provider: 'local' });

@@ -34,3 +34,3 @@ import chalk from 'chalk';

{ regex: /(?:^|[\s;])(?:export\s+)?(?:API_KEY|SECRET_KEY|AUTH_TOKEN|ACCESS_TOKEN|PRIVATE_KEY|DB_PASSWORD|DATABASE_URL|JWT_SECRET|ENCRYPTION_KEY|MASTER_KEY)\s*=\s*["']?([^\s'"]{8,})/gmi, label: 'Environment variable secret' },
{ regex: /(?:password|passwd|pwd)\s*[:=]\s*["']?([^\s'"]{6,})/gi, label: 'Password' },
{ regex: /(?:password|passwd|pwd)\s*[:=]\s*["']?([^\s'"]{6,})/gi, label: 'Password', minLength: 6 },

@@ -59,6 +59,14 @@ // Private keys

const secret = match[1] || match[0];
// Skip very short matches (likely false positives)
if (secret.length < 8) continue;
// Per-pattern floor. A global 8 threw away 6-7 char matches that the
// Password pattern ({6,}) was written to catch: `password: s3cr3t`
// survived verbatim into the handoff and the backup while the scan
// reported "no secrets detected" — a silent miss is worse than a
// false positive in a tool that promises redaction.
if (secret.length < (pattern.minLength ?? 8)) continue;
const redacted = secret.slice(0, 4) + '****' + secret.slice(-4);
// For short secrets, slice(0,4)+slice(-4) can reproduce the whole
// thing (a 6-char secret would show 4+4 of 6 characters).
const redacted = secret.length >= 12
? secret.slice(0, 4) + '****' + secret.slice(-4)
: secret.slice(0, 2) + '****';
findings.push({

@@ -65,0 +73,0 @@ label: pattern.label,

@@ -71,3 +71,25 @@ // Lightweight file lock for a read-modify-write critical section, with NO

if (Date.now() - stat.mtimeMs > STALE_MS) {
try { fs.unlinkSync(lockPath); } catch {}
// Steal by rename, not unlink: two processes racing an unlink can
// both "win" and both proceed. rename() is atomic, so exactly one
// wins and the loser simply retries.
let stolen = false;
try {
const graveyard = `${lockPath}.stale-${process.pid}-${Date.now()}`;
fs.renameSync(lockPath, graveyard);
stolen = true;
// The rename is only there to make the steal atomic; the file
// itself is debris. Remove it immediately — best-effort, and
// harmless to leave behind if this fails.
try { fs.unlinkSync(graveyard); } catch {}
} catch {}
if (stolen) {
continue; // we removed it; retry the acquire immediately
}
// Could not remove it (read-only dir, permissions). Fall through
// to the deadline + backoff below instead of spinning forever.
if (Date.now() - start > MAX_WAIT_MS) {
fd = null;
break;
}
await sleep(RETRY_DELAY_MS);
continue;

@@ -99,6 +121,18 @@ }

if (fd !== null) {
// Only unlink if the file at lockPath is still OURS. If our lock was
// stolen as stale and another process now holds a NEW file at the same
// path, unlinking by path would delete the current holder's lock and
// let a third process in. Compare inode via the fd we still hold.
let ours = false;
try {
const byFd = fs.fstatSync(fd);
const byPath = fs.statSync(lockPath);
ours = byFd.ino === byPath.ino && byFd.dev === byPath.dev;
} catch {
ours = false; // path gone or unreadable — nothing safe to remove
}
try { fs.closeSync(fd); } catch {}
try { fs.unlinkSync(lockPath); } catch {}
if (ours) { try { fs.unlinkSync(lockPath); } catch {} }
}
}
}

@@ -392,5 +392,12 @@ // Session state: the canonical source of truth for "what are we working on"

return Array.from(byText.values())
.sort((x, y) => new Date(y[dateField] || 0) - new Date(x[dateField] || 0))
.slice(0, cap);
// Partition before capping. Tombstones keep their original (recent) date,
// so a plain sort+slice let them win cap slots and silently evict real
// entries on merge. They must SURVIVE the merge (removing them
// reintroduces the resurrection the sticky-tombstone rule fixed) but must
// not count against the visible budget.
const all = Array.from(byText.values())
.sort((x, y) => new Date(y[dateField] || 0) - new Date(x[dateField] || 0));
const visible = all.filter((i) => !i.hidden).slice(0, cap);
const tombstones = all.filter((i) => i.hidden).slice(0, cap);
return [...visible, ...tombstones];
}

@@ -397,0 +404,0 @@