open-memex
Version:
Local-first memory layer and protocol for AI coding agents. Markdown source of truth, SQLite FTS5 index, zero cloud.
1,684 lines • 64.5 kB
text/typescript
#!/usr/bin/env node
// Run with: node --experimental-strip-types src/cli.ts <command>
import { resolveProjectScope, resolveCwdScope, PERSONAL_SCOPE, type Scope } from "./scope.ts";
import { db, closeDb } from "./store/db.ts";
import { syncScope, upsertFromFile, deleteFromIndex } from "./store/sync.ts";
import { migrateScope, scopeHasFiles, type ConflictStrategy } from "./store/migrate.ts";
import { migrateV2 } from "./store/v2migrate.ts";
import { findDuplicates, supersede, setStatus } from "./store/lifecycle.ts";
import { proposeMemories, promoteMemory, listConflicts, resolveConflict, formatReviewHistory } from "./review.ts";
import { getSyncStatus, formatSyncStatus, submitMemories } from "./submit.ts";
import { getPrStatus, formatPrStatus, applyPrStatus } from "./github.ts";
import { search, list, countList, hitStateLabel } from "./retrieve/search.ts";
import { formatInventoryLine, truncationNote } from "./retrieve/display.ts";
import {
writeMemoryFile,
readMemoryFile,
ulid,
msToRfc3339,
normalizeAliases,
type Frontmatter,
} from "./store/markdown.ts";
import { loadConfig, configSource } from "./config.ts";
import { paths, projectRoot, isInRepoMemoryFile } from "./paths.ts";
import { redact } from "./redact.ts";
import { resolveMcpCommand } from "./init.ts";
import fs from "node:fs";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
/**
* True when `target` sits inside a git working tree (D68 guard).
*
* D70: resolve the nearest EXISTING ancestor first. `git -C` on a
* directory that does not exist throws, and the old code turned that
* into "not in a worktree" — a fail-open guard on the one path where
* personal memories would ride a commit.
*/
function isInsideWorkTree(target: string): boolean {
try {
let dir = path.dirname(path.resolve(target));
while (!fs.existsSync(dir)) {
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
const out = execFileSync("git", ["-C", dir, "rev-parse", "--is-inside-work-tree"], {
stdio: ["ignore", "pipe", "ignore"],
encoding: "utf8",
});
return out.trim() === "true";
} catch {
return false;
}
}
/**
* D70: the one place that decides where a report may be written, for
* every `inventory` format. Returns an error message to print, or null
* when the path is safe. Kept single so the text/json/html paths cannot
* drift apart (they already had two copies, D68).
*/
function inventoryWriteRefusal(
target: string,
includesPersonal: boolean,
allowPersonal: boolean,
): string | null {
// Personal content must not ride a commit (the design's hardest rule).
if (includesPersonal && !allowPersonal && isInsideWorkTree(target)) {
return (
`inventory: ${target} is inside a git working tree and this report includes personal memories. ` +
`Refusing to write it there. Pass --allow-personal if you really want that, or write outside the repo.`
);
}
const parent = path.dirname(path.resolve(target));
if (!fs.existsSync(parent)) {
return `inventory: the directory ${parent} does not exist — create it first, or choose another --out path.`;
}
return null;
}
/** Per-command help, printed by `open-memex <command> --help`.
AI assistants discover the CLI through --help, so every command needs one. */
const COMMAND_HELP: Record<string, string> = {
where: `Show which project scope the current directory resolves to, and where its data lives.
Usage: open-memex where
Example:
open-memex where`,
list: `List memories in a scope, newest first, as a numbered inventory.
Usage: open-memex list [--scope project|personal|both] [--type T] [--limit N] [--include active|all]
Flags:
--scope project (default), personal, or both
--type filter by memory type
--limit max results (default 20, max 100)
--include active (default: what is currently remembered) or all (audit
view: superseded versions, retracted and archived included)
A cut list says how many entries it left out. Numbers are unique within one
listing, so "the third one" names exactly one memory - but they are only
valid for the listing you just ran.
Examples:
open-memex list
open-memex list --scope personal --limit 20`,
search: `Search memories by keyword (BM25 full-text), best matches first.
Usage: open-memex search "query" [--scope project|personal|both] [--type T] [--limit N] [--explain]
Flags:
--scope project (default), personal, or both
--type filter by memory type
--limit max results
--explain show the FTS expression, per-hit score, and how many matches
lifecycle hid (superseded / retracted / archived)
Example:
open-memex search "deploy checklist" --scope both`,
add: `Save a fact, preference, decision, or note to local memory.
Usage: open-memex add "content" [--scope project|personal] [--type T] [--tag t1,t2] [--aliases "a;b"]
Flags:
--scope project (default) or personal (personal never leaves this machine)
--type memory type (default: fact)
--tag comma-separated tags
--aliases alternate phrasings of this fact (up to 4), indexed with the
memory so differently-worded searches still match; separate
with ; (commas also accepted — use ; when an alias itself
contains one). Stored only when capture aliases are enabled
(init default)
Example:
open-memex add "We deploy on Fridays" --scope project --tag process`,
supersede: `Replace a memory with a newer version. The old one is kept as history.
Usage: open-memex supersede <id> "new content" [--type T] [--tag t1,t2] [--aliases "a;b"]
Without --aliases, the old memory's aliases carry over.
Example:
open-memex supersede 01ABC "We deploy on Thursdays now"`,
status: `Change a memory's lifecycle status.
Usage: open-memex status <id> active|deprecated|retracted|archived
Retraction is one-way: a retracted memory cannot be set back to active
(it was withdrawn on purpose). Save the content as a new memory instead.
Example:
open-memex status 01ABC deprecated`,
forget: `Delete a memory by id. With --soft, hide it instead: the memory
is retracted (out of lists and search, file kept). Retraction is one-way —
save the content as a new memory to bring it back. If the memory lives in
the repo, either action is only local until you commit and push it.
Usage: open-memex forget <id> [--soft]
Example:
open-memex forget 01ABC
open-memex forget 01ABC --soft`,
propose: `Copy personal memories into the project outbox as review drafts.
The personal originals stay put. Nothing enters git at this step.
Usage: open-memex propose <id...> --to project [--local-approve]
Flags:
--to project (required)
--local-approve mark the copies approved right away (solo-dev shortcut)
Example:
open-memex propose 01ABC 01DEF --to project`,
promote: `Advance a project memory one step up the review ladder
(proposed → approved → published), or reject it with a note.
Every transition is appended to the memory's review_history.
Rejected memories are never deleted — they can be revised and resubmitted.
Usage: open-memex promote <id> [--reject] [--resubmit] [--note "..."] [--by NAME]
Flags:
--reject move back to rejected (requires --note)
--resubmit move a rejected memory back to proposed
--note reason for the transition (recorded in review_history)
--by reviewer name (defaults to the git user)
Examples:
open-memex promote 01ABC --note "verified against the runbook"
open-memex promote 01ABC --reject --note "outdated after the migration"`,
resolve: `List conflicted memories, or 3-way-merge one.
Usage: open-memex resolve [id-or-path]
With no argument, lists conflicts. With an id or file path, shows the
3-way merge (base / outbox / repo) so you can resolve it by hand.
Conflicts are never auto-resolved.
Examples:
open-memex resolve
open-memex resolve 01ABC`,
"sync-status": `Show the project memory sync pipeline: when the index last synced
and what triggered it, drafts waiting in the outbox (appdata), memories in the
repo awaiting review or published, and repo files not yet committed.
Usage: open-memex sync-status
Example:
open-memex sync-status`,
inventory: `Show everything remembered — personal + this project — in the
open: what each memory says, where it came from, and whether it has reached
the repo yet. Read-only; deleting stays a confirmed conversation with your
agent. Formats: text (default, diffable), json (for agents), html (a local
page for your browser; default writes <data dir>/inventory.html).
Usage: open-memex inventory [--format text|json|html] [--scope project|personal|both] [--out <path>] [--allow-personal]
Default scope: both. Without --out, text/json go to stdout. A report that
includes personal memories is refused inside a git working tree unless
--allow-personal is passed — personal content must not ride a commit.
Example:
open-memex inventory --format json --out ~/inventory.json
open-memex inventory --format html`,
pull: `Pull shared project memories from the git remote: fetch + fast-forward
only. Never auto-merges — a diverged branch fails with a clear message and is
left for you to resolve by hand. On success the local index re-syncs.
Usage: open-memex pull
Example:
open-memex pull`,
push: `Push the current branch (with its submitted memories) to the git
remote. Explicit only — open-memex never pushes on its own.
Usage: open-memex push
Example:
open-memex push`,
export: `Export memories to a portable .tar.gz bundle (markdown source of
truth + manifest.json) for moving to another machine or another app.
Excludes visibility:private memories by default; --all includes everything.
Usage: open-memex export [--scope project|personal|both] [--type T] [--tag t] [--all] [-o <file>]
Flags:
--scope project (default), personal, or both
--type filter by memory type
--tag filter by tag
--all, -a include private memories (full migration)
-o output file (default: ./open-memex-export-<timestamp>.tar.gz)
Examples:
open-memex export -o backup.tar.gz
open-memex export --scope both --all -o full-migration.tar.gz`,
import: `Import a bundle created by \`open-memex export\`. Personal memories
go to the personal dir; project memories are re-keyed to the current project
and land in the outbox as drafts. Existing identical memories are skipped;
conflicting ids are reported, never overwritten.
Usage: open-memex import <bundle.tar.gz> [--dry-run]
Examples:
open-memex import backup.tar.gz --dry-run
open-memex import backup.tar.gz`,
"distill-agents": `Propose an AGENTS.md snippet distilled from project
memories (decisions, constraints, lessons, gotchas, howtos). Prints markdown
to stdout, or writes it with -o. Review and merge by hand — open-memex never
rewrites your AGENTS.md on its own.
Usage: open-memex distill-agents [--scope project|personal] [--type t1,t2] [--limit N] [-o <file>]
Examples:
open-memex distill-agents
open-memex distill-agents --type decision,gotcha -o agents-snippet.md`,
submit: `Move outbox drafts into the repo for review: copies the drafts into
the repo memory dir as proposed (a local-approved copy keeps its approval),
commits locally on the CURRENT branch, and moves the outbox originals out.
Never creates a branch on its own — branch creation is your call.
Usage: open-memex submit <id...> [--branch <name>] [--base <branch>]
Flags:
--branch create this branch and submit onto it (only with your explicit
approval for the full chain); default: stay on current branch
--base PR base override (default: the branch you're on)
Example:
open-memex submit 01ABC 01DEF`,
"pr-status": `Map the current branch's GitHub PR state back onto review_state:
merged → published, approval → approved (approved_by = the reviewer),
changes-requested → suggestion only (never auto-rejects).
Each memory in the PR is mapped independently; a human rejection is never
overwritten. Report-only by default.
Usage: open-memex pr-status [--apply]
Flags:
--apply write the transitions locally (still never pushes)
Examples:
open-memex pr-status
open-memex pr-status --apply`,
reindex: `Rebuild the SQLite index from the markdown files.
Usage: open-memex reindex
Example:
open-memex reindex`,
scopes: `List the known scopes (personal + project).
Usage: open-memex scopes
Example:
open-memex scopes`,
migrate: `Move memories between scopes, or convert a legacy my-o-memory data dir.
Usage: open-memex migrate [--from <key>] [--to <key>] [--dry-run] [--on-conflict newer|overwrite|skip]
open-memex migrate --to-v2 [--dry-run]
Flags:
--from / --to scope keys (default: current project → personal)
--dry-run preview without moving anything (--to-v2 previews the legacy
files in place, including per-file conversion plans)
--on-conflict newer (default), overwrite, or skip
--to-v2 convert a legacy my-o-memory data dir to the v2 layout
Always preview with --dry-run first; nothing moves without confirmation.
On Windows, if another program holds the legacy folder open, the backup
rename fails with an actionable message instead of a stack trace — close
the program and re-run.
Examples:
open-memex migrate --dry-run
open-memex migrate --from personal --to project --dry-run`,
mcp: `Start the stdio MCP server (the same server editors connect to).
Usage: open-memex mcp [--print-config vscode|cursor|claude|opencode|visualstudio]
Flags:
--print-config print the MCP client config instead of starting the server
Examples:
open-memex mcp
open-memex mcp --print-config vscode`,
init: `One-command project setup: writes the MCP config for your editor and the
agent memory instructions. Existing files are merged, never clobbered.
Usage: open-memex init [--client vscode|cursor|opencode|visualstudio]
[--instructions personal|project] [--global] [--force] [--yes]
Flags:
--client editor to configure (default: auto-detect all installed editors)
--instructions personal (default, ~/.copilot/copilot-instructions.md) or project
--global write the MCP server entry to the editor's user-level config
(VS Code / Cursor) — init once, works in every project.
For opencode, --global wires the native plugin at user level
(~/.config/opencode/opencode.json) — no per-project init needed.
--force overwrite existing config
--yes accept all defaults, never prompt
Examples:
open-memex init
open-memex init --client vscode --global --yes
open-memex init --client cursor --yes`,
uninstall: `Remove the editor wiring that \`init\` wrote: the MCP server entry,
the opencode native plugin line, and the Copilot instructions section.
Your memories are never touched.
Usage: open-memex uninstall [--client vscode|cursor|opencode|visualstudio]
[--global] [--yes]
Flags:
--client editor to unwire (default: auto-detect all installed editors)
--global only the user-level config; without it, both project-level and
user-level wiring are removed (init may have written either)
--yes accept all defaults, never prompt
Examples:
open-memex uninstall
open-memex uninstall --client vscode --yes`,
config: `Show config, or set a key.
Usage: open-memex config [set <key> <value>]
Examples:
open-memex config
open-memex config set sync.autoPull false`,
capture: `Preview what the keyword-capture watcher would extract from text.
Usage: open-memex capture --dry-run "text"
Example:
open-memex capture --dry-run "remember: we deploy on Fridays"`,
doctor: `Environment health check: Node version, config source, scope resolution,
storage writability, VS Code MCP enablement (settings.json + system policy),
then boots a real MCP server and runs initialize + tools/list against it —
all eleven tools must show up.
Usage: open-memex doctor
Example:
open-memex doctor`,
audit: `Memory health audit (read-only, reports never mutates): near-duplicate
pairs among active memories, stale actives untouched for 90+ days, broken
supersede chains, personal files found inside the repo memory dir, and
index/file drift.
Usage: open-memex audit [--scope project|personal|both]
Flags:
--scope project (default), personal, or both
Example:
open-memex audit --scope both`,
};
function usage(exitCode = 1): never {
console.log(`open-memex CLI
Usage:
open-memex where
open-memex list [--scope project|personal|both] [--type T] [--limit N] [--include active|all]
open-memex search "query" [--scope project|personal|both] [--type T] [--limit N] [--explain]
open-memex add "content" [--scope project|personal] [--type T] [--tag t1,t2]
open-memex supersede <id> "new content" [--type T] [--tag t1,t2]
open-memex status <id> active|deprecated|retracted|archived
open-memex forget <id> [--soft]
open-memex propose <id...> --to project [--local-approve]
open-memex promote <id> [--reject] [--resubmit] [--note "..."] [--by NAME]
open-memex resolve [id-or-path]
open-memex sync-status
open-memex inventory [--format text|json|html] [--scope project|personal|both] [--out <path>] [--allow-personal]
open-memex pull
open-memex push
open-memex export [--scope project|personal|both] [--type T] [--tag t] [--all] [-o <file>]
open-memex import <bundle.tar.gz> [--dry-run]
open-memex distill-agents [--scope project|personal] [--type t1,t2] [--limit N] [-o <file>]
open-memex submit <id...> [--branch <name>] [--base <branch>]
open-memex pr-status [--apply]
open-memex reindex
open-memex audit [--scope project|personal|both]
open-memex scopes
open-memex migrate [--from <key>] [--to <key>]
[--dry-run] [--on-conflict newer|overwrite|skip]
open-memex migrate --to-v2 [--dry-run]
open-memex mcp [--print-config vscode|cursor|claude|opencode|visualstudio]
open-memex init [--client vscode|cursor|opencode|visualstudio]
[--instructions personal|project] [--force] [--yes]
open-memex uninstall [--client vscode|cursor|opencode|visualstudio]
[--global] [--yes]
open-memex config [set <key> <value>]
open-memex capture --dry-run "text"
open-memex doctor
Every command has its own help with description and examples:
open-memex <command> --help (or -h)
One-command project setup: \`open-memex init\` (or \`npx -y open-memex init\`) detects
your installed editors and wires them all — user-level where the editor supports it
(VS Code / Cursor MCP config, opencode native plugin), so one init covers every project;
Visual Studio is included when the project has a solution file (its \`.mcp.json\`
stays solution-level by design). \`--client\` picks a single editor instead, and a
single-editor opencode init writes the per-project plain-MCP \`opencode.jsonc\`.
The Copilot memory instructions default to your user-level
\`~/.copilot/copilot-instructions.md\` (all projects, never checked into a repo);
\`--instructions project\` writes \`.github/copilot-instructions.md\` instead for
teams where everyone uses open-memex.
Existing files are merged, never clobbered; re-running is safe. On a terminal it
confirms the detected editors and asks a couple of settings (keyword capture,
first-turn injection); \`--yes\` accepts all defaults, and non-terminal runs never prompt.
\`open-memex config set <key> <value>\` changes those settings after install.
Once installed globally (\`npm i -g open-memex\`) the \`open-memex\` command is
available directly: \`open-memex mcp\` starts the stdio MCP server (11 tools, a superset of
the opencode plugin's five memory_* tools); \`open-memex mcp --print-config <client>\`
prints a copy-paste MCP client config snippet.
Scope defaults to \`project\` (derived from cwd's git remote or path).
\`user\` is accepted as a deprecated alias of \`personal\`.
\`scopes\` lists every project scope dir with its file count — use it to find
the \`--from\` key when migrating.
\`migrate\` moves memories between scope keys — useful when a repo gains a
git remote after memories were already stored under the cwd-based key.
\`migrate --to-v2\` converts v1 memory files to the v2 format (§19):
user→personal scope rename, epoch→RFC 3339 times, priority→importance,
type: instruction→role split. Always preview with --dry-run first.
Run \`open-memex <command> --help\` for details on a single command.`);
process.exit(exitCode);
}
/**
* Build a saveConfig patch for a (possibly dotted) config key, preserving
* sibling keys already present in the nested object.
*/
function setConfigPath(key: string, value: unknown): Record<string, unknown> {
const parts = key.split(".");
if (parts.length === 1) return { [key]: value };
const cfg = loadConfig() as unknown as Record<string, unknown>;
const top = parts[0]!;
const cur =
cfg[top] && typeof cfg[top] === "object"
? { ...(cfg[top] as Record<string, unknown>) }
: {};
let node: Record<string, unknown> = cur;
for (let i = 1; i < parts.length - 1; i++) {
const seg = parts[i]!;
const nxt =
node[seg] && typeof node[seg] === "object"
? { ...(node[seg] as Record<string, unknown>) }
: {};
node[seg] = nxt;
node = nxt;
}
node[parts[parts.length - 1]!] = value;
return { [top]: cur };
}
function parseFlags(argv: string[]): Record<string, string> {
const out: Record<string, string> = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i]!;
if (a.startsWith("--")) {
const raw = a.slice(2);
const eq = raw.indexOf("=");
if (eq >= 0) {
// D44: accept --key=value as well as --key value (issue #7 — the
// = form was silently misparsed before, dropping the flag).
out[raw.slice(0, eq)] = raw.slice(eq + 1);
continue;
}
const val = argv[i + 1];
if (val !== undefined && !val.startsWith("--")) {
out[raw] = val;
i++;
} else {
out[raw] = "true";
}
}
}
return out;
}
/** CLI `--scope` flag → Scope. `user` is a deprecated alias of `personal`. */
function resolveCliScope(flags: Record<string, string>, project: Scope): Scope {
return flags.scope === "personal" || flags.scope === "user"
? PERSONAL_SCOPE
: project;
}
/** Print a copy-paste MCP client config snippet. Requires a global install
* (`npm i -g open-memex`) so the `open-memex` command is on PATH. */
function printMcpConfig(client: string): never {
const c = client.toLowerCase();
// D17: resolve the server command the same way `init` does.
const mc = resolveMcpCommand();
if (c === "vscode") {
console.log(
JSON.stringify(
{
servers: {
"open-memex": {
type: "stdio",
command: mc.command,
args: mc.args,
cwd: "${workspaceFolder}",
},
},
},
null,
2,
),
);
} else if (c === "cursor") {
console.log(
JSON.stringify(
{
mcpServers: {
"open-memex": { command: mc.command, args: mc.args },
},
},
null,
2,
),
);
} else if (c === "claude") {
console.log(`claude mcp add open-memex -- ${mc.command} ${mc.args.join(" ")}`);
} else if (c === "opencode") {
// D16: opencode as a plain MCP consumer (alternative to the native plugin).
console.log(
JSON.stringify(
{
mcp: {
"open-memex": {
type: "local",
command: [mc.command, ...mc.args],
enabled: true,
},
},
},
null,
2,
),
);
} else if (c === "visualstudio" || c === "visual-studio") {
// D20: Visual Studio (Windows-only) reads solution-level `.mcp.json`.
console.log(
JSON.stringify(
{
servers: {
"open-memex": {
type: "stdio",
command: mc.command,
args: mc.args,
},
},
},
null,
2,
),
);
} else {
console.error(`unknown client "${client}" (vscode|cursor|claude|opencode|visualstudio)`);
process.exit(1);
}
if (!mc.durable) {
console.error(
`\n# note: no durable \`open-memex\` on PATH — snippet uses npx. \`npm i -g open-memex\` for faster startup.`,
);
}
process.exit(0);
}
async function main() {
const [cmd, ...rest] = process.argv.slice(2);
if (!cmd) {
// D50: fresh machine + interactive terminal → offer init instead of bare usage.
// Non-interactive (CI/scripts/pipes) prints usage exactly as before.
const { offerFirstRunInit } = await import("./first-run.ts");
const outcome = await offerFirstRunInit(async () => {
// Re-exec `open-memex init` as a child with inherited stdio instead of
// calling initProject() in-process: the offer's readline already
// consumed stdin's buffer, and a second readline on the same stream
// would see EOF on burst input instead of the user's next answers.
const { spawnSync } = await import("node:child_process");
const r = spawnSync(
process.execPath,
[...process.execArgv, fileURLToPath(import.meta.url), "init"],
{ stdio: "inherit" },
);
if (r.error) throw r.error;
if ((r.status ?? 1) !== 0) process.exit(r.status ?? 1);
});
if (outcome !== "initialized") usage(0);
return;
}
// F28: npm runs lifecycle scripts in the background and swallows their
// stdout (unless --foreground-scripts), so the D50 postinstall pointer
// never reaches the user. Every CLI entry point therefore carries a
// one-line nudge on stderr until init has run or been declined — stderr
// keeps the MCP stdio protocol (stdout) intact, and the .init.json marker
// makes it once-ever. `init`/`uninstall` are excluded (already there /
// nothing to wire). Placed before the --help/--version early returns:
// `-v` is the first thing people run after installing.
if (cmd !== "init" && cmd !== "uninstall") {
const { isFirstRun } = await import("./first-run.ts");
if (isFirstRun()) {
console.error(
"open-memex: first run? `open-memex init` wires it into your editors " +
"(auto-detects VS Code, Cursor, opencode — and Visual Studio for solution projects).",
);
}
}
if (cmd === "--help" || cmd === "-h" || cmd === "help") usage(0);
if (cmd === "--version" || cmd === "-v") {
// package.json sits two levels above this file in both layouts
// (src/cli.ts and dist/cli.js).
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
console.log(`open-memex ${pkg.version}`);
return;
}
// Per-command help: `open-memex <command> --help`. Checked before loadConfig()
// so it works even when the environment is broken.
if (rest.includes("--help") || rest.includes("-h")) {
const h = COMMAND_HELP[cmd];
if (h) {
console.log(`open-memex ${cmd}\n\n${h}`);
return;
}
usage(0);
}
const cfg = loadConfig();
const project = resolveProjectScope(process.cwd());
// `migrate --to-v2` is a pure file operation (V2-DESIGN §19) — it runs
// before the index is opened so it also works where the DB driver
// isn't available.
if (cmd === "migrate" && rest.includes("--to-v2")) {
const flags = parseFlags(rest);
const dryRun = flags["dry-run"] === "true";
let stats;
try {
stats = migrateV2({ dryRun });
} catch (err) {
// D44: actionable message, not a raw syscall stack (issue #7).
console.error(`Error: ${(err as Error).message}`);
process.exit(1);
}
console.log(
`${dryRun ? "DRY RUN: " : ""}scanned ${stats.scanned} files: ` +
`${stats.converted} to convert, ${stats.skippedV2} already v2`,
);
for (const p of stats.plans) {
console.log(`\n${p.fm.id}`);
console.log(
` ${p.fromPath}${p.toPath !== p.fromPath ? `\n → ${p.toPath}` : ""}`,
);
for (const c of p.changes) console.log(` - ${c}`);
}
if (stats.legacyBackup) {
console.log(
dryRun
? `\nDRY RUN: legacy my-o-memory data dir found — it would be merged and backed up at:\n ${stats.legacyBackup}`
: `\nlegacy my-o-memory data dir merged; backup kept at:\n ${stats.legacyBackup}`,
);
}
if (!dryRun && stats.converted > 0) {
console.log(
`\nindex schema will rebuild automatically on next run; ` +
`run \`open-memex reindex\` to verify.`,
);
}
return;
}
// `init` is a pure file operation (§17 adoption path) — no DB needed.
// Interactive when on a TTY (confirms detected editors + settings); --yes skips prompts.
if (cmd === "init") {
const flags = parseFlags(rest);
const { initProject } = await import("./init.ts");
await initProject({
client: flags["client"],
force: flags["force"] === "true",
yes: flags["yes"] === "true",
instructions: flags["instructions"],
global: flags["global"] === "true",
});
return;
}
// `uninstall` reverses `init` — removes the editor wiring; never touches data.
// No DB needed (pure file operation, like init).
if (cmd === "uninstall") {
const flags = parseFlags(rest);
const { uninstallProject } = await import("./init.ts");
await uninstallProject({
client: flags["client"],
global: flags["global"] === "true",
yes: flags["yes"] === "true",
});
return;
}
// `doctor` runs environment health checks — no DB needed (it self-contains).
if (cmd === "doctor") {
const { runDoctor } = await import("./doctor.ts");
const ok = await runDoctor();
if (!ok) process.exitCode = 1;
return;
}
if (cmd === "audit") {
const flags = parseFlags(rest);
syncScope(project.key, "cli");
syncScope(PERSONAL_SCOPE.key, "cli");
const keys =
flags.scope === "personal"
? [PERSONAL_SCOPE.key]
: flags.scope === "both"
? [project.key, PERSONAL_SCOPE.key]
: [project.key];
const { runAudit, STALE_DAYS } = await import("./store/audit.ts");
const r = runAudit(keys);
console.log(`audit: ${r.totalMemories} memories (${keys.length === 2 ? "project+personal" : keys[0] === PERSONAL_SCOPE.key ? "personal" : "project"})`);
console.log(` near-duplicate active pairs: ${r.duplicatePairs.length}`);
for (const p of r.duplicatePairs.slice(0, 10)) {
console.log(` ${p.a} ~ ${p.b} (similarity ${p.score.toFixed(2)}) — consider supersede`);
}
console.log(` stale actives (>= ${STALE_DAYS} days untouched): ${r.stale.length}`);
for (const s of r.stale.slice(0, 10)) {
console.log(` ${s.id} — ${s.ageDays} days`);
}
console.log(` broken supersede chains: ${r.brokenChains.length}`);
for (const b of r.brokenChains) {
console.log(` ${b.id} -> missing ${b.missingTarget}`);
}
console.log(` personal files inside repo memory dir: ${r.personalInRepo.length}`);
for (const f of r.personalInRepo) console.log(` ${f}`);
console.log(
` index drift: ${r.drift.filesNotIndexed} files not indexed, ${r.drift.rowsMissingFiles} rows missing files`,
);
return;
}
// `config` prints the effective configuration (defaults + file). No DB needed.
// `config set <key> <value>` persists a setting to the config file.
if (cmd === "config") {
if (rest[0] === "set") {
const [, key, ...valueParts] = rest;
const { SETTABLE_KEYS, saveConfig, configFilePath } = await import("./config.ts");
const validate = key ? SETTABLE_KEYS[key] : undefined;
if (!validate) {
console.error(
`unknown or unsettable key "${key ?? ""}". Settable keys: ${Object.keys(SETTABLE_KEYS).join(", ")}`,
);
process.exit(1);
}
const raw = valueParts.join(" ");
if (!raw) {
console.error(`usage: open-memex config set <key> <value>`);
process.exit(1);
}
let value: unknown = raw;
try {
value = JSON.parse(raw);
} catch {
/* keep as string */
}
try {
const saved = validate(value);
// Dotted keys (e.g. sync.autoPull) write into the nested config
// object, preserving sibling keys already on disk.
const file = saveConfig(setConfigPath(key!, saved));
console.log(`set ${key} = ${JSON.stringify(saved)} (${file})`);
} catch (err) {
console.error(`invalid value for ${key}: ${(err as Error).message}`);
process.exit(1);
}
return;
}
const cfg = loadConfig();
console.log(JSON.stringify(cfg, null, 2));
return;
}
// `capture --dry-run "text"` previews keyword capture without writing. No DB needed.
if (cmd === "capture") {
if (rest[0] !== "--dry-run") {
console.error(`usage: open-memex capture --dry-run "text"`);
process.exit(1);
}
const cfg = loadConfig();
const { scanKeywords, MIN_CAPTURE_CHARS, MAX_CAPTURE_CHARS } = await import(
"./capture/keywords.ts"
);
const text = rest.slice(1).join(" ");
const { hits, dropped } = scanKeywords(text, cfg);
if (hits.length === 0) {
console.log("no keyword triggers - nothing would be captured.");
}
for (const h of hits) {
const r = redact(h.content, cfg.redactPatterns);
console.log(`- pattern: ${h.pattern}`);
console.log(` scope: ${h.personal ? "personal (forced by pattern)" : "current scope"}`);
console.log(` secret hit: ${r.hadSecret ? `yes (${r.matchedPattern}) — will be masked` : "no"}`);
console.log(
` body: ${r.content.slice(0, 160)}${r.content.length > 160 ? "…" : ""}`,
);
}
// D67: a trigger that matched but produced a fragment is reported, not
// silently dropped - otherwise "记住:这个" just looks broken.
for (const d of dropped) {
console.log(
d.reason === "too-short"
? `! matched but NOT captured: body "${d.content}" is ${d.content.length} chars (floor: ${MIN_CAPTURE_CHARS}). A trigger alone isn't a memory - write the fact: "记住:<fact>".`
: `! matched but NOT captured: body is ${d.content.length} chars (ceiling: ${MAX_CAPTURE_CHARS}) - save it with memory_add instead.`,
);
}
return;
}
// `mcp` starts the stdio MCP server (11 tools; the opencode plugin exposes 5).
// Branched before db() — runMcpServer() does its own init, and stdout must
// stay clean for the MCP protocol.
if (cmd === "mcp") {
const flags = parseFlags(rest);
if (flags["print-config"]) {
printMcpConfig(flags["print-config"] === "true" ? "vscode" : flags["print-config"]);
}
const { runMcpServer } = await import("./mcp.ts");
await runMcpServer();
return;
}
db();
if (cmd === "where") {
const p = paths();
console.log(`root: ${p.root}`);
console.log(`config: ${configSource() ?? "(built-in defaults)"}`);
console.log(`memories: ${p.memories}`);
console.log(`index: ${p.indexDb}`);
console.log(`project: ${project.key}`);
console.log(`personal: ${PERSONAL_SCOPE.key}`);
return;
}
if (cmd === "reindex") {
const a = syncScope(project.key, "cli");
const b = syncScope(PERSONAL_SCOPE.key, "cli");
console.log(
`reindexed. project: +${a.added} ~${a.updated} -${a.removed} (scanned ${a.scanned}), personal: +${b.added} ~${b.updated} -${b.removed} (scanned ${b.scanned})`,
);
return;
}
if (cmd === "list") {
const flags = parseFlags(rest);
// D66: --scope both, on par with the memory_list tool — prints each
// scope's newest under its own header.
const scopes =
flags.scope === "both" ? [PERSONAL_SCOPE, project] : [resolveCliScope(flags, project)];
const include = flags.include === "all" ? "all" : "active";
// D70: the CLI renders the SAME structured inventory the agent sees,
// numbering runs across the whole listing, and a cut list says how
// many entries it dropped — `open-memex list` is the surface a
// sceptical user runs first, so it may not look complete when it isn't.
let seq = 0;
for (const s of scopes) {
syncScope(s.key, "cli");
const hits = list(s.key, {
type: flags.type,
limit: flags.limit ? Number(flags.limit) : undefined,
include,
});
const total = countList(s.key, { type: flags.type, include });
if (scopes.length > 1) {
console.log(
s.key === PERSONAL_SCOPE.key
? "## About you (personal — stays on this machine, applies everywhere)"
: `## This project (${s.projectName || s.key})`,
);
}
if (hits.length === 0) {
console.log(
scopes.length > 1
? "(nothing remembered in this scope yet)"
: `(no memories in ${s.key})`,
);
continue;
}
for (const h of hits) {
console.log(formatInventoryLine(seq++, h, PERSONAL_SCOPE.key));
}
const note = truncationNote(hits.length, total);
if (note) console.log(note);
}
return;
}
if (cmd === "search") {
const query = rest[0];
if (!query || query.startsWith("--")) usage();
const flags = parseFlags(rest.slice(1));
syncScope(project.key, "cli");
syncScope(PERSONAL_SCOPE.key, "cli");
const keys =
flags.scope === "personal" || flags.scope === "user"
? [PERSONAL_SCOPE.key]
: flags.scope === "project"
? [project.key]
: [project.key, PERSONAL_SCOPE.key];
const explain = flags.explain === "true";
const stats = explain
? { ftsQuery: "", candidates: 0, hiddenSuperseded: 0, hiddenExcluded: 0 }
: undefined;
const hits = search(query, {
scopeKeys: keys,
limit: flags.limit ? Number(flags.limit) : undefined,
type: flags.type,
stats,
});
if (explain && stats) {
console.log(`fts: ${stats.ftsQuery || "(empty query)"}`);
console.log(
`candidates: ${stats.candidates}, hidden by lifecycle: ${stats.hiddenSuperseded} superseded, ${stats.hiddenExcluded} retracted/archived`,
);
}
if (hits.length === 0) {
console.log("(no matches)");
return;
}
for (const h of hits) {
const tag = h.scope_key === PERSONAL_SCOPE.key ? "personal" : "project";
const dep = h.status === "deprecated" ? " [deprecated]" : "";
const rev = hitStateLabel(h);
const sc = explain ? ` score=${h.score.toFixed(3)}` : "";
console.log(`[${tag}/${h.type}]${dep}${rev}${sc} ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
}
return;
}
if (cmd === "add") {
const content = rest[0];
if (!content || content.startsWith("--")) usage();
const flags = parseFlags(rest.slice(1));
const s = resolveCliScope(flags, project);
const { content: red, hadSecret, matchedPattern } = redact(content, cfg.redactPatterns);
// Dedup on write (§3.4): identical content is idempotent; near-duplicates
// warn but still save (use `supersede` when this replaces the old one).
// Secrets are masked (first 4 chars kept) and the write proceeds.
const dups = findDuplicates(s.key, red);
if (dups.exact) {
console.log(`already exists: id=${dups.exact.id} (identical content — not duplicated)`);
return;
}
const now = Date.now();
const rfc = msToRfc3339(now);
const fm: Frontmatter = {
id: ulid(),
schema_version: 2,
scope_key: s.key,
scope: s.kind === "project" ? "project" : "personal",
visibility: s.kind === "project" ? "internal" : "private",
project_name: s.projectName,
type: flags.type ?? "fact",
role: "knowledge",
importance: "normal",
status: "active",
tags: flags.tag
? flags.tag
.split(",")
.map((t) => t.trim())
.filter(Boolean)
: [],
// D61: --aliases "phrase one; phrase two" (comma also accepted; use
// ; when an alias itself contains a comma). A bare --aliases with no
// value makes parseFlags yield "true" — treat that as absent. Stored
// only when the install enables capture aliases, redacted like content.
...(typeof flags.aliases === "string" && flags.aliases !== "true" && cfg.captureAliases
? {
aliases: normalizeAliases(
flags.aliases
.split(/[;,]/)
.map((a) => redact(a.trim(), cfg.redactPatterns).content)
.filter(Boolean),
),
}
: {}),
source: "cli",
created_at: rfc,
updated_at: rfc,
supersedes: null,
superseded_by: null,
review_state: "draft",
proposed_by: null,
approved_by: null,
derived_from: null,
review_note: null,
review_history: [],
};
const { filePath } = writeMemoryFile(fm, red);
const mf = readMemoryFile(filePath);
if (mf) upsertFromFile(mf);
console.log(`saved ${fm.id} [${s.kind}] -> ${filePath}`);
if (hadSecret) {
console.log(
`warning: content matched secret pattern (${matchedPattern}); saved with the secret masked (first 4 chars kept).`,
);
}
for (const n of dups.near) {
console.log(
`warning: similar memory exists (score ${n.score.toFixed(2)}): id=${n.id}\n ${n.snippet}\n use \`open-memex supersede ${n.id} "new content"\` if this replaces it.`,
);
}
return;
}
if (cmd === "supersede") {
const id = rest[0];
const content = rest[1];
if (!id || !content || content.startsWith("--")) usage();
const flags = parseFlags(rest.slice(2));
const { content: red, hadSecret, matchedPattern } = redact(content, cfg.redactPatterns);
try {
const { oldMf, newMf } = supersede(id, {
body: red,
type: flags.type,
tags: flags.tag
? flags.tag.split(",").map((t) => t.trim()).filter(Boolean)
: undefined,
aliases:
typeof flags.aliases === "string" && flags.aliases !== "true" && cfg.captureAliases
? normalizeAliases(
flags.aliases
.split(/[;,]/)
.map((a) => redact(a.trim(), cfg.redactPatterns).content)
.filter(Boolean),
)
: undefined,
source: "cli",
});
upsertFromFile(oldMf);
upsertFromFile(newMf);
console.log(`superseded ${oldMf.fm.id} → ${newMf.fm.id}`);
if (hadSecret) {
console.log(
`warning: content matched secret pattern (${matchedPattern}); saved with the secret masked (first 4 chars kept).`,
);
}
} catch (e) {
console.error(`supersede failed: ${(e as Error).message}`);
process.exit(2);
}
return;
}
if (cmd === "status") {
const id = rest[0];
const st = rest[1];
if (!id || !st) usage();
try {
const mf = setStatus(id, st as "active" | "deprecated" | "retracted" | "archived");
upsertFromFile(mf);
console.log(`status ${id} → ${mf.fm.status}`);
} catch (e) {
console.error(`status failed: ${(e as Error).message}`);
process.exit(2);
}
return;
}
if (cmd === "forget") {
const id = rest[0];
if (!id) usage();
// D68: --soft hides (retracts) instead of deleting — same action as
// the memory_forget tool's soft flag. One-way (D64).
if (rest.includes("--soft")) {
const target = db().prepare(`SELECT file_path FROM memories WHERE id = ?`).get(id) as
| { file_path: string }
| undefined;
try {
const mf = setStatus(id, "retracted");
upsertFromFile(mf);
console.log(`hidden ${id} (retracted — out of lists and search; save it again as a new memory to bring it back)`);
// D70: same honesty as the hard delete — a retraction of an in-repo
// memory is a local working-tree edit until it is committed and pushed.
if (target && isInRepoMemoryFile(target.file_path)) {
console.log(
`note: that memory lives in the repo (.ai/open-memex/) — the retraction is only local until you commit and push it; until then the team still sees it.`,
);
}
} catch (e) {
console.error(`hide failed: ${(e as Error).message}`);
process.exit(1);
}
return;
}
const row = db().prepare(`SELECT file_path FROM memories WHERE id = ?`).get(id) as
| { file_path: string }
| undefined;
if (!row) {
console.error("not found");
process.exit(1);
}
// Delete by indexed file_path — location-agnostic (appdata or in-repo, 2B/D24).
// Only ENOENT = success; anything else means the file survives and
// would be re-indexed — fail loudly (P0 review, 2026-10-03).
if (row.file_path) {
try {
fs.unlinkSync(row.file_path);
} catch (e) {
const code = (e as NodeJS.ErrnoException).code;
if (code !== "ENOENT") {
console.error(`could not delete ${row.file_path} (${code ?? "error"}) — memory not forgotten`);
process.exit(1);
}
}
}
deleteFromIndex(id);
console.log(`deleted ${id}`);
return;
}
/** Positional args with flags (and their values) skipped. */
function positionalArgs(argv: string[]): string[] {
const out: string[] = [];
for (let i = 0; i < argv.length; i++) {
const a = argv[i]!;
if (a.startsWith("--")) {
const val = argv[i + 1];
if (val !== undefined && !val.startsWith("--")) i++; // skip the flag's value
} else {
out.push(a);
}
}
return out;
}
if (cmd === "propose") {
const flags = parseFlags(rest);
const ids = positionalArgs(rest);
if (ids.length === 0) usage();
const to = flags.to ?? "project";
if (to !== "project") {
console.error(`propose --to "${to}" is not supported yet (org sharing is Phase 4).`);
process.exit(2);
}
try {
const batch = proposeMemories(ids, { localApprove: flags["local-approve"] === "true" });
for (const { sourceId, result: r } of batch) {
console.log(`proposed ${sourceId} → ${r.id} [${r.reviewState}]`);
}
console.log(`Next: these are drafts in the outbox (appdata) — nothing is in git yet.`);
console.log(` open-memex sync-status`);
console.log(` open-memex submit ${batch.map(({ result: r }) => r.id).join(" ")} # new branch, local commit; prints push + PR commands`);
} catch (e) {
console.error(`propose failed: ${(e as Error).message}`);
process.exit(2);
}
return;
}
if (cmd === "promote") {
const flags = parseFlags(rest);
const id = rest.find((a) => !a.startsWith("--"));
if (!id) usage();
try {
const r = promoteMemory(id, {
reject: flags.reject === "true",
resubmit: flags.resubmit === "true",
note: flags.note,
by: flags.by,
});
console.log(`promote ${r.id}: ${r.from} → ${r.to}`);
if (r.to === "published") {
console.log(` merged to the shared branch? Teammates pick it up with an explicit pull.`);
}
if (r.to === "rejected") {
console.log(` not deleted — the file stays on your branch. Your call:`);
console.log(` accept: close the PR and delete the branch;`);
console.log(` revise: edit the file, then \`open-memex promote ${r.id} --resubmit\`;`);
console.log(` keep: leave it as a [rejected] record.`);
}
if (r.history.length > 0) {
console.log(` history (${r.history.length}):`);
for (const h of formatReviewHistory(r.history)) console.log(h);
}
} catch (e) {
console.error(`promote failed: ${(e as Error).message}`);
process.exit(2);
}
return;
}
if (cmd === "resolve") {
const target = rest.find((a) => !a.startsWith("--"));
try {
if (!target) {
const conflicts = listConflicts();
if (conflicts.length === 0) {
console.log("(no conflicted memory files)");
} else {
for (const c of conflicts) console.log(`conflicted: ${c.id} ${c.filePath}`);
console.log(`\nRun \`open-memex resolve <id>\` to attempt a field-level 3-way merge.`);
}
return;
}
const outcome = resolveConflict(target);
if (!outcome.ok) {
console.error(`cannot auto-resolve ${path.basename(outcome.filePath)} — semantic conflicts need a human:`);
for (const c of outcome.conflicts) {
console.error(` ${c.field}:`);
console.error(` base: ${c.base}`);
console.error(` ours: ${c.ours}`);
console.error(` theirs: ${c.theirs}`);
}
console.error(`Edit the file manually, then \`git add\` it. Nothing was written.`);
process.exit(3);
}
console.log(`resolved ${path.basename(outcome.filePath)}`);
if (outcome.autoMerged.length > 0)
console.log(` auto-merged: ${outcome.autoMerged.join(", ")}`);
console.log(` next: git add ${path.relative(process.cwd(), outcome.filePath)}`);
} catch (e) {
console.error(`resolve failed: ${(e as Error).message}`);
process.exit(2);
}
return;
}
if (cmd === "inventory") {
// D68: refresh both scopes first so the report matches the markdown
// on disk. Default scope: personal + this project.
syncScope(project.key, "cli");
syncScope(PERSONAL_SCOPE.key, "cli");
const flags = parseFlags(rest);
const format = flags.format ?? "text";
if (format !== "text" && format !== "json" && format !== "html") {
console.error(`inventory: unknown --format '${format}' (text|json|html)`);
process.exit(2);
}
const scopeKeys =
flags.scope === "personal"
? [PERSONAL_SCOPE.key]
: flags.scope === "project"
? [project.key]
: [PERSONAL_SCOPE.key, project.key];
const outPath = flags["out"] ?? flags["output"];
const includesPersonal = scopeKeys.includes(PERSONAL_SCOPE.key);
const allowPersonal = flags["allow-personal"] === "true";
try {
const { loadInventory, renderInventoryText, renderInventoryJson } = await import("./inventory.ts");
if (format === "html") {
const { renderInventoryHtml, defaultHtmlOutPath } = await import("./inventory-html.ts");
const target = outPath ?? defaultHtmlOutPath(paths().root);
// D70: one guard for every format (this path had its own copy).
const refusal = inventoryWriteRefusal(target, includesPersonal, allowPersonal);
if (refusal) {
console.error(refusal);
process.exit(2);
}
// D70: only the audit surface needs the hidden rows themselves;
// text/json report counts, so they must not pay to load them.
const data = loadInventory(scopeKeys, { hidden: "entries" });
fs.writeFileSync(target, renderInventoryHtml(data), "utf8");
console.log(`inventory: ${data.total} memories → ${target}`);
console.log(`open it in any browser; the report is read-only.`);
return;
}
const data = loadInventory(scopeKeys);
const rendered = format === "json" ? renderInventoryJson(data) : renderInventoryText(data);
if (outPath) {
// D68/3-C v2: the report contains personal content. Writing it into
// a git working tree would ride the next commit off this machine —
// refuse unless the user explicitly accepts that.
const refusal = inventoryWriteRefusal(outPath, includesPersonal, allowPersonal);
if (refusal) {
console.error(refusal);
process.exit(2);
}
fs.writeFileSync(outPath, rendered, "utf8");
console.log(`inventory: ${data.total} memories → ${outPath}`);
} else {
process.stdout.write(rendered);
}
} catch (e) {
console.error(`inventory failed: ${(e as Error).message}`);
process.exit(2);
}
return;
}
if (cmd === "sync-status") {
syncScope(project.key, "cli");
syncScope(PERSONAL_SCOPE.key, "cli");
try {
console.log(formatSyncStatus(getSyncStatus(), { cap: Number.POSITIVE_INFINITY }));
} catch (e) {
console.error(`sync-status failed: ${(e as Error).message}`);
process.exit(2);
}
return;
}
// §9 / D12: explicit pull — fetch + fast-forward only, never auto-merge.
if (cmd === "pull") {
const { GitProvider } = await import("./providers/git.ts");
const root = projectRoot();
try {
const r = new GitProvider().pull(root);
const stats = syncScope(project.key, "pull");
if (r.fastForwarded) {
console.log(
`pulled ${r.branch} from ${r.remote}: ${r.before.slice(0, 8)} → ${r.after.slice(0, 8)} (fast-forward)`,
);
} else {
console.log(`already up to date: ${r.branch} @ ${r.after.slice(0, 8)}`);
}
console.log(
`index: +${stats.added} ~${stats.updated} -${stats.removed} (scanned ${stats.scanned})`,
);
} catch (e) {
console.error(`pull failed: ${(e as Error).message}`);
process.exit(2);
}
return;
}
// Explicit push — open-memex never pushes on its own (D36).
if (cmd === "push") {
const { GitProvider } = await import("./providers/git.ts");
const root = projectRoot();
try {
const r = new GitProvider().push(root);
syncScope(project.key, "push");
console.log(`pushed ${r.branch} to ${r.remote} @ ${r.head.slice(0, 8)}`);
} catch (e) {
console.error(`push failed: ${(e as Error).message}`);
process.exit(2);
}
return;
}
// §9 / D40: portable export bundle (markdown + manifest).
if (cmd === "export") {
const { exportMemories } = await import("./export.ts");
const flags = parseFlags(rest);
const all = flags["all"] === "true" || flags["a"] === "true" || rest.includes("--all") || rest.includes("-a");
const scopeFlag = flags["scope"] ?? "project";
// parseFlags only handles `--` flags; `-o <file>` is picked up here.
const oIdx = rest.findIndex((a) => a === "-o");
const outFile = flags["o"] ?? flags["output"] ?? (oIdx >= 0 ? rest[oIdx + 1] : undefined);
const scopeKeys =
scopeFlag === "both"
? [project.key, PERSONAL_SCOPE.key]
: scopeFlag === "personal"
? [PERSONAL_SCOPE.key]
: [project.key];
try {
const r = exportMemories({
scopeKeys,
type: flags["type"],
tag: flags["tag"],
includePrivate: all,
outFile,
});
console.log(`exported ${r.exported} memories → ${r.file}`);
if (!r.includePrivate && r.skippedPrivate > 0) {
console.log(`skipped ${r.skippedPrivate} private memories (use --all to include them)`);
}
} catch (e) {
console.error(`export failed: ${(e as Error).message}`);
process.exit(2);
}
return;
}
if (cmd === "import") {
const { importBundle } = await import("./export.ts");
const flags = parseFlags(rest);
const bundle = positionalArgs(rest)[0];
if (!bundle) {
console.error(`usage: open-memex import <bundle.tar.gz> [--dry-run]`);
process.exit(2);
}
const dryRun = flags["dry-run"] === "true";
try {
const r = importBundle(bundle, { projectScopeKey: project.key, dryRun });
if (!dryRun) {
syncScope(project.key, "cli");
syncScope(PERSONAL_SCOPE.key, "cli");
}
console.log(
`${dryRun ? "DRY RUN: " : ""}imported ${r.imported}, skipped ${r.skippedIdentical} identical`,
);
for (const rp of r.rejectedPaths) {
console.log(` rejected: ${rp} (path outside the bundle)`);
}
for (const c of r.skippedConflict) {
console.log(` conflict (kept existing): ${c.id} from ${c.file}`);
}
} catch (e) {
console.error(`import failed: ${(e as Error).message}`);
process.exit(2);
}
return;
}
// Phase 3: distill project memories into a proposed AGENTS.md snippet.
if (cmd === "distill-agents") {
const { distillAgentsMarkdown } = await import("./distill-agents.ts");
const flags = parseFlags(rest);
const scopeFlag = flags["scope"] ?? "project";
const scopeKeys =
scopeFlag === "personal" ? [PERSONAL_SCOPE.key] : [project.key];
const types = flags["type"]
? flags["type"].split(",").map((t) => t.trim()).filter(Boolean)
: undefined;
const limit = flags["limit"] ? parseInt(flags["limit"], 10) : undefined;
const oIdx = rest.findIndex((a) => a === "-o");
const outFile = oIdx >= 0 ? rest[oIdx + 1] : undefined;
try {
const md = distillAgentsMarkdown({ scopeKeys, types, limit });
if (!md) {
console.log("no distillable memories found (decisions, constraints, lessons, gotchas, howtos)");
return;
}
if (outFile) {
fs.writeFileSync(outFile, md, "utf8");
console.log(`wrote proposed AGENTS.md snippet → ${outFile} (review and merge by hand)`);
} else {
console.log(md);
}
} catch (e) {
console.error(`distill-agents failed: ${(e as Error).message}`);
process.exit(2);
}
return;
}
if (cmd === "pr-status") {
const flags = parseFlags(rest);
try {
const st = getPrStatus();
console.log(formatPrStatus(st));
if (flags.apply === "true") {
const applied = applyPrStatus(st);
if (applied.length === 0) {
console.log(`nothing to apply.`);
} else {
for (const a of applied) {
console.log(`applied ${a.memoryId.slice(0, 8)}: ${a.from} → ${a.to} (by ${a.by})`);
}
}
}
} catch (e) {
console.error(`pr-status failed: ${(e as Error).message}`);
process.exit(2);
}
return;
}
if (cmd === "submit") {
const flags = parseFlags(rest);
const ids = positionalArgs(rest);
if (ids.length === 0) usage();
syncScope(project.key, "submit");
try {
const r = submitMemories(ids, { branch: flags.branch, base: flags.base });
for (const s of r.submitted) {
console.log(`submitted ${s.id} → ${path.relative(process.cwd(), s.filePath)} [${s.reviewState}]`);
}
for (const id of r.skippedIdentical) {
console.log(`already on branch: ${id} (identical content — outbox copy removed)`);
}
if (!r.committed) {
console.log(`nothing new to commit — branch ${r.branch} already holds these memories.`);
} else {
console.log(`committed on ${r.branch}.`);
}
console.log(`Next (needs your approval — open-memex never pushes for you):`);
console.log(` ${r.pushCommand}`);
console.log(` ${r.prCommand}`);
} catch (e) {
console.error(`submit failed: ${(e as Error).message}`);
process.exit(2);
}
return;
}
if (cmd === "scopes") {
const { memories } = paths();
if (!fs.existsSync(memories)) {
console.log("(no memories dir yet)");
return;
}
const entries: Array<{ key: string; files: number; marker: string }> = [];
for (const name of fs.readdirSync(memories)) {
const dir = `${memories}/${name}`;
if (!fs.statSync(dir).isDirectory()) continue;
const files = fs.readdirSync(dir).filter((f) => f.endsWith(".md")).length;
const marker =
name === project.key ? " <- current project"
: name === PERSONAL_SCOPE.key ? " <- personal"
: "";
entries.push({ key: name, files, marker });
}
entries.sort((a, b) => b.files - a.files);
if (entries.length === 0) {
console.log("(no scopes with memories yet — add one with `open-memex add`)");
return;
}
for (const e of entries) {
console.log(` ${e.files.toString().padStart(4)} ${e.key}${e.marker}`);
}
return;
}
if (cmd === "migrate") {
const flags = parseFlags(rest);
const toKey = flags.to ?? project.key;
let fromKey = flags.from;
if (!fromKey) {
// Auto-detect: the "legacy" scope is what the key WOULD be if we
// ignored the git remote (pure cwd hash). Only offer it if it differs
// from the current project scope AND has files on disk.
const cwd = resolveCwdScope(process.cwd());
if (cwd.key !== project.key && scopeHasFiles(cwd.key)) {
fromKey = cwd.key;
console.log(`(auto-detected legacy scope: ${fromKey})`);
} else {
console.error(
`no --from given and no legacy cwd-based scope with files detected.\n` +
`current project scope: ${project.key}\n` +
`cwd-only scope: ${cwd.key}`,
);
process.exit(2);
}
}
if (fromKey === toKey) {
console.error(`--from and --to are identical (${fromKey}); nothing to do.`);
process.exit(2);
}
const dryRun = flags["dry-run"] === "true";
const onConflict = flags["on-conflict"] as ConflictStrategy | undefined;
if (onConflict && !["newer", "overwrite", "skip"].includes(onConflict)) {
console.error(`invalid --on-conflict: ${onConflict}`);
process.exit(2);
}
const stats = migrateScope(fromKey, toKey, {
toProjectName: project.projectName,
dryRun,
onConflict,
logger: (m) => console.log(m),
});
console.log(
`${dryRun ? "DRY RUN: " : ""}moved ${stats.moved}, skipped ${stats.skipped}, conflicts ${stats.conflicts}`,
);
return;
}
usage();
}
main()
.catch((err) => {
// D57: lock contention past the busy timeout should read as a transient
// condition, not a stack trace. The memory file is written before the
// index update, so a locked-out write still lands on the next sync.
const code = (err as { code?: string })?.code;
if (code === "SQLITE_BUSY" || code === "SQLITE_LOCKED") {
console.error(
"open-memex: the memory store is busy (another open-memex process is writing). Wait a moment and retry — nothing was lost.",
);
} else {
console.error(err);
}
process.exit(1);
})
.finally(() => {
closeDb();
});