open-memex 0.1.0 → 0.2.0-alpha

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.
@@ -0,0 +1,190 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { paths } from "../paths.ts";
4
+ import {
5
+ parseRawFrontmatter,
6
+ normalizeFrontmatter,
7
+ isTaxonomyType,
8
+ serialize,
9
+ type Frontmatter,
10
+ } from "./markdown.ts";
11
+
12
+ /**
13
+ * v1 → v2 file migration (V2-DESIGN §19). Explicit only — run via
14
+ * `open-memex migrate --to-v2`. Converts frontmatter in place:
15
+ *
16
+ * - scope rename: v1 `user` → v2 `personal` (scope, scope_key, and the
17
+ * storage dir `memories/user/` → `memories/personal/`)
18
+ * - times: epoch ms → RFC 3339
19
+ * - `priority: N` → `importance: low|normal|high`
20
+ * - `type: instruction` → `type: knowledge` + `role: instruction`
21
+ * - unknown v1 types → `knowledge` (v2 taxonomy, §3.1)
22
+ * - adds `schema_version: 2`, `visibility`, `role`, `status`
23
+ *
24
+ * Legacy `my-o-memory` data roots (§19/D13: `.my-o-memory/` →
25
+ * `.open-memex/`, `~/.my-o-memory/` → `~/.open-memex/`) are merged into the
26
+ * current root; the old dir is kept as a dated backup.
27
+ */
28
+
29
+ const V2_MARKER = /^schema_version:\s*2(\s|$)/m;
30
+
31
+ export function isV2File(raw: string): boolean {
32
+ return V2_MARKER.test(raw);
33
+ }
34
+
35
+ export interface ConversionPlan {
36
+ fm: Frontmatter;
37
+ body: string;
38
+ fromPath: string;
39
+ toPath: string;
40
+ changes: string[];
41
+ }
42
+
43
+ /**
44
+ * Plan the v1 → v2 conversion of one file. Returns null when the file is
45
+ * already v2 (nothing to do). Pure — no disk writes.
46
+ */
47
+ export function planConversion(
48
+ filePath: string,
49
+ memoriesRoot: string,
50
+ ): ConversionPlan | null {
51
+ const raw = fs.readFileSync(filePath, "utf8");
52
+ if (isV2File(raw)) return null;
53
+ const parsed = parseRawFrontmatter(raw);
54
+ if (!parsed) return null;
55
+ const { rawFm, body } = parsed;
56
+
57
+ const fm = normalizeFrontmatter(rawFm);
58
+ const changes: string[] = [];
59
+
60
+ // scope rename
61
+ if (rawFm.scope_kind === "user" || rawFm.scope_key === "user") {
62
+ changes.push("scope user → personal");
63
+ } else if (!rawFm.scope) {
64
+ changes.push(`scope → ${fm.scope} (default)`);
65
+ }
66
+
67
+ // storage dir move follows scope_key
68
+ const toPath = path.join(memoriesRoot, fm.scope_key, path.basename(filePath));
69
+ if (toPath !== filePath) {
70
+ changes.push(
71
+ `file moved: ${path.basename(path.dirname(filePath))}/ → ${fm.scope_key}/`,
72
+ );
73
+ }
74
+
75
+ // visibility default
76
+ if (!rawFm.visibility) {
77
+ changes.push(`visibility → ${fm.visibility} (default)`);
78
+ }
79
+
80
+ // type taxonomy
81
+ const rawType = rawFm.type;
82
+ if (rawType === "instruction") {
83
+ changes.push("type instruction → knowledge, role → instruction (D11)");
84
+ } else if (typeof rawType === "string" && rawType && !isTaxonomyType(rawType)) {
85
+ changes.push(`type "${rawType}" → knowledge (not in v2 taxonomy)`);
86
+ fm.type = "knowledge";
87
+ } else if (!rawType) {
88
+ changes.push(`type → ${fm.type} (default)`);
89
+ }
90
+
91
+ // priority → importance
92
+ if (typeof rawFm.priority === "number") {
93
+ changes.push(`priority ${rawFm.priority} → importance ${fm.importance}`);
94
+ } else if (!rawFm.importance) {
95
+ changes.push(`importance → ${fm.importance} (default)`);
96
+ }
97
+
98
+ // times
99
+ if (typeof rawFm.created_at === "number") {
100
+ changes.push("created_at/updated_at: epoch ms → RFC 3339");
101
+ }
102
+
103
+ // status default
104
+ if (!rawFm.status) {
105
+ changes.push(`status → ${fm.status} (default)`);
106
+ }
107
+
108
+ changes.push("schema_version → 2");
109
+ return { fm, body, fromPath: filePath, toPath, changes };
110
+ }
111
+
112
+ export interface MigrateV2Result {
113
+ scanned: number;
114
+ converted: number;
115
+ skippedV2: number;
116
+ plans: ConversionPlan[];
117
+ legacyBackup: string | null;
118
+ }
119
+
120
+ /**
121
+ * Merge a legacy `my-o-memory` data root into the current `open-memex` root.
122
+ * The old dir is renamed to a dated backup — never deleted. Returns the
123
+ * backup path, or null when no legacy dir exists.
124
+ */
125
+ function migrateLegacyDataRoot(dryRun: boolean): string | null {
126
+ const { root } = paths();
127
+ const legacy = path.join(path.dirname(root), "my-o-memory");
128
+ if (!fs.existsSync(legacy) || !fs.statSync(legacy).isDirectory()) return null;
129
+
130
+ const stamp = new Date().toISOString().slice(0, 10);
131
+ const backup = `${legacy}.backup-${stamp}`;
132
+ if (!dryRun) {
133
+ const legacyMem = path.join(legacy, "memories");
134
+ if (fs.existsSync(legacyMem)) {
135
+ for (const entry of fs.readdirSync(legacyMem)) {
136
+ const src = path.join(legacyMem, entry);
137
+ const dst = path.join(root, "memories", entry);
138
+ if (!fs.existsSync(dst)) {
139
+ fs.renameSync(src, dst);
140
+ } else {
141
+ // merge file-by-file, never overwrite
142
+ for (const name of fs.readdirSync(src)) {
143
+ const s = path.join(src, name);
144
+ const d = path.join(dst, name);
145
+ if (!fs.existsSync(d)) fs.renameSync(s, d);
146
+ }
147
+ }
148
+ }
149
+ }
150
+ fs.renameSync(legacy, backup);
151
+ }
152
+ return backup;
153
+ }
154
+
155
+ export function migrateV2(opts: { dryRun: boolean }): MigrateV2Result {
156
+ const { memories: memoriesRoot } = paths();
157
+ const result: MigrateV2Result = {
158
+ scanned: 0,
159
+ converted: 0,
160
+ skippedV2: 0,
161
+ plans: [],
162
+ legacyBackup: null,
163
+ };
164
+
165
+ result.legacyBackup = migrateLegacyDataRoot(opts.dryRun);
166
+
167
+ if (!fs.existsSync(memoriesRoot)) return result;
168
+ for (const entry of fs.readdirSync(memoriesRoot, { withFileTypes: true })) {
169
+ if (!entry.isDirectory()) continue;
170
+ const dir = path.join(memoriesRoot, entry.name);
171
+ for (const name of fs.readdirSync(dir)) {
172
+ if (!name.endsWith(".md")) continue;
173
+ const filePath = path.join(dir, name);
174
+ result.scanned++;
175
+ const plan = planConversion(filePath, memoriesRoot);
176
+ if (!plan) {
177
+ result.skippedV2++;
178
+ continue;
179
+ }
180
+ if (!opts.dryRun) {
181
+ fs.mkdirSync(path.dirname(plan.toPath), { recursive: true });
182
+ fs.writeFileSync(plan.toPath, serialize(plan.fm, plan.body), "utf8");
183
+ if (plan.toPath !== plan.fromPath) fs.unlinkSync(plan.fromPath);
184
+ }
185
+ result.converted++;
186
+ result.plans.push(plan);
187
+ }
188
+ }
189
+ return result;
190
+ }
@@ -1,41 +1,37 @@
1
1
  import { tool } from "@opencode-ai/plugin/tool";
