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.
package/src/scope.ts ADDED
@@ -0,0 +1,76 @@
1
+ import { execSync } from "node:child_process";
2
+ import crypto from "node:crypto";
3
+ import path from "node:path";
4
+
5
+ export interface Scope {
6
+ key: string;
7
+ kind: "user" | "project";
8
+ projectName: string;
9
+ }
10
+
11
+ export const USER_SCOPE: Scope = { key: "user", kind: "user", projectName: "user" };
12
+
13
+ function normalizeRemote(url: string): string {
14
+ return url
15
+ .trim()
16
+ .replace(/\.git$/i, "")
17
+ .replace(/^git@([^:]+):/, "https://$1/")
18
+ .replace(/^ssh:\/\/git@/, "https://")
19
+ .toLowerCase();
20
+ }
21
+
22
+ function tryGitRemote(cwd: string): string | null {
23
+ try {
24
+ const url = execSync("git config --get remote.origin.url", {
25
+ cwd,
26
+ stdio: ["ignore", "pipe", "ignore"],
27
+ encoding: "utf8",
28
+ }).trim();
29
+ return url || null;
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ function sanitize(name: string): string {
36
+ const cleaned = name.replace(/[^a-zA-Z0-9._-]/g, "-").slice(0, 40);
37
+ return cleaned.length > 0 ? cleaned : "project";
38
+ }
39
+
40
+ function scopeFromSeed(seed: string, projectName: string): Scope {
41
+ const hash = crypto.createHash("sha256").update(seed).digest("hex").slice(0, 12);
42
+ return {
43
+ key: `project__${sanitize(projectName)}__${hash}`,
44
+ kind: "project",
45
+ projectName,
46
+ };
47
+ }
48
+
49
+ /** Scope keyed purely on the absolute cwd. Never consults git. Used both as
50
+ * the fallback for `resolveProjectScope` when no remote is set, and as the
51
+ * "legacy" scope detector for migration when a remote is added later. */
52
+ export function resolveCwdScope(worktree: string): Scope {
53
+ const target = worktree && worktree.length > 0 ? worktree : ".";
54
+ const resolved = path.resolve(target);
55
+ const seed = resolved.toLowerCase();
56
+ // basename can return "" on Windows drive roots (e.g. "C:\\"); walk the path
57
+ // segments and pick the last non-empty, non-drive-letter component.
58
+ const parts = resolved
59
+ .split(/[\\/]+/)
60
+ .filter((p) => p && !/^[A-Za-z]:$/.test(p));
61
+ let projectName = parts[parts.length - 1] ?? path.basename(resolved) ?? "workspace";
62
+ if (!projectName) projectName = "workspace";
63
+ return scopeFromSeed(seed, projectName);
64
+ }
65
+
66
+ export function resolveProjectScope(worktree: string): Scope {
67
+ const target = worktree && worktree.length > 0 ? worktree : ".";
68
+ const remote = tryGitRemote(target);
69
+ if (remote) {
70
+ const norm = normalizeRemote(remote);
71
+ const m = norm.match(/\/([^/]+)$/);
72
+ const projectName = m?.[1] ?? "workspace";
73
+ return scopeFromSeed(norm, projectName);
74
+ }
75
+ return resolveCwdScope(target);
76
+ }
@@ -0,0 +1,98 @@
1
+ import { createRequire } from "node:module";
2
+ import { paths } from "../paths.ts";
3
+
4
+ // Runtime-agnostic SQLite:
5
+ // - Inside opencode (Bun runtime): bun:sqlite (built-in, no native module load)
6
+ // - Under Node CLI: better-sqlite3
7
+ // Both expose the same shape: new Database(path), .exec(sql), .prepare(sql).{run,all,get}, .close()
8
+ const require = createRequire(import.meta.url);
9
+ const isBun = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined";
10
+
11
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
12
+ type AnyDatabase = any;
13
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
14
+ type AnyDatabaseCtor = new (path: string) => AnyDatabase;
15
+
16
+ function loadDatabase(): AnyDatabaseCtor {
17
+ if (isBun) {
18
+ // bun:sqlite is a built-in module; only resolvable under the Bun runtime.
19
+ return require("bun:sqlite").Database as AnyDatabaseCtor;
20
+ }
21
+ return require("better-sqlite3") as AnyDatabaseCtor;
22
+ }
23
+
24
+ let _db: AnyDatabase | null = null;
25
+
26
+ const SCHEMA = `
27
+ CREATE TABLE IF NOT EXISTS memories (
28
+ id TEXT PRIMARY KEY,
29
+ scope_key TEXT NOT NULL,
30
+ scope_kind TEXT NOT NULL,
31
+ project_name TEXT NOT NULL,
32
+ type TEXT NOT NULL DEFAULT 'note',
33
+ tags TEXT NOT NULL DEFAULT '',
34
+ content TEXT NOT NULL,
35
+ source TEXT NOT NULL DEFAULT '',
36
+ file_path TEXT NOT NULL,
37
+ mtime_ms INTEGER NOT NULL,
38
+ created_at INTEGER NOT NULL,
39
+ updated_at INTEGER NOT NULL
40
+ );
41
+
42
+ CREATE INDEX IF NOT EXISTS idx_memories_scope_updated
43
+ ON memories(scope_key, updated_at DESC);
44
+ CREATE INDEX IF NOT EXISTS idx_memories_type ON memories(type);
45
+
46
+ CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
47
+ content,
48
+ tags,
49
+ type,
50
+ scope_key UNINDEXED,
51
+ content='memories',
52
+ content_rowid='rowid',
53
+ tokenize='porter unicode61'
54
+ );
55
+
56
+ CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
57
+ INSERT INTO memories_fts(rowid, content, tags, type, scope_key)
58
+ VALUES (new.rowid, new.content, new.tags, new.type, new.scope_key);
59
+ END;
60
+
61
+ CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
62
+ INSERT INTO memories_fts(memories_fts, rowid, content, tags, type, scope_key)
63
+ VALUES ('delete', old.rowid, old.content, old.tags, old.type, old.scope_key);
64
+ END;
65
+
66
+ CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
67
+ INSERT INTO memories_fts(memories_fts, rowid, content, tags, type, scope_key)
68
+ VALUES ('delete', old.rowid, old.content, old.tags, old.type, old.scope_key);
69
+ INSERT INTO memories_fts(rowid, content, tags, type, scope_key)
70
+ VALUES (new.rowid, new.content, new.tags, new.type, new.scope_key);
71
+ END;
72
+ `;
73
+
74
+ export function db(): AnyDatabase {
75
+ if (_db) return _db;
76
+ const { indexDb } = paths();
77
+ const Database = loadDatabase();
78
+ const d = new Database(indexDb);
79
+ d.exec("PRAGMA journal_mode = WAL;");
80
+ d.exec("PRAGMA synchronous = NORMAL;");
81
+ d.exec("PRAGMA foreign_keys = ON;");
82
+ d.exec(SCHEMA);
83
+ _db = d;
84
+ return d;
85
+ }
86
+
87
+ /** Close the DB. Only used by tests / CLI teardown. */
88
+ export function closeDb(): void {
89
+ if (_db) {
90
+ _db.close();
91
+ _db = null;
92
+ }
93
+ }
94
+
95
+ /** Which backend this process is using. Useful for diagnostics. */
96
+ export function backendName(): "bun:sqlite" | "better-sqlite3" {
97
+ return isBun ? "bun:sqlite" : "better-sqlite3";
98
+ }
@@ -0,0 +1,100 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { randomBytes } from "node:crypto";
4
+ import yaml from "js-yaml";
5
+ import { memoriesDirFor, memoriesDirPath } from "../paths.ts";
6
+
7
+ export interface Frontmatter {
8
+ id: string;
9
+ scope_key: string;
10
+ scope_kind: "user" | "project";
11
+ project_name: string;
12
+ type: string;
13
+ tags: string[];
14
+ source: string;
15
+ created_at: number;
16
+ updated_at: number;
17
+ }
18
+
19
+ export interface MemoryFile {
20
+ fm: Frontmatter;
21
+ body: string;
22
+ filePath: string;
23
+ mtimeMs: number;
24
+ }
25
+
26
+ const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
27
+
28
+ /** ULID-ish: 10 chars of base32 timestamp + 16 chars of base32 randomness. */
29
+ export function ulid(): string {
30
+ let ts = "";
31
+ let n = Date.now();
32
+ for (let i = 0; i < 10; i++) {
33
+ ts = CROCKFORD[n % 32]! + ts;
34
+ n = Math.floor(n / 32);
35
+ }
36
+ const bytes = randomBytes(16);
37
+ let rand = "";
38
+ for (const b of bytes) rand += CROCKFORD[b % 32]!;
39
+ return ts + rand;
40
+ }
41
+
42
+ export function serialize(fm: Frontmatter, body: string): string {
43
+ const yml = yaml.dump(fm, { lineWidth: -1, quotingType: '"' });
44
+ return `---\n${yml}---\n\n${body.trimEnd()}\n`;
45
+ }
46
+
47
+ export function parse(raw: string): { fm: Frontmatter; body: string } | null {
48
+ const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
49
+ if (!m) return null;
50
+ try {
51
+ const fm = yaml.load(m[1]!) as Frontmatter;
52
+ if (!fm || typeof fm !== "object" || !fm.id) return null;
53
+ // Normalize
54
+ fm.tags = Array.isArray(fm.tags) ? fm.tags : [];
55
+ fm.type = fm.type ?? "note";
56
+ fm.source = fm.source ?? "";
57
+ const body = (m[2] ?? "").replace(/^\n+/, "");
58
+ return { fm, body };
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ export function writeMemoryFile(
65
+ fm: Frontmatter,
66
+ body: string,
67
+ ): { filePath: string; mtimeMs: number } {
68
+ const dir = memoriesDirFor(fm.scope_key);
69
+ const filePath = path.join(dir, `${fm.id}.md`);
70
+ fs.writeFileSync(filePath, serialize(fm, body), "utf8");
71
+ const st = fs.statSync(filePath);
72
+ return { filePath, mtimeMs: st.mtimeMs };
73
+ }
74
+
75
+ export function deleteMemoryFile(scopeKey: string, id: string): boolean {
76
+ const dir = memoriesDirPath(scopeKey);
77
+ const filePath = path.join(dir, `${id}.md`);
78
+ if (fs.existsSync(filePath)) {
79
+ fs.unlinkSync(filePath);
80
+ return true;
81
+ }
82
+ return false;
83
+ }
84
+
85
+ export function readMemoryFile(filePath: string): MemoryFile | null {
86
+ if (!fs.existsSync(filePath)) return null;
87
+ const raw = fs.readFileSync(filePath, "utf8");
88
+ const parsed = parse(raw);
89
+ if (!parsed) return null;
90
+ const st = fs.statSync(filePath);
91
+ return { fm: parsed.fm, body: parsed.body, filePath, mtimeMs: st.mtimeMs };
92
+ }
93
+
94
+ export function* iterMemoryFiles(scopeKey: string): Generator<string> {
95
+ const dir = memoriesDirPath(scopeKey);
96
+ if (!fs.existsSync(dir)) return;
97
+ for (const name of fs.readdirSync(dir)) {
98
+ if (name.endsWith(".md")) yield path.join(dir, name);
99
+ }
100
+ }
@@ -0,0 +1,141 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { memoriesDirFor, memoriesDirPath } from "../paths.ts";
4
+ import { db } from "./db.ts";
5
+ import {
6
+ iterMemoryFiles,
7
+ readMemoryFile,
8
+ serialize,
9
+ type Frontmatter,
10
+ } from "./markdown.ts";
11
+ import { syncScope } from "./sync.ts";
12
+
13
+ export type ConflictStrategy = "newer" | "overwrite" | "skip";
14
+
15
+ export interface MigrateOptions {
16
+ /** New `project_name` to stamp into each moved memory's frontmatter. If
17
+ * omitted, keeps the original file's project_name. */
18
+ toProjectName?: string;
19
+ dryRun?: boolean;
20
+ /** How to resolve id collisions in the destination scope. Default: "newer". */
21
+ onConflict?: ConflictStrategy;
22
+ logger?: (msg: string) => void;
23
+ }
24
+
25
+ export interface MigrateStats {
26
+ moved: number;
27
+ skipped: number;
28
+ conflicts: number;
29
+ dryRun: boolean;
30
+ }
31
+
32
+ /** True if this scope has at least one `.md` file on disk. Does not create
33
+ * the directory if it doesn't already exist. */
34
+ export function scopeHasFiles(scopeKey: string): boolean {
35
+ const dir = memoriesDirPath(scopeKey);
36
+ if (!fs.existsSync(dir)) return false;
37
+ return fs.readdirSync(dir).some((f) => f.endsWith(".md"));
38
+ }
39
+
40
+ /**
41
+ * Move all memories from `fromKey` to `toKey`. Rewrites each file's
42
+ * `scope_key` (and optionally `project_name`) frontmatter. On id collision
43
+ * in the destination, `onConflict` decides:
44
+ * - "newer" (default): keep the file with the greater `updated_at`
45
+ * - "overwrite": always take the source
46
+ * - "skip": always keep the destination
47
+ *
48
+ * After moving files, clears any lingering FTS rows for `fromKey` and
49
+ * re-runs `syncScope(toKey)` so the index reflects reality. Removes the
50
+ * empty source directory when possible.
51
+ */
52
+ export function migrateScope(
53
+ fromKey: string,
54
+ toKey: string,
55
+ opts: MigrateOptions = {},
56
+ ): MigrateStats {
57
+ const log = opts.logger ?? (() => {});
58
+ const strategy: ConflictStrategy = opts.onConflict ?? "newer";
59
+ const dryRun = !!opts.dryRun;
60
+ const stats: MigrateStats = { moved: 0, skipped: 0, conflicts: 0, dryRun };
61
+
62
+ if (fromKey === toKey) {
63
+ throw new Error("migrate: --from and --to are identical");
64
+ }
65
+
66
+ const fromDir = memoriesDirPath(fromKey);
67
+ const toDir = memoriesDirFor(toKey);
68
+ if (!fs.existsSync(fromDir)) {
69
+ log(`no source directory: ${fromDir}`);
70
+ return stats;
71
+ }
72
+
73
+ for (const srcPath of iterMemoryFiles(fromKey)) {
74
+ const mf = readMemoryFile(srcPath);
75
+ if (!mf) {
76
+ log(`skip unparseable: ${srcPath}`);
77
+ stats.skipped++;
78
+ continue;
79
+ }
80
+ const dstPath = path.join(toDir, path.basename(srcPath));
81
+
82
+ let action: "write" | "skip" = "write";
83
+ if (fs.existsSync(dstPath)) {
84
+ stats.conflicts++;
85
+ const dst = readMemoryFile(dstPath);
86
+ if (!dst) {
87
+ log(`conflict but destination unreadable, overwriting: ${dstPath}`);
88
+ } else if (strategy === "skip") {
89
+ action = "skip";
90
+ log(`conflict: kept destination (--on-conflict=skip): ${dstPath}`);
91
+ } else if (strategy === "overwrite") {
92
+ log(`conflict: overwriting destination (--on-conflict=overwrite): ${dstPath}`);
93
+ } else if (dst.fm.updated_at >= mf.fm.updated_at) {
94
+ action = "skip";
95
+ log(`conflict: kept destination (newer updated_at): ${dstPath}`);
96
+ } else {
97
+ log(`conflict: replaced destination (source newer): ${dstPath}`);
98
+ }
99
+ }
100
+
101
+ if (action === "skip") {
102
+ stats.skipped++;
103
+ continue;
104
+ }
105
+
106
+ const newFm: Frontmatter = {
107
+ ...mf.fm,
108
+ scope_key: toKey,
109
+ project_name: opts.toProjectName ?? mf.fm.project_name,
110
+ };
111
+ const raw = serialize(newFm, mf.body);
112
+ if (!dryRun) {
113
+ fs.writeFileSync(dstPath, raw, "utf8");
114
+ // Only unlink source if it's a different path (paths differ because
115
+ // scope dir differs; belt-and-suspenders check).
116
+ if (path.resolve(srcPath) !== path.resolve(dstPath)) {
117
+ fs.unlinkSync(srcPath);
118
+ }
119
+ }
120
+ log(`${dryRun ? "would move" : "moved"}: ${srcPath} -> ${dstPath}`);
121
+ stats.moved++;
122
+ }
123
+
124
+ if (!dryRun) {
125
+ // Best-effort: remove now-empty source dir.
126
+ try {
127
+ if (fs.existsSync(fromDir) && fs.readdirSync(fromDir).length === 0) {
128
+ fs.rmdirSync(fromDir);
129
+ }
130
+ } catch {
131
+ /* ignore */
132
+ }
133
+ // Drop any remaining FTS/memories rows for the old scope, then rebuild
134
+ // the destination index from disk. `syncScope` also handles removals for
135
+ // the destination scope if a conflict-skip left stale rows behind.
136
+ db().prepare(`DELETE FROM memories WHERE scope_key = ?`).run(fromKey);
137
+ syncScope(toKey);
138
+ }
139
+
140
+ return stats;
141
+ }
@@ -0,0 +1,83 @@
1
+ import fs from "node:fs";
2
+ import { db } from "./db.ts";
3
+ import { iterMemoryFiles, readMemoryFile, type MemoryFile } from "./markdown.ts";
4
+
5
+ const UPSERT_SQL = `
6
+ INSERT INTO memories (id, scope_key, scope_kind, project_name, type, tags, content, source, file_path, mtime_ms, created_at, updated_at)
7
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
8
+ ON CONFLICT(id) DO UPDATE SET
9
+ scope_key = excluded.scope_key,
10
+ scope_kind = excluded.scope_kind,
11
+ project_name = excluded.project_name,
12
+ type = excluded.type,
13
+ tags = excluded.tags,
14
+ content = excluded.content,
15
+ source = excluded.source,
16
+ file_path = excluded.file_path,
17
+ mtime_ms = excluded.mtime_ms,
18
+ updated_at = excluded.updated_at
19
+ `;
20
+
21
+ export function upsertFromFile(mf: MemoryFile): void {
22
+ const { fm, body, filePath, mtimeMs } = mf;
23
+ db().prepare(UPSERT_SQL).run(
24
+ fm.id,
25
+ fm.scope_key,
26
+ fm.scope_kind,
27
+ fm.project_name,
28
+ fm.type,
29
+ (fm.tags ?? []).join(","),
30
+ body,
31
+ fm.source ?? "",
32
+ filePath,
33
+ mtimeMs,
34
+ fm.created_at,
35
+ fm.updated_at,
36
+ );
37
+ }
38
+
39
+ export function deleteFromIndex(id: string): void {
40
+ db().prepare(`DELETE FROM memories WHERE id = ?`).run(id);
41
+ }
42
+
43
+ export interface SyncStats {
44
+ added: number;
45
+ updated: number;
46
+ removed: number;
47
+ scanned: number;
48
+ }
49
+
50
+ export function syncScope(scopeKey: string): SyncStats {
51
+ const d = db();
52
+ const stats: SyncStats = { added: 0, updated: 0, removed: 0, scanned: 0 };
53
+
54
+ const existing = d
55
+ .prepare(`SELECT id, file_path, mtime_ms FROM memories WHERE scope_key = ?`)
56
+ .all(scopeKey) as Array<{ id: string; file_path: string; mtime_ms: number }>;
57
+ const existingById = new Map(existing.map((r) => [r.id, r]));
58
+ const seen = new Set<string>();
59
+
60
+ for (const fp of iterMemoryFiles(scopeKey)) {
61
+ const mf = readMemoryFile(fp);
62
+ if (!mf) continue;
63
+ stats.scanned++;
64
+ seen.add(mf.fm.id);
65
+ const prev = existingById.get(mf.fm.id);
66
+ if (!prev) {
67
+ upsertFromFile(mf);
68
+ stats.added++;
69
+ } else if (prev.mtime_ms !== mf.mtimeMs || prev.file_path !== mf.filePath) {
70
+ upsertFromFile(mf);
71
+ stats.updated++;
72
+ }
73
+ }
74
+
75
+ for (const row of existing) {
76
+ if (!seen.has(row.id) || !fs.existsSync(row.file_path)) {
77
+ deleteFromIndex(row.id);
78
+ stats.removed++;
79
+ }
80
+ }
81
+
82
+ return stats;
83
+ }
@@ -0,0 +1,174 @@
1
+ import { tool } from "@opencode-ai/plugin/tool";
2
+ import type { Scope } from "../scope.ts";
3
+ import type { MyOMemoryConfig } from "../config.ts";
4
+ import { USER_SCOPE } from "../scope.ts";
5
+ import { search, list } from "../retrieve/search.ts";
6
+ import {
7
+ writeMemoryFile,
8
+ deleteMemoryFile,
9
+ readMemoryFile,
10
+ ulid,
11
+ type Frontmatter,
12
+ } from "../store/markdown.ts";
13
+ import { upsertFromFile, deleteFromIndex } from "../store/sync.ts";
14
+ import { db } from "../store/db.ts";
15
+ import { redact } from "../redact.ts";
16
+
17
+ const z = tool.schema;
18
+
19
+ const MEMORY_TYPES = [
20
+ "note",
21
+ "preference",
22
+ "project-config",
23
+ "architecture",
24
+ "error-solution",
25
+ "learned-pattern",
26
+ "conversation",
27
+ ] as const;
28
+
29
+ export function makeTools(getScope: () => Scope, cfg: MyOMemoryConfig) {
30
+ const scopeArg = z
31
+ .enum(["project", "user"])
32
+ .optional()
33
+ .describe(
34
+ "Memory scope. `project` = tied to this repo. `user` = global across all your projects. Default: project.",
35
+ );
36
+
37
+ function resolveScope(kind?: "project" | "user"): Scope {
38
+ return kind === "user" ? USER_SCOPE : getScope();
39
+ }
40
+
41
+ const memory_add = tool({
42
+ description:
43
+ "Save a fact, preference, decision, or note to persistent local memory. Call this whenever the user tells you something you should remember in future sessions (project conventions, tool choices, personal preferences, error fixes). Keep each memory to one self-contained statement.",
44
+ args: {
45
+ content: z.string().min(1).describe("The fact to remember. One idea per memory."),
46
+ type: z
47
+ .enum(MEMORY_TYPES)
48
+ .optional()
49
+ .describe("Category of memory. Default: note."),
50
+ scope: scopeArg,
51
+ tags: z.array(z.string()).optional().describe("Optional tags for filtering."),
52
+ },
53
+ async execute(args) {
54
+ const { content: redacted, hadSecret, matchedPattern } = redact(
55
+ args.content,
56
+ cfg.redactPatterns,
57
+ );
58
+ if (hadSecret) {
59
+ return {
60
+ title: "memory: rejected (secret detected)",
61
+ output: `Refused to save: content matched a secret pattern (${matchedPattern}). Wrap the sensitive part in <private>...</private> tags or paraphrase, then try again.`,
62
+ };
63
+ }
64
+ const s = resolveScope(args.scope);
65
+ const now = Date.now();
66
+ const fm: Frontmatter = {
67
+ id: ulid(),
68
+ scope_key: s.key,
69
+ scope_kind: s.kind,
70
+ project_name: s.projectName,
71
+ type: args.type ?? "note",
72
+ tags: args.tags ?? [],
73
+ source: "tool",
74
+ created_at: now,
75
+ updated_at: now,
76
+ };
77
+ const { filePath } = writeMemoryFile(fm, redacted);
78
+ const mf = readMemoryFile(filePath);
79
+ if (mf) upsertFromFile(mf);
80
+ return {
81
+ title: `memory: saved ${fm.id}`,
82
+ output: `Saved to ${s.key} as ${fm.type}. id=${fm.id}`,
83
+ };
84
+ },
85
+ });
86
+
87
+ const memory_search = tool({
88
+ description:
89
+ "Search persistent memory by keyword (BM25 full-text). Returns matching memories from the current project and/or user scope. Use before asking the user something they may have told you before.",
90
+ args: {
91
+ query: z
92
+ .string()
93
+ .min(1)
94
+ .describe("Free-text query. File paths, error strings, identifiers work well."),
95
+ scope: z
96
+ .enum(["project", "user", "both"])
97
+ .optional()
98
+ .describe("Which scope(s) to search. Default: both."),
99
+ type: z.string().optional().describe("Restrict to memories of this type."),
100
+ limit: z.number().int().min(1).max(50).optional(),
101
+ },
102
+ async execute(args) {
103
+ const project = getScope();
104
+ const scopeKeys =
105
+ args.scope === "user"
106
+ ? [USER_SCOPE.key]
107
+ : args.scope === "project"
108
+ ? [project.key]
109
+ : [project.key, USER_SCOPE.key];
110
+ const hits = search(args.query, {
111
+ scopeKeys,
112
+ limit: args.limit,
113
+ type: args.type,
114
+ });
115
+ if (hits.length === 0) {
116
+ return {
117
+ title: "memory: 0 results",
118
+ output: `No memories matched "${args.query}".`,
119
+ };
120
+ }
121
+ const lines = hits.map(
122
+ (h) =>
123
+ `- [${h.scope_key === USER_SCOPE.key ? "user" : "project"}/${h.type}] id=${h.id}\n ${h.snippet.replace(/\s+/g, " ").trim()}`,
124
+ );
125
+ return {
126
+ title: `memory: ${hits.length} result(s)`,
127
+ output: lines.join("\n"),
128
+ };
129
+ },
130
+ });
131
+
132
+ const memory_list = tool({
133
+ description: "List memories in a scope, newest first. Useful for browsing.",
134
+ args: {
135
+ scope: scopeArg,
136
+ type: z.string().optional(),
137
+ limit: z.number().int().min(1).max(100).optional(),
138
+ },
139
+ async execute(args) {
140
+ const s = resolveScope(args.scope);
141
+ const hits = list(s.key, { type: args.type, limit: args.limit });
142
+ if (hits.length === 0) {
143
+ return { title: "memory: empty", output: `No memories in scope ${s.key}.` };
144
+ }
145
+ const lines = hits.map(
146
+ (h) => `- [${h.type}] id=${h.id} — ${h.snippet.replace(/\s+/g, " ").trim()}`,
147
+ );
148
+ return {
149
+ title: `memory: ${hits.length} in ${s.key}`,
150
+ output: lines.join("\n"),
151
+ };
152
+ },
153
+ });
154
+
155
+ const memory_forget = tool({
156
+ description: "Delete a memory by id. Use when the user asks to forget something.",
157
+ args: {
158
+ id: z.string().min(1),
159
+ },
160
+ async execute(args) {
161
+ const row = db()
162
+ .prepare(`SELECT scope_key FROM memories WHERE id = ?`)
163
+ .get(args.id) as { scope_key: string } | undefined;
164
+ if (!row) {
165
+ return { title: "memory: not found", output: `No memory with id ${args.id}.` };
166
+ }
167
+ deleteMemoryFile(row.scope_key, args.id);
168
+ deleteFromIndex(args.id);
169
+ return { title: "memory: forgotten", output: `Deleted ${args.id}.` };
170
+ },
171
+ });
172
+
173
+ return { memory_add, memory_search, memory_list, memory_forget };
174
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "types": ["node"],
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "skipLibCheck": true,
10
+ "esModuleInterop": true,
11
+ "resolveJsonModule": true,
12
+ "allowImportingTsExtensions": true,
13
+ "verbatimModuleSyntax": false,
14
+ "forceConsistentCasingInFileNames": true
15
+ },
16
+ "include": ["src/**/*.ts"]
17
+ }