open-memex 0.3.0 → 0.4.0-alpha.10

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.
Files changed (49) hide show
  1. package/AGENTS.md +20 -1
  2. package/README.md +117 -9
  3. package/README.zh-CN.md +103 -9
  4. package/dist/cli.js +583 -14
  5. package/dist/config.js +12 -0
  6. package/dist/distill-agents.js +55 -0
  7. package/dist/doctor.js +1 -1
  8. package/dist/export.js +157 -0
  9. package/dist/github.js +215 -0
  10. package/dist/index.js +6 -0
  11. package/dist/init.js +43 -18
  12. package/dist/mcp.js +119 -12
  13. package/dist/paths.js +31 -0
  14. package/dist/providers/git.js +142 -0
  15. package/dist/retrieve/inject.js +2 -2
  16. package/dist/retrieve/search.js +31 -6
  17. package/dist/review.js +437 -0
  18. package/dist/store/db.js +3 -2
  19. package/dist/store/lifecycle.js +28 -13
  20. package/dist/store/markdown.js +57 -14
  21. package/dist/store/sync.js +74 -6
  22. package/dist/submit.js +347 -0
  23. package/dist/tools/ops.js +171 -8
  24. package/docs/CURATOR.md +59 -0
  25. package/docs/USER-GUIDE.md +197 -0
  26. package/docs/USER-GUIDE.zh-CN.md +161 -0
  27. package/docs/V2-DESIGN.md +287 -13
  28. package/package.json +1 -1
  29. package/scripts/smoke-mcp.ts +2 -2
  30. package/src/cli.ts +608 -15
  31. package/src/config.ts +26 -0
  32. package/src/distill-agents.ts +70 -0
  33. package/src/doctor.ts +1 -1
  34. package/src/export.ts +206 -0
  35. package/src/github.ts +257 -0
  36. package/src/index.ts +6 -0
  37. package/src/init.ts +43 -17
  38. package/src/mcp.ts +166 -11
  39. package/src/paths.ts +32 -0
  40. package/src/providers/git.ts +191 -0
  41. package/src/retrieve/inject.ts +2 -2
  42. package/src/retrieve/search.ts +33 -6
  43. package/src/review.ts +504 -0
  44. package/src/store/db.ts +3 -2
  45. package/src/store/lifecycle.ts +30 -12
  46. package/src/store/markdown.ts +91 -14
  47. package/src/store/sync.ts +85 -4
  48. package/src/submit.ts +424 -0
  49. package/src/tools/ops.ts +203 -9
package/dist/mcp.js CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * open-memex generic MCP server (stdio transport).
3
3
  *
4
- * Exposes the same five memory tools as the opencode plugin
4
+ * Exposes the memory tools as the opencode plugin
5
5
  * (memory_add / memory_search / memory_list / memory_supersede /
6
- * memory_forget) over the Model Context Protocol, so any MCP client —
6
+ * memory_forget, plus memory_status / memory_submit / memory_propose /
7
+ * memory_promote / memory_resolve / memory_pr_status) over the Model Context Protocol, so any MCP client —
7
8
  * VS Code Copilot Chat, Cursor, Claude Code, etc. — can use open-memex
8
9
  * without a host-specific plugin.
9
10
  *
@@ -19,13 +20,69 @@
19
20
  */
20
21
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
21
22
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
23
+ import fs from "node:fs";
24
+ import path from "node:path";
25
+ import { fileURLToPath } from "node:url";
22
26
  import { z } from "zod";
23
27
  import { loadConfig } from "./config.js";
24
28
  import { resolveProjectScope, PERSONAL_SCOPE } from "./scope.js";
25
29
  import { db } from "./store/db.js";
26
30
  import { syncScope } from "./store/sync.js";