2
2
  import type { Scope } from "../scope.ts";
3
3
  import type { MyOMemoryConfig } from "../config.ts";
4
- import { USER_SCOPE } from "../scope.ts";
4
+ import { PERSONAL_SCOPE } from "../scope.ts";
5
5
  import { search, list } from "../retrieve/search.ts";
6
6
  import {
7
7
  writeMemoryFile,
8
8
  deleteMemoryFile,
9
9
  readMemoryFile,
10
10
  ulid,
11
+ msToRfc3339,
12
+ MEMORY_TYPE_TAXONOMY,
11
13
  type Frontmatter,
12
14
  } from "../store/markdown.ts";
13
15
  import { upsertFromFile, deleteFromIndex } from "../store/sync.ts";
16
+ import { findDuplicates, supersede } from "../store/lifecycle.ts";
14
17
  import { db } from "../store/db.ts";
15
18
  import { redact } from "../redact.ts";
16
19
 
17
20
  const z = tool.schema;
18
21
 
19
- const MEMORY_TYPES = [
20
- "note",
21
- "preference",
22
- "project-config",
23
- "architecture",
24
- "error-solution",
25
- "learned-pattern",
26
- "conversation",
27
- ] as const;
22
+ /** v2 content-kind taxonomy (V2-DESIGN §3.1). */
23
+ const MEMORY_TYPES = MEMORY_TYPE_TAXONOMY;
28
24
 
