open-memex 0.4.0-alpha.7 → 0.4.1
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 +5 -0
- package/README.md +193 -21
- package/README.zh-CN.md +166 -17
- package/dist/cli.js +309 -23
- package/dist/config.js +2 -0
- package/dist/distill-agents.js +62 -0
- package/dist/export.js +157 -0
- package/dist/mcp.js +14 -0
- package/dist/providers/git.js +142 -0
- package/dist/store/v2migrate.js +83 -34
- package/docs/CURATOR.md +59 -0
- package/docs/TEST-PLAN.md +72 -0
- package/docs/V2-DESIGN.md +26 -2
- package/package.json +1 -1
- package/scripts/smoke-pure.ts +49 -1
- package/scripts/test-full.ts +345 -0
- package/src/cli.ts +321 -23
- package/src/config.ts +10 -0
- package/src/distill-agents.ts +85 -0
- package/src/export.ts +206 -0
- package/src/mcp.ts +16 -0
- package/src/providers/git.ts +191 -0
- package/src/store/sync.ts +1 -1
- package/src/store/v2migrate.ts +88 -34
package/dist/export.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Export / import: portable memory archives (§9, D40).
|
|
3
|
+
*
|
|
4
|
+
* `export` bundles selected memories (markdown source of truth + manifest)
|
|
5
|
+
* into a single .tar.gz for moving to another machine or another app.
|
|
6
|
+
* `import` restores a bundle: personal memories go to the personal dir,
|
|
7
|
+
* project memories are re-keyed to the current project and land in the
|
|
8
|
+
* outbox as drafts (submit moves them into the repo).
|
|
9
|
+
*
|
|
10
|
+
* D40: export excludes `visibility: private` by default; `--all` / `-a`
|
|
11
|
+
* includes everything — the full-migration escape hatch.
|
|
12
|
+
*/
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { execFileSync } from "node:child_process";
|
|
17
|
+
import { db } from "./store/db.js";
|
|
18
|
+
import { readMemoryFile, writeMemoryFile, normalizeFrontmatter, } from "./store/markdown.js";
|
|
19
|
+
import { upsertFromFile } from "./store/sync.js";
|
|
20
|
+
import { contentHash } from "./store/lifecycle.js";
|
|
21
|
+
const EXPORT_FORMAT = "open-memex-export/1";
|
|
22
|
+
function fail(msg) {
|
|
23
|
+
throw new Error(`[open-memex] ${msg}`);
|
|
24
|
+
}
|
|
25
|
+
function checkTar() {
|
|
26
|
+
try {
|
|
27
|
+
execFileSync("tar", ["--version"], { stdio: ["ignore", "pipe", "ignore"] });
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
fail("the `tar` command is required for export/import but was not found on PATH");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function exportMemories(opts) {
|
|
34
|
+
checkTar();
|
|
35
|
+
const includePrivate = opts.includePrivate ?? false;
|
|
36
|
+
let sql = `SELECT id, scope_key, scope, visibility, type, file_path FROM memories WHERE scope_key IN (${opts.scopeKeys.map(() => "?").join(",")})`;
|
|
37
|
+
const params = [...opts.scopeKeys];
|
|
38
|
+
if (!includePrivate)
|
|
39
|
+
sql += ` AND visibility != 'private'`;
|
|
40
|
+
if (opts.type) {
|
|
41
|
+
sql += ` AND type = ?`;
|
|
42
|
+
params.push(opts.type);
|
|
43
|
+
}
|
|
44
|
+
if (opts.tag) {
|
|
45
|
+
sql += ` AND (',' || tags || ',' LIKE ?)`;
|
|
46
|
+
params.push(`%,${opts.tag},%`);
|
|
47
|
+
}
|
|
48
|
+
sql += ` ORDER BY updated_at DESC`;
|
|
49
|
+
const rows = db().prepare(sql).all(...params);
|
|
50
|
+
const skippedPrivate = includePrivate
|
|
51
|
+
? 0
|
|
52
|
+
: db().prepare(`SELECT COUNT(*) AS n FROM memories WHERE scope_key IN (${opts.scopeKeys.map(() => "?").join(",")}) AND visibility = 'private'`).get(...opts.scopeKeys).n;
|
|
53
|
+
const stage = fs.mkdtempSync(path.join(os.tmpdir(), "open-memex-export-"));
|
|
54
|
+
try {
|
|
55
|
+
const memDir = path.join(stage, "memories");
|
|
56
|
+
fs.mkdirSync(memDir, { recursive: true });
|
|
57
|
+
const manifestEntries = [];
|
|
58
|
+
let exported = 0;
|
|
59
|
+
for (const r of rows) {
|
|
60
|
+
const mf = readMemoryFile(r.file_path);
|
|
61
|
+
if (!mf)
|
|
62
|
+
continue; // stale index row — skip, don't fail the export
|
|
63
|
+
const rel = path.join(r.scope, `${r.id}.md`);
|
|
64
|
+
const dest = path.join(memDir, rel);
|
|
65
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
66
|
+
fs.copyFileSync(r.file_path, dest);
|
|
67
|
+
manifestEntries.push({ id: r.id, scope: r.scope, file: path.join("memories", rel) });
|
|
68
|
+
exported++;
|
|
69
|
+
}
|
|
70
|
+
const manifest = {
|
|
71
|
+
format: EXPORT_FORMAT,
|
|
72
|
+
exported_at: new Date().toISOString(),
|
|
73
|
+
open_memex_version: readPackageVersion(),
|
|
74
|
+
include_private: includePrivate,
|
|
75
|
+
filters: {
|
|
76
|
+
scope_keys: opts.scopeKeys,
|
|
77
|
+
type: opts.type ?? null,
|
|
78
|
+
tag: opts.tag ?? null,
|
|
79
|
+
},
|
|
80
|
+
memories: manifestEntries,
|
|
81
|
+
};
|
|
82
|
+
fs.writeFileSync(path.join(stage, "manifest.json"), JSON.stringify(manifest, null, 2), "utf8");
|
|
83
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15);
|
|
84
|
+
const outFile = opts.outFile ?? path.resolve(`open-memex-export-${stamp}.tar.gz`);
|
|
85
|
+
execFileSync("tar", ["-czf", outFile, "-C", stage, "manifest.json", "memories"], {
|
|
86
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
87
|
+
});
|
|
88
|
+
return { file: outFile, exported, skippedPrivate, includePrivate };
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
fs.rmSync(stage, { recursive: true, force: true });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function readPackageVersion() {
|
|
95
|
+
try {
|
|
96
|
+
const here = new URL(import.meta.url);
|
|
97
|
+
const pkg = path.join(path.dirname(here.pathname), "..", "package.json");
|
|
98
|
+
return JSON.parse(fs.readFileSync(pkg, "utf8")).version;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return "unknown";
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
export function importBundle(bundlePath, opts) {
|
|
105
|
+
checkTar();
|
|
106
|
+
if (!fs.existsSync(bundlePath))
|
|
107
|
+
fail(`bundle not found: ${bundlePath}`);
|
|
108
|
+
const stage = fs.mkdtempSync(path.join(os.tmpdir(), "open-memex-import-"));
|
|
109
|
+
try {
|
|
110
|
+
execFileSync("tar", ["-xzf", path.resolve(bundlePath), "-C", stage], {
|
|
111
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
112
|
+
});
|
|
113
|
+
const manifestPath = path.join(stage, "manifest.json");
|
|
114
|
+
if (!fs.existsSync(manifestPath))
|
|
115
|
+
fail("not an open-memex export bundle (manifest.json missing)");
|
|
116
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
117
|
+
if (manifest.format !== EXPORT_FORMAT) {
|
|
118
|
+
fail(`unsupported bundle format: ${manifest.format} (expected ${EXPORT_FORMAT})`);
|
|
119
|
+
}
|
|
120
|
+
const result = { imported: 0, skippedIdentical: 0, skippedConflict: [] };
|
|
121
|
+
const existingStmt = db().prepare(`SELECT content_hash FROM memories WHERE id = ?`);
|
|
122
|
+
for (const entry of manifest.memories ?? []) {
|
|
123
|
+
const src = path.join(stage, entry.file);
|
|
124
|
+
const mf = readMemoryFile(src);
|
|
125
|
+
if (!mf)
|
|
126
|
+
continue;
|
|
127
|
+
const fm = normalizeFrontmatter({ ...mf.fm });
|
|
128
|
+
// Re-key to this machine: personal stays personal; project memories
|
|
129
|
+
// adopt the current project's scope key (keys embed a path hash).
|
|
130
|
+
if (fm.scope === "project")
|
|
131
|
+
fm.scope_key = opts.projectScopeKey;
|
|
132
|
+
else if (fm.scope === "personal")
|
|
133
|
+
fm.scope_key = "personal";
|
|
134
|
+
const existing = existingStmt.get(fm.id);
|
|
135
|
+
if (existing) {
|
|
136
|
+
if (existing.content_hash === contentHash(mf.body))
|
|
137
|
+
result.skippedIdentical++;
|
|
138
|
+
else
|
|
139
|
+
result.skippedConflict.push({ id: fm.id, file: entry.file });
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (opts.dryRun) {
|
|
143
|
+
result.imported++;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const { filePath } = writeMemoryFile(fm, mf.body);
|
|
147
|
+
const written = readMemoryFile(filePath);
|
|
148
|
+
if (written)
|
|
149
|
+
upsertFromFile(written);
|
|
150
|
+
result.imported++;
|
|
151
|
+
}
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
fs.rmSync(stage, { recursive: true, force: true });
|
|
156
|
+
}
|
|
157
|
+
}
|
package/dist/mcp.js
CHANGED
|
@@ -104,6 +104,20 @@ export async function runMcpServer() {
|
|
|
104
104
|
syncScope(scope.key, "session");
|
|
105
105
|
syncScope(PERSONAL_SCOPE.key, "session");
|
|
106
106
|
console.error(`[open-memex] MCP server up. scope=${scope.key}`);
|
|
107
|
+
// D12: pulls are explicit by default — session start never touches the
|
|
108
|
+
// network. With sync.autoPull, one best-effort pull; a failure never
|
|
109
|
+
// blocks the session, it just logs and continues.
|
|
110
|
+
if (cfg.sync?.autoPull) {
|
|
111
|
+
try {
|
|
112
|
+
const { GitProvider } = await import("./providers/git.js");
|
|
113
|
+
const r = new GitProvider().pull(process.cwd());
|
|
114
|
+
syncScope(scope.key, "pull");
|
|
115
|
+
console.error(`[open-memex] auto-pull: ${r.branch} ${r.fastForwarded ? "fast-forwarded" : "already up to date"}`);
|
|
116
|
+
}
|
|
117
|
+
catch (e) {
|
|
118
|
+
console.error(`[open-memex] auto-pull skipped: ${e.message}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
107
121
|
// D26: re-sync on every request, not just at startup. The in-repo dir
|
|
108
122
|
// follows the current git branch, so a branch switch mid-session would
|
|
109
123
|
// otherwise leave the index pointing at files that no longer exist.
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitProvider — the git transport for repo-synced shared scopes (§9, D12).
|
|
3
|
+
*
|
|
4
|
+
* Git is a transport, not the product boundary: this provider moves the
|
|
5
|
+
* in-repo memory dir (`.ai/open-memex/`) between the local checkout and the
|
|
6
|
+
* remote. It never touches the appdata outbox, never force-pushes, never
|
|
7
|
+
* auto-merges a divergence — those are human decisions.
|
|
8
|
+
*
|
|
9
|
+
* Hard rules (from §9 / D12 / D36):
|
|
10
|
+
* - pull is explicit (`open-memex pull`); pull = fetch + fast-forward only.
|
|
11
|
+
* - push is explicit (`open-memex push`); the tool never pushes on its own.
|
|
12
|
+
* - a failed pull/push fails with a clear message and leaves no broken state.
|
|
13
|
+
*/
|
|
14
|
+
import { execFileSync } from "node:child_process";
|
|
15
|
+
function fail(msg) {
|
|
16
|
+
throw new Error(`[open-memex] ${msg}`);
|
|
17
|
+
}
|
|
18
|
+
/** Run git, raising a readable error. `timeoutMs` guards network calls. */
|
|
19
|
+
function git(root, args, timeoutMs = 0) {
|
|
20
|
+
try {
|
|
21
|
+
return execFileSync("git", args, {
|
|
22
|
+
cwd: root,
|
|
23
|
+
encoding: "utf8",
|
|
24
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
25
|
+
...(timeoutMs > 0 ? { timeout: timeoutMs } : {}),
|
|
26
|
+
}).trim();
|
|
27
|
+
}
|
|
28
|
+
catch (e) {
|
|
29
|
+
const err = e;
|
|
30
|
+
if (err.code === "ETIMEDOUT")
|
|
31
|
+
fail(`git ${args.join(" ")} timed out — remote unreachable?`);
|
|
32
|
+
const detail = (err.stderr ?? err.message ?? "").trim().split("\n")[0];
|
|
33
|
+
fail(`git ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const FETCH_TIMEOUT_MS = 30_000;
|
|
37
|
+
export class GitProvider {
|
|
38
|
+
name = "git";
|
|
39
|
+
capabilities = {
|
|
40
|
+
read: true,
|
|
41
|
+
write: true,
|
|
42
|
+
delete: false,
|
|
43
|
+
history: true,
|
|
44
|
+
sync: "bidirectional",
|
|
45
|
+
};
|
|
46
|
+
/** Current branch + upstream, or a clear failure when git can't answer. */
|
|
47
|
+
status(root) {
|
|
48
|
+
git(root, ["rev-parse", "--git-dir"]);
|
|
49
|
+
const branch = git(root, ["branch", "--show-current"]) ||
|
|
50
|
+
git(root, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
51
|
+
let upstream = null;
|
|
52
|
+
try {
|
|
53
|
+
upstream = git(root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
upstream = null;
|
|
57
|
+
}
|
|
58
|
+
let ahead = 0;
|
|
59
|
+
let behind = 0;
|
|
60
|
+
if (upstream) {
|
|
61
|
+
const counts = git(root, ["rev-list", "--left-right", "--count", `HEAD...${upstream}`]);
|
|
62
|
+
const [a, b] = counts.split(/\s+/).map((n) => parseInt(n, 10));
|
|
63
|
+
ahead = Number.isFinite(a) ? a : 0;
|
|
64
|
+
behind = Number.isFinite(b) ? b : 0;
|
|
65
|
+
}
|
|
66
|
+
const remote = upstream ? upstream.split("/")[0] : null;
|
|
67
|
+
return { branch, upstream, remote, ahead, behind };
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Explicit pull: fetch + fast-forward only (§9).
|
|
71
|
+
* Diverged branches are NOT merged — the user resolves them by hand.
|
|
72
|
+
*/
|
|
73
|
+
pull(root) {
|
|
74
|
+
const st = this.status(root);
|
|
75
|
+
if (!st.upstream || !st.remote) {
|
|
76
|
+
fail(`branch ${st.branch} has no upstream — set one with ` +
|
|
77
|
+
`\`git push -u <remote> ${st.branch}\`, then pull again`);
|
|
78
|
+
}
|
|
79
|
+
git(root, ["fetch", st.remote], FETCH_TIMEOUT_MS);
|
|
80
|
+
const before = git(root, ["rev-parse", "HEAD"]);
|
|
81
|
+
const remoteSha = git(root, ["rev-parse", st.upstream]);
|
|
82
|
+
if (before === remoteSha) {
|
|
83
|
+
return {
|
|
84
|
+
at: new Date().toISOString(),
|
|
85
|
+
branch: st.branch,
|
|
86
|
+
remote: st.remote,
|
|
87
|
+
before,
|
|
88
|
+
after: before,
|
|
89
|
+
fastForwarded: false,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
// Fast-forward is possible iff HEAD is an ancestor of the upstream.
|
|
93
|
+
let ffPossible = false;
|
|
94
|
+
try {
|
|
95
|
+
git(root, ["merge-base", "--is-ancestor", "HEAD", st.upstream]);
|
|
96
|
+
ffPossible = true;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
ffPossible = false;
|
|
100
|
+
}
|
|
101
|
+
if (!ffPossible) {
|
|
102
|
+
fail(`branch ${st.branch} has diverged from ${st.upstream} — ` +
|
|
103
|
+
`open-memex never force-merges; resolve it by hand ` +
|
|
104
|
+
`(rebase or merge), then pull again`);
|
|
105
|
+
}
|
|
106
|
+
git(root, ["merge", "--ff-only", st.upstream]);
|
|
107
|
+
const after = git(root, ["rev-parse", "HEAD"]);
|
|
108
|
+
return {
|
|
109
|
+
at: new Date().toISOString(),
|
|
110
|
+
branch: st.branch,
|
|
111
|
+
remote: st.remote,
|
|
112
|
+
before,
|
|
113
|
+
after,
|
|
114
|
+
fastForwarded: true,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/** Explicit push of the current branch. Never called automatically. */
|
|
118
|
+
push(root) {
|
|
119
|
+
const st = this.status(root);
|
|
120
|
+
if (!st.remote) {
|
|
121
|
+
// No upstream yet: push explicitly sets it (-u), still user-invoked.
|
|
122
|
+
const remotes = git(root, ["remote"]);
|
|
123
|
+
const remote = remotes.split("\n").map((r) => r.trim()).filter(Boolean)[0];
|
|
124
|
+
if (!remote)
|
|
125
|
+
fail("no git remote configured — add one before pushing");
|
|
126
|
+
git(root, ["push", "-u", remote, st.branch], FETCH_TIMEOUT_MS);
|
|
127
|
+
return {
|
|
128
|
+
at: new Date().toISOString(),
|
|
129
|
+
branch: st.branch,
|
|
130
|
+
remote,
|
|
131
|
+
head: git(root, ["rev-parse", "HEAD"]),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
git(root, ["push", st.remote, st.branch], FETCH_TIMEOUT_MS);
|
|
135
|
+
return {
|
|
136
|
+
at: new Date().toISOString(),
|
|
137
|
+
branch: st.branch,
|
|
138
|
+
remote: st.remote,
|
|
139
|
+
head: git(root, ["rev-parse", "HEAD"]),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
}
|
package/dist/store/v2migrate.js
CHANGED
|
@@ -83,23 +83,45 @@ export function planConversion(filePath, memoriesRoot) {
|
|
|
83
83
|
return { fm, body, fromPath: filePath, toPath, changes };
|
|
84
84
|
}
|
|
85
85
|
/**
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* backup path, or null when no legacy dir exists.
|
|
86
|
+
* Locate a legacy `my-o-memory` data root next to the current root.
|
|
87
|
+
* Pure lookup — no disk writes.
|
|
89
88
|
*/
|
|
90
|
-
function
|
|
89
|
+
export function findLegacyDataRoot() {
|
|
91
90
|
const { root } = paths();
|
|
92
91
|
const legacy = path.join(path.dirname(root), "my-o-memory");
|
|
93
92
|
if (!fs.existsSync(legacy) || !fs.statSync(legacy).isDirectory())
|
|
94
93
|
return null;
|
|
94
|
+
return legacy;
|
|
95
|
+
}
|
|
96
|
+
/** Dated backup path for a legacy data root. Pure — no disk writes. */
|
|
97
|
+
export function legacyBackupPath(legacy) {
|
|
95
98
|
const stamp = new Date().toISOString().slice(0, 10);
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
99
|
+
return `${legacy}.backup-${stamp}`;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Merge a legacy `my-o-memory` data root into the current `open-memex` root.
|
|
103
|
+
* The old dir is renamed to a dated backup — never deleted.
|
|
104
|
+
* Failures throw an actionable Error (no raw syscall dump): on Windows the
|
|
105
|
+
* backup rename typically fails with EPERM when another program holds the
|
|
106
|
+
* folder open.
|
|
107
|
+
*/
|
|
108
|
+
function mergeLegacyDataRoot(legacy, backup) {
|
|
109
|
+
const { memories } = paths();
|
|
110
|
+
const fail = (where, err) => {
|
|
111
|
+
const code = err?.code;
|
|
112
|
+
throw new Error(`could not ${where} (${legacy})` +
|
|
113
|
+
(code ? ` [${code}]` : "") +
|
|
114
|
+
`. Another program may be holding the folder open (e.g. a running MCP server, editor, or antivirus). ` +
|
|
115
|
+
`Your memories are safe — nothing was deleted. Close the program and re-run ` +
|
|
116
|
+
`\`open-memex migrate --to-v2\`, or rename the folder to ${backup} yourself and re-run.`);
|
|
117
|
+
};
|
|
118
|
+
const legacyMem = path.join(legacy, "memories");
|
|
119
|
+
if (fs.existsSync(legacyMem)) {
|
|
120
|
+
fs.mkdirSync(memories, { recursive: true });
|
|
121
|
+
for (const entry of fs.readdirSync(legacyMem)) {
|
|
122
|
+
const src = path.join(legacyMem, entry);
|
|
123
|
+
const dst = path.join(memories, entry);
|
|
124
|
+
try {
|
|
103
125
|
if (!fs.existsSync(dst)) {
|
|
104
126
|
fs.renameSync(src, dst);
|
|
105
127
|
}
|
|
@@ -113,46 +135,73 @@ function migrateLegacyDataRoot(dryRun) {
|
|
|
113
135
|
}
|
|
114
136
|
}
|
|
115
137
|
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
fail(`move memories from the legacy data dir`, err);
|
|
140
|
+
}
|
|
116
141
|
}
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
117
144
|
fs.renameSync(legacy, backup);
|
|
118
145
|
}
|
|
119
|
-
|
|
146
|
+
catch (err) {
|
|
147
|
+
fail(`back up the legacy data dir`, err);
|
|
148
|
+
}
|
|
120
149
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
skippedV2: 0,
|
|
127
|
-
plans: [],
|
|
128
|
-
legacyBackup: null,
|
|
129
|
-
};
|
|
130
|
-
result.legacyBackup = migrateLegacyDataRoot(opts.dryRun);
|
|
131
|
-
if (!fs.existsSync(memoriesRoot))
|
|
132
|
-
return result;
|
|
133
|
-
for (const entry of fs.readdirSync(memoriesRoot, { withFileTypes: true })) {
|
|
150
|
+
/** Plan v1 → v2 conversion for every `.md` file under `dir`. Read-only. */
|
|
151
|
+
function planDir(dir, memoriesRoot, result) {
|
|
152
|
+
if (!fs.existsSync(dir))
|
|
153
|
+
return;
|
|
154
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
134
155
|
if (!entry.isDirectory())
|
|
135
156
|
continue;
|
|
136
|
-
const
|
|
137
|
-
for (const name of fs.readdirSync(
|
|
157
|
+
const sub = path.join(dir, entry.name);
|
|
158
|
+
for (const name of fs.readdirSync(sub)) {
|
|
138
159
|
if (!name.endsWith(".md"))
|
|
139
160
|
continue;
|
|
140
|
-
const filePath = path.join(
|
|
161
|
+
const filePath = path.join(sub, name);
|
|
141
162
|
result.scanned++;
|
|
142
163
|
const plan = planConversion(filePath, memoriesRoot);
|
|
143
164
|
if (!plan) {
|
|
144
165
|
result.skippedV2++;
|
|
145
166
|
continue;
|
|
146
167
|
}
|
|
147
|
-
if (!opts.dryRun) {
|
|
148
|
-
fs.mkdirSync(path.dirname(plan.toPath), { recursive: true });
|
|
149
|
-
fs.writeFileSync(plan.toPath, serialize(plan.fm, plan.body), "utf8");
|
|
150
|
-
if (plan.toPath !== plan.fromPath)
|
|
151
|
-
fs.unlinkSync(plan.fromPath);
|
|
152
|
-
}
|
|
153
168
|
result.converted++;
|
|
154
169
|
result.plans.push(plan);
|
|
155
170
|
}
|
|
156
171
|
}
|
|
172
|
+
}
|
|
173
|
+
/** Apply conversion plans to disk (real run only — never in dry-run). */
|
|
174
|
+
function applyPlans(result) {
|
|
175
|
+
for (const plan of result.plans) {
|
|
176
|
+
fs.mkdirSync(path.dirname(plan.toPath), { recursive: true });
|
|
177
|
+
fs.writeFileSync(plan.toPath, serialize(plan.fm, plan.body), "utf8");
|
|
178
|
+
if (plan.toPath !== plan.fromPath)
|
|
179
|
+
fs.unlinkSync(plan.fromPath);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
export function migrateV2(opts) {
|
|
183
|
+
const { memories: memoriesRoot } = paths();
|
|
184
|
+
const result = {
|
|
185
|
+
scanned: 0,
|
|
186
|
+
converted: 0,
|
|
187
|
+
skippedV2: 0,
|
|
188
|
+
plans: [],
|
|
189
|
+
legacyBackup: null,
|
|
190
|
+
};
|
|
191
|
+
const legacy = findLegacyDataRoot();
|
|
192
|
+
if (legacy) {
|
|
193
|
+
result.legacyBackup = legacyBackupPath(legacy);
|
|
194
|
+
if (opts.dryRun) {
|
|
195
|
+
// D44: dry-run previews the legacy files in place — nothing is moved,
|
|
196
|
+
// so the preview actually shows what would convert (issue #7).
|
|
197
|
+
planDir(path.join(legacy, "memories"), memoriesRoot, result);
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
mergeLegacyDataRoot(legacy, result.legacyBackup);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
planDir(memoriesRoot, memoriesRoot, result);
|
|
204
|
+
if (!opts.dryRun)
|
|
205
|
+
applyPlans(result);
|
|
157
206
|
return result;
|
|
158
207
|
}
|
package/docs/CURATOR.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Curator Convention
|
|
2
|
+
|
|
3
|
+
The **curator** is the human who tends a project's shared memory. It is a
|
|
4
|
+
documented convention, not a permission system — anyone on the team can act
|
|
5
|
+
as curator; the tool records *who* did *what* (the audit trail), it doesn't
|
|
6
|
+
decide who is *allowed* to.
|
|
7
|
+
|
|
8
|
+
## What the curator does
|
|
9
|
+
|
|
10
|
+
1. **Triage proposals.** `open-memex propose` puts memories up for review.
|
|
11
|
+
The curator reads them, then `open-memex promote <id>` to approve or
|
|
12
|
+
`open-memex promote <id> --reject` to send back, with `--note` saying why.
|
|
13
|
+
2. **Resolve conflicts.** `open-memex resolve` lists file-level and semantic
|
|
14
|
+
conflicts. The curator merges or picks a winner — Core never silently
|
|
15
|
+
resolves a semantic conflict; both sides stay `active` until a human
|
|
16
|
+
decides.
|
|
17
|
+
3. **Keep the garden.** Deprecate what's stale (`open-memex status <id>
|
|
18
|
+
deprecated`), supersede what's been replaced, forget what's noise.
|
|
19
|
+
Shared memory rots without pruning.
|
|
20
|
+
4. **Watch the pipeline.** `open-memex sync-status` shows the outbox, review
|
|
21
|
+
states, and uncommitted files; `open-memex pr-status --apply` maps the
|
|
22
|
+
GitHub PR state back onto `review_state`.
|
|
23
|
+
|
|
24
|
+
## Admission bar
|
|
25
|
+
|
|
26
|
+
Approve a memory when it is:
|
|
27
|
+
|
|
28
|
+
- **True** — you believe it, or it cites something verifiable.
|
|
29
|
+
- **Durable** — it will still matter in a month. Chat logs are not memories.
|
|
30
|
+
- **Scoped right** — project knowledge in project scope; personal stuff stays
|
|
31
|
+
personal (personal memories are never the curator's business).
|
|
32
|
+
- **Well-typed** — `type` says what it IS (`decision`, `gotcha`, `lesson`…),
|
|
33
|
+
`tags` say what it's ABOUT.
|
|
34
|
+
|
|
35
|
+
Send back (don't silently fix) when it's vague, duplicated, or belongs in
|
|
36
|
+
`docs/` as formal documentation instead — memory is the fast-changing long
|
|
37
|
+
tail, `docs/` is the slow-changing core.
|
|
38
|
+
|
|
39
|
+
## What the curator does NOT do
|
|
40
|
+
|
|
41
|
+
- **Never rewrite someone else's memory in place.** Propose a superseding
|
|
42
|
+
memory instead — the chain (`supersedes` / `superseded_by`) is the audit
|
|
43
|
+
trail.
|
|
44
|
+
- **Never approve their own proposals silently in team mode.** That's what
|
|
45
|
+
`--local-approve` is for — solo projects only.
|
|
46
|
+
- **Never pull rank with the tool.** If the team disagrees with a call, the
|
|
47
|
+
disagreement itself is worth a memory.
|
|
48
|
+
|
|
49
|
+
## Cadence
|
|
50
|
+
|
|
51
|
+
There is no required cadence. A workable default: triage proposals at the
|
|
52
|
+
end of each work chunk (the same checkpoint where §3.5 distillation runs),
|
|
53
|
+
and do a pruning pass when `sync-status` starts feeling noisy.
|
|
54
|
+
|
|
55
|
+
## Solo mode
|
|
56
|
+
|
|
57
|
+
No team, no curator needed. `open-memex propose --local-approve` records
|
|
58
|
+
`approved_by: self` and skips the PR. You are the curator, the proposer,
|
|
59
|
+
and the gardener — the same hygiene rules apply, just faster.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# OpenMemex 测试计划(v0.4.0-alpha.10)
|
|
2
|
+
|
|
3
|
+
> 自动化部分:`node --experimental-strip-types scripts/test-full.ts`
|
|
4
|
+
> 62 项全过(26 个 CLI 命令 + 11 个 MCP tool),隔离环境运行,不碰真实数据。
|
|
5
|
+
> 下面是机器/账号相关的部分,需要 Stone 在真机上过一遍。
|
|
6
|
+
|
|
7
|
+
## A. Windows 真机 + VS Code Copilot
|
|
8
|
+
|
|
9
|
+
- [ ] `npm i -g open-memex@alpha` 全局安装,`open-memex --version` 显示正确版本
|
|
10
|
+
- [ ] 在一个真实项目目录跑 `open-memex init`(不加 `--yes`,走一遍交互)
|
|
11
|
+
- 确认 `.vscode/mcp.json` 生成,`~/.copilot/copilot-instructions.md` 合并写入(不覆盖已有内容)
|
|
12
|
+
- [ ] 重启 VS Code,Copilot Chat 里问 "what do you remember about this project?"
|
|
13
|
+
- 预期:MCP 连接成功,能调用 memory_search
|
|
14
|
+
- [ ] `open-memex add "windows 真机测试" --type fact`,再让 Copilot 搜出来
|
|
15
|
+
- [ ] 中文路径项目、中文记忆内容各试一条(CJK 索引)
|
|
16
|
+
|
|
17
|
+
## B. 真实 GitHub PR 全流程(review 工作流)
|
|
18
|
+
|
|
19
|
+
在一个真实 repo 里:
|
|
20
|
+
|
|
21
|
+
- [ ] `open-memex add "PR流程测试" --scope personal` → `propose --to project` → `sync-status` 看到 outbox draft
|
|
22
|
+
- [ ] `open-memex submit <id>`(留在当前分支,本地 commit)
|
|
23
|
+
- [ ] 手动 `git push` + 开 PR
|
|
24
|
+
- [ ] 在 PR 里点 Approve → 回来跑 `open-memex pr-status`(先看 report),再 `pr-status --apply`
|
|
25
|
+
- 预期:memory 变成 approved,`approved_by` 是 reviewer
|
|
26
|
+
- [ ] 找一条让 reviewer 点 "Request changes" → `pr-status --apply`
|
|
27
|
+
- 预期:只给 suggestion,**不**自动 reject(D32)
|
|
28
|
+
- [ ] Merge PR → `pr-status --apply`
|
|
29
|
+
- 预期:memory 变成 published
|
|
30
|
+
- [ ] `open-memex resolve` 无冲突时输出 "(no conflicted memory files)"
|
|
31
|
+
|
|
32
|
+
## C. 其他编辑器 MCP 集成
|
|
33
|
+
|
|
34
|
+
- [ ] Cursor:`open-memex mcp --print-config cursor` → 贴到 Cursor MCP 配置 → 能连上
|
|
35
|
+
- [ ] opencode:`open-memex init --client opencode` → `opencode.jsonc` 生效
|
|
36
|
+
- [ ] Claude Code:`open-memex mcp --print-config claude` 给出的 `claude mcp add` 命令能跑通
|
|
37
|
+
|
|
38
|
+
## D. 跨机迁移(export/import 真实场景)
|
|
39
|
+
|
|
40
|
+
- [ ] 本机:`open-memex export --all -o migration.tar.gz`(含 private 的全量)
|
|
41
|
+
- [ ] 本机:`open-memex export -o share.tar.gz`(默认排除 private)→ 解包检查 manifest,确认没有 visibility:private 的条目
|
|
42
|
+
- [ ] 另一台机器:`open-memex import migration.tar.gz --dry-run` 先看预览,再正式 import
|
|
43
|
+
- 预期:project memory re-key 到新机器的 project scope,进 outbox 当 draft;personal 进 personal
|
|
44
|
+
- [ ] 同一个 bundle 导两次 → 第二次 "skipped N identical"
|
|
45
|
+
|
|
46
|
+
## E. Agent 会话行为(D42 / §3.5)
|
|
47
|
+
|
|
48
|
+
- [ ] 新开一个 agent 会话(MCP 已接),看 initialize 返回的 instructions 里有没有 session-start 同步指引
|
|
49
|
+
- [ ] 对 agent 说 "sync memory" / "同步记忆"
|
|
50
|
+
- 预期:agent 走 memory_status → 摘要 → 问你要同步哪条(而不是直接翻 appdata)
|
|
51
|
+
- [ ] 长对话中 agent 是否在检查点提议蒸馏(§3.5),提议后是否等你批准才保存(D42)
|
|
52
|
+
|
|
53
|
+
## F. 冲突解决(3-way merge)
|
|
54
|
+
|
|
55
|
+
- [ ] 两台机器(或两个 clone)同时改同一条 project memory,各自 submit + push,一边 pull 制造 diverged
|
|
56
|
+
- 预期:`open-memex pull` 明确报错退出,不自动 merge
|
|
57
|
+
- [ ] 手动 merge 后 `open-memex resolve <id>` 看 3-way 展示(base/outbox/repo),手动解决
|
|
58
|
+
|
|
59
|
+
## G. 同事 pilot(1–2 人,Stone 私下选)
|
|
60
|
+
|
|
61
|
+
- [ ] 对方 `npx open-memex@alpha init` 走通
|
|
62
|
+
- [ ] 对方能 propose → 你这边能看到 PR → promote 流程走通
|
|
63
|
+
- [ ] 收集反馈:哪里卡、哪里不符合直觉
|
|
64
|
+
|
|
65
|
+
## H. 已知问题观察
|
|
66
|
+
|
|
67
|
+
- [ ] better-sqlite3 在 Node 24 退出时偶发 crash(exit 134):注意是否丢数据(预期:不丢,只影响退出码)
|
|
68
|
+
- [ ] `memory_list` 默认只列 project scope 是否符合预期(Stone 已定保持现状)
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
测试中发现的 bug 直接记到 GitHub issue;改完后更新本文档的复选框。
|
package/docs/V2-DESIGN.md
CHANGED
|
@@ -534,8 +534,10 @@ requirement: personal data never touches third-party services). Benchmarks to tr
|
|
|
534
534
|
| atlaso-labs/codex | Codex marketplace | long-term memory plugin for Codex (hooks + MCP + cloud-sync upsell) | **direct comparable** for a future Codex plugin; their cloud upsell vs our local-first |
|
|
535
535
|
|
|
536
536
|
(Star counts / funding as of Sep 2026 — re-verify before quoting publicly.)
|
|
537
|
-
- **Phase 3 — Org layer.** Org memory repo ·
|
|
538
|
-
|
|
537
|
+
- **Phase 3 — Org layer.** Org memory repo · `examples/remote-server/` ·
|
|
538
|
+
curator convention ✅ `docs/CURATOR.md` (2026-09-29, pulled forward) ·
|
|
539
|
+
distill-to-AGENTS.md assist ✅ `open-memex distill-agents` (2026-09-29, pulled forward) ·
|
|
540
|
+
export/import archive command ✅ `open-memex export` / `import` (2026-09-29, pulled forward, D40).
|
|
539
541
|
- **Phase 4 — Future, signal-gated.** Cloud `RemoteProvider` customization only on: multi-private-repo
|
|
540
542
|
sharing needs, fine-grained ACL, audit/compliance mandates · optional API-backed exporters/providers
|
|
541
543
|
for enterprise knowledge systems.
|
|
@@ -858,6 +860,28 @@ requirement: personal data never touches third-party services). Benchmarks to tr
|
|
|
858
860
|
saved via `memory_add` with the new optional `source` param set to
|
|
859
861
|
`"inference"` (default `"tool"`). *Rationale: closes the 0.4.0 TODO from D41 —
|
|
860
862
|
the design's capture loop now reaches the agent. Implemented 2026-09-29.*
|
|
863
|
+
- **D43** — `distill-agents` output gains a "Memory hygiene (open-memex)"
|
|
864
|
+
footer carrying the §3.5 checkpoint guidance (propose 1–3 distilled captures
|
|
865
|
+
at checkpoints; save nothing without approval; prefer `reference` over
|
|
866
|
+
copying). *Rationale: double insurance for opencode users, who never see the
|
|
867
|
+
MCP handshake instructions or the `init`-written instruction files — but
|
|
868
|
+
opencode reads AGENTS.md natively, so the distilled snippet teaches the
|
|
869
|
+
checkpoint habit wherever it lands. Approved 2026-09-29.*
|
|
870
|
+
- **D44** — `migrate --to-v2` hotfix (issue #7, 0.4.1): (1) `--dry-run` now
|
|
871
|
+
previews the legacy `my-o-memory` files in place — per-file conversion plans
|
|
872
|
+
without moving anything (previously it scanned only the new, still-empty
|
|
873
|
+
root and always reported "0 files", making the preview useless); (2) dry-run
|
|
874
|
+
no longer prints the false "legacy data dir merged" line; (3) the Windows
|
|
875
|
+
EPERM on the legacy-dir backup rename is caught and rethrown as an actionable
|
|
876
|
+
message (close the program holding the folder — e.g. a running MCP server —
|
|
877
|
+
and re-run; nothing was deleted), and the CLI prints it as `Error: …` with
|
|
878
|
+
exit 1 instead of a raw syscall stack; (4) `parseFlags` accepts `--key=value`
|
|
879
|
+
in addition to `--key value` (the `=` form was silently misparsed before,
|
|
880
|
+
dropping the flag — which can turn a `--dry-run` into a real run).
|
|
881
|
+
*Rationale: a preview that shows nothing is worse than no preview — the user
|
|
882
|
+
cannot confirm what the real run will do; and a destructive-path failure must
|
|
883
|
+
speak in user terms. Shipped as 0.4.1 hotfix on the stable line. Approved
|
|
884
|
+
2026-09-29.*
|
|
861
885
|
- **D41** — The type taxonomy is reconciled to 11 types with one-line definitions
|
|
862
886
|
(§3.1): `fact` `preference` `decision` `constraint` `todo` `knowledge` `howto`
|
|
863
887
|
`gotcha` `lesson` `observation` `reference`. Merged away: `warning`→`gotcha`,
|
package/package.json
CHANGED