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/dist/github.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 2B/D32: GitHub PR ↔ review_state mapping.
|
|
3
|
+
*
|
|
4
|
+
* Memories ride into review inside a branch + PR (dedicated memory PR or
|
|
5
|
+
* together with code). The PR's lifecycle on GitHub is the team's review
|
|
6
|
+
* signal; this module reads it back and maps it onto each memory's own
|
|
7
|
+
* review_state:
|
|
8
|
+
*
|
|
9
|
+
* PR merged → proposed/approved → published
|
|
10
|
+
* PR reviewDecision == APPROVED → proposed → approved (approved_by = reviewer login)
|
|
11
|
+
* PR reviewDecision == CHANGES_REQUESTED → suggestion only, printed for the human
|
|
12
|
+
*
|
|
13
|
+
* Per-memory independence: every memory on the branch keeps its own state.
|
|
14
|
+
* A locally `rejected` memory is never flipped by a PR approval, and a
|
|
15
|
+
* changes-requested review never auto-rejects — the human runs `promote`.
|
|
16
|
+
*
|
|
17
|
+
* Report by default; `--apply` performs the mapped transitions locally
|
|
18
|
+
* (file rewrite + index, never a push — inside the D27 local-git line).
|
|
19
|
+
* Needs the GitHub CLI (`gh`) authenticated on the user's machine.
|
|
20
|
+
*/
|
|
21
|
+
import { execFileSync } from "node:child_process";
|
|
22
|
+
import * as fs from "node:fs";
|
|
23
|
+
import { loadConfig } from "./config.js";
|
|
24
|
+
import { projectRoot } from "./paths.js";
|
|
25
|
+
import { parse as parseMemory } from "./store/markdown.js";
|
|
26
|
+
import { iterInRepoMemoryFiles } from "./store/markdown.js";
|
|
27
|
+
import { promoteMemory, currentAuthor } from "./review.js";
|
|
28
|
+
function fail(msg) {
|
|
29
|
+
throw new Error(`[open-memex] ${msg}`);
|
|
30
|
+
}
|
|
31
|
+
function sh(cmd, args, cwd) {
|
|
32
|
+
try {
|
|
33
|
+
return execFileSync(cmd, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
const err = e;
|
|
37
|
+
const detail = (err.stderr ?? err.message ?? "").trim().split("\n")[0];
|
|
38
|
+
fail(`${cmd} ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Read the PR for a branch (default: current branch) via the GitHub CLI. */
|
|
42
|
+
export function getPrInfo(branch) {
|
|
43
|
+
const root = projectRoot();
|
|
44
|
+
// `gh` must exist and be authenticated on the user's machine.
|
|
45
|
+
try {
|
|
46
|
+
execFileSync("gh", ["--version"], { stdio: ["ignore", "pipe", "ignore"] });
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
fail("the GitHub CLI (`gh`) is not installed — install it and run `gh auth login` first.");
|
|
50
|
+
}
|
|
51
|
+
const ref = branch ?? sh("git", ["branch", "--show-current"], root);
|
|
52
|
+
if (!ref)
|
|
53
|
+
fail("not on a branch — check out the memory branch first.");
|
|
54
|
+
let raw;
|
|
55
|
+
try {
|
|
56
|
+
raw = sh("gh", ["pr", "view", ref, "--json",
|
|
57
|
+
"number,url,state,mergedAt,reviewDecision,baseRefName,headRefName,title,reviews"], root);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null; // no PR for this branch yet
|
|
61
|
+
}
|
|
62
|
+
let j;
|
|
63
|
+
try {
|
|
64
|
+
j = JSON.parse(raw);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
fail(`could not parse \`gh pr view\` output for branch ${ref}.`);
|
|
68
|
+
}
|
|
69
|
+
const approvedBy = [];
|
|
70
|
+
const changesRequestedBy = [];
|
|
71
|
+
// Latest review per author wins.
|
|
72
|
+
const latest = new Map();
|
|
73
|
+
for (const r of j.reviews ?? []) {
|
|
74
|
+
const login = r.author?.login;
|
|
75
|
+
if (!login)
|
|
76
|
+
continue;
|
|
77
|
+
const prev = latest.get(login);
|
|
78
|
+
if (!prev || r.submittedAt >= prev.at)
|
|
79
|
+
latest.set(login, { state: r.state, at: r.submittedAt });
|
|
80
|
+
}
|
|
81
|
+
for (const [login, r] of latest) {
|
|
82
|
+
if (r.state === "APPROVED")
|
|
83
|
+
approvedBy.push(login);
|
|
84
|
+
else if (r.state === "CHANGES_REQUESTED")
|
|
85
|
+
changesRequestedBy.push(login);
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
number: j.number,
|
|
89
|
+
url: j.url,
|
|
90
|
+
state: j.state === "OPEN" ? "open" : "closed",
|
|
91
|
+
merged: !!j.mergedAt,
|
|
92
|
+
mergedAt: j.mergedAt,
|
|
93
|
+
reviewDecision: j.reviewDecision ?? null,
|
|
94
|
+
approvedBy,
|
|
95
|
+
changesRequestedBy,
|
|
96
|
+
base: j.baseRefName,
|
|
97
|
+
head: j.headRefName,
|
|
98
|
+
title: j.title,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/** Map the branch PR's state onto each in-repo memory's review_state. */
|
|
102
|
+
export function getPrStatus(branch) {
|
|
103
|
+
const root = projectRoot();
|
|
104
|
+
const cfg = loadConfig();
|
|
105
|
+
const current = branch ?? sh("git", ["branch", "--show-current"], root);
|
|
106
|
+
const pr = getPrInfo(current);
|
|
107
|
+
const mappings = [];
|
|
108
|
+
for (const fp of iterInRepoMemoryFiles(root, cfg.memoryDir)) {
|
|
109
|
+
const raw = fs.readFileSync(fp, "utf8");
|
|
110
|
+
const parsed = parseMemory(raw);
|
|
111
|
+
if (!parsed)
|
|
112
|
+
continue;
|
|
113
|
+
const from = parsed.fm.review_state ?? "draft";
|
|
114
|
+
const id = parsed.fm.id;
|
|
115
|
+
if (!pr) {
|
|
116
|
+
mappings.push({ memoryId: id, from, suggestedTo: null, reason: `no PR for branch ${current} yet`, approvedBy: null });
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (from === "published") {
|
|
120
|
+
mappings.push({ memoryId: id, from, suggestedTo: null, reason: "already published", approvedBy: null });
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (from === "rejected") {
|
|
124
|
+
mappings.push({ memoryId: id, from, suggestedTo: null, reason: `stays rejected — a PR ${pr.merged ? "merge" : "approval"} never overrides a human rejection`, approvedBy: null });
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (pr.merged) {
|
|
128
|
+
if (from === "proposed" || from === "approved") {
|
|
129
|
+
mappings.push({ memoryId: id, from, suggestedTo: "published", reason: `PR #${pr.number} merged`, approvedBy: null });
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
mappings.push({ memoryId: id, from, suggestedTo: null, reason: `PR #${pr.number} merged, but memory is "${from}"`, approvedBy: null });
|
|
133
|
+
}
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (pr.reviewDecision === "APPROVED" && from === "proposed") {
|
|
137
|
+
const by = pr.approvedBy[0] ?? null;
|
|
138
|
+
mappings.push({
|
|
139
|
+
memoryId: id, from, suggestedTo: "approved",
|
|
140
|
+
reason: `PR #${pr.number} approved${by ? ` by @${by}` : ""}`, approvedBy: by,
|
|
141
|
+
});
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (pr.reviewDecision === "CHANGES_REQUESTED") {
|
|
145
|
+
const by = pr.changesRequestedBy[0] ?? null;
|
|
146
|
+
mappings.push({
|
|
147
|
+
memoryId: id, from, suggestedTo: null,
|
|
148
|
+
reason: `PR #${pr.number} has changes requested${by ? ` by @${by}` : ""} — run \`open-memex promote ${id} --reject --note "..."\` yourself if you agree`,
|
|
149
|
+
approvedBy: null,
|
|
150
|
+
});
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
mappings.push({
|
|
154
|
+
memoryId: id, from, suggestedTo: null,
|
|
155
|
+
reason: pr.state === "open" ? `PR #${pr.number} still awaiting review` : `PR #${pr.number} is closed without merge`,
|
|
156
|
+
approvedBy: null,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return { branch: current, pr, mappings };
|
|
160
|
+
}
|
|
161
|
+
/** Apply the mapped transitions locally (no push — D27). Advances step by
|
|
162
|
+
* step through the ladder, so a merged PR moves proposed → approved →
|
|
163
|
+
* published with each step in the audit trail. */
|
|
164
|
+
export function applyPrStatus(st) {
|
|
165
|
+
const out = [];
|
|
166
|
+
const actor = currentAuthor();
|
|
167
|
+
const order = ["draft", "proposed", "approved", "published"];
|
|
168
|
+
for (const m of st.mappings) {
|
|
169
|
+
if (!m.suggestedTo || order.indexOf(m.from) >= order.indexOf(m.suggestedTo))
|
|
170
|
+
continue;
|
|
171
|
+
const by = m.approvedBy ?? actor;
|
|
172
|
+
const note = m.suggestedTo === "published" && st.pr
|
|
173
|
+
? `PR #${st.pr.number} merged`
|
|
174
|
+
: m.reason;
|
|
175
|
+
let cur = m.from;
|
|
176
|
+
while (order.indexOf(cur) < order.indexOf(m.suggestedTo)) {
|
|
177
|
+
const r = promoteMemory(m.memoryId, { by, note });
|
|
178
|
+
cur = r.to;
|
|
179
|
+
out.push({ memoryId: m.memoryId, from: r.from, to: r.to, by });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
export function formatPrStatus(st) {
|
|
185
|
+
const lines = [];
|
|
186
|
+
lines.push(`branch: ${st.branch}`);
|
|
187
|
+
if (!st.pr) {
|
|
188
|
+
lines.push(`PR: none yet — push the branch and open one, then re-run.`);
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
const p = st.pr;
|
|
192
|
+
lines.push(`PR #${p.number}: ${p.title}`);
|
|
193
|
+
lines.push(` ${p.url}`);
|
|
194
|
+
lines.push(` state: ${p.merged ? `merged${p.mergedAt ? ` at ${p.mergedAt}` : ""}` : p.state}` +
|
|
195
|
+
(p.reviewDecision ? `, reviews: ${p.reviewDecision}` : ""));
|
|
196
|
+
if (p.approvedBy.length > 0)
|
|
197
|
+
lines.push(` approved by: ${p.approvedBy.map((l) => "@" + l).join(", ")}`);
|
|
198
|
+
if (p.changesRequestedBy.length > 0)
|
|
199
|
+
lines.push(` changes requested by: ${p.changesRequestedBy.map((l) => "@" + l).join(", ")}`);
|
|
200
|
+
}
|
|
201
|
+
if (st.mappings.length === 0) {
|
|
202
|
+
lines.push(`no in-repo memories on this branch.`);
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
lines.push(`memories (${st.mappings.length}):`);
|
|
206
|
+
for (const m of st.mappings) {
|
|
207
|
+
const arrow = m.suggestedTo ? ` → [${m.suggestedTo}]` : "";
|
|
208
|
+
lines.push(` ${m.memoryId.slice(0, 8)} [${m.from}]${arrow} ${m.reason}`);
|
|
209
|
+
}
|
|
210
|
+
if (st.mappings.some((m) => m.suggestedTo)) {
|
|
211
|
+
lines.push(`run with --apply to perform the mapped transitions locally (no push).`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return lines.join("\n");
|
|
215
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -83,6 +83,12 @@ const plugin = async ({ worktree, directory }) => {
|
|
|
83
83
|
updated_at: rfc,
|
|
84
84
|
supersedes: null,
|
|
85
85
|
superseded_by: null,
|
|
86
|
+
review_state: "draft",
|
|
87
|
+
proposed_by: null,
|
|
88
|
+
approved_by: null,
|
|
89
|
+
derived_from: null,
|
|
90
|
+
review_note: null,
|
|
91
|
+
review_history: [],
|
|
86
92
|
};
|
|
87
93
|
try {
|
|
88
94
|
const { filePath } = writeMemoryFile(fm, content);
|
package/dist/init.js
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
|
-
import { execFileSync } from "node:child_process";
|
|
7
6
|
import { createInterface } from "node:readline/promises";
|
|
8
7
|
import { DEFAULT_CONFIG, saveConfig } from "./config.js";
|
|
8
|
+
import { projectRoot } from "./paths.js";
|
|
9
9
|
const MARKER = "<!-- open-memex -->";
|
|
10
10
|
/** npm dist-tag carrying the 0.3.x preview line. */
|
|
11
11
|
const ALPHA_TAG = "open-memex@alpha";
|
|
@@ -39,8 +39,10 @@ const INSTRUCTIONS = `${MARKER}
|
|
|
39
39
|
> Applies only when the \`open-memex\` MCP server is available in this session
|
|
40
40
|
> (the \`memory_*\` tools exist). Otherwise ignore this section.
|
|
41
41
|
|
|
42
|
-
You have a local memory MCP server (\`open-memex\`) with
|
|
43
|
-
\`memory_add\`, \`memory_search\`, \`memory_list\`, \`memory_supersede\`, \`memory_forget
|
|
42
|
+
You have a local memory MCP server (\`open-memex\`) with eleven tools:
|
|
43
|
+
\`memory_add\`, \`memory_search\`, \`memory_list\`, \`memory_supersede\`, \`memory_forget\`,
|
|
44
|
+
\`memory_status\`, \`memory_submit\`, \`memory_propose\`, \`memory_promote\`, \`memory_resolve\`,
|
|
45
|
+
\`memory_pr_status\`.
|
|
44
46
|
|
|
45
47
|
- BE PROACTIVE. When the user shares something worth remembering across sessions
|
|
46
48
|
(a decision, a preference, a project convention, a fix and its cause), call
|
|
@@ -51,22 +53,32 @@ You have a local memory MCP server (\`open-memex\`) with five tools:
|
|
|
51
53
|
- Memories default to this project's scope; use the \`personal\` scope for facts about
|
|
52
54
|
the user that hold across all projects. When a saved fact becomes outdated, call
|
|
53
55
|
\`memory_supersede\` instead of adding a duplicate.
|
|
56
|
+
|
|
57
|
+
## Syncing project memories for review (D26)
|
|
58
|
+
|
|
59
|
+
Project memories you save land in a local outbox first — they are NOT in git yet.
|
|
60
|
+
Syncing them into the repo for review is an explicit, user-approved step:
|
|
61
|
+
|
|
62
|
+
- At session start, and when you finish a meaningful chunk of work, call
|
|
63
|
+
\`memory_status\`. If the outbox has drafts, summarize them (one line each) and ask
|
|
64
|
+
the user which ones to sync. Sync NOTHING the user did not name.
|
|
65
|
+
- When the user approves, ask ONE follow-up: a separate memory-only branch/PR
|
|
66
|
+
(recommended), or fold the memories into the current branch alongside code?
|
|
67
|
+
A "yes, you do it" answer covers the whole chain — branch, local commit,
|
|
68
|
+
push, PR creation — do NOT re-ask at each step. If the user says they will
|
|
69
|
+
do it themselves, hand them the printed push/PR commands and do nothing.
|
|
70
|
+
Then call \`memory_submit\` with the approved ids (\`onto\` = current branch name
|
|
71
|
+
only for the fold-into-code path). It creates the branch, copies the drafts in
|
|
72
|
+
as \`proposed\`, commits locally, and prints the push + PR commands.
|
|
73
|
+
- Base branch for the memory PR defaults to the branch you are on; the user may
|
|
74
|
+
redirect it to the integration branch (main) for branch-independent knowledge.
|
|
75
|
+
- If anything conflicts (same id with different content, push rejected), STOP and
|
|
76
|
+
let the user judge — never overwrite.
|
|
77
|
+
- After the PR merges, call \`memory_pr_status\` (with \`apply\` when the user
|
|
78
|
+
approves) to map the PR's review state back onto each memory — merged means
|
|
79
|
+
\`published\`, an approval means \`approved\` (credited to the reviewer).
|
|
80
|
+
- \`personal\` scope memories NEVER leave the machine.
|
|
54
81
|
`;
|
|
55
|
-
/** Project root: git top-level, falling back to cwd. */
|
|
56
|
-
function projectRoot() {
|
|
57
|
-
try {
|
|
58
|
-
const top = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
59
|
-
encoding: "utf8",
|
|
60
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
61
|
-
}).trim();
|
|
62
|
-
if (top)
|
|
63
|
-
return top;
|
|
64
|
-
}
|
|
65
|
-
catch {
|
|
66
|
-
/* not a git repo — use cwd */
|
|
67
|
-
}
|
|
68
|
-
return process.cwd();
|
|
69
|
-
}
|
|
70
82
|
function writeMcpJson(root, client, force) {
|
|
71
83
|
if (client === "opencode")
|
|
72
84
|
return writeOpencodeMcpJson(root, force);
|
package/dist/mcp.js
CHANGED
|
@@ -24,7 +24,7 @@ import { loadConfig } from "./config.js";
|
|
|
24
24
|
import { resolveProjectScope, PERSONAL_SCOPE } from "./scope.js";
|
|
25
25
|
import { db } from "./store/db.js";
|
|
26
26
|
import { syncScope } from "./store/sync.js";
|
|
27
|
-
import { addMemory, searchMemories, listMemories, supersedeMemory, forgetMemory, memoryAddArgs, memorySearchArgs, memoryListArgs, memorySupersedeArgs, memoryForgetArgs, TOOL_DESCRIPTIONS, } from "./tools/ops.js";
|
|
27
|
+
import { addMemory, searchMemories, listMemories, supersedeMemory, forgetMemory, statusMemories, submitMemoriesOp, proposeMemoriesOp, promoteMemoryOp, resolveMemoryOp, prStatusOp, memoryAddArgs, memorySearchArgs, memoryListArgs, memorySupersedeArgs, memoryForgetArgs, memoryStatusArgs, memorySubmitArgs, memoryProposeArgs, memoryPromoteArgs, memoryResolveArgs, memoryPrStatusArgs, TOOL_DESCRIPTIONS, } from "./tools/ops.js";
|
|
28
28
|
const SERVER_VERSION = "0.2.0-alpha";
|
|
29
29
|
/** Adapt a framework-agnostic op result to an MCP tool response. */
|
|
30
30
|
function toMcp(p) {
|
|
@@ -44,33 +44,69 @@ export async function runMcpServer() {
|
|
|
44
44
|
const getScope = () => scope;
|
|
45
45
|
// Init DB and one-shot sync of markdown -> index, mirroring the plugin.
|
|
46
46
|
db();
|
|
47
|
-
syncScope(scope.key);
|
|
48
|
-
syncScope(PERSONAL_SCOPE.key);
|
|
47
|
+
syncScope(scope.key, "session");
|
|
48
|
+
syncScope(PERSONAL_SCOPE.key, "session");
|
|
49
49
|
console.error(`[open-memex] MCP server up. scope=${scope.key}`);
|
|
50
|
+
// D26: re-sync on every request, not just at startup. The in-repo dir
|
|
51
|
+
// follows the current git branch, so a branch switch mid-session would
|
|
52
|
+
// otherwise leave the index pointing at files that no longer exist.
|
|
53
|
+
const withSync = (fn) => {
|
|
54
|
+
return (args) => {
|
|
55
|
+
syncScope(scope.key, "request");
|
|
56
|
+
syncScope(PERSONAL_SCOPE.key, "request");
|
|
57
|
+
return fn(args);
|
|
58
|
+
};
|
|
59
|
+
};
|
|
50
60
|
const server = new McpServer({ name: "open-memex", version: SERVER_VERSION });
|
|
51
61
|
server.registerTool("memory_add", {
|
|
52
62
|
description: TOOL_DESCRIPTIONS.memory_add,
|
|
53
63
|
inputSchema: z.object(memoryAddArgs),
|
|
54
|
-
}, (args) => toMcp(addMemory(getScope, cfg, args)));
|
|
64
|
+
}, withSync((args) => toMcp(addMemory(getScope, cfg, args))));
|
|
55
65
|
server.registerTool("memory_search", {
|
|
56
66
|
description: TOOL_DESCRIPTIONS.memory_search,
|
|
57
67
|
inputSchema: z.object(memorySearchArgs),
|
|
58
68
|
annotations: { readOnlyHint: true },
|
|
59
|
-
}, (args) => toMcp(searchMemories(getScope, args)));
|
|
69
|
+
}, withSync((args) => toMcp(searchMemories(getScope, args))));
|
|
60
70
|
server.registerTool("memory_list", {
|
|
61
71
|
description: TOOL_DESCRIPTIONS.memory_list,
|
|
62
72
|
inputSchema: z.object(memoryListArgs),
|
|
63
73
|
annotations: { readOnlyHint: true },
|
|
64
|
-
}, (args) => toMcp(listMemories(getScope, args)));
|
|
74
|
+
}, withSync((args) => toMcp(listMemories(getScope, args))));
|
|
65
75
|
server.registerTool("memory_supersede", {
|
|
66
76
|
description: TOOL_DESCRIPTIONS.memory_supersede,
|
|
67
77
|
inputSchema: z.object(memorySupersedeArgs),
|
|
68
|
-
}, (args) => toMcp(supersedeMemory(cfg, args)));
|
|
78
|
+
}, withSync((args) => toMcp(supersedeMemory(cfg, args))));
|
|
69
79
|
server.registerTool("memory_forget", {
|
|
70
80
|
description: TOOL_DESCRIPTIONS.memory_forget,
|
|
71
81
|
inputSchema: z.object(memoryForgetArgs),
|
|
72
82
|
annotations: { destructiveHint: true },
|
|
73
|
-
}, (args) => toMcp(forgetMemory(args)));
|
|
83
|
+
}, withSync((args) => toMcp(forgetMemory(args))));
|
|
84
|
+
server.registerTool("memory_status", {
|
|
85
|
+
description: TOOL_DESCRIPTIONS.memory_status,
|
|
86
|
+
inputSchema: z.object(memoryStatusArgs),
|
|
87
|
+
annotations: { readOnlyHint: true },
|
|
88
|
+
}, withSync((_args) => toMcp(statusMemories())));
|
|
89
|
+
server.registerTool("memory_submit", {
|
|
90
|
+
description: TOOL_DESCRIPTIONS.memory_submit,
|
|
91
|
+
inputSchema: z.object(memorySubmitArgs),
|
|
92
|
+
}, withSync((args) => toMcp(submitMemoriesOp(args))));
|
|
93
|
+
server.registerTool("memory_propose", {
|
|
94
|
+
description: TOOL_DESCRIPTIONS.memory_propose,
|
|
95
|
+
inputSchema: z.object(memoryProposeArgs),
|
|
96
|
+
}, withSync((args) => toMcp(proposeMemoriesOp(args))));
|
|
97
|
+
server.registerTool("memory_promote", {
|
|
98
|
+
description: TOOL_DESCRIPTIONS.memory_promote,
|
|
99
|
+
inputSchema: z.object(memoryPromoteArgs),
|
|
100
|
+
}, withSync((args) => toMcp(promoteMemoryOp(args))));
|
|
101
|
+
server.registerTool("memory_resolve", {
|
|
102
|
+
description: TOOL_DESCRIPTIONS.memory_resolve,
|
|
103
|
+
inputSchema: z.object(memoryResolveArgs),
|
|
104
|
+
}, withSync((args) => toMcp(resolveMemoryOp(args))));
|
|
105
|
+
server.registerTool("memory_pr_status", {
|
|
106
|
+
description: TOOL_DESCRIPTIONS.memory_pr_status,
|
|
107
|
+
inputSchema: z.object(memoryPrStatusArgs),
|
|
108
|
+
annotations: { readOnlyHint: true },
|
|
109
|
+
}, withSync((args) => toMcp(prStatusOp(args))));
|
|
74
110
|
const transport = new StdioServerTransport();
|
|
75
111
|
await server.connect(transport);
|
|
76
112
|
}
|
package/dist/paths.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import os from "node:os";
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
4
5
|
function dataRoot() {
|
|
5
6
|
if (process.env.MY_O_MEMORY_HOME)
|
|
6
7
|
return path.resolve(process.env.MY_O_MEMORY_HOME);
|
|
@@ -34,3 +35,33 @@ export function memoriesDirPath(scopeKey) {
|
|
|
34
35
|
const { memories } = paths();
|
|
35
36
|
return path.join(memories, scopeKey);
|
|
36
37
|
}
|
|
38
|
+
/** Project root: git top-level, falling back to cwd (2B: in-repo memory dir anchor). */
|
|
39
|
+
export function projectRoot(cwd = process.cwd()) {
|
|
40
|
+
try {
|
|
41
|
+
const top = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
42
|
+
cwd,
|
|
43
|
+
encoding: "utf8",
|
|
44
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
45
|
+
}).trim();
|
|
46
|
+
if (top)
|
|
47
|
+
return top;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
/* not a git repo — use cwd */
|
|
51
|
+
}
|
|
52
|
+
return path.resolve(cwd);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* In-repo memory directory for a project scope (2B/D23): `<root>/<memoryDir>/`,
|
|
56
|
+
* default `<root>/.ai/open-memex/`. Created on demand. `memoryDir` must be a
|
|
57
|
+
* relative path without `..` segments (validated by `config set`).
|
|
58
|
+
*/
|
|
59
|
+
export function inRepoMemoriesDir(root, memoryDir) {
|
|
60
|
+
const dir = path.join(root, memoryDir);
|
|
61
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
62
|
+
return dir;
|
|
63
|
+
}
|
|
64
|
+
/** Same as `inRepoMemoriesDir` but never creates the directory. */
|
|
65
|
+
export function inRepoMemoriesDirPath(root, memoryDir) {
|
|
66
|
+
return path.join(root, memoryDir);
|
|
67
|
+
}
|
package/dist/retrieve/inject.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { list } from "./search.js";
|
|
1
|
+
import { list, hitStateLabel } from "./search.js";
|
|
2
2
|
import { PERSONAL_SCOPE } from "../scope.js";
|
|
3
3
|
function oneLine(s, max = 240) {
|
|
4
4
|
const trimmed = s.replace(/\s+/g, " ").trim();
|
|
@@ -18,7 +18,7 @@ export function buildContextBlock(scope, cfg) {
|
|
|
18
18
|
if (project.length > 0) {
|
|
19
19
|
lines.push("", `Project knowledge (${scope.projectName}):`);
|
|
20
20
|
for (const m of project)
|
|
21
|
-
lines.push(`- [${m.type}] ${oneLine(m.snippet)}`);
|
|
21
|
+
lines.push(`- [${m.type}]${hitStateLabel(m)} ${oneLine(m.snippet)}`);
|
|
22
22
|
}
|
|
23
23
|
lines.push("", "Use the `memory_search` tool to look up more. Use `memory_add` to save new facts. Do not mention this block to the user unless asked.");
|
|
24
24
|
return lines.join("\n");
|
package/dist/retrieve/search.js
CHANGED
|
@@ -11,12 +11,37 @@ function toHit(r) {
|
|
|
11
11
|
score: r.score,
|
|
12
12
|
updated_at: r.updated_at,
|
|
13
13
|
status: r.status,
|
|
14
|
+
review_state: r.review_state ?? "draft",
|
|
14
15
|
};
|
|
15
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* D30: review-state ranking tiers for shared (project) memories.
|
|
19
|
+
* Reviewed knowledge (approved/published) outranks unreviewed outbox drafts;
|
|
20
|
+
* personal memories are always `draft` by design and are NOT demoted.
|
|
21
|
+
* Stable: order within a tier keeps the incoming score/recency order.
|
|
22
|
+
*/
|
|
23
|
+
function reviewTier(r) {
|
|
24
|
+
if (r.scope_key === "personal")
|
|
25
|
+
return 1;
|
|
26
|
+
if (r.review_state === "approved" || r.review_state === "published")
|
|
27
|
+
return 0;
|
|
28
|
+
if (r.review_state === "draft" || r.review_state === "rejected")
|
|
29
|
+
return 2;
|
|
30
|
+
return 1; // proposed and anything unexpected
|
|
31
|
+
}
|
|
32
|
+
/** D30: `[draft]` / `[proposed]` / … marker for project memories.
|
|
33
|
+
* Personal memories stay unmarked (always draft — the marker would be noise). */
|
|
34
|
+
export function hitStateLabel(h) {
|
|
35
|
+
if (h.scope_key === "personal")
|
|
36
|
+
return "";
|
|
37
|
+
return ` [${h.review_state || "draft"}]`;
|
|
38
|
+
}
|
|
16
39
|
/**
|
|
17
40
|
* Lifecycle-aware post-processing (§3.3):
|
|
18
41
|
* - retracted / archived are excluded from retrieval (kept for audit);
|
|
19
42
|
* - a superseded memory resolves to the newest of its chain (cycle-safe);
|
|
43
|
+
* - D30: approved/published project memories rank first, project
|
|
44
|
+
* drafts/rejected rank after unreviewed content;
|
|
20
45
|
* - deprecated stays visible as a warning but ranks after active.
|
|
21
46
|
*/
|
|
22
47
|
function resolveVisible(rows, limit) {
|
|
@@ -27,7 +52,7 @@ function resolveVisible(rows, limit) {
|
|
|
27
52
|
return cached;
|
|
28
53
|
const r = db()
|
|
29
54
|
.prepare(`SELECT id, scope_key, project_name, type, tags, updated_at, status,
|
|
30
|
-
superseded_by, substr(content, 1, 240) AS snippet
|
|
55
|
+
superseded_by, review_state, substr(content, 1, 240) AS snippet
|
|
31
56
|
FROM memories WHERE id = ?`)
|
|
32
57
|
.get(id);
|
|
33
58
|
if (!r)
|
|
@@ -37,7 +62,7 @@ function resolveVisible(rows, limit) {
|
|
|
37
62
|
return full;
|
|
38
63
|
};
|
|
39
64
|
const seen = new Set();
|
|
40
|
-
const
|
|
65
|
+
const tiers = [[], [], [], []];
|
|
41
66
|
const deprecated = [];
|
|
42
67
|
for (const r of rows) {
|
|
43
68
|
if (r.status === "retracted" || r.status === "archived")
|
|
@@ -66,9 +91,9 @@ function resolveVisible(rows, limit) {
|
|
|
66
91
|
if (target.status === "deprecated")
|
|
67
92
|
deprecated.push(hit);
|
|
68
93
|
else
|
|
69
|
-
|
|
94
|
+
tiers[reviewTier(target)].push(hit);
|
|
70
95
|
}
|
|
71
|
-
return [...
|
|
96
|
+
return [...tiers[0], ...tiers[1], ...tiers[2], ...deprecated].slice(0, limit);
|
|
72
97
|
}
|
|
73
98
|
/**
|
|
74
99
|
* Convert free-text query into a safe FTS5 MATCH expression.
|
|
@@ -101,7 +126,7 @@ export function search(query, opts = {}) {
|
|
|
101
126
|
const typeFilter = opts.type ? ` AND m.type = ?` : "";
|
|
102
127
|
const sql = `
|
|
103
128
|
SELECT m.id, m.scope_key, m.project_name, m.type, m.tags, m.updated_at,
|
|
104
|
-
m.status, m.superseded_by,
|
|
129
|
+
m.status, m.superseded_by, m.review_state,
|
|
105
130
|
snippet(memories_fts, 0, '[', ']', ' ... ', 12) AS snippet,
|
|
106
131
|
bm25(memories_fts) AS score
|
|
107
132
|
FROM memories_fts
|
|
@@ -129,7 +154,7 @@ export function list(scopeKey, opts = {}) {
|
|
|
129
154
|
const typeFilter = opts.type ? ` AND type = ?` : "";
|
|
130
155
|
const sql = `
|
|
131
156
|
SELECT id, scope_key, project_name, type, tags, updated_at, status,
|
|
132
|
-
superseded_by, substr(content, 1, 240) AS snippet
|
|
157
|
+
superseded_by, review_state, substr(content, 1, 240) AS snippet
|
|
133
158
|
FROM memories
|
|
134
159
|
WHERE scope_key = ?${typeFilter}
|
|
135
160
|
ORDER BY updated_at DESC
|