29
25
  export function makeTools(getScope: () => Scope, cfg: MyOMemoryConfig) {
30
26
  const scopeArg = z
31
- .enum(["project", "user"])
27
+ .enum(["project", "personal", "user"])
32
28
  .optional()
33
29
  .describe(
34
- "Memory scope. `project` = tied to this repo. `user` = global across all your projects. Default: project.",
30
+ "Memory scope. `project` = tied to this repo. `personal` = global across all your projects. `user` is a deprecated alias of `personal`. Default: project.",
35
31
  );
36
32
 
37
- function resolveScope(kind?: "project" | "user"): Scope {
38
- return kind === "user" ? USER_SCOPE : getScope();
33
+ function resolveScope(kind?: "project" | "personal" | "user"): Scope {
34
+ return kind === "personal" || kind === "user" ? PERSONAL_SCOPE : getScope();
39
35
  }
40
36
 
41
37
  const memory_add = tool({
@@ -62,38 +58,58 @@ export function makeTools(getScope: () => Scope, cfg: MyOMemoryConfig) {
62
58
  };
63
59
  }
64
60
  const s = resolveScope(args.scope);
65
- const now = Date.now();
61
+ // Dedup on write (§3.4): identical content is idempotent; near-duplicates
62
+ // are reported so the caller can supersede instead of duplicating.
63
+ const dups = findDuplicates(s.key, redacted);
64
+ if (dups.exact) {
65
+ return {
66
+ title: "memory: already exists",
67
+ output: `Identical memory already exists: id=${dups.exact.id}. Not duplicated.`,
68
+ };
69
+ }
70
+ const rfc = msToRfc3339(Date.now());
66
71
  const fm: Frontmatter = {
67
72
  id: ulid(),
73
+ schema_version: 2,
68
74
  scope_key: s.key,
69
- scope_kind: s.kind,
75
+ scope: s.kind === "project" ? "project" : "personal",
76
+ visibility: s.kind === "project" ? "internal" : "private",
70
77
  project_name: s.projectName,
71
- type: args.type ?? "note",
78
+ type: args.type ?? "fact",
79
+ role: "knowledge",
80
+ importance: "normal",
81
+ status: "active",
72
82
  tags: args.tags ?? [],
73
83
  source: "tool",
74
- created_at: now,
75
- updated_at: now,
84
+ created_at: rfc,
85
+ updated_at: rfc,
86
+ supersedes: null,
87
+ superseded_by: null,
76
88
  };
77
89
  const { filePath } = writeMemoryFile(fm, redacted);
78
90
  const mf = readMemoryFile(filePath);
79
91
  if (mf) upsertFromFile(mf);
92
+ let output = `Saved to ${s.key} as ${fm.type}. id=${fm.id}`;
93
+ for (const n of dups.near) {
94
+ output += `\nNote: similar memory exists (score ${n.score.toFixed(2)}): id=${n.id} — ${n.snippet}. Use memory_supersede if this replaces it.`;
95
+ }
80
96
  return {
81
97
  title: `memory: saved ${fm.id}`,
82
- output: `Saved to ${s.key} as ${fm.type}. id=${fm.id}`,
98
+ output,
83
99
  };
84
100
  },
85
101
  });
86
102
 
