open-memex 0.4.0-alpha.7 → 0.4.0

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/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