27
- import { addMemory, searchMemories, listMemories, supersedeMemory, forgetMemory, memoryAddArgs, memorySearchArgs, memoryListArgs, memorySupersedeArgs, memoryForgetArgs, TOOL_DESCRIPTIONS, } from "./tools/ops.js";
28
- const SERVER_VERSION = "0.2.0-alpha";
31
+ 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";
32
+ // Server version tracks package.json — never hardcode it here again.
33
+ // package.json sits two levels above this file in both layouts
34
+ // (src/mcp.ts and dist/mcp.js), same convention as cli.ts --version.
35
+ const SERVER_VERSION = (() => {
36
+ try {
37
+ const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
38
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
39
+ return typeof pkg.version === "string" ? pkg.version : "0.0.0-unknown";
40
+ }
41
+ catch {
42
+ return "0.0.0-unknown";
43
+ }
44
+ })();
45
+ /**
46
+ * D26: session-start guidance delivered through the MCP handshake itself.
47
+ * The init-written instruction files only exist if the user ran
48
+ * `open-memex init --client`; the initialize `instructions` reach every MCP
49
+ * client at connect time. Still advisory — no MCP consumer offers a hard
50
+ * session-start hook — but it is the strongest signal available.
51
+ */
52
+ const SERVER_INSTRUCTIONS = `You are connected to an open-memex local memory MCP server
53
+ (eleven memory_* tools: add, search, list, supersede, forget, status, submit,
54
+ propose, promote, resolve, pr_status).
55
+
56
+ - At the START of this session, when you finish a meaningful chunk of work,
57
+ after the user commits (git commit), and after any memory_* action completes,
58
+ call memory_status. If the project outbox has drafts waiting for review,
59
+ summarize them (one line each) and ask the user which ones to sync into the
60
+ repo. Sync NOTHING the user did not name.
61
+ - When the user says "sync memory" (or "同步记忆"), treat it as a request to run
62
+ the sync flow: call memory_status, summarize the outbox drafts, and ask which
63
+ ones to sync. ALWAYS use the memory_status tool for this — never browse the
64
+ appdata directory directly.
65
+ - After memory_submit, ask ONE follow-up: "want me to create a branch + push +
66
+ open the PR, or will you handle it yourself?" NEVER create branches, push, or
67
+ open PRs without the user's explicit approval. A "yes, you do it" covers the
68
+ whole chain — do NOT re-ask at each step.
69
+ - BE PROACTIVE: when the user shares something worth remembering across sessions
70
+ (a decision, a preference, a project convention, a fix and its cause), call
71
+ memory_add without being asked. Keep each memory to one self-contained statement.
72
+ - At the same checkpoints (session start, end of a work chunk, after the user
73
+ commits, after any memory_* action), DISTILL the session: propose 1–3 short
74
+ memories capturing the useful conclusion — what was learned or decided, how an
75
+ issue was resolved, what to avoid, where the authoritative doc lives — not the
76
+ raw transcript. Save NOTHING the user did not approve; on approval call
77
+ memory_add with source "inference" at the confirmed scope. If the knowledge
78
+ already lives in project docs, save a \`reference\` memory pointing at the doc
79
+ instead of copying it. Long-form notes are fine ONLY when the user explicitly
80
+ asks to save one.
81
+ - Before asking the user about past decisions, conventions, or preferences they
82
+ may have told you before, call memory_search first.
83
+ - Memories default to this project's scope; use the personal scope for facts
84
+ about the user that hold across all projects.
85
+ - personal scope memories NEVER leave this machine.`;
29
86
  /** Adapt a framework-agnostic op result to an MCP tool response. */
