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/src/github.ts ADDED
@@ -0,0 +1,257 @@
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
+
22
+ import { execFileSync } from "node:child_process";
23
+ import * as fs from "node:fs";
24
+ import * as path from "node:path";
25
+ import { loadConfig } from "./config.ts";
26
+ import { projectRoot } from "./paths.ts";
27
+ import { parse as parseMemory, type ReviewState } from "./store/markdown.ts";
28
+ import { iterInRepoMemoryFiles } from "./store/markdown.ts";
29
+ import { promoteMemory, currentAuthor } from "./review.ts";
30
+
31
+ function fail(msg: string): never {
32
+ throw new Error(`[open-memex] ${msg}`);
33
+ }
34
+
35
+ function sh(cmd: string, args: string[], cwd: string): string {
36
+ try {
37
+ return execFileSync(cmd, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
38
+ } catch (e) {
39
+ const err = e as { stderr?: string; message?: string };
40
+ const detail = (err.stderr ?? err.message ?? "").trim().split("\n")[0];
41
+ fail(`${cmd} ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
42
+ }
43
+ }
44
+
45
+ export interface PrInfo {
46
+ number: number;
47
+ url: string;
48
+ state: "open" | "closed"; // GitHub PR state
49
+ merged: boolean;
50
+ mergedAt: string | null;
51
+ reviewDecision: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null;
52
+ approvedBy: string[];
53
+ changesRequestedBy: string[];
54
+ base: string;
55
+ head: string;
56
+ title: string;
57
+ }
58
+
59
+ interface GhPrJson {
60
+ number: number;
61
+ url: string;
62
+ state: string;
63
+ mergedAt: string | null;
64
+ isMerged?: boolean;
65
+ reviewDecision?: string | null;
66
+ baseRefName: string;
67
+ headRefName: string;
68
+ title: string;
69
+ reviews?: Array<{ author: { login: string }; state: string; submittedAt: string }>;
70
+ }
71
+
72
+ /** Read the PR for a branch (default: current branch) via the GitHub CLI. */
73
+ export function getPrInfo(branch?: string): PrInfo | null {
74
+ const root = projectRoot();
75
+ // `gh` must exist and be authenticated on the user's machine.
76
+ try {
77
+ execFileSync("gh", ["--version"], { stdio: ["ignore", "pipe", "ignore"] });
78
+ } catch {
79
+ fail("the GitHub CLI (`gh`) is not installed — install it and run `gh auth login` first.");
80
+ }
81
+ const ref = branch ?? sh("git", ["branch", "--show-current"], root);
82
+ if (!ref) fail("not on a branch — check out the memory branch first.");
83
+ let raw: string;
84
+ try {
85
+ raw = sh("gh", ["pr", "view", ref, "--json",
86
+ "number,url,state,mergedAt,reviewDecision,baseRefName,headRefName,title,reviews"], root);
87
+ } catch {
88
+ return null; // no PR for this branch yet
89
+ }
90
+ let j: GhPrJson;
91
+ try {
92
+ j = JSON.parse(raw) as GhPrJson;
93
+ } catch {
94
+ fail(`could not parse \`gh pr view\` output for branch ${ref}.`);
95
+ }
96
+ const approvedBy: string[] = [];
97
+ const changesRequestedBy: string[] = [];
98
+ // Latest review per author wins.
99
+ const latest = new Map<string, { state: string; at: string }>();
100
+ for (const r of j.reviews ?? []) {
101
+ const login = r.author?.login;
102
+ if (!login) continue;
103
+ const prev = latest.get(login);
104
+ if (!prev || r.submittedAt >= prev.at) latest.set(login, { state: r.state, at: r.submittedAt });
105
+ }
106
+ for (const [login, r] of latest) {
107
+ if (r.state === "APPROVED") approvedBy.push(login);
108
+ else if (r.state === "CHANGES_REQUESTED") changesRequestedBy.push(login);
109
+ }
110
+ return {
111
+ number: j.number,
112
+ url: j.url,
113
+ state: j.state === "OPEN" ? "open" : "closed",
114
+ merged: !!j.mergedAt,
115
+ mergedAt: j.mergedAt,
116
+ reviewDecision: (j.reviewDecision as PrInfo["reviewDecision"]) ?? null,
117
+ approvedBy,
118
+ changesRequestedBy,
119
+ base: j.baseRefName,
120
+ head: j.headRefName,
121
+ title: j.title,
122
+ };
123
+ }
124
+
125
+ export interface MemoryPrMapping {
126
+ memoryId: string;
127
+ from: ReviewState;
128
+ /** Null = no automatic transition; see reason. */
129
+ suggestedTo: ReviewState | null;
130
+ reason: string;
131
+ approvedBy: string | null;
132
+ }
133
+
134
+ export interface PrStatus {
135
+ branch: string;
136
+ pr: PrInfo | null;
137
+ mappings: MemoryPrMapping[];
138
+ }
139
+
140
+ /** Map the branch PR's state onto each in-repo memory's review_state. */
141
+ export function getPrStatus(branch?: string): PrStatus {
142
+ const root = projectRoot();
143
+ const cfg = loadConfig();
144
+ const current = branch ?? sh("git", ["branch", "--show-current"], root);
145
+ const pr = getPrInfo(current);
146
+
147
+ const mappings: MemoryPrMapping[] = [];
148
+ for (const fp of iterInRepoMemoryFiles(root, cfg.memoryDir)) {
149
+ const raw = fs.readFileSync(fp, "utf8");
150
+ const parsed = parseMemory(raw);
151
+ if (!parsed) continue;
152
+ const from = parsed.fm.review_state ?? "draft";
153
+ const id = parsed.fm.id;
154
+ if (!pr) {
155
+ mappings.push({ memoryId: id, from, suggestedTo: null, reason: `no PR for branch ${current} yet`, approvedBy: null });
156
+ continue;
157
+ }
158
+ if (from === "published") {
159
+ mappings.push({ memoryId: id, from, suggestedTo: null, reason: "already published", approvedBy: null });
160
+ continue;
161
+ }
162
+ if (from === "rejected") {
163
+ mappings.push({ memoryId: id, from, suggestedTo: null, reason: `stays rejected — a PR ${pr.merged ? "merge" : "approval"} never overrides a human rejection`, approvedBy: null });
164
+ continue;
165
+ }
166
+ if (pr.merged) {
167
+ if (from === "proposed" || from === "approved") {
168
+ mappings.push({ memoryId: id, from, suggestedTo: "published", reason: `PR #${pr.number} merged`, approvedBy: null });
169
+ } else {
170
+ mappings.push({ memoryId: id, from, suggestedTo: null, reason: `PR #${pr.number} merged, but memory is "${from}"`, approvedBy: null });
171
+ }
172
+ continue;
173
+ }
174
+ if (pr.reviewDecision === "APPROVED" && from === "proposed") {
175
+ const by = pr.approvedBy[0] ?? null;
176
+ mappings.push({
177
+ memoryId: id, from, suggestedTo: "approved",
178
+ reason: `PR #${pr.number} approved${by ? ` by @${by}` : ""}`, approvedBy: by,
179
+ });
180
+ continue;
181
+ }
182
+ if (pr.reviewDecision === "CHANGES_REQUESTED") {
183
+ const by = pr.changesRequestedBy[0] ?? null;
184
+ mappings.push({
185
+ memoryId: id, from, suggestedTo: null,
186
+ reason: `PR #${pr.number} has changes requested${by ? ` by @${by}` : ""} — run \`open-memex promote ${id} --reject --note "..."\` yourself if you agree`,
187
+ approvedBy: null,
188
+ });
189
+ continue;
190
+ }
191
+ mappings.push({
192
+ memoryId: id, from, suggestedTo: null,
193
+ reason: pr.state === "open" ? `PR #${pr.number} still awaiting review` : `PR #${pr.number} is closed without merge`,
194
+ approvedBy: null,
195
+ });
196
+ }
197
+ return { branch: current, pr, mappings };
198
+ }
199
+
200
+ export interface PrApplyResult {
201
+ memoryId: string;
202
+ from: ReviewState;
203
+ to: ReviewState;
204
+ by: string;
205
+ }
206
+
207
+ /** Apply the mapped transitions locally (no push — D27). Advances step by
208
+ * step through the ladder, so a merged PR moves proposed → approved →
209
+ * published with each step in the audit trail. */
210
+ export function applyPrStatus(st: PrStatus): PrApplyResult[] {
211
+ const out: PrApplyResult[] = [];
212
+ const actor = currentAuthor();
213
+ const order: ReviewState[] = ["draft", "proposed", "approved", "published"];
214
+ for (const m of st.mappings) {
215
+ if (!m.suggestedTo || order.indexOf(m.from) >= order.indexOf(m.suggestedTo)) continue;
216
+ const by = m.approvedBy ?? actor;
217
+ const note = m.suggestedTo === "published" && st.pr
218
+ ? `PR #${st.pr.number} merged`
219
+ : m.reason;
220
+ let cur = m.from;
221
+ while (order.indexOf(cur) < order.indexOf(m.suggestedTo)) {
222
+ const r = promoteMemory(m.memoryId, { by, note });
223
+ cur = r.to;
224
+ out.push({ memoryId: m.memoryId, from: r.from, to: r.to, by });
225
+ }
226
+ }
227
+ return out;
228
+ }
229
+
230
+ export function formatPrStatus(st: PrStatus): string {
231
+ const lines: string[] = [];
232
+ lines.push(`branch: ${st.branch}`);
233
+ if (!st.pr) {
234
+ lines.push(`PR: none yet — push the branch and open one, then re-run.`);
235
+ } else {
236
+ const p = st.pr;
237
+ lines.push(`PR #${p.number}: ${p.title}`);
238
+ lines.push(` ${p.url}`);
239
+ lines.push(` state: ${p.merged ? `merged${p.mergedAt ? ` at ${p.mergedAt}` : ""}` : p.state}` +
240
+ (p.reviewDecision ? `, reviews: ${p.reviewDecision}` : ""));
241
+ if (p.approvedBy.length > 0) lines.push(` approved by: ${p.approvedBy.map((l) => "@" + l).join(", ")}`);
242
+ if (p.changesRequestedBy.length > 0) lines.push(` changes requested by: ${p.changesRequestedBy.map((l) => "@" + l).join(", ")}`);
243
+ }
244
+ if (st.mappings.length === 0) {
245
+ lines.push(`no in-repo memories on this branch.`);
246
+ } else {
247
+ lines.push(`memories (${st.mappings.length}):`);
248
+ for (const m of st.mappings) {
249
+ const arrow = m.suggestedTo ? ` → [${m.suggestedTo}]` : "";
250
+ lines.push(` ${m.memoryId.slice(0, 8)} [${m.from}]${arrow} ${m.reason}`);
251
+ }
252
+ if (st.mappings.some((m) => m.suggestedTo)) {
253
+ lines.push(`run with --apply to perform the mapped transitions locally (no push).`);
254
+ }
255
+ }
256
+ return lines.join("\n");
257
+ }
package/src/index.ts CHANGED
@@ -93,6 +93,12 @@ const plugin: Plugin = async ({ worktree, directory }) => {
93
93
  updated_at: rfc,
94
94
  supersedes: null,
95
95
  superseded_by: null,
96
+ review_state: "draft",
97
+ proposed_by: null,
98
+ approved_by: null,
99
+ derived_from: null,
100
+ review_note: null,
101
+ review_history: [],
96
102
  };
97
103
  try {
98
104
  const { filePath } = writeMemoryFile(fm, content);
package/src/init.ts 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.ts";
8
+ import { projectRoot } from "./paths.ts";
9
9
 
10
10
  const MARKER = "<!-- open-memex -->";
11
11
 
@@ -50,8 +50,10 @@ const INSTRUCTIONS = `${MARKER}
50
50
  > Applies only when the \`open-memex\` MCP server is available in this session
51
51
  > (the \`memory_*\` tools exist). Otherwise ignore this section.
52
52
 
53
- You have a local memory MCP server (\`open-memex\`) with five tools:
54
- \`memory_add\`, \`memory_search\`, \`memory_list\`, \`memory_supersede\`, \`memory_forget\`.
53
+ You have a local memory MCP server (\`open-memex\`) with eleven tools:
54
+ \`memory_add\`, \`memory_search\`, \`memory_list\`, \`memory_supersede\`, \`memory_forget\`,
55
+ \`memory_status\`, \`memory_submit\`, \`memory_propose\`, \`memory_promote\`, \`memory_resolve\`,
56
+ \`memory_pr_status\`.
55
57
 
56
58
  - BE PROACTIVE. When the user shares something worth remembering across sessions
57
59
  (a decision, a preference, a project convention, a fix and its cause), call
@@ -62,21 +64,35 @@ You have a local memory MCP server (\`open-memex\`) with five tools:
62
64
  - Memories default to this project's scope; use the \`personal\` scope for facts about
63
65
  the user that hold across all projects. When a saved fact becomes outdated, call
64
66
  \`memory_supersede\` instead of adding a duplicate.
65
- `;
66
67
 
67
- /** Project root: git top-level, falling back to cwd. */
68
- function projectRoot(): string {
69
- try {
70
- const top = execFileSync("git", ["rev-parse", "--show-toplevel"], {
71
- encoding: "utf8",
72
- stdio: ["ignore", "pipe", "ignore"],
73
- }).trim();
74
- if (top) return top;
75
- } catch {
76
- /* not a git repo — use cwd */
77
- }
78
- return process.cwd();
79
- }
68
+ ## Syncing project memories for review (D26)
69
+
70
+ Project memories you save land in a local outbox first — they are NOT in git yet.
71
+ Syncing them into the repo for review is an explicit, user-approved step:
72
+
73
+ - At session start, and when you finish a meaningful chunk of work, call
74
+ \`memory_status\`. If the outbox has drafts, summarize them (one line each) and ask
75
+ the user which ones to sync. Sync NOTHING the user did not name.
76
+ - When the user says "sync memory" (or "同步记忆"), treat it as a request to run
77
+ the sync flow above: call \`memory_status\`, summarize the outbox drafts, and ask
78
+ which ones to sync.
79
+ - When the user approves, ask ONE follow-up: a separate memory-only branch/PR
80
+ (recommended), or fold the memories into the current branch alongside code?
81
+ A "yes, you do it" answer covers the whole chain — branch, local commit,
82
+ push, PR creation — do NOT re-ask at each step. If the user says they will
83
+ do it themselves, hand them the printed push/PR commands and do nothing.
84
+ Then call \`memory_submit\` with the approved ids (\`onto\` = current branch name
85
+ only for the fold-into-code path). It creates the branch, copies the drafts in
86
+ as \`proposed\`, commits locally, and prints the push + PR commands.
87
+ - Base branch for the memory PR defaults to the branch you are on; the user may
88
+ redirect it to the integration branch (main) for branch-independent knowledge.
89
+ - If anything conflicts (same id with different content, push rejected), STOP and
90
+ let the user judge — never overwrite.
91
+ - After the PR merges, call \`memory_pr_status\` (with \`apply\` when the user
92
+ approves) to map the PR's review state back onto each memory — merged means
93
+ \`published\`, an approval means \`approved\` (credited to the reviewer).
94
+ - \`personal\` scope memories NEVER leave the machine.
95
+ `;
80
96
 
81
97
  function writeMcpJson(root: string, client: string, force: boolean): string | null {
82
98
  if (client === "opencode") return writeOpencodeMcpJson(root, force);
package/src/mcp.ts CHANGED
@@ -30,17 +30,56 @@ import {
30
30
  listMemories,
31
31
  supersedeMemory,
32
32
  forgetMemory,
33
+ statusMemories,
34
+ submitMemoriesOp,
35
+ proposeMemoriesOp,
36
+ promoteMemoryOp,
37
+ resolveMemoryOp,
38
+ prStatusOp,
33
39
  memoryAddArgs,
34
40
  memorySearchArgs,
35
41
  memoryListArgs,
36
42
  memorySupersedeArgs,
37
43
  memoryForgetArgs,
44
+ memoryStatusArgs,
45
+ memorySubmitArgs,
46
+ memoryProposeArgs,
47
+ memoryPromoteArgs,
48
+ memoryResolveArgs,
49
+ memoryPrStatusArgs,
38
50
  TOOL_DESCRIPTIONS,
39
51
  type ToolResult,
40
52
  } from "./tools/ops.ts";
41
53
 
42
54
  const SERVER_VERSION = "0.2.0-alpha";
43
55
 
56
+ /**
57
+ * D26: session-start guidance delivered through the MCP handshake itself.
58
+ * The init-written instruction files only exist if the user ran
59
+ * `open-memex init --client`; the initialize `instructions` reach every MCP
60
+ * client at connect time. Still advisory — no MCP consumer offers a hard
61
+ * session-start hook — but it is the strongest signal available.
62
+ */
63
+ const SERVER_INSTRUCTIONS = `You are connected to an open-memex local memory MCP server
64
+ (eleven memory_* tools: add, search, list, supersede, forget, status, submit,
65
+ propose, promote, resolve, pr_status).
66
+
67
+ - At the START of this session, call memory_status. If the project outbox has
68
+ drafts waiting for review, summarize them (one line each) and ask the user
69
+ which ones to sync into the repo. Sync NOTHING the user did not name.
70
+ - When the user says "sync memory" (or "同步记忆"), treat it as a request to run
71
+ the sync flow: call memory_status, summarize the outbox drafts, and ask which
72
+ ones to sync.
73
+ - When you finish a meaningful chunk of work, call memory_status again as a checkpoint.
74
+ - BE PROACTIVE: when the user shares something worth remembering across sessions
75
+ (a decision, a preference, a project convention, a fix and its cause), call
76
+ memory_add without being asked. Keep each memory to one self-contained statement.
77
+ - Before asking the user about past decisions, conventions, or preferences they
78
+ may have told you before, call memory_search first.
79
+ - Memories default to this project's scope; use the personal scope for facts
80
+ about the user that hold across all projects.
81
+ - personal scope memories NEVER leave this machine.`;
82
+
44
83
  /** Adapt a framework-agnostic op result to an MCP tool response. */
45
84
  function toMcp(p: Promise<ToolResult>) {
46
85
  return p.then(
@@ -64,11 +103,25 @@ export async function runMcpServer() {
64
103
 
65
104
  // Init DB and one-shot sync of markdown -> index, mirroring the plugin.
66
105
  db();
67
- syncScope(scope.key);
68
- syncScope(PERSONAL_SCOPE.key);
106
+ syncScope(scope.key, "session");
107
+ syncScope(PERSONAL_SCOPE.key, "session");
69
108
  console.error(`[open-memex] MCP server up. scope=${scope.key}`);
70
109
 
71
- const server = new McpServer({ name: "open-memex", version: SERVER_VERSION });
110
+ // D26: re-sync on every request, not just at startup. The in-repo dir
111
+ // follows the current git branch, so a branch switch mid-session would
112
+ // otherwise leave the index pointing at files that no longer exist.
113
+ const withSync = <A extends object, R>(fn: (args: A) => R) => {
114
+ return (args: A): R => {
115
+ syncScope(scope.key, "request");
116
+ syncScope(PERSONAL_SCOPE.key, "request");
117
+ return fn(args);
118
+ };
119
+ };
120
+
121
+ const server = new McpServer(
122
+ { name: "open-memex", version: SERVER_VERSION },
123
+ { instructions: SERVER_INSTRUCTIONS },
124
+ );
72
125
 
73
126
  server.registerTool(
74
127
  "memory_add",
@@ -76,7 +129,7 @@ export async function runMcpServer() {
76
129
  description: TOOL_DESCRIPTIONS.memory_add,
77
130
  inputSchema: z.object(memoryAddArgs),
78
131
  },
79
- (args) => toMcp(addMemory(getScope, cfg, args)),
132
+ withSync((args) => toMcp(addMemory(getScope, cfg, args))),
80
133
  );
81
134
 
82
135
  server.registerTool(
@@ -86,7 +139,7 @@ export async function runMcpServer() {
86
139
  inputSchema: z.object(memorySearchArgs),
87
140
  annotations: { readOnlyHint: true },
88
141
  },
89
- (args) => toMcp(searchMemories(getScope, args)),
142
+ withSync((args) => toMcp(searchMemories(getScope, args))),
90
143
  );
91
144
 
92
145
  server.registerTool(
@@ -96,7 +149,7 @@ export async function runMcpServer() {
96
149
  inputSchema: z.object(memoryListArgs),
97
150
  annotations: { readOnlyHint: true },
98
151
  },
99
- (args) => toMcp(listMemories(getScope, args)),
152
+ withSync((args) => toMcp(listMemories(getScope, args))),
100
153
  );
101
154
 
102
155
  server.registerTool(
@@ -105,7 +158,7 @@ export async function runMcpServer() {
105
158
  description: TOOL_DESCRIPTIONS.memory_supersede,
106
159
  inputSchema: z.object(memorySupersedeArgs),
107
160
  },
108
- (args) => toMcp(supersedeMemory(cfg, args)),
161
+ withSync((args) => toMcp(supersedeMemory(cfg, args))),
109
162
  );
110
163
 
111
164
  server.registerTool(
@@ -115,7 +168,63 @@ export async function runMcpServer() {
115
168
  inputSchema: z.object(memoryForgetArgs),
116
169
  annotations: { destructiveHint: true },
117
170
  },
118
- (args) => toMcp(forgetMemory(args)),
171
+ withSync((args) => toMcp(forgetMemory(args))),
172
+ );
173
+
174
+ server.registerTool(
175
+ "memory_status",
176
+ {
177
+ description: TOOL_DESCRIPTIONS.memory_status,
178
+ inputSchema: z.object(memoryStatusArgs),
179
+ annotations: { readOnlyHint: true },
180
+ },
181
+ withSync((_args) => toMcp(statusMemories())),
182
+ );
183
+
184
+ server.registerTool(
185
+ "memory_submit",
186
+ {
187
+ description: TOOL_DESCRIPTIONS.memory_submit,
188
+ inputSchema: z.object(memorySubmitArgs),
189
+ },
190
+ withSync((args) => toMcp(submitMemoriesOp(args))),
191
+ );
192
+
193
+ server.registerTool(
194
+ "memory_propose",
195
+ {
196
+ description: TOOL_DESCRIPTIONS.memory_propose,
197
+ inputSchema: z.object(memoryProposeArgs),
198
+ },
199
+ withSync((args) => toMcp(proposeMemoriesOp(args))),
200
+ );
201
+
202
+ server.registerTool(
203
+ "memory_promote",
204
+ {
205
+ description: TOOL_DESCRIPTIONS.memory_promote,
206
+ inputSchema: z.object(memoryPromoteArgs),
207
+ },
208
+ withSync((args) => toMcp(promoteMemoryOp(args))),
209
+ );
210
+
211
+ server.registerTool(
212
+ "memory_resolve",
213
+ {
214
+ description: TOOL_DESCRIPTIONS.memory_resolve,
215
+ inputSchema: z.object(memoryResolveArgs),
216
+ },
217
+ withSync((args) => toMcp(resolveMemoryOp(args))),
218
+ );
219
+
220
+ server.registerTool(
221
+ "memory_pr_status",
222
+ {
223
+ description: TOOL_DESCRIPTIONS.memory_pr_status,
224
+ inputSchema: z.object(memoryPrStatusArgs),
225
+ annotations: { readOnlyHint: true },
226
+ },
227
+ withSync((args) => toMcp(prStatusOp(args))),
119
228
  );
120
229
 
121
230
  const transport = new StdioServerTransport();
package/src/paths.ts 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
 
5
6
  function dataRoot(): string {
6
7
  if (process.env.MY_O_MEMORY_HOME) return path.resolve(process.env.MY_O_MEMORY_HOME);
@@ -43,3 +44,34 @@ export function memoriesDirPath(scopeKey: string): string {
43
44
  const { memories } = paths();
44
45
  return path.join(memories, scopeKey);
45
46
  }
47
+
48
+ /** Project root: git top-level, falling back to cwd (2B: in-repo memory dir anchor). */
49
+ export function projectRoot(cwd: string = process.cwd()): string {
50
+ try {
51
+ const top = execFileSync("git", ["rev-parse", "--show-toplevel"], {
52
+ cwd,
53
+ encoding: "utf8",
54
+ stdio: ["ignore", "pipe", "ignore"],
55
+ }).trim();
56
+ if (top) return top;
57
+ } catch {
58
+ /* not a git repo — use cwd */
59
+ }
60
+ return path.resolve(cwd);
61
+ }
62
+
63
+ /**
64
+ * In-repo memory directory for a project scope (2B/D23): `<root>/<memoryDir>/`,
65
+ * default `<root>/.ai/open-memex/`. Created on demand. `memoryDir` must be a
66
+ * relative path without `..` segments (validated by `config set`).
67
+ */
68
+ export function inRepoMemoriesDir(root: string, memoryDir: string): string {
69
+ const dir = path.join(root, memoryDir);
70
+ fs.mkdirSync(dir, { recursive: true });
71
+ return dir;
72
+ }
73
+
74
+ /** Same as `inRepoMemoriesDir` but never creates the directory. */
75
+ export function inRepoMemoriesDirPath(root: string, memoryDir: string): string {
76
+ return path.join(root, memoryDir);
77
+ }
@@ -1,4 +1,4 @@
1
- import { list } from "./search.ts";
1
+ import { list, hitStateLabel } from "./search.ts";
2
2
  import type { Scope } from "../scope.ts";
3
3
  import { PERSONAL_SCOPE } from "../scope.ts";
4
4
  import type { MyOMemoryConfig } from "../config.ts";
@@ -23,7 +23,7 @@ export function buildContextBlock(scope: Scope, cfg: MyOMemoryConfig): string |
23
23
 
24
24
  if (project.length > 0) {
25
25
  lines.push("", `Project knowledge (${scope.projectName}):`);
26
- for (const m of project) lines.push(`- [${m.type}] ${oneLine(m.snippet)}`);
26
+ for (const m of project) lines.push(`- [${m.type}]${hitStateLabel(m)} ${oneLine(m.snippet)}`);
27
27
  }
28
28
 
29
29
  lines.push(