open-memex 0.3.0 → 0.4.0-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -6,8 +6,11 @@ import { syncScope, upsertFromFile, deleteFromIndex } from "./store/sync.js";
6
6
  import { migrateScope, scopeHasFiles } from "./store/migrate.js";
7
7
  import { migrateV2 } from "./store/v2migrate.js";
8
8
  import { findDuplicates, supersede, setStatus } from "./store/lifecycle.js";
9
- import { search, list } from "./retrieve/search.js";
10
- import { writeMemoryFile, readMemoryFile, deleteMemoryFile, ulid, msToRfc3339, } from "./store/markdown.js";
9
+ import { proposeMemories, promoteMemory, listConflicts, resolveConflict, formatReviewHistory } from "./review.js";
10
+ import { getSyncStatus, formatSyncStatus, submitMemories } from "./submit.js";
11
+ import { getPrStatus, formatPrStatus, applyPrStatus } from "./github.js";
12
+ import { search, list, hitStateLabel } from "./retrieve/search.js";
13
+ import { writeMemoryFile, readMemoryFile, ulid, msToRfc3339, } from "./store/markdown.js";
11
14
  import { loadConfig } from "./config.js";
12
15
  import { paths } from "./paths.js";
13
16
  import { redact } from "./redact.js";
@@ -15,6 +18,168 @@ import { resolveMcpCommand } from "./init.js";
15
18
  import fs from "node:fs";
16
19
  import path from "node:path";
17
20
  import { fileURLToPath } from "node:url";
