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/retrieve/search.ts
CHANGED
|
@@ -11,6 +11,7 @@ export interface SearchHit {
|
|
|
11
11
|
score: number;
|
|
12
12
|
updated_at: number;
|
|
13
13
|
status: string;
|
|
14
|
+
review_state: string;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
interface RawRow {
|
|
@@ -24,6 +25,7 @@ interface RawRow {
|
|
|
24
25
|
score: number;
|
|
25
26
|
status: string;
|
|
26
27
|
superseded_by: string | null;
|
|
28
|
+
review_state: string;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
function toHit(r: RawRow): SearchHit {
|
|
@@ -37,13 +39,36 @@ function toHit(r: RawRow): SearchHit {
|
|
|
37
39
|
score: r.score,
|
|
38
40
|
updated_at: r.updated_at,
|
|
39
41
|
status: r.status,
|
|
42
|
+
review_state: r.review_state ?? "draft",
|
|
40
43
|
};
|
|
41
44
|
}
|
|
42
45
|
|
|
46
|
+
/**
|
|
47
|
+
* D30: review-state ranking tiers for shared (project) memories.
|
|
48
|
+
* Reviewed knowledge (approved/published) outranks unreviewed outbox drafts;
|
|
49
|
+
* personal memories are always `draft` by design and are NOT demoted.
|
|
50
|
+
* Stable: order within a tier keeps the incoming score/recency order.
|
|
51
|
+
*/
|
|
52
|
+
function reviewTier(r: RawRow): number {
|
|
53
|
+
if (r.scope_key === "personal") return 1;
|
|
54
|
+
if (r.review_state === "approved" || r.review_state === "published") return 0;
|
|
55
|
+
if (r.review_state === "draft" || r.review_state === "rejected") return 2;
|
|
56
|
+
return 1; // proposed and anything unexpected
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** D30: `[draft]` / `[proposed]` / … marker for project memories.
|
|
60
|
+
* Personal memories stay unmarked (always draft — the marker would be noise). */
|
|
61
|
+
export function hitStateLabel(h: Pick<SearchHit, "scope_key" | "review_state">): string {
|
|
62
|
+
if (h.scope_key === "personal") return "";
|
|
63
|
+
return ` [${h.review_state || "draft"}]`;
|
|
64
|
+
}
|
|
65
|
+
|
|
43
66
|
/**
|
|
44
67
|
* Lifecycle-aware post-processing (§3.3):
|
|
45
68
|
* - retracted / archived are excluded from retrieval (kept for audit);
|
|
46
69
|
* - a superseded memory resolves to the newest of its chain (cycle-safe);
|
|
70
|
+
* - D30: approved/published project memories rank first, project
|
|
71
|
+
* drafts/rejected rank after unreviewed content;
|
|
47
72
|
* - deprecated stays visible as a warning but ranks after active.
|
|
48
73
|
*/
|
|
49
74
|
function resolveVisible(rows: RawRow[], limit: number): SearchHit[] {
|
|
@@ -54,7 +79,7 @@ function resolveVisible(rows: RawRow[], limit: number): SearchHit[] {
|
|
|
54
79
|
const r = db()
|
|
55
80
|
.prepare(
|
|
56
81
|
`SELECT id, scope_key, project_name, type, tags, updated_at, status,
|
|
57
|
-
superseded_by, substr(content, 1, 240) AS snippet
|
|
82
|
+
superseded_by, review_state, substr(content, 1, 240) AS snippet
|
|
58
83
|
FROM memories WHERE id = ?`,
|
|
59
84
|
)
|
|
60
85
|
.get(id) as
|
|
@@ -67,7 +92,7 @@ function resolveVisible(rows: RawRow[], limit: number): SearchHit[] {
|
|
|
67
92
|
};
|
|
68
93
|
|
|
69
94
|
const seen = new Set<string>();
|
|
70
|
-
const
|
|
95
|
+
const tiers: SearchHit[][] = [[], [], [], []];
|
|
71
96
|
const deprecated: SearchHit[] = [];
|
|
72
97
|
|
|
73
98
|
for (const r of rows) {
|
|
@@ -90,9 +115,9 @@ function resolveVisible(rows: RawRow[], limit: number): SearchHit[] {
|
|
|
90
115
|
seen.add(target.id);
|
|
91
116
|
const hit = toHit(target);
|
|
92
117
|
if (target.status === "deprecated") deprecated.push(hit);
|
|
93
|
-
else
|
|
118
|
+
else tiers[reviewTier(target)].push(hit);
|
|
94
119
|
}
|
|
95
|
-
return [...
|
|
120
|
+
return [...tiers[0], ...tiers[1], ...tiers[2], ...deprecated].slice(0, limit);
|
|
96
121
|
}
|
|
97
122
|
|
|
98
123
|
/**
|
|
@@ -130,7 +155,7 @@ export function search(
|
|
|
130
155
|
|
|
131
156
|
const sql = `
|
|
132
157
|
SELECT m.id, m.scope_key, m.project_name, m.type, m.tags, m.updated_at,
|
|
133
|
-
m.status, m.superseded_by,
|
|
158
|
+
m.status, m.superseded_by, m.review_state,
|
|
134
159
|
snippet(memories_fts, 0, '[', ']', ' ... ', 12) AS snippet,
|
|
135
160
|
bm25(memories_fts) AS score
|
|
136
161
|
FROM memories_fts
|
|
@@ -155,6 +180,7 @@ export function search(
|
|
|
155
180
|
updated_at: number;
|
|
156
181
|
status: string;
|
|
157
182
|
superseded_by: string | null;
|
|
183
|
+
review_state: string;
|
|
158
184
|
snippet: string;
|
|
159
185
|
score: number;
|
|
160
186
|
}>;
|
|
@@ -173,7 +199,7 @@ export function list(
|
|
|
173
199
|
const typeFilter = opts.type ? ` AND type = ?` : "";
|
|
174
200
|
const sql = `
|
|
175
201
|
SELECT id, scope_key, project_name, type, tags, updated_at, status,
|
|
176
|
-
superseded_by, substr(content, 1, 240) AS snippet
|
|
202
|
+
superseded_by, review_state, substr(content, 1, 240) AS snippet
|
|
177
203
|
FROM memories
|
|
178
204
|
WHERE scope_key = ?${typeFilter}
|
|
179
205
|
ORDER BY updated_at DESC
|
|
@@ -194,6 +220,7 @@ export function list(
|
|
|
194
220
|
updated_at: number;
|
|
195
221
|
status: string;
|
|
196
222
|
superseded_by: string | null;
|
|
223
|
+
review_state: string;
|
|
197
224
|
snippet: string;
|
|
198
225
|
}>;
|
|
199
226
|
|
package/src/review.ts
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 2B/D26: propose → submit → promote → resolve workflow.
|
|
3
|
+
*
|
|
4
|
+
* - `propose <id> --to project`: copy a personal memory into the project
|
|
5
|
+
* outbox (appdata) as a draft. Never moves — the personal original stays
|
|
6
|
+
* put. Outbox drafts are git-invisible until submitted.
|
|
7
|
+
* - `submit <ids>`: move outbox drafts into a git branch (new `mem/sync-*`
|
|
8
|
+
* by default), as `proposed`, commit locally, print push + PR commands.
|
|
9
|
+
* Push/PR need the user's explicit approval — the tool never pushes.
|
|
10
|
+
* - `promote <id>`: advance a project memory one step up the review ladder
|
|
11
|
+
* (proposed → approved → published), or --reject it with a note.
|
|
12
|
+
* - `resolve [id]`: assist with git merge conflicts inside the in-repo
|
|
13
|
+
* memory dir. Frontmatter gets a field-level 3-way merge; semantic
|
|
14
|
+
* conflicts are reported, never auto-resolved.
|
|
15
|
+
*
|
|
16
|
+
* Git automation line (D25 as revised by D26): local git only (submit's
|
|
17
|
+
* branch + commit). Nothing leaves the machine without approval.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { execFileSync } from "node:child_process";
|
|
21
|
+
import * as fs from "node:fs";
|
|
22
|
+
import * as os from "node:os";
|
|
23
|
+
import * as path from "node:path";
|
|
24
|
+
import { loadConfig } from "./config.ts";
|
|
25
|
+
import { inRepoMemoriesDirPath, projectRoot } from "./paths.ts";
|
|
26
|
+
import { resolveProjectScope } from "./scope.ts";
|
|
27
|
+
import {
|
|
28
|
+
parse as parseMemory,
|
|
29
|
+
serialize,
|
|
30
|
+
ulid,
|
|
31
|
+
writeMemoryFile,
|
|
32
|
+
type Frontmatter,
|
|
33
|
+
type ReviewState,
|
|
34
|
+
type ReviewTransition,
|
|
35
|
+
} from "./store/markdown.ts";
|
|
36
|
+
import { findMemoryFile } from "./store/lifecycle.ts";
|
|
37
|
+
import { upsertFromFile } from "./store/sync.ts";
|
|
38
|
+
|
|
39
|
+
/** Best-effort author identity: git user.name, else the OS user. */
|
|
40
|
+
export function currentAuthor(): string {
|
|
41
|
+
try {
|
|
42
|
+
const name = execFileSync("git", ["config", "user.name"], {
|
|
43
|
+
encoding: "utf8",
|
|
44
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
45
|
+
}).trim();
|
|
46
|
+
if (name) return name;
|
|
47
|
+
} catch {
|
|
48
|
+
/* git unavailable — fall through */
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
return os.userInfo().username || "unknown";
|
|
52
|
+
} catch {
|
|
53
|
+
return "unknown";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function fail(msg: string): never {
|
|
58
|
+
throw new Error(`[open-memex] ${msg}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// propose
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
export interface ProposeResult {
|
|
66
|
+
id: string;
|
|
67
|
+
filePath: string;
|
|
68
|
+
reviewState: ReviewState;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Copy one personal memory into the project scope as a review candidate.
|
|
73
|
+
* The source memory is untouched (copy, not move — D25).
|
|
74
|
+
* Batch-friendly: validates nothing, copies one — use proposeMemories()
|
|
75
|
+
* for the all-or-nothing multi-id path.
|
|
76
|
+
*/
|
|
77
|
+
export function proposeMemory(
|
|
78
|
+
sourceId: string,
|
|
79
|
+
opts: { localApprove?: boolean } = {},
|
|
80
|
+
): ProposeResult {
|
|
81
|
+
const src = findMemoryFile(sourceId);
|
|
82
|
+
if (!src) fail(`no memory found with id "${sourceId}".`);
|
|
83
|
+
return copyPersonalToProject(sourceId, src.fm, src.body, opts);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function copyPersonalToProject(
|
|
87
|
+
sourceId: string,
|
|
88
|
+
srcFm: Frontmatter,
|
|
89
|
+
srcBody: string,
|
|
90
|
+
opts: { localApprove?: boolean } = {},
|
|
91
|
+
): ProposeResult {
|
|
92
|
+
if (srcFm.scope === "project")
|
|
93
|
+
fail(`memory "${sourceId}" is already in project scope — nothing to propose.`);
|
|
94
|
+
if (srcFm.scope === "org")
|
|
95
|
+
fail(`memory "${sourceId}" is already shared at org scope.`);
|
|
96
|
+
if (srcFm.status === "retracted" || srcFm.status === "archived")
|
|
97
|
+
fail(`memory "${sourceId}" is ${srcFm.status} — cannot propose it.`);
|
|
98
|
+
|
|
99
|
+
const project = resolveProjectScope(projectRoot());
|
|
100
|
+
const author = currentAuthor();
|
|
101
|
+
const now = new Date().toISOString();
|
|
102
|
+
// propose lands in the outbox as a draft (submit moves it to proposed);
|
|
103
|
+
// --local-approve is the solo-dev shortcut straight to approved.
|
|
104
|
+
const targetState: ReviewState = opts.localApprove ? "approved" : "draft";
|
|
105
|
+
const fm: Frontmatter = {
|
|
106
|
+
...srcFm,
|
|
107
|
+
id: ulid(),
|
|
108
|
+
scope: "project",
|
|
109
|
+
scope_key: project.key,
|
|
110
|
+
project_name: project.projectName,
|
|
111
|
+
created_at: now,
|
|
112
|
+
updated_at: now,
|
|
113
|
+
supersedes: null,
|
|
114
|
+
superseded_by: null,
|
|
115
|
+
review_state: targetState,
|
|
116
|
+
proposed_by: author,
|
|
117
|
+
approved_by: opts.localApprove ? author : null,
|
|
118
|
+
derived_from: srcFm.id,
|
|
119
|
+
review_history: [
|
|
120
|
+
{
|
|
121
|
+
at: now,
|
|
122
|
+
by: author,
|
|
123
|
+
from: "draft",
|
|
124
|
+
to: targetState,
|
|
125
|
+
note: opts.localApprove
|
|
126
|
+
? `proposed from personal memory ${srcFm.id} (local-approved)`
|
|
127
|
+
: `proposed from personal memory ${srcFm.id}`,
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
};
|
|
131
|
+
const { filePath } = writeMemoryFile(fm, srcBody);
|
|
132
|
+
upsertFromFile({ fm, body: srcBody, filePath, mtimeMs: Date.now() });
|
|
133
|
+
return { id: fm.id, filePath, reviewState: fm.review_state };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Propose several personal memories at once — one branch, one PR.
|
|
138
|
+
* All-or-nothing: every id is validated before anything is copied, so a bad
|
|
139
|
+
* id never leaves a half-proposed batch behind.
|
|
140
|
+
*/
|
|
141
|
+
export function proposeMemories(
|
|
142
|
+
sourceIds: string[],
|
|
143
|
+
opts: { localApprove?: boolean } = {},
|
|
144
|
+
): { sourceId: string; result: ProposeResult }[] {
|
|
145
|
+
if (sourceIds.length === 0) fail("propose needs at least one memory id.");
|
|
146
|
+
const seen = new Set<string>();
|
|
147
|
+
const sources = sourceIds.map((sourceId) => {
|
|
148
|
+
if (seen.has(sourceId)) fail(`duplicate id "${sourceId}" — list each memory once.`);
|
|
149
|
+
seen.add(sourceId);
|
|
150
|
+
const src = findMemoryFile(sourceId);
|
|
151
|
+
if (!src) fail(`no memory found with id "${sourceId}".`);
|
|
152
|
+
if (src.fm.scope !== "personal")
|
|
153
|
+
fail(`only personal memories can be proposed (memory "${sourceId}" is ${src.fm.scope}).`);
|
|
154
|
+
if (src.fm.status === "retracted" || src.fm.status === "archived")
|
|
155
|
+
fail(`memory "${sourceId}" is ${src.fm.status} — cannot propose it.`);
|
|
156
|
+
return { sourceId, fm: src.fm, body: src.body };
|
|
157
|
+
});
|
|
158
|
+
return sources.map(({ sourceId, fm, body }) => ({
|
|
159
|
+
sourceId,
|
|
160
|
+
result: copyPersonalToProject(sourceId, fm, body, opts),
|
|
161
|
+
}));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// promote
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
export interface PromoteResult {
|
|
169
|
+
id: string;
|
|
170
|
+
from: ReviewState;
|
|
171
|
+
to: ReviewState;
|
|
172
|
+
/** Full audit trail after this transition (D29). */
|
|
173
|
+
history: ReviewTransition[];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const NEXT_STATE: Partial<Record<ReviewState, ReviewState>> = {
|
|
177
|
+
proposed: "approved",
|
|
178
|
+
approved: "published",
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Advance a project memory one step up the review ladder
|
|
183
|
+
* (proposed → approved → published), reject it with --reject, or send a
|
|
184
|
+
* rejected memory back for another round with --resubmit.
|
|
185
|
+
*
|
|
186
|
+
* A rejection never deletes anything: the file stays on the author's branch.
|
|
187
|
+
* What happens next is the human's call — accept it (close the PR, delete the
|
|
188
|
+
* branch), revise + --resubmit, or keep the rejected file as a record.
|
|
189
|
+
*/
|
|
190
|
+
export function promoteMemory(
|
|
191
|
+
id: string,
|
|
192
|
+
opts: { reject?: boolean; resubmit?: boolean; note?: string; by?: string } = {},
|
|
193
|
+
): PromoteResult {
|
|
194
|
+
const mf = findMemoryFile(id);
|
|
195
|
+
if (!mf) fail(`no memory found with id "${id}".`);
|
|
196
|
+
if (mf.fm.scope !== "project")
|
|
197
|
+
fail(`only project-scope memories go through review (memory "${id}" is ${mf.fm.scope}).`);
|
|
198
|
+
|
|
199
|
+
const from = mf.fm.review_state ?? "draft";
|
|
200
|
+
const by = opts.by?.trim() || currentAuthor();
|
|
201
|
+
let to: ReviewState;
|
|
202
|
+
if (opts.resubmit) {
|
|
203
|
+
if (opts.reject) fail("choose one: --resubmit or --reject, not both.");
|
|
204
|
+
if (from !== "rejected")
|
|
205
|
+
fail(`only a "rejected" memory can be resubmitted (memory "${id}" is "${from}").`);
|
|
206
|
+
to = "proposed";
|
|
207
|
+
} else if (opts.reject) {
|
|
208
|
+
if (from !== "proposed" && from !== "approved")
|
|
209
|
+
fail(`cannot reject from "${from}" — only "proposed" or "approved" can be rejected.`);
|
|
210
|
+
to = "rejected";
|
|
211
|
+
} else {
|
|
212
|
+
const next = NEXT_STATE[from];
|
|
213
|
+
if (!next)
|
|
214
|
+
fail(
|
|
215
|
+
from === "draft"
|
|
216
|
+
? `memory "${id}" is still a draft — run "open-memex propose ${id} --to project" first.`
|
|
217
|
+
: `memory "${id}" is "${from}" — a terminal state, nothing to promote.`,
|
|
218
|
+
);
|
|
219
|
+
to = next;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const fm: Frontmatter = {
|
|
223
|
+
...mf.fm,
|
|
224
|
+
review_state: to,
|
|
225
|
+
updated_at: new Date().toISOString(),
|
|
226
|
+
// A rejection withdraws any earlier approval; a resubmission clears it too
|
|
227
|
+
// (the memory must earn approval again).
|
|
228
|
+
approved_by: to === "approved" ? by : to === "rejected" || to === "proposed" ? null : mf.fm.approved_by,
|
|
229
|
+
review_note: opts.note?.trim() ? opts.note.trim() : mf.fm.review_note,
|
|
230
|
+
// D29: every transition is appended to the audit trail — the note is
|
|
231
|
+
// preserved here even when review_note later gets overwritten.
|
|
232
|
+
review_history: [
|
|
233
|
+
...(mf.fm.review_history ?? []),
|
|
234
|
+
{
|
|
235
|
+
at: new Date().toISOString(),
|
|
236
|
+
by,
|
|
237
|
+
from,
|
|
238
|
+
to,
|
|
239
|
+
note: opts.note?.trim() ? opts.note.trim() : null,
|
|
240
|
+
},
|
|
241
|
+
],
|
|
242
|
+
};
|
|
243
|
+
fs.writeFileSync(mf.filePath, serialize(fm, mf.body), "utf8");
|
|
244
|
+
upsertFromFile({ fm, body: mf.body, filePath: mf.filePath, mtimeMs: Date.now() });
|
|
245
|
+
return { id, from, to, history: fm.review_history };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Render a memory's audit trail as compact human-readable lines (D29). */
|
|
249
|
+
export function formatReviewHistory(history: ReviewTransition[]): string[] {
|
|
250
|
+
return history.map(
|
|
251
|
+
(t) =>
|
|
252
|
+
` ${t.at.slice(0, 10)} ${t.by}: ${t.from} → ${t.to}` +
|
|
253
|
+
(t.note ? ` — ${t.note}` : ""),
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// resolve
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
export interface ConflictEntry {
|
|
262
|
+
id: string;
|
|
263
|
+
filePath: string;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function git(args: string[], cwd: string): string {
|
|
267
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function inRepoDir(): { root: string; dir: string } {
|
|
271
|
+
const root = projectRoot();
|
|
272
|
+
try {
|
|
273
|
+
git(["rev-parse", "--git-dir"], root);
|
|
274
|
+
} catch {
|
|
275
|
+
fail("resolve needs a git repo — this directory is not one.");
|
|
276
|
+
}
|
|
277
|
+
return { root, dir: inRepoMemoriesDirPath(root, loadConfig().memoryDir) };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** List in-repo memory files currently in an unmerged (conflicted) state. */
|
|
281
|
+
export function listConflicts(): ConflictEntry[] {
|
|
282
|
+
const { root, dir } = inRepoDir();
|
|
283
|
+
let out: string;
|
|
284
|
+
try {
|
|
285
|
+
out = git(["diff", "--name-only", "--diff-filter=U", "--", dir], root);
|
|
286
|
+
} catch {
|
|
287
|
+
fail("git is unavailable or the repo state cannot be read.");
|
|
288
|
+
}
|
|
289
|
+
return out
|
|
290
|
+
.split("\n")
|
|
291
|
+
.map((s) => s.trim())
|
|
292
|
+
.filter((s) => s && s.endsWith(".md"))
|
|
293
|
+
.map((rel) => ({
|
|
294
|
+
id: path.basename(rel, ".md"),
|
|
295
|
+
filePath: path.join(root, rel),
|
|
296
|
+
}));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export interface FieldConflict {
|
|
300
|
+
field: string;
|
|
301
|
+
base: string;
|
|
302
|
+
ours: string;
|
|
303
|
+
theirs: string;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export type ResolveOutcome =
|
|
307
|
+
| { ok: true; filePath: string; autoMerged: string[] }
|
|
308
|
+
| { ok: false; filePath: string; conflicts: FieldConflict[] };
|
|
309
|
+
|
|
310
|
+
function show(v: unknown): string {
|
|
311
|
+
if (v === null || v === undefined) return "(empty)";
|
|
312
|
+
if (Array.isArray(v)) return v.length ? v.join(", ") : "(empty)";
|
|
313
|
+
return String(v);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Attempt a field-level 3-way merge of a conflicted memory file.
|
|
318
|
+
* Never auto-resolves semantic conflicts (same field changed differently
|
|
319
|
+
* on both sides) — those are reported for the human to decide.
|
|
320
|
+
*/
|
|
321
|
+
export function resolveConflict(target: string): ResolveOutcome {
|
|
322
|
+
const { root, dir } = inRepoDir();
|
|
323
|
+
|
|
324
|
+
// Locate the file: by memory id, or as a direct path. Note findMemoryFile
|
|
325
|
+
// returns null for a file with conflict markers (YAML won't parse), so
|
|
326
|
+
// fall back to a direct <id>.md lookup inside the in-repo dir.
|
|
327
|
+
let filePath: string | null = null;
|
|
328
|
+
const byId = findMemoryFile(target);
|
|
329
|
+
if (byId) {
|
|
330
|
+
filePath = byId.filePath;
|
|
331
|
+
} else {
|
|
332
|
+
const base = path.basename(target).replace(/\.md$/, "");
|
|
333
|
+
const candidates = [
|
|
334
|
+
path.join(dir, `${base}.md`),
|
|
335
|
+
path.isAbsolute(target) ? target : path.join(root, target),
|
|
336
|
+
path.isAbsolute(target) ? `${target}.md` : path.join(root, `${target}.md`),
|
|
337
|
+
];
|
|
338
|
+
for (const c of candidates) {
|
|
339
|
+
if (fs.existsSync(c) && fs.statSync(c).isFile()) {
|
|
340
|
+
filePath = c;
|
|
341
|
+
break;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
if (!filePath) fail(`no memory or file found for "${target}".`);
|
|
346
|
+
|
|
347
|
+
// Must actually be in conflict (stages 1/2/3 present).
|
|
348
|
+
const stages = git(["ls-files", "-u", "--", filePath], root)
|
|
349
|
+
.split("\n")
|
|
350
|
+
.map((l) => l.trim())
|
|
351
|
+
.filter(Boolean);
|
|
352
|
+
if (stages.length === 0)
|
|
353
|
+
fail(`"${path.basename(filePath)}" is not in a merge-conflict state.`);
|
|
354
|
+
|
|
355
|
+
const blob = (stage: string): string => {
|
|
356
|
+
// Git wants forward slashes even on Windows.
|
|
357
|
+
const rel = path.relative(root, filePath).split(path.sep).join("/");
|
|
358
|
+
try {
|
|
359
|
+
return git(["show", `:${stage}:${rel}`], root);
|
|
360
|
+
} catch {
|
|
361
|
+
fail(`cannot read git stage ${stage} for "${path.basename(filePath)}".`);
|
|
362
|
+
throw new Error("unreachable");
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
const base = parseMemory(blob("1"));
|
|
366
|
+
const ours = parseMemory(blob("2"));
|
|
367
|
+
const theirs = parseMemory(blob("3"));
|
|
368
|
+
if (!base || !ours || !theirs)
|
|
369
|
+
fail(`one of the conflict stages for "${path.basename(filePath)}" is not a valid memory file.`);
|
|
370
|
+
|
|
371
|
+
const b = base.fm;
|
|
372
|
+
const o = ours.fm;
|
|
373
|
+
const t = theirs.fm;
|
|
374
|
+
const conflicts: FieldConflict[] = [];
|
|
375
|
+
const autoMerged: string[] = [];
|
|
376
|
+
const merged: Record<string, unknown> = {};
|
|
377
|
+
|
|
378
|
+
// Identity fields: both sides must agree, else this is not the same memory.
|
|
379
|
+
for (const f of ["id", "scope_key", "scope", "project_name", "schema_version"] as const) {
|
|
380
|
+
if (o[f] !== t[f]) {
|
|
381
|
+
conflicts.push({ field: f, base: show(b[f]), ours: show(o[f]), theirs: show(t[f]) });
|
|
382
|
+
} else {
|
|
383
|
+
merged[f] = o[f];
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Plain scalar fields: standard 3-way.
|
|
388
|
+
const scalars = [
|
|
389
|
+
"visibility",
|
|
390
|
+
"type",
|
|
391
|
+
"role",
|
|
392
|
+
"importance",
|
|
393
|
+
"status",
|
|
394
|
+
"source",
|
|
395
|
+
"created_at",
|
|
396
|
+
"proposed_by",
|
|
397
|
+
"approved_by",
|
|
398
|
+
"derived_from",
|
|
399
|
+
"review_note",
|
|
400
|
+
"supersedes",
|
|
401
|
+
"superseded_by",
|
|
402
|
+
] as const;
|
|
403
|
+
for (const f of scalars) {
|
|
404
|
+
const bv = b[f] as unknown;
|
|
405
|
+
const ov = o[f] as unknown;
|
|
406
|
+
const tv = t[f] as unknown;
|
|
407
|
+
if (eq(ov, tv)) {
|
|
408
|
+
merged[f] = ov;
|
|
409
|
+
} else if (eq(bv, ov)) {
|
|
410
|
+
merged[f] = tv;
|
|
411
|
+
autoMerged.push(f);
|
|
412
|
+
} else if (eq(bv, tv)) {
|
|
413
|
+
merged[f] = ov;
|
|
414
|
+
autoMerged.push(f);
|
|
415
|
+
} else {
|
|
416
|
+
conflicts.push({ field: f, base: show(bv), ours: show(ov), theirs: show(tv) });
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// review_state: 3-way, but never silently drop a rejection.
|
|
421
|
+
{
|
|
422
|
+
const bv = b.review_state;
|
|
423
|
+
const ov = o.review_state;
|
|
424
|
+
const tv = t.review_state;
|
|
425
|
+
if (ov === tv) merged.review_state = ov;
|
|
426
|
+
else if (bv === ov) merged.review_state = tv;
|
|
427
|
+
else if (bv === tv) merged.review_state = ov;
|
|
428
|
+
else if (ov === "rejected" || tv === "rejected")
|
|
429
|
+
conflicts.push({ field: "review_state", base: bv, ours: ov, theirs: tv });
|
|
430
|
+
else {
|
|
431
|
+
// Both advanced along the ladder; keep the further one.
|
|
432
|
+
const order: ReviewState[] = ["draft", "proposed", "approved", "published"];
|
|
433
|
+
merged.review_state = order.indexOf(tv) > order.indexOf(ov) ? tv : ov;
|
|
434
|
+
}
|
|
435
|
+
if (!conflicts.some((c) => c.field === "review_state") && merged.review_state !== bv)
|
|
436
|
+
autoMerged.push("review_state");
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// tags: union when both sides changed.
|
|
440
|
+
{
|
|
441
|
+
const bs = new Set(b.tags ?? []);
|
|
442
|
+
const os = new Set(o.tags ?? []);
|
|
443
|
+
const ts = new Set(t.tags ?? []);
|
|
444
|
+
if (eq([...os].sort(), [...ts].sort())) merged.tags = [...os];
|
|
445
|
+
else {
|
|
446
|
+
const union = [...new Set([...os, ...ts])].sort();
|
|
447
|
+
merged.tags = union;
|
|
448
|
+
if (!eq([...bs].sort(), union)) autoMerged.push("tags");
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// review_history (D29 audit trail): union by identity — a transition that
|
|
453
|
+
// happened on either side is kept; identical entries collapse.
|
|
454
|
+
{
|
|
455
|
+
const key = (e: ReviewTransition) => `${e.at}|${e.by}|${e.from}|${e.to}`;
|
|
456
|
+
const seen = new Set<string>();
|
|
457
|
+
const union: ReviewTransition[] = [];
|
|
458
|
+
const all = [...(b.review_history ?? []), ...(o.review_history ?? []), ...(t.review_history ?? [])];
|
|
459
|
+
for (const e of all) {
|
|
460
|
+
const k = key(e);
|
|
461
|
+
if (!seen.has(k)) {
|
|
462
|
+
seen.add(k);
|
|
463
|
+
union.push(e);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
merged.review_history = union;
|
|
467
|
+
if (union.length !== (b.review_history ?? []).length) autoMerged.push("review_history");
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// updated_at: always moves forward — take the latest.
|
|
471
|
+
{
|
|
472
|
+
const latest = [b.updated_at, o.updated_at, t.updated_at].sort().at(-1)!;
|
|
473
|
+
merged.updated_at = latest;
|
|
474
|
+
if (latest !== b.updated_at) autoMerged.push("updated_at");
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// body: 3-way text merge; divergent edits are a human decision.
|
|
478
|
+
const bb = base.body.trimEnd();
|
|
479
|
+
const ob = ours.body.trimEnd();
|
|
480
|
+
const tb = theirs.body.trimEnd();
|
|
481
|
+
let mergedBody: string;
|
|
482
|
+
if (ob === tb) mergedBody = ob;
|
|
483
|
+
else if (bb === ob) {
|
|
484
|
+
mergedBody = tb;
|
|
485
|
+
autoMerged.push("body");
|
|
486
|
+
} else if (bb === tb) {
|
|
487
|
+
mergedBody = ob;
|
|
488
|
+
autoMerged.push("body");
|
|
489
|
+
} else {
|
|
490
|
+
conflicts.push({ field: "body", base: bb.slice(0, 120), ours: ob.slice(0, 120), theirs: tb.slice(0, 120) });
|
|
491
|
+
mergedBody = bb; // unused when conflicts exist
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (conflicts.length > 0) return { ok: false, filePath, conflicts };
|
|
495
|
+
|
|
496
|
+
const fm = merged as unknown as Frontmatter;
|
|
497
|
+
fs.writeFileSync(filePath, serialize(fm, mergedBody), "utf8");
|
|
498
|
+
upsertFromFile({ fm, body: mergedBody, filePath, mtimeMs: Date.now() });
|
|
499
|
+
return { ok: true, filePath, autoMerged: [...new Set(autoMerged)] };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function eq(a: unknown, b: unknown): boolean {
|
|
503
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
504
|
+
}
|
package/src/store/db.ts
CHANGED
|
@@ -43,7 +43,8 @@ CREATE TABLE IF NOT EXISTS memories (
|
|
|
43
43
|
file_path TEXT NOT NULL,
|
|
44
44
|
mtime_ms REAL NOT NULL,
|
|
45
45
|
created_at INTEGER NOT NULL,
|
|
46
|
-
updated_at INTEGER NOT NULL
|
|
46
|
+
updated_at INTEGER NOT NULL,
|
|
47
|
+
review_state TEXT NOT NULL DEFAULT 'draft'
|
|
47
48
|
);
|
|
48
49
|
|
|
49
50
|
CREATE INDEX IF NOT EXISTS idx_memories_scope_updated
|
|
@@ -88,7 +89,7 @@ END;
|
|
|
88
89
|
`;
|
|
89
90
|
|
|
90
91
|
/** Current index schema version. Bump when TABLE_SCHEMA/FTS_SCHEMA change. */
|
|
91
|
-
const SCHEMA_VERSION =
|
|
92
|
+
const SCHEMA_VERSION = 6;
|
|
92
93
|
|
|
93
94
|
function userVersion(d: AnyDatabase): number {
|
|
94
95
|
const row = d.prepare("PRAGMA user_version").get() as { user_version: number };
|