open-memex 0.1.0 → 0.2.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 +31 -5
- package/CONTRIBUTING.md +31 -0
- package/README.md +17 -7
- package/docs/SCOPES.md +81 -0
- package/docs/V2-DESIGN.md +484 -0
- package/package.json +1 -1
- package/scripts/smoke-pure.ts +209 -9
- package/src/cli.ts +138 -22
- package/src/config.ts +3 -10
- package/src/index.ts +18 -6
- package/src/redact.ts +151 -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 +88 -27
package/scripts/smoke-pure.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
// Quick smoke test — runs the pure-logic modules (no bun:sqlite dependency).
|
|
2
2
|
// Usage: node --experimental-strip-types scripts\smoke-pure.ts
|
|
3
|
-
import { parse, serialize, ulid, type Frontmatter } from "../src/store/markdown.ts";
|
|
3
|
+
import { parse, serialize, ulid, normalizeFrontmatter, msToRfc3339, timeToMs, parseRawFrontmatter, type Frontmatter } from "../src/store/markdown.ts";
|
|
4
|
+
import { planConversion, isV2File } from "../src/store/v2migrate.ts";
|
|
4
5
|
import { redact, findSecret } from "../src/redact.ts";
|
|
5
6
|
import { detectKeywords } from "../src/capture/keywords.ts";
|
|
6
7
|
import { DEFAULT_CONFIG } from "../src/config.ts";
|
|
7
|
-
import { resolveProjectScope, resolveCwdScope,
|
|
8
|
+
import { resolveProjectScope, resolveCwdScope, PERSONAL_SCOPE } from "../src/scope.ts";
|
|
9
|
+
import { cjkIndexText, cjkQueryExpr, hasCjk } from "../src/retrieve/cjk.ts";
|
|
10
|
+
import { contentHash, similarity, NEAR_DUP_THRESHOLD } from "../src/store/lifecycle.ts";
|
|
8
11
|
|
|
9
12
|
let fails = 0;
|
|
10
13
|
function ok(name: string, cond: boolean, info?: unknown) {
|
|
@@ -20,19 +23,28 @@ const a = ulid();
|
|
|
20
23
|
const b = ulid();
|
|
21
24
|
ok("length is 26", a.length === 26);
|
|
22
25
|
ok("two calls differ", a !== b);
|
|
23
|
-
|
|
26
|
+
// NOTE: same-ms monotonicity is NOT guaranteed — the 16-char suffix is
|
|
27
|
+
// random by design (timestamp prefix still gives rough ordering).
|
|
28
|
+
ok("time prefix is 10 chars", a.slice(0, 10).length === 10);
|
|
24
29
|
|
|
25
|
-
console.log("== markdown round-trip ==");
|
|
30
|
+
console.log("== markdown round-trip (v2) ==");
|
|
26
31
|
const fm: Frontmatter = {
|
|
27
32
|
id: a,
|
|
33
|
+
schema_version: 2,
|
|
28
34
|
scope_key: "project__test__abcdef123456",
|
|
29
|
-
|
|
35
|
+
scope: "project",
|
|
36
|
+
visibility: "internal",
|
|
30
37
|
project_name: "test",
|
|
31
|
-
type: "
|
|
38
|
+
type: "fact",
|
|
39
|
+
role: "knowledge",
|
|
40
|
+
importance: "normal",
|
|
41
|
+
status: "active",
|
|
32
42
|
tags: ["hello", "world"],
|
|
33
43
|
source: "test",
|
|
34
|
-
created_at: 1000,
|
|
35
|
-
updated_at: 2000,
|
|
44
|
+
created_at: msToRfc3339(1000),
|
|
45
|
+
updated_at: msToRfc3339(2000),
|
|
46
|
+
supersedes: null,
|
|
47
|
+
superseded_by: null,
|
|
36
48
|
};
|
|
37
49
|
const body = "hello\n\nworld";
|
|
38
50
|
const raw = serialize(fm, body);
|
|
@@ -54,6 +66,82 @@ ok("bare secret detected", r2.hadSecret === true);
|
|
|
54
66
|
const r3 = redact("ghp_" + "a".repeat(36), DEFAULT_CONFIG.redactPatterns);
|
|
55
67
|
ok("github PAT detected", r3.hadSecret === true);
|
|
56
68
|
|
|
69
|
+
console.log("== redact hardening ==");
|
|
70
|
+
import {
|
|
71
|
+
BUILTIN_SECRET_PATTERNS,
|
|
72
|
+
findBuiltinSecret,
|
|
73
|
+
findHighEntropySecret,
|
|
74
|
+
stripPrivate,
|
|
75
|
+
} from "../src/redact.ts";
|
|
76
|
+
|
|
77
|
+
// Every builtin pattern fires on a realistic sample, reported by id.
|
|
78
|
+
const builtinSamples: Array<[string, string]> = [
|
|
79
|
+
["openai-key", "key=sk-1234567890abcdefghij1234"],
|
|
80
|
+
["github-oauth-token", "tok=gho_12345678901234567890123456789012"],
|
|
81
|
+
["github-fine-grained-pat", "tok=github_pat_1234567890123456789012345678901234567890"],
|
|
82
|
+
["aws-access-key-id", "id=AKIA1234567890ABCDEF"],
|
|
83
|
+
["slack-token", "tok=xoxb-123456789012-abcdefghij"],
|
|
84
|
+
["google-api-key", "k=AIza12345678901234567890123456789012"],
|
|
85
|
+
["npm-token", "t=npm_12345678901234567890123456789012"],
|
|
86
|
+
["gitlab-pat", "t=glpat-12345678901234567890ab"],
|
|
87
|
+
["stripe-webhook-secret", "s=whsec_12345678901234567890"],
|
|
88
|
+
["private-key-block", "-----BEGIN RSA PRIVATE KEY-----"],
|
|
89
|
+
["generic-secret-assignment", 'password = "hunter2hunter2hunter2"'],
|
|
90
|
+
];
|
|
91
|
+
for (const [id, sample] of builtinSamples) {
|
|
92
|
+
const r = redact(`note: ${sample} end`, []);
|
|
93
|
+
ok(`builtin ${id} detected`, r.hadSecret === true && r.matchedPattern === id, r.matchedPattern);
|
|
94
|
+
}
|
|
95
|
+
// All builtin regexes compile.
|
|
96
|
+
ok(
|
|
97
|
+
"all builtins compile",
|
|
98
|
+
BUILTIN_SECRET_PATTERNS.every((p) => {
|
|
99
|
+
try {
|
|
100
|
+
new RegExp(p.source, p.flags ?? "");
|
|
101
|
+
return true;
|
|
102
|
+
} catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
// Non-secrets pass.
|
|
108
|
+
const benign = [
|
|
109
|
+
"remember that the meeting is at 3pm tomorrow",
|
|
110
|
+
"the api endpoint is https://api.example.com/v1/users/list",
|
|
111
|
+
"commit = a3f5c81234abcd1234abcd1234abcd1234abcd12",
|
|
112
|
+
"version = 1.2.3-alpha.4",
|
|
113
|
+
"note = aaaaaaaaaaaaaaaaaaaaaaaa",
|
|
114
|
+
"token: abc",
|
|
115
|
+
// sk- inside ordinary words must not trip the openai-key pattern
|
|
116
|
+
// (regression: these were refused as "openai-key" before the boundary guard).
|
|
117
|
+
"the task-management-system-workflow-2024 document is here",
|
|
118
|
+
"disk-encryption-key-management-system-v2 rollout notes",
|
|
119
|
+
"risk-assessment-score-2024-10 update",
|
|
120
|
+
];
|
|
121
|
+
for (const b of benign) {
|
|
122
|
+
const r = redact(b, []);
|
|
123
|
+
ok(`benign passes: ${b.slice(0, 40)}`, r.hadSecret === false, r.matchedPattern);
|
|
124
|
+
}
|
|
125
|
+
// High-entropy heuristic.
|
|
126
|
+
const entropic = findHighEntropySecret('deploy_key = "aB3dE5fG7hJ9kL2mN4pQ6rS8tU0vW2xY4zA6bC8dE0"');
|
|
127
|
+
ok("high-entropy assignment caught", entropic === "high-entropy-secret:deploy_key", entropic);
|
|
128
|
+
ok("low-entropy passes", findHighEntropySecret('note = "aaaaaaaaaaaaaaaaaaaaaaaa"') === null);
|
|
129
|
+
ok("hex digest passes", findHighEntropySecret("commit = a3f5c81234abcd1234abcd1234abcd1234abcd12") === null);
|
|
130
|
+
// <private> handling.
|
|
131
|
+
ok(
|
|
132
|
+
"unclosed private redacts to end",
|
|
133
|
+
stripPrivate("hello <private>my secret") === "hello [REDACTED]",
|
|
134
|
+
);
|
|
135
|
+
ok(
|
|
136
|
+
"closed private still stripped",
|
|
137
|
+
stripPrivate("a <private>x</private> b") === "a [REDACTED] b",
|
|
138
|
+
);
|
|
139
|
+
const r4 = redact("token is <private>sk-1234567890abcdefghij1234</private> ok", []);
|
|
140
|
+
ok("wrapped secret does not trigger", r4.hadSecret === false);
|
|
141
|
+
// User patterns still work, reported verbatim.
|
|
142
|
+
const r5 = redact("foo CUSTOM123 bar", ["CUSTOM\\d+"]);
|
|
143
|
+
ok("user pattern detected", r5.hadSecret === true && r5.matchedPattern === "CUSTOM\\d+", r5.matchedPattern);
|
|
144
|
+
|
|
57
145
|
console.log("== keywords ==");
|
|
58
146
|
const hits1 = detectKeywords("remember that this repo uses Bun, not Node", DEFAULT_CONFIG);
|
|
59
147
|
ok("basic remember matched", hits1.length === 1);
|
|
@@ -74,7 +162,9 @@ ok("kind=project", s.kind === "project");
|
|
|
74
162
|
ok("key starts with project__", s.key.startsWith("project__"));
|
|
75
163
|
ok("hash length 12", /__[a-f0-9]{12}$/.test(s.key));
|
|
76
164
|
console.log(" scope.key =", s.key);
|
|
77
|
-
console.log("
|
|
165
|
+
console.log(" personal.key =", PERSONAL_SCOPE.key);
|
|
166
|
+
ok("personal scope key", PERSONAL_SCOPE.key === "personal");
|
|
167
|
+
ok("personal scope kind", PERSONAL_SCOPE.kind === "personal");
|
|
78
168
|
|
|
79
169
|
console.log("== scope (cwd-only legacy) ==");
|
|
80
170
|
const cwdOnly = resolveCwdScope(process.cwd());
|
|
@@ -87,5 +177,115 @@ ok("cwd scope deterministic", cwdOnly.key === cwdOnly2.key);
|
|
|
87
177
|
// (This repo does have an origin after `git push`.)
|
|
88
178
|
console.log(" cwd.key =", cwdOnly.key);
|
|
89
179
|
|
|
180
|
+
console.log("== cjk ==");
|
|
181
|
+
ok("detects han", hasCjk("中文记忆"));
|
|
182
|
+
ok("detects mixed", hasCjk("open中文"));
|
|
183
|
+
ok("no cjk in latin", !hasCjk("hello world 123"));
|
|
184
|
+
ok(
|
|
185
|
+
"index bigrams",
|
|
186
|
+
cjkIndexText("中文记忆") === "中 文 记 忆 中文 文记 记忆",
|
|
187
|
+
cjkIndexText("中文记忆"),
|
|
188
|
+
);
|
|
189
|
+
ok("index single char", cjkIndexText("猫") === "猫", cjkIndexText("猫"));
|
|
190
|
+
ok(
|
|
191
|
+
"index skips latin",
|
|
192
|
+
cjkIndexText("open中文") === "中 文 中文",
|
|
193
|
+
cjkIndexText("open中文"),
|
|
194
|
+
);
|
|
195
|
+
ok("index empty", cjkIndexText("hello") === "");
|
|
196
|
+
ok(
|
|
197
|
+
"query bigrams",
|
|
198
|
+
cjkQueryExpr("中文记忆") === '"中文" OR "文记" OR "记忆"',
|
|
199
|
+
cjkQueryExpr("中文记忆"),
|
|
200
|
+
);
|
|
201
|
+
ok("query single char", cjkQueryExpr("猫") === '"猫"', cjkQueryExpr("猫"));
|
|
202
|
+
ok("query no cjk", cjkQueryExpr("hello") === "");
|
|
203
|
+
|
|
204
|
+
console.log("== v2 times ==");
|
|
205
|
+
ok("epoch 0 → rfc3339", msToRfc3339(0) === "1970-01-01T00:00:00Z", msToRfc3339(0));
|
|
206
|
+
ok("rfc3339 round-trip", timeToMs(msToRfc3339(1758854400000)) === 1758854400000);
|
|
207
|
+
ok("timeToMs accepts epoch", timeToMs(1000) === 1000);
|
|
208
|
+
ok("timeToMs accepts rfc3339", timeToMs("2026-09-26T12:00:00Z") === Date.parse("2026-09-26T12:00:00Z"));
|
|
209
|
+
|
|
210
|
+
console.log("== v1 → v2 normalize ==");
|
|
211
|
+
const v1raw = {
|
|
212
|
+
id: "01TESTV1",
|
|
213
|
+
scope_key: "user",
|
|
214
|
+
scope_kind: "user",
|
|
215
|
+
project_name: "user",
|
|
216
|
+
type: "instruction",
|
|
217
|
+
priority: 9,
|
|
218
|
+
tags: ["x"],
|
|
219
|
+
source: "test",
|
|
220
|
+
created_at: 1758854400000,
|
|
221
|
+
updated_at: 1758854400000,
|
|
222
|
+
} as Record<string, unknown>;
|
|
223
|
+
const nfm = normalizeFrontmatter(v1raw);
|
|
224
|
+
ok("scope user→personal", nfm.scope === "personal", nfm.scope);
|
|
225
|
+
ok("scope_key user→personal", nfm.scope_key === "personal", nfm.scope_key);
|
|
226
|
+
ok("visibility private", nfm.visibility === "private", nfm.visibility);
|
|
227
|
+
ok("type instruction→knowledge", nfm.type === "knowledge", nfm.type);
|
|
228
|
+
ok("role →instruction", nfm.role === "instruction", nfm.role);
|
|
229
|
+
ok("priority 9→high", nfm.importance === "high", nfm.importance);
|
|
230
|
+
ok("epoch→rfc3339", nfm.created_at === "2025-09-26T02:40:00Z", nfm.created_at);
|
|
231
|
+
ok("status active", nfm.status === "active");
|
|
232
|
+
ok("schema_version 2", nfm.schema_version === 2);
|
|
233
|
+
|
|
234
|
+
const v1raw2 = {
|
|
235
|
+
id: "01TESTV2",
|
|
236
|
+
scope_key: "project__x__abcdef123456",
|
|
237
|
+
scope_kind: "project",
|
|
238
|
+
project_name: "x",
|
|
239
|
+
type: "error-solution",
|
|
240
|
+
priority: 2,
|
|
241
|
+
tags: [],
|
|
242
|
+
created_at: 1000,
|
|
243
|
+
updated_at: 1000,
|
|
244
|
+
} as Record<string, unknown>;
|
|
245
|
+
const nfm2 = normalizeFrontmatter(v1raw2);
|
|
246
|
+
ok("project scope kept", nfm2.scope === "project");
|
|
247
|
+
ok("project visibility internal", nfm2.visibility === "internal");
|
|
248
|
+
ok("priority 2→low", nfm2.importance === "low");
|
|
249
|
+
ok("role default knowledge", nfm2.role === "knowledge");
|
|
250
|
+
|
|
251
|
+
console.log("== v1 → v2 planConversion ==");
|
|
252
|
+
import fs from "node:fs";
|
|
253
|
+
import path from "node:path";
|
|
254
|
+
import os from "node:os";
|
|
255
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "v2mig-"));
|
|
256
|
+
const memRoot = path.join(tmpRoot, "memories");
|
|
257
|
+
fs.mkdirSync(path.join(memRoot, "user"), { recursive: true });
|
|
258
|
+
const v1file = `---\nid: 01PLANTEST\nscope_key: user\nscope_kind: user\nproject_name: user\ntype: note\npriority: 5\ntags: []\nsource: test\ncreated_at: 1758854400000\nupdated_at: 1758854400000\n---\n\n记得买菜\n`;
|
|
259
|
+
const v1path = path.join(memRoot, "user", "01PLANTEST.md");
|
|
260
|
+
fs.writeFileSync(v1path, v1file, "utf8");
|
|
261
|
+
ok("v1 not v2 file", !isV2File(v1file));
|
|
262
|
+
const plan = planConversion(v1path, memRoot);
|
|
263
|
+
ok("plan exists", plan !== null);
|
|
264
|
+
ok("plan moves to personal/", plan?.toPath === path.join(memRoot, "personal", "01PLANTEST.md"), plan?.toPath);
|
|
265
|
+
ok("plan notes unknown type", plan?.changes.some((c) => c.includes('"note" → knowledge')) ?? false, plan?.changes);
|
|
266
|
+
ok("plan notes scope rename", plan?.changes.some((c) => c.includes("personal")) ?? false);
|
|
267
|
+
ok("plan fm scope personal", plan?.fm.scope === "personal");
|
|
268
|
+
const v2raw = serialize(plan!.fm, plan!.body);
|
|
269
|
+
ok("v2 marker detected", isV2File(v2raw));
|
|
270
|
+
ok("already-v2 → null plan", planConversion(v1path, memRoot) !== null); // file still v1 on disk
|
|
271
|
+
fs.writeFileSync(v1path, v2raw, "utf8");
|
|
272
|
+
const plan2 = planConversion(v1path, memRoot);
|
|
273
|
+
ok("v2 on disk → null plan", plan2 === null);
|
|
274
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
275
|
+
|
|
276
|
+
console.log("== lifecycle pure: contentHash / similarity ==");
|
|
277
|
+
ok("hash deterministic + whitespace-insensitive", contentHash("hello world\n") === contentHash("hello world"));
|
|
278
|
+
ok("hash differs on content", contentHash("hello world") !== contentHash("hello mars"));
|
|
279
|
+
ok("hash is sha256 hex", /^[0-9a-f]{64}$/.test(contentHash("x")));
|
|
280
|
+
ok("similarity identical latin = 1", similarity("apple banana", "apple banana") === 1);
|
|
281
|
+
ok("similarity identical CJK = 1", similarity("北京烤鸭好吃", "北京烤鸭好吃") === 1);
|
|
282
|
+
ok("similarity disjoint ≈ 0", similarity("apple banana", "car train") < 0.2);
|
|
283
|
+
const near = similarity(
|
|
284
|
+
"the meeting notes from tuesday about deployment",
|
|
285
|
+
"meeting notes from tuesday about deployment",
|
|
286
|
+
);
|
|
287
|
+
ok(`similarity near-dup ${near.toFixed(2)} >= ${NEAR_DUP_THRESHOLD}`, near >= NEAR_DUP_THRESHOLD);
|
|
288
|
+
ok("similarity empty → 0", similarity("", "anything") === 0);
|
|
289
|
+
|
|
90
290
|
console.log(fails === 0 ? "\nALL PASS" : `\n${fails} FAILURES`);
|
|
91
291
|
process.exit(fails === 0 ? 0 : 1);
|
package/src/cli.ts
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Run with: node --experimental-strip-types src/cli.ts <command>
|
|
3
|
-
import { resolveProjectScope, resolveCwdScope,
|
|
3
|
+
import { resolveProjectScope, resolveCwdScope, PERSONAL_SCOPE, type Scope } from "./scope.ts";
|
|
4
4
|
import { db, closeDb } from "./store/db.ts";
|
|
5
5
|
import { syncScope, upsertFromFile, deleteFromIndex } from "./store/sync.ts";
|
|
6
6
|
import { migrateScope, scopeHasFiles, type ConflictStrategy } from "./store/migrate.ts";
|
|
7
|
+
import { migrateV2 } from "./store/v2migrate.ts";
|
|
8
|
+
import { findDuplicates, supersede, setStatus } from "./store/lifecycle.ts";
|
|
7
9
|
import { search, list } from "./retrieve/search.ts";
|
|
8
10
|
import {
|
|
9
11
|
writeMemoryFile,
|
|
10
12
|
readMemoryFile,
|
|
11
13
|
deleteMemoryFile,
|
|
12
14
|
ulid,
|
|
15
|
+
msToRfc3339,
|
|
13
16
|
type Frontmatter,
|
|
14
17
|
} from "./store/markdown.ts";
|
|
15
18
|
import { loadConfig } from "./config.ts";
|
|
@@ -22,20 +25,27 @@ function usage(): never {
|
|
|
22
25
|
|
|
23
26
|
Usage:
|
|
24
27
|
node --experimental-strip-types src/cli.ts where
|
|
25
|
-
node --experimental-strip-types src/cli.ts list [--scope project|
|
|
26
|
-
node --experimental-strip-types src/cli.ts search "query" [--scope project|
|
|
27
|
-
node --experimental-strip-types src/cli.ts add "content" [--scope project|
|
|
28
|
+
node --experimental-strip-types src/cli.ts list [--scope project|personal] [--type T] [--limit N]
|
|
29
|
+
node --experimental-strip-types src/cli.ts search "query" [--scope project|personal|both] [--type T] [--limit N]
|
|
30
|
+
node --experimental-strip-types src/cli.ts add "content" [--scope project|personal] [--type T] [--tag t1,t2]
|
|
31
|
+
node --experimental-strip-types src/cli.ts supersede <id> "new content" [--type T] [--tag t1,t2]
|
|
32
|
+
node --experimental-strip-types src/cli.ts status <id> active|deprecated|retracted|archived
|
|
28
33
|
node --experimental-strip-types src/cli.ts forget <id>
|
|
29
34
|
node --experimental-strip-types src/cli.ts reindex
|
|
30
35
|
node --experimental-strip-types src/cli.ts scopes
|
|
31
36
|
node --experimental-strip-types src/cli.ts migrate [--from <key>] [--to <key>]
|
|
32
37
|
[--dry-run] [--on-conflict newer|overwrite|skip]
|
|
38
|
+
node --experimental-strip-types src/cli.ts migrate --to-v2 [--dry-run]
|
|
33
39
|
|
|
34
40
|
Scope defaults to \`project\` (derived from cwd's git remote or path).
|
|
41
|
+
\`user\` is accepted as a deprecated alias of \`personal\`.
|
|
35
42
|
\`scopes\` lists every project scope dir with its file count — use it to find
|
|
36
43
|
the \`--from\` key when migrating.
|
|
37
44
|
\`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
|
|
45
|
+
git remote after memories were already stored under the cwd-based key.
|
|
46
|
+
\`migrate --to-v2\` converts v1 memory files to the v2 format (§19):
|
|
47
|
+
user→personal scope rename, epoch→RFC 3339 times, priority→importance,
|
|
48
|
+
type: instruction→role split. Always preview with --dry-run first.`);
|
|
39
49
|
process.exit(1);
|
|
40
50
|
}
|
|
41
51
|
|
|
@@ -57,12 +67,52 @@ function parseFlags(argv: string[]): Record<string, string> {
|
|
|
57
67
|
return out;
|
|
58
68
|
}
|
|
59
69
|
|
|
70
|
+
/** CLI `--scope` flag → Scope. `user` is a deprecated alias of `personal`. */
|
|
71
|
+
function resolveCliScope(flags: Record<string, string>, project: Scope): Scope {
|
|
72
|
+
return flags.scope === "personal" || flags.scope === "user"
|
|
73
|
+
? PERSONAL_SCOPE
|
|
74
|
+
: project;
|
|
75
|
+
}
|
|
76
|
+
|
|
60
77
|
async function main() {
|
|
61
78
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
62
79
|
if (!cmd) usage();
|
|
63
80
|
|
|
64
81
|
const cfg = loadConfig();
|
|
65
82
|
const project = resolveProjectScope(process.cwd());
|
|
83
|
+
|
|
84
|
+
// `migrate --to-v2` is a pure file operation (V2-DESIGN §19) — it runs
|
|
85
|
+
// before the index is opened so it also works where the DB driver
|
|
86
|
+
// isn't available.
|
|
87
|
+
if (cmd === "migrate" && rest.includes("--to-v2")) {
|
|
88
|
+
const flags = parseFlags(rest);
|
|
89
|
+
const dryRun = flags["dry-run"] === "true";
|
|
90
|
+
const stats = migrateV2({ dryRun });
|
|
91
|
+
console.log(
|
|
92
|
+
`${dryRun ? "DRY RUN: " : ""}scanned ${stats.scanned} files: ` +
|
|
93
|
+
`${stats.converted} to convert, ${stats.skippedV2} already v2`,
|
|
94
|
+
);
|
|
95
|
+
for (const p of stats.plans) {
|
|
96
|
+
console.log(`\n${p.fm.id}`);
|
|
97
|
+
console.log(
|
|
98
|
+
` ${p.fromPath}${p.toPath !== p.fromPath ? `\n → ${p.toPath}` : ""}`,
|
|
99
|
+
);
|
|
100
|
+
for (const c of p.changes) console.log(` - ${c}`);
|
|
101
|
+
}
|
|
102
|
+
if (stats.legacyBackup) {
|
|
103
|
+
console.log(
|
|
104
|
+
`\nlegacy my-o-memory data dir merged; backup kept at:\n ${stats.legacyBackup}`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
if (!dryRun && stats.converted > 0) {
|
|
108
|
+
console.log(
|
|
109
|
+
`\nindex schema will rebuild automatically on next run; ` +
|
|
110
|
+
`run \`open-memex reindex\` to verify.`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
66
116
|
db();
|
|
67
117
|
|
|
68
118
|
if (cmd === "where") {
|
|
@@ -71,22 +121,22 @@ async function main() {
|
|
|
71
121
|
console.log(`memories: ${p.memories}`);
|
|
72
122
|
console.log(`index: ${p.indexDb}`);
|
|
73
123
|
console.log(`project: ${project.key}`);
|
|
74
|
-
console.log(`
|
|
124
|
+
console.log(`personal: ${PERSONAL_SCOPE.key}`);
|
|
75
125
|
return;
|
|
76
126
|
}
|
|
77
127
|
|
|
78
128
|
if (cmd === "reindex") {
|
|
79
129
|
const a = syncScope(project.key);
|
|
80
|
-
const b = syncScope(
|
|
130
|
+
const b = syncScope(PERSONAL_SCOPE.key);
|
|
81
131
|
console.log(
|
|
82
|
-
`reindexed. project: +${a.added} ~${a.updated} -${a.removed} (scanned ${a.scanned}),
|
|
132
|
+
`reindexed. project: +${a.added} ~${a.updated} -${a.removed} (scanned ${a.scanned}), personal: +${b.added} ~${b.updated} -${b.removed} (scanned ${b.scanned})`,
|
|
83
133
|
);
|
|
84
134
|
return;
|
|
85
135
|
}
|
|
86
136
|
|
|
87
137
|
if (cmd === "list") {
|
|
88
138
|
const flags = parseFlags(rest);
|
|
89
|
-
const s = flags
|
|
139
|
+
const s = resolveCliScope(flags, project);
|
|
90
140
|
syncScope(s.key);
|
|
91
141
|
const hits = list(s.key, {
|
|
92
142
|
type: flags.type,
|
|
@@ -97,7 +147,8 @@ async function main() {
|
|
|
97
147
|
return;
|
|
98
148
|
}
|
|
99
149
|
for (const h of hits) {
|
|
100
|
-
|
|
150
|
+
const dep = h.status === "deprecated" ? " [deprecated]" : "";
|
|
151
|
+
console.log(`[${h.type}]${dep} ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
|
|
101
152
|
}
|
|
102
153
|
return;
|
|
103
154
|
}
|
|
@@ -107,13 +158,13 @@ async function main() {
|
|
|
107
158
|
if (!query || query.startsWith("--")) usage();
|
|
108
159
|
const flags = parseFlags(rest.slice(1));
|
|
109
160
|
syncScope(project.key);
|
|
110
|
-
syncScope(
|
|
161
|
+
syncScope(PERSONAL_SCOPE.key);
|
|
111
162
|
const keys =
|
|
112
|
-
flags.scope === "user"
|
|
113
|
-
? [
|
|
163
|
+
flags.scope === "personal" || flags.scope === "user"
|
|
164
|
+
? [PERSONAL_SCOPE.key]
|
|
114
165
|
: flags.scope === "project"
|
|
115
166
|
? [project.key]
|
|
116
|
-
: [project.key,
|
|
167
|
+
: [project.key, PERSONAL_SCOPE.key];
|
|
117
168
|
const hits = search(query, {
|
|
118
169
|
scopeKeys: keys,
|
|
119
170
|
limit: flags.limit ? Number(flags.limit) : undefined,
|
|
@@ -124,8 +175,9 @@ async function main() {
|
|
|
124
175
|
return;
|
|
125
176
|
}
|
|
126
177
|
for (const h of hits) {
|
|
127
|
-
const tag = h.scope_key ===
|
|
128
|
-
|
|
178
|
+
const tag = h.scope_key === PERSONAL_SCOPE.key ? "personal" : "project";
|
|
179
|
+
const dep = h.status === "deprecated" ? " [deprecated]" : "";
|
|
180
|
+
console.log(`[${tag}/${h.type}]${dep} ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
|
|
129
181
|
}
|
|
130
182
|
return;
|
|
131
183
|
}
|
|
@@ -134,19 +186,32 @@ async function main() {
|
|
|
134
186
|
const content = rest[0];
|
|
135
187
|
if (!content || content.startsWith("--")) usage();
|
|
136
188
|
const flags = parseFlags(rest.slice(1));
|
|
137
|
-
const s = flags
|
|
189
|
+
const s = resolveCliScope(flags, project);
|
|
138
190
|
const { content: red, hadSecret, matchedPattern } = redact(content, cfg.redactPatterns);
|
|
139
191
|
if (hadSecret) {
|
|
140
192
|
console.error(`refused: content matched secret pattern (${matchedPattern}).`);
|
|
141
193
|
process.exit(2);
|
|
142
194
|
}
|
|
195
|
+
// Dedup on write (§3.4): identical content is idempotent; near-duplicates
|
|
196
|
+
// warn but still save (use `supersede` when this replaces the old one).
|
|
197
|
+
const dups = findDuplicates(s.key, red);
|
|
198
|
+
if (dups.exact) {
|
|
199
|
+
console.log(`already exists: id=${dups.exact.id} (identical content — not duplicated)`);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
143
202
|
const now = Date.now();
|
|
203
|
+
const rfc = msToRfc3339(now);
|
|
144
204
|
const fm: Frontmatter = {
|
|
145
205
|
id: ulid(),
|
|
206
|
+
schema_version: 2,
|
|
146
207
|
scope_key: s.key,
|
|
147
|
-
|
|
208
|
+
scope: s.kind === "project" ? "project" : "personal",
|
|
209
|
+
visibility: s.kind === "project" ? "internal" : "private",
|
|
148
210
|
project_name: s.projectName,
|
|
149
|
-
type: flags.type ?? "
|
|
211
|
+
type: flags.type ?? "fact",
|
|
212
|
+
role: "knowledge",
|
|
213
|
+
importance: "normal",
|
|
214
|
+
status: "active",
|
|
150
215
|
tags: flags.tag
|
|
151
216
|
? flags.tag
|
|
152
217
|
.split(",")
|
|
@@ -154,13 +219,64 @@ async function main() {
|
|
|
154
219
|
.filter(Boolean)
|
|
155
220
|
: [],
|
|
156
221
|
source: "cli",
|
|
157
|
-
created_at:
|
|
158
|
-
updated_at:
|
|
222
|
+
created_at: rfc,
|
|
223
|
+
updated_at: rfc,
|
|
224
|
+
supersedes: null,
|
|
225
|
+
superseded_by: null,
|
|
159
226
|
};
|
|
160
227
|
const { filePath } = writeMemoryFile(fm, red);
|
|
161
228
|
const mf = readMemoryFile(filePath);
|
|
162
229
|
if (mf) upsertFromFile(mf);
|
|
163
230
|
console.log(`saved ${fm.id} -> ${filePath}`);
|
|
231
|
+
for (const n of dups.near) {
|
|
232
|
+
console.log(
|
|
233
|
+
`warning: similar memory exists (score ${n.score.toFixed(2)}): id=${n.id}\n ${n.snippet}\n use \`open-memex supersede ${n.id} "new content"\` if this replaces it.`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (cmd === "supersede") {
|
|
240
|
+
const id = rest[0];
|
|
241
|
+
const content = rest[1];
|
|
242
|
+
if (!id || !content || content.startsWith("--")) usage();
|
|
243
|
+
const flags = parseFlags(rest.slice(2));
|
|
244
|
+
const { content: red, hadSecret, matchedPattern } = redact(content, cfg.redactPatterns);
|
|
245
|
+
if (hadSecret) {
|
|
246
|
+
console.error(`refused: content matched secret pattern (${matchedPattern}).`);
|
|
247
|
+
process.exit(2);
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
const { oldMf, newMf } = supersede(id, {
|
|
251
|
+
body: red,
|
|
252
|
+
type: flags.type,
|
|
253
|
+
tags: flags.tag
|
|
254
|
+
? flags.tag.split(",").map((t) => t.trim()).filter(Boolean)
|
|
255
|
+
: undefined,
|
|
256
|
+
source: "cli",
|
|
257
|
+
});
|
|
258
|
+
upsertFromFile(oldMf);
|
|
259
|
+
upsertFromFile(newMf);
|
|
260
|
+
console.log(`superseded ${oldMf.fm.id} → ${newMf.fm.id}`);
|
|
261
|
+
} catch (e) {
|
|
262
|
+
console.error(`supersede failed: ${(e as Error).message}`);
|
|
263
|
+
process.exit(2);
|
|
264
|
+
}
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (cmd === "status") {
|
|
269
|
+
const id = rest[0];
|
|
270
|
+
const st = rest[1];
|
|
271
|
+
if (!id || !st) usage();
|
|
272
|
+
try {
|
|
273
|
+
const mf = setStatus(id, st as "active" | "deprecated" | "retracted" | "archived");
|
|
274
|
+
upsertFromFile(mf);
|
|
275
|
+
console.log(`status ${id} → ${mf.fm.status}`);
|
|
276
|
+
} catch (e) {
|
|
277
|
+
console.error(`status failed: ${(e as Error).message}`);
|
|
278
|
+
process.exit(2);
|
|
279
|
+
}
|
|
164
280
|
return;
|
|
165
281
|
}
|
|
166
282
|
|
|
@@ -193,7 +309,7 @@ async function main() {
|
|
|
193
309
|
const files = fs.readdirSync(dir).filter((f) => f.endsWith(".md")).length;
|
|
194
310
|
const marker =
|
|
195
311
|
name === project.key ? " <- current project"
|
|
196
|
-
: name ===
|
|
312
|
+
: name === PERSONAL_SCOPE.key ? " <- personal"
|
|
197
313
|
: "";
|
|
198
314
|
entries.push({ key: name, files, marker });
|
|
199
315
|
}
|
package/src/config.ts
CHANGED
|
@@ -23,16 +23,9 @@ export const DEFAULT_CONFIG: MyOMemoryConfig = {
|
|
|
23
23
|
"^\\s*TIL[:,]?\\s+(.+)$",
|
|
24
24
|
"^\\s*save\\s+(?:this|to\\s+memory)[:,]?\\s+(.+)$",
|
|
25
25
|
],
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
],
|
|
26
|
+
// Built-in provider patterns now live in src/redact.ts (always on).
|
|
27
|
+
// Add only your own extra patterns here.
|
|
28
|
+
redactPatterns: [],
|
|
36
29
|
logLevel: "info",
|
|
37
30
|
};
|
|
38
31
|
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Plugin } from "@opencode-ai/plugin";
|
|
2
2
|
import { loadConfig } from "./config.ts";
|
|
3
|
-
import { resolveProjectScope, resolveCwdScope,
|
|
3
|
+
import { resolveProjectScope, resolveCwdScope, PERSONAL_SCOPE, type Scope } from "./scope.ts";
|
|
4
4
|
import { db } from "./store/db.ts";
|
|
5
5
|
import { syncScope, upsertFromFile } from "./store/sync.ts";
|
|
6
6
|
import { scopeHasFiles } from "./store/migrate.ts";
|
|
@@ -8,10 +8,12 @@ import {
|
|
|
8
8
|
writeMemoryFile,
|
|
9
9
|
readMemoryFile,
|
|
10
10
|
ulid,
|
|
11
|
+
msToRfc3339,
|
|
11
12
|
type Frontmatter,
|
|
12
13
|
} from "./store/markdown.ts";
|
|
13
14
|
import { buildContextBlock } from "./retrieve/inject.ts";
|
|
14
15
|
import { detectKeywords } from "./capture/keywords.ts";
|
|
16
|
+
import { findDuplicates } from "./store/lifecycle.ts";
|
|
15
17
|
import { redact } from "./redact.ts";
|
|
16
18
|
import { makeTools } from "./tools/memory.ts";
|
|
17
19
|
|
|
@@ -24,7 +26,7 @@ const plugin: Plugin = async ({ worktree, directory }) => {
|
|
|
24
26
|
try {
|
|
25
27
|
db();
|
|
26
28
|
syncScope(scope.key);
|
|
27
|
-
syncScope(
|
|
29
|
+
syncScope(PERSONAL_SCOPE.key);
|
|
28
30
|
if (cfg.logLevel === "debug") {
|
|
29
31
|
console.log(`[open-memex] loaded. scope=${scope.key}`);
|
|
30
32
|
}
|
|
@@ -63,17 +65,27 @@ const plugin: Plugin = async ({ worktree, directory }) => {
|
|
|
63
65
|
for (const h of hits) {
|
|
64
66
|
const { content, hadSecret } = redact(h.content, cfg.redactPatterns);
|
|
65
67
|
if (hadSecret || content.length === 0) continue;
|
|
68
|
+
// Dedup (§3.4): skip exact duplicates captured before.
|
|
69
|
+
if (findDuplicates(scope.key, content).exact) continue;
|
|
66
70
|
const now = Date.now();
|
|
71
|
+
const rfc = msToRfc3339(now);
|
|
67
72
|
const fm: Frontmatter = {
|
|
68
73
|
id: ulid(),
|
|
74
|
+
schema_version: 2,
|
|
69
75
|
scope_key: scope.key,
|
|
70
|
-
|
|
76
|
+
scope: scope.kind === "project" ? "project" : "personal",
|
|
77
|
+
visibility: scope.kind === "project" ? "internal" : "private",
|
|
71
78
|
project_name: scope.projectName,
|
|
72
|
-
type: "
|
|
79
|
+
type: "fact",
|
|
80
|
+
role: "knowledge",
|
|
81
|
+
importance: "normal",
|
|
82
|
+
status: "active",
|
|
73
83
|
tags: ["keyword"],
|
|
74
84
|
source: "keyword",
|
|
75
|
-
created_at:
|
|
76
|
-
updated_at:
|
|
85
|
+
created_at: rfc,
|
|
86
|
+
updated_at: rfc,
|
|
87
|
+
supersedes: null,
|
|
88
|
+
superseded_by: null,
|
|
77
89
|
};
|
|
78
90
|
try {
|
|
79
91
|
const { filePath } = writeMemoryFile(fm, content);
|