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/src/cli.ts CHANGED
@@ -6,11 +6,13 @@ import { syncScope, upsertFromFile, deleteFromIndex } from "./store/sync.ts";
6
6
  import { migrateScope, scopeHasFiles, type ConflictStrategy } from "./store/migrate.ts";
7
7
  import { migrateV2 } from "./store/v2migrate.ts";
8
8
  import { findDuplicates, supersede, setStatus } from "./store/lifecycle.ts";
9
- import { search, list } from "./retrieve/search.ts";
9
+ import { proposeMemories, promoteMemory, listConflicts, resolveConflict, formatReviewHistory } from "./review.ts";
10
+ import { getSyncStatus, formatSyncStatus, submitMemories } from "./submit.ts";
11
+ import { getPrStatus, formatPrStatus, applyPrStatus } from "./github.ts";
12
+ import { search, list, hitStateLabel } from "./retrieve/search.ts";
10
13
  import {
11
14
  writeMemoryFile,
12
15
  readMemoryFile,
13
- deleteMemoryFile,
14
16
  ulid,
15
17
  msToRfc3339,
16
18
  type Frontmatter,
@@ -23,6 +25,189 @@ import fs from "node:fs";
23
25
  import path from "node:path";
24
26
  import { fileURLToPath } from "node:url";
25
27
 
28
+ /** Per-command help, printed by `open-memex <command> --help`.
29
+ AI assistants discover the CLI through --help, so every command needs one. */
30
+ const COMMAND_HELP: Record<string, string> = {
31
+ where: `Show which project scope the current directory resolves to, and where its data lives.
32
+
33
+ Usage: open-memex where`,
34
+
35
+ list: `List memories in a scope, newest first.
36
+
37
+ Usage: open-memex list [--scope project|personal] [--type T] [--limit N]
38
+
39
+ Flags:
40
+ --scope project (default) or personal
41
+ --type filter by memory type
42
+ --limit max results
43
+
44
+ Examples:
45
+ open-memex list
46
+ open-memex list --scope personal --limit 20`,
47
+
48
+ search: `Search memories by keyword (BM25 full-text), best matches first.
49
+
50
+ Usage: open-memex search "query" [--scope project|personal|both] [--type T] [--limit N]
51
+
52
+ Flags:
53
+ --scope project (default), personal, or both
54
+ --type filter by memory type
55
+ --limit max results
56
+
57
+ Example:
58
+ open-memex search "deploy checklist" --scope both`,
59
+
60
+ add: `Save a fact, preference, decision, or note to local memory.
61
+
62
+ Usage: open-memex add "content" [--scope project|personal] [--type T] [--tag t1,t2]
63
+
64
+ Flags:
65
+ --scope project (default) or personal (personal never leaves this machine)
66
+ --type memory type (default: fact)
67
+ --tag comma-separated tags
68
+
69
+ Example:
70
+ open-memex add "We deploy on Fridays" --scope project --tag process`,
71
+
72
+ supersede: `Replace a memory with a newer version. The old one is kept as history.
73
+
74
+ Usage: open-memex supersede <id> "new content" [--type T] [--tag t1,t2]`,
75
+
76
+ status: `Change a memory's lifecycle status.
77
+
78
+ Usage: open-memex status <id> active|deprecated|retracted|archived`,
79
+
80
+ forget: `Delete a memory by id.
81
+
82
+ Usage: open-memex forget <id>`,
83
+
84
+ propose: `Copy personal memories into the project outbox as review drafts.
85
+ The personal originals stay put. Nothing enters git at this step.
86
+
87
+ Usage: open-memex propose <id...> --to project [--local-approve]
88
+
89
+ Flags:
90
+ --to project (required)
91
+ --local-approve mark the copies approved right away (solo-dev shortcut)
92
+
93
+ Example:
94
+ open-memex propose 01ABC 01DEF --to project`,
95
+
96
+ promote: `Advance a project memory one step up the review ladder
97
+ (proposed → approved → published), or reject it with a note.
98
+ Every transition is appended to the memory's review_history.
99
+ Rejected memories are never deleted — they can be revised and resubmitted.
100
+
101
+ Usage: open-memex promote <id> [--reject] [--resubmit] [--note "..."] [--by NAME]
102
+
103
+ Flags:
104
+ --reject move back to rejected (requires --note)
105
+ --resubmit move a rejected memory back to proposed
106
+ --note reason for the transition (recorded in review_history)
107
+ --by reviewer name (defaults to the git user)
108
+
109
+ Examples:
110
+ open-memex promote 01ABC --note "verified against the runbook"
111
+ open-memex promote 01ABC --reject --note "outdated after the migration"`,
112
+
113
+ resolve: `List conflicted memories, or 3-way-merge one.
114
+
115
+ Usage: open-memex resolve [id-or-path]
116
+
117
+ With no argument, lists conflicts. With an id or file path, shows the
118
+ 3-way merge (base / outbox / repo) so you can resolve it by hand.
119
+ Conflicts are never auto-resolved.`,
120
+
121
+ "sync-status": `Show the project memory sync pipeline: when the index last synced
122
+ and what triggered it, drafts waiting in the outbox (appdata), memories in the
123
+ repo awaiting review or published, and repo files not yet committed.
124
+
125
+ Usage: open-memex sync-status`,
126
+
127
+ submit: `Move outbox drafts into a git branch for review: creates a branch
128
+ (default mem/sync-*), copies the drafts into the repo memory dir as proposed
129
+ (local-approved copies keep their approval), commits locally, and moves the
130
+ outbox originals out. Prints the push and PR commands — those need your
131
+ explicit approval and are never run automatically.
132
+
133
+ Usage: open-memex submit <id...> [--onto <branch>] [--base <branch>]
134
+
135
+ Flags:
136
+ --onto submit onto the current branch instead of creating mem/sync-*
137
+ --base base branch for the PR suggestion (default: the branch you're on)
138
+
139
+ Example:
140
+ open-memex submit 01ABC 01DEF`,
141
+
142
+ "pr-status": `Map the current branch's GitHub PR state back onto review_state:
143
+ merged → published, approval → approved (approved_by = the reviewer),
144
+ changes-requested → suggestion only (never auto-rejects).
145
+ Each memory in the PR is mapped independently; a human rejection is never
146
+ overwritten. Report-only by default.
147
+
148
+ Usage: open-memex pr-status [--apply]
149
+
150
+ Flags:
151
+ --apply write the transitions locally (still never pushes)`,
152
+
153
+ reindex: `Rebuild the SQLite index from the markdown files.
154
+
155
+ Usage: open-memex reindex`,
156
+
157
+ scopes: `List the known scopes (personal + project).
158
+
159
+ Usage: open-memex scopes`,
160
+
161
+ migrate: `Move memories between scopes, or convert a legacy my-o-memory data dir.
162
+
163
+ Usage: open-memex migrate [--from <key>] [--to <key>] [--dry-run] [--on-conflict newer|overwrite|skip]
164
+ open-memex migrate --to-v2 [--dry-run]
165
+
166
+ Flags:
167
+ --from / --to scope keys (default: current project → personal)
168
+ --dry-run preview without moving anything
169
+ --on-conflict newer (default), overwrite, or skip
170
+ --to-v2 convert a legacy my-o-memory data dir to the v2 layout
171
+
172
+ Always preview with --dry-run first; nothing moves without confirmation.`,
173
+
174
+ mcp: `Start the stdio MCP server (the same server editors connect to).
175
+
176
+ Usage: open-memex mcp [--print-config vscode|cursor|claude|opencode|visualstudio]
177
+
178
+ Flags:
179
+ --print-config print the MCP client config instead of starting the server`,
180
+
181
+ init: `One-command project setup: writes the MCP config for your editor and the
182
+ agent memory instructions. Existing files are merged, never clobbered.
183
+
184
+ Usage: open-memex init [--client vscode|cursor|opencode|visualstudio]
185
+ [--instructions personal|project] [--force] [--yes]
186
+
187
+ Flags:
188
+ --client editor to configure (default: auto-detect)
189
+ --instructions personal (default, ~/.copilot/copilot-instructions.md) or project
190
+ --force overwrite existing config
191
+ --yes accept all defaults, never prompt`,
192
+
193
+ config: `Show config, or set a key.
194
+
195
+ Usage: open-memex config [set <key> <value>]
196
+
197
+ Example:
198
+ open-memex config set sync.autoPull false`,
199
+
200
+ capture: `Preview what the keyword-capture watcher would extract from text.
201
+
202
+ Usage: open-memex capture --dry-run "text"`,
203
+
204
+ doctor: `Environment health check: Node version, config source, scope resolution,
205
+ storage writability, then boots a real MCP server and runs initialize +
206
+ tools/list against it — all eleven tools must show up.
207
+
208
+ Usage: open-memex doctor`,
209
+ };
210
+
26
211
  function usage(exitCode = 1): never {
27
212
  console.log(`open-memex CLI
28
213
 
@@ -34,6 +219,12 @@ Usage:
34
219
  open-memex supersede <id> "new content" [--type T] [--tag t1,t2]
35
220
  open-memex status <id> active|deprecated|retracted|archived
36
221
  open-memex forget <id>
222
+ open-memex propose <id...> --to project [--local-approve]
223
+ open-memex promote <id> [--reject] [--resubmit] [--note "..."] [--by NAME]
224
+ open-memex resolve [id-or-path]
225
+ open-memex sync-status
226
+ open-memex submit <id...> [--onto <branch>] [--base <branch>]
227
+ open-memex pr-status [--apply]
37
228
  open-memex reindex
38
229
  open-memex scopes
39
230
  open-memex migrate [--from <key>] [--to <key>]
@@ -71,7 +262,9 @@ the \`--from\` key when migrating.
71
262
  git remote after memories were already stored under the cwd-based key.
72
263
  \`migrate --to-v2\` converts v1 memory files to the v2 format (§19):
73
264
  user→personal scope rename, epoch→RFC 3339 times, priority→importance,
74
- type: instruction→role split. Always preview with --dry-run first.`);
265
+ type: instruction→role split. Always preview with --dry-run first.
266
+
267
+ Run \`open-memex <command> --help\` for details on a single command.`);
75
268
  process.exit(exitCode);
76
269
  }