21
+ /** Per-command help, printed by `open-memex <command> --help`.
22
+ AI assistants discover the CLI through --help, so every command needs one. */
23
+ const COMMAND_HELP = {
24
+ where: `Show which project scope the current directory resolves to, and where its data lives.
25
+
26
+ Usage: open-memex where`,
27
+ list: `List memories in a scope, newest first.
28
+
29
+ Usage: open-memex list [--scope project|personal] [--type T] [--limit N]
30
+
31
+ Flags:
32
+ --scope project (default) or personal
33
+ --type filter by memory type
34
+ --limit max results
35
+
36
+ Examples:
37
+ open-memex list
38
+ open-memex list --scope personal --limit 20`,
39
+ search: `Search memories by keyword (BM25 full-text), best matches first.
40
+
41
+ Usage: open-memex search "query" [--scope project|personal|both] [--type T] [--limit N]
42
+
43
+ Flags:
44
+ --scope project (default), personal, or both
45
+ --type filter by memory type
46
+ --limit max results
47
+
48
+ Example:
49
+ open-memex search "deploy checklist" --scope both`,
50
+ add: `Save a fact, preference, decision, or note to local memory.
51
+
52
+ Usage: open-memex add "content" [--scope project|personal] [--type T] [--tag t1,t2]
53
+
54
+ Flags:
55
+ --scope project (default) or personal (personal never leaves this machine)
56
+ --type memory type (default: fact)
57
+ --tag comma-separated tags
58
+
59
+ Example:
60
+ open-memex add "We deploy on Fridays" --scope project --tag process`,
61
+ supersede: `Replace a memory with a newer version. The old one is kept as history.
62
+
63
+ Usage: open-memex supersede <id> "new content" [--type T] [--tag t1,t2]`,
64
+ status: `Change a memory's lifecycle status.
65
+
66
+ Usage: open-memex status <id> active|deprecated|retracted|archived`,
67
+ forget: `Delete a memory by id.
68
+
69
+ Usage: open-memex forget <id>`,
70
+ propose: `Copy personal memories into the project outbox as review drafts.
71
+ The personal originals stay put. Nothing enters git at this step.
72
+
73
+ Usage: open-memex propose <id...> --to project [--local-approve]
74
+
75
+ Flags:
76
+ --to project (required)
77
+ --local-approve mark the copies approved right away (solo-dev shortcut)
78
+
79
+ Example:
80
+ open-memex propose 01ABC 01DEF --to project`,
81
+ promote: `Advance a project memory one step up the review ladder
82
+ (proposed → approved → published), or reject it with a note.
83
+ Every transition is appended to the memory's review_history.
84
+ Rejected memories are never deleted — they can be revised and resubmitted.
85
+
86
+ Usage: open-memex promote <id> [--reject] [--resubmit] [--note "..."] [--by NAME]
87
+
88
+ Flags:
89
+ --reject move back to rejected (requires --note)
90
+ --resubmit move a rejected memory back to proposed
91
+ --note reason for the transition (recorded in review_history)
92
+ --by reviewer name (defaults to the git user)
93
+
94
+ Examples:
95
+ open-memex promote 01ABC --note "verified against the runbook"
96
+ open-memex promote 01ABC --reject --note "outdated after the migration"`,
97
+ resolve: `List conflicted memories, or 3-way-merge one.
98
+
99
+ Usage: open-memex resolve [id-or-path]
100
+
101
+ With no argument, lists conflicts. With an id or file path, shows the
102
+ 3-way merge (base / outbox / repo) so you can resolve it by hand.
103
+ Conflicts are never auto-resolved.`,
104
+ "sync-status": `Show the project memory sync pipeline: when the index last synced
105
+ and what triggered it, drafts waiting in the outbox (appdata), memories in the
106
+ repo awaiting review or published, and repo files not yet committed.
107
+
108
+ Usage: open-memex sync-status`,
109
+ submit: `Move outbox drafts into a git branch for review: creates a branch
110
+ (default mem/sync-*), copies the drafts into the repo memory dir as proposed
111
+ (local-approved copies keep their approval), commits locally, and moves the
112
+ outbox originals out. Prints the push and PR commands — those need your
113
+ explicit approval and are never run automatically.
114
+
115
+ Usage: open-memex submit <id...> [--onto <branch>] [--base <branch>]
116
+
117
+ Flags:
118
+ --onto submit onto the current branch instead of creating mem/sync-*
119
+ --base base branch for the PR suggestion (default: the branch you're on)
120
+
121
+ Example:
122
+ open-memex submit 01ABC 01DEF`,
123
+ "pr-status": `Map the current branch's GitHub PR state back onto review_state:
124
+ merged → published, approval → approved (approved_by = the reviewer),
125
+ changes-requested → suggestion only (never auto-rejects).
126
+ Each memory in the PR is mapped independently; a human rejection is never
127
+ overwritten. Report-only by default.
128
+
129
+ Usage: open-memex pr-status [--apply]
130
+
131
+ Flags:
132
+ --apply write the transitions locally (still never pushes)`,
133
+ reindex: `Rebuild the SQLite index from the markdown files.
134
+
135
+ Usage: open-memex reindex`,
136
+ scopes: `List the known scopes (personal + project).
137
+
138
+ Usage: open-memex scopes`,
139
+ migrate: `Move memories between scopes, or convert a legacy my-o-memory data dir.
140
+
141
+ Usage: open-memex migrate [--from <key>] [--to <key>] [--dry-run] [--on-conflict newer|overwrite|skip]
142
+ open-memex migrate --to-v2 [--dry-run]
143
+
144
+ Flags:
145
+ --from / --to scope keys (default: current project → personal)
146
+ --dry-run preview without moving anything
147
+ --on-conflict newer (default), overwrite, or skip
148
+ --to-v2 convert a legacy my-o-memory data dir to the v2 layout
149
+
150
+ Always preview with --dry-run first; nothing moves without confirmation.`,
151
+ mcp: `Start the stdio MCP server (the same server editors connect to).
152
+
153
+ Usage: open-memex mcp [--print-config vscode|cursor|claude|opencode|visualstudio]
154
+
155
+ Flags:
156
+ --print-config print the MCP client config instead of starting the server`,
157
+ init: `One-command project setup: writes the MCP config for your editor and the
158
+ agent memory instructions. Existing files are merged, never clobbered.
159
+
160
+ Usage: open-memex init [--client vscode|cursor|opencode|visualstudio]
161
+ [--instructions personal|project] [--force] [--yes]
162
+
163
+ Flags:
164
+ --client editor to configure (default: auto-detect)
165
+ --instructions personal (default, ~/.copilot/copilot-instructions.md) or project
166
+ --force overwrite existing config
167
+ --yes accept all defaults, never prompt`,
168
+ config: `Show config, or set a key.
169
+
170
+ Usage: open-memex config [set <key> <value>]
171
+
172
+ Example:
173
+ open-memex config set sync.autoPull false`,
174
+ capture: `Preview what the keyword-capture watcher would extract from text.
175
+
176
+ Usage: open-memex capture --dry-run "text"`,
177
+ doctor: `Environment health check: Node version, config source, scope resolution,
178
+ storage writability, then boots a real MCP server and runs initialize +
179
+ tools/list against it — all eleven tools must show up.
180
+
181
+ Usage: open-memex doctor`,
182
+ };
18
183
  function usage(exitCode = 1) {
19
184
  console.log(`open-memex CLI
20
185
 
@@ -26,6 +191,12 @@ Usage:
26
191
  open-memex supersede <id> "new content" [--type T] [--tag t1,t2]
27
192
  open-memex status <id> active|deprecated|retracted|archived
28
193
  open-memex forget <id>
194
+ open-memex propose <id...> --to project [--local-approve]
195
+ open-memex promote <id> [--reject] [--resubmit] [--note "..."] [--by NAME]
196
+ open-memex resolve [id-or-path]
197
+ open-memex sync-status
198
+ open-memex submit <id...> [--onto <branch>] [--base <branch>]
199
+ open-memex pr-status [--apply]
29
200
  open-memex reindex
30
201
  open-memex scopes
31
202
  open-memex migrate [--from <key>] [--to <key>]
@@ -63,7 +234,9 @@ the \`--from\` key when migrating.
63
234
  git remote after memories were already stored under the cwd-based key.
64
235
  \`migrate --to-v2\` converts v1 memory files to the v2 format (§19):
65
236
  user→personal scope rename, epoch→RFC 3339 times, priority→importance,
66
- type: instruction→role split. Always preview with --dry-run first.`);
237
+ type: instruction→role split. Always preview with --dry-run first.
238
+
239
+ Run \`open-memex <command> --help\` for details on a single command.`);
67
240
  process.exit(exitCode);
