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.
- package/AGENTS.md +31 -5
- package/CONTRIBUTING.md +31 -0
- package/README.md +17 -7
- package/docs/SCOPES.md +81 -0
- package/docs/V2-DESIGN.md +484 -0
- package/package.json +1 -1
- package/scripts/smoke-pure.ts +209 -9
- package/src/cli.ts +138 -22
- package/src/config.ts +3 -10
- package/src/index.ts +18 -6
- package/src/redact.ts +151 -4
- package/src/retrieve/cjk.ts +63 -0
- package/src/retrieve/inject.ts +2 -2
- package/src/retrieve/search.ts +115 -28
- package/src/scope.ts +7 -2
- package/src/store/db.ts +62 -14
- package/src/store/lifecycle.ts +280 -0
- package/src/store/markdown.ts +163 -11
- package/src/store/sync.ts +53 -9
- package/src/store/v2migrate.ts +190 -0
- package/src/tools/memory.ts +88 -27
|
@@ -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
|
+
}
|
package/src/tools/memory.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
20
|
-
|
|
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. `
|
|
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" ?
|
|
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
|
-
|
|
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
|
-
|
|
75
|
+
scope: s.kind === "project" ? "project" : "personal",
|
|
76
|
+
visibility: s.kind === "project" ? "internal" : "private",
|
|
70
77
|
project_name: s.projectName,
|
|
71
|
-
type: args.type ?? "
|
|
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:
|
|
75
|
-
updated_at:
|
|
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
|
|
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
|
|
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
|
-
? [
|
|
121
|
+
args.scope === "personal" || args.scope === "user"
|
|
122
|
+
? [PERSONAL_SCOPE.key]
|
|
107
123
|
: args.scope === "project"
|
|
108
124
|
? [project.key]
|
|
109
|
-
: [project.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 ===
|
|
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
|
}
|