open-memex 0.3.0 → 0.4.0-alpha.4
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 +12 -1
- package/README.md +78 -8
- package/README.zh-CN.md +67 -7
- package/dist/cli.js +389 -12
- package/dist/config.js +10 -0
- package/dist/github.js +215 -0
- package/dist/index.js +6 -0
- package/dist/init.js +33 -18
- package/dist/mcp.js +71 -9
- package/dist/paths.js +31 -0
- package/dist/retrieve/inject.js +2 -2
- package/dist/retrieve/search.js +31 -6
- package/dist/review.js +437 -0
- package/dist/store/db.js +3 -2
- package/dist/store/lifecycle.js +28 -13
- package/dist/store/markdown.js +49 -8
- package/dist/store/sync.js +74 -6
- package/dist/submit.js +339 -0
- package/dist/tools/ops.js +163 -6
- package/docs/USER-GUIDE.md +195 -0
- package/docs/USER-GUIDE.zh-CN.md +160 -0
- package/docs/V2-DESIGN.md +129 -6
- package/package.json +1 -1
- package/scripts/smoke-mcp.ts +2 -2
- package/src/cli.ts +401 -13
- package/src/config.ts +16 -0
- package/src/github.ts +257 -0
- package/src/index.ts +6 -0
- package/src/init.ts +33 -17
- package/src/mcp.ts +117 -8
- package/src/paths.ts +32 -0
- package/src/retrieve/inject.ts +2 -2
- package/src/retrieve/search.ts +33 -6
- package/src/review.ts +504 -0
- package/src/store/db.ts +3 -2
- package/src/store/lifecycle.ts +30 -12
- package/src/store/markdown.ts +83 -8
- package/src/store/sync.ts +85 -4
- package/src/submit.ts +411 -0
- package/src/tools/ops.ts +192 -7
package/src/store/lifecycle.ts
CHANGED
|
@@ -2,7 +2,12 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { db } from "./db.ts";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
paths,
|
|
7
|
+
projectRoot,
|
|
8
|
+
inRepoMemoriesDirPath,
|
|
9
|
+
} from "../paths.ts";
|
|
10
|
+
import { loadConfig } from "../config.ts";
|
|
6
11
|
import { cjkIndexText } from "../retrieve/cjk.ts";
|
|
7
12
|
import {
|
|
8
13
|
parse,
|
|
@@ -97,25 +102,33 @@ export function findDuplicates(
|
|
|
97
102
|
return { exact, near: near.slice(0, 3) };
|
|
98
103
|
}
|
|
99
104
|
|
|
100
|
-
/** Locate a memory file by id: index first, then
|
|
105
|
+
/** Locate a memory file by id: index file_path first, then dir-scan fallback. */
|
|
101
106
|
export function findMemoryFile(id: string): MemoryFile | null {
|
|
102
107
|
const { memories } = paths();
|
|
103
108
|
let filePath: string | null = null;
|
|
104
109
|
try {
|
|
105
110
|
const row = db()
|
|
106
|
-
.prepare(`SELECT
|
|
107
|
-
.get(id) as {
|
|
108
|
-
if (row)
|
|
109
|
-
const p = path.join(memoriesDirPath(row.scope_key), `${id}.md`);
|
|
110
|
-
if (fs.existsSync(p)) filePath = p;
|
|
111
|
-
}
|
|
111
|
+
.prepare(`SELECT file_path FROM memories WHERE id = ?`)
|
|
112
|
+
.get(id) as { file_path: string } | undefined;
|
|
113
|
+
if (row && fs.existsSync(row.file_path)) filePath = row.file_path;
|
|
112
114
|
} catch {
|
|
113
115
|
// index unavailable — fall through to scan
|
|
114
116
|
}
|
|
115
|
-
if (!filePath
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
if (!filePath) {
|
|
118
|
+
// Fallback scan: appdata scope dirs + the in-repo dir (2B/D24).
|
|
119
|
+
const dirs: string[] = [];
|
|
120
|
+
if (fs.existsSync(memories)) {
|
|
121
|
+
for (const entry of fs.readdirSync(memories, { withFileTypes: true })) {
|
|
122
|
+
if (entry.isDirectory()) dirs.push(path.join(memories, entry.name));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
dirs.push(inRepoMemoriesDirPath(projectRoot(), loadConfig().memoryDir));
|
|
127
|
+
} catch {
|
|
128
|
+
/* ignore */
|
|
129
|
+
}
|
|
130
|
+
for (const dir of dirs) {
|
|
131
|
+
const p = path.join(dir, `${id}.md`);
|
|
119
132
|
if (fs.existsSync(p)) {
|
|
120
133
|
filePath = p;
|
|
121
134
|
break;
|
|
@@ -225,6 +238,11 @@ export function supersede(
|
|
|
225
238
|
updated_at: now,
|
|
226
239
|
supersedes: oldId,
|
|
227
240
|
superseded_by: null,
|
|
241
|
+
review_state: "draft",
|
|
242
|
+
proposed_by: null,
|
|
243
|
+
approved_by: null,
|
|
244
|
+
derived_from: null,
|
|
245
|
+
review_note: null,
|
|
228
246
|
};
|
|
229
247
|
const dir = path.dirname(oldMf.filePath);
|
|
230
248
|
const newPath = path.join(dir, `${newFm.id}.md`);
|
package/src/store/markdown.ts
CHANGED
|
@@ -2,7 +2,11 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { randomBytes } from "node:crypto";
|
|
4
4
|
import yaml from "js-yaml";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
memoriesDirFor,
|
|
7
|
+
memoriesDirPath,
|
|
8
|
+
inRepoMemoriesDirPath,
|
|
9
|
+
} from "../paths.ts";
|
|
6
10
|
|
|
7
11
|
/** v2 content-kind taxonomy (V2-DESIGN §3.1). `type` = what the memory IS. */
|
|
8
12
|
export const MEMORY_TYPE_TAXONOMY = [
|
|
@@ -52,6 +56,62 @@ export interface Frontmatter {
|
|
|
52
56
|
updated_at: string; // RFC 3339
|
|
53
57
|
supersedes: string | null; // on the NEW memory → points BACK (§3.3)
|
|
54
58
|
superseded_by: string | null; // on the OLD memory → points FORWARD
|
|
59
|
+
/** 2B/D25: where this memory sits in the propose → promote workflow. */
|
|
60
|
+
review_state: ReviewState;
|
|
61
|
+
/** Who proposed / approved it (git user.name, fallback OS user). */
|
|
62
|
+
proposed_by: string | null;
|
|
63
|
+
approved_by: string | null;
|
|
64
|
+
/** For propose-copies: the personal memory this was derived from. */
|
|
65
|
+
derived_from: string | null;
|
|
66
|
+
/** Curator note on the latest review transition (e.g. rejection reason). */
|
|
67
|
+
review_note: string | null;
|
|
68
|
+
/** 2B/D29: append-only audit trail of review transitions. Lives in the
|
|
69
|
+
* file (source of truth), so it travels through branches and PRs. */
|
|
70
|
+
review_history: ReviewTransition[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** One step in a memory's review lifecycle — who moved it, when, why. */
|
|
74
|
+
export interface ReviewTransition {
|
|
75
|
+
at: string; // RFC 3339
|
|
76
|
+
by: string; // author identity (git user.name, fallback OS user)
|
|
77
|
+
from: ReviewState;
|
|
78
|
+
to: ReviewState;
|
|
79
|
+
note: string | null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Defensive parse: malformed entries are dropped, never fatal. */
|
|
83
|
+
export function asReviewHistory(v: unknown): ReviewTransition[] {
|
|
84
|
+
if (!Array.isArray(v)) return [];
|
|
85
|
+
const out: ReviewTransition[] = [];
|
|
86
|
+
for (const e of v) {
|
|
87
|
+
if (!e || typeof e !== "object") continue;
|
|
88
|
+
const r = e as Record<string, unknown>;
|
|
89
|
+
const from = asReviewState(r.from);
|
|
90
|
+
const to = asReviewState(r.to);
|
|
91
|
+
const at = typeof r.at === "string" && r.at ? r.at : null;
|
|
92
|
+
const by = typeof r.by === "string" && r.by ? r.by : null;
|
|
93
|
+
if (!at || !by) continue;
|
|
94
|
+
out.push({
|
|
95
|
+
at,
|
|
96
|
+
by,
|
|
97
|
+
from,
|
|
98
|
+
to,
|
|
99
|
+
note: typeof r.note === "string" && r.note ? r.note : null,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Review lifecycle for shared memories (2B/D25): draft → proposed →
|
|
106
|
+
* approved/rejected → published. Personal memories stay `draft`. */
|
|
107
|
+
export type ReviewState = "draft" | "proposed" | "approved" | "rejected" | "published";
|
|
108
|
+
|
|
109
|
+
const REVIEW_STATES: ReviewState[] = ["draft", "proposed", "approved", "rejected", "published"];
|
|
110
|
+
|
|
111
|
+
export function asReviewState(v: unknown): ReviewState {
|
|
112
|
+
return typeof v === "string" && (REVIEW_STATES as string[]).includes(v)
|
|
113
|
+
? (v as ReviewState)
|
|
114
|
+
: "draft";
|
|
55
115
|
}
|
|
56
116
|
|
|
57
117
|
export interface MemoryFile {
|
|
@@ -179,6 +239,16 @@ export function normalizeFrontmatter(
|
|
|
179
239
|
typeof raw.superseded_by === "string" && raw.superseded_by
|
|
180
240
|
? raw.superseded_by
|
|
181
241
|
: null,
|
|
242
|
+
review_state: asReviewState(raw.review_state),
|
|
243
|
+
proposed_by:
|
|
244
|
+
typeof raw.proposed_by === "string" && raw.proposed_by ? raw.proposed_by : null,
|
|
245
|
+
approved_by:
|
|
246
|
+
typeof raw.approved_by === "string" && raw.approved_by ? raw.approved_by : null,
|
|
247
|
+
derived_from:
|
|
248
|
+
typeof raw.derived_from === "string" && raw.derived_from ? raw.derived_from : null,
|
|
249
|
+
review_note:
|
|
250
|
+
typeof raw.review_note === "string" && raw.review_note ? raw.review_note : null,
|
|
251
|
+
review_history: asReviewHistory(raw.review_history),
|
|
182
252
|
};
|
|
183
253
|
}
|
|
184
254
|
|
|
@@ -217,6 +287,9 @@ export function writeMemoryFile(
|
|
|
217
287
|
fm: Frontmatter,
|
|
218
288
|
body: string,
|
|
219
289
|
): { filePath: string; mtimeMs: number } {
|
|
290
|
+
// 2B/D26: project drafts live in appdata (the outbox) — the in-repo dir
|
|
291
|
+
// only ever holds submitted memories (proposed/approved/published).
|
|
292
|
+
// Personal stays in appdata and never leaves the machine.
|
|
220
293
|
const dir = memoriesDirFor(fm.scope_key);
|
|
221
294
|
const filePath = path.join(dir, `${fm.id}.md`);
|
|
222
295
|
fs.writeFileSync(filePath, serialize(fm, body), "utf8");
|
|
@@ -224,14 +297,16 @@ export function writeMemoryFile(
|
|
|
224
297
|
return { filePath, mtimeMs: st.mtimeMs };
|
|
225
298
|
}
|
|
226
299
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
300
|
+
/** Yield `*.md` files in the in-repo dir (read path — never creates it). */
|
|
301
|
+
export function* iterInRepoMemoryFiles(
|
|
302
|
+
root: string,
|
|
303
|
+
memoryDir: string,
|
|
304
|
+
): Generator<string> {
|
|
305
|
+
const dir = inRepoMemoriesDirPath(root, memoryDir);
|
|
306
|
+
if (!fs.existsSync(dir)) return;
|
|
307
|
+
for (const name of fs.readdirSync(dir)) {
|
|
308
|
+
if (name.endsWith(".md")) yield path.join(dir, name);
|
|
233
309
|
}
|
|
234
|
-
return false;
|
|
235
310
|
}
|
|
236
311
|
|
|
237
312
|
export function readMemoryFile(filePath: string): MemoryFile | null {
|
package/src/store/sync.ts
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
2
3
|
import { db } from "./db.ts";
|
|
3
4
|
import { cjkIndexText } from "../retrieve/cjk.ts";
|
|
4
5
|
import { contentHash, repairChain } from "./lifecycle.ts";
|
|
5
6
|
import {
|
|
6
7
|
iterMemoryFiles,
|
|
8
|
+
iterInRepoMemoryFiles,
|
|
7
9
|
readMemoryFile,
|
|
8
10
|
timeToMs,
|
|
9
11
|
type MemoryFile,
|
|
10
12
|
} from "./markdown.ts";
|
|
13
|
+
import { projectRoot, paths } from "../paths.ts";
|
|
14
|
+
import { loadConfig } from "../config.ts";
|
|
11
15
|
|
|
12
16
|
const UPSERT_SQL = `
|
|
13
|
-
INSERT INTO memories (id, scope_key, scope, visibility, project_name, type, role, importance, status, tags, content, cjk, content_hash, superseded_by, source, file_path, mtime_ms, created_at, updated_at)
|
|
14
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
17
|
+
INSERT INTO memories (id, scope_key, scope, visibility, project_name, type, role, importance, status, tags, content, cjk, content_hash, superseded_by, source, file_path, mtime_ms, created_at, updated_at, review_state)
|
|
18
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
15
19
|
ON CONFLICT(id) DO UPDATE SET
|
|
16
20
|
scope_key = excluded.scope_key,
|
|
17
21
|
scope = excluded.scope,
|
|
@@ -27,6 +31,7 @@ const UPSERT_SQL = `
|
|
|
27
31
|
content_hash = excluded.content_hash,
|
|
28
32
|
superseded_by = excluded.superseded_by,
|
|
29
33
|
source = excluded.source,
|
|
34
|
+
review_state = excluded.review_state,
|
|
30
35
|
file_path = excluded.file_path,
|
|
31
36
|
mtime_ms = excluded.mtime_ms,
|
|
32
37
|
updated_at = excluded.updated_at
|
|
@@ -77,6 +82,7 @@ function writeRow(mf: MemoryFile, mtimeMs: number): void {
|
|
|
77
82
|
mtimeMs,
|
|
78
83
|
timeToMs(fm.created_at),
|
|
79
84
|
timeToMs(fm.updated_at),
|
|
85
|
+
fm.review_state ?? "draft",
|
|
80
86
|
);
|
|
81
87
|
}
|
|
82
88
|
|
|
@@ -91,7 +97,7 @@ export interface SyncStats {
|
|
|
91
97
|
scanned: number;
|
|
92
98
|
}
|
|
93
99
|
|
|
94
|
-
export function syncScope(scopeKey: string): SyncStats {
|
|
100
|
+
export function syncScope(scopeKey: string, kind: SyncKind = "auto"): SyncStats {
|
|
95
101
|
const d = db();
|
|
96
102
|
const stats: SyncStats = { added: 0, updated: 0, removed: 0, scanned: 0 };
|
|
97
103
|
|
|
@@ -101,7 +107,22 @@ export function syncScope(scopeKey: string): SyncStats {
|
|
|
101
107
|
const existingById = new Map(existing.map((r) => [r.id, r]));
|
|
102
108
|
const seen = new Set<string>();
|
|
103
109
|
|
|
104
|
-
|
|
110
|
+
// 2B/D26: project scopes have two homes — the appdata outbox (drafts,
|
|
111
|
+
// branch-independent) and the in-repo dir (submitted memories, following
|
|
112
|
+
// the current branch). No migration: appdata files stay put until an
|
|
113
|
+
// explicit `submit` moves them. On the near-impossible id collision the
|
|
114
|
+
// in-repo (submitted) copy wins, so scan appdata first.
|
|
115
|
+
const filePaths: string[] = [];
|
|
116
|
+
if (scopeKey.startsWith("project__")) {
|
|
117
|
+
const root = projectRoot();
|
|
118
|
+
const memoryDir = loadConfig().memoryDir;
|
|
119
|
+
for (const fp of iterMemoryFiles(scopeKey)) filePaths.push(fp);
|
|
120
|
+
for (const fp of iterInRepoMemoryFiles(root, memoryDir)) filePaths.push(fp);
|
|
121
|
+
} else {
|
|
122
|
+
for (const fp of iterMemoryFiles(scopeKey)) filePaths.push(fp);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
for (const fp of filePaths) {
|
|
105
126
|
const mf = readMemoryFile(fp);
|
|
106
127
|
if (!mf) continue;
|
|
107
128
|
stats.scanned++;
|
|
@@ -123,5 +144,65 @@ export function syncScope(scopeKey: string): SyncStats {
|
|
|
123
144
|
}
|
|
124
145
|
}
|
|
125
146
|
|
|
147
|
+
recordSync(scopeKey, kind, stats);
|
|
126
148
|
return stats;
|
|
127
149
|
}
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// D31: last-sync visibility
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
/** What triggered the sync — shown in sync-status so the user can see it. */
|
|
156
|
+
export type SyncKind = "session" | "request" | "cli" | "submit" | "auto";
|
|
157
|
+
|
|
158
|
+
export interface SyncStateEntry {
|
|
159
|
+
lastSyncAt: string; // RFC 3339
|
|
160
|
+
lastSyncKind: SyncKind;
|
|
161
|
+
added: number;
|
|
162
|
+
updated: number;
|
|
163
|
+
removed: number;
|
|
164
|
+
scanned: number;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function syncStatePath(): string {
|
|
168
|
+
return path.join(paths().root, "sync-state.json");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Best-effort: a failed write never breaks the sync itself. */
|
|
172
|
+
export function recordSync(scopeKey: string, kind: SyncKind, stats: SyncStats): void {
|
|
173
|
+
try {
|
|
174
|
+
const p = syncStatePath();
|
|
175
|
+
let all: Record<string, SyncStateEntry> = {};
|
|
176
|
+
if (fs.existsSync(p)) {
|
|
177
|
+
try {
|
|
178
|
+
all = JSON.parse(fs.readFileSync(p, "utf8")) as Record<string, SyncStateEntry>;
|
|
179
|
+
} catch {
|
|
180
|
+
/* corrupt — start fresh */
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
all[scopeKey] = {
|
|
184
|
+
lastSyncAt: new Date().toISOString(),
|
|
185
|
+
lastSyncKind: kind,
|
|
186
|
+
added: stats.added,
|
|
187
|
+
updated: stats.updated,
|
|
188
|
+
removed: stats.removed,
|
|
189
|
+
scanned: stats.scanned,
|
|
190
|
+
};
|
|
191
|
+
fs.writeFileSync(p, JSON.stringify(all, null, 2), "utf8");
|
|
192
|
+
} catch {
|
|
193
|
+
/* visibility is advisory — never fail a sync over it */
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function readSyncState(scopeKey: string): SyncStateEntry | null {
|
|
198
|
+
try {
|
|
199
|
+
const p = syncStatePath();
|
|
200
|
+
if (!fs.existsSync(p)) return null;
|
|
201
|
+
const all = JSON.parse(fs.readFileSync(p, "utf8")) as Record<string, unknown>;
|
|
202
|
+
const e = all?.[scopeKey] as SyncStateEntry | undefined;
|
|
203
|
+
if (!e || typeof e.lastSyncAt !== "string") return null;
|
|
204
|
+
return e;
|
|
205
|
+
} catch {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
}
|