memoir-cli
Advanced tools
| // Lean-memory: keep the loaded memory index (MEMORY.md) under a line budget so | ||
| // the AI loads ALL of it (Claude Code reads only ~200 lines) and wastes no | ||
| // context on bloat. When over budget, the fattest *inline* sections are moved | ||
| // into a dated archive file and replaced with one-line pointers. | ||
| // | ||
| // Guarantees: archive-not-delete (nothing lost), never touches the critical | ||
| // behavior-rules section or the preamble, idempotent, dry-run capable, | ||
| // code-fence aware, content-deduped, atomic writes, graceful on errors. | ||
| import fs from 'fs-extra'; | ||
| import path from 'path'; | ||
| export const DEFAULT_BUDGET = 180; // Claude loads ~200 lines of MEMORY.md; leave headroom. | ||
| // Split into ## sections — but a "## " INSIDE a fenced code block (``` or ~~~) | ||
| // is content, not a header, so we never split there (would orphan content + | ||
| // leave an unclosed fence = invalid markdown + data loss). | ||
| function splitSections(text) { | ||
| const sections = []; | ||
| let cur = { header: '(preamble)', lines: [] }; | ||
| let fence = null; // active fence marker while inside a code block | ||
| for (const line of text.split('\n')) { | ||
| const t = line.trimStart(); | ||
| const m = t.match(/^(```|~~~)/); | ||
| if (m) { | ||
| if (!fence) fence = m[1]; | ||
| else if (t.startsWith(fence)) fence = null; | ||
| } | ||
| if (!fence && /^##\s/.test(line)) { | ||
| sections.push(cur); | ||
| cur = { header: line.replace(/^##\s+/, '').trim(), lines: [line] }; | ||
| } else { | ||
| cur.lines.push(line); | ||
| } | ||
| } | ||
| sections.push(cur); | ||
| return sections; | ||
| } | ||
| // A line is a lightweight pointer (not inline content to archive) if it's a | ||
| // clean `- [text](file)` OR any link to one of our archive files. | ||
| function isPointer(t) { | ||
| if (/^- \[[^\]]+\]\([^)]+\)/.test(t)) return true; | ||
| if (/\[[^\]]*\]\(memory_index_archive_[^)]*\)/.test(t)) return true; | ||
| return false; | ||
| } | ||
| function inlineWeight(section) { | ||
| return section.lines.filter(l => { | ||
| const t = l.trim(); | ||
| if (!t) return false; | ||
| if (/^#{2,3}\s/.test(t)) return false; | ||
| if (isPointer(t)) return false; | ||
| return true; | ||
| }).length; | ||
| } | ||
| const PROTECTED = (header) => /critical behavior rules/i.test(header) || header === '(preamble)'; | ||
| async function atomicWrite(filePath, content) { | ||
| const tmp = `${filePath}.tmp-${process.pid}`; | ||
| await fs.writeFile(tmp, content); | ||
| await fs.move(tmp, filePath, { overwrite: true }); | ||
| } | ||
| /** | ||
| * Tidy MEMORY.md down under `budgetLines`. | ||
| * @returns { overBudget, lineCount, newLineCount?, budgetLines, archived[], archiveFile?, dryRun? } | { ok:false, reason } | ||
| */ | ||
| export async function tidyIndex(memoryDir, { budgetLines = DEFAULT_BUDGET, dryRun = false, stamp = 'archive' } = {}) { | ||
| const mdPath = path.join(memoryDir, 'MEMORY.md'); | ||
| let text; | ||
| try { | ||
| if (!await fs.pathExists(mdPath)) return { ok: false, reason: 'no MEMORY.md' }; | ||
| text = await fs.readFile(mdPath, 'utf8'); | ||
| } catch (err) { | ||
| return { ok: false, reason: `read failed: ${err.code || err.message}` }; | ||
| } | ||
| const lineCount = text.split('\n').length; | ||
| if (lineCount <= budgetLines) return { overBudget: false, lineCount, budgetLines, archived: [] }; | ||
| const sections = splitSections(text); | ||
| const archiveFile = `memory_index_archive_${stamp}.md`; | ||
| const archivePath = path.join(memoryDir, archiveFile); | ||
| // Read the prior archive ONCE so we can content-dedup (no re-append bloat). | ||
| let priorArchive = ''; | ||
| try { if (await fs.pathExists(archivePath)) priorArchive = await fs.readFile(archivePath, 'utf8'); } catch {} | ||
| // Fattest inline sections first; skip empty headers (would make `- []()`) and | ||
| // protected sections. | ||
| const candidates = sections | ||
| .map((s, i) => ({ i, s, weight: inlineWeight(s) })) | ||
| .filter(c => c.weight >= 6 && c.s.header.trim().length > 0 && !PROTECTED(c.s.header)) | ||
| .sort((a, b) => b.weight - a.weight); | ||
| const removeIdx = new Map(); | ||
| const archived = []; | ||
| let toAppend = ''; | ||
| let projected = lineCount; | ||
| for (const c of candidates) { | ||
| if (projected <= budgetLines) break; | ||
| const body = c.s.lines.join('\n'); | ||
| const key = body.trim(); | ||
| // Only append content not already archived — dedup prevents bloat; the | ||
| // section is still safely in the archive so removing it from MEMORY.md is | ||
| // never a loss. | ||
| if (key && !priorArchive.includes(key) && !toAppend.includes(key)) { | ||
| toAppend += body + '\n\n'; | ||
| } | ||
| removeIdx.set(c.i, `- [${c.s.header}](${archiveFile}) — moved out of the index ${stamp} (full detail in file)`); | ||
| archived.push({ section: c.s.header, lines: c.s.lines.length }); | ||
| projected -= (c.s.lines.length - 1); | ||
| } | ||
| if (!archived.length) return { overBudget: true, lineCount, budgetLines, archived: [], note: 'over budget but no fat inline sections found' }; | ||
| if (dryRun) return { overBudget: true, lineCount, projectedLines: projected, budgetLines, wouldArchive: archived, dryRun: true }; | ||
| const out = []; | ||
| for (let i = 0; i < sections.length; i++) { | ||
| if (removeIdx.has(i)) out.push(removeIdx.get(i)); | ||
| else out.push(...sections[i].lines); | ||
| } | ||
| const fm = `---\nname: Memory index archive (${stamp})\ndescription: Fat inline sections moved out of MEMORY.md to keep the loaded index under ${budgetLines} lines. Nothing deleted; pointers remain in MEMORY.md.\nmetadata:\n type: reference\n---\n`; | ||
| const base = priorArchive || fm; | ||
| if (toAppend) await atomicWrite(archivePath, base.trimEnd() + '\n\n' + toAppend.trimEnd() + '\n'); | ||
| await atomicWrite(mdPath, out.join('\n')); | ||
| return { overBudget: true, lineCount, newLineCount: out.length, budgetLines, archived, archiveFile }; | ||
| } |
| // Single source of truth for OS-specific config locations. | ||
| // | ||
| // Why this exists: before this module, every adapter branched on a single | ||
| // `isWin` flag — `isWin ? <windows> : <macOS>`. On Linux, `process.platform` | ||
| // is `'linux'`, so those ternaries silently fell through to the *macOS* path | ||
| // (`~/Library/Application Support/...`), which doesn't exist on Linux. memoir | ||
| // would then detect zero tools, sync nothing, and the user would churn without | ||
| // any error — the "silent-zero-memory activation cliff." | ||
| // | ||
| // Every function is parameterized by { platform, env, home } so the three OS | ||
| // branches can be unit-tested from any machine, not just the target OS. Runtime | ||
| // callers omit the options and get the live platform. | ||
| import path from 'node:path'; | ||
| import os from 'node:os'; | ||
| const HOME = os.homedir(); | ||
| // VS Code-family per-user config base: <root>/<App>/User | ||
| // win32 : %APPDATA%/<App>/User | ||
| // darwin : ~/Library/Application Support/<App>/User | ||
| // linux : $XDG_CONFIG_HOME (or ~/.config)/<App>/User | ||
| export function vscodeUserDir(appName, { platform = process.platform, env = process.env, home = HOME } = {}) { | ||
| if (platform === 'win32') { | ||
| const appData = env.APPDATA || path.join(home, 'AppData', 'Roaming'); | ||
| return path.join(appData, appName, 'User'); | ||
| } | ||
| if (platform === 'darwin') { | ||
| return path.join(home, 'Library', 'Application Support', appName, 'User'); | ||
| } | ||
| // linux + anything else POSIX-y | ||
| const xdg = env.XDG_CONFIG_HOME || path.join(home, '.config'); | ||
| return path.join(xdg, appName, 'User'); | ||
| } | ||
| // A VS Code extension's globalStorage dir (e.g. Cline lives under the base | ||
| // "Code" install, not its own app dir). | ||
| export function vscodeGlobalStorage(extId, opts = {}) { | ||
| return path.join(vscodeUserDir('Code', opts), 'globalStorage', extId); | ||
| } | ||
| // XDG-aware ~/.config base, for non-VSCode tools that already store there | ||
| // (zed, github-copilot). Exposed so callers don't re-hardcode ~/.config and | ||
| // drift from XDG_CONFIG_HOME. | ||
| export function xdgConfigDir({ env = process.env, home = HOME } = {}) { | ||
| return env.XDG_CONFIG_HOME || path.join(home, '.config'); | ||
| } |
+2
-2
| { | ||
| "name": "memoir-cli", | ||
| "version": "3.8.1", | ||
| "version": "3.9.0", | ||
| "mcpName": "io.github.camgitt/memoir", | ||
| "description": "MCP server that gives Claude, Cursor, and Gemini long-term memory across sessions. Your AI remembers your codebase, decisions, and preferences — across tools and machines.", | ||
| "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.", | ||
| "main": "src/index.js", | ||
@@ -7,0 +7,0 @@ "type": "module", |
+20
-4
@@ -5,3 +5,3 @@ <div align="center"> | ||
| **Sync AI memory across every coding tool. Zero config.** | ||
| **Sync AI memory across every tool and every machine — end-to-end encrypted. Free.** | ||
@@ -19,3 +19,3 @@ [](https://npmjs.org/package/memoir-cli) | ||
| One command. No install, no config, no API keys. Your AI now has persistent memory across sessions, tools, and machines. Works with Claude Code, Cursor, Windsurf, Gemini CLI, GitHub Copilot, and 6 more tools. | ||
| One command. No install, no config, no API keys. Claude Code on your Mac, Cursor on your laptop, Copilot at the office — **one memory follows you** across every tool and every machine, encrypted with a key only you hold. memoir's servers literally can't read it. | ||
@@ -26,4 +26,6 @@ --- | ||
| memoir is an [MCP memory server](https://modelcontextprotocol.io) that gives your AI tools persistent memory. Your AI can search, save, and recall context automatically — like a Claude Code backup that works everywhere. | ||
| Your coding tools are starting to remember you — Claude Code, Cursor, and Copilot all ship built-in memory now. But that memory is **trapped: one tool, one machine, stored in plaintext.** Switch from Cursor to Claude Code, or open a different laptop, and your AI is a stranger again. | ||
| memoir is the [MCP memory server](https://modelcontextprotocol.io) that breaks it out. **One memory, shared across every tool and synced to every machine — encrypted client-side, so even memoir's servers can't read it.** Your AI searches, saves, and recalls context automatically, everywhere you work. | ||
| ``` | ||
@@ -42,2 +44,16 @@ you: how does auth work in this project? | ||
| ## How it's different | ||
| Native memory and the other memory tools each give you *part* of this. memoir is the only one that gives you all of it: | ||
| | | Cross-tool | Cross-machine sync | Zero-knowledge encrypted | | ||
| |---|:---:|:---:|:---:| | ||
| | **memoir** | ✅ | ✅ **free** | ✅ | | ||
| | Claude Code / Cursor native | ❌ one tool | ❌ one machine | ❌ | | ||
| | claude-mem | ✅ | ❌ local only | ❌ | | ||
| | basic-memory | ✅ | 💲 paid cloud | ❌ | | ||
| | mem0 / OpenMemory | ✅ | 💲 paid cloud | ❌ | | ||
| Native memory is locked to one tool on one machine. The others keep your memory in plaintext, or put cross-machine sync behind a paywall. memoir is the only one that does all three — every tool, every machine, encrypted under a key only you hold — for free. <sub>(Based on public docs, June 2026.)</sub> | ||
| ## Quick start | ||
@@ -74,3 +90,3 @@ | ||
| memoir fixes this by giving your AI a shared memory layer that works across **every tool you use**. Tell Claude something once. Cursor knows it too. Sync AI memory between tools, back it up to the cloud, restore it on any machine. And when your memories pile up, `memoir consolidate` cleans house — finds duplicates, flags stale context, and optionally uses AI to merge and prune. | ||
| memoir fixes that. Tell Claude something once and Cursor knows it too — your memory syncs between tools, backs up to the cloud, and restores on any machine. When it piles up, `memoir consolidate` cleans house: finds duplicates, flags stale context, and can use AI to merge and prune. | ||
@@ -77,0 +93,0 @@ **11 tools supported:** Claude Code, Cursor, Windsurf, Gemini CLI, GitHub Copilot, OpenAI Codex, ChatGPT, Aider, Zed, Cline, Continue.dev. |
+10
-12
@@ -7,2 +7,3 @@ import fs from 'fs-extra'; | ||
| import { shouldIgnoreProject } from '../context/capture.js'; | ||
| import { vscodeUserDir, vscodeGlobalStorage } from '../utils/platform.js'; | ||
@@ -14,2 +15,7 @@ const home = os.homedir(); | ||
| // VS Code-family config dirs — resolved per-OS (incl. Linux) via platform.js. | ||
| const cursorUserDir = vscodeUserDir('Cursor'); | ||
| const windsurfUserDir = vscodeUserDir('Windsurf'); | ||
| const clineStorageDir = vscodeGlobalStorage('saoudrizwan.claude-dev'); | ||
| export const adapters = [ | ||
@@ -83,9 +89,5 @@ { | ||
| icon: '⚡', | ||
| source: isWin | ||
| ? path.join(appData, 'Cursor', 'User') | ||
| : path.join(home, 'Library', 'Application Support', 'Cursor', 'User'), | ||
| source: cursorUserDir, | ||
| filter: (src) => { | ||
| const cursorDir = isWin | ||
| ? path.join(appData, 'Cursor', 'User') | ||
| : path.join(home, 'Library', 'Application Support', 'Cursor', 'User'); | ||
| const cursorDir = cursorUserDir; | ||
| const rel = path.relative(cursorDir, src); | ||
@@ -123,9 +125,5 @@ if (src === cursorDir) return true; | ||
| icon: '🏄', | ||
| source: isWin | ||
| ? path.join(appData, 'Windsurf', 'User') | ||
| : path.join(home, 'Library', 'Application Support', 'Windsurf', 'User'), | ||
| source: windsurfUserDir, | ||
| filter: (src) => { | ||
| const windsurfDir = isWin | ||
| ? path.join(appData, 'Windsurf', 'User') | ||
| : path.join(home, 'Library', 'Application Support', 'Windsurf', 'User'); | ||
| const windsurfDir = windsurfUserDir; | ||
| const rel = path.relative(windsurfDir, src); | ||
@@ -132,0 +130,0 @@ if (src === windsurfDir) return true; |
@@ -6,2 +6,3 @@ import chalk from 'chalk'; | ||
| import inquirer from 'inquirer'; | ||
| import { detectAvailableTargets } from '../session/inject.js'; | ||
@@ -70,2 +71,21 @@ // The instruction files each AI tool reads, in priority order | ||
| /** | ||
| * Auto-activate recall GLOBALLY: ensure the memoir instruction block exists in | ||
| * each installed tool's user-global config (e.g. ~/.claude/CLAUDE.md), so the AI | ||
| * is told to use memoir_recall/remember in EVERY project — no per-project step. | ||
| * Idempotent and additive (never clobbers existing content). Opt out by setting | ||
| * MEMOIR_NO_AUTO_ACTIVATE. Called from the SessionStart hook (auto-refresh). | ||
| */ | ||
| export async function ensureRecallInstruction() { | ||
| if (process.env.MEMOIR_NO_AUTO_ACTIVATE) return { skipped: true, added: 0 }; | ||
| let added = 0; | ||
| for (const target of Object.values(detectAvailableTargets())) { | ||
| try { | ||
| const res = await injectBlock(target); | ||
| if (res === 'appended' || res === 'created') added++; | ||
| } catch {} | ||
| } | ||
| return { added }; | ||
| } | ||
| /** | ||
| * Remove memoir block from a file | ||
@@ -72,0 +92,0 @@ */ |
@@ -12,2 +12,5 @@ // Auto-refresh — called by the Claude Code SessionStart hook. | ||
| import { injectInto, detectAvailableTargets } from '../session/inject.js'; | ||
| import { ensureRecallInstruction } from './activate.js'; | ||
| import { tidyIndex } from './tidy.js'; | ||
| import { resolveHomeMemoryDir } from '../context/capture.js'; | ||
@@ -28,2 +31,26 @@ export async function autoRefreshCommand(options = {}) { | ||
| } | ||
| // Ensure recall is globally active (idempotent) so the AI uses memoir in | ||
| // every project without a manual `memoir activate`. | ||
| try { | ||
| const r = await ensureRecallInstruction(); | ||
| if (verbose && r.added) console.log(`memoir auto-refresh: enabled recall in ${r.added} global config(s)`); | ||
| } catch (err) { | ||
| if (verbose) console.error(`memoir auto-refresh: ensureRecallInstruction failed: ${err.message}`); | ||
| } | ||
| // Lean-memory: keep the loaded index under budget so the AI loads ALL of it | ||
| // and wastes no context on bloat. Over-budget-only, archive-not-delete, | ||
| // opt out with MEMOIR_NO_AUTO_TIDY. | ||
| if (!process.env.MEMOIR_NO_AUTO_TIDY) { | ||
| try { | ||
| const dir = resolveHomeMemoryDir(); | ||
| if (dir) { | ||
| const t = await tidyIndex(dir, { stamp: 'auto' }); | ||
| if (verbose && t.archived?.length) { | ||
| console.log(`memoir auto-refresh: tidied index → archived ${t.archived.length} section(s), now ${t.newLineCount} lines`); | ||
| } | ||
| } | ||
| } catch (err) { | ||
| if (verbose) console.error(`memoir auto-refresh: tidy failed: ${err.message}`); | ||
| } | ||
| } | ||
| } catch (err) { | ||
@@ -30,0 +57,0 @@ if (verbose) console.error(`memoir auto-refresh: ${err.message}`); |
@@ -163,2 +163,4 @@ import chalk from 'chalk'; | ||
| if (!/[a-zA-Z]/.test(text)) return false; // no actual words | ||
| if (/\?/.test(text)) return false; // questions aren't decisions | ||
| if (/^(it|this|that|these|those|we|i|they|you|some|there|here|just|back|now|also)\b/i.test(text)) return false; // fragment | ||
| const words = text.split(/\s+/).length; | ||
@@ -165,0 +167,0 @@ if (words < 3) return false; // less than 3 words isn't a decision |
+15
-5
@@ -11,7 +11,17 @@ // Decision registry lookup — `memoir why <query>` | ||
| if (!query) return decisions; | ||
| const q = String(query).toLowerCase(); | ||
| return decisions.filter(d => { | ||
| const haystack = [d.text, d.why, d.rejected].filter(Boolean).join(' ').toLowerCase(); | ||
| return haystack.includes(q); | ||
| }); | ||
| // Tokenize the query and match decisions containing any term, ranked by how | ||
| // many terms hit (recency breaks ties). A single whole-phrase substring match | ||
| // silently missed multi-word queries like "memoir positioning" even when every | ||
| // word was present — which is exactly how the MCP memoir_why tool queries. | ||
| const terms = String(query).toLowerCase().split(/\s+/).filter(Boolean); | ||
| if (!terms.length) return decisions; | ||
| return decisions | ||
| .map(d => { | ||
| const haystack = [d.text, d.why, d.rejected].filter(Boolean).join(' ').toLowerCase(); | ||
| const score = terms.reduce((s, t) => s + (haystack.includes(t) ? 1 : 0), 0); | ||
| return { d, score }; | ||
| }) | ||
| .filter(x => x.score > 0) | ||
| .sort((a, b) => b.score - a.score || String(b.d.date || '').localeCompare(String(a.d.date || ''))) | ||
| .map(x => x.d); | ||
| } | ||
@@ -18,0 +28,0 @@ |
+37
-29
@@ -154,2 +154,12 @@ import fs from 'fs-extra'; | ||
| // Reject conversational fragments that loose regexes sometimes capture as | ||
| // "decisions" — questions, and clauses starting with a pronoun/filler word | ||
| // ("we pick this back up Monday", "it up at...", "some lenders may..."). | ||
| function looksLikeFragment(v) { | ||
| if (!v) return true; | ||
| if (/\?/.test(v)) return true; | ||
| if (/^(it|this|that|these|those|we|i|they|you|he|she|some|there|here|just|back|now|also)\b/i.test(v)) return true; | ||
| return false; | ||
| } | ||
| /** | ||
@@ -172,6 +182,8 @@ * Extract durable decisions from session conversation. | ||
| { regex: /(?:switch|migrate|move)\s+(?:from\s+\S+\s+)?to\s+([A-Z][a-zA-Z0-9_./-]+)/gi, type: 'tech' }, | ||
| // Architecture / design | ||
| { regex: /(?:let'?s|we(?:'ll| will| should)?)\s+(?:go with|pick|choose)\s+(.{5,60}?)(?:\.|$|,|\n)/gi, type: 'design' }, | ||
| // Stack choices | ||
| { regex: /(?:stack|framework|database|backend|frontend)\s+(?:is|will be|should be)\s+(.{5,60}?)(?:\.|$|,|\n)/gi, type: 'stack' }, | ||
| // Architecture / design — require an explicit decision verb and a capitalized | ||
| // 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' }, | ||
| // Stack choices — require a capitalized, tech-looking value, not a prose | ||
| // fragment ("backend is just throwing it away" used to leak through). | ||
| { regex: /(?:stack|framework|database|backend|frontend|hosting|infra)\s+(?:is|will be|should be)\s+([A-Z][\w .\/+-]{2,40}?)(?:\.|$|,|\n)/g, type: 'stack' }, | ||
| ]; | ||
@@ -183,2 +195,3 @@ | ||
| const value = match[1].trim().replace(/["']+$/, ''); | ||
| if (looksLikeFragment(value)) continue; | ||
| if (value.length > 2 && value.length < 80) { | ||
@@ -207,2 +220,20 @@ // Avoid duplicates | ||
| /** | ||
| * Resolve the HOME-level memory dir (~/.claude/projects/<home-key>/memory) — | ||
| * the one that matches the user's home path encoding, not a sub-project. | ||
| * Returns null if none exists. Shared by persistDecisions + lean-memory tidy. | ||
| */ | ||
| export function resolveHomeMemoryDir(claudeSource) { | ||
| const claudeDir = claudeSource || path.join(home, '.claude'); | ||
| const projectsDir = path.join(claudeDir, 'projects'); | ||
| // Canonical home-key path ONLY. We deliberately do NOT fall back to "shortest | ||
| // dir that has a memory/ subfolder" — on a shared machine that could silently | ||
| // target a different project's (or teammate's) memory. Callers create the dir | ||
| // if needed; tidy safely no-ops when MEMORY.md is absent. | ||
| const homeKey = process.platform === 'win32' | ||
| ? home.replace(/\\/g, '-').replace(/:/g, '-') | ||
| : '-' + home.replace(/^\//, '').replace(/\//g, '-'); | ||
| return path.join(projectsDir, homeKey, 'memory'); | ||
| } | ||
| /** | ||
| * Write extracted decisions to Claude's persistent memory. | ||
@@ -214,27 +245,4 @@ * This ensures decisions survive across sessions and machines. | ||
| const claudeDir = claudeSource || path.join(home, '.claude'); | ||
| const projectsDir = path.join(claudeDir, 'projects'); | ||
| if (!fs.existsSync(projectsDir)) return 0; | ||
| // Find the HOME-level memory dir (not project-specific) | ||
| // This is the dir that matches the user's home path encoding | ||
| let homeKey; | ||
| if (process.platform === 'win32') { | ||
| homeKey = home.replace(/\\/g, '-').replace(/:/g, '-'); | ||
| } else { | ||
| homeKey = '-' + home.replace(/^\//, '').replace(/\//g, '-'); | ||
| } | ||
| // Try exact match first, then detect from existing dirs | ||
| let memDir = path.join(projectsDir, homeKey, 'memory'); | ||
| if (!fs.existsSync(memDir)) { | ||
| // Fallback: find dirs with memory/ subfolder, pick shortest name (likely home-level) | ||
| const entries = fs.readdirSync(projectsDir, { withFileTypes: true }) | ||
| .filter(e => e.isDirectory() && fs.existsSync(path.join(projectsDir, e.name, 'memory'))); | ||
| if (entries.length === 0) return 0; | ||
| // Shortest dir name is most likely the home key (not a sub-project) | ||
| const homeEntry = entries.sort((a, b) => a.name.length - b.name.length)[0]; | ||
| memDir = path.join(projectsDir, homeEntry.name, 'memory'); | ||
| } | ||
| const memDir = resolveHomeMemoryDir(claudeSource); | ||
| if (!memDir) return 0; | ||
| fs.mkdirSync(memDir, { recursive: true }); | ||
@@ -241,0 +249,0 @@ const decisionsFile = path.join(memDir, 'session-decisions.md'); |
AI-detected potential malware
Supply chain riskAI has identified this package as malware. This is a strong signal that the package may be malicious.
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 2 instances
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 2 instances
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
389219
3.29%62
3.33%9343
2.42%167
10.6%2
100%49
11.36%