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/dist/store/sync.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
2
3
|
import { db } from "./db.js";
|
|
3
4
|
import { cjkIndexText } from "../retrieve/cjk.js";
|
|
4
5
|
import { contentHash, repairChain } from "./lifecycle.js";
|
|
5
|
-
import { iterMemoryFiles, readMemoryFile, timeToMs, } from "./markdown.js";
|
|
6
|
+
import { iterMemoryFiles, iterInRepoMemoryFiles, readMemoryFile, timeToMs, } from "./markdown.js";
|
|
7
|
+
import { projectRoot, paths } from "../paths.js";
|
|
8
|
+
import { loadConfig } from "../config.js";
|
|
6
9
|
const UPSERT_SQL = `
|
|
7
|
-
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)
|
|
8
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
10
|
+
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)
|
|
11
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
9
12
|
ON CONFLICT(id) DO UPDATE SET
|
|
10
13
|
scope_key = excluded.scope_key,
|
|
11
14
|
scope = excluded.scope,
|
|
@@ -21,6 +24,7 @@ const UPSERT_SQL = `
|
|
|
21
24
|
content_hash = excluded.content_hash,
|
|
22
25
|
superseded_by = excluded.superseded_by,
|
|
23
26
|
source = excluded.source,
|
|
27
|
+
review_state = excluded.review_state,
|
|
24
28
|
file_path = excluded.file_path,
|
|
25
29
|
mtime_ms = excluded.mtime_ms,
|
|
26
30
|
updated_at = excluded.updated_at
|
|
@@ -49,12 +53,12 @@ export function upsertFromFile(mf) {
|
|
|
49
53
|
function writeRow(mf, mtimeMs) {
|
|
50
54
|
const { fm, body, filePath } = mf;
|
|
51
55
|
const tags = (fm.tags ?? []).join(",");
|
|
52
|
-
db().prepare(UPSERT_SQL).run(fm.id, fm.scope_key, fm.scope, fm.visibility, fm.project_name, fm.type, fm.role, fm.importance, fm.status, tags, body, cjkIndexText(body + "\n" + tags), contentHash(body), fm.superseded_by ?? null, fm.source ?? "", filePath, mtimeMs, timeToMs(fm.created_at), timeToMs(fm.updated_at));
|
|
56
|
+
db().prepare(UPSERT_SQL).run(fm.id, fm.scope_key, fm.scope, fm.visibility, fm.project_name, fm.type, fm.role, fm.importance, fm.status, tags, body, cjkIndexText(body + "\n" + tags), contentHash(body), fm.superseded_by ?? null, fm.source ?? "", filePath, mtimeMs, timeToMs(fm.created_at), timeToMs(fm.updated_at), fm.review_state ?? "draft");
|
|
53
57
|
}
|
|
54
58
|
export function deleteFromIndex(id) {
|
|
55
59
|
db().prepare(`DELETE FROM memories WHERE id = ?`).run(id);
|
|
56
60
|
}
|
|
57
|
-
export function syncScope(scopeKey) {
|
|
61
|
+
export function syncScope(scopeKey, kind = "auto") {
|
|
58
62
|
const d = db();
|
|
59
63
|
const stats = { added: 0, updated: 0, removed: 0, scanned: 0 };
|
|
60
64
|
const existing = d
|
|
@@ -62,7 +66,25 @@ export function syncScope(scopeKey) {
|
|
|
62
66
|
.all(scopeKey);
|
|
63
67
|
const existingById = new Map(existing.map((r) => [r.id, r]));
|
|
64
68
|
const seen = new Set();
|
|
65
|
-
|
|
69
|
+
// 2B/D26: project scopes have two homes — the appdata outbox (drafts,
|
|
70
|
+
// branch-independent) and the in-repo dir (submitted memories, following
|
|
71
|
+
// the current branch). No migration: appdata files stay put until an
|
|
72
|
+
// explicit `submit` moves them. On the near-impossible id collision the
|
|
73
|
+
// in-repo (submitted) copy wins, so scan appdata first.
|
|
74
|
+
const filePaths = [];
|
|
75
|
+
if (scopeKey.startsWith("project__")) {
|
|
76
|
+
const root = projectRoot();
|
|
77
|
+
const memoryDir = loadConfig().memoryDir;
|
|
78
|
+
for (const fp of iterMemoryFiles(scopeKey))
|
|
79
|
+
filePaths.push(fp);
|
|
80
|
+
for (const fp of iterInRepoMemoryFiles(root, memoryDir))
|
|
81
|
+
filePaths.push(fp);
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
for (const fp of iterMemoryFiles(scopeKey))
|
|
85
|
+
filePaths.push(fp);
|
|
86
|
+
}
|
|
87
|
+
for (const fp of filePaths) {
|
|
66
88
|
const mf = readMemoryFile(fp);
|
|
67
89
|
if (!mf)
|
|
68
90
|
continue;
|
|
@@ -84,5 +106,51 @@ export function syncScope(scopeKey) {
|
|
|
84
106
|
stats.removed++;
|
|
85
107
|
}
|
|
86
108
|
}
|
|
109
|
+
recordSync(scopeKey, kind, stats);
|
|
87
110
|
return stats;
|
|
88
111
|
}
|
|
112
|
+
function syncStatePath() {
|
|
113
|
+
return path.join(paths().root, "sync-state.json");
|
|
114
|
+
}
|
|
115
|
+
/** Best-effort: a failed write never breaks the sync itself. */
|
|
116
|
+
export function recordSync(scopeKey, kind, stats) {
|
|
117
|
+
try {
|
|
118
|
+
const p = syncStatePath();
|
|
119
|
+
let all = {};
|
|
120
|
+
if (fs.existsSync(p)) {
|
|
121
|
+
try {
|
|
122
|
+
all = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
/* corrupt — start fresh */
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
all[scopeKey] = {
|
|
129
|
+
lastSyncAt: new Date().toISOString(),
|
|
130
|
+
lastSyncKind: kind,
|
|
131
|
+
added: stats.added,
|
|
132
|
+
updated: stats.updated,
|
|
133
|
+
removed: stats.removed,
|
|
134
|
+
scanned: stats.scanned,
|
|
135
|
+
};
|
|
136
|
+
fs.writeFileSync(p, JSON.stringify(all, null, 2), "utf8");
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
/* visibility is advisory — never fail a sync over it */
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
export function readSyncState(scopeKey) {
|
|
143
|
+
try {
|
|
144
|
+
const p = syncStatePath();
|
|
145
|
+
if (!fs.existsSync(p))
|
|
146
|
+
return null;
|
|
147
|
+
const all = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
148
|
+
const e = all?.[scopeKey];
|
|
149
|
+
if (!e || typeof e.lastSyncAt !== "string")
|
|
150
|
+
return null;
|
|
151
|
+
return e;
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
package/dist/submit.js
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
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
|
+
import { execFileSync } from "node:child_process";
|
|
22
|
+
import fs from "node:fs";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import { loadConfig } from "./config.js";
|
|
25
|
+
import { inRepoMemoriesDir, memoriesDirPath, projectRoot } from "./paths.js";
|
|
26
|
+
import { resolveProjectScope } from "./scope.js";
|
|
27
|
+
import { contentHash } from "./store/lifecycle.js";
|
|
28
|
+
import { msToRfc3339, parse as parseMemory, readMemoryFile, serialize, } from "./store/markdown.js";
|
|
29
|
+
import { upsertFromFile, recordSync, readSyncState } from "./store/sync.js";
|
|
30
|
+
import { db } from "./store/db.js";
|
|
31
|
+
import { currentAuthor } from "./review.js";
|
|
32
|
+
function fail(msg) {
|
|
33
|
+
throw new Error(`[open-memex] ${msg}`);
|
|
34
|
+
}
|
|
35
|
+
function git(root, args) {
|
|
36
|
+
try {
|
|
37
|
+
return execFileSync("git", args, {
|
|
38
|
+
cwd: root,
|
|
39
|
+
encoding: "utf8",
|
|
40
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
41
|
+
}).trim();
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
const err = e;
|
|
45
|
+
const detail = (err.stderr ?? err.message ?? "").trim().split("\n")[0];
|
|
46
|
+
fail(`git ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function snippetOf(body) {
|
|
50
|
+
const line = body.split("\n").map((l) => l.trim()).find((l) => l.length > 0) ?? "";
|
|
51
|
+
return line.length > 60 ? line.slice(0, 60) + "…" : line;
|
|
52
|
+
}
|
|
53
|
+
/** Files under .ai/open-memex that git sees as new/modified/untracked. */
|
|
54
|
+
function uncommittedRepoFiles(root, repoDir) {
|
|
55
|
+
const out = new Set();
|
|
56
|
+
try {
|
|
57
|
+
const porcelain = git(root, ["status", "--porcelain", "--", repoDir]);
|
|
58
|
+
for (const line of porcelain.split("\n")) {
|
|
59
|
+
const m = line.match(/^.{2}\s+(.*)$/);
|
|
60
|
+
if (!m)
|
|
61
|
+
continue;
|
|
62
|
+
let p = m[1].trim();
|
|
63
|
+
// handle renames: "R old -> new"
|
|
64
|
+
if (p.includes(" -> "))
|
|
65
|
+
p = p.split(" -> ")[1];
|
|
66
|
+
// strip quotes git adds around unusual names
|
|
67
|
+
if (p.startsWith('"') && p.endsWith('"'))
|
|
68
|
+
p = p.slice(1, -1);
|
|
69
|
+
out.add(path.resolve(root, p));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
/* not a git repo or git unavailable — no git state */
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
export function getSyncStatus() {
|
|
78
|
+
const root = projectRoot();
|
|
79
|
+
const scope = resolveProjectScope(root);
|
|
80
|
+
const cfg = loadConfig();
|
|
81
|
+
const outboxDir = memoriesDirPath(scope.key);
|
|
82
|
+
const repoDir = path.join(root, cfg.memoryDir);
|
|
83
|
+
const rows = db()
|
|
84
|
+
.prepare(`SELECT id, review_state, file_path, content FROM memories WHERE scope_key = ? ORDER BY updated_at DESC`)
|
|
85
|
+
.all(scope.key);
|
|
86
|
+
const uncommitted = fs.existsSync(repoDir) ? uncommittedRepoFiles(root, repoDir) : new Set();
|
|
87
|
+
const outbox = [];
|
|
88
|
+
const inRepo = [];
|
|
89
|
+
const uncommittedList = [];
|
|
90
|
+
for (const r of rows) {
|
|
91
|
+
const inOutbox = r.file_path.startsWith(outboxDir + path.sep);
|
|
92
|
+
const entry = {
|
|
93
|
+
id: r.id,
|
|
94
|
+
title: snippetOf(r.content ?? ""),
|
|
95
|
+
reviewState: (r.review_state ?? "draft"),
|
|
96
|
+
location: inOutbox ? "outbox" : "repo",
|
|
97
|
+
gitState: "n/a",
|
|
98
|
+
};
|
|
99
|
+
if (inOutbox) {
|
|
100
|
+
outbox.push(entry);
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
entry.gitState = uncommitted.has(path.resolve(r.file_path)) ? "uncommitted" : "committed";
|
|
104
|
+
inRepo.push(entry);
|
|
105
|
+
if (entry.gitState === "uncommitted")
|
|
106
|
+
uncommittedList.push(entry);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
scopeKey: scope.key,
|
|
111
|
+
projectName: scope.projectName,
|
|
112
|
+
outbox,
|
|
113
|
+
inRepo,
|
|
114
|
+
uncommitted: uncommittedList,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
export function formatSyncStatus(st) {
|
|
118
|
+
const lines = [];
|
|
119
|
+
lines.push(`project: ${st.projectName} (${st.scopeKey})`);
|
|
120
|
+
// D31: when the index was last synced and what triggered it.
|
|
121
|
+
const last = readSyncState(st.scopeKey);
|
|
122
|
+
if (last) {
|
|
123
|
+
lines.push(`last sync: ${last.lastSyncAt} (${last.lastSyncKind}) — +${last.added} ~${last.updated} -${last.removed} (scanned ${last.scanned})`);
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
lines.push(`last sync: never`);
|
|
127
|
+
}
|
|
128
|
+
lines.push(`outbox (appdata, pending sync): ${st.outbox.length}`);
|
|
129
|
+
for (const e of st.outbox) {
|
|
130
|
+
lines.push(` ${e.id} [${e.reviewState}] ${e.title}`);
|
|
131
|
+
}
|
|
132
|
+
const byState = new Map();
|
|
133
|
+
for (const e of st.inRepo)
|
|
134
|
+
byState.set(e.reviewState, (byState.get(e.reviewState) ?? 0) + 1);
|
|
135
|
+
const breakdown = [...byState.entries()].map(([s, n]) => `${n} ${s}`).join(", ") || "none";
|
|
136
|
+
lines.push(`repo .ai/open-memex: ${st.inRepo.length} (${breakdown})`);
|
|
137
|
+
for (const e of st.inRepo) {
|
|
138
|
+
const u = e.gitState === "uncommitted" ? " (uncommitted)" : "";
|
|
139
|
+
lines.push(` ${e.id} [${e.reviewState}]${u} ${e.title}`);
|
|
140
|
+
}
|
|
141
|
+
if (st.uncommitted.length > 0) {
|
|
142
|
+
lines.push(`note: ${st.uncommitted.length} repo file(s) not yet committed — they ride with the working tree until you commit.`);
|
|
143
|
+
}
|
|
144
|
+
if (st.outbox.length > 0) {
|
|
145
|
+
lines.push(`next: open-memex submit ${st.outbox.map((e) => e.id).join(" ")}`);
|
|
146
|
+
}
|
|
147
|
+
return lines.join("\n");
|
|
148
|
+
}
|
|
149
|
+
export function submitMemories(ids, opts = {}) {
|
|
150
|
+
if (ids.length === 0)
|
|
151
|
+
fail("submit needs at least one memory id");
|
|
152
|
+
const root = projectRoot();
|
|
153
|
+
// submit needs a git repo — the whole point is branch + commit.
|
|
154
|
+
git(root, ["rev-parse", "--git-dir"]);
|
|
155
|
+
const scope = resolveProjectScope(root);
|
|
156
|
+
const cfg = loadConfig();
|
|
157
|
+
const outboxDir = memoriesDirPath(scope.key);
|
|
158
|
+
const repoDir = inRepoMemoriesDir(root, cfg.memoryDir);
|
|
159
|
+
// 1. Validate everything first (all-or-nothing — nothing is written yet).
|
|
160
|
+
// Read the outbox files directly from disk: the index resolves same-id
|
|
161
|
+
// collisions in favor of the repo copy, which would hide a genuine
|
|
162
|
+
// outbox-vs-branch conflict from us.
|
|
163
|
+
const problems = [];
|
|
164
|
+
const validated = [];
|
|
165
|
+
for (const id of ids) {
|
|
166
|
+
if (!/^[A-Za-z0-9_-]+$/.test(id)) {
|
|
167
|
+
problems.push(`${id}: not a valid memory id`);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const srcPath = path.join(outboxDir, `${id}.md`);
|
|
171
|
+
if (!fs.existsSync(srcPath)) {
|
|
172
|
+
problems.push(`${id}: not in the outbox (never staged, or already submitted? see sync-status)`);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const mf = readMemoryFile(srcPath);
|
|
176
|
+
if (!mf) {
|
|
177
|
+
problems.push(`${id}: unreadable outbox file`);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (mf.fm.id !== id) {
|
|
181
|
+
problems.push(`${id}: outbox file's frontmatter id is ${mf.fm.id} — not submitting a mislabeled file`);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (mf.fm.scope !== "project") {
|
|
185
|
+
problems.push(`${id}: scope is ${mf.fm.scope}, only project memories can be submitted`);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
const rs = (mf.fm.review_state ?? "draft");
|
|
189
|
+
// draft/rejected move to proposed; a local-approved copy keeps its approval.
|
|
190
|
+
if (rs !== "draft" && rs !== "rejected" && rs !== "approved") {
|
|
191
|
+
problems.push(`${id}: review_state is ${rs}, only draft/rejected/approved can be submitted`);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
validated.push({ id, srcPath, body: mf.body, reviewState: rs });
|
|
195
|
+
}
|
|
196
|
+
if (problems.length > 0) {
|
|
197
|
+
fail(`submit aborted — nothing was written:\n ${problems.join("\n ")}`);
|
|
198
|
+
}
|
|
199
|
+
// 2. Resolve the target branch.
|
|
200
|
+
const startBranch = git(root, ["branch", "--show-current"]) || git(root, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
201
|
+
let branch;
|
|
202
|
+
if (opts.onto) {
|
|
203
|
+
if (opts.onto !== startBranch) {
|
|
204
|
+
fail(`--onto ${opts.onto} is not the current branch (${startBranch || "(detached)"}). submit only targets the branch you're on.`);
|
|
205
|
+
}
|
|
206
|
+
branch = opts.onto;
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
const stamp = new Date().toISOString().replace(/[-:]/g, "").slice(0, 15);
|
|
210
|
+
branch = `mem/sync-${stamp}`;
|
|
211
|
+
let n = 0;
|
|
212
|
+
while (true) {
|
|
213
|
+
const exists = git(root, ["branch", "--list", branch]);
|
|
214
|
+
if (!exists)
|
|
215
|
+
break;
|
|
216
|
+
n++;
|
|
217
|
+
branch = `mem/sync-${stamp}-${n}`;
|
|
218
|
+
}
|
|
219
|
+
git(root, ["checkout", "-b", branch]);
|
|
220
|
+
}
|
|
221
|
+
const base = opts.base ?? startBranch;
|
|
222
|
+
const author = currentAuthor();
|
|
223
|
+
// 3. Copy drafts into the repo dir as proposed (same id — one memory, one id).
|
|
224
|
+
// Same id + different content on the branch = conflict → abort, human judges.
|
|
225
|
+
const submitted = [];
|
|
226
|
+
const skippedIdentical = [];
|
|
227
|
+
const now = msToRfc3339(Date.now());
|
|
228
|
+
const written = [];
|
|
229
|
+
const conflicts = [];
|
|
230
|
+
for (const v of validated) {
|
|
231
|
+
const destPath = path.join(repoDir, `${v.id}.md`);
|
|
232
|
+
const raw = fs.existsSync(destPath) ? fs.readFileSync(destPath, "utf8") : null;
|
|
233
|
+
if (raw !== null) {
|
|
234
|
+
const existing = parseMemory(raw);
|
|
235
|
+
const sameContent = existing !== null && contentHash(existing.body) === contentHash(v.body);
|
|
236
|
+
if (sameContent) {
|
|
237
|
+
skippedIdentical.push(v.id);
|
|
238
|
+
written.push({ id: v.id, srcPath: v.srcPath, destPath, hash: contentHash(v.body) });
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
conflicts.push(`${v.id}: already on branch ${branch} with different content — resolve it manually, then re-submit`);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const parsed = parseMemory(fs.readFileSync(v.srcPath, "utf8"));
|
|
245
|
+
if (!parsed)
|
|
246
|
+
fail(`${v.id}: unreadable source file`);
|
|
247
|
+
// draft/rejected → proposed; a local-approved copy arrives already
|
|
248
|
+
// approved and keeps its approval (and its approved_by).
|
|
249
|
+
const destState = v.reviewState === "approved" ? "approved" : "proposed";
|
|
250
|
+
const fm = {
|
|
251
|
+
...parsed.fm,
|
|
252
|
+
review_state: destState,
|
|
253
|
+
updated_at: now,
|
|
254
|
+
proposed_by: parsed.fm.proposed_by ?? author,
|
|
255
|
+
// D29: the outbox → repo move is part of the audit trail.
|
|
256
|
+
review_history: [
|
|
257
|
+
...(parsed.fm.review_history ?? []),
|
|
258
|
+
{
|
|
259
|
+
at: now,
|
|
260
|
+
by: author,
|
|
261
|
+
from: parsed.fm.review_state ?? "draft",
|
|
262
|
+
to: destState,
|
|
263
|
+
note: `submitted to branch ${branch}`,
|
|
264
|
+
},
|
|
265
|
+
],
|
|
266
|
+
};
|
|
267
|
+
const text = serialize(fm, parsed.body);
|
|
268
|
+
fs.writeFileSync(destPath, text, "utf8");
|
|
269
|
+
written.push({ id: v.id, srcPath: v.srcPath, destPath, hash: contentHash(parsed.body) });
|
|
270
|
+
submitted.push({ id: v.id, filePath: destPath, reviewState: destState });
|
|
271
|
+
}
|
|
272
|
+
if (conflicts.length > 0) {
|
|
273
|
+
// Roll back the copies we just made — nothing half-submitted.
|
|
274
|
+
for (const w of written) {
|
|
275
|
+
if (submitted.some((s) => s.id === w.id) && fs.existsSync(w.destPath)) {
|
|
276
|
+
fs.unlinkSync(w.destPath);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
// If we created the branch and never committed, remove it too — an
|
|
280
|
+
// aborted submit leaves no trace.
|
|
281
|
+
if (!opts.onto) {
|
|
282
|
+
try {
|
|
283
|
+
git(root, ["checkout", "--quiet", startBranch]);
|
|
284
|
+
git(root, ["branch", "--quiet", "-D", branch]);
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
/* best effort — the error below is what matters */
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
fail(`submit aborted — nothing was committed:\n ${conflicts.join("\n ")}`);
|
|
291
|
+
}
|
|
292
|
+
// 4. Commit locally (tool automates local git only — D27 line).
|
|
293
|
+
// Only our own files are committed: anything else the user staged stays
|
|
294
|
+
// staged and untouched.
|
|
295
|
+
const destPaths = written.map((w) => w.destPath);
|
|
296
|
+
const relPaths = destPaths.map((p) => path.relative(root, p));
|
|
297
|
+
git(root, ["add", "--", ...destPaths]);
|
|
298
|
+
const staged = git(root, ["diff", "--cached", "--name-only", "--", ...relPaths]);
|
|
299
|
+
let committed = false;
|
|
300
|
+
if (staged.trim()) {
|
|
301
|
+
const short = submitted.map((s) => s.id.slice(0, 8)).join(" ");
|
|
302
|
+
git(root, ["commit", "-m", `mem: submit ${submitted.length} ${submitted.length === 1 ? "memory" : "memories"} for review (${short})`, "--", ...relPaths]);
|
|
303
|
+
committed = true;
|
|
304
|
+
}
|
|
305
|
+
// 5. Verify the committed copies, then move the outbox originals out
|
|
306
|
+
// (copy + verify + delete — never a bare rename across the move).
|
|
307
|
+
for (const w of written) {
|
|
308
|
+
const back = fs.readFileSync(w.destPath, "utf8");
|
|
309
|
+
const bParsed = parseMemory(back);
|
|
310
|
+
if (!bParsed || contentHash(bParsed.body) !== w.hash) {
|
|
311
|
+
fail(`${w.id}: verification failed after commit — outbox copy kept at ${w.srcPath}`);
|
|
312
|
+
}
|
|
313
|
+
// Same id, new home: ON CONFLICT(id) rewrites the row to the repo path.
|
|
314
|
+
upsertFromFile({
|
|
315
|
+
fm: bParsed.fm,
|
|
316
|
+
body: bParsed.body,
|
|
317
|
+
filePath: w.destPath,
|
|
318
|
+
mtimeMs: fs.statSync(w.destPath).mtimeMs,
|
|
319
|
+
});
|
|
320
|
+
fs.unlinkSync(w.srcPath);
|
|
321
|
+
}
|
|
322
|
+
const ids8 = validated.map((v) => v.id.slice(0, 8)).join(" ");
|
|
323
|
+
// D31: the submit itself is a sync event — visible in sync-status.
|
|
324
|
+
recordSync(scope.key, "submit", {
|
|
325
|
+
added: submitted.length,
|
|
326
|
+
updated: skippedIdentical.length,
|
|
327
|
+
removed: 0,
|
|
328
|
+
scanned: validated.length,
|
|
329
|
+
});
|
|
330
|
+
return {
|
|
331
|
+
branch,
|
|
332
|
+
base,
|
|
333
|
+
submitted,
|
|
334
|
+
skippedIdentical,
|
|
335
|
+
committed,
|
|
336
|
+
pushCommand: `git push -u origin ${branch}`,
|
|
337
|
+
prCommand: `gh pr create --base ${base} --title "mem: review ${validated.length} ${validated.length === 1 ? "memory" : "memories"}" --body "Submitted from the open-memex outbox: ${ids8}."`,
|
|
338
|
+
};
|
|
339
|
+
}
|
package/dist/tools/ops.js
CHANGED
|
@@ -6,14 +6,18 @@
|
|
|
6
6
|
* They take plain validated args and return a plain { title, output }
|
|
7
7
|
* result; each host adapts that to its own tool-result shape.
|
|
8
8
|
*/
|
|
9
|
+
import fs from "node:fs";
|
|
9
10
|
import { z } from "zod";
|
|
10
11
|
import { PERSONAL_SCOPE } from "../scope.js";
|
|
11
|
-
import { search, list } from "../retrieve/search.js";
|
|
12
|
-
import { writeMemoryFile,
|
|
12
|
+
import { search, list, hitStateLabel } from "../retrieve/search.js";
|
|
13
|
+
import { writeMemoryFile, readMemoryFile, ulid, msToRfc3339, MEMORY_TYPE_TAXONOMY, } from "../store/markdown.js";
|
|
13
14
|
import { upsertFromFile, deleteFromIndex } from "../store/sync.js";
|
|
14
15
|
import { findDuplicates, supersede } from "../store/lifecycle.js";
|
|
15
16
|
import { db } from "../store/db.js";
|
|
16
17
|
import { redact } from "../redact.js";
|
|
18
|
+
import { getSyncStatus, formatSyncStatus, submitMemories } from "../submit.js";
|
|
19
|
+
import { getPrStatus, formatPrStatus, applyPrStatus } from "../github.js";
|
|
20
|
+
import { proposeMemories, promoteMemory, listConflicts, resolveConflict, formatReviewHistory, } from "../review.js";
|
|
17
21
|
/** LLM-facing tool descriptions, shared by the opencode plugin and the MCP server. */
|
|
18
22
|
export const TOOL_DESCRIPTIONS = {
|
|
19
23
|
memory_add: "Save a fact, preference, decision, or note to persistent local memory. Call this PROACTIVELY whenever the user shares something worth remembering across sessions — project conventions, tool choices, personal preferences, decisions made, error fixes and their causes. Do not wait to be asked. Keep each memory to one self-contained statement. Default scope is the current project; use the personal scope for facts about the user that apply across all projects.",
|
|
@@ -21,6 +25,12 @@ export const TOOL_DESCRIPTIONS = {
|
|
|
21
25
|
memory_list: "List memories in a scope, newest first. Useful for browsing what is remembered, or verifying that a save landed.",
|
|
22
26
|
memory_supersede: "Replace an existing memory with a newer version. The old memory is kept as history (status: superseded) and retrieval returns the new one. Use when a saved fact becomes outdated and should be replaced rather than duplicated.",
|
|
23
27
|
memory_forget: "Delete a memory by id. Use when the user asks to forget something.",
|
|
28
|
+
memory_status: "Show the project memory sync pipeline: drafts waiting in the outbox (appdata), memories in the repo awaiting review or published, and any repo files not yet committed. Call this at session start and at task checkpoints, then ask the user which drafts to sync. The user may also trigger this flow by saying 'sync memory' (or '同步记忆').",
|
|
29
|
+
memory_submit: "Move outbox drafts into a git branch for review: creates a branch (default mem/sync-*), copies the drafts into the repo memory dir as proposed, commits locally, and moves the outbox originals out. Prints the push and PR commands — those need the user's explicit approval and are never run automatically.",
|
|
30
|
+
memory_propose: "Copy personal memories into the project outbox as review drafts. The personal originals stay put.",
|
|
31
|
+
memory_promote: "Advance a project memory one step up the review ladder (proposed → approved → published), or reject it with a note. Rejected memories are never deleted — they can be revised and resubmitted.",
|
|
32
|
+
memory_resolve: "List git-conflicted memory files, or attempt a field-level 3-way merge of one. Semantic conflicts are reported, never auto-resolved.",
|
|
33
|
+
memory_pr_status: "Read the current branch's GitHub PR and map its review state onto each in-repo memory: merged PR → published, PR approval → approved (approved_by = reviewer), changes-requested → suggestion only. Report by default; apply=true performs the mapped transitions locally (no push).",
|
|
24
34
|
};
|
|
25
35
|
/** Shared zod input shapes (raw shape, not z.object — hosts wrap as needed). */
|
|
26
36
|
export const scopeArg = z
|
|
@@ -65,6 +75,44 @@ export const memorySupersedeArgs = {
|
|
|
65
75
|
export const memoryForgetArgs = {
|
|
66
76
|
id: z.string().min(1),
|
|
67
77
|
};
|
|
78
|
+
export const memoryStatusArgs = {};
|
|
79
|
+
export const memorySubmitArgs = {
|
|
80
|
+
ids: z.array(z.string().min(1)).min(1).describe("Outbox draft ids to submit."),
|
|
81
|
+
onto: z
|
|
82
|
+
.string()
|
|
83
|
+
.optional()
|
|
84
|
+
.describe("Submit onto this branch instead of creating mem/sync-*. Must be the current branch (for folding memories into a code PR)."),
|
|
85
|
+
base: z
|
|
86
|
+
.string()
|
|
87
|
+
.optional()
|
|
88
|
+
.describe("PR base branch override. Default: the branch the submit branched from."),
|
|
89
|
+
};
|
|
90
|
+
export const memoryProposeArgs = {
|
|
91
|
+
ids: z.array(z.string().min(1)).min(1).describe("Personal memory ids to copy into the project outbox."),
|
|
92
|
+
localApprove: z
|
|
93
|
+
.boolean()
|
|
94
|
+
.optional()
|
|
95
|
+
.describe("Single-developer shortcut: mark the copies approved immediately."),
|
|
96
|
+
};
|
|
97
|
+
export const memoryPromoteArgs = {
|
|
98
|
+
id: z.string().min(1).describe("Project memory id."),
|
|
99
|
+
reject: z.boolean().optional().describe("Reject instead of advancing."),
|
|
100
|
+
resubmit: z.boolean().optional().describe("Move rejected back to proposed for another round."),
|
|
101
|
+
note: z.string().optional().describe("Review note recorded on reject."),
|
|
102
|
+
by: z.string().optional().describe("Reviewer name override."),
|
|
103
|
+
};
|
|
104
|
+
export const memoryResolveArgs = {
|
|
105
|
+
target: z
|
|
106
|
+
.string()
|
|
107
|
+
.optional()
|
|
108
|
+
.describe("Memory id or file path to resolve. Omit to list conflicts."),
|
|
109
|
+
};
|
|
110
|
+
export const memoryPrStatusArgs = {
|
|
111
|
+
apply: z
|
|
112
|
+
.boolean()
|
|
113
|
+
.optional()
|
|
114
|
+
.describe("Perform the mapped review transitions locally (no push). Default: report only."),
|
|
115
|
+
};
|
|
68
116
|
function resolveScope(getScope, kind) {
|
|
69
117
|
return kind === "personal" || kind === "user" ? PERSONAL_SCOPE : getScope();
|
|
70
118
|
}
|
|
@@ -87,6 +135,12 @@ function buildFrontmatter(s, body) {
|
|
|
87
135
|
updated_at: rfc,
|
|
88
136
|
supersedes: null,
|
|
89
137
|
superseded_by: null,
|
|
138
|
+
review_state: "draft",
|
|
139
|
+
proposed_by: null,
|
|
140
|
+
approved_by: null,
|
|
141
|
+
derived_from: null,
|
|
142
|
+
review_note: null,
|
|
143
|
+
review_history: [],
|
|
90
144
|
};
|
|
91
145
|
}
|
|
92
146
|
export async function addMemory(getScope, cfg, args) {
|
|
@@ -137,7 +191,7 @@ export async function searchMemories(getScope, args) {
|
|
|
137
191
|
output: `No memories matched "${args.query}".`,
|
|
138
192
|
};
|
|
139
193
|
}
|
|
140
|
-
const lines = hits.map((h) => `- [${h.scope_key === PERSONAL_SCOPE.key ? "personal" : "project"}/${h.type}] id=${h.id}\n ${h.snippet.replace(/\s+/g, " ").trim()}`);
|
|
194
|
+
const lines = hits.map((h) => `- [${h.scope_key === PERSONAL_SCOPE.key ? "personal" : "project"}/${h.type}]${hitStateLabel(h)} id=${h.id}\n ${h.snippet.replace(/\s+/g, " ").trim()}`);
|
|
141
195
|
return { title: `memory: ${hits.length} result(s)`, output: lines.join("\n") };
|
|
142
196
|
}
|
|
143
197
|
export async function listMemories(getScope, args) {
|
|
@@ -146,7 +200,7 @@ export async function listMemories(getScope, args) {
|
|
|
146
200
|
if (hits.length === 0) {
|
|
147
201
|
return { title: "memory: empty", output: `No memories in scope ${s.key}.` };
|
|
148
202
|
}
|
|
149
|
-
const lines = hits.map((h) => `- [${h.type}] id=${h.id} — ${h.snippet.replace(/\s+/g, " ").trim()}`);
|
|
203
|
+
const lines = hits.map((h) => `- [${h.type}]${hitStateLabel(h)} id=${h.id} — ${h.snippet.replace(/\s+/g, " ").trim()}`);
|
|
150
204
|
return { title: `memory: ${hits.length} in ${s.key}`, output: lines.join("\n") };
|
|
151
205
|
}
|
|
152
206
|
export async function supersedeMemory(cfg, args) {
|
|
@@ -175,12 +229,115 @@ export async function supersedeMemory(cfg, args) {
|
|
|
175
229
|
}
|
|
176
230
|
export async function forgetMemory(args) {
|
|
177
231
|
const row = db()
|
|
178
|
-
.prepare(`SELECT
|
|
232
|
+
.prepare(`SELECT file_path FROM memories WHERE id = ?`)
|
|
179
233
|
.get(args.id);
|
|
180
234
|
if (!row) {
|
|
181
235
|
return { title: "memory: not found", output: `No memory with id ${args.id}.` };
|
|
182
236
|
}
|
|
183
|
-
|
|
237
|
+
// Delete by indexed file_path — location-agnostic (appdata or in-repo, 2B/D24).
|
|
238
|
+
if (row.file_path) {
|
|
239
|
+
try {
|
|
240
|
+
fs.unlinkSync(row.file_path);
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
/* already gone */
|
|
244
|
+
}
|
|
245
|
+
}
|
|
184
246
|
deleteFromIndex(args.id);
|
|
185
247
|
return { title: "memory: forgotten", output: `Deleted ${args.id}.` };
|
|
186
248
|
}
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
// 2B/D26: review workflow ops (also exposed over MCP so agents can drive the
|
|
251
|
+
// sync loop: status → ask user → submit → push/PR on approval).
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
export async function statusMemories() {
|
|
254
|
+
const st = getSyncStatus();
|
|
255
|
+
const n = st.outbox.length +
|
|
256
|
+
st.inRepo.filter((e) => e.reviewState === "proposed" || e.reviewState === "approved").length;
|
|
257
|
+
return {
|
|
258
|
+
title: `memory: sync status (${n} pending)`,
|
|
259
|
+
output: formatSyncStatus(st),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
export async function submitMemoriesOp(args) {
|
|
263
|
+
const r = submitMemories(args.ids, { onto: args.onto, base: args.base });
|
|
264
|
+
const lines = [];
|
|
265
|
+
for (const s of r.submitted)
|
|
266
|
+
lines.push(`submitted ${s.id} [${s.reviewState}]`);
|
|
267
|
+
for (const id of r.skippedIdentical)
|
|
268
|
+
lines.push(`already on branch: ${id} (outbox copy removed)`);
|
|
269
|
+
lines.push(r.committed ? `committed on ${r.branch}.` : `nothing new to commit on ${r.branch}.`);
|
|
270
|
+
lines.push(`Next (needs the user's explicit approval — never run automatically):`);
|
|
271
|
+
lines.push(` ${r.pushCommand}`);
|
|
272
|
+
lines.push(` ${r.prCommand}`);
|
|
273
|
+
return { title: `memory: submitted ${r.submitted.length}`, output: lines.join("\n") };
|
|
274
|
+
}
|
|
275
|
+
export async function proposeMemoriesOp(args) {
|
|
276
|
+
const batch = proposeMemories(args.ids, { localApprove: args.localApprove });
|
|
277
|
+
const lines = batch.map(({ sourceId, result: r }) => `proposed ${sourceId} → ${r.id} [${r.reviewState}] (outbox draft)`);
|
|
278
|
+
lines.push(`Next: open-memex sync-status, then submit the new ids to move them into a branch.`);
|
|
279
|
+
return { title: `memory: proposed ${batch.length}`, output: lines.join("\n") };
|
|
280
|
+
}
|
|
281
|
+
export async function promoteMemoryOp(args) {
|
|
282
|
+
const r = promoteMemory(args.id, {
|
|
283
|
+
reject: args.reject,
|
|
284
|
+
resubmit: args.resubmit,
|
|
285
|
+
note: args.note,
|
|
286
|
+
by: args.by,
|
|
287
|
+
});
|
|
288
|
+
const lines = [`promote ${r.id}: ${r.from} → ${r.to}`];
|
|
289
|
+
if (r.to === "rejected") {
|
|
290
|
+
lines.push(`not deleted — the file stays on the branch. Accept (close the PR), revise + resubmit, or keep as a [rejected] record.`);
|
|
291
|
+
}
|
|
292
|
+
if (r.history.length > 0) {
|
|
293
|
+
lines.push(`history (${r.history.length}):`);
|
|
294
|
+
lines.push(...formatReviewHistory(r.history));
|
|
295
|
+
}
|
|
296
|
+
return { title: `memory: ${r.id} → ${r.to}`, output: lines.join("\n") };
|
|
297
|
+
}
|
|
298
|
+
export async function resolveMemoryOp(args) {
|
|
299
|
+
if (!args.target) {
|
|
300
|
+
const conflicts = listConflicts();
|
|
301
|
+
if (conflicts.length === 0) {
|
|
302
|
+
return { title: "memory: no conflicts", output: "(no conflicted memory files)" };
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
title: `memory: ${conflicts.length} conflicted`,
|
|
306
|
+
output: conflicts.map((c) => `conflicted: ${c.id} ${c.filePath}`).join("\n"),
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
const outcome = resolveConflict(args.target);
|
|
310
|
+
if (!outcome.ok) {
|
|
311
|
+
const lines = [
|
|
312
|
+
`cannot auto-resolve ${outcome.filePath} — semantic conflicts need a human:`,
|
|
313
|
+
...outcome.conflicts.flatMap((c) => [
|
|
314
|
+
` ${c.field}:`,
|
|
315
|
+
` base: ${c.base}`,
|
|
316
|
+
` ours: ${c.ours}`,
|
|
317
|
+
` theirs: ${c.theirs}`,
|
|
318
|
+
]),
|
|
319
|
+
`Edit the file manually, then \`git add\` it. Nothing was written.`,
|
|
320
|
+
];
|
|
321
|
+
return { title: "memory: resolve blocked", output: lines.join("\n") };
|
|
322
|
+
}
|
|
323
|
+
const lines = [`resolved ${outcome.filePath}`];
|
|
324
|
+
if (outcome.autoMerged.length > 0)
|
|
325
|
+
lines.push(` auto-merged: ${outcome.autoMerged.join(", ")}`);
|
|
326
|
+
return { title: "memory: resolved", output: lines.join("\n") };
|
|
327
|
+
}
|
|
328
|
+
export async function prStatusOp(args) {
|
|
329
|
+
const st = getPrStatus();
|
|
330
|
+
const lines = [formatPrStatus(st)];
|
|
331
|
+
if (args.apply) {
|
|
332
|
+
const applied = applyPrStatus(st);
|
|
333
|
+
if (applied.length === 0) {
|
|
334
|
+
lines.push(`nothing to apply.`);
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
for (const a of applied) {
|
|
338
|
+
lines.push(`applied ${a.memoryId.slice(0, 8)}: ${a.from} → ${a.to} (by ${a.by})`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return { title: "memory: pr-status", output: lines.join("\n") };
|
|
343
|
+
}
|