87
103
  const memory_search = tool({
88
104
  description:
89
- "Search persistent memory by keyword (BM25 full-text). Returns matching memories from the current project and/or user scope. Use before asking the user something they may have told you before.",
105
+ "Search persistent memory by keyword (BM25 full-text). Returns matching memories from the current project and/or personal scope. Use before asking the user something they may have told you before.",
90
106
  args: {
91
107
  query: z
92
108
  .string()
93
109
  .min(1)
94
110
  .describe("Free-text query. File paths, error strings, identifiers work well."),
95
111
  scope: z
96
- .enum(["project", "user", "both"])
112
+ .enum(["project", "personal", "user", "both"])
97
113
  .optional()
98
114
  .describe("Which scope(s) to search. Default: both."),
99
115
  type: z.string().optional().describe("Restrict to memories of this type."),
@@ -102,11 +118,11 @@ export function makeTools(getScope: () => Scope, cfg: MyOMemoryConfig) {
102
118
  async execute(args) {
103
119
  const project = getScope();
104
120
  const scopeKeys =
105
- args.scope === "user"
106
- ? [USER_SCOPE.key]
121
+ args.scope === "personal" || args.scope === "user"
122
+ ? [PERSONAL_SCOPE.key]
107
123
  : args.scope === "project"
108
124
  ? [project.key]
109
- : [project.key, USER_SCOPE.key];
125
+ : [project.key, PERSONAL_SCOPE.key];
110
126
  const hits = search(args.query, {
111
127
  scopeKeys,
112
128
  limit: args.limit,
@@ -120,7 +136,7 @@ export function makeTools(getScope: () => Scope, cfg: MyOMemoryConfig) {
120
136
  }
121
137
  const lines = hits.map(
122
138
  (h) =>
123
- `- [${h.scope_key === USER_SCOPE.key ? "user" : "project"}/${h.type}] id=${h.id}\n ${h.snippet.replace(/\s+/g, " ").trim()}`,
139
+ `- [${h.scope_key === PERSONAL_SCOPE.key ? "personal" : "project"}/${h.type}] id=${h.id}\n ${h.snippet.replace(/\s+/g, " ").trim()}`,
124
140
  );
125
141
  return {
126
142
  title: `memory: ${hits.length} result(s)`,
@@ -152,6 +168,51 @@ export function makeTools(getScope: () => Scope, cfg: MyOMemoryConfig) {
152
168
  },
153
169
  });
154
170
 
171
+ const memory_supersede = tool({
172
+ description:
173
+ "Replace an existing memory with a newer version. The old memory is kept as history (status: superseded) and retrieval returns the new one. Use when a saved fact becomes outdated and should be replaced rather than duplicated.",
174
+ args: {
175
+ id: z.string().min(1).describe("ID of the memory being replaced."),
176
+ content: z.string().min(1).describe("The new, corrected content."),
177
+ type: z
178
+ .enum(MEMORY_TYPES)
179
+ .optional()
180
+ .describe("Category of the new memory. Defaults to the old memory's type."),
181
+ tags: z.array(z.string()).optional().describe("Optional tags for the new memory."),
182
+ },
183
+ async execute(args) {
184
+ const { content: redacted, hadSecret, matchedPattern } = redact(
185
+ args.content,
186
+ cfg.redactPatterns,
187
+ );
188
+ if (hadSecret) {
189
+ return {
190
+ title: "memory: rejected (secret detected)",
191
+ output: `Refused to save: content matched a secret pattern (${matchedPattern}). Wrap the sensitive part in <private>...</private> tags or paraphrase, then try again.`,
192
+ };
193
+ }
194
+ try {
195
+ const { oldMf, newMf } = supersede(args.id, {
196
+ body: redacted,
197
+ type: args.type,
198
+ tags: args.tags,
199
+ source: "tool",
200
+ });
201
+ upsertFromFile(oldMf);
202
+ upsertFromFile(newMf);
203
+ return {
204
+ title: `memory: superseded ${oldMf.fm.id}`,
205
+ output: `Replaced ${oldMf.fm.id} with ${newMf.fm.id} (old kept as history).`,
206
+ };
207
+ } catch (e) {
208
+ return {
209
+ title: "memory: supersede failed",
210
+ output: (e as Error).message,
211
+ };
212
+ }
213
+ },
214
+ });
215
+
155
216
  const memory_forget = tool({
156
217
  description: "Delete a memory by id. Use when the user asks to forget something.",
157
218
  args: {
@@ -170,5 +231,5 @@ export function makeTools(getScope: () => Scope, cfg: MyOMemoryConfig) {
170
231
  },
171
232
  });
172
233
 
173
- return { memory_add, memory_search, memory_list, memory_forget };
234
+ return { memory_add, memory_search, memory_list, memory_forget, memory_supersede };
174
235
  }