30
87
  function toMcp(p) {
31
88
  return p.then((r) => ({ content: [{ type: "text", text: r.output }] }), (e) => ({
@@ -44,33 +101,83 @@ export async function runMcpServer() {
44
101
  const getScope = () => scope;
45
102
  // Init DB and one-shot sync of markdown -> index, mirroring the plugin.
46
103
  db();
47
- syncScope(scope.key);
48
- syncScope(PERSONAL_SCOPE.key);
104
+ syncScope(scope.key, "session");
105
+ syncScope(PERSONAL_SCOPE.key, "session");
49
106
  console.error(`[open-memex] MCP server up. scope=${scope.key}`);
50
- const server = new McpServer({ name: "open-memex", version: SERVER_VERSION });
107
+ // D12: pulls are explicit by default — session start never touches the
108
+ // network. With sync.autoPull, one best-effort pull; a failure never
109
+ // blocks the session, it just logs and continues.
110
+ if (cfg.sync?.autoPull) {
111
+ try {
112
+ const { GitProvider } = await import("./providers/git.js");
113
+ const r = new GitProvider().pull(process.cwd());
114
+ syncScope(scope.key, "pull");
115
+ console.error(`[open-memex] auto-pull: ${r.branch} ${r.fastForwarded ? "fast-forwarded" : "already up to date"}`);
116
+ }
117
+ catch (e) {
118
+ console.error(`[open-memex] auto-pull skipped: ${e.message}`);
119
+ }
120
+ }
121
+ // D26: re-sync on every request, not just at startup. The in-repo dir
122
+ // follows the current git branch, so a branch switch mid-session would
123
+ // otherwise leave the index pointing at files that no longer exist.
124
+ const withSync = (fn) => {
125
+ return (args) => {
126
+ syncScope(scope.key, "request");
127
+ syncScope(PERSONAL_SCOPE.key, "request");
128
+ return fn(args);
129
+ };
130
+ };
131
+ const server = new McpServer({ name: "open-memex", version: SERVER_VERSION }, { instructions: SERVER_INSTRUCTIONS });
51
132
  server.registerTool("memory_add", {
52
133
  description: TOOL_DESCRIPTIONS.memory_add,
53
134
  inputSchema: z.object(memoryAddArgs),
54
- }, (args) => toMcp(addMemory(getScope, cfg, args)));
135
+ }, withSync((args) => toMcp(addMemory(getScope, cfg, args))));
55
136
  server.registerTool("memory_search", {
56
137
  description: TOOL_DESCRIPTIONS.memory_search,
57
138
  inputSchema: z.object(memorySearchArgs),
58
139
  annotations: { readOnlyHint: true },
59
- }, (args) => toMcp(searchMemories(getScope, args)));
140
+ }, withSync((args) => toMcp(searchMemories(getScope, args))));
60
141
  server.registerTool("memory_list", {
61
142
  description: TOOL_DESCRIPTIONS.memory_list,
62
143
  inputSchema: z.object(memoryListArgs),
63
144
  annotations: { readOnlyHint: true },
64
- }, (args) => toMcp(listMemories(getScope, args)));
145
+ }, withSync((args) => toMcp(listMemories(getScope, args))));
65
146
  server.registerTool("memory_supersede", {
66
147
  description: TOOL_DESCRIPTIONS.memory_supersede,
67
148
  inputSchema: z.object(memorySupersedeArgs),
68
- }, (args) => toMcp(supersedeMemory(cfg, args)));
149
+ }, withSync((args) => toMcp(supersedeMemory(cfg, args))));
69
150
  server.registerTool("memory_forget", {
70
151
  description: TOOL_DESCRIPTIONS.memory_forget,
71
152
  inputSchema: z.object(memoryForgetArgs),
72
153
  annotations: { destructiveHint: true },
73
- }, (args) => toMcp(forgetMemory(args)));
154
+ }, withSync((args) => toMcp(forgetMemory(args))));
155
+ server.registerTool("memory_status", {
156
+ description: TOOL_DESCRIPTIONS.memory_status,
157
+ inputSchema: z.object(memoryStatusArgs),
158
+ annotations: { readOnlyHint: true },
159
+ }, withSync((_args) => toMcp(statusMemories())));
160
+ server.registerTool("memory_submit", {
161
+ description: TOOL_DESCRIPTIONS.memory_submit,
162
+ inputSchema: z.object(memorySubmitArgs),
163
+ }, withSync((args) => toMcp(submitMemoriesOp(args))));
164
+ server.registerTool("memory_propose", {
165
+ description: TOOL_DESCRIPTIONS.memory_propose,
166
+ inputSchema: z.object(memoryProposeArgs),
167
+ }, withSync((args) => toMcp(proposeMemoriesOp(args))));
168
+ server.registerTool("memory_promote", {
169
+ description: TOOL_DESCRIPTIONS.memory_promote,
170
+ inputSchema: z.object(memoryPromoteArgs),
171
+ }, withSync((args) => toMcp(promoteMemoryOp(args))));
172
+ server.registerTool("memory_resolve", {
173
+ description: TOOL_DESCRIPTIONS.memory_resolve,
174
+ inputSchema: z.object(memoryResolveArgs),
175
+ }, withSync((args) => toMcp(resolveMemoryOp(args))));
176
+ server.registerTool("memory_pr_status", {
177
+ description: TOOL_DESCRIPTIONS.memory_pr_status,
178
+ inputSchema: z.object(memoryPrStatusArgs),
179
+ annotations: { readOnlyHint: true },
180
+ }, withSync((args) => toMcp(prStatusOp(args))));
74
181
  const transport = new StdioServerTransport();
