open-memex 0.1.0 → 0.3.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.
package/src/store/sync.ts CHANGED
@@ -1,17 +1,31 @@
1
1
  import fs from "node:fs";
2
2
  import { db } from "./db.ts";
3
- import { iterMemoryFiles, readMemoryFile, type MemoryFile } from "./markdown.ts";
3
+ import { cjkIndexText } from "../retrieve/cjk.ts";
4
+ import { contentHash, repairChain } from "./lifecycle.ts";
5
+ import {
6
+ iterMemoryFiles,
7
+ readMemoryFile,
8
+ timeToMs,
9
+ type MemoryFile,
10
+ } from "./markdown.ts";
4
11
 
5
12
  const UPSERT_SQL = `
6
- INSERT INTO memories (id, scope_key, scope_kind, project_name, type, tags, content, source, file_path, mtime_ms, created_at, updated_at)
7
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
13
+ INSERT INTO memories (id, scope_key, scope, visibility, project_name, type, role, importance, status, tags, content, cjk, content_hash, superseded_by, source, file_path, mtime_ms, created_at, updated_at)
14
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
8
15
  ON CONFLICT(id) DO UPDATE SET
9
16
  scope_key = excluded.scope_key,
10
- scope_kind = excluded.scope_kind,
17
+ scope = excluded.scope,
18
+ visibility = excluded.visibility,
11
19
  project_name = excluded.project_name,
12
20
  type = excluded.type,
21
+ role = excluded.role,
22
+ importance = excluded.importance,
23
+ status = excluded.status,
13
24
  tags = excluded.tags,
14
25
  content = excluded.content,
26
+ cjk = excluded.cjk,
27
+ content_hash = excluded.content_hash,
28
+ superseded_by = excluded.superseded_by,
15
29
  source = excluded.source,
16
30
  file_path = excluded.file_path,
17
31
  mtime_ms = excluded.mtime_ms,
@@ -19,20 +33,50 @@ const UPSERT_SQL = `
19
33
  `;
20
34
 
21
35
  export function upsertFromFile(mf: MemoryFile): void {
22
- const { fm, body, filePath, mtimeMs } = mf;
36
+ // Chain integrity (§3.3): every write path through Core validates the
37
+ // supersedes/superseded_by pair and auto-completes a missing side.
38
+ const { warnings, repaired } = repairChain(mf);
39
+ for (const w of warnings) console.warn(`[open-memex] chain: ${w}`);
40
+ const mtimeOf = (m: MemoryFile): number => {
41
+ try {
42
+ return fs.statSync(m.filePath).mtimeMs;
43
+ } catch {
44
+ return m.mtimeMs; // file vanished mid-repair; keep the old mtime
45
+ }
46
+ };
47
+ writeRow(
48
+ mf,
49
+ repaired.some((r) => r.filePath === mf.filePath) ? mtimeOf(mf) : mf.mtimeMs,
50
+ );
51
+ for (const r of repaired) {
52
+ if (r.filePath === mf.filePath) continue;
53
+ writeRow(r, mtimeOf(r));
54
+ }
55
+ }
56
+
57
+ function writeRow(mf: MemoryFile, mtimeMs: number): void {
58
+ const { fm, body, filePath } = mf;
59
+ const tags = (fm.tags ?? []).join(",");
23
60
  db().prepare(UPSERT_SQL).run(
24
61
  fm.id,
25
62
  fm.scope_key,
26
- fm.scope_kind,
63
+ fm.scope,
64
+ fm.visibility,
27
65
  fm.project_name,
28
66
  fm.type,
29
- (fm.tags ?? []).join(","),
67
+ fm.role,
68
+ fm.importance,
69
+ fm.status,
70
+ tags,
30
71
  body,
72
+ cjkIndexText(body + "\n" + tags),
73
+ contentHash(body),
74
+ fm.superseded_by ?? null,
31
75
  fm.source ?? "",
32
76
  filePath,
33
77
  mtimeMs,
34
- fm.created_at,
35
- fm.updated_at,
78
+ timeToMs(fm.created_at),
79
+ timeToMs(fm.updated_at),
36
80
  );
37
81
  }
38
82
 
@@ -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,174 +1,60 @@
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";
5
- import { search, list } from "../retrieve/search.ts";
6
4
  import {
7
- writeMemoryFile,
8
- deleteMemoryFile,
9
- readMemoryFile,
10
- ulid,
11
- type Frontmatter,
12
- } from "../store/markdown.ts";
13
- import { upsertFromFile, deleteFromIndex } from "../store/sync.ts";
14
- import { db } from "../store/db.ts";
15
- import { redact } from "../redact.ts";
16
-
17
- const z = tool.schema;
18
-
19
- const MEMORY_TYPES = [
20
- "note",
21
- "preference",
22
- "project-config",
23
- "architecture",
24
- "error-solution",
25
- "learned-pattern",
26
- "conversation",
27
- ] as const;
5
+ addMemory,
6
+ searchMemories,
7
+ listMemories,
8
+ supersedeMemory,
9
+ forgetMemory,
10
+ memoryAddArgs,
11
+ memorySearchArgs,
12
+ memoryListArgs,
13
+ memorySupersedeArgs,
14
+ memoryForgetArgs,
15
+ TOOL_DESCRIPTIONS,
16
+ } from "./ops.ts";
28
17
 
29
18
  export function makeTools(getScope: () => Scope, cfg: MyOMemoryConfig) {
30
- const scopeArg = z
31
- .enum(["project", "user"])
32
- .optional()
33
- .describe(
34
- "Memory scope. `project` = tied to this repo. `user` = global across all your projects. Default: project.",
35
- );
36
-
37
- function resolveScope(kind?: "project" | "user"): Scope {
38
- return kind === "user" ? USER_SCOPE : getScope();
39
- }
40
-
41
19
  const memory_add = tool({
42
- description:
43
- "Save a fact, preference, decision, or note to persistent local memory. Call this whenever the user tells you something you should remember in future sessions (project conventions, tool choices, personal preferences, error fixes). Keep each memory to one self-contained statement.",
44
- args: {
45
- content: z.string().min(1).describe("The fact to remember. One idea per memory."),
46
- type: z
47
- .enum(MEMORY_TYPES)
48
- .optional()
49
- .describe("Category of memory. Default: note."),
50
- scope: scopeArg,
51
- tags: z.array(z.string()).optional().describe("Optional tags for filtering."),
52
- },
20
+ description: TOOL_DESCRIPTIONS.memory_add,
21
+ args: memoryAddArgs,
53
22
  async execute(args) {
54
- const { content: redacted, hadSecret, matchedPattern } = redact(
55
- args.content,
56
- cfg.redactPatterns,
57
- );
58
- if (hadSecret) {
59
- return {
60
- title: "memory: rejected (secret detected)",
61
- output: `Refused to save: content matched a secret pattern (${matchedPattern}). Wrap the sensitive part in <private>...</private> tags or paraphrase, then try again.`,
62
- };
63
- }
64
- const s = resolveScope(args.scope);
65
- const now = Date.now();
66
- const fm: Frontmatter = {
67
- id: ulid(),
68
- scope_key: s.key,
69
- scope_kind: s.kind,
70
- project_name: s.projectName,
71
- type: args.type ?? "note",
72
- tags: args.tags ?? [],
73
- source: "tool",
74
- created_at: now,
75
- updated_at: now,
76
- };
77
- const { filePath } = writeMemoryFile(fm, redacted);
78
- const mf = readMemoryFile(filePath);
79
- if (mf) upsertFromFile(mf);
80
- return {
81
- title: `memory: saved ${fm.id}`,
82
- output: `Saved to ${s.key} as ${fm.type}. id=${fm.id}`,
83
- };
23
+ return addMemory(getScope, cfg, args);
84
24
  },
85
25
  });
86
26
 
87
27
  const memory_search = tool({
88
- 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.",
90
- args: {
91
- query: z
92
- .string()
93
- .min(1)
94
- .describe("Free-text query. File paths, error strings, identifiers work well."),
95
- scope: z
96
- .enum(["project", "user", "both"])
97
- .optional()
98
- .describe("Which scope(s) to search. Default: both."),
99
- type: z.string().optional().describe("Restrict to memories of this type."),
100
- limit: z.number().int().min(1).max(50).optional(),
101
- },
28
+ description: TOOL_DESCRIPTIONS.memory_search,
29
+ args: memorySearchArgs,
102
30
  async execute(args) {
103
- const project = getScope();
104
- const scopeKeys =
105
- args.scope === "user"
106
- ? [USER_SCOPE.key]
107
- : args.scope === "project"
108
- ? [project.key]
109
- : [project.key, USER_SCOPE.key];
110
- const hits = search(args.query, {
111
- scopeKeys,
112
- limit: args.limit,
113
- type: args.type,
114
- });
115
- if (hits.length === 0) {
116
- return {
117
- title: "memory: 0 results",
118
- output: `No memories matched "${args.query}".`,
119
- };
120
- }
121
- const lines = hits.map(
122
- (h) =>
123
- `- [${h.scope_key === USER_SCOPE.key ? "user" : "project"}/${h.type}] id=${h.id}\n ${h.snippet.replace(/\s+/g, " ").trim()}`,
124
- );
125
- return {
126
- title: `memory: ${hits.length} result(s)`,
127
- output: lines.join("\n"),
128
- };
31
+ return searchMemories(getScope, args);
129
32
  },
130
33
  });
131
34
 
132
35
  const memory_list = tool({
133
- description: "List memories in a scope, newest first. Useful for browsing.",
134
- args: {
135
- scope: scopeArg,
136
- type: z.string().optional(),
137
- limit: z.number().int().min(1).max(100).optional(),
36
+ description: TOOL_DESCRIPTIONS.memory_list,
37
+ args: memoryListArgs,
38
+ async execute(args) {
39
+ return listMemories(getScope, args);
138
40
  },
41
+ });
42
+
43
+ const memory_supersede = tool({
44
+ description: TOOL_DESCRIPTIONS.memory_supersede,
45
+ args: memorySupersedeArgs,
139
46
  async execute(args) {
140
- const s = resolveScope(args.scope);
141
- const hits = list(s.key, { type: args.type, limit: args.limit });
142
- if (hits.length === 0) {
143
- return { title: "memory: empty", output: `No memories in scope ${s.key}.` };
144
- }
145
- const lines = hits.map(
146
- (h) => `- [${h.type}] id=${h.id} — ${h.snippet.replace(/\s+/g, " ").trim()}`,
147
- );
148
- return {
149
- title: `memory: ${hits.length} in ${s.key}`,
150
- output: lines.join("\n"),
151
- };
47
+ return supersedeMemory(cfg, args);
152
48
  },
153
49
  });
154
50
 
155
51
  const memory_forget = tool({
156
- description: "Delete a memory by id. Use when the user asks to forget something.",
157
- args: {
158
- id: z.string().min(1),
159
- },
52
+ description: TOOL_DESCRIPTIONS.memory_forget,
53
+ args: memoryForgetArgs,
160
54
  async execute(args) {
161
- const row = db()
162
- .prepare(`SELECT scope_key FROM memories WHERE id = ?`)
163
- .get(args.id) as { scope_key: string } | undefined;
164
- if (!row) {
165
- return { title: "memory: not found", output: `No memory with id ${args.id}.` };
166
- }
167
- deleteMemoryFile(row.scope_key, args.id);
168
- deleteFromIndex(args.id);
169
- return { title: "memory: forgotten", output: `Deleted ${args.id}.` };
55
+ return forgetMemory(args);
170
56
  },
171
57
  });
172
58
 
173
- return { memory_add, memory_search, memory_list, memory_forget };
59
+ return { memory_add, memory_search, memory_list, memory_forget, memory_supersede };
174
60
  }