77
270
 
@@ -196,6 +389,17 @@ async function main() {
196
389
  return;
197
390
  }
198
391
 
392
+ // Per-command help: `open-memex <command> --help`. Checked before loadConfig()
393
+ // so it works even when the environment is broken.
394
+ if (rest.includes("--help") || rest.includes("-h")) {
395
+ const h = COMMAND_HELP[cmd];
396
+ if (h) {
397
+ console.log(`open-memex ${cmd}\n\n${h}`);
398
+ return;
399
+ }
400
+ usage(0);
401
+ }
402
+
199
403
  const cfg = loadConfig();
200
404
  const project = resolveProjectScope(process.cwd());
201
405
 
@@ -344,8 +548,8 @@ async function main() {
344
548
  }
345
549
 
346
550
  if (cmd === "reindex") {
347
- const a = syncScope(project.key);
348
- const b = syncScope(PERSONAL_SCOPE.key);
551
+ const a = syncScope(project.key, "cli");
552
+ const b = syncScope(PERSONAL_SCOPE.key, "cli");
349
553
  console.log(
350
554
  `reindexed. project: +${a.added} ~${a.updated} -${a.removed} (scanned ${a.scanned}), personal: +${b.added} ~${b.updated} -${b.removed} (scanned ${b.scanned})`,
351
555
  );
@@ -355,7 +559,7 @@ async function main() {
355
559
  if (cmd === "list") {
356
560
  const flags = parseFlags(rest);
357
561
  const s = resolveCliScope(flags, project);
358
- syncScope(s.key);
562
+ syncScope(s.key, "cli");
359
563
  const hits = list(s.key, {
360
564
  type: flags.type,
361
565
  limit: flags.limit ? Number(flags.limit) : undefined,
@@ -366,7 +570,8 @@ async function main() {
366
570
  }
367
571
  for (const h of hits) {
368
572
  const dep = h.status === "deprecated" ? " [deprecated]" : "";
369
- console.log(`[${h.type}]${dep} ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
573
+ const rev = hitStateLabel(h);
574
+ console.log(`[${h.type}]${dep}${rev} ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
370
575
  }
371
576
  return;
372
577
  }
@@ -375,8 +580,8 @@ async function main() {
375
580
  const query = rest[0];
376
581
  if (!query || query.startsWith("--")) usage();
377
582
  const flags = parseFlags(rest.slice(1));
378
- syncScope(project.key);
379
- syncScope(PERSONAL_SCOPE.key);
583
+ syncScope(project.key, "cli");
584
+ syncScope(PERSONAL_SCOPE.key, "cli");
380
585
  const keys =
381
586
  flags.scope === "personal" || flags.scope === "user"
382
587
  ? [PERSONAL_SCOPE.key]
@@ -395,7 +600,8 @@ async function main() {
395
600
  for (const h of hits) {
396
601
  const tag = h.scope_key === PERSONAL_SCOPE.key ? "personal" : "project";
397
602
  const dep = h.status === "deprecated" ? " [deprecated]" : "";
398
- console.log(`[${tag}/${h.type}]${dep} ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
603
+ const rev = hitStateLabel(h);
604
+ console.log(`[${tag}/${h.type}]${dep}${rev} ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
399
605
  }
400
606
  return;
401
607
  }
@@ -438,6 +644,12 @@ async function main() {
438
644
  updated_at: rfc,
439
645
  supersedes: null,
440
646
  superseded_by: null,
647
+ review_state: "draft",
648
+ proposed_by: null,
649
+ approved_by: null,
650
+ derived_from: null,
651
+ review_note: null,
652
+ review_history: [],
441
653
  };
442
654
  const { filePath } = writeMemoryFile(fm, red);
443
655
  const mf = readMemoryFile(filePath);
@@ -504,19 +716,195 @@ async function main() {
504
716
  if (cmd === "forget") {
505
717
  const id = rest[0];
506
718
  if (!id) usage();
507
- const row = db().prepare(`SELECT scope_key FROM memories WHERE id = ?`).get(id) as
508
- | { scope_key: string }
719
+ const row = db().prepare(`SELECT file_path FROM memories WHERE id = ?`).get(id) as
720
+ | { file_path: string }
509
721
  | undefined;
510
722
  if (!row) {
511
723
  console.error("not found");
512
724
  process.exit(1);
513
725
  }
514
- deleteMemoryFile(row.scope_key, id);
726
+ // Delete by indexed file_path — location-agnostic (appdata or in-repo, 2B/D24).
727
+ if (row.file_path) {
728
+ try {
729
+ fs.unlinkSync(row.file_path);
730
+ } catch {
731
+ /* already gone */
732
+ }
733
+ }
515
734
  deleteFromIndex(id);
516
735
  console.log(`deleted ${id}`);
517
736
  return;
518
737
  }
519
738
 
739
+ /** Positional args with flags (and their values) skipped. */
740
+ function positionalArgs(argv: string[]): string[] {
741
+ const out: string[] = [];
742
+ for (let i = 0; i < argv.length; i++) {
743
+ const a = argv[i]!;
744
+ if (a.startsWith("--")) {
745
+ const val = argv[i + 1];
746
+ if (val !== undefined && !val.startsWith("--")) i++; // skip the flag's value
747
+ } else {
748
+ out.push(a);
749
+ }
750
+ }
751
+ return out;
752
+ }
753
+
754
+ if (cmd === "propose") {
755
+ const flags = parseFlags(rest);
756
+ const ids = positionalArgs(rest);
757
+ if (ids.length === 0) usage();
758
+ const to = flags.to ?? "project";
759
+ if (to !== "project") {
760
+ console.error(`propose --to "${to}" is not supported yet (org sharing is Phase 4).`);
761
+ process.exit(2);
762
+ }
763
+ try {
764
+ const batch = proposeMemories(ids, { localApprove: flags["local-approve"] === "true" });
765
+ for (const { sourceId, result: r } of batch) {
766
+ console.log(`proposed ${sourceId} → ${r.id} [${r.reviewState}]`);
767
+ }
768
+ console.log(`Next: these are drafts in the outbox (appdata) — nothing is in git yet.`);
769
+ console.log(` open-memex sync-status`);
770
+ console.log(` open-memex submit ${batch.map(({ result: r }) => r.id).join(" ")} # new branch, local commit; prints push + PR commands`);
771
+ } catch (e) {
772
+ console.error(`propose failed: ${(e as Error).message}`);
773
+ process.exit(2);
774
+ }
775
+ return;
776
+ }
777
+
778
+ if (cmd === "promote") {
779
+ const flags = parseFlags(rest);
780
+ const id = rest.find((a) => !a.startsWith("--"));
781
+ if (!id) usage();
782
+ try {
783
+ const r = promoteMemory(id, {
784
+ reject: flags.reject === "true",
785
+ resubmit: flags.resubmit === "true",
786
+ note: flags.note,
787
+ by: flags.by,
788
+ });
789
+ console.log(`promote ${r.id}: ${r.from} → ${r.to}`);
790
+ if (r.to === "published") {
791
+ console.log(` merged to the shared branch? Teammates pick it up with an explicit pull.`);
792
+ }
793
+ if (r.to === "rejected") {
794
+ console.log(` not deleted — the file stays on your branch. Your call:`);
795
+ console.log(` accept: close the PR and delete the branch;`);
796
+ console.log(` revise: edit the file, then \`open-memex promote ${r.id} --resubmit\`;`);
797
+ console.log(` keep: leave it as a [rejected] record.`);
798
+ }
799
+ if (r.history.length > 0) {
800
+ console.log(` history (${r.history.length}):`);
801
+ for (const h of formatReviewHistory(r.history)) console.log(h);
802
+ }
803
+ } catch (e) {
804
+ console.error(`promote failed: ${(e as Error).message}`);
805
+ process.exit(2);
806
+ }
807
+ return;
808
+ }
809
+
810
+ if (cmd === "resolve") {
811
+ const target = rest.find((a) => !a.startsWith("--"));
812
+ try {
813
+ if (!target) {
814
+ const conflicts = listConflicts();
815
+ if (conflicts.length === 0) {
816
+ console.log("(no conflicted memory files)");
817
+ } else {
818
+ for (const c of conflicts) console.log(`conflicted: ${c.id} ${c.filePath}`);
819
+ console.log(`\nRun \`open-memex resolve <id>\` to attempt a field-level 3-way merge.`);
820
+ }
821
+ return;
822
+ }
823
+ const outcome = resolveConflict(target);
824
+ if (!outcome.ok) {
825
+ console.error(`cannot auto-resolve ${path.basename(outcome.filePath)} — semantic conflicts need a human:`);
826
+ for (const c of outcome.conflicts) {
827
+ console.error(` ${c.field}:`);
828
+ console.error(` base: ${c.base}`);
829
+ console.error(` ours: ${c.ours}`);
830
+ console.error(` theirs: ${c.theirs}`);
831
+ }
832
+ console.error(`Edit the file manually, then \`git add\` it. Nothing was written.`);
833
+ process.exit(3);
834
+ }
835
+ console.log(`resolved ${path.basename(outcome.filePath)}`);
836
+ if (outcome.autoMerged.length > 0)
837
+ console.log(` auto-merged: ${outcome.autoMerged.join(", ")}`);
838
+ console.log(` next: git add ${path.relative(process.cwd(), outcome.filePath)}`);
839
+ } catch (e) {
840
+ console.error(`resolve failed: ${(e as Error).message}`);
841
+ process.exit(2);
842
+ }
843
+ return;
844
+ }
845
+
846
+ if (cmd === "sync-status") {
847
+ syncScope(project.key, "cli");
848
+ syncScope(PERSONAL_SCOPE.key, "cli");
849
+ try {
850
+ console.log(formatSyncStatus(getSyncStatus()));
851
+ } catch (e) {
852
+ console.error(`sync-status failed: ${(e as Error).message}`);
853
+ process.exit(2);
854
+ }
855
+ return;
856
+ }
857
+
858
+ if (cmd === "pr-status") {
859
+ const flags = parseFlags(rest);
860
+ try {
861
+ const st = getPrStatus();
862
+ console.log(formatPrStatus(st));
863
+ if (flags.apply === "true") {
864
+ const applied = applyPrStatus(st);
865
+ if (applied.length === 0) {
866
+ console.log(`nothing to apply.`);
867
+ } else {
868
+ for (const a of applied) {
869
+ console.log(`applied ${a.memoryId.slice(0, 8)}: ${a.from} → ${a.to} (by ${a.by})`);
870
+ }
871
+ }
872
+ }
873
+ } catch (e) {
874
+ console.error(`pr-status failed: ${(e as Error).message}`);
875
+ process.exit(2);
876
+ }
877
+ return;
878
+ }
879
+
880
+ if (cmd === "submit") {
881
+ const flags = parseFlags(rest);
882
+ const ids = positionalArgs(rest);
883
+ if (ids.length === 0) usage();
884
+ syncScope(project.key, "submit");
885
+ try {
886
+ const r = submitMemories(ids, { onto: flags.onto, base: flags.base });
887
+ for (const s of r.submitted) {
888
+ console.log(`submitted ${s.id} → ${path.relative(process.cwd(), s.filePath)} [${s.reviewState}]`);
889
+ }
890
+ for (const id of r.skippedIdentical) {
891
+ console.log(`already on branch: ${id} (identical content — outbox copy removed)`);
892
+ }
893
+ if (!r.committed) {
894
+ console.log(`nothing new to commit — branch ${r.branch} already holds these memories.`);
895
+ } else {
896
+ console.log(`committed on ${r.branch}.`);
897
+ }
898
+ console.log(`Next (needs your approval — open-memex never pushes for you):`);
899
+ console.log(` ${r.pushCommand}`);
900
+ console.log(` ${r.prCommand}`);
901
+ } catch (e) {
902
+ console.error(`submit failed: ${(e as Error).message}`);
903
+ process.exit(2);
904
+ }
905
+ return;
906
+ }
907
+
520
908
  if (cmd === "scopes") {
521
909
  const { memories } = paths();
522
910
  if (!fs.existsSync(memories)) {
package/src/config.ts CHANGED
@@ -13,6 +13,12 @@ export interface MyOMemoryConfig {
13
13
  keywordPersonalPatterns: string[];
14
14
  redactPatterns: string[];
15
15
  logLevel: "debug" | "info" | "warn" | "error";
16
+ /**
17
+ * In-repo project-memory directory, relative to the project root (2B/D23).
18
+ * Default `.ai/open-memex/`. Must stay relative and `..`-free so project
19
+ * memories can never escape the repo.
20
+ */
21
+ memoryDir: string;
16
22
  }
17
23
 
18
24
  export const DEFAULT_CONFIG: MyOMemoryConfig = {
@@ -47,6 +53,7 @@ export const DEFAULT_CONFIG: MyOMemoryConfig = {
47
53
  // Add only your own extra patterns here.
48
54
  redactPatterns: [],
49
55
  logLevel: "info",
56
+ memoryDir: ".ai/open-memex",
50
57
  };
51
58
 
52
59
  function stripJsonComments(raw: string): string {
@@ -80,12 +87,21 @@ function toNonNegInt(v: unknown): number {
80
87
  return n;
81
88
  }
82
89
 
90
+ function toRelativeDir(v: unknown): string {
91
+ if (typeof v !== "string" || !v.trim()) throw new Error("must be a non-empty relative directory");
92
+ const t = v.trim().replace(/\\/g, "/").replace(/\/+$/, "");
93
+ if (!t || path.isAbsolute(t) || t.split("/").includes(".."))
94
+ throw new Error('must be a relative path without ".." (e.g. ".ai/open-memex")');
95
+ return t;
96
+ }
97
+
83
98
  /** Keys users may change via `open-memex config set <key> <value>`, with validators. */
84
99
  export const SETTABLE_KEYS: Record<string, (v: unknown) => unknown> = {
85
100
  maxProjectMemories: toNonNegInt,
86
101
  maxProfileItems: toNonNegInt,
87
102
  injectOnFirstTurn: toBool,
88
103
  keywordCaptureEnabled: toBool,
104
+ memoryDir: toRelativeDir,
89
105
  logLevel: (v) => {
90
106
  if (v !== "info" && v !== "debug") throw new Error('must be "info" or "debug"');
91
107
  return v;