75
182
  await server.connect(transport);
76
183
  }
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
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * GitProvider — the git transport for repo-synced shared scopes (§9, D12).
3
+ *
4
+ * Git is a transport, not the product boundary: this provider moves the
5
+ * in-repo memory dir (`.ai/open-memex/`) between the local checkout and the
6
+ * remote. It never touches the appdata outbox, never force-pushes, never
7
+ * auto-merges a divergence — those are human decisions.
8
+ *
9
+ * Hard rules (from §9 / D12 / D36):
10
+ * - pull is explicit (`open-memex pull`); pull = fetch + fast-forward only.
11
+ * - push is explicit (`open-memex push`); the tool never pushes on its own.
12
+ * - a failed pull/push fails with a clear message and leaves no broken state.
13
+ */
14
+ import { execFileSync } from "node:child_process";
15
+ function fail(msg) {
16
+ throw new Error(`[open-memex] ${msg}`);
17
+ }
18
+ /** Run git, raising a readable error. `timeoutMs` guards network calls. */
19
+ function git(root, args, timeoutMs = 0) {
20
+ try {
21
+ return execFileSync("git", args, {
22
+ cwd: root,
23
+ encoding: "utf8",
24
+ stdio: ["ignore", "pipe", "pipe"],
25
+ ...(timeoutMs > 0 ? { timeout: timeoutMs } : {}),
26
+ }).trim();
27
+ }
28
+ catch (e) {
29
+ const err = e;
30
+ if (err.code === "ETIMEDOUT")
31
+ fail(`git ${args.join(" ")} timed out — remote unreachable?`);
32
+ const detail = (err.stderr ?? err.message ?? "").trim().split("\n")[0];
33
+ fail(`git ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
34
+ }
35
+ }
36
+ const FETCH_TIMEOUT_MS = 30_000;
37
+ export class GitProvider {
38
+ name = "git";
39
+ capabilities = {
40
+ read: true,
41
+ write: true,
42
+ delete: false,
43
+ history: true,
44
+ sync: "bidirectional",
45
+ };
46
+ /** Current branch + upstream, or a clear failure when git can't answer. */
47
+ status(root) {
48
+ git(root, ["rev-parse", "--git-dir"]);
49
+ const branch = git(root, ["branch", "--show-current"]) ||
50
+ git(root, ["rev-parse", "--abbrev-ref", "HEAD"]);
51
+ let upstream = null;
52
+ try {
53
+ upstream = git(root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
54
+ }
55
+ catch {
56
+ upstream = null;
57
+ }
58
+ let ahead = 0;
59
+ let behind = 0;
60
+ if (upstream) {
61
+ const counts = git(root, ["rev-list", "--left-right", "--count", `HEAD...${upstream}`]);
62
+ const [a, b] = counts.split(/\s+/).map((n) => parseInt(n, 10));
63
+ ahead = Number.isFinite(a) ? a : 0;
64
+ behind = Number.isFinite(b) ? b : 0;
65
+ }
66
+ const remote = upstream ? upstream.split("/")[0] : null;
67
+ return { branch, upstream, remote, ahead, behind };
68
+ }
69
+ /**
70
+ * Explicit pull: fetch + fast-forward only (§9).
71
+ * Diverged branches are NOT merged — the user resolves them by hand.
72
+ */
73
+ pull(root) {
74
+ const st = this.status(root);
75
+ if (!st.upstream || !st.remote) {
76
+ fail(`branch ${st.branch} has no upstream — set one with ` +
77
+ `\`git push -u <remote> ${st.branch}\`, then pull again`);
78
+ }
79
+ git(root, ["fetch", st.remote], FETCH_TIMEOUT_MS);
80
+ const before = git(root, ["rev-parse", "HEAD"]);
81
+ const remoteSha = git(root, ["rev-parse", st.upstream]);
82
+ if (before === remoteSha) {
83
+ return {
84
+ at: new Date().toISOString(),
85
+ branch: st.branch,
86
+ remote: st.remote,
87
+ before,
88
+ after: before,
89
+ fastForwarded: false,
90
+ };
91
+ }
92
+ // Fast-forward is possible iff HEAD is an ancestor of the upstream.
93
+ let ffPossible = false;
94
+ try {
95
+ git(root, ["merge-base", "--is-ancestor", "HEAD", st.upstream]);
96
+ ffPossible = true;
97
+ }
98
+ catch {
99
+ ffPossible = false;
100
+ }
101
+ if (!ffPossible) {
102
+ fail(`branch ${st.branch} has diverged from ${st.upstream} — ` +
103
+ `open-memex never force-merges; resolve it by hand ` +
104
+ `(rebase or merge), then pull again`);
105
+ }
106
+ git(root, ["merge", "--ff-only", st.upstream]);
107
+ const after = git(root, ["rev-parse", "HEAD"]);
108
+ return {
109
+ at: new Date().toISOString(),
110
+ branch: st.branch,
111
+ remote: st.remote,
112
+ before,
113
+ after,
114
+ fastForwarded: true,
115
+ };
116
+ }
117
+ /** Explicit push of the current branch. Never called automatically. */
118
+ push(root) {
119
+ const st = this.status(root);
120
+ if (!st.remote) {
121
+ // No upstream yet: push explicitly sets it (-u), still user-invoked.
122
+ const remotes = git(root, ["remote"]);
123
+ const remote = remotes.split("\n").map((r) => r.trim()).filter(Boolean)[0];
124
+ if (!remote)
125
+ fail("no git remote configured — add one before pushing");
126
+ git(root, ["push", "-u", remote, st.branch], FETCH_TIMEOUT_MS);
127
+ return {
128
+ at: new Date().toISOString(),
129
+ branch: st.branch,
130
+ remote,
131
+ head: git(root, ["rev-parse", "HEAD"]),
132
+ };
133
+ }
134
+ git(root, ["push", st.remote, st.branch], FETCH_TIMEOUT_MS);
135
+ return {
136
+ at: new Date().toISOString(),
137
+ branch: st.branch,
138
+ remote: st.remote,
139
+ head: git(root, ["rev-parse", "HEAD"]),
140
+ };
141
+ }
142
+ }
@@ -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");
@@ -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 active = [];
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
- active.push(hit);
94
+ tiers[reviewTier(target)].push(hit);
70
95
  }
71
- return [...active, ...deprecated].slice(0, limit);
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