open-memex 0.1.0 → 0.3.0-alpha

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/redact.ts CHANGED
@@ -1,5 +1,129 @@
1
- export function stripPrivate(text: string): string {
2
- return text.replace(/<private>[\s\S]*?<\/private>/gi, "[REDACTED]");
1
+ /**
2
+ * Redaction: secrets must never land in memory files or the index in
3
+ * readable form.
4
+ *
5
+ * Layers, in order:
6
+ * 1. <private>...</private> regions are stripped first (explicit opt-out —
7
+ * the author marked this span as sensitive, so it is replaced with
8
+ * [REDACTED] before any detection runs).
9
+ * 2. Built-in provider patterns (always on, reported by id).
10
+ * 3. User patterns from config redactPatterns (reported verbatim).
11
+ * 4. High-entropy assignment heuristic (catches secrets whose provider we
12
+ * don't have a pattern for, e.g. `deploy_key = "aB3d..."`).
13
+ *
14
+ * A detected secret does NOT refuse the write: the matched string is masked
15
+ * in place — first 4 characters kept, the rest replaced with 'x' — and the
16
+ * write proceeds with the masked content. hadSecret reports that masking
17
+ * happened so callers can surface a notice. A partially-masked memory still
18
+ * identifies which credential it referred to without storing the secret.
19
+ */
20
+
21
+ export interface SecretPattern {
22
+ /** Stable id reported in matchedPattern. */
23
+ id: string;
24
+ /** Regex source (no slashes). */
25
+ source: string;
26
+ /** Optional RegExp flags, e.g. "i". */
27
+ flags?: string;
28
+ /**
29
+ * Capture-group index holding the secret value. When set, masking replaces
30
+ * only that group — the credential name stays readable (D14: a masked
31
+ * memory should still identify which key it referred to). Default: mask
32
+ * the whole match.
33
+ */
34
+ valueGroup?: number;
35
+ }
36
+
37
+ /**
38
+ * Prefixes like `sk-` also occur inside ordinary English words ("task-…",
39
+ * "risk-…", "disk-…"), which caused confirmed false positives. Require the
40
+ * token prefix NOT to be preceded by a word/hyphen char, so a real key after
41
+ * "=", ":", space, or a quote still matches.
42
+ */
43
+ const TOKEN_BOUNDARY = "(?<![A-Za-z0-9_-])";
44
+
45
+ export const BUILTIN_SECRET_PATTERNS: SecretPattern[] = [
46
+ { id: "openai-key", source: `${TOKEN_BOUNDARY}sk-[A-Za-z0-9_-]{20,}` },
47
+ {
48
+ id: "openai-admin-key",
49
+ source: `${TOKEN_BOUNDARY}sk-admin-[A-Za-z0-9_-]{20,}`,
50
+ },
51
+ {
52
+ id: "openai-session-key",
53
+ source: `${TOKEN_BOUNDARY}sm_[A-Za-z0-9_-]{20,}`,
54
+ },
55
+ { id: "github-pat", source: `${TOKEN_BOUNDARY}ghp_[A-Za-z0-9]{30,}` },
56
+ { id: "github-oauth-token", source: `${TOKEN_BOUNDARY}gho_[A-Za-z0-9]{30,}` },
57
+ { id: "github-user-token", source: `${TOKEN_BOUNDARY}ghu_[A-Za-z0-9]{30,}` },
58
+ {
59
+ id: "github-refresh-token",
60
+ source: `${TOKEN_BOUNDARY}ghr_[A-Za-z0-9]{30,}`,
61
+ },
62
+ {
63
+ id: "github-fine-grained-pat",
64
+ source: `${TOKEN_BOUNDARY}github_pat_[A-Za-z0-9_]{40,}`,
65
+ },
66
+ { id: "aws-access-key-id", source: `${TOKEN_BOUNDARY}AKIA[0-9A-Z]{16}` },
67
+ {
68
+ id: "aws-secret-access-key",
69
+ source:
70
+ "(aws[_-]?secret[_-]?access[_-]?key)([\"']?\\s*[:=]\\s*[\"']?)([A-Za-z0-9/+=]{40})",
71
+ flags: "i",
72
+ valueGroup: 3,
73
+ },
74
+ { id: "slack-token", source: `${TOKEN_BOUNDARY}xox[baprs]-[A-Za-z0-9-]{10,}` },
75
+ { id: "google-api-key", source: `${TOKEN_BOUNDARY}AIza[0-9A-Za-z_-]{30,}` },
76
+ { id: "npm-token", source: `${TOKEN_BOUNDARY}npm_[A-Za-z0-9]{30,}` },
77
+ { id: "gitlab-pat", source: `${TOKEN_BOUNDARY}glpat-[A-Za-z0-9_-]{20,}` },
78
+ {
79
+ id: "stripe-restricted-key",
80
+ source: `${TOKEN_BOUNDARY}rk_(live|test)_[A-Za-z0-9]{20,}`,
81
+ },
82
+ {
83
+ id: "stripe-webhook-secret",
84
+ source: `${TOKEN_BOUNDARY}whsec_[A-Za-z0-9]{20,}`,
85
+ },
86
+ {
87
+ id: "private-key-block",
88
+ // Whole block: masking only the BEGIN header would leave the base64 body
89
+ // readable in the memory file. Non-greedy so two blocks mask separately.
90
+ source:
91
+ "-----BEGIN [A-Z ]*PRIVATE KEY-----[\\s\\S]*?-----END [A-Z ]*PRIVATE KEY-----",
92
+ },
93
+ {
94
+ id: "private-key-truncated",
95
+ // No END marker: mask from the header to end of text. A header without a
96
+ // body is still key material; over-masking is the safe direction.
97
+ source: "-----BEGIN [A-Z ]*PRIVATE KEY-----[\\s\\S]*$",
98
+ },
99
+ {
100
+ id: "jwt",
101
+ source: `${TOKEN_BOUNDARY}eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}`,
102
+ },
103
+ {
104
+ id: "generic-secret-assignment",
105
+ source:
106
+ "(api[_-]?key|secret|passwd|password|auth[_-]?token|access[_-]?token)([\"']?\\s*[:=]\\s*[\"']?)([A-Za-z0-9_\\-./+=]{16,})([\"']?)",
107
+ flags: "i",
108
+ valueGroup: 3,
109
+ },
110
+ ];
111
+
112
+ function compile(p: SecretPattern): RegExp | null {
113
+ try {
114
+ return new RegExp(p.source, p.flags ?? "");
115
+ } catch {
116
+ return null; // ignore malformed builtin (should never happen)
117
+ }
118
+ }
119
+
120
+ /** Returns the matched builtin pattern id, or null. */
121
+ export function findBuiltinSecret(text: string): string | null {
122
+ for (const p of BUILTIN_SECRET_PATTERNS) {
123
+ const re = compile(p);
124
+ if (re && re.test(text)) return p.id;
125
+ }
126
+ return null;
3
127
  }
4
128
 
5
129
  export function findSecret(text: string, patterns: string[]): string | null {
@@ -14,11 +138,138 @@ export function findSecret(text: string, patterns: string[]): string | null {
14
138
  return null;
15
139
  }
16
140
 
141
+ function shannonEntropy(s: string): number {
142
+ const freq = new Map<string, number>();
143
+ for (const c of s) freq.set(c, (freq.get(c) ?? 0) + 1);
144
+ let h = 0;
145
+ for (const n of freq.values()) {
146
+ const p = n / s.length;
147
+ h -= p * Math.log2(p);
148
+ }
149
+ return h;
150
+ }
151
+
152
+ // name = value assignments with a long token-ish value.
153
+ const ASSIGNMENT_RE =
154
+ /([A-Za-z_][A-Za-z0-9_]{1,63})\s*[:=]\s*["']?([A-Za-z0-9_\-./+=]{24,})["']?/g;
155
+
156
+ /**
157
+ * Heuristic last line of defense: a long, high-entropy value assigned to a
158
+ * name is almost certainly a credential, even when no provider pattern
159
+ * matches. Tuned conservatively (length >= 24, entropy >= 4.5 bits/char):
160
+ * hex digests (<= 4.0) and prose (~4.0) pass through; base64-ish randomness
161
+ * does not. URLs are skipped.
162
+ */
163
+ export function findHighEntropySecret(text: string): string | null {
164
+ for (const m of text.matchAll(ASSIGNMENT_RE)) {
165
+ const name = m[1];
166
+ const value = m[2];
167
+ if (!isSuspectAssignmentValue(value, m[0], m.index ?? 0, text)) continue;
168
+ return `high-entropy-secret:${name}`;
169
+ }
170
+ return null;
171
+ }
172
+
173
+ /**
174
+ * Shared benign-value rule for detection AND masking: a URL (e.g. the value
175
+ * after "https:") or a low-entropy value must never be touched. Note the
176
+ * "://" check: ASSIGNMENT_RE consumes the colon of "https:" as the separator,
177
+ * so the captured value starts with "//..." — check for "://" in the
178
+ * original text around the match, not just inside the value.
179
+ */
180
+ function isSuspectAssignmentValue(value: string, fullMatch: string, offset: number, text: string): boolean {
181
+ // Bare URL: ASSIGNMENT_RE consumed the scheme colon ("https:") as the
182
+ // separator, so the match itself starts with "scheme://".
183
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(fullMatch)) return false;
184
+ if (value.includes("://")) return false;
185
+ // Reconstruct what preceded the value inside the match (name + separator);
186
+ // if the text right before the value looks like scheme://, it is a URL.
187
+ const before = text.slice(Math.max(0, offset - 12), offset);
188
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:(\/\/)?$/.test(before.trim()) || before.includes("://")) return false;
189
+ return shannonEntropy(value) >= 4.5;
190
+ }
191
+
192
+ export function stripPrivate(text: string): string {
193
+ // Closed pairs first...
194
+ let out = text.replace(/<private>[\s\S]*?<\/private>/gi, "[REDACTED]");
195
+ // ...then an unclosed <private> redacts everything after it. A dangling
196
+ // tag almost always means the author intended the rest to be private.
197
+ out = out.replace(/<private>[\s\S]*$/gi, "[REDACTED]");
198
+ return out;
199
+ }
200
+
201
+ /**
202
+ * Mask a matched secret: keep the first 4 characters, replace the rest
203
+ * with 'x' (length-preserving). "sk-1234567890abcdefghij" → "sk-1xxxxxxxxxxxxx".
204
+ */
205
+ function maskMatch(m: string): string {
206
+ return m.slice(0, 4) + "x".repeat(Math.max(0, m.length - 4));
207
+ }
208
+
209
+ /** Apply the builtin patterns as masking (global, all matches). */
210
+ function maskBuiltin(content: string): string {
211
+ let out = content;
212
+ for (const p of BUILTIN_SECRET_PATTERNS) {
213
+ const re = compile(p);
214
+ if (!re) continue;
215
+ const g = new RegExp(re.source, re.flags + "g");
216
+ if (p.valueGroup == null) {
217
+ out = out.replace(g, (m) => maskMatch(m));
218
+ } else {
219
+ // Mask only the secret-value group; the credential name stays readable.
220
+ out = out.replace(g, (...args: unknown[]) => {
221
+ const m = args[0] as string;
222
+ const val = args[p.valueGroup as number] as string | undefined;
223
+ if (!val) return m;
224
+ const idx = m.lastIndexOf(val);
225
+ if (idx < 0) return m;
226
+ return m.slice(0, idx) + maskMatch(val) + m.slice(idx + val.length);
227
+ });
228
+ }
229
+ }
230
+ return out;
231
+ }
232
+
233
+ /** Apply user patterns as masking. Invalid regexes are ignored. */
234
+ function maskUser(content: string, patterns: string[]): string {
235
+ let out = content;
236
+ for (const p of patterns) {
237
+ try {
238
+ out = out.replace(new RegExp(p, "g"), (m) => maskMatch(m));
239
+ } catch {
240
+ // ignore malformed regex
241
+ }
242
+ }
243
+ return out;
244
+ }
245
+
246
+ /** Mask the value side of high-entropy assignments (name stays readable).
247
+ * Uses the exact same benign-value rule as detection, so URLs and
248
+ * low-entropy values are never touched even when another secret triggers. */
249
+ function maskEntropy(content: string): string {
250
+ return content.replace(
251
+ ASSIGNMENT_RE,
252
+ (m, name: string, value: string, offset: number) => {
253
+ if (!isSuspectAssignmentValue(value, m, offset, content)) return m;
254
+ return m.split(value).join(maskMatch(value));
255
+ },
256
+ );
257
+ }
258
+
17
259
  export function redact(
18
260
  text: string,
19
261
  patterns: string[],
20
262
  ): { content: string; hadSecret: boolean; matchedPattern: string | null } {
21
263
  const stripped = stripPrivate(text);
22
- const matched = findSecret(stripped, patterns);
23
- return { content: stripped, hadSecret: matched !== null, matchedPattern: matched };
264
+ // Detection (for reporting) runs in priority order: builtin → user → entropy.
265
+ const builtin = findBuiltinSecret(stripped);
266
+ const user = builtin ? null : findSecret(stripped, patterns);
267
+ const entropic =
268
+ builtin || user ? null : findHighEntropySecret(stripped);
269
+ const matched = builtin ?? user ?? entropic;
270
+ if (!matched) return { content: stripped, hadSecret: false, matchedPattern: null };
271
+ // Masking runs every family over the text (not just the reported one) so
272
+ // multiple credentials in one memory are all masked.
273
+ const masked = maskEntropy(maskUser(maskBuiltin(stripped), patterns));
274
+ return { content: masked, hadSecret: true, matchedPattern: matched };
24
275
  }
@@ -0,0 +1,63 @@
1
+ // CJK retrieval helpers (pure logic, no sqlite).
2
+ //
3
+ // FTS5's `porter unicode61` tokenizer treats a run of CJK ideographs as one
4
+ // token ("中文记忆" is a single token), so substring queries never match, and
5
+ // the old query builder dropped CJK characters entirely. Per D8 the default
6
+ // CJK strategy is bigram: at write time we pre-tokenize CJK runs into
7
+ // space-separated unigrams + overlapping bigrams stored in the `cjk` FTS
8
+ // column; at query time the CJK part of the query becomes an OR of bigrams
9
+ // against that column. Unigrams are included so single-character queries
10
+ // ("猫") still match. Works identically on bun:sqlite and better-sqlite3
11
+ // with no native tokenizer dependency.
12
+
13
+ const CJK_RE =
14
+ /[\u3400-\u4DBF\u4E00-\u9FFF\u3040-\u309F\u30A0-\u30FF\uAC00-\uD7AF\u1100-\u11FF\u{20000}-\u{2A6DF}]/u;
15
+
16
+ const CJK_RUN_RE =
17
+ /[\u3400-\u4DBF\u4E00-\u9FFF\u3040-\u309F\u30A0-\u30FF\uAC00-\uD7AF\u1100-\u11FF\u{20000}-\u{2A6DF}]+/gu;
18
+
19
+ /** True if the string contains any CJK (Han/Hiragana/Katakana/Hangul) character. */
20
+ export function hasCjk(s: string): boolean {
21
+ return CJK_RE.test(s);
22
+ }
23
+
24
+ /**
25
+ * Build the index text for the `cjk` FTS column: for every CJK run emit each
26
+ * character (unigram) then every overlapping bigram, space-separated.
27
+ * "中文记忆" -> "中 文 记 忆 中文 文记 记忆". Non-CJK text yields "".
28
+ */
29
+ export function cjkIndexText(text: string): string {
30
+ const out: string[] = [];
31
+ for (const m of text.matchAll(CJK_RUN_RE)) {
32
+ const chars = [...m[0]];
33
+ for (const ch of chars) out.push(ch);
34
+ for (let i = 0; i + 1 < chars.length; i++) out.push(chars[i] + chars[i + 1]);
35
+ }
36
+ return out.join(" ");
37
+ }
38
+
39
+ /** Escape a raw token for embedding in a double-quoted FTS5 phrase. */
40
+ function esc(t: string): string {
41
+ return t.replace(/"/g, '""');
42
+ }
43
+
44
+ /**
45
+ * Convert the CJK runs of a query into an FTS5 expression for the `cjk`
46
+ * column. Multi-char runs become an OR of bigrams (recall-oriented; bm25
47
+ * ranks docs matching more bigrams higher). Single chars stay unigrams.
48
+ * Returns "" when the query has no CJK.
49
+ */
50
+ export function cjkQueryExpr(query: string): string {
51
+ const parts: string[] = [];
52
+ for (const m of query.matchAll(CJK_RUN_RE)) {
53
+ const chars = [...m[0]];
54
+ if (chars.length === 1) {
55
+ parts.push(`"${esc(chars[0])}"`);
56
+ } else {
57
+ for (let i = 0; i + 1 < chars.length; i++) {
58
+ parts.push(`"${esc(chars[i] + chars[i + 1])}"`);
59
+ }
60
+ }
61
+ }
62
+ return parts.join(" OR ");
63
+ }
@@ -1,6 +1,6 @@
1
1
  import { list } from "./search.ts";
2
2
  import type { Scope } from "../scope.ts";
3
- import { USER_SCOPE } from "../scope.ts";
3
+ import { PERSONAL_SCOPE } from "../scope.ts";
4
4
  import type { MyOMemoryConfig } from "../config.ts";
5
5
 
6
6
  function oneLine(s: string, max = 240): string {
@@ -10,7 +10,7 @@ function oneLine(s: string, max = 240): string {
10
10
 
11
11
  export function buildContextBlock(scope: Scope, cfg: MyOMemoryConfig): string | null {
12
12
  const project = list(scope.key, { limit: cfg.maxProjectMemories });
13
- const user = list(USER_SCOPE.key, { limit: cfg.maxProfileItems });
13
+ const user = list(PERSONAL_SCOPE.key, { limit: cfg.maxProfileItems });
14
14
 
15
15
  if (project.length === 0 && user.length === 0) return null;
16
16
 
@@ -1,4 +1,5 @@
1
1
  import { db } from "../store/db.ts";
2
+ import { cjkQueryExpr, hasCjk } from "./cjk.ts";
2
3
 
3
4
  export interface SearchHit {
4
5
  id: string;
@@ -9,13 +10,105 @@ export interface SearchHit {
9
10
  snippet: string;
10
11
  score: number;
11
12
  updated_at: number;
13
+ status: string;
12
14
  }
13
15
 
14
- /** Convert free-text query into a safe FTS5 MATCH expression. */
16
+ interface RawRow {
17
+ id: string;
18
+ scope_key: string;
19
+ project_name: string;
20
+ type: string;
21
+ tags: string;
22
+ updated_at: number;
23
+ snippet: string;
24
+ score: number;
25
+ status: string;
26
+ superseded_by: string | null;
27
+ }
28
+
29
+ function toHit(r: RawRow): SearchHit {
30
+ return {
31
+ id: r.id,
32
+ scope_key: r.scope_key,
33
+ project_name: r.project_name,
34
+ type: r.type,
35
+ tags: r.tags ? r.tags.split(",").filter(Boolean) : [],
36
+ snippet: r.snippet ?? "",
37
+ score: r.score,
38
+ updated_at: r.updated_at,
39
+ status: r.status,
40
+ };
41
+ }
42
+
43
+ /**
44
+ * Lifecycle-aware post-processing (§3.3):
45
+ * - retracted / archived are excluded from retrieval (kept for audit);
46
+ * - a superseded memory resolves to the newest of its chain (cycle-safe);
47
+ * - deprecated stays visible as a warning but ranks after active.
48
+ */
49
+ function resolveVisible(rows: RawRow[], limit: number): SearchHit[] {
50
+ const byId = new Map(rows.map((r) => [r.id, r]));
51
+ const fullRow = (id: string): RawRow | undefined => {
52
+ const cached = byId.get(id);
53
+ if (cached) return cached;
54
+ const r = db()
55
+ .prepare(
56
+ `SELECT id, scope_key, project_name, type, tags, updated_at, status,
57
+ superseded_by, substr(content, 1, 240) AS snippet
58
+ FROM memories WHERE id = ?`,
59
+ )
60
+ .get(id) as
61
+ | (Omit<RawRow, "score" | "snippet"> & { snippet: string })
62
+ | undefined;
63
+ if (!r) return undefined;
64
+ const full: RawRow = { ...r, score: 0 };
65
+ byId.set(id, full);
66
+ return full;
67
+ };
68
+
69
+ const seen = new Set<string>();
70
+ const active: SearchHit[] = [];
71
+ const deprecated: SearchHit[] = [];
72
+
73
+ for (const r of rows) {
74
+ if (r.status === "retracted" || r.status === "archived") continue;
75
+ let target = r;
76
+ if (r.status === "superseded") {
77
+ let cur = r;
78
+ const chain = new Set([r.id]);
79
+ while (cur.status === "superseded" && cur.superseded_by) {
80
+ if (chain.has(cur.superseded_by)) break; // cycle guard
81
+ chain.add(cur.superseded_by);
82
+ const nxt = fullRow(cur.superseded_by);
83
+ if (!nxt) break;
84
+ cur = nxt;
85
+ }
86
+ if (cur.status === "retracted" || cur.status === "archived") continue;
87
+ target = cur;
88
+ }
89
+ if (seen.has(target.id)) continue;
90
+ seen.add(target.id);
91
+ const hit = toHit(target);
92
+ if (target.status === "deprecated") deprecated.push(hit);
93
+ else active.push(hit);
94
+ }
95
+ return [...active, ...deprecated].slice(0, limit);
96
+ }
97
+
98
+ /**
99
+ * Convert free-text query into a safe FTS5 MATCH expression.
100
+ * Latin tokens keep the old behavior (prefix match on content/tags/type).
101
+ * CJK runs become an OR of bigrams against the `cjk` column (see cjk.ts).
102
+ * Mixed queries OR the two parts together.
103
+ */
15
104
  function toFtsQuery(q: string): string {
16
- const tokens = q.toLowerCase().match(/[a-z0-9_.\-]+/g) ?? [];
17
- if (tokens.length === 0) return "";
18
- return tokens.map((t) => `"${t.replace(/"/g, '""')}"*`).join(" OR ");
105
+ const latin = (q.toLowerCase().match(/[a-z0-9_.\-]+/g) ?? [])
106
+ .map((t) => `"${t.replace(/"/g, '""')}"*`)
107
+ .join(" OR ");
108
+ const cjk = hasCjk(q) ? cjkQueryExpr(q) : "";
109
+ if (latin && cjk) return `(${latin}) OR {cjk}:(${cjk})`;
110
+ if (cjk) return `{cjk}:(${cjk})`;
111
+ return latin;
19
112
  }
20
113
 
21
114
  export function search(
@@ -25,6 +118,9 @@ export function search(
25
118
  const q = toFtsQuery(query);
26
119
  if (!q) return [];
27
120
  const limit = Math.max(1, Math.min(opts.limit ?? 8, 50));
121
+ // Over-fetch: lifecycle filtering (chain resolution, exclusions) happens
122
+ // after the FTS query, so candidates must survive it.
123
+ const fetchLimit = Math.min(limit * 3 + 10, 150);
28
124
 
29
125
  const scopeFilter =
30
126
  opts.scopeKeys && opts.scopeKeys.length > 0
@@ -34,6 +130,7 @@ export function search(
34
130
 
35
131
  const sql = `
36
132
  SELECT m.id, m.scope_key, m.project_name, m.type, m.tags, m.updated_at,
133
+ m.status, m.superseded_by,
37
134
  snippet(memories_fts, 0, '[', ']', ' ... ', 12) AS snippet,
38
135
  bm25(memories_fts) AS score
39
136
  FROM memories_fts
@@ -45,7 +142,7 @@ export function search(
45
142
  const params: unknown[] = [q];
46
143
  if (opts.scopeKeys && opts.scopeKeys.length > 0) params.push(...opts.scopeKeys);
47
144
  if (opts.type) params.push(opts.type);
48
- params.push(limit);
145
+ params.push(fetchLimit);
49
146
 
50
147
  const rows = db()
51
148
  .prepare(sql)
@@ -56,20 +153,15 @@ export function search(
56
153
  type: string;
57
154
  tags: string;
58
155
  updated_at: number;
156
+ status: string;
157
+ superseded_by: string | null;
59
158
  snippet: string;
60
159
  score: number;
61
160
  }>;
62
161
 
63
- return rows.map((r) => ({
64
- id: r.id,
65
- scope_key: r.scope_key,
66
- project_name: r.project_name,
67
- type: r.type,
68
- tags: r.tags ? r.tags.split(",").filter(Boolean) : [],
69
- snippet: r.snippet ?? "",
70
- score: -r.score, // FTS5 bm25: lower = better; invert for intuition
71
- updated_at: r.updated_at,
72
- }));
162
+ // FTS5 bm25: lower = better; invert for intuition.
163
+ const raw: RawRow[] = rows.map((r) => ({ ...r, score: -r.score }));
164
+ return resolveVisible(raw, limit);
73
165
  }
74
166
 
75
167
  export function list(
@@ -77,10 +169,11 @@ export function list(
77
169
  opts: { type?: string; limit?: number } = {},
78
170
  ): SearchHit[] {
79
171
  const limit = Math.max(1, Math.min(opts.limit ?? 20, 100));
172
+ const fetchLimit = Math.min(limit * 3 + 10, 150);
80
173
  const typeFilter = opts.type ? ` AND type = ?` : "";
81
174
  const sql = `
82
- SELECT id, scope_key, project_name, type, tags, updated_at,
83
- substr(content, 1, 240) AS snippet
175
+ SELECT id, scope_key, project_name, type, tags, updated_at, status,
176
+ superseded_by, substr(content, 1, 240) AS snippet
84
177
  FROM memories
85
178
  WHERE scope_key = ?${typeFilter}
86
179
  ORDER BY updated_at DESC
@@ -88,7 +181,7 @@ export function list(
88
181
  `;
89
182
  const params: unknown[] = [scopeKey];
90
183
  if (opts.type) params.push(opts.type);
91
- params.push(limit);
184
+ params.push(fetchLimit);
92
185
 
93
186
  const rows = db()
94
187
  .prepare(sql)
@@ -99,17 +192,11 @@ export function list(
99
192
  type: string;
100
193
  tags: string;
101
194
  updated_at: number;
195
+ status: string;
196
+ superseded_by: string | null;
102
197
  snippet: string;
103
198
  }>;
104
199
 
105
- return rows.map((r) => ({
106
- id: r.id,
107
- scope_key: r.scope_key,
108
- project_name: r.project_name,
109
- type: r.type,
110
- tags: r.tags ? r.tags.split(",").filter(Boolean) : [],
111
- snippet: r.snippet ?? "",
112
- score: 1,
113
- updated_at: r.updated_at,
114
- }));
200
+ const raw: RawRow[] = rows.map((r) => ({ ...r, score: 1 }));
201
+ return resolveVisible(raw, limit);
115
202
  }
package/src/scope.ts CHANGED
@@ -4,11 +4,16 @@ import path from "node:path";
4
4
 
5
5
  export interface Scope {
6
6
  key: string;
7
- kind: "user" | "project";
7
+ kind: "personal" | "project" | "org";
8
8
  projectName: string;
9
9
  }
10
10
 
11
- export const USER_SCOPE: Scope = { key: "user", kind: "user", projectName: "user" };
11
+ /** v2: v1 `user` scope is renamed to `personal` (§19). */
12
+ export const PERSONAL_SCOPE: Scope = {
13
+ key: "personal",
14
+ kind: "personal",
15
+ projectName: "personal",
16
+ };
12
17
 
13
18
  function normalizeRemote(url: string): string {
14
19
  return url