memoir-cli
Advanced tools
+1
-1
| { | ||
| "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.", |
+22
-11
@@ -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, |
+36
-2
@@ -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 {} } | ||
| } | ||
| } | ||
| } |
+10
-3
@@ -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 @@ |
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 3 instances
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 3 instances
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
451374
1.24%10530
0.92%