open-memex 0.6.2 → 0.7.0-alpha.11
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 +25 -7
- package/CHANGELOG.md +103 -1
- package/README.md +54 -16
- package/README.zh-CN.md +30 -15
- package/dist/.build-stamp +1 -0
- package/dist/capture/keywords.js +41 -8
- package/dist/cli.js +259 -40
- package/dist/config.js +102 -18
- package/dist/distill-agents.js +9 -1
- package/dist/doctor.js +180 -79
- package/dist/export.js +12 -2
- package/dist/index.js +20 -112
- package/dist/init.js +103 -11
- package/dist/inventory-html.js +150 -0
- package/dist/inventory.js +176 -0
- package/dist/mcp.js +21 -5
- package/dist/opencode-v2.js +43 -0
- package/dist/paths.js +34 -2
- package/dist/plugin-core.js +138 -0
- package/dist/retrieve/display.js +161 -0
- package/dist/retrieve/inject.js +1 -1
- package/dist/retrieve/search.js +44 -5
- package/dist/review.js +9 -3
- package/dist/store/db.js +11 -9
- package/dist/store/lifecycle.js +20 -4
- package/dist/store/markdown.js +53 -7
- package/dist/store/migrate.js +14 -3
- package/dist/store/sync.js +5 -3
- package/dist/store/v2migrate.js +2 -2
- package/dist/submit.js +13 -5
- package/dist/tools/memory.js +28 -0
- package/dist/tools/ops.js +127 -16
- package/docs/CURATOR.md +7 -3
- package/docs/V2-DESIGN.md +335 -32
- package/docs/memory-visibility-design.md +130 -0
- package/package.json +2 -2
- package/scripts/build-stamp.mjs +63 -0
- package/scripts/smoke-mcp.ts +47 -1
- package/scripts/smoke-pure.ts +237 -3
- package/scripts/test-full.ts +362 -5
- package/skills/open-memex/SKILL.md +53 -1
- package/src/capture/keywords.ts +56 -6
- package/src/cli.ts +282 -39
- package/src/config.ts +104 -17
- package/src/distill-agents.ts +19 -0
- package/src/doctor.ts +181 -71
- package/src/export.ts +14 -2
- package/src/index.ts +25 -112
- package/src/init.ts +108 -11
- package/src/inventory-html.ts +191 -0
- package/src/inventory.ts +242 -0
- package/src/mcp.ts +21 -5
- package/src/opencode-v2.ts +119 -0
- package/src/paths.ts +35 -1
- package/src/plugin-core.ts +152 -0
- package/src/retrieve/display.ts +174 -0
- package/src/retrieve/inject.ts +1 -1
- package/src/retrieve/search.ts +63 -6
- package/src/review.ts +9 -2
- package/src/store/db.ts +11 -9
- package/src/store/lifecycle.ts +24 -3
- package/src/store/markdown.ts +60 -7
- package/src/store/migrate.ts +15 -2
- package/src/store/sync.ts +6 -3
- package/src/store/v2migrate.ts +2 -1
- package/src/submit.ts +12 -4
- package/src/tools/memory.ts +28 -0
- package/src/tools/ops.ts +147 -17
package/src/cli.ts
CHANGED
|
@@ -9,22 +9,76 @@ import { findDuplicates, supersede, setStatus } from "./store/lifecycle.ts";
|
|
|
9
9
|
import { proposeMemories, promoteMemory, listConflicts, resolveConflict, formatReviewHistory } from "./review.ts";
|
|
10
10
|
import { getSyncStatus, formatSyncStatus, submitMemories } from "./submit.ts";
|
|
11
11
|
import { getPrStatus, formatPrStatus, applyPrStatus } from "./github.ts";
|
|
12
|
-
import { search, list, hitStateLabel } from "./retrieve/search.ts";
|
|
12
|
+
import { search, list, countList, hitStateLabel } from "./retrieve/search.ts";
|
|
13
|
+
import { formatInventoryLine, truncationNote } from "./retrieve/display.ts";
|
|
13
14
|
import {
|
|
14
15
|
writeMemoryFile,
|
|
15
16
|
readMemoryFile,
|
|
16
17
|
ulid,
|
|
17
18
|
msToRfc3339,
|
|
19
|
+
normalizeAliases,
|
|
18
20
|
type Frontmatter,
|
|
19
21
|
} from "./store/markdown.ts";
|
|
20
|
-
import { loadConfig } from "./config.ts";
|
|
21
|
-
import { paths, projectRoot } from "./paths.ts";
|
|
22
|
+
import { loadConfig, configSource } from "./config.ts";
|
|
23
|
+
import { paths, projectRoot, isInRepoMemoryFile } from "./paths.ts";
|
|
22
24
|
import { redact } from "./redact.ts";
|
|
23
25
|
import { resolveMcpCommand } from "./init.ts";
|
|
24
26
|
import fs from "node:fs";
|
|
25
27
|
import path from "node:path";
|
|
28
|
+
import { execFileSync } from "node:child_process";
|
|
26
29
|
import { fileURLToPath } from "node:url";
|
|
27
30
|
|
|
31
|
+
/**
|
|
32
|
+
* True when `target` sits inside a git working tree (D68 guard).
|
|
33
|
+
*
|
|
34
|
+
* D70: resolve the nearest EXISTING ancestor first. `git -C` on a
|
|
35
|
+
* directory that does not exist throws, and the old code turned that
|
|
36
|
+
* into "not in a worktree" — a fail-open guard on the one path where
|
|
37
|
+
* personal memories would ride a commit.
|
|
38
|
+
*/
|
|
39
|
+
function isInsideWorkTree(target: string): boolean {
|
|
40
|
+
try {
|
|
41
|
+
let dir = path.dirname(path.resolve(target));
|
|
42
|
+
while (!fs.existsSync(dir)) {
|
|
43
|
+
const parent = path.dirname(dir);
|
|
44
|
+
if (parent === dir) break;
|
|
45
|
+
dir = parent;
|
|
46
|
+
}
|
|
47
|
+
const out = execFileSync("git", ["-C", dir, "rev-parse", "--is-inside-work-tree"], {
|
|
48
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
49
|
+
encoding: "utf8",
|
|
50
|
+
});
|
|
51
|
+
return out.trim() === "true";
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* D70: the one place that decides where a report may be written, for
|
|
59
|
+
* every `inventory` format. Returns an error message to print, or null
|
|
60
|
+
* when the path is safe. Kept single so the text/json/html paths cannot
|
|
61
|
+
* drift apart (they already had two copies, D68).
|
|
62
|
+
*/
|
|
63
|
+
function inventoryWriteRefusal(
|
|
64
|
+
target: string,
|
|
65
|
+
includesPersonal: boolean,
|
|
66
|
+
allowPersonal: boolean,
|
|
67
|
+
): string | null {
|
|
68
|
+
// Personal content must not ride a commit (the design's hardest rule).
|
|
69
|
+
if (includesPersonal && !allowPersonal && isInsideWorkTree(target)) {
|
|
70
|
+
return (
|
|
71
|
+
`inventory: ${target} is inside a git working tree and this report includes personal memories. ` +
|
|
72
|
+
`Refusing to write it there. Pass --allow-personal if you really want that, or write outside the repo.`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
const parent = path.dirname(path.resolve(target));
|
|
76
|
+
if (!fs.existsSync(parent)) {
|
|
77
|
+
return `inventory: the directory ${parent} does not exist — create it first, or choose another --out path.`;
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
28
82
|
/** Per-command help, printed by `open-memex <command> --help`.
|
|
29
83
|
AI assistants discover the CLI through --help, so every command needs one. */
|
|
30
84
|
const COMMAND_HELP: Record<string, string> = {
|
|
@@ -35,14 +89,20 @@ Usage: open-memex where
|
|
|
35
89
|
Example:
|
|
36
90
|
open-memex where`,
|
|
37
91
|
|
|
38
|
-
list: `List memories in a scope, newest first.
|
|
92
|
+
list: `List memories in a scope, newest first, as a numbered inventory.
|
|
39
93
|
|
|
40
|
-
Usage: open-memex list [--scope project|personal] [--type T] [--limit N]
|
|
94
|
+
Usage: open-memex list [--scope project|personal|both] [--type T] [--limit N] [--include active|all]
|
|
41
95
|
|
|
42
96
|
Flags:
|
|
43
|
-
--scope
|
|
44
|
-
--type
|
|
45
|
-
--limit
|
|
97
|
+
--scope project (default), personal, or both
|
|
98
|
+
--type filter by memory type
|
|
99
|
+
--limit max results (default 20, max 100)
|
|
100
|
+
--include active (default: what is currently remembered) or all (audit
|
|
101
|
+
view: superseded versions, retracted and archived included)
|
|
102
|
+
|
|
103
|
+
A cut list says how many entries it left out. Numbers are unique within one
|
|
104
|
+
listing, so "the third one" names exactly one memory - but they are only
|
|
105
|
+
valid for the listing you just ran.
|
|
46
106
|
|
|
47
107
|
Examples:
|
|
48
108
|
open-memex list
|
|
@@ -64,19 +124,26 @@ Example:
|
|
|
64
124
|
|
|
65
125
|
add: `Save a fact, preference, decision, or note to local memory.
|
|
66
126
|
|
|
67
|
-
Usage: open-memex add "content" [--scope project|personal] [--type T] [--tag t1,t2]
|
|
127
|
+
Usage: open-memex add "content" [--scope project|personal] [--type T] [--tag t1,t2] [--aliases "a;b"]
|
|
68
128
|
|
|
69
129
|
Flags:
|
|
70
|
-
--scope
|
|
71
|
-
--type
|
|
72
|
-
--tag
|
|
130
|
+
--scope project (default) or personal (personal never leaves this machine)
|
|
131
|
+
--type memory type (default: fact)
|
|
132
|
+
--tag comma-separated tags
|
|
133
|
+
--aliases alternate phrasings of this fact (up to 4), indexed with the
|
|
134
|
+
memory so differently-worded searches still match; separate
|
|
135
|
+
with ; (commas also accepted — use ; when an alias itself
|
|
136
|
+
contains one). Stored only when capture aliases are enabled
|
|
137
|
+
(init default)
|
|
73
138
|
|
|
74
139
|
Example:
|
|
75
140
|
open-memex add "We deploy on Fridays" --scope project --tag process`,
|
|
76
141
|
|
|
77
142
|
supersede: `Replace a memory with a newer version. The old one is kept as history.
|
|
78
143
|
|
|
79
|
-
Usage: open-memex supersede <id> "new content" [--type T] [--tag t1,t2]
|
|
144
|
+
Usage: open-memex supersede <id> "new content" [--type T] [--tag t1,t2] [--aliases "a;b"]
|
|
145
|
+
|
|
146
|
+
Without --aliases, the old memory's aliases carry over.
|
|
80
147
|
|
|
81
148
|
Example:
|
|
82
149
|
open-memex supersede 01ABC "We deploy on Thursdays now"`,
|
|
@@ -85,15 +152,22 @@ Example:
|
|
|
85
152
|
|
|
86
153
|
Usage: open-memex status <id> active|deprecated|retracted|archived
|
|
87
154
|
|
|
155
|
+
Retraction is one-way: a retracted memory cannot be set back to active
|
|
156
|
+
(it was withdrawn on purpose). Save the content as a new memory instead.
|
|
157
|
+
|
|
88
158
|
Example:
|
|
89
159
|
open-memex status 01ABC deprecated`,
|
|
90
160
|
|
|
91
|
-
forget: `Delete a memory by id.
|
|
161
|
+
forget: `Delete a memory by id. With --soft, hide it instead: the memory
|
|
162
|
+
is retracted (out of lists and search, file kept). Retraction is one-way —
|
|
163
|
+
save the content as a new memory to bring it back. If the memory lives in
|
|
164
|
+
the repo, either action is only local until you commit and push it.
|
|
92
165
|
|
|
93
|
-
Usage: open-memex forget <id>
|
|
166
|
+
Usage: open-memex forget <id> [--soft]
|
|
94
167
|
|
|
95
168
|
Example:
|
|
96
|
-
open-memex forget 01ABC
|
|
169
|
+
open-memex forget 01ABC
|
|
170
|
+
open-memex forget 01ABC --soft`,
|
|
97
171
|
|
|
98
172
|
propose: `Copy personal memories into the project outbox as review drafts.
|
|
99
173
|
The personal originals stay put. Nothing enters git at this step.
|
|
@@ -145,6 +219,22 @@ Usage: open-memex sync-status
|
|
|
145
219
|
Example:
|
|
146
220
|
open-memex sync-status`,
|
|
147
221
|
|
|
222
|
+
inventory: `Show everything remembered — personal + this project — in the
|
|
223
|
+
open: what each memory says, where it came from, and whether it has reached
|
|
224
|
+
the repo yet. Read-only; deleting stays a confirmed conversation with your
|
|
225
|
+
agent. Formats: text (default, diffable), json (for agents), html (a local
|
|
226
|
+
page for your browser; default writes <data dir>/inventory.html).
|
|
227
|
+
|
|
228
|
+
Usage: open-memex inventory [--format text|json|html] [--scope project|personal|both] [--out <path>] [--allow-personal]
|
|
229
|
+
|
|
230
|
+
Default scope: both. Without --out, text/json go to stdout. A report that
|
|
231
|
+
includes personal memories is refused inside a git working tree unless
|
|
232
|
+
--allow-personal is passed — personal content must not ride a commit.
|
|
233
|
+
|
|
234
|
+
Example:
|
|
235
|
+
open-memex inventory --format json --out ~/inventory.json
|
|
236
|
+
open-memex inventory --format html`,
|
|
237
|
+
|
|
148
238
|
pull: `Pull shared project memories from the git remote: fetch + fast-forward
|
|
149
239
|
only. Never auto-merges — a diverged branch fails with a clear message and is
|
|
150
240
|
left for you to resolve by hand. On success the local index re-syncs.
|
|
@@ -359,16 +449,17 @@ function usage(exitCode = 1): never {
|
|
|
359
449
|
|
|
360
450
|
Usage:
|
|
361
451
|
open-memex where
|
|
362
|
-
open-memex list [--scope project|personal] [--type T] [--limit N]
|
|
452
|
+
open-memex list [--scope project|personal|both] [--type T] [--limit N] [--include active|all]
|
|
363
453
|
open-memex search "query" [--scope project|personal|both] [--type T] [--limit N] [--explain]
|
|
364
454
|
open-memex add "content" [--scope project|personal] [--type T] [--tag t1,t2]
|
|
365
455
|
open-memex supersede <id> "new content" [--type T] [--tag t1,t2]
|
|
366
456
|
open-memex status <id> active|deprecated|retracted|archived
|
|
367
|
-
open-memex forget <id>
|
|
457
|
+
open-memex forget <id> [--soft]
|
|
368
458
|
open-memex propose <id...> --to project [--local-approve]
|
|
369
459
|
open-memex promote <id> [--reject] [--resubmit] [--note "..."] [--by NAME]
|
|
370
460
|
open-memex resolve [id-or-path]
|
|
371
461
|
open-memex sync-status
|
|
462
|
+
open-memex inventory [--format text|json|html] [--scope project|personal|both] [--out <path>] [--allow-personal]
|
|
372
463
|
open-memex pull
|
|
373
464
|
open-memex push
|
|
374
465
|
open-memex export [--scope project|personal|both] [--type T] [--tag t] [--all] [-o <file>]
|
|
@@ -795,12 +886,13 @@ async function main() {
|
|
|
795
886
|
process.exit(1);
|
|
796
887
|
}
|
|
797
888
|
const cfg = loadConfig();
|
|
798
|
-
const {
|
|
889
|
+
const { scanKeywords, MIN_CAPTURE_CHARS, MAX_CAPTURE_CHARS } = await import(
|
|
890
|
+
"./capture/keywords.ts"
|
|
891
|
+
);
|
|
799
892
|
const text = rest.slice(1).join(" ");
|
|
800
|
-
const hits =
|
|
893
|
+
const { hits, dropped } = scanKeywords(text, cfg);
|
|
801
894
|
if (hits.length === 0) {
|
|
802
|
-
console.log("no keyword triggers
|
|
803
|
-
return;
|
|
895
|
+
console.log("no keyword triggers - nothing would be captured.");
|
|
804
896
|
}
|
|
805
897
|
for (const h of hits) {
|
|
806
898
|
const r = redact(h.content, cfg.redactPatterns);
|
|
@@ -811,6 +903,15 @@ async function main() {
|
|
|
811
903
|
` body: ${r.content.slice(0, 160)}${r.content.length > 160 ? "…" : ""}`,
|
|
812
904
|
);
|
|
813
905
|
}
|
|
906
|
+
// D67: a trigger that matched but produced a fragment is reported, not
|
|
907
|
+
// silently dropped - otherwise "记住:这个" just looks broken.
|
|
908
|
+
for (const d of dropped) {
|
|
909
|
+
console.log(
|
|
910
|
+
d.reason === "too-short"
|
|
911
|
+
? `! matched but NOT captured: body "${d.content}" is ${d.content.length} chars (floor: ${MIN_CAPTURE_CHARS}). A trigger alone isn't a memory - write the fact: "记住:<fact>".`
|
|
912
|
+
: `! matched but NOT captured: body is ${d.content.length} chars (ceiling: ${MAX_CAPTURE_CHARS}) - save it with memory_add instead.`,
|
|
913
|
+
);
|
|
914
|
+
}
|
|
814
915
|
return;
|
|
815
916
|
}
|
|
816
917
|
|
|
@@ -832,6 +933,7 @@ async function main() {
|
|
|
832
933
|
if (cmd === "where") {
|
|
833
934
|
const p = paths();
|
|
834
935
|
console.log(`root: ${p.root}`);
|
|
936
|
+
console.log(`config: ${configSource() ?? "(built-in defaults)"}`);
|
|
835
937
|
console.log(`memories: ${p.memories}`);
|
|
836
938
|
console.log(`index: ${p.indexDb}`);
|
|
837
939
|
console.log(`project: ${project.key}`);
|
|
@@ -850,20 +952,44 @@ async function main() {
|
|
|
850
952
|
|
|
851
953
|
if (cmd === "list") {
|
|
852
954
|
const flags = parseFlags(rest);
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
const
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
for (const
|
|
864
|
-
|
|
865
|
-
const
|
|
866
|
-
|
|
955
|
+
// D66: --scope both, on par with the memory_list tool — prints each
|
|
956
|
+
// scope's newest under its own header.
|
|
957
|
+
const scopes =
|
|
958
|
+
flags.scope === "both" ? [PERSONAL_SCOPE, project] : [resolveCliScope(flags, project)];
|
|
959
|
+
const include = flags.include === "all" ? "all" : "active";
|
|
960
|
+
// D70: the CLI renders the SAME structured inventory the agent sees,
|
|
961
|
+
// numbering runs across the whole listing, and a cut list says how
|
|
962
|
+
// many entries it dropped — `open-memex list` is the surface a
|
|
963
|
+
// sceptical user runs first, so it may not look complete when it isn't.
|
|
964
|
+
let seq = 0;
|
|
965
|
+
for (const s of scopes) {
|
|
966
|
+
syncScope(s.key, "cli");
|
|
967
|
+
const hits = list(s.key, {
|
|
968
|
+
type: flags.type,
|
|
969
|
+
limit: flags.limit ? Number(flags.limit) : undefined,
|
|
970
|
+
include,
|
|
971
|
+
});
|
|
972
|
+
const total = countList(s.key, { type: flags.type, include });
|
|
973
|
+
if (scopes.length > 1) {
|
|
974
|
+
console.log(
|
|
975
|
+
s.key === PERSONAL_SCOPE.key
|
|
976
|
+
? "## About you (personal — stays on this machine, applies everywhere)"
|
|
977
|
+
: `## This project (${s.projectName || s.key})`,
|
|
978
|
+
);
|
|
979
|
+
}
|
|
980
|
+
if (hits.length === 0) {
|
|
981
|
+
console.log(
|
|
982
|
+
scopes.length > 1
|
|
983
|
+
? "(nothing remembered in this scope yet)"
|
|
984
|
+
: `(no memories in ${s.key})`,
|
|
985
|
+
);
|
|
986
|
+
continue;
|
|
987
|
+
}
|
|
988
|
+
for (const h of hits) {
|
|
989
|
+
console.log(formatInventoryLine(seq++, h, PERSONAL_SCOPE.key));
|
|
990
|
+
}
|
|
991
|
+
const note = truncationNote(hits.length, total);
|
|
992
|
+
if (note) console.log(note);
|
|
867
993
|
}
|
|
868
994
|
return;
|
|
869
995
|
}
|
|
@@ -943,6 +1069,20 @@ async function main() {
|
|
|
943
1069
|
.map((t) => t.trim())
|
|
944
1070
|
.filter(Boolean)
|
|
945
1071
|
: [],
|
|
1072
|
+
// D61: --aliases "phrase one; phrase two" (comma also accepted; use
|
|
1073
|
+
// ; when an alias itself contains a comma). A bare --aliases with no
|
|
1074
|
+
// value makes parseFlags yield "true" — treat that as absent. Stored
|
|
1075
|
+
// only when the install enables capture aliases, redacted like content.
|
|
1076
|
+
...(typeof flags.aliases === "string" && flags.aliases !== "true" && cfg.captureAliases
|
|
1077
|
+
? {
|
|
1078
|
+
aliases: normalizeAliases(
|
|
1079
|
+
flags.aliases
|
|
1080
|
+
.split(/[;,]/)
|
|
1081
|
+
.map((a) => redact(a.trim(), cfg.redactPatterns).content)
|
|
1082
|
+
.filter(Boolean),
|
|
1083
|
+
),
|
|
1084
|
+
}
|
|
1085
|
+
: {}),
|
|
946
1086
|
source: "cli",
|
|
947
1087
|
created_at: rfc,
|
|
948
1088
|
updated_at: rfc,
|
|
@@ -985,6 +1125,15 @@ async function main() {
|
|
|
985
1125
|
tags: flags.tag
|
|
986
1126
|
? flags.tag.split(",").map((t) => t.trim()).filter(Boolean)
|
|
987
1127
|
: undefined,
|
|
1128
|
+
aliases:
|
|
1129
|
+
typeof flags.aliases === "string" && flags.aliases !== "true" && cfg.captureAliases
|
|
1130
|
+
? normalizeAliases(
|
|
1131
|
+
flags.aliases
|
|
1132
|
+
.split(/[;,]/)
|
|
1133
|
+
.map((a) => redact(a.trim(), cfg.redactPatterns).content)
|
|
1134
|
+
.filter(Boolean),
|
|
1135
|
+
)
|
|
1136
|
+
: undefined,
|
|
988
1137
|
source: "cli",
|
|
989
1138
|
});
|
|
990
1139
|
upsertFromFile(oldMf);
|
|
@@ -1020,6 +1169,29 @@ async function main() {
|
|
|
1020
1169
|
if (cmd === "forget") {
|
|
1021
1170
|
const id = rest[0];
|
|
1022
1171
|
if (!id) usage();
|
|
1172
|
+
// D68: --soft hides (retracts) instead of deleting — same action as
|
|
1173
|
+
// the memory_forget tool's soft flag. One-way (D64).
|
|
1174
|
+
if (rest.includes("--soft")) {
|
|
1175
|
+
const target = db().prepare(`SELECT file_path FROM memories WHERE id = ?`).get(id) as
|
|
1176
|
+
| { file_path: string }
|
|
1177
|
+
| undefined;
|
|
1178
|
+
try {
|
|
1179
|
+
const mf = setStatus(id, "retracted");
|
|
1180
|
+
upsertFromFile(mf);
|
|
1181
|
+
console.log(`hidden ${id} (retracted — out of lists and search; save it again as a new memory to bring it back)`);
|
|
1182
|
+
// D70: same honesty as the hard delete — a retraction of an in-repo
|
|
1183
|
+
// memory is a local working-tree edit until it is committed and pushed.
|
|
1184
|
+
if (target && isInRepoMemoryFile(target.file_path)) {
|
|
1185
|
+
console.log(
|
|
1186
|
+
`note: that memory lives in the repo (.ai/open-memex/) — the retraction is only local until you commit and push it; until then the team still sees it.`,
|
|
1187
|
+
);
|
|
1188
|
+
}
|
|
1189
|
+
} catch (e) {
|
|
1190
|
+
console.error(`hide failed: ${(e as Error).message}`);
|
|
1191
|
+
process.exit(1);
|
|
1192
|
+
}
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1023
1195
|
const row = db().prepare(`SELECT file_path FROM memories WHERE id = ?`).get(id) as
|
|
1024
1196
|
| { file_path: string }
|
|
1025
1197
|
| undefined;
|
|
@@ -1028,11 +1200,17 @@ async function main() {
|
|
|
1028
1200
|
process.exit(1);
|
|
1029
1201
|
}
|
|
1030
1202
|
// Delete by indexed file_path — location-agnostic (appdata or in-repo, 2B/D24).
|
|
1203
|
+
// Only ENOENT = success; anything else means the file survives and
|
|
1204
|
+
// would be re-indexed — fail loudly (P0 review, 2026-10-03).
|
|
1031
1205
|
if (row.file_path) {
|
|
1032
1206
|
try {
|
|
1033
1207
|
fs.unlinkSync(row.file_path);
|
|
1034
|
-
} catch {
|
|
1035
|
-
|
|
1208
|
+
} catch (e) {
|
|
1209
|
+
const code = (e as NodeJS.ErrnoException).code;
|
|
1210
|
+
if (code !== "ENOENT") {
|
|
1211
|
+
console.error(`could not delete ${row.file_path} (${code ?? "error"}) — memory not forgotten`);
|
|
1212
|
+
process.exit(1);
|
|
1213
|
+
}
|
|
1036
1214
|
}
|
|
1037
1215
|
}
|
|
1038
1216
|
deleteFromIndex(id);
|
|
@@ -1147,11 +1325,73 @@ function positionalArgs(argv: string[]): string[] {
|
|
|
1147
1325
|
return;
|
|
1148
1326
|
}
|
|
1149
1327
|
|
|
1328
|
+
if (cmd === "inventory") {
|
|
1329
|
+
// D68: refresh both scopes first so the report matches the markdown
|
|
1330
|
+
// on disk. Default scope: personal + this project.
|
|
1331
|
+
syncScope(project.key, "cli");
|
|
1332
|
+
syncScope(PERSONAL_SCOPE.key, "cli");
|
|
1333
|
+
const flags = parseFlags(rest);
|
|
1334
|
+
const format = flags.format ?? "text";
|
|
1335
|
+
if (format !== "text" && format !== "json" && format !== "html") {
|
|
1336
|
+
console.error(`inventory: unknown --format '${format}' (text|json|html)`);
|
|
1337
|
+
process.exit(2);
|
|
1338
|
+
}
|
|
1339
|
+
const scopeKeys =
|
|
1340
|
+
flags.scope === "personal"
|
|
1341
|
+
? [PERSONAL_SCOPE.key]
|
|
1342
|
+
: flags.scope === "project"
|
|
1343
|
+
? [project.key]
|
|
1344
|
+
: [PERSONAL_SCOPE.key, project.key];
|
|
1345
|
+
const outPath = flags["out"] ?? flags["output"];
|
|
1346
|
+
const includesPersonal = scopeKeys.includes(PERSONAL_SCOPE.key);
|
|
1347
|
+
const allowPersonal = flags["allow-personal"] === "true";
|
|
1348
|
+
try {
|
|
1349
|
+
const { loadInventory, renderInventoryText, renderInventoryJson } = await import("./inventory.ts");
|
|
1350
|
+
if (format === "html") {
|
|
1351
|
+
const { renderInventoryHtml, defaultHtmlOutPath } = await import("./inventory-html.ts");
|
|
1352
|
+
const target = outPath ?? defaultHtmlOutPath(paths().root);
|
|
1353
|
+
// D70: one guard for every format (this path had its own copy).
|
|
1354
|
+
const refusal = inventoryWriteRefusal(target, includesPersonal, allowPersonal);
|
|
1355
|
+
if (refusal) {
|
|
1356
|
+
console.error(refusal);
|
|
1357
|
+
process.exit(2);
|
|
1358
|
+
}
|
|
1359
|
+
// D70: only the audit surface needs the hidden rows themselves;
|
|
1360
|
+
// text/json report counts, so they must not pay to load them.
|
|
1361
|
+
const data = loadInventory(scopeKeys, { hidden: "entries" });
|
|
1362
|
+
fs.writeFileSync(target, renderInventoryHtml(data), "utf8");
|
|
1363
|
+
console.log(`inventory: ${data.total} memories → ${target}`);
|
|
1364
|
+
console.log(`open it in any browser; the report is read-only.`);
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
const data = loadInventory(scopeKeys);
|
|
1368
|
+
const rendered = format === "json" ? renderInventoryJson(data) : renderInventoryText(data);
|
|
1369
|
+
if (outPath) {
|
|
1370
|
+
// D68/3-C v2: the report contains personal content. Writing it into
|
|
1371
|
+
// a git working tree would ride the next commit off this machine —
|
|
1372
|
+
// refuse unless the user explicitly accepts that.
|
|
1373
|
+
const refusal = inventoryWriteRefusal(outPath, includesPersonal, allowPersonal);
|
|
1374
|
+
if (refusal) {
|
|
1375
|
+
console.error(refusal);
|
|
1376
|
+
process.exit(2);
|
|
1377
|
+
}
|
|
1378
|
+
fs.writeFileSync(outPath, rendered, "utf8");
|
|
1379
|
+
console.log(`inventory: ${data.total} memories → ${outPath}`);
|
|
1380
|
+
} else {
|
|
1381
|
+
process.stdout.write(rendered);
|
|
1382
|
+
}
|
|
1383
|
+
} catch (e) {
|
|
1384
|
+
console.error(`inventory failed: ${(e as Error).message}`);
|
|
1385
|
+
process.exit(2);
|
|
1386
|
+
}
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1150
1390
|
if (cmd === "sync-status") {
|
|
1151
1391
|
syncScope(project.key, "cli");
|
|
1152
1392
|
syncScope(PERSONAL_SCOPE.key, "cli");
|
|
1153
1393
|
try {
|
|
1154
|
-
console.log(formatSyncStatus(getSyncStatus()));
|
|
1394
|
+
console.log(formatSyncStatus(getSyncStatus(), { cap: Number.POSITIVE_INFINITY }));
|
|
1155
1395
|
} catch (e) {
|
|
1156
1396
|
console.error(`sync-status failed: ${(e as Error).message}`);
|
|
1157
1397
|
process.exit(2);
|
|
@@ -1250,6 +1490,9 @@ function positionalArgs(argv: string[]): string[] {
|
|
|
1250
1490
|
console.log(
|
|
1251
1491
|
`${dryRun ? "DRY RUN: " : ""}imported ${r.imported}, skipped ${r.skippedIdentical} identical`,
|
|
1252
1492
|
);
|
|
1493
|
+
for (const rp of r.rejectedPaths) {
|
|
1494
|
+
console.log(` rejected: ${rp} (path outside the bundle)`);
|
|
1495
|
+
}
|
|
1253
1496
|
for (const c of r.skippedConflict) {
|
|
1254
1497
|
console.log(` conflict (kept existing): ${c.id} from ${c.file}`);
|
|
1255
1498
|
}
|
package/src/config.ts
CHANGED
|
@@ -7,6 +7,14 @@ export interface MyOMemoryConfig {
|
|
|
7
7
|
maxProfileItems: number;
|
|
8
8
|
injectOnFirstTurn: boolean;
|
|
9
9
|
keywordCaptureEnabled: boolean;
|
|
10
|
+
/**
|
|
11
|
+
* D61: capture-time aliasing (retrieval layer 3). When on, agents are
|
|
12
|
+
* asked to attach 2–4 aliases (alternate phrasings, other-language
|
|
13
|
+
* equivalents) to each memory they save; ops stores them and the index
|
|
14
|
+
* searches them, so differently-worded questions still hit. Asked once
|
|
15
|
+
* at init, default on; off = aliases passed by agents are dropped.
|
|
16
|
+
*/
|
|
17
|
+
captureAliases: boolean;
|
|
10
18
|
/** Patterns whose capture group 1 becomes the memory body; saved to the current scope. */
|
|
11
19
|
keywordPatterns: string[];
|
|
12
20
|
/** Same shape, but hits are forced into the personal scope (e.g. "remember for me"). */
|
|
@@ -34,26 +42,41 @@ export const DEFAULT_CONFIG: MyOMemoryConfig = {
|
|
|
34
42
|
maxProfileItems: 5,
|
|
35
43
|
injectOnFirstTurn: true,
|
|
36
44
|
keywordCaptureEnabled: true,
|
|
45
|
+
captureAliases: true,
|
|
37
46
|
keywordPatterns: [
|
|
38
|
-
"^\\s*remember(?!\\s+for\\s+me)(?:\\s+that)?[:,]?\\s+(.+)$",
|
|
39
|
-
"^\\s*(?:please\\s+)?
|
|
47
|
+
"^\\s*remember(?!\\s+for\\s+me)(?:\\s+that|\\s+to)?[:,]?\\s+(.+)$",
|
|
48
|
+
"^\\s*(?:please\\s+)?note(?:\\s+that)?[:,]?\\s+(.+)$",
|
|
49
|
+
// D67: "don't forget the wifi password" is narration addressed to the
|
|
50
|
+
// agent, not a statement to the store. An explicit marker - "that" or a
|
|
51
|
+
// colon - makes it an instruction ("don't forget that ..." / "don't
|
|
52
|
+
// forget: ..."). Mirrors 别忘了 below; see D67.
|
|
53
|
+
"^\\s*(?:please\\s+)?don'?t\\s+forget(?:\\s+that\\s+|[:,]\\s+)(.+)$",
|
|
40
54
|
"^\\s*TIL[:,]?\\s+(.+)$",
|
|
41
55
|
"^\\s*save\\s+(?:this|to\\s+memory)[:,]?\\s+(.+)$",
|
|
42
56
|
// Chinese equivalents
|
|
43
|
-
"^\\s
|
|
57
|
+
"^\\s*(?:请)?记住(?!(个人))[::,,]?\\s*(.+)$",
|
|
44
58
|
"^\\s*(?:请)?(?:记一下|记录一下)[::,,]?\\s*(.+)$",
|
|
45
|
-
"
|
|
59
|
+
// D67: separator required. "别忘了带伞" is narration ("don't forget your
|
|
60
|
+
// umbrella"); "别忘了:周五不发布" is an instruction to the store. Same
|
|
61
|
+
// rule as the English "don't forget" above.
|
|
62
|
+
"^\\s*别忘了[::,,]\\s*(.+)$",
|
|
46
63
|
// First-person plural: team/project context, NOT personal
|
|
47
64
|
"^\\s*我们认为[::,,]?\\s*(.+)$",
|
|
48
65
|
"^\\s*我们决定[::,,]?\\s*(.+)$",
|
|
49
|
-
|
|
66
|
+
// D67: the boundary applies to the BARE verb only. 记 + (住|录) + 一下 is a
|
|
67
|
+
// complete verb phrase, so the noun may follow directly ("帮我记住这个配置",
|
|
68
|
+
// "记一下这个约定"); only a bare 记 needs a separator, otherwise 帮我记得…
|
|
69
|
+
// would capture mid-word garbage. See D67.
|
|
70
|
+
"^\\s*(?:请)?帮我们记(?:(?:住|录)(?!得|着)(?:一下)?|一下|(?=[::,,\\s]|$))[::,,]?\\s*(.+)$",
|
|
50
71
|
],
|
|
51
72
|
keywordPersonalPatterns: [
|
|
52
73
|
"^\\s*remember\\s+for\\s+me(?:\\s+that)?[:,]?\\s+(.+)$",
|
|
53
|
-
"^\\s
|
|
74
|
+
"^\\s*(?:请)?记住(个人)[::,,]?\\s*(.+)$",
|
|
54
75
|
// First-person singular: personal scope ("我" → 个人, "我们" → 项目)
|
|
55
|
-
"^\\s
|
|
56
|
-
"^\\s
|
|
76
|
+
"^\\s*(?:请)?记住我(?!们)[::,,]?\\s*(.+)$",
|
|
77
|
+
"^\\s*(?:请)?替我记(?:(?:住|录)(?!得|着)(?:一下)?|一下|(?=[::,,\\s]|$))[::,,]?\\s*(.+)$",
|
|
78
|
+
"^\\s*(?:请)?帮我记(?:(?:住|录)(?!得|着)(?:一下)?|一下|(?=[::,,\\s]|$))[::,,]?\\s*(.+)$",
|
|
79
|
+
"^\\s*help\\s+me\\s+remember(?:\\s+that)?[:,]?\\s+(.+)$",
|
|
57
80
|
"^\\s*我觉得[::,,]?\\s*(.+)$",
|
|
58
81
|
"^\\s*我喜欢[::,,]?\\s*(.+)$",
|
|
59
82
|
],
|
|
@@ -65,19 +88,68 @@ export const DEFAULT_CONFIG: MyOMemoryConfig = {
|
|
|
65
88
|
sync: { autoPull: false },
|
|
66
89
|
};
|
|
67
90
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
91
|
+
/**
|
|
92
|
+
* Strip JSONC comments and trailing commas with a string-aware scanner.
|
|
93
|
+
* The previous regex version was not string-aware: a config value like
|
|
94
|
+
* "src/**\/secrets" had its `/**…*\/` eaten as a block comment, silently
|
|
95
|
+
* corrupting the user's redact pattern (P0 review, 2026-10-03). Shared
|
|
96
|
+
* with doctor.ts — one parser, not two.
|
|
97
|
+
*/
|
|
98
|
+
export function stripJsonComments(raw: string): string {
|
|
99
|
+
let out = "";
|
|
100
|
+
let i = 0;
|
|
101
|
+
let inStr = false;
|
|
102
|
+
let esc = false;
|
|
103
|
+
while (i < raw.length) {
|
|
104
|
+
const c = raw[i]!;
|
|
105
|
+
const n = raw[i + 1];
|
|
106
|
+
if (inStr) {
|
|
107
|
+
out += c;
|
|
108
|
+
if (esc) esc = false;
|
|
109
|
+
else if (c === "\\") esc = true;
|
|
110
|
+
else if (c === '"') inStr = false;
|
|
111
|
+
i++;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (c === '"') {
|
|
115
|
+
inStr = true;
|
|
116
|
+
out += c;
|
|
117
|
+
i++;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (c === "/" && n === "/") {
|
|
121
|
+
while (i < raw.length && raw[i] !== "\n") i++;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (c === "/" && n === "*") {
|
|
125
|
+
i += 2;
|
|
126
|
+
while (i < raw.length && !(raw[i] === "*" && raw[i + 1] === "/")) i++;
|
|
127
|
+
i += 2;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (c === ",") {
|
|
131
|
+
// Trailing comma: drop it when the next significant char closes a
|
|
132
|
+
// container. Whitespace/comments between are tolerated.
|
|
133
|
+
let j = i + 1;
|
|
134
|
+
while (j < raw.length && /\s/.test(raw[j]!)) j++;
|
|
135
|
+
if (raw[j] === "}" || raw[j] === "]") {
|
|
136
|
+
i++;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
out += c;
|
|
141
|
+
i++;
|
|
142
|
+
}
|
|
75
143
|
return out;
|
|
76
144
|
}
|
|
77
145
|
|
|
78
|
-
/**
|
|
146
|
+
/**
|
|
147
|
+
* Where `config set` / interactive `init` persist. Respects OPEN_MEMEX_CONFIG
|
|
148
|
+
* (D62); MY_O_MEMORY_CONFIG is the pre-rename fallback, still honored.
|
|
149
|
+
*/
|
|
79
150
|
export function configFilePath(): string {
|
|
80
151
|
return (
|
|
152
|
+
process.env.OPEN_MEMEX_CONFIG ??
|
|
81
153
|
process.env.MY_O_MEMORY_CONFIG ??
|
|
82
154
|
path.join(os.homedir(), ".config", "opencode", "open-memex.jsonc")
|
|
83
155
|
);
|
|
@@ -110,6 +182,7 @@ export const SETTABLE_KEYS: Record<string, (v: unknown) => unknown> = {
|
|
|
110
182
|
maxProfileItems: toNonNegInt,
|
|
111
183
|
injectOnFirstTurn: toBool,
|
|
112
184
|
keywordCaptureEnabled: toBool,
|
|
185
|
+
captureAliases: toBool,
|
|
113
186
|
memoryDir: toRelativeDir,
|
|
114
187
|
"sync.autoPull": toBool,
|
|
115
188
|
logLevel: (v) => {
|
|
@@ -146,6 +219,7 @@ export function saveConfig(patch: Record<string, unknown>): string {
|
|
|
146
219
|
/** Path of the config file in effect, or null when using built-in defaults. */
|
|
147
220
|
export function configSource(): string | null {
|
|
148
221
|
const candidates = [
|
|
222
|
+
process.env.OPEN_MEMEX_CONFIG,
|
|
149
223
|
process.env.MY_O_MEMORY_CONFIG,
|
|
150
224
|
path.join(os.homedir(), ".config", "opencode", "open-memex.jsonc"),
|
|
151
225
|
path.join(os.homedir(), ".config", "opencode", "open-memex.json"),
|
|
@@ -162,7 +236,20 @@ export function loadConfig(): MyOMemoryConfig {
|
|
|
162
236
|
try {
|
|
163
237
|
const raw = fs.readFileSync(p, "utf8");
|
|
164
238
|
const parsed = JSON.parse(stripJsonComments(raw)) as Partial<MyOMemoryConfig>;
|
|
165
|
-
|
|
239
|
+
const merged = { ...DEFAULT_CONFIG, ...parsed };
|
|
240
|
+
// The config file is hand-editable, so the load path must enforce
|
|
241
|
+
// the same invariants `config set` does (P0 review, 2026-10-03):
|
|
242
|
+
// an unvalidated memoryDir like "../../shared" would put
|
|
243
|
+
// committed project memories outside the repo.
|
|
244
|
+
try {
|
|
245
|
+
merged.memoryDir = toRelativeDir(merged.memoryDir);
|
|
246
|
+
} catch {
|
|
247
|
+
console.error(
|
|
248
|
+
`[open-memex] invalid memoryDir "${merged.memoryDir}" in ${p} — using default "${DEFAULT_CONFIG.memoryDir}"`,
|
|
249
|
+
);
|
|
250
|
+
merged.memoryDir = DEFAULT_CONFIG.memoryDir;
|
|
251
|
+
}
|
|
252
|
+
return merged;
|
|
166
253
|
} catch (err) {
|
|
167
254
|
console.error(`[open-memex] failed to parse ${p}:`, err);
|
|
168
255
|
}
|