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/AGENTS.md +62 -10
- package/CONTRIBUTING.md +31 -0
- package/README.md +250 -38
- package/README.zh-CN.md +307 -0
- package/bin/open-memex.js +28 -0
- package/docs/SCOPES.md +81 -0
- package/docs/V2-DESIGN.md +588 -0
- package/package.json +12 -3
- package/scripts/smoke-mcp.ts +135 -0
- package/scripts/smoke-pure.ts +250 -9
- package/src/capture/keywords.ts +28 -15
- package/src/cli.ts +347 -26
- package/src/config.ts +91 -13
- package/src/doctor.ts +161 -0
- package/src/index.ts +34 -13
- package/src/init.ts +304 -0
- package/src/mcp.ts +133 -0
- package/src/redact.ts +255 -4
- package/src/retrieve/cjk.ts +63 -0
- package/src/retrieve/inject.ts +2 -2
- package/src/retrieve/search.ts +115 -28
- package/src/scope.ts +7 -2
- package/src/store/db.ts +62 -14
- package/src/store/lifecycle.ts +280 -0
- package/src/store/markdown.ts +163 -11
- package/src/store/sync.ts +53 -9
- package/src/store/v2migrate.ts +190 -0
- package/src/tools/memory.ts +32 -146
- package/src/tools/ops.ts +259 -0
- package/PLAN.md +0 -168
package/src/redact.ts
CHANGED
|
@@ -1,5 +1,129 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
+
}
|
package/src/retrieve/inject.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { list } from "./search.ts";
|
|
2
2
|
import type { Scope } from "../scope.ts";
|
|
3
|
-
import {
|
|
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(
|
|
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
|
|
package/src/retrieve/search.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
17
|
-
|
|
18
|
-
|
|
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(
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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(
|
|
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
|
-
|
|
106
|
-
|
|
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: "
|
|
7
|
+
kind: "personal" | "project" | "org";
|
|
8
8
|
projectName: string;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
|
|
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
|