open-memex 0.1.0

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.
@@ -0,0 +1,34 @@
1
+ import type { MyOMemoryConfig } from "../config.ts";
2
+
3
+ export interface KeywordHit {
4
+ content: string;
5
+ pattern: string;
6
+ }
7
+
8
+ /**
9
+ * Scan user text for memory-capture keyword triggers.
10
+ * Each pattern must have a single capture group whose value becomes the memory body.
11
+ */
12
+ export function detectKeywords(userText: string, cfg: MyOMemoryConfig): KeywordHit[] {
13
+ if (!cfg.keywordCaptureEnabled) return [];
14
+ const hits: KeywordHit[] = [];
15
+ const lines = userText.split(/\r?\n/);
16
+ for (const src of cfg.keywordPatterns) {
17
+ let re: RegExp;
18
+ try {
19
+ re = new RegExp(src, "i");
20
+ } catch {
21
+ continue;
22
+ }
23
+ for (const line of lines) {
24
+ const m = line.match(re);
25
+ if (!m || !m[1]) continue;
26
+ const content = m[1].trim().replace(/[.!?]$/, "").trim();
27
+ if (content.length >= 3 && content.length <= 2000) {
28
+ hits.push({ content, pattern: src });
29
+ break; // one hit per pattern per message
30
+ }
31
+ }
32
+ }
33
+ return hits;
34
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,264 @@
1
+ #!/usr/bin/env node
2
+ // Run with: node --experimental-strip-types src/cli.ts <command>
3
+ import { resolveProjectScope, resolveCwdScope, USER_SCOPE } from "./scope.ts";
4
+ import { db, closeDb } from "./store/db.ts";
5
+ import { syncScope, upsertFromFile, deleteFromIndex } from "./store/sync.ts";
6
+ import { migrateScope, scopeHasFiles, type ConflictStrategy } from "./store/migrate.ts";
7
+ import { search, list } from "./retrieve/search.ts";
8
+ import {
9
+ writeMemoryFile,
10
+ readMemoryFile,
11
+ deleteMemoryFile,
12
+ ulid,
13
+ type Frontmatter,
14
+ } from "./store/markdown.ts";
15
+ import { loadConfig } from "./config.ts";
16
+ import { paths } from "./paths.ts";
17
+ import { redact } from "./redact.ts";
18
+ import fs from "node:fs";
19
+
20
+ function usage(): never {
21
+ console.log(`open-memex CLI
22
+
23
+ Usage:
24
+ node --experimental-strip-types src/cli.ts where
25
+ node --experimental-strip-types src/cli.ts list [--scope project|user] [--type T] [--limit N]
26
+ node --experimental-strip-types src/cli.ts search "query" [--scope project|user|both] [--type T] [--limit N]
27
+ node --experimental-strip-types src/cli.ts add "content" [--scope project|user] [--type T] [--tag t1,t2]
28
+ node --experimental-strip-types src/cli.ts forget <id>
29
+ node --experimental-strip-types src/cli.ts reindex
30
+ node --experimental-strip-types src/cli.ts scopes
31
+ node --experimental-strip-types src/cli.ts migrate [--from <key>] [--to <key>]
32
+ [--dry-run] [--on-conflict newer|overwrite|skip]
33
+
34
+ Scope defaults to \`project\` (derived from cwd's git remote or path).
35
+ \`scopes\` lists every project scope dir with its file count — use it to find
36
+ the \`--from\` key when migrating.
37
+ \`migrate\` moves memories between scope keys — useful when a repo gains a
38
+ git remote after memories were already stored under the cwd-based key.`);
39
+ process.exit(1);
40
+ }
41
+
42
+ function parseFlags(argv: string[]): Record<string, string> {
43
+ const out: Record<string, string> = {};
44
+ for (let i = 0; i < argv.length; i++) {
45
+ const a = argv[i]!;
46
+ if (a.startsWith("--")) {
47
+ const key = a.slice(2);
48
+ const val = argv[i + 1];
49
+ if (val !== undefined && !val.startsWith("--")) {
50
+ out[key] = val;
51
+ i++;
52
+ } else {
53
+ out[key] = "true";
54
+ }
55
+ }
56
+ }
57
+ return out;
58
+ }
59
+
60
+ async function main() {
61
+ const [cmd, ...rest] = process.argv.slice(2);
62
+ if (!cmd) usage();
63
+
64
+ const cfg = loadConfig();
65
+ const project = resolveProjectScope(process.cwd());
66
+ db();
67
+
68
+ if (cmd === "where") {
69
+ const p = paths();
70
+ console.log(`root: ${p.root}`);
71
+ console.log(`memories: ${p.memories}`);
72
+ console.log(`index: ${p.indexDb}`);
73
+ console.log(`project: ${project.key}`);
74
+ console.log(`user: ${USER_SCOPE.key}`);
75
+ return;
76
+ }
77
+
78
+ if (cmd === "reindex") {
79
+ const a = syncScope(project.key);
80
+ const b = syncScope(USER_SCOPE.key);
81
+ console.log(
82
+ `reindexed. project: +${a.added} ~${a.updated} -${a.removed} (scanned ${a.scanned}), user: +${b.added} ~${b.updated} -${b.removed} (scanned ${b.scanned})`,
83
+ );
84
+ return;
85
+ }
86
+
87
+ if (cmd === "list") {
88
+ const flags = parseFlags(rest);
89
+ const s = flags.scope === "user" ? USER_SCOPE : project;
90
+ syncScope(s.key);
91
+ const hits = list(s.key, {
92
+ type: flags.type,
93
+ limit: flags.limit ? Number(flags.limit) : undefined,
94
+ });
95
+ if (hits.length === 0) {
96
+ console.log(`(no memories in ${s.key})`);
97
+ return;
98
+ }
99
+ for (const h of hits) {
100
+ console.log(`[${h.type}] ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
101
+ }
102
+ return;
103
+ }
104
+
105
+ if (cmd === "search") {
106
+ const query = rest[0];
107
+ if (!query || query.startsWith("--")) usage();
108
+ const flags = parseFlags(rest.slice(1));
109
+ syncScope(project.key);
110
+ syncScope(USER_SCOPE.key);
111
+ const keys =
112
+ flags.scope === "user"
113
+ ? [USER_SCOPE.key]
114
+ : flags.scope === "project"
115
+ ? [project.key]
116
+ : [project.key, USER_SCOPE.key];
117
+ const hits = search(query, {
118
+ scopeKeys: keys,
119
+ limit: flags.limit ? Number(flags.limit) : undefined,
120
+ type: flags.type,
121
+ });
122
+ if (hits.length === 0) {
123
+ console.log("(no matches)");
124
+ return;
125
+ }
126
+ for (const h of hits) {
127
+ const tag = h.scope_key === USER_SCOPE.key ? "user" : "project";
128
+ console.log(`[${tag}/${h.type}] ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
129
+ }
130
+ return;
131
+ }
132
+
133
+ if (cmd === "add") {
134
+ const content = rest[0];
135
+ if (!content || content.startsWith("--")) usage();
136
+ const flags = parseFlags(rest.slice(1));
137
+ const s = flags.scope === "user" ? USER_SCOPE : project;
138
+ const { content: red, hadSecret, matchedPattern } = redact(content, cfg.redactPatterns);
139
+ if (hadSecret) {
140
+ console.error(`refused: content matched secret pattern (${matchedPattern}).`);
141
+ process.exit(2);
142
+ }
143
+ const now = Date.now();
144
+ const fm: Frontmatter = {
145
+ id: ulid(),
146
+ scope_key: s.key,
147
+ scope_kind: s.kind,
148
+ project_name: s.projectName,
149
+ type: flags.type ?? "note",
150
+ tags: flags.tag
151
+ ? flags.tag
152
+ .split(",")
153
+ .map((t) => t.trim())
154
+ .filter(Boolean)
155
+ : [],
156
+ source: "cli",
157
+ created_at: now,
158
+ updated_at: now,
159
+ };
160
+ const { filePath } = writeMemoryFile(fm, red);
161
+ const mf = readMemoryFile(filePath);
162
+ if (mf) upsertFromFile(mf);
163
+ console.log(`saved ${fm.id} -> ${filePath}`);
164
+ return;
165
+ }
166
+
167
+ if (cmd === "forget") {
168
+ const id = rest[0];
169
+ if (!id) usage();
170
+ const row = db().prepare(`SELECT scope_key FROM memories WHERE id = ?`).get(id) as
171
+ | { scope_key: string }
172
+ | undefined;
173
+ if (!row) {
174
+ console.error("not found");
175
+ process.exit(1);
176
+ }
177
+ deleteMemoryFile(row.scope_key, id);
178
+ deleteFromIndex(id);
179
+ console.log(`deleted ${id}`);
180
+ return;
181
+ }
182
+
183
+ if (cmd === "scopes") {
184
+ const { memories } = paths();
185
+ if (!fs.existsSync(memories)) {
186
+ console.log("(no memories dir yet)");
187
+ return;
188
+ }
189
+ const entries: Array<{ key: string; files: number; marker: string }> = [];
190
+ for (const name of fs.readdirSync(memories)) {
191
+ const dir = `${memories}/${name}`;
192
+ if (!fs.statSync(dir).isDirectory()) continue;
193
+ const files = fs.readdirSync(dir).filter((f) => f.endsWith(".md")).length;
194
+ const marker =
195
+ name === project.key ? " <- current project"
196
+ : name === USER_SCOPE.key ? " <- user"
197
+ : "";
198
+ entries.push({ key: name, files, marker });
199
+ }
200
+ entries.sort((a, b) => b.files - a.files);
201
+ for (const e of entries) {
202
+ console.log(` ${e.files.toString().padStart(4)} ${e.key}${e.marker}`);
203
+ }
204
+ return;
205
+ }
206
+
207
+ if (cmd === "migrate") {
208
+ const flags = parseFlags(rest);
209
+ const toKey = flags.to ?? project.key;
210
+
211
+ let fromKey = flags.from;
212
+ if (!fromKey) {
213
+ // Auto-detect: the "legacy" scope is what the key WOULD be if we
214
+ // ignored the git remote (pure cwd hash). Only offer it if it differs
215
+ // from the current project scope AND has files on disk.
216
+ const cwd = resolveCwdScope(process.cwd());
217
+ if (cwd.key !== project.key && scopeHasFiles(cwd.key)) {
218
+ fromKey = cwd.key;
219
+ console.log(`(auto-detected legacy scope: ${fromKey})`);
220
+ } else {
221
+ console.error(
222
+ `no --from given and no legacy cwd-based scope with files detected.\n` +
223
+ `current project scope: ${project.key}\n` +
224
+ `cwd-only scope: ${cwd.key}`,
225
+ );
226
+ process.exit(2);
227
+ }
228
+ }
229
+
230
+ if (fromKey === toKey) {
231
+ console.error(`--from and --to are identical (${fromKey}); nothing to do.`);
232
+ process.exit(2);
233
+ }
234
+
235
+ const dryRun = flags["dry-run"] === "true";
236
+ const onConflict = flags["on-conflict"] as ConflictStrategy | undefined;
237
+ if (onConflict && !["newer", "overwrite", "skip"].includes(onConflict)) {
238
+ console.error(`invalid --on-conflict: ${onConflict}`);
239
+ process.exit(2);
240
+ }
241
+
242
+ const stats = migrateScope(fromKey, toKey, {
243
+ toProjectName: project.projectName,
244
+ dryRun,
245
+ onConflict,
246
+ logger: (m) => console.log(m),
247
+ });
248
+ console.log(
249
+ `${dryRun ? "DRY RUN: " : ""}moved ${stats.moved}, skipped ${stats.skipped}, conflicts ${stats.conflicts}`,
250
+ );
251
+ return;
252
+ }
253
+
254
+ usage();
255
+ }
256
+
257
+ main()
258
+ .catch((err) => {
259
+ console.error(err);
260
+ process.exit(1);
261
+ })
262
+ .finally(() => {
263
+ closeDb();
264
+ });
package/src/config.ts ADDED
@@ -0,0 +1,67 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+
5
+ export interface MyOMemoryConfig {
6
+ maxProjectMemories: number;
7
+ maxProfileItems: number;
8
+ injectOnFirstTurn: boolean;
9
+ keywordCaptureEnabled: boolean;
10
+ keywordPatterns: string[];
11
+ redactPatterns: string[];
12
+ logLevel: "debug" | "info" | "warn" | "error";
13
+ }
14
+
15
+ export const DEFAULT_CONFIG: MyOMemoryConfig = {
16
+ maxProjectMemories: 8,
17
+ maxProfileItems: 5,
18
+ injectOnFirstTurn: true,
19
+ keywordCaptureEnabled: true,
20
+ keywordPatterns: [
21
+ "^\\s*remember(?:\\s+that)?[:,]?\\s+(.+)$",
22
+ "^\\s*(?:please\\s+)?(?:note|don'?t\\s+forget)(?:\\s+that)?[:,]?\\s+(.+)$",
23
+ "^\\s*TIL[:,]?\\s+(.+)$",
24
+ "^\\s*save\\s+(?:this|to\\s+memory)[:,]?\\s+(.+)$",
25
+ ],
26
+ redactPatterns: [
27
+ "sk-[A-Za-z0-9_-]{20,}",
28
+ "sm_[A-Za-z0-9_-]{20,}",
29
+ "ghp_[A-Za-z0-9]{30,}",
30
+ "gho_[A-Za-z0-9]{30,}",
31
+ "github_pat_[A-Za-z0-9_]{40,}",
32
+ "AKIA[0-9A-Z]{16}",
33
+ "xox[baprs]-[A-Za-z0-9-]{10,}",
34
+ "AIza[0-9A-Za-z_-]{30,}",
35
+ ],
36
+ logLevel: "info",
37
+ };
38
+
39
+ function stripJsonComments(raw: string): string {
40
+ // Line comments
41
+ let out = raw.replace(/^\s*\/\/.*$/gm, "");
42
+ // Block comments (non-greedy)
43
+ out = out.replace(/\/\*[\s\S]*?\*\//g, "");
44
+ // Trailing commas before closing brackets
45
+ out = out.replace(/,(\s*[}\]])/g, "$1");
46
+ return out;
47
+ }
48
+
49
+ export function loadConfig(): MyOMemoryConfig {
50
+ const candidates = [
51
+ process.env.MY_O_MEMORY_CONFIG,
52
+ path.join(os.homedir(), ".config", "opencode", "open-memex.jsonc"),
53
+ path.join(os.homedir(), ".config", "opencode", "open-memex.json"),
54
+ ].filter(Boolean) as string[];
55
+
56
+ for (const p of candidates) {
57
+ if (!fs.existsSync(p)) continue;
58
+ try {
59
+ const raw = fs.readFileSync(p, "utf8");
60
+ const parsed = JSON.parse(stripJsonComments(raw)) as Partial<MyOMemoryConfig>;
61
+ return { ...DEFAULT_CONFIG, ...parsed };
62
+ } catch (err) {
63
+ console.error(`[open-memex] failed to parse ${p}:`, err);
64
+ }
65
+ }
66
+ return DEFAULT_CONFIG;
67
+ }
package/src/index.ts ADDED
@@ -0,0 +1,106 @@
1
+ import type { Plugin } from "@opencode-ai/plugin";
2
+ import { loadConfig } from "./config.ts";
3
+ import { resolveProjectScope, resolveCwdScope, USER_SCOPE, type Scope } from "./scope.ts";
4
+ import { db } from "./store/db.ts";
5
+ import { syncScope, upsertFromFile } from "./store/sync.ts";
6
+ import { scopeHasFiles } from "./store/migrate.ts";
7
+ import {
8
+ writeMemoryFile,
9
+ readMemoryFile,
10
+ ulid,
11
+ type Frontmatter,
12
+ } from "./store/markdown.ts";
13
+ import { buildContextBlock } from "./retrieve/inject.ts";
14
+ import { detectKeywords } from "./capture/keywords.ts";
15
+ import { redact } from "./redact.ts";
16
+ import { makeTools } from "./tools/memory.ts";
17
+
18
+ const plugin: Plugin = async ({ worktree, directory }) => {
19
+ const cfg = loadConfig();
20
+ const roots = worktree || directory || process.cwd();
21
+ const scope: Scope = resolveProjectScope(roots);
22
+
23
+ // Init DB and one-shot sync of markdown -> index on plugin load.
24
+ try {
25
+ db();
26
+ syncScope(scope.key);
27
+ syncScope(USER_SCOPE.key);
28
+ if (cfg.logLevel === "debug") {
29
+ console.log(`[open-memex] loaded. scope=${scope.key}`);
30
+ }
31
+ // If a git remote was added *after* memories were first captured, the
32
+ // scope key changes (cwd-hash -> origin-hash). Detect the legacy dir on
33
+ // disk and tell the user how to migrate. We do NOT auto-migrate: two
34
+ // different repos at the same cwd would collide.
35
+ const cwdScope = resolveCwdScope(roots);
36
+ if (cwdScope.key !== scope.key && scopeHasFiles(cwdScope.key)) {
37
+ console.warn(
38
+ `[open-memex] found memories under legacy scope key "${cwdScope.key}". ` +
39
+ `Current project scope is "${scope.key}". ` +
40
+ `To move them: npm run cli -- migrate --from ${cwdScope.key}`,
41
+ );
42
+ }
43
+ } catch (err) {
44
+ console.error("[open-memex] init failed:", err);
45
+ }
46
+
47
+ const tools = makeTools(() => scope, cfg);
48
+ const injectedSessions = new Set<string>();
49
+
50
+ return {
51
+ tool: tools,
52
+
53
+ async "chat.message"(_input, output) {
54
+ if (!cfg.keywordCaptureEnabled) return;
55
+ const parts = (output.parts ?? []) as Array<{ type: string; text?: string }>;
56
+ const text = parts
57
+ .map((p) => (p?.type === "text" ? p.text ?? "" : ""))
58
+ .filter(Boolean)
59
+ .join("\n");
60
+ if (!text) return;
61
+
62
+ const hits = detectKeywords(text, cfg);
63
+ for (const h of hits) {
64
+ const { content, hadSecret } = redact(h.content, cfg.redactPatterns);
65
+ if (hadSecret || content.length === 0) continue;
66
+ const now = Date.now();
67
+ const fm: Frontmatter = {
68
+ id: ulid(),
69
+ scope_key: scope.key,
70
+ scope_kind: scope.kind,
71
+ project_name: scope.projectName,
72
+ type: "note",
73
+ tags: ["keyword"],
74
+ source: "keyword",
75
+ created_at: now,
76
+ updated_at: now,
77
+ };
78
+ try {
79
+ const { filePath } = writeMemoryFile(fm, content);
80
+ const mf = readMemoryFile(filePath);
81
+ if (mf) upsertFromFile(mf);
82
+ if (cfg.logLevel === "debug") {
83
+ console.log(`[open-memex] captured keyword memory ${fm.id}`);
84
+ }
85
+ } catch (err) {
86
+ console.error("[open-memex] keyword capture failed:", err);
87
+ }
88
+ }
89
+ },
90
+
91
+ async "experimental.chat.system.transform"(input, output) {
92
+ if (!cfg.injectOnFirstTurn) return;
93
+ const sid = input.sessionID ?? "";
94
+ if (injectedSessions.has(sid)) return;
95
+ injectedSessions.add(sid);
96
+ try {
97
+ const block = buildContextBlock(scope, cfg);
98
+ if (block) output.system.push(block);
99
+ } catch (err) {
100
+ console.error("[open-memex] context injection failed:", err);
101
+ }
102
+ },
103
+ };
104
+ };
105
+
106
+ export default plugin;
package/src/paths.ts ADDED
@@ -0,0 +1,45 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+
5
+ function dataRoot(): string {
6
+ if (process.env.MY_O_MEMORY_HOME) return path.resolve(process.env.MY_O_MEMORY_HOME);
7
+ if (process.platform === "win32") {
8
+ const appData = process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming");
9
+ return path.join(appData, "open-memex");
10
+ }
11
+ const xdg = process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share");
12
+ return path.join(xdg, "open-memex");
13
+ }
14
+
15
+ export interface Paths {
16
+ root: string;
17
+ memories: string;
18
+ indexDb: string;
19
+ }
20
+
21
+ let _cached: Paths | null = null;
22
+
23
+ export function paths(): Paths {
24
+ if (_cached) return _cached;
25
+ const root = dataRoot();
26
+ const memories = path.join(root, "memories");
27
+ const indexDb = path.join(root, "index.db");
28
+ fs.mkdirSync(memories, { recursive: true });
29
+ _cached = { root, memories, indexDb };
30
+ return _cached;
31
+ }
32
+
33
+ export function memoriesDirFor(scopeKey: string): string {
34
+ const { memories } = paths();
35
+ const dir = path.join(memories, scopeKey);
36
+ fs.mkdirSync(dir, { recursive: true });
37
+ return dir;
38
+ }
39
+
40
+ /** Same as `memoriesDirFor` but never creates the directory. For read-only
41
+ * callers (iteration, existence checks) that must not pollute storage. */
42
+ export function memoriesDirPath(scopeKey: string): string {
43
+ const { memories } = paths();
44
+ return path.join(memories, scopeKey);
45
+ }
package/src/redact.ts ADDED
@@ -0,0 +1,24 @@
1
+ export function stripPrivate(text: string): string {
2
+ return text.replace(/<private>[\s\S]*?<\/private>/gi, "[REDACTED]");
3
+ }
4
+
5
+ export function findSecret(text: string, patterns: string[]): string | null {
6
+ for (const p of patterns) {
7
+ try {
8
+ const re = new RegExp(p);
9
+ if (re.test(text)) return p;
10
+ } catch {
11
+ // ignore malformed regex
12
+ }
13
+ }
14
+ return null;
15
+ }
16
+
17
+ export function redact(
18
+ text: string,
19
+ patterns: string[],
20
+ ): { content: string; hadSecret: boolean; matchedPattern: string | null } {
21
+ const stripped = stripPrivate(text);
22
+ const matched = findSecret(stripped, patterns);
23
+ return { content: stripped, hadSecret: matched !== null, matchedPattern: matched };
24
+ }
@@ -0,0 +1,35 @@
1
+ import { list } from "./search.ts";
2
+ import type { Scope } from "../scope.ts";
3
+ import { USER_SCOPE } from "../scope.ts";
4
+ import type { MyOMemoryConfig } from "../config.ts";
5
+
6
+ function oneLine(s: string, max = 240): string {
7
+ const trimmed = s.replace(/\s+/g, " ").trim();
8
+ return trimmed.length > max ? trimmed.slice(0, max - 3) + "..." : trimmed;
9
+ }
10
+
11
+ export function buildContextBlock(scope: Scope, cfg: MyOMemoryConfig): string | null {
12
+ const project = list(scope.key, { limit: cfg.maxProjectMemories });
13
+ const user = list(USER_SCOPE.key, { limit: cfg.maxProfileItems });
14
+
15
+ if (project.length === 0 && user.length === 0) return null;
16
+
17
+ const lines: string[] = ["[OPEN-MEMEX]"];
18
+
19
+ if (user.length > 0) {
20
+ lines.push("", "User profile / preferences:");
21
+ for (const m of user) lines.push(`- ${oneLine(m.snippet)}`);
22
+ }
23
+
24
+ if (project.length > 0) {
25
+ lines.push("", `Project knowledge (${scope.projectName}):`);
26
+ for (const m of project) lines.push(`- [${m.type}] ${oneLine(m.snippet)}`);
27
+ }
28
+
29
+ lines.push(
30
+ "",
31
+ "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.",
32
+ );
33
+
34
+ return lines.join("\n");
35
+ }
@@ -0,0 +1,115 @@
1
+ import { db } from "../store/db.ts";
2
+
3
+ export interface SearchHit {
4
+ id: string;
5
+ scope_key: string;
6
+ project_name: string;
7
+ type: string;
8
+ tags: string[];
9
+ snippet: string;
10
+ score: number;
11
+ updated_at: number;
12
+ }
13
+
14
+ /** Convert free-text query into a safe FTS5 MATCH expression. */
15
+ 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 ");
19
+ }
20
+
21
+ export function search(
22
+ query: string,
23
+ opts: { scopeKeys?: string[]; limit?: number; type?: string } = {},
24
+ ): SearchHit[] {
25
+ const q = toFtsQuery(query);
26
+ if (!q) return [];
27
+ const limit = Math.max(1, Math.min(opts.limit ?? 8, 50));
28
+
29
+ const scopeFilter =
30
+ opts.scopeKeys && opts.scopeKeys.length > 0
31
+ ? ` AND m.scope_key IN (${opts.scopeKeys.map(() => "?").join(",")})`
32
+ : "";
33
+ const typeFilter = opts.type ? ` AND m.type = ?` : "";
34
+
35
+ const sql = `
36
+ SELECT m.id, m.scope_key, m.project_name, m.type, m.tags, m.updated_at,
37
+ snippet(memories_fts, 0, '[', ']', ' ... ', 12) AS snippet,
38
+ bm25(memories_fts) AS score
39
+ FROM memories_fts
40
+ JOIN memories m ON m.rowid = memories_fts.rowid
41
+ WHERE memories_fts MATCH ?${scopeFilter}${typeFilter}
42
+ ORDER BY score ASC
43
+ LIMIT ?
44
+ `;
45
+ const params: unknown[] = [q];
46
+ if (opts.scopeKeys && opts.scopeKeys.length > 0) params.push(...opts.scopeKeys);
47
+ if (opts.type) params.push(opts.type);
48
+ params.push(limit);
49
+
50
+ const rows = db()
51
+ .prepare(sql)
52
+ .all(...(params as any[])) as Array<{
53
+ id: string;
54
+ scope_key: string;
55
+ project_name: string;
56
+ type: string;
57
+ tags: string;
58
+ updated_at: number;
59
+ snippet: string;
60
+ score: number;
61
+ }>;
62
+
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
+ }));
73
+ }
74
+
75
+ export function list(
76
+ scopeKey: string,
77
+ opts: { type?: string; limit?: number } = {},
78
+ ): SearchHit[] {
79
+ const limit = Math.max(1, Math.min(opts.limit ?? 20, 100));
80
+ const typeFilter = opts.type ? ` AND type = ?` : "";
81
+ const sql = `
82
+ SELECT id, scope_key, project_name, type, tags, updated_at,
83
+ substr(content, 1, 240) AS snippet
84
+ FROM memories
85
+ WHERE scope_key = ?${typeFilter}
86
+ ORDER BY updated_at DESC
87
+ LIMIT ?
88
+ `;
89
+ const params: unknown[] = [scopeKey];
90
+ if (opts.type) params.push(opts.type);
91
+ params.push(limit);
92
+
93
+ const rows = db()
94
+ .prepare(sql)
95
+ .all(...(params as any[])) as Array<{
96
+ id: string;
97
+ scope_key: string;
98
+ project_name: string;
99
+ type: string;
100
+ tags: string;
101
+ updated_at: number;
102
+ snippet: string;
103
+ }>;
104
+
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
+ }));
115
+ }