open-memex 0.3.0 → 0.4.0-alpha.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 +11 -1
- package/README.md +67 -6
- package/README.zh-CN.md +58 -6
- package/dist/cli.js +214 -11
- package/dist/config.js +10 -0
- package/dist/github.js +215 -0
- package/dist/index.js +6 -0
- package/dist/init.js +30 -18
- package/dist/mcp.js +44 -8
- 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 +108 -6
- package/package.json +1 -1
- package/scripts/smoke-mcp.ts +2 -2
- package/src/cli.ts +204 -12
- package/src/config.ts +16 -0
- package/src/github.ts +257 -0
- package/src/index.ts +6 -0
- package/src/init.ts +30 -17
- package/src/mcp.ts +86 -7
- 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/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
|
+
}
|
package/src/submit.ts
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 2B/D26: submit — move outbox drafts into a git branch for review.
|
|
3
|
+
*
|
|
4
|
+
* The AI-driven sync loop:
|
|
5
|
+
* 1. `open-memex sync-status` — what is waiting (outbox drafts, in-review, published)
|
|
6
|
+
* 2. user picks memories to sync
|
|
7
|
+
* 3. `open-memex submit <ids>` — new branch (default `mem/sync-*`), copy
|
|
8
|
+
* drafts into the repo dir as `proposed`, commit locally, move the
|
|
9
|
+
* outbox originals out (copy + verify + delete)
|
|
10
|
+
* 4. push + open PR — done by the agent holding the user's approval, or by
|
|
11
|
+
* the human; the tool only prints the exact commands (D27)
|
|
12
|
+
*
|
|
13
|
+
* Git automation line (D27): the tool automates LOCAL git
|
|
14
|
+
* only (branch, copy, commit). Nothing leaves the machine without the user's
|
|
15
|
+
* explicit approval — push/PR are the agent's or the human's job.
|
|
16
|
+
*
|
|
17
|
+
* `submit` is idempotent: re-submitting an id whose file is already on the
|
|
18
|
+
* branch with identical content just completes the outbox move. A same-id
|
|
19
|
+
* file with DIFFERENT content is a conflict — abort, the human judges.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { execFileSync } from "node:child_process";
|
|
23
|
+
import fs from "node:fs";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import { loadConfig } from "./config.ts";
|
|
26
|
+
import { inRepoMemoriesDir, memoriesDirPath, projectRoot } from "./paths.ts";
|
|
27
|
+
import { resolveProjectScope } from "./scope.ts";
|
|
28
|
+
import { contentHash } from "./store/lifecycle.ts";
|
|
29
|
+
import {
|
|
30
|
+
msToRfc3339,
|
|
31
|
+
parse as parseMemory,
|
|
32
|
+
readMemoryFile,
|
|
33
|
+
serialize,
|
|
34
|
+
type ReviewState,
|
|
35
|
+
} from "./store/markdown.ts";
|
|
36
|
+
import { upsertFromFile, recordSync, readSyncState } from "./store/sync.ts";
|
|
37
|
+
import { db } from "./store/db.ts";
|
|
38
|
+
import { currentAuthor } from "./review.ts";
|
|
39
|
+
|
|
40
|
+
function fail(msg: string): never {
|
|
41
|
+
throw new Error(`[open-memex] ${msg}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function git(root: string, args: string[]): string {
|
|
45
|
+
try {
|
|
46
|
+
return execFileSync("git", args, {
|
|
47
|
+
cwd: root,
|
|
48
|
+
encoding: "utf8",
|
|
49
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
50
|
+
}).trim();
|
|
51
|
+
} catch (e) {
|
|
52
|
+
const err = e as { stderr?: string; message?: string };
|
|
53
|
+
const detail = (err.stderr ?? err.message ?? "").trim().split("\n")[0];
|
|
54
|
+
fail(`git ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function snippetOf(body: string): string {
|
|
59
|
+
const line = body.split("\n").map((l) => l.trim()).find((l) => l.length > 0) ?? "";
|
|
60
|
+
return line.length > 60 ? line.slice(0, 60) + "…" : line;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// status
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
export type OutboxLocation = "outbox" | "repo";
|
|
68
|
+
export type GitState = "uncommitted" | "committed" | "n/a";
|
|
69
|
+
|
|
70
|
+
export interface StatusEntry {
|
|
71
|
+
id: string;
|
|
72
|
+
title: string;
|
|
73
|
+
reviewState: ReviewState;
|
|
74
|
+
location: OutboxLocation;
|
|
75
|
+
gitState: GitState;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface SyncStatus {
|
|
79
|
+
scopeKey: string;
|
|
80
|
+
projectName: string;
|
|
81
|
+
outbox: StatusEntry[]; // appdata drafts waiting for submit
|
|
82
|
+
inRepo: StatusEntry[]; // files in the repo dir (any review_state)
|
|
83
|
+
uncommitted: StatusEntry[]; // repo files not yet committed (the untracked window)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Files under .ai/open-memex that git sees as new/modified/untracked. */
|
|
87
|
+
function uncommittedRepoFiles(root: string, repoDir: string): Set<string> {
|
|
88
|
+
const out = new Set<string>();
|
|
89
|
+
try {
|
|
90
|
+
const porcelain = git(root, ["status", "--porcelain", "--", repoDir]);
|
|
91
|
+
for (const line of porcelain.split("\n")) {
|
|
92
|
+
const m = line.match(/^.{2}\s+(.*)$/);
|
|
93
|
+
if (!m) continue;
|
|
94
|
+
let p = m[1].trim();
|
|
95
|
+
// handle renames: "R old -> new"
|
|
96
|
+
if (p.includes(" -> ")) p = p.split(" -> ")[1];
|
|
97
|
+
// strip quotes git adds around unusual names
|
|
98
|
+
if (p.startsWith('"') && p.endsWith('"')) p = p.slice(1, -1);
|
|
99
|
+
out.add(path.resolve(root, p));
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
/* not a git repo or git unavailable — no git state */
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function getSyncStatus(): SyncStatus {
|
|
108
|
+
const root = projectRoot();
|
|
109
|
+
const scope = resolveProjectScope(root);
|
|
110
|
+
const cfg = loadConfig();
|
|
111
|
+
const outboxDir = memoriesDirPath(scope.key);
|
|
112
|
+
const repoDir = path.join(root, cfg.memoryDir);
|
|
113
|
+
|
|
114
|
+
const rows = db()
|
|
115
|
+
.prepare(
|
|
116
|
+
`SELECT id, review_state, file_path, content FROM memories WHERE scope_key = ? ORDER BY updated_at DESC`,
|
|
117
|
+
)
|
|
118
|
+
.all(scope.key) as Array<{
|
|
119
|
+
id: string;
|
|
120
|
+
review_state: string;
|
|
121
|
+
file_path: string;
|
|
122
|
+
content: string;
|
|
123
|
+
}>;
|
|
124
|
+
|
|
125
|
+
const uncommitted = fs.existsSync(repoDir) ? uncommittedRepoFiles(root, repoDir) : new Set<string>();
|
|
126
|
+
const outbox: StatusEntry[] = [];
|
|
127
|
+
const inRepo: StatusEntry[] = [];
|
|
128
|
+
const uncommittedList: StatusEntry[] = [];
|
|
129
|
+
|
|
130
|
+
for (const r of rows) {
|
|
131
|
+
const inOutbox = r.file_path.startsWith(outboxDir + path.sep);
|
|
132
|
+
const entry: StatusEntry = {
|
|
133
|
+
id: r.id,
|
|
134
|
+
title: snippetOf(r.content ?? ""),
|
|
135
|
+
reviewState: (r.review_state ?? "draft") as ReviewState,
|
|
136
|
+
location: inOutbox ? "outbox" : "repo",
|
|
137
|
+
gitState: "n/a",
|
|
138
|
+
};
|
|
139
|
+
if (inOutbox) {
|
|
140
|
+
outbox.push(entry);
|
|
141
|
+
} else {
|
|
142
|
+
entry.gitState = uncommitted.has(path.resolve(r.file_path)) ? "uncommitted" : "committed";
|
|
143
|
+
inRepo.push(entry);
|
|
144
|
+
if (entry.gitState === "uncommitted") uncommittedList.push(entry);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
scopeKey: scope.key,
|
|
149
|
+
projectName: scope.projectName,
|
|
150
|
+
outbox,
|
|
151
|
+
inRepo,
|
|
152
|
+
uncommitted: uncommittedList,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function formatSyncStatus(st: SyncStatus): string {
|
|
157
|
+
const lines: string[] = [];
|
|
158
|
+
lines.push(`project: ${st.projectName} (${st.scopeKey})`);
|
|
159
|
+
// D31: when the index was last synced and what triggered it.
|
|
160
|
+
const last = readSyncState(st.scopeKey);
|
|
161
|
+
if (last) {
|
|
162
|
+
lines.push(
|
|
163
|
+
`last sync: ${last.lastSyncAt} (${last.lastSyncKind}) — +${last.added} ~${last.updated} -${last.removed} (scanned ${last.scanned})`,
|
|
164
|
+
);
|
|
165
|
+
} else {
|
|
166
|
+
lines.push(`last sync: never`);
|
|
167
|
+
}
|
|
168
|
+
lines.push(`outbox (appdata, pending sync): ${st.outbox.length}`);
|
|
169
|
+
for (const e of st.outbox) {
|
|
170
|
+
lines.push(` ${e.id} [${e.reviewState}] ${e.title}`);
|
|
171
|
+
}
|
|
172
|
+
const byState = new Map<string, number>();
|
|
173
|
+
for (const e of st.inRepo) byState.set(e.reviewState, (byState.get(e.reviewState) ?? 0) + 1);
|
|
174
|
+
const breakdown = [...byState.entries()].map(([s, n]) => `${n} ${s}`).join(", ") || "none";
|
|
175
|
+
lines.push(`repo .ai/open-memex: ${st.inRepo.length} (${breakdown})`);
|
|
176
|
+
for (const e of st.inRepo) {
|
|
177
|
+
const u = e.gitState === "uncommitted" ? " (uncommitted)" : "";
|
|
178
|
+
lines.push(` ${e.id} [${e.reviewState}]${u} ${e.title}`);
|
|
179
|
+
}
|
|
180
|
+
if (st.uncommitted.length > 0) {
|
|
181
|
+
lines.push(`note: ${st.uncommitted.length} repo file(s) not yet committed — they ride with the working tree until you commit.`);
|
|
182
|
+
}
|
|
183
|
+
if (st.outbox.length > 0) {
|
|
184
|
+
lines.push(`next: open-memex submit ${st.outbox.map((e) => e.id).join(" ")}`);
|
|
185
|
+
}
|
|
186
|
+
return lines.join("\n");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// submit
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
export interface SubmitOptions {
|
|
194
|
+
/** Submit onto this branch instead of creating mem/sync-*. Must be the current branch. */
|
|
195
|
+
onto?: string;
|
|
196
|
+
/** PR base override (default: the branch we branched from). */
|
|
197
|
+
base?: string;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export interface SubmitResult {
|
|
201
|
+
branch: string;
|
|
202
|
+
base: string;
|
|
203
|
+
submitted: Array<{ id: string; filePath: string; reviewState: ReviewState }>;
|
|
204
|
+
/** already on the branch with identical content — outbox move completed */
|
|
205
|
+
skippedIdentical: string[];
|
|
206
|
+
/** false when everything was already on the branch (nothing new to commit) */
|
|
207
|
+
committed: boolean;
|
|
208
|
+
pushCommand: string;
|
|
209
|
+
prCommand: string;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
interface Validated {
|
|
213
|
+
id: string;
|
|
214
|
+
srcPath: string;
|
|
215
|
+
body: string;
|
|
216
|
+
reviewState: ReviewState;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function submitMemories(ids: string[], opts: SubmitOptions = {}): SubmitResult {
|
|
220
|
+
if (ids.length === 0) fail("submit needs at least one memory id");
|
|
221
|
+
const root = projectRoot();
|
|
222
|
+
// submit needs a git repo — the whole point is branch + commit.
|
|
223
|
+
git(root, ["rev-parse", "--git-dir"]);
|
|
224
|
+
|
|
225
|
+
const scope = resolveProjectScope(root);
|
|
226
|
+
const cfg = loadConfig();
|
|
227
|
+
const outboxDir = memoriesDirPath(scope.key);
|
|
228
|
+
const repoDir = inRepoMemoriesDir(root, cfg.memoryDir);
|
|
229
|
+
|
|
230
|
+
// 1. Validate everything first (all-or-nothing — nothing is written yet).
|
|
231
|
+
// Read the outbox files directly from disk: the index resolves same-id
|
|
232
|
+
// collisions in favor of the repo copy, which would hide a genuine
|
|
233
|
+
// outbox-vs-branch conflict from us.
|
|
234
|
+
const problems: string[] = [];
|
|
235
|
+
const validated: Validated[] = [];
|
|
236
|
+
for (const id of ids) {
|
|
237
|
+
if (!/^[A-Za-z0-9_-]+$/.test(id)) {
|
|
238
|
+
problems.push(`${id}: not a valid memory id`);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
const srcPath = path.join(outboxDir, `${id}.md`);
|
|
242
|
+
if (!fs.existsSync(srcPath)) {
|
|
243
|
+
problems.push(`${id}: not in the outbox (never staged, or already submitted? see sync-status)`);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
const mf = readMemoryFile(srcPath);
|
|
247
|
+
if (!mf) {
|
|
248
|
+
problems.push(`${id}: unreadable outbox file`);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (mf.fm.id !== id) {
|
|
252
|
+
problems.push(`${id}: outbox file's frontmatter id is ${mf.fm.id} — not submitting a mislabeled file`);
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (mf.fm.scope !== "project") {
|
|
256
|
+
problems.push(`${id}: scope is ${mf.fm.scope}, only project memories can be submitted`);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const rs = (mf.fm.review_state ?? "draft") as ReviewState;
|
|
260
|
+
// draft/rejected move to proposed; a local-approved copy keeps its approval.
|
|
261
|
+
if (rs !== "draft" && rs !== "rejected" && rs !== "approved") {
|
|
262
|
+
problems.push(`${id}: review_state is ${rs}, only draft/rejected/approved can be submitted`);
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
validated.push({ id, srcPath, body: mf.body, reviewState: rs });
|
|
266
|
+
}
|
|
267
|
+
if (problems.length > 0) {
|
|
268
|
+
fail(`submit aborted — nothing was written:\n ${problems.join("\n ")}`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// 2. Resolve the target branch.
|
|
272
|
+
const startBranch = git(root, ["branch", "--show-current"]) || git(root, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
273
|
+
let branch: string;
|
|
274
|
+
if (opts.onto) {
|
|
275
|
+
if (opts.onto !== startBranch) {
|
|
276
|
+
fail(`--onto ${opts.onto} is not the current branch (${startBranch || "(detached)"}). submit only targets the branch you're on.`);
|
|
277
|
+
}
|
|
278
|
+
branch = opts.onto;
|
|
279
|
+
} else {
|
|
280
|
+
const stamp = new Date().toISOString().replace(/[-:]/g, "").slice(0, 15);
|
|
281
|
+
branch = `mem/sync-${stamp}`;
|
|
282
|
+
let n = 0;
|
|
283
|
+
while (true) {
|
|
284
|
+
const exists = git(root, ["branch", "--list", branch]);
|
|
285
|
+
if (!exists) break;
|
|
286
|
+
n++;
|
|
287
|
+
branch = `mem/sync-${stamp}-${n}`;
|
|
288
|
+
}
|
|
289
|
+
git(root, ["checkout", "-b", branch]);
|
|
290
|
+
}
|
|
291
|
+
const base = opts.base ?? startBranch;
|
|
292
|
+
const author = currentAuthor();
|
|
293
|
+
|
|
294
|
+
// 3. Copy drafts into the repo dir as proposed (same id — one memory, one id).
|
|
295
|
+
// Same id + different content on the branch = conflict → abort, human judges.
|
|
296
|
+
const submitted: Array<{ id: string; filePath: string; reviewState: ReviewState }> = [];
|
|
297
|
+
const skippedIdentical: string[] = [];
|
|
298
|
+
const now = msToRfc3339(Date.now());
|
|
299
|
+
const written: Array<{ id: string; srcPath: string; destPath: string; hash: string }> = [];
|
|
300
|
+
const conflicts: string[] = [];
|
|
301
|
+
for (const v of validated) {
|
|
302
|
+
const destPath = path.join(repoDir, `${v.id}.md`);
|
|
303
|
+
const raw = fs.existsSync(destPath) ? fs.readFileSync(destPath, "utf8") : null;
|
|
304
|
+
if (raw !== null) {
|
|
305
|
+
const existing = parseMemory(raw);
|
|
306
|
+
const sameContent = existing !== null && contentHash(existing.body) === contentHash(v.body);
|
|
307
|
+
if (sameContent) {
|
|
308
|
+
skippedIdentical.push(v.id);
|
|
309
|
+
written.push({ id: v.id, srcPath: v.srcPath, destPath, hash: contentHash(v.body) });
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
conflicts.push(`${v.id}: already on branch ${branch} with different content — resolve it manually, then re-submit`);
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
const parsed = parseMemory(fs.readFileSync(v.srcPath, "utf8"));
|
|
316
|
+
if (!parsed) fail(`${v.id}: unreadable source file`);
|
|
317
|
+
// draft/rejected → proposed; a local-approved copy arrives already
|
|
318
|
+
// approved and keeps its approval (and its approved_by).
|
|
319
|
+
const destState: ReviewState = v.reviewState === "approved" ? "approved" : "proposed";
|
|
320
|
+
const fm = {
|
|
321
|
+
...parsed.fm,
|
|
322
|
+
review_state: destState,
|
|
323
|
+
updated_at: now,
|
|
324
|
+
proposed_by: parsed.fm.proposed_by ?? author,
|
|
325
|
+
// D29: the outbox → repo move is part of the audit trail.
|
|
326
|
+
review_history: [
|
|
327
|
+
...(parsed.fm.review_history ?? []),
|
|
328
|
+
{
|
|
329
|
+
at: now,
|
|
330
|
+
by: author,
|
|
331
|
+
from: parsed.fm.review_state ?? "draft",
|
|
332
|
+
to: destState,
|
|
333
|
+
note: `submitted to branch ${branch}`,
|
|
334
|
+
},
|
|
335
|
+
],
|
|
336
|
+
};
|
|
337
|
+
const text = serialize(fm, parsed.body);
|
|
338
|
+
fs.writeFileSync(destPath, text, "utf8");
|
|
339
|
+
written.push({ id: v.id, srcPath: v.srcPath, destPath, hash: contentHash(parsed.body) });
|
|
340
|
+
submitted.push({ id: v.id, filePath: destPath, reviewState: destState });
|
|
341
|
+
}
|
|
342
|
+
if (conflicts.length > 0) {
|
|
343
|
+
// Roll back the copies we just made — nothing half-submitted.
|
|
344
|
+
for (const w of written) {
|
|
345
|
+
if (submitted.some((s) => s.id === w.id) && fs.existsSync(w.destPath)) {
|
|
346
|
+
fs.unlinkSync(w.destPath);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
// If we created the branch and never committed, remove it too — an
|
|
350
|
+
// aborted submit leaves no trace.
|
|
351
|
+
if (!opts.onto) {
|
|
352
|
+
try {
|
|
353
|
+
git(root, ["checkout", "--quiet", startBranch]);
|
|
354
|
+
git(root, ["branch", "--quiet", "-D", branch]);
|
|
355
|
+
} catch {
|
|
356
|
+
/* best effort — the error below is what matters */
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
fail(`submit aborted — nothing was committed:\n ${conflicts.join("\n ")}`);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// 4. Commit locally (tool automates local git only — D27 line).
|
|
363
|
+
// Only our own files are committed: anything else the user staged stays
|
|
364
|
+
// staged and untouched.
|
|
365
|
+
const destPaths = written.map((w) => w.destPath);
|
|
366
|
+
const relPaths = destPaths.map((p: string) => path.relative(root, p));
|
|
367
|
+
git(root, ["add", "--", ...destPaths]);
|
|
368
|
+
const staged = git(root, ["diff", "--cached", "--name-only", "--", ...relPaths]);
|
|
369
|
+
let committed = false;
|
|
370
|
+
if (staged.trim()) {
|
|
371
|
+
const short = submitted.map((s) => s.id.slice(0, 8)).join(" ");
|
|
372
|
+
git(root, ["commit", "-m", `mem: submit ${submitted.length} ${submitted.length === 1 ? "memory" : "memories"} for review (${short})`, "--", ...relPaths]);
|
|
373
|
+
committed = true;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// 5. Verify the committed copies, then move the outbox originals out
|
|
377
|
+
// (copy + verify + delete — never a bare rename across the move).
|
|
378
|
+
for (const w of written) {
|
|
379
|
+
const back = fs.readFileSync(w.destPath, "utf8");
|
|
380
|
+
const bParsed = parseMemory(back);
|
|
381
|
+
if (!bParsed || contentHash(bParsed.body) !== w.hash) {
|
|
382
|
+
fail(`${w.id}: verification failed after commit — outbox copy kept at ${w.srcPath}`);
|
|
383
|
+
}
|
|
384
|
+
// Same id, new home: ON CONFLICT(id) rewrites the row to the repo path.
|
|
385
|
+
upsertFromFile({
|
|
386
|
+
fm: bParsed.fm,
|
|
387
|
+
body: bParsed.body,
|
|
388
|
+
filePath: w.destPath,
|
|
389
|
+
mtimeMs: fs.statSync(w.destPath).mtimeMs,
|
|
390
|
+
});
|
|
391
|
+
fs.unlinkSync(w.srcPath);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const ids8 = validated.map((v) => v.id.slice(0, 8)).join(" ");
|
|
395
|
+
// D31: the submit itself is a sync event — visible in sync-status.
|
|
396
|
+
recordSync(scope.key, "submit", {
|
|
397
|
+
added: submitted.length,
|
|
398
|
+
updated: skippedIdentical.length,
|
|
399
|
+
removed: 0,
|
|
400
|
+
scanned: validated.length,
|
|
401
|
+
});
|
|
402
|
+
return {
|
|
403
|
+
branch,
|
|
404
|
+
base,
|
|
405
|
+
submitted,
|
|
406
|
+
skippedIdentical,
|
|
407
|
+
committed,
|
|
408
|
+
pushCommand: `git push -u origin ${branch}`,
|
|
409
|
+
prCommand: `gh pr create --base ${base} --title "mem: review ${validated.length} ${validated.length === 1 ? "memory" : "memories"}" --body "Submitted from the open-memex outbox: ${ids8}."`,
|
|
410
|
+
};
|
|
411
|
+
}
|