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/AGENTS.md +73 -0
- package/LICENSE +198 -0
- package/PLAN.md +168 -0
- package/README.md +95 -0
- package/package.json +31 -0
- package/scripts/smoke-pure.ts +91 -0
- package/src/capture/keywords.ts +34 -0
- package/src/cli.ts +264 -0
- package/src/config.ts +67 -0
- package/src/index.ts +106 -0
- package/src/paths.ts +45 -0
- package/src/redact.ts +24 -0
- package/src/retrieve/inject.ts +35 -0
- package/src/retrieve/search.ts +115 -0
- package/src/scope.ts +76 -0
- package/src/store/db.ts +98 -0
- package/src/store/markdown.ts +100 -0
- package/src/store/migrate.ts +141 -0
- package/src/store/sync.ts +83 -0
- package/src/tools/memory.ts +174 -0
- package/tsconfig.json +17 -0
|
@@ -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
|
+
}
|