open-memex 0.7.0-alpha.1 → 0.7.0-alpha.13
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 +23 -5
- package/CHANGELOG.md +88 -0
- package/README.md +46 -15
- package/README.zh-CN.md +27 -14
- 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 +37 -47
- package/dist/export.js +12 -2
- package/dist/index.js +4 -2
- package/dist/init.js +23 -6
- package/dist/inventory-html.js +150 -0
- package/dist/inventory.js +176 -0
- package/dist/mcp.js +21 -5
- package/dist/paths.js +34 -2
- 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/scope.js +17 -0
- 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 +299 -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 +227 -3
- package/scripts/test-full.ts +435 -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 +41 -42
- package/src/export.ts +14 -2
- package/src/index.ts +4 -2
- package/src/init.ts +25 -6
- package/src/inventory-html.ts +191 -0
- package/src/inventory.ts +242 -0
- package/src/mcp.ts +21 -5
- package/src/paths.ts +35 -1
- 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/scope.ts +18 -0
- 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/dist/cli.js
CHANGED
|
@@ -9,15 +9,62 @@ import { findDuplicates, supersede, setStatus } from "./store/lifecycle.js";
|
|
|
9
9
|
import { proposeMemories, promoteMemory, listConflicts, resolveConflict, formatReviewHistory } from "./review.js";
|
|
10
10
|
import { getSyncStatus, formatSyncStatus, submitMemories } from "./submit.js";
|
|
11
11
|
import { getPrStatus, formatPrStatus, applyPrStatus } from "./github.js";
|
|
12
|
-
import { search, list, hitStateLabel } from "./retrieve/search.js";
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
12
|
+
import { search, list, countList, hitStateLabel } from "./retrieve/search.js";
|
|
13
|
+
import { formatInventoryLine, truncationNote } from "./retrieve/display.js";
|
|
14
|
+
import { writeMemoryFile, readMemoryFile, ulid, msToRfc3339, normalizeAliases, } from "./store/markdown.js";
|
|
15
|
+
import { loadConfig, configSource } from "./config.js";
|
|
16
|
+
import { paths, projectRoot, isInRepoMemoryFile } from "./paths.js";
|
|
16
17
|
import { redact } from "./redact.js";
|
|
17
18
|
import { resolveMcpCommand } from "./init.js";
|
|
18
19
|
import fs from "node:fs";
|
|
19
20
|
import path from "node:path";
|
|
21
|
+
import { execFileSync } from "node:child_process";
|
|
20
22
|
import { fileURLToPath } from "node:url";
|
|
23
|
+
/**
|
|
24
|
+
* True when `target` sits inside a git working tree (D68 guard).
|
|
25
|
+
*
|
|
26
|
+
* D70: resolve the nearest EXISTING ancestor first. `git -C` on a
|
|
27
|
+
* directory that does not exist throws, and the old code turned that
|
|
28
|
+
* into "not in a worktree" — a fail-open guard on the one path where
|
|
29
|
+
* personal memories would ride a commit.
|
|
30
|
+
*/
|
|
31
|
+
function isInsideWorkTree(target) {
|
|
32
|
+
try {
|
|
33
|
+
let dir = path.dirname(path.resolve(target));
|
|
34
|
+
while (!fs.existsSync(dir)) {
|
|
35
|
+
const parent = path.dirname(dir);
|
|
36
|
+
if (parent === dir)
|
|
37
|
+
break;
|
|
38
|
+
dir = parent;
|
|
39
|
+
}
|
|
40
|
+
const out = execFileSync("git", ["-C", dir, "rev-parse", "--is-inside-work-tree"], {
|
|
41
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
42
|
+
encoding: "utf8",
|
|
43
|
+
});
|
|
44
|
+
return out.trim() === "true";
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* D70: the one place that decides where a report may be written, for
|
|
52
|
+
* every `inventory` format. Returns an error message to print, or null
|
|
53
|
+
* when the path is safe. Kept single so the text/json/html paths cannot
|
|
54
|
+
* drift apart (they already had two copies, D68).
|
|
55
|
+
*/
|
|
56
|
+
function inventoryWriteRefusal(target, includesPersonal, allowPersonal) {
|
|
57
|
+
// Personal content must not ride a commit (the design's hardest rule).
|
|
58
|
+
if (includesPersonal && !allowPersonal && isInsideWorkTree(target)) {
|
|
59
|
+
return (`inventory: ${target} is inside a git working tree and this report includes personal memories. ` +
|
|
60
|
+
`Refusing to write it there. Pass --allow-personal if you really want that, or write outside the repo.`);
|
|
61
|
+
}
|
|
62
|
+
const parent = path.dirname(path.resolve(target));
|
|
63
|
+
if (!fs.existsSync(parent)) {
|
|
64
|
+
return `inventory: the directory ${parent} does not exist — create it first, or choose another --out path.`;
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
21
68
|
/** Per-command help, printed by `open-memex <command> --help`.
|
|
22
69
|
AI assistants discover the CLI through --help, so every command needs one. */
|
|
23
70
|
const COMMAND_HELP = {
|
|
@@ -27,14 +74,20 @@ Usage: open-memex where
|
|
|
27
74
|
|
|
28
75
|
Example:
|
|
29
76
|
open-memex where`,
|
|
30
|
-
list: `List memories in a scope, newest first.
|
|
77
|
+
list: `List memories in a scope, newest first, as a numbered inventory.
|
|
31
78
|
|
|
32
|
-
Usage: open-memex list [--scope project|personal] [--type T] [--limit N]
|
|
79
|
+
Usage: open-memex list [--scope project|personal|both] [--type T] [--limit N] [--include active|all]
|
|
33
80
|
|
|
34
81
|
Flags:
|
|
35
|
-
--scope
|
|
36
|
-
--type
|
|
37
|
-
--limit
|
|
82
|
+
--scope project (default), personal, or both
|
|
83
|
+
--type filter by memory type
|
|
84
|
+
--limit max results (default 20, max 100)
|
|
85
|
+
--include active (default: what is currently remembered) or all (audit
|
|
86
|
+
view: superseded versions, retracted and archived included)
|
|
87
|
+
|
|
88
|
+
A cut list says how many entries it left out. Numbers are unique within one
|
|
89
|
+
listing, so "the third one" names exactly one memory - but they are only
|
|
90
|
+
valid for the listing you just ran.
|
|
38
91
|
|
|
39
92
|
Examples:
|
|
40
93
|
open-memex list
|
|
@@ -54,18 +107,25 @@ Example:
|
|
|
54
107
|
open-memex search "deploy checklist" --scope both`,
|
|
55
108
|
add: `Save a fact, preference, decision, or note to local memory.
|
|
56
109
|
|
|
57
|
-
Usage: open-memex add "content" [--scope project|personal] [--type T] [--tag t1,t2]
|
|
110
|
+
Usage: open-memex add "content" [--scope project|personal] [--type T] [--tag t1,t2] [--aliases "a;b"]
|
|
58
111
|
|
|
59
112
|
Flags:
|
|
60
|
-
--scope
|
|
61
|
-
--type
|
|
62
|
-
--tag
|
|
113
|
+
--scope project (default) or personal (personal never leaves this machine)
|
|
114
|
+
--type memory type (default: fact)
|
|
115
|
+
--tag comma-separated tags
|
|
116
|
+
--aliases alternate phrasings of this fact (up to 4), indexed with the
|
|
117
|
+
memory so differently-worded searches still match; separate
|
|
118
|
+
with ; (commas also accepted — use ; when an alias itself
|
|
119
|
+
contains one). Stored only when capture aliases are enabled
|
|
120
|
+
(init default)
|
|
63
121
|
|
|
64
122
|
Example:
|
|
65
123
|
open-memex add "We deploy on Fridays" --scope project --tag process`,
|
|
66
124
|
supersede: `Replace a memory with a newer version. The old one is kept as history.
|
|
67
125
|
|
|
68
|
-
Usage: open-memex supersede <id> "new content" [--type T] [--tag t1,t2]
|
|
126
|
+
Usage: open-memex supersede <id> "new content" [--type T] [--tag t1,t2] [--aliases "a;b"]
|
|
127
|
+
|
|
128
|
+
Without --aliases, the old memory's aliases carry over.
|
|
69
129
|
|
|
70
130
|
Example:
|
|
71
131
|
open-memex supersede 01ABC "We deploy on Thursdays now"`,
|
|
@@ -73,14 +133,21 @@ Example:
|
|
|
73
133
|
|
|
74
134
|
Usage: open-memex status <id> active|deprecated|retracted|archived
|
|
75
135
|
|
|
136
|
+
Retraction is one-way: a retracted memory cannot be set back to active
|
|
137
|
+
(it was withdrawn on purpose). Save the content as a new memory instead.
|
|
138
|
+
|
|
76
139
|
Example:
|
|
77
140
|
open-memex status 01ABC deprecated`,
|
|
78
|
-
forget: `Delete a memory by id.
|
|
141
|
+
forget: `Delete a memory by id. With --soft, hide it instead: the memory
|
|
142
|
+
is retracted (out of lists and search, file kept). Retraction is one-way —
|
|
143
|
+
save the content as a new memory to bring it back. If the memory lives in
|
|
144
|
+
the repo, either action is only local until you commit and push it.
|
|
79
145
|
|
|
80
|
-
Usage: open-memex forget <id>
|
|
146
|
+
Usage: open-memex forget <id> [--soft]
|
|
81
147
|
|
|
82
148
|
Example:
|
|
83
|
-
open-memex forget 01ABC
|
|
149
|
+
open-memex forget 01ABC
|
|
150
|
+
open-memex forget 01ABC --soft`,
|
|
84
151
|
propose: `Copy personal memories into the project outbox as review drafts.
|
|
85
152
|
The personal originals stay put. Nothing enters git at this step.
|
|
86
153
|
|
|
@@ -127,6 +194,21 @@ Usage: open-memex sync-status
|
|
|
127
194
|
|
|
128
195
|
Example:
|
|
129
196
|
open-memex sync-status`,
|
|
197
|
+
inventory: `Show everything remembered — personal + this project — in the
|
|
198
|
+
open: what each memory says, where it came from, and whether it has reached
|
|
199
|
+
the repo yet. Read-only; deleting stays a confirmed conversation with your
|
|
200
|
+
agent. Formats: text (default, diffable), json (for agents), html (a local
|
|
201
|
+
page for your browser; default writes <data dir>/inventory.html).
|
|
202
|
+
|
|
203
|
+
Usage: open-memex inventory [--format text|json|html] [--scope project|personal|both] [--out <path>] [--allow-personal]
|
|
204
|
+
|
|
205
|
+
Default scope: both. Without --out, text/json go to stdout. A report that
|
|
206
|
+
includes personal memories is refused inside a git working tree unless
|
|
207
|
+
--allow-personal is passed — personal content must not ride a commit.
|
|
208
|
+
|
|
209
|
+
Example:
|
|
210
|
+
open-memex inventory --format json --out ~/inventory.json
|
|
211
|
+
open-memex inventory --format html`,
|
|
130
212
|
pull: `Pull shared project memories from the git remote: fetch + fast-forward
|
|
131
213
|
only. Never auto-merges — a diverged branch fails with a clear message and is
|
|
132
214
|
left for you to resolve by hand. On success the local index re-syncs.
|
|
@@ -324,16 +406,17 @@ function usage(exitCode = 1) {
|
|
|
324
406
|
|
|
325
407
|
Usage:
|
|
326
408
|
open-memex where
|
|
327
|
-
open-memex list [--scope project|personal] [--type T] [--limit N]
|
|
409
|
+
open-memex list [--scope project|personal|both] [--type T] [--limit N] [--include active|all]
|
|
328
410
|
open-memex search "query" [--scope project|personal|both] [--type T] [--limit N] [--explain]
|
|
329
411
|
open-memex add "content" [--scope project|personal] [--type T] [--tag t1,t2]
|
|
330
412
|
open-memex supersede <id> "new content" [--type T] [--tag t1,t2]
|
|
331
413
|
open-memex status <id> active|deprecated|retracted|archived
|
|
332
|
-
open-memex forget <id>
|
|
414
|
+
open-memex forget <id> [--soft]
|
|
333
415
|
open-memex propose <id...> --to project [--local-approve]
|
|
334
416
|
open-memex promote <id> [--reject] [--resubmit] [--note "..."] [--by NAME]
|
|
335
417
|
open-memex resolve [id-or-path]
|
|
336
418
|
open-memex sync-status
|
|
419
|
+
open-memex inventory [--format text|json|html] [--scope project|personal|both] [--out <path>] [--allow-personal]
|
|
337
420
|
open-memex pull
|
|
338
421
|
open-memex push
|
|
339
422
|
open-memex export [--scope project|personal|both] [--type T] [--tag t] [--all] [-o <file>]
|
|
@@ -713,12 +796,11 @@ async function main() {
|
|
|
713
796
|
process.exit(1);
|
|
714
797
|
}
|
|
715
798
|
const cfg = loadConfig();
|
|
716
|
-
const {
|
|
799
|
+
const { scanKeywords, MIN_CAPTURE_CHARS, MAX_CAPTURE_CHARS } = await import("./capture/keywords.js");
|
|
717
800
|
const text = rest.slice(1).join(" ");
|
|
718
|
-
const hits =
|
|
801
|
+
const { hits, dropped } = scanKeywords(text, cfg);
|
|
719
802
|
if (hits.length === 0) {
|
|
720
|
-
console.log("no keyword triggers
|
|
721
|
-
return;
|
|
803
|
+
console.log("no keyword triggers - nothing would be captured.");
|
|
722
804
|
}
|
|
723
805
|
for (const h of hits) {
|
|
724
806
|
const r = redact(h.content, cfg.redactPatterns);
|
|
@@ -727,6 +809,13 @@ async function main() {
|
|
|
727
809
|
console.log(` secret hit: ${r.hadSecret ? `yes (${r.matchedPattern}) — will be masked` : "no"}`);
|
|
728
810
|
console.log(` body: ${r.content.slice(0, 160)}${r.content.length > 160 ? "…" : ""}`);
|
|
729
811
|
}
|
|
812
|
+
// D67: a trigger that matched but produced a fragment is reported, not
|
|
813
|
+
// silently dropped - otherwise "记住:这个" just looks broken.
|
|
814
|
+
for (const d of dropped) {
|
|
815
|
+
console.log(d.reason === "too-short"
|
|
816
|
+
? `! 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>".`
|
|
817
|
+
: `! matched but NOT captured: body is ${d.content.length} chars (ceiling: ${MAX_CAPTURE_CHARS}) - save it with memory_add instead.`);
|
|
818
|
+
}
|
|
730
819
|
return;
|
|
731
820
|
}
|
|
732
821
|
// `mcp` starts the stdio MCP server (11 tools; the opencode plugin exposes 5).
|
|
@@ -745,6 +834,7 @@ async function main() {
|
|
|
745
834
|
if (cmd === "where") {
|
|
746
835
|
const p = paths();
|
|
747
836
|
console.log(`root: ${p.root}`);
|
|
837
|
+
console.log(`config: ${configSource() ?? "(built-in defaults)"}`);
|
|
748
838
|
console.log(`memories: ${p.memories}`);
|
|
749
839
|
console.log(`index: ${p.indexDb}`);
|
|
750
840
|
console.log(`project: ${project.key}`);
|
|
@@ -759,20 +849,40 @@ async function main() {
|
|
|
759
849
|
}
|
|
760
850
|
if (cmd === "list") {
|
|
761
851
|
const flags = parseFlags(rest);
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
const
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
const
|
|
774
|
-
|
|
775
|
-
|
|
852
|
+
// D66: --scope both, on par with the memory_list tool — prints each
|
|
853
|
+
// scope's newest under its own header.
|
|
854
|
+
const scopes = flags.scope === "both" ? [PERSONAL_SCOPE, project] : [resolveCliScope(flags, project)];
|
|
855
|
+
const include = flags.include === "all" ? "all" : "active";
|
|
856
|
+
// D70: the CLI renders the SAME structured inventory the agent sees,
|
|
857
|
+
// numbering runs across the whole listing, and a cut list says how
|
|
858
|
+
// many entries it dropped — `open-memex list` is the surface a
|
|
859
|
+
// sceptical user runs first, so it may not look complete when it isn't.
|
|
860
|
+
let seq = 0;
|
|
861
|
+
for (const s of scopes) {
|
|
862
|
+
syncScope(s.key, "cli");
|
|
863
|
+
const hits = list(s.key, {
|
|
864
|
+
type: flags.type,
|
|
865
|
+
limit: flags.limit ? Number(flags.limit) : undefined,
|
|
866
|
+
include,
|
|
867
|
+
});
|
|
868
|
+
const total = countList(s.key, { type: flags.type, include });
|
|
869
|
+
if (scopes.length > 1) {
|
|
870
|
+
console.log(s.key === PERSONAL_SCOPE.key
|
|
871
|
+
? "## About you (personal — stays on this machine, applies everywhere)"
|
|
872
|
+
: `## This project (${s.projectName || s.key})`);
|
|
873
|
+
}
|
|
874
|
+
if (hits.length === 0) {
|
|
875
|
+
console.log(scopes.length > 1
|
|
876
|
+
? "(nothing remembered in this scope yet)"
|
|
877
|
+
: `(no memories in ${s.key})`);
|
|
878
|
+
continue;
|
|
879
|
+
}
|
|
880
|
+
for (const h of hits) {
|
|
881
|
+
console.log(formatInventoryLine(seq++, h, PERSONAL_SCOPE.key));
|
|
882
|
+
}
|
|
883
|
+
const note = truncationNote(hits.length, total);
|
|
884
|
+
if (note)
|
|
885
|
+
console.log(note);
|
|
776
886
|
}
|
|
777
887
|
return;
|
|
778
888
|
}
|
|
@@ -849,6 +959,18 @@ async function main() {
|
|
|
849
959
|
.map((t) => t.trim())
|
|
850
960
|
.filter(Boolean)
|
|
851
961
|
: [],
|
|
962
|
+
// D61: --aliases "phrase one; phrase two" (comma also accepted; use
|
|
963
|
+
// ; when an alias itself contains a comma). A bare --aliases with no
|
|
964
|
+
// value makes parseFlags yield "true" — treat that as absent. Stored
|
|
965
|
+
// only when the install enables capture aliases, redacted like content.
|
|
966
|
+
...(typeof flags.aliases === "string" && flags.aliases !== "true" && cfg.captureAliases
|
|
967
|
+
? {
|
|
968
|
+
aliases: normalizeAliases(flags.aliases
|
|
969
|
+
.split(/[;,]/)
|
|
970
|
+
.map((a) => redact(a.trim(), cfg.redactPatterns).content)
|
|
971
|
+
.filter(Boolean)),
|
|
972
|
+
}
|
|
973
|
+
: {}),
|
|
852
974
|
source: "cli",
|
|
853
975
|
created_at: rfc,
|
|
854
976
|
updated_at: rfc,
|
|
@@ -888,6 +1010,12 @@ async function main() {
|
|
|
888
1010
|
tags: flags.tag
|
|
889
1011
|
? flags.tag.split(",").map((t) => t.trim()).filter(Boolean)
|
|
890
1012
|
: undefined,
|
|
1013
|
+
aliases: typeof flags.aliases === "string" && flags.aliases !== "true" && cfg.captureAliases
|
|
1014
|
+
? normalizeAliases(flags.aliases
|
|
1015
|
+
.split(/[;,]/)
|
|
1016
|
+
.map((a) => redact(a.trim(), cfg.redactPatterns).content)
|
|
1017
|
+
.filter(Boolean))
|
|
1018
|
+
: undefined,
|
|
891
1019
|
source: "cli",
|
|
892
1020
|
});
|
|
893
1021
|
upsertFromFile(oldMf);
|
|
@@ -923,18 +1051,44 @@ async function main() {
|
|
|
923
1051
|
const id = rest[0];
|
|
924
1052
|
if (!id)
|
|
925
1053
|
usage();
|
|
1054
|
+
// D68: --soft hides (retracts) instead of deleting — same action as
|
|
1055
|
+
// the memory_forget tool's soft flag. One-way (D64).
|
|
1056
|
+
if (rest.includes("--soft")) {
|
|
1057
|
+
const target = db().prepare(`SELECT file_path FROM memories WHERE id = ?`).get(id);
|
|
1058
|
+
try {
|
|
1059
|
+
const mf = setStatus(id, "retracted");
|
|
1060
|
+
upsertFromFile(mf);
|
|
1061
|
+
console.log(`hidden ${id} (retracted — out of lists and search; save it again as a new memory to bring it back)`);
|
|
1062
|
+
// D70: same honesty as the hard delete — a retraction of an in-repo
|
|
1063
|
+
// memory is a local working-tree edit until it is committed and pushed.
|
|
1064
|
+
if (target && isInRepoMemoryFile(target.file_path)) {
|
|
1065
|
+
console.log(`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.`);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
catch (e) {
|
|
1069
|
+
console.error(`hide failed: ${e.message}`);
|
|
1070
|
+
process.exit(1);
|
|
1071
|
+
}
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
926
1074
|
const row = db().prepare(`SELECT file_path FROM memories WHERE id = ?`).get(id);
|
|
927
1075
|
if (!row) {
|
|
928
1076
|
console.error("not found");
|
|
929
1077
|
process.exit(1);
|
|
930
1078
|
}
|
|
931
1079
|
// Delete by indexed file_path — location-agnostic (appdata or in-repo, 2B/D24).
|
|
1080
|
+
// Only ENOENT = success; anything else means the file survives and
|
|
1081
|
+
// would be re-indexed — fail loudly (P0 review, 2026-10-03).
|
|
932
1082
|
if (row.file_path) {
|
|
933
1083
|
try {
|
|
934
1084
|
fs.unlinkSync(row.file_path);
|
|
935
1085
|
}
|
|
936
|
-
catch {
|
|
937
|
-
|
|
1086
|
+
catch (e) {
|
|
1087
|
+
const code = e.code;
|
|
1088
|
+
if (code !== "ENOENT") {
|
|
1089
|
+
console.error(`could not delete ${row.file_path} (${code ?? "error"}) — memory not forgotten`);
|
|
1090
|
+
process.exit(1);
|
|
1091
|
+
}
|
|
938
1092
|
}
|
|
939
1093
|
}
|
|
940
1094
|
deleteFromIndex(id);
|
|
@@ -1054,11 +1208,73 @@ async function main() {
|
|
|
1054
1208
|
}
|
|
1055
1209
|
return;
|
|
1056
1210
|
}
|
|
1211
|
+
if (cmd === "inventory") {
|
|
1212
|
+
// D68: refresh both scopes first so the report matches the markdown
|
|
1213
|
+
// on disk. Default scope: personal + this project.
|
|
1214
|
+
syncScope(project.key, "cli");
|
|
1215
|
+
syncScope(PERSONAL_SCOPE.key, "cli");
|
|
1216
|
+
const flags = parseFlags(rest);
|
|
1217
|
+
const format = flags.format ?? "text";
|
|
1218
|
+
if (format !== "text" && format !== "json" && format !== "html") {
|
|
1219
|
+
console.error(`inventory: unknown --format '${format}' (text|json|html)`);
|
|
1220
|
+
process.exit(2);
|
|
1221
|
+
}
|
|
1222
|
+
const scopeKeys = flags.scope === "personal"
|
|
1223
|
+
? [PERSONAL_SCOPE.key]
|
|
1224
|
+
: flags.scope === "project"
|
|
1225
|
+
? [project.key]
|
|
1226
|
+
: [PERSONAL_SCOPE.key, project.key];
|
|
1227
|
+
const outPath = flags["out"] ?? flags["output"];
|
|
1228
|
+
const includesPersonal = scopeKeys.includes(PERSONAL_SCOPE.key);
|
|
1229
|
+
const allowPersonal = flags["allow-personal"] === "true";
|
|
1230
|
+
try {
|
|
1231
|
+
const { loadInventory, renderInventoryText, renderInventoryJson } = await import("./inventory.js");
|
|
1232
|
+
if (format === "html") {
|
|
1233
|
+
const { renderInventoryHtml, defaultHtmlOutPath } = await import("./inventory-html.js");
|
|
1234
|
+
const target = outPath ?? defaultHtmlOutPath(paths().root);
|
|
1235
|
+
// D70: one guard for every format (this path had its own copy).
|
|
1236
|
+
const refusal = inventoryWriteRefusal(target, includesPersonal, allowPersonal);
|
|
1237
|
+
if (refusal) {
|
|
1238
|
+
console.error(refusal);
|
|
1239
|
+
process.exit(2);
|
|
1240
|
+
}
|
|
1241
|
+
// D70: only the audit surface needs the hidden rows themselves;
|
|
1242
|
+
// text/json report counts, so they must not pay to load them.
|
|
1243
|
+
const data = loadInventory(scopeKeys, { hidden: "entries" });
|
|
1244
|
+
fs.writeFileSync(target, renderInventoryHtml(data), "utf8");
|
|
1245
|
+
console.log(`inventory: ${data.total} memories → ${target}`);
|
|
1246
|
+
console.log(`open it in any browser; the report is read-only.`);
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
const data = loadInventory(scopeKeys);
|
|
1250
|
+
const rendered = format === "json" ? renderInventoryJson(data) : renderInventoryText(data);
|
|
1251
|
+
if (outPath) {
|
|
1252
|
+
// D68/3-C v2: the report contains personal content. Writing it into
|
|
1253
|
+
// a git working tree would ride the next commit off this machine —
|
|
1254
|
+
// refuse unless the user explicitly accepts that.
|
|
1255
|
+
const refusal = inventoryWriteRefusal(outPath, includesPersonal, allowPersonal);
|
|
1256
|
+
if (refusal) {
|
|
1257
|
+
console.error(refusal);
|
|
1258
|
+
process.exit(2);
|
|
1259
|
+
}
|
|
1260
|
+
fs.writeFileSync(outPath, rendered, "utf8");
|
|
1261
|
+
console.log(`inventory: ${data.total} memories → ${outPath}`);
|
|
1262
|
+
}
|
|
1263
|
+
else {
|
|
1264
|
+
process.stdout.write(rendered);
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
catch (e) {
|
|
1268
|
+
console.error(`inventory failed: ${e.message}`);
|
|
1269
|
+
process.exit(2);
|
|
1270
|
+
}
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1057
1273
|
if (cmd === "sync-status") {
|
|
1058
1274
|
syncScope(project.key, "cli");
|
|
1059
1275
|
syncScope(PERSONAL_SCOPE.key, "cli");
|
|
1060
1276
|
try {
|
|
1061
|
-
console.log(formatSyncStatus(getSyncStatus()));
|
|
1277
|
+
console.log(formatSyncStatus(getSyncStatus(), { cap: Number.POSITIVE_INFINITY }));
|
|
1062
1278
|
}
|
|
1063
1279
|
catch (e) {
|
|
1064
1280
|
console.error(`sync-status failed: ${e.message}`);
|
|
@@ -1151,6 +1367,9 @@ async function main() {
|
|
|
1151
1367
|
syncScope(PERSONAL_SCOPE.key, "cli");
|
|
1152
1368
|
}
|
|
1153
1369
|
console.log(`${dryRun ? "DRY RUN: " : ""}imported ${r.imported}, skipped ${r.skippedIdentical} identical`);
|
|
1370
|
+
for (const rp of r.rejectedPaths) {
|
|
1371
|
+
console.log(` rejected: ${rp} (path outside the bundle)`);
|
|
1372
|
+
}
|
|
1154
1373
|
for (const c of r.skippedConflict) {
|
|
1155
1374
|
console.log(` conflict (kept existing): ${c.id} from ${c.file}`);
|
|
1156
1375
|
}
|
package/dist/config.js
CHANGED
|
@@ -6,26 +6,41 @@ export const DEFAULT_CONFIG = {
|
|
|
6
6
|
maxProfileItems: 5,
|
|
7
7
|
injectOnFirstTurn: true,
|
|
8
8
|
keywordCaptureEnabled: true,
|
|
9
|
+
captureAliases: true,
|
|
9
10
|
keywordPatterns: [
|
|
10
|
-
"^\\s*remember(?!\\s+for\\s+me)(?:\\s+that)?[:,]?\\s+(.+)$",
|
|
11
|
-
"^\\s*(?:please\\s+)?
|
|
11
|
+
"^\\s*remember(?!\\s+for\\s+me)(?:\\s+that|\\s+to)?[:,]?\\s+(.+)$",
|
|
12
|
+
"^\\s*(?:please\\s+)?note(?:\\s+that)?[:,]?\\s+(.+)$",
|
|
13
|
+
// D67: "don't forget the wifi password" is narration addressed to the
|
|
14
|
+
// agent, not a statement to the store. An explicit marker - "that" or a
|
|
15
|
+
// colon - makes it an instruction ("don't forget that ..." / "don't
|
|
16
|
+
// forget: ..."). Mirrors 别忘了 below; see D67.
|
|
17
|
+
"^\\s*(?:please\\s+)?don'?t\\s+forget(?:\\s+that\\s+|[:,]\\s+)(.+)$",
|
|
12
18
|
"^\\s*TIL[:,]?\\s+(.+)$",
|
|
13
19
|
"^\\s*save\\s+(?:this|to\\s+memory)[:,]?\\s+(.+)$",
|
|
14
20
|
// Chinese equivalents
|
|
15
|
-
"^\\s
|
|
21
|
+
"^\\s*(?:请)?记住(?!(个人))[::,,]?\\s*(.+)$",
|
|
16
22
|
"^\\s*(?:请)?(?:记一下|记录一下)[::,,]?\\s*(.+)$",
|
|
17
|
-
"
|
|
23
|
+
// D67: separator required. "别忘了带伞" is narration ("don't forget your
|
|
24
|
+
// umbrella"); "别忘了:周五不发布" is an instruction to the store. Same
|
|
25
|
+
// rule as the English "don't forget" above.
|
|
26
|
+
"^\\s*别忘了[::,,]\\s*(.+)$",
|
|
18
27
|
// First-person plural: team/project context, NOT personal
|
|
19
28
|
"^\\s*我们认为[::,,]?\\s*(.+)$",
|
|
20
29
|
"^\\s*我们决定[::,,]?\\s*(.+)$",
|
|
21
|
-
|
|
30
|
+
// D67: the boundary applies to the BARE verb only. 记 + (住|录) + 一下 is a
|
|
31
|
+
// complete verb phrase, so the noun may follow directly ("帮我记住这个配置",
|
|
32
|
+
// "记一下这个约定"); only a bare 记 needs a separator, otherwise 帮我记得…
|
|
33
|
+
// would capture mid-word garbage. See D67.
|
|
34
|
+
"^\\s*(?:请)?帮我们记(?:(?:住|录)(?!得|着)(?:一下)?|一下|(?=[::,,\\s]|$))[::,,]?\\s*(.+)$",
|
|
22
35
|
],
|
|
23
36
|
keywordPersonalPatterns: [
|
|
24
37
|
"^\\s*remember\\s+for\\s+me(?:\\s+that)?[:,]?\\s+(.+)$",
|
|
25
|
-
"^\\s
|
|
38
|
+
"^\\s*(?:请)?记住(个人)[::,,]?\\s*(.+)$",
|
|
26
39
|
// First-person singular: personal scope ("我" → 个人, "我们" → 项目)
|
|
27
|
-
"^\\s
|
|
28
|
-
"^\\s
|
|
40
|
+
"^\\s*(?:请)?记住我(?!们)[::,,]?\\s*(.+)$",
|
|
41
|
+
"^\\s*(?:请)?替我记(?:(?:住|录)(?!得|着)(?:一下)?|一下|(?=[::,,\\s]|$))[::,,]?\\s*(.+)$",
|
|
42
|
+
"^\\s*(?:请)?帮我记(?:(?:住|录)(?!得|着)(?:一下)?|一下|(?=[::,,\\s]|$))[::,,]?\\s*(.+)$",
|
|
43
|
+
"^\\s*help\\s+me\\s+remember(?:\\s+that)?[:,]?\\s+(.+)$",
|
|
29
44
|
"^\\s*我觉得[::,,]?\\s*(.+)$",
|
|
30
45
|
"^\\s*我喜欢[::,,]?\\s*(.+)$",
|
|
31
46
|
],
|
|
@@ -36,18 +51,73 @@ export const DEFAULT_CONFIG = {
|
|
|
36
51
|
memoryDir: ".ai/open-memex",
|
|
37
52
|
sync: { autoPull: false },
|
|
38
53
|
};
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Strip JSONC comments and trailing commas with a string-aware scanner.
|
|
56
|
+
* The previous regex version was not string-aware: a config value like
|
|
57
|
+
* "src/**\/secrets" had its `/**…*\/` eaten as a block comment, silently
|
|
58
|
+
* corrupting the user's redact pattern (P0 review, 2026-10-03). Shared
|
|
59
|
+
* with doctor.ts — one parser, not two.
|
|
60
|
+
*/
|
|
61
|
+
export function stripJsonComments(raw) {
|
|
62
|
+
let out = "";
|
|
63
|
+
let i = 0;
|
|
64
|
+
let inStr = false;
|
|
65
|
+
let esc = false;
|
|
66
|
+
while (i < raw.length) {
|
|
67
|
+
const c = raw[i];
|
|
68
|
+
const n = raw[i + 1];
|
|
69
|
+
if (inStr) {
|
|
70
|
+
out += c;
|
|
71
|
+
if (esc)
|
|
72
|
+
esc = false;
|
|
73
|
+
else if (c === "\\")
|
|
74
|
+
esc = true;
|
|
75
|
+
else if (c === '"')
|
|
76
|
+
inStr = false;
|
|
77
|
+
i++;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (c === '"') {
|
|
81
|
+
inStr = true;
|
|
82
|
+
out += c;
|
|
83
|
+
i++;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (c === "/" && n === "/") {
|
|
87
|
+
while (i < raw.length && raw[i] !== "\n")
|
|
88
|
+
i++;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (c === "/" && n === "*") {
|
|
92
|
+
i += 2;
|
|
93
|
+
while (i < raw.length && !(raw[i] === "*" && raw[i + 1] === "/"))
|
|
94
|
+
i++;
|
|
95
|
+
i += 2;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (c === ",") {
|
|
99
|
+
// Trailing comma: drop it when the next significant char closes a
|
|
100
|
+
// container. Whitespace/comments between are tolerated.
|
|
101
|
+
let j = i + 1;
|
|
102
|
+
while (j < raw.length && /\s/.test(raw[j]))
|
|
103
|
+
j++;
|
|
104
|
+
if (raw[j] === "}" || raw[j] === "]") {
|
|
105
|
+
i++;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
out += c;
|
|
110
|
+
i++;
|
|
111
|
+
}
|
|
46
112
|
return out;
|
|
47
113
|
}
|
|
48
|
-
/**
|
|
114
|
+
/**
|
|
115
|
+
* Where `config set` / interactive `init` persist. Respects OPEN_MEMEX_CONFIG
|
|
116
|
+
* (D62); MY_O_MEMORY_CONFIG is the pre-rename fallback, still honored.
|
|
117
|
+
*/
|
|
49
118
|
export function configFilePath() {
|
|
50
|
-
return (process.env.
|
|
119
|
+
return (process.env.OPEN_MEMEX_CONFIG ??
|
|
120
|
+
process.env.MY_O_MEMORY_CONFIG ??
|
|
51
121
|
path.join(os.homedir(), ".config", "opencode", "open-memex.jsonc"));
|
|
52
122
|
}
|
|
53
123
|
function toBool(v) {
|
|
@@ -79,6 +149,7 @@ export const SETTABLE_KEYS = {
|
|
|
79
149
|
maxProfileItems: toNonNegInt,
|
|
80
150
|
injectOnFirstTurn: toBool,
|
|
81
151
|
keywordCaptureEnabled: toBool,
|
|
152
|
+
captureAliases: toBool,
|
|
82
153
|
memoryDir: toRelativeDir,
|
|
83
154
|
"sync.autoPull": toBool,
|
|
84
155
|
logLevel: (v) => {
|
|
@@ -109,6 +180,7 @@ export function saveConfig(patch) {
|
|
|
109
180
|
/** Path of the config file in effect, or null when using built-in defaults. */
|
|
110
181
|
export function configSource() {
|
|
111
182
|
const candidates = [
|
|
183
|
+
process.env.OPEN_MEMEX_CONFIG,
|
|
112
184
|
process.env.MY_O_MEMORY_CONFIG,
|
|
113
185
|
path.join(os.homedir(), ".config", "opencode", "open-memex.jsonc"),
|
|
114
186
|
path.join(os.homedir(), ".config", "opencode", "open-memex.json"),
|
|
@@ -125,7 +197,19 @@ export function loadConfig() {
|
|
|
125
197
|
try {
|
|
126
198
|
const raw = fs.readFileSync(p, "utf8");
|
|
127
199
|
const parsed = JSON.parse(stripJsonComments(raw));
|
|
128
|
-
|
|
200
|
+
const merged = { ...DEFAULT_CONFIG, ...parsed };
|
|
201
|
+
// The config file is hand-editable, so the load path must enforce
|
|
202
|
+
// the same invariants `config set` does (P0 review, 2026-10-03):
|
|
203
|
+
// an unvalidated memoryDir like "../../shared" would put
|
|
204
|
+
// committed project memories outside the repo.
|
|
205
|
+
try {
|
|
206
|
+
merged.memoryDir = toRelativeDir(merged.memoryDir);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
console.error(`[open-memex] invalid memoryDir "${merged.memoryDir}" in ${p} — using default "${DEFAULT_CONFIG.memoryDir}"`);
|
|
210
|
+
merged.memoryDir = DEFAULT_CONFIG.memoryDir;
|
|
211
|
+
}
|
|
212
|
+
return merged;
|
|
129
213
|
}
|
|
130
214
|
catch (err) {
|
|
131
215
|
console.error(`[open-memex] failed to parse ${p}:`, err);
|
package/dist/distill-agents.js
CHANGED
|
@@ -57,7 +57,15 @@ export function distillAgentsMarkdown(opts) {
|
|
|
57
57
|
// (D53: the checkpoint mechanism is gone).
|
|
58
58
|
lines.push(`### Memory hygiene (open-memex)`);
|
|
59
59
|
lines.push(``);
|
|
60
|
-
lines.push(`- When you finish a task the user would describe in one sentence, distill`, ` the session: propose 1–3 short memories capturing the useful`, ` conclusion — what was learned or decided, how an issue was resolved, what`, ` to avoid, where the authoritative doc lives — not the raw transcript.`, ` Save nothing without user approval.`, `- If the knowledge already lives in project docs, save a \`reference\` memory`, ` pointing at the doc instead of copying it
|
|
60
|
+
lines.push(`- When you finish a task the user would describe in one sentence, distill`, ` the session: propose 1–3 short memories capturing the useful`, ` conclusion — what was learned or decided, how an issue was resolved, what`, ` to avoid, where the authoritative doc lives — not the raw transcript.`, ` Save nothing without user approval.`, ` Worth keeping: decisions and their reasons, preferences, conventions,`, ` gotchas, approaches tried and abandoned. Not worth keeping: one-off task`, ` details or anything re-derivable from the code.`, `- If the knowledge already lives in project docs, save a \`reference\` memory`, ` pointing at the doc instead of copying it.`, `- When saving via \`memory_add\`, attach 2–4 aliases (alternate phrasings,`, ` synonyms, equivalents in the user's other language) when the install has`, ` capture aliases enabled — they make reworded questions find the memory.`,
|
|
61
|
+
// D64: the skills runbook and the MCP handshake both teach marking
|
|
62
|
+
// inferred captures source: "inference"; this footer omitted it, so
|
|
63
|
+
// opencode agents saved distilled conclusions as if the user had
|
|
64
|
+
// stated them ("tool"). Keep the rubric one voice across surfaces.
|
|
65
|
+
`- Mark captures you infer (rather than the user stating) with`, ` \`source: "inference"\` — provenance matters more than polish.`,
|
|
66
|
+
// D68: same voice for the visibility surface — opencode agents reading
|
|
67
|
+
// this footer never see the MCP handshake either.
|
|
68
|
+
`- When the user asks what you remember, list it conversationally`, ` (\`memory_list\` with scope both, or \`open-memex inventory\`) — plain`, ` words, not raw ids. Before deleting an entry they point at, read it`, ` back in full and confirm; deletion is permanent. Offer hiding`, ` (\`memory_forget\` soft / \`forget --soft\`) when they hesitate.`);
|
|
61
69
|
lines.push(``);
|
|
62
70
|
return lines.join("\n");
|
|
63
71
|
}
|