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/src/export.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
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.ts";
|
|
18
|
+
import {
|
|
19
|
+
readMemoryFile,
|
|
20
|
+
writeMemoryFile,
|
|
21
|
+
normalizeFrontmatter,
|
|
22
|
+
} from "./store/markdown.ts";
|
|
23
|
+
import { upsertFromFile } from "./store/sync.ts";
|
|
24
|
+
import { contentHash } from "./store/lifecycle.ts";
|
|
25
|
+
|
|
26
|
+
const EXPORT_FORMAT = "open-memex-export/1";
|
|
27
|
+
|
|
28
|
+
function fail(msg: string): never {
|
|
29
|
+
throw new Error(`[open-memex] ${msg}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function checkTar(): void {
|
|
33
|
+
try {
|
|
34
|
+
execFileSync("tar", ["--version"], { stdio: ["ignore", "pipe", "ignore"] });
|
|
35
|
+
} catch {
|
|
36
|
+
fail("the `tar` command is required for export/import but was not found on PATH");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ExportOptions {
|
|
41
|
+
/** scope keys to include (resolved by the caller) */
|
|
42
|
+
scopeKeys: string[];
|
|
43
|
+
type?: string;
|
|
44
|
+
tag?: string;
|
|
45
|
+
/** D40: include visibility:private memories */
|
|
46
|
+
includePrivate?: boolean;
|
|
47
|
+
outFile?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ExportResult {
|
|
51
|
+
file: string;
|
|
52
|
+
exported: number;
|
|
53
|
+
skippedPrivate: number;
|
|
54
|
+
includePrivate: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface ExportRow {
|
|
58
|
+
id: string;
|
|
59
|
+
scope_key: string;
|
|
60
|
+
scope: string;
|
|
61
|
+
visibility: string;
|
|
62
|
+
type: string;
|
|
63
|
+
file_path: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function exportMemories(opts: ExportOptions): ExportResult {
|
|
67
|
+
checkTar();
|
|
68
|
+
const includePrivate = opts.includePrivate ?? false;
|
|
69
|
+
|
|
70
|
+
let sql = `SELECT id, scope_key, scope, visibility, type, file_path FROM memories WHERE scope_key IN (${opts.scopeKeys.map(() => "?").join(",")})`;
|
|
71
|
+
const params: unknown[] = [...opts.scopeKeys];
|
|
72
|
+
if (!includePrivate) sql += ` AND visibility != 'private'`;
|
|
73
|
+
if (opts.type) {
|
|
74
|
+
sql += ` AND type = ?`;
|
|
75
|
+
params.push(opts.type);
|
|
76
|
+
}
|
|
77
|
+
if (opts.tag) {
|
|
78
|
+
sql += ` AND (',' || tags || ',' LIKE ?)`;
|
|
79
|
+
params.push(`%,${opts.tag},%`);
|
|
80
|
+
}
|
|
81
|
+
sql += ` ORDER BY updated_at DESC`;
|
|
82
|
+
const rows = db().prepare(sql).all(...(params as any[])) as ExportRow[];
|
|
83
|
+
|
|
84
|
+
const skippedPrivate = includePrivate
|
|
85
|
+
? 0
|
|
86
|
+
: (db().prepare(
|
|
87
|
+
`SELECT COUNT(*) AS n FROM memories WHERE scope_key IN (${opts.scopeKeys.map(() => "?").join(",")}) AND visibility = 'private'`,
|
|
88
|
+
).get(...(opts.scopeKeys as any[])) as { n: number }).n;
|
|
89
|
+
|
|
90
|
+
const stage = fs.mkdtempSync(path.join(os.tmpdir(), "open-memex-export-"));
|
|
91
|
+
try {
|
|
92
|
+
const memDir = path.join(stage, "memories");
|
|
93
|
+
fs.mkdirSync(memDir, { recursive: true });
|
|
94
|
+
const manifestEntries: Array<{ id: string; scope: string; file: string }> = [];
|
|
95
|
+
let exported = 0;
|
|
96
|
+
for (const r of rows) {
|
|
97
|
+
const mf = readMemoryFile(r.file_path);
|
|
98
|
+
if (!mf) continue; // stale index row — skip, don't fail the export
|
|
99
|
+
const rel = path.join(r.scope, `${r.id}.md`);
|
|
100
|
+
const dest = path.join(memDir, rel);
|
|
101
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
102
|
+
fs.copyFileSync(r.file_path, dest);
|
|
103
|
+
manifestEntries.push({ id: r.id, scope: r.scope, file: path.join("memories", rel) });
|
|
104
|
+
exported++;
|
|
105
|
+
}
|
|
106
|
+
const manifest = {
|
|
107
|
+
format: EXPORT_FORMAT,
|
|
108
|
+
exported_at: new Date().toISOString(),
|
|
109
|
+
open_memex_version: readPackageVersion(),
|
|
110
|
+
include_private: includePrivate,
|
|
111
|
+
filters: {
|
|
112
|
+
scope_keys: opts.scopeKeys,
|
|
113
|
+
type: opts.type ?? null,
|
|
114
|
+
tag: opts.tag ?? null,
|
|
115
|
+
},
|
|
116
|
+
memories: manifestEntries,
|
|
117
|
+
};
|
|
118
|
+
fs.writeFileSync(path.join(stage, "manifest.json"), JSON.stringify(manifest, null, 2), "utf8");
|
|
119
|
+
|
|
120
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15);
|
|
121
|
+
const outFile = opts.outFile ?? path.resolve(`open-memex-export-${stamp}.tar.gz`);
|
|
122
|
+
execFileSync("tar", ["-czf", outFile, "-C", stage, "manifest.json", "memories"], {
|
|
123
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
124
|
+
});
|
|
125
|
+
return { file: outFile, exported, skippedPrivate, includePrivate };
|
|
126
|
+
} finally {
|
|
127
|
+
fs.rmSync(stage, { recursive: true, force: true });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function readPackageVersion(): string {
|
|
132
|
+
try {
|
|
133
|
+
const here = new URL(import.meta.url);
|
|
134
|
+
const pkg = path.join(path.dirname(here.pathname), "..", "package.json");
|
|
135
|
+
return (JSON.parse(fs.readFileSync(pkg, "utf8")) as { version: string }).version;
|
|
136
|
+
} catch {
|
|
137
|
+
return "unknown";
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// Import
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
export interface ImportOptions {
|
|
146
|
+
/** current project scope key — project memories are re-keyed to it */
|
|
147
|
+
projectScopeKey: string;
|
|
148
|
+
dryRun?: boolean;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface ImportResult {
|
|
152
|
+
imported: number;
|
|
153
|
+
skippedIdentical: number;
|
|
154
|
+
skippedConflict: { id: string; file: string }[];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function importBundle(bundlePath: string, opts: ImportOptions): ImportResult {
|
|
158
|
+
checkTar();
|
|
159
|
+
if (!fs.existsSync(bundlePath)) fail(`bundle not found: ${bundlePath}`);
|
|
160
|
+
const stage = fs.mkdtempSync(path.join(os.tmpdir(), "open-memex-import-"));
|
|
161
|
+
try {
|
|
162
|
+
execFileSync("tar", ["-xzf", path.resolve(bundlePath), "-C", stage], {
|
|
163
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
164
|
+
});
|
|
165
|
+
const manifestPath = path.join(stage, "manifest.json");
|
|
166
|
+
if (!fs.existsSync(manifestPath)) fail("not an open-memex export bundle (manifest.json missing)");
|
|
167
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as {
|
|
168
|
+
format: string;
|
|
169
|
+
memories: Array<{ id: string; scope: string; file: string }>;
|
|
170
|
+
};
|
|
171
|
+
if (manifest.format !== EXPORT_FORMAT) {
|
|
172
|
+
fail(`unsupported bundle format: ${manifest.format} (expected ${EXPORT_FORMAT})`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const result: ImportResult = { imported: 0, skippedIdentical: 0, skippedConflict: [] };
|
|
176
|
+
const existingStmt = db().prepare(`SELECT content_hash FROM memories WHERE id = ?`);
|
|
177
|
+
for (const entry of manifest.memories ?? []) {
|
|
178
|
+
const src = path.join(stage, entry.file);
|
|
179
|
+
const mf = readMemoryFile(src);
|
|
180
|
+
if (!mf) continue;
|
|
181
|
+
const fm = normalizeFrontmatter({ ...mf.fm } as unknown as Record<string, unknown>);
|
|
182
|
+
// Re-key to this machine: personal stays personal; project memories
|
|
183
|
+
// adopt the current project's scope key (keys embed a path hash).
|
|
184
|
+
if (fm.scope === "project") fm.scope_key = opts.projectScopeKey;
|
|
185
|
+
else if (fm.scope === "personal") fm.scope_key = "personal";
|
|
186
|
+
|
|
187
|
+
const existing = existingStmt.get(fm.id) as { content_hash: string } | undefined;
|
|
188
|
+
if (existing) {
|
|
189
|
+
if (existing.content_hash === contentHash(mf.body)) result.skippedIdentical++;
|
|
190
|
+
else result.skippedConflict.push({ id: fm.id, file: entry.file });
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (opts.dryRun) {
|
|
194
|
+
result.imported++;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const { filePath } = writeMemoryFile(fm, mf.body);
|
|
198
|
+
const written = readMemoryFile(filePath);
|
|
199
|
+
if (written) upsertFromFile(written);
|
|
200
|
+
result.imported++;
|
|
201
|
+
}
|
|
202
|
+
return result;
|
|
203
|
+
} finally {
|
|
204
|
+
fs.rmSync(stage, { recursive: true, force: true });
|
|
205
|
+
}
|
|
206
|
+
}
|
package/src/mcp.ts
CHANGED
|
@@ -137,6 +137,22 @@ export async function runMcpServer() {
|
|
|
137
137
|
syncScope(PERSONAL_SCOPE.key, "session");
|
|
138
138
|
console.error(`[open-memex] MCP server up. scope=${scope.key}`);
|
|
139
139
|
|
|
140
|
+
// D12: pulls are explicit by default — session start never touches the
|
|
141
|
+
// network. With sync.autoPull, one best-effort pull; a failure never
|
|
142
|
+
// blocks the session, it just logs and continues.
|
|
143
|
+
if (cfg.sync?.autoPull) {
|
|
144
|
+
try {
|
|
145
|
+
const { GitProvider } = await import("./providers/git.ts");
|
|
146
|
+
const r = new GitProvider().pull(process.cwd());
|
|
147
|
+
syncScope(scope.key, "pull");
|
|
148
|
+
console.error(
|
|
149
|
+
`[open-memex] auto-pull: ${r.branch} ${r.fastForwarded ? "fast-forwarded" : "already up to date"}`,
|
|
150
|
+
);
|
|
151
|
+
} catch (e) {
|
|
152
|
+
console.error(`[open-memex] auto-pull skipped: ${(e as Error).message}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
140
156
|
// D26: re-sync on every request, not just at startup. The in-repo dir
|
|
141
157
|
// follows the current git branch, so a branch switch mid-session would
|
|
142
158
|
// otherwise leave the index pointing at files that no longer exist.
|
|
@@ -0,0 +1,191 @@
|
|
|
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
|
+
|
|
16
|
+
function fail(msg: string): never {
|
|
17
|
+
throw new Error(`[open-memex] ${msg}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Run git, raising a readable error. `timeoutMs` guards network calls. */
|
|
21
|
+
function git(root: string, args: string[], timeoutMs = 0): string {
|
|
22
|
+
try {
|
|
23
|
+
return execFileSync("git", args, {
|
|
24
|
+
cwd: root,
|
|
25
|
+
encoding: "utf8",
|
|
26
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
27
|
+
...(timeoutMs > 0 ? { timeout: timeoutMs } : {}),
|
|
28
|
+
}).trim();
|
|
29
|
+
} catch (e) {
|
|
30
|
+
const err = e as { stderr?: string; message?: string; code?: string };
|
|
31
|
+
if (err.code === "ETIMEDOUT") 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
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Provider capability model (§2) — every provider declares what it can do.
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
export interface MemoryProviderCapabilities {
|
|
42
|
+
read: boolean;
|
|
43
|
+
write: boolean;
|
|
44
|
+
delete: boolean;
|
|
45
|
+
/** version / audit trail */
|
|
46
|
+
history: boolean;
|
|
47
|
+
sync: "none" | "pull" | "push" | "bidirectional";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface MemoryProvider {
|
|
51
|
+
readonly name: string;
|
|
52
|
+
readonly capabilities: MemoryProviderCapabilities;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface PullReceipt {
|
|
56
|
+
at: string; // RFC 3339
|
|
57
|
+
branch: string;
|
|
58
|
+
remote: string;
|
|
59
|
+
before: string; // HEAD sha before the pull
|
|
60
|
+
after: string; // HEAD sha after the pull
|
|
61
|
+
fastForwarded: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface PushReceipt {
|
|
65
|
+
at: string; // RFC 3339
|
|
66
|
+
branch: string;
|
|
67
|
+
remote: string;
|
|
68
|
+
head: string; // sha pushed
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface GitSyncStatus {
|
|
72
|
+
branch: string;
|
|
73
|
+
upstream: string | null;
|
|
74
|
+
remote: string | null;
|
|
75
|
+
ahead: number;
|
|
76
|
+
behind: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const FETCH_TIMEOUT_MS = 30_000;
|
|
80
|
+
|
|
81
|
+
export class GitProvider implements MemoryProvider {
|
|
82
|
+
readonly name = "git";
|
|
83
|
+
readonly capabilities: MemoryProviderCapabilities = {
|
|
84
|
+
read: true,
|
|
85
|
+
write: true,
|
|
86
|
+
delete: false,
|
|
87
|
+
history: true,
|
|
88
|
+
sync: "bidirectional",
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** Current branch + upstream, or a clear failure when git can't answer. */
|
|
92
|
+
status(root: string): GitSyncStatus {
|
|
93
|
+
git(root, ["rev-parse", "--git-dir"]);
|
|
94
|
+
const branch =
|
|
95
|
+
git(root, ["branch", "--show-current"]) ||
|
|
96
|
+
git(root, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
97
|
+
let upstream: string | null = null;
|
|
98
|
+
try {
|
|
99
|
+
upstream = git(root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
|
|
100
|
+
} catch {
|
|
101
|
+
upstream = null;
|
|
102
|
+
}
|
|
103
|
+
let ahead = 0;
|
|
104
|
+
let behind = 0;
|
|
105
|
+
if (upstream) {
|
|
106
|
+
const counts = git(root, ["rev-list", "--left-right", "--count", `HEAD...${upstream}`]);
|
|
107
|
+
const [a, b] = counts.split(/\s+/).map((n) => parseInt(n, 10));
|
|
108
|
+
ahead = Number.isFinite(a) ? a : 0;
|
|
109
|
+
behind = Number.isFinite(b) ? b : 0;
|
|
110
|
+
}
|
|
111
|
+
const remote = upstream ? upstream.split("/")[0] : null;
|
|
112
|
+
return { branch, upstream, remote, ahead, behind };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Explicit pull: fetch + fast-forward only (§9).
|
|
117
|
+
* Diverged branches are NOT merged — the user resolves them by hand.
|
|
118
|
+
*/
|
|
119
|
+
pull(root: string): PullReceipt {
|
|
120
|
+
const st = this.status(root);
|
|
121
|
+
if (!st.upstream || !st.remote) {
|
|
122
|
+
fail(
|
|
123
|
+
`branch ${st.branch} has no upstream — set one with ` +
|
|
124
|
+
`\`git push -u <remote> ${st.branch}\`, then pull again`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
git(root, ["fetch", st.remote], FETCH_TIMEOUT_MS);
|
|
128
|
+
const before = git(root, ["rev-parse", "HEAD"]);
|
|
129
|
+
const remoteSha = git(root, ["rev-parse", st.upstream]);
|
|
130
|
+
if (before === remoteSha) {
|
|
131
|
+
return {
|
|
132
|
+
at: new Date().toISOString(),
|
|
133
|
+
branch: st.branch,
|
|
134
|
+
remote: st.remote,
|
|
135
|
+
before,
|
|
136
|
+
after: before,
|
|
137
|
+
fastForwarded: false,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
// Fast-forward is possible iff HEAD is an ancestor of the upstream.
|
|
141
|
+
let ffPossible = false;
|
|
142
|
+
try {
|
|
143
|
+
git(root, ["merge-base", "--is-ancestor", "HEAD", st.upstream]);
|
|
144
|
+
ffPossible = true;
|
|
145
|
+
} catch {
|
|
146
|
+
ffPossible = false;
|
|
147
|
+
}
|
|
148
|
+
if (!ffPossible) {
|
|
149
|
+
fail(
|
|
150
|
+
`branch ${st.branch} has diverged from ${st.upstream} — ` +
|
|
151
|
+
`open-memex never force-merges; resolve it by hand ` +
|
|
152
|
+
`(rebase or merge), then pull again`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
git(root, ["merge", "--ff-only", st.upstream]);
|
|
156
|
+
const after = git(root, ["rev-parse", "HEAD"]);
|
|
157
|
+
return {
|
|
158
|
+
at: new Date().toISOString(),
|
|
159
|
+
branch: st.branch,
|
|
160
|
+
remote: st.remote,
|
|
161
|
+
before,
|
|
162
|
+
after,
|
|
163
|
+
fastForwarded: true,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Explicit push of the current branch. Never called automatically. */
|
|
168
|
+
push(root: string): PushReceipt {
|
|
169
|
+
const st = this.status(root);
|
|
170
|
+
if (!st.remote) {
|
|
171
|
+
// No upstream yet: push explicitly sets it (-u), still user-invoked.
|
|
172
|
+
const remotes = git(root, ["remote"]);
|
|
173
|
+
const remote = remotes.split("\n").map((r) => r.trim()).filter(Boolean)[0];
|
|
174
|
+
if (!remote) fail("no git remote configured — add one before pushing");
|
|
175
|
+
git(root, ["push", "-u", remote, st.branch], FETCH_TIMEOUT_MS);
|
|
176
|
+
return {
|
|
177
|
+
at: new Date().toISOString(),
|
|
178
|
+
branch: st.branch,
|
|
179
|
+
remote,
|
|
180
|
+
head: git(root, ["rev-parse", "HEAD"]),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
git(root, ["push", st.remote, st.branch], FETCH_TIMEOUT_MS);
|
|
184
|
+
return {
|
|
185
|
+
at: new Date().toISOString(),
|
|
186
|
+
branch: st.branch,
|
|
187
|
+
remote: st.remote,
|
|
188
|
+
head: git(root, ["rev-parse", "HEAD"]),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
}
|
package/src/store/sync.ts
CHANGED
|
@@ -153,7 +153,7 @@ export function syncScope(scopeKey: string, kind: SyncKind = "auto"): SyncStats
|
|
|
153
153
|
// ---------------------------------------------------------------------------
|
|
154
154
|
|
|
155
155
|
/** What triggered the sync — shown in sync-status so the user can see it. */
|
|
156
|
-
export type SyncKind = "session" | "request" | "cli" | "submit" | "auto";
|
|
156
|
+
export type SyncKind = "session" | "request" | "cli" | "submit" | "pull" | "push" | "auto";
|
|
157
157
|
|
|
158
158
|
export interface SyncStateEntry {
|
|
159
159
|
lastSyncAt: string; // RFC 3339
|
package/src/store/v2migrate.ts
CHANGED
|
@@ -118,23 +118,48 @@ export interface MigrateV2Result {
|
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
/**
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
* backup path, or null when no legacy dir exists.
|
|
121
|
+
* Locate a legacy `my-o-memory` data root next to the current root.
|
|
122
|
+
* Pure lookup — no disk writes.
|
|
124
123
|
*/
|
|
125
|
-
function
|
|
124
|
+
export function findLegacyDataRoot(): string | null {
|
|
126
125
|
const { root } = paths();
|
|
127
126
|
const legacy = path.join(path.dirname(root), "my-o-memory");
|
|
128
127
|
if (!fs.existsSync(legacy) || !fs.statSync(legacy).isDirectory()) return null;
|
|
128
|
+
return legacy;
|
|
129
|
+
}
|
|
129
130
|
|
|
131
|
+
/** Dated backup path for a legacy data root. Pure — no disk writes. */
|
|
132
|
+
export function legacyBackupPath(legacy: string): string {
|
|
130
133
|
const stamp = new Date().toISOString().slice(0, 10);
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
134
|
+
return `${legacy}.backup-${stamp}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Merge a legacy `my-o-memory` data root into the current `open-memex` root.
|
|
139
|
+
* The old dir is renamed to a dated backup — never deleted.
|
|
140
|
+
* Failures throw an actionable Error (no raw syscall dump): on Windows the
|
|
141
|
+
* backup rename typically fails with EPERM when another program holds the
|
|
142
|
+
* folder open.
|
|
143
|
+
*/
|
|
144
|
+
function mergeLegacyDataRoot(legacy: string, backup: string): void {
|
|
145
|
+
const { memories } = paths();
|
|
146
|
+
const fail = (where: string, err: unknown): never => {
|
|
147
|
+
const code = (err as NodeJS.ErrnoException)?.code;
|
|
148
|
+
throw new Error(
|
|
149
|
+
`could not ${where} (${legacy})` +
|
|
150
|
+
(code ? ` [${code}]` : "") +
|
|
151
|
+
`. Another program may be holding the folder open (e.g. a running MCP server, editor, or antivirus). ` +
|
|
152
|
+
`Your memories are safe — nothing was deleted. Close the program and re-run ` +
|
|
153
|
+
`\`open-memex migrate --to-v2\`, or rename the folder to ${backup} yourself and re-run.`,
|
|
154
|
+
);
|
|
155
|
+
};
|
|
156
|
+
const legacyMem = path.join(legacy, "memories");
|
|
157
|
+
if (fs.existsSync(legacyMem)) {
|
|
158
|
+
fs.mkdirSync(memories, { recursive: true });
|
|
159
|
+
for (const entry of fs.readdirSync(legacyMem)) {
|
|
160
|
+
const src = path.join(legacyMem, entry);
|
|
161
|
+
const dst = path.join(memories, entry);
|
|
162
|
+
try {
|
|
138
163
|
if (!fs.existsSync(dst)) {
|
|
139
164
|
fs.renameSync(src, dst);
|
|
140
165
|
} else {
|
|
@@ -145,46 +170,75 @@ function migrateLegacyDataRoot(dryRun: boolean): string | null {
|
|
|
145
170
|
if (!fs.existsSync(d)) fs.renameSync(s, d);
|
|
146
171
|
}
|
|
147
172
|
}
|
|
173
|
+
} catch (err) {
|
|
174
|
+
fail(`move memories from the legacy data dir`, err);
|
|
148
175
|
}
|
|
149
176
|
}
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
150
179
|
fs.renameSync(legacy, backup);
|
|
180
|
+
} catch (err) {
|
|
181
|
+
fail(`back up the legacy data dir`, err);
|
|
151
182
|
}
|
|
152
|
-
return backup;
|
|
153
183
|
}
|
|
154
184
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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 })) {
|
|
185
|
+
/** Plan v1 → v2 conversion for every `.md` file under `dir`. Read-only. */
|
|
186
|
+
function planDir(
|
|
187
|
+
dir: string,
|
|
188
|
+
memoriesRoot: string,
|
|
189
|
+
result: MigrateV2Result,
|
|
190
|
+
): void {
|
|
191
|
+
if (!fs.existsSync(dir)) return;
|
|
192
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
169
193
|
if (!entry.isDirectory()) continue;
|
|
170
|
-
const
|
|
171
|
-
for (const name of fs.readdirSync(
|
|
194
|
+
const sub = path.join(dir, entry.name);
|
|
195
|
+
for (const name of fs.readdirSync(sub)) {
|
|
172
196
|
if (!name.endsWith(".md")) continue;
|
|
173
|
-
const filePath = path.join(
|
|
197
|
+
const filePath = path.join(sub, name);
|
|
174
198
|
result.scanned++;
|
|
175
199
|
const plan = planConversion(filePath, memoriesRoot);
|
|
176
200
|
if (!plan) {
|
|
177
201
|
result.skippedV2++;
|
|
178
202
|
continue;
|
|
179
203
|
}
|
|
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
204
|
result.converted++;
|
|
186
205
|
result.plans.push(plan);
|
|
187
206
|
}
|
|
188
207
|
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Apply conversion plans to disk (real run only — never in dry-run). */
|
|
211
|
+
function applyPlans(result: MigrateV2Result): void {
|
|
212
|
+
for (const plan of result.plans) {
|
|
213
|
+
fs.mkdirSync(path.dirname(plan.toPath), { recursive: true });
|
|
214
|
+
fs.writeFileSync(plan.toPath, serialize(plan.fm, plan.body), "utf8");
|
|
215
|
+
if (plan.toPath !== plan.fromPath) fs.unlinkSync(plan.fromPath);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function migrateV2(opts: { dryRun: boolean }): MigrateV2Result {
|
|
220
|
+
const { memories: memoriesRoot } = paths();
|
|
221
|
+
const result: MigrateV2Result = {
|
|
222
|
+
scanned: 0,
|
|
223
|
+
converted: 0,
|
|
224
|
+
skippedV2: 0,
|
|
225
|
+
plans: [],
|
|
226
|
+
legacyBackup: null,
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const legacy = findLegacyDataRoot();
|
|
230
|
+
if (legacy) {
|
|
231
|
+
result.legacyBackup = legacyBackupPath(legacy);
|
|
232
|
+
if (opts.dryRun) {
|
|
233
|
+
// D44: dry-run previews the legacy files in place — nothing is moved,
|
|
234
|
+
// so the preview actually shows what would convert (issue #7).
|
|
235
|
+
planDir(path.join(legacy, "memories"), memoriesRoot, result);
|
|
236
|
+
} else {
|
|
237
|
+
mergeLegacyDataRoot(legacy, result.legacyBackup);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
planDir(memoriesRoot, memoriesRoot, result);
|
|
242
|
+
if (!opts.dryRun) applyPlans(result);
|
|
189
243
|
return result;
|
|
190
244
|
}
|