68
241
  }
69
242
  function parseFlags(argv) {
@@ -163,6 +336,16 @@ async function main() {
163
336
  console.log(`open-memex ${pkg.version}`);
164
337
  return;
165
338
  }
339
+ // Per-command help: `open-memex <command> --help`. Checked before loadConfig()
340
+ // so it works even when the environment is broken.
341
+ if (rest.includes("--help") || rest.includes("-h")) {
342
+ const h = COMMAND_HELP[cmd];
343
+ if (h) {
344
+ console.log(`open-memex ${cmd}\n\n${h}`);
345
+ return;
346
+ }
347
+ usage(0);
348
+ }
166
349
  const cfg = loadConfig();
167
350
  const project = resolveProjectScope(process.cwd());
168
351
  // `migrate --to-v2` is a pure file operation (V2-DESIGN §19) — it runs
@@ -294,15 +477,15 @@ async function main() {
294
477
  return;
295
478
  }
296
479
  if (cmd === "reindex") {
297
- const a = syncScope(project.key);
298
- const b = syncScope(PERSONAL_SCOPE.key);
480
+ const a = syncScope(project.key, "cli");
481
+ const b = syncScope(PERSONAL_SCOPE.key, "cli");
299
482
  console.log(`reindexed. project: +${a.added} ~${a.updated} -${a.removed} (scanned ${a.scanned}), personal: +${b.added} ~${b.updated} -${b.removed} (scanned ${b.scanned})`);
300
483
  return;
301
484
  }
302
485
  if (cmd === "list") {
303
486
  const flags = parseFlags(rest);
304
487
  const s = resolveCliScope(flags, project);
305
- syncScope(s.key);
488
+ syncScope(s.key, "cli");
306
489
  const hits = list(s.key, {
307
490
  type: flags.type,
308
491
  limit: flags.limit ? Number(flags.limit) : undefined,
@@ -313,7 +496,8 @@ async function main() {
313
496
  }
314
497
  for (const h of hits) {
315
498
  const dep = h.status === "deprecated" ? " [deprecated]" : "";
316
- console.log(`[${h.type}]${dep} ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
499
+ const rev = hitStateLabel(h);
500
+ console.log(`[${h.type}]${dep}${rev} ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
317
501
  }
318
502
  return;
319
503
  }
@@ -322,8 +506,8 @@ async function main() {
322
506
  if (!query || query.startsWith("--"))
323
507
  usage();
324
508
  const flags = parseFlags(rest.slice(1));
325
- syncScope(project.key);
326
- syncScope(PERSONAL_SCOPE.key);
509
+ syncScope(project.key, "cli");
510
+ syncScope(PERSONAL_SCOPE.key, "cli");
327
511
  const keys = flags.scope === "personal" || flags.scope === "user"
328
512
  ? [PERSONAL_SCOPE.key]
329
513
  : flags.scope === "project"
@@ -341,7 +525,8 @@ async function main() {
341
525
  for (const h of hits) {
342
526
  const tag = h.scope_key === PERSONAL_SCOPE.key ? "personal" : "project";
343
527
  const dep = h.status === "deprecated" ? " [deprecated]" : "";
344
- console.log(`[${tag}/${h.type}]${dep} ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
528
+ const rev = hitStateLabel(h);
529
+ console.log(`[${tag}/${h.type}]${dep}${rev} ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
345
530
  }
346
531
  return;
347
532
  }
@@ -384,6 +569,12 @@ async function main() {
384
569
  updated_at: rfc,
385
570
  supersedes: null,
386
571
  superseded_by: null,
572
+ review_state: "draft",
573
+ proposed_by: null,
574
+ approved_by: null,
575
+ derived_from: null,
576
+ review_note: null,
577
+ review_history: [],
387
578
  };
388
579
  const { filePath } = writeMemoryFile(fm, red);
389
580
  const mf = readMemoryFile(filePath);
@@ -447,16 +638,202 @@ async function main() {
447
638
  const id = rest[0];
448
639
  if (!id)
449
640
  usage();
450
- const row = db().prepare(`SELECT scope_key FROM memories WHERE id = ?`).get(id);
641
+ const row = db().prepare(`SELECT file_path FROM memories WHERE id = ?`).get(id);
451
642
  if (!row) {
452
643
  console.error("not found");
453
644
  process.exit(1);
454
645
  }
455
- deleteMemoryFile(row.scope_key, id);
646
+ // Delete by indexed file_path — location-agnostic (appdata or in-repo, 2B/D24).
647
+ if (row.file_path) {
648
+ try {
649
+ fs.unlinkSync(row.file_path);
650
+ }
651
+ catch {
652
+ /* already gone */
653
+ }
654
+ }
456
655
  deleteFromIndex(id);
457
656
  console.log(`deleted ${id}`);
458
657
  return;
459
658
  }
659
+ /** Positional args with flags (and their values) skipped. */
660
+ function positionalArgs(argv) {
661
+ const out = [];
662
+ for (let i = 0; i < argv.length; i++) {
663
+ const a = argv[i];
664
+ if (a.startsWith("--")) {
665
+ const val = argv[i + 1];
666
+ if (val !== undefined && !val.startsWith("--"))
667
+ i++; // skip the flag's value
668
+ }
669
+ else {
670
+ out.push(a);
671
+ }
672
+ }
673
+ return out;
674
+ }
675
+ if (cmd === "propose") {
676
+ const flags = parseFlags(rest);
677
+ const ids = positionalArgs(rest);
678
+ if (ids.length === 0)
679
+ usage();
680
+ const to = flags.to ?? "project";
681
+ if (to !== "project") {
682
+ console.error(`propose --to "${to}" is not supported yet (org sharing is Phase 4).`);
683
+ process.exit(2);
684
+ }
685
+ try {
686
+ const batch = proposeMemories(ids, { localApprove: flags["local-approve"] === "true" });
687
+ for (const { sourceId, result: r } of batch) {
688
+ console.log(`proposed ${sourceId} → ${r.id} [${r.reviewState}]`);
689
+ }
690
+ console.log(`Next: these are drafts in the outbox (appdata) — nothing is in git yet.`);
691
+ console.log(` open-memex sync-status`);
692
+ console.log(` open-memex submit ${batch.map(({ result: r }) => r.id).join(" ")} # new branch, local commit; prints push + PR commands`);
693
+ }
694
+ catch (e) {
695
+ console.error(`propose failed: ${e.message}`);
696
+ process.exit(2);
697
+ }
698
+ return;
699
+ }
700
+ if (cmd === "promote") {
701
+ const flags = parseFlags(rest);
702
+ const id = rest.find((a) => !a.startsWith("--"));
703
+ if (!id)
704
+ usage();
705
+ try {
706
+ const r = promoteMemory(id, {
707
+ reject: flags.reject === "true",
708
+ resubmit: flags.resubmit === "true",
709
+ note: flags.note,
710
+ by: flags.by,
711
+ });
712
+ console.log(`promote ${r.id}: ${r.from} → ${r.to}`);
713
+ if (r.to === "published") {
714
+ console.log(` merged to the shared branch? Teammates pick it up with an explicit pull.`);
715
+ }
716
+ if (r.to === "rejected") {
717
+ console.log(` not deleted — the file stays on your branch. Your call:`);
718
+ console.log(` accept: close the PR and delete the branch;`);
719
+ console.log(` revise: edit the file, then \`open-memex promote ${r.id} --resubmit\`;`);
720
+ console.log(` keep: leave it as a [rejected] record.`);
721
+ }
722
+ if (r.history.length > 0) {
723
+ console.log(` history (${r.history.length}):`);
724
+ for (const h of formatReviewHistory(r.history))
725
+ console.log(h);
726
+ }
727
+ }
728
+ catch (e) {
729
+ console.error(`promote failed: ${e.message}`);
730
+ process.exit(2);
731
+ }
732
+ return;
733
+ }
734
+ if (cmd === "resolve") {
735
+ const target = rest.find((a) => !a.startsWith("--"));
736
+ try {
737
+ if (!target) {
738
+ const conflicts = listConflicts();
739
+ if (conflicts.length === 0) {
740
+ console.log("(no conflicted memory files)");
741
+ }
742
+ else {
743
+ for (const c of conflicts)
744
+ console.log(`conflicted: ${c.id} ${c.filePath}`);
745
+ console.log(`\nRun \`open-memex resolve <id>\` to attempt a field-level 3-way merge.`);
746
+ }
747
+ return;
748
+ }
749
+ const outcome = resolveConflict(target);
750
+ if (!outcome.ok) {
751
+ console.error(`cannot auto-resolve ${path.basename(outcome.filePath)} — semantic conflicts need a human:`);
752
+ for (const c of outcome.conflicts) {
753
+ console.error(` ${c.field}:`);
754
+ console.error(` base: ${c.base}`);
755
+ console.error(` ours: ${c.ours}`);
756
+ console.error(` theirs: ${c.theirs}`);
757
+ }
758
+ console.error(`Edit the file manually, then \`git add\` it. Nothing was written.`);
759
+ process.exit(3);
760
+ }
761
+ console.log(`resolved ${path.basename(outcome.filePath)}`);
762
+ if (outcome.autoMerged.length > 0)
763
+ console.log(` auto-merged: ${outcome.autoMerged.join(", ")}`);
764
+ console.log(` next: git add ${path.relative(process.cwd(), outcome.filePath)}`);
765
+ }
766
+ catch (e) {
767
+ console.error(`resolve failed: ${e.message}`);
768
+ process.exit(2);
769
+ }
770
+ return;
771
+ }
772
+ if (cmd === "sync-status") {
773
+ syncScope(project.key, "cli");
774
+ syncScope(PERSONAL_SCOPE.key, "cli");
775
+ try {
776
+ console.log(formatSyncStatus(getSyncStatus()));
777
+ }
778
+ catch (e) {
779
+ console.error(`sync-status failed: ${e.message}`);
780
+ process.exit(2);
781
+ }
782
+ return;
783
+ }
784
+ if (cmd === "pr-status") {
785
+ const flags = parseFlags(rest);
786
+ try {
787
+ const st = getPrStatus();
788
+ console.log(formatPrStatus(st));
789
+ if (flags.apply === "true") {
790
+ const applied = applyPrStatus(st);
791
+ if (applied.length === 0) {
792
+ console.log(`nothing to apply.`);
793
+ }
794
+ else {
795
+ for (const a of applied) {
796
+ console.log(`applied ${a.memoryId.slice(0, 8)}: ${a.from} → ${a.to} (by ${a.by})`);
797
+ }
798
+ }
799
+ }
800
+ }
801
+ catch (e) {
802
+ console.error(`pr-status failed: ${e.message}`);
803
+ process.exit(2);
804
+ }
805
+ return;
806
+ }
807
+ if (cmd === "submit") {
808
+ const flags = parseFlags(rest);
809
+ const ids = positionalArgs(rest);
810
+ if (ids.length === 0)
811
+ usage();
812
+ syncScope(project.key, "submit");
813
+ try {
814
+ const r = submitMemories(ids, { onto: flags.onto, base: flags.base });
815
+ for (const s of r.submitted) {
816
+ console.log(`submitted ${s.id} → ${path.relative(process.cwd(), s.filePath)} [${s.reviewState}]`);
817
+ }
818
+ for (const id of r.skippedIdentical) {
819
+ console.log(`already on branch: ${id} (identical content — outbox copy removed)`);
820
+ }
821
+ if (!r.committed) {
822
+ console.log(`nothing new to commit — branch ${r.branch} already holds these memories.`);
823
+ }
824
+ else {
825
+ console.log(`committed on ${r.branch}.`);
826
+ }
827
+ console.log(`Next (needs your approval — open-memex never pushes for you):`);
828
+ console.log(` ${r.pushCommand}`);
829
+ console.log(` ${r.prCommand}`);
830
+ }
831
+ catch (e) {
832
+ console.error(`submit failed: ${e.message}`);
833
+ process.exit(2);
834
+ }
835
+ return;
836
+ }
460
837
  if (cmd === "scopes") {
461
838
  const { memories } = paths();
462
839
  if (!fs.existsSync(memories)) {
package/dist/config.js CHANGED
@@ -33,6 +33,7 @@ export const DEFAULT_CONFIG = {
33
33
  // Add only your own extra patterns here.
34
34
  redactPatterns: [],
35
35
  logLevel: "info",
36
+ memoryDir: ".ai/open-memex",
36
37
  };
37
38
  function stripJsonComments(raw) {
38
39
  // Line comments
@@ -63,12 +64,21 @@ function toNonNegInt(v) {
63
64
  throw new Error("must be a non-negative integer");
64
65
  return n;
65
66
  }
67
+ function toRelativeDir(v) {
68
+ if (typeof v !== "string" || !v.trim())
69
+ throw new Error("must be a non-empty relative directory");
70
+ const t = v.trim().replace(/\\/g, "/").replace(/\/+$/, "");
71
+ if (!t || path.isAbsolute(t) || t.split("/").includes(".."))
72
+ throw new Error('must be a relative path without ".." (e.g. ".ai/open-memex")');
73
+ return t;
74
+ }
66
75
  /** Keys users may change via `open-memex config set <key> <value>`, with validators. */
67
76
  export const SETTABLE_KEYS = {
68
77
  maxProjectMemories: toNonNegInt,
69
78
  maxProfileItems: toNonNegInt,
70
79
  injectOnFirstTurn: toBool,
71
80
  keywordCaptureEnabled: toBool,
81
+ memoryDir: toRelativeDir,
72
82
  logLevel: (v) => {
73
83
  if (v !== "info" && v !== "debug")
74
84
  throw new Error('must be "info" or "debug"');