open-memex 0.7.0-alpha.2 → 0.7.0-alpha.4
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 +3 -2
- package/CHANGELOG.md +15 -0
- package/README.md +3 -2
- package/README.zh-CN.md +1 -1
- package/dist/cli.js +11 -2
- package/dist/config.js +78 -10
- package/dist/doctor.js +16 -47
- package/dist/export.js +12 -2
- package/dist/init.js +1 -0
- package/dist/paths.js +16 -2
- package/dist/store/migrate.js +13 -2
- package/dist/tools/ops.js +11 -2
- package/docs/V2-DESIGN.md +32 -3
- package/package.json +1 -1
- package/scripts/smoke-pure.ts +68 -0
- package/scripts/test-full.ts +83 -0
- package/src/cli.ts +11 -2
- package/src/config.ts +72 -9
- package/src/doctor.ts +17 -42
- package/src/export.ts +14 -2
- package/src/init.ts +1 -0
- package/src/paths.ts +16 -1
- package/src/store/migrate.ts +13 -1
- package/src/tools/ops.ts +11 -2
package/AGENTS.md
CHANGED
|
@@ -102,7 +102,7 @@ The published `open-memex` bin points at `dist/cli.js` (compiled at publish time
|
|
|
102
102
|
From a source checkout, `npm run cli` / `npm run mcp` still run `src/` directly
|
|
103
103
|
with type-stripping — no build step needed for development.
|
|
104
104
|
|
|
105
|
-
There is **no `npm test`** and no CI. Verification loop is: `npm run typecheck` + `smoke-pure.ts` + (if touching sqlite) `npm run cli -- reindex` against a scratch `
|
|
105
|
+
There is **no `npm test`** and no CI. Verification loop is: `npm run typecheck` + `smoke-pure.ts` + (if touching sqlite) `npm run cli -- reindex` against a scratch `OPEN_MEMEX_HOME`.
|
|
106
106
|
|
|
107
107
|
To load the plugin in opencode locally, `~/.config/opencode/opencode.jsonc` must have:
|
|
108
108
|
```
|
|
@@ -115,7 +115,7 @@ Restart opencode after any change — the plugin is not hot-reloaded.
|
|
|
115
115
|
Storage root:
|
|
116
116
|
- Windows: `%APPDATA%\open-memex\`
|
|
117
117
|
- Linux/macOS: `$XDG_DATA_HOME/open-memex/` (fallback `~/.local/share/open-memex/`)
|
|
118
|
-
- Override with `
|
|
118
|
+
- Override with `OPEN_MEMEX_HOME` (use this for tests to avoid clobbering real data). The pre-rename `MY_O_MEMORY_HOME` is still honored as a fallback (D62).
|
|
119
119
|
|
|
120
120
|
Layout: `memories/<scope_key>/<id>.md` (YAML frontmatter + body) + `index.db` (SQLite FTS5).
|
|
121
121
|
|
|
@@ -136,6 +136,7 @@ Every write path (tool, keyword hook, CLI `add`) must:
|
|
|
136
136
|
2. If `hadSecret` → the matched secret is **masked in place** (first 4 characters kept, the rest replaced with `x`) and the write proceeds; never save the unmasked original. `<private>…</private>` spans are stripped to `[REDACTED]` instead (design D14).
|
|
137
137
|
3. Check `findDuplicates` (design §3.4): identical content is idempotent (return existing id); near-duplicates (similarity ≥ 0.8) warn but save — suggest `supersede` when the new content replaces the old.
|
|
138
138
|
4. `writeMemoryFile` first, then `readMemoryFile` + `upsertFromFile` to keep FTS in sync.
|
|
139
|
+
5. Deletion reports honestly (D63): only ENOENT counts as "deleted"; any other unlink failure keeps the index row and returns failure — the file is the source of truth and would otherwise resurrect the memory on next sync.
|
|
139
140
|
|
|
140
141
|
Capture-time aliases (D61): `memory_add`/`supersede` may carry up to 4
|
|
141
142
|
`aliases` (alternate phrasings) into frontmatter; they index in their own FTS
|
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,21 @@ the `@alpha` tag are development snapshots and are not listed individually.
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
## Correctness fixes from the whole-project review (D63)
|
|
13
|
+
|
|
14
|
+
Six silent-wrong-answer bugs found by an external three-lens review, each fixed with a regression test:
|
|
15
|
+
|
|
16
|
+
- `init --client visualstudio` no longer wipes other servers from an existing `.mcp.json` (the parsed config was dropped instead of merged).
|
|
17
|
+
- `memory_forget` / `forget` tell the truth: when the memory file can't be deleted (locked, in use), they report failure and keep the index entry, instead of claiming success while the file — the source of truth — brings the memory back on next sync.
|
|
18
|
+
- Config parsing is string-aware: a redact pattern like `src/**/secrets` is no longer eaten as a block comment and silently disabled. `doctor` and the config loader now share one parser.
|
|
19
|
+
- `loadConfig` enforces the `memoryDir` invariant on hand-edited configs; an escaping value (`../../shared`) falls back to the default with a warning instead of writing project memories outside the repo.
|
|
20
|
+
- `migrate` re-scopes as well as re-keys: migrated files get the destination's `scope`/`visibility`, so personal→project migrations are submittable and project→personal memories stay private in exports. Conflict resolution compares parsed timestamps, not RFC-3339 strings.
|
|
21
|
+
- `import` rejects bundle entries whose paths escape the bundle (`../` or symlinks) instead of reading arbitrary local files into the store; rejected paths are printed.
|
|
22
|
+
|
|
23
|
+
## Environment variable names (D62)
|
|
24
|
+
|
|
25
|
+
- The override variables now answer to `OPEN_MEMEX_HOME` (storage root) and `OPEN_MEMEX_CONFIG` (config path). The pre-rename `MY_O_MEMORY_HOME` / `MY_O_MEMORY_CONFIG` names keep working as fallbacks — nothing to migrate, the data root itself does not move. `doctor` notes when the storage root came from the legacy variable.
|
|
26
|
+
|
|
12
27
|
## Proactive capture: checkpoint proposals and aliases (D61)
|
|
13
28
|
|
|
14
29
|
- At task checkpoints the agent now proposes instead of waiting to be asked: when a task wraps up, it offers 1–3 short candidate memories distilled from the session (decisions and their reasons, conventions, gotchas, approaches tried and abandoned) and saves only the ones you approve — approved inferences are marked `source: "inference"`. One memorability rubric and one search discipline (break the question into concepts, try several phrasings, check both scopes) are written into every guidance surface — the bundled skill, the AGENTS.md memory-hygiene footer, the MCP handshake, and the tool descriptions — so behavior is consistent across editors. Nothing is auto-drafted in the background; that posture waits on memory visibility.
|
package/README.md
CHANGED
|
@@ -578,8 +578,9 @@ branches and PRs like any other file.
|
|
|
578
578
|
|
|
579
579
|
Settings live in `~/.config/opencode/open-memex.jsonc` — the `opencode` in the
|
|
580
580
|
path is historical; this one file is shared by every client. Override the config
|
|
581
|
-
path with `
|
|
582
|
-
(
|
|
581
|
+
path with `OPEN_MEMEX_CONFIG` and the storage root with `OPEN_MEMEX_HOME`
|
|
582
|
+
(the pre-rename `MY_O_MEMORY_CONFIG` / `MY_O_MEMORY_HOME` names are still
|
|
583
|
+
honored as fallbacks).
|
|
583
584
|
|
|
584
585
|
Defaults:
|
|
585
586
|
|
package/README.zh-CN.md
CHANGED
|
@@ -391,7 +391,7 @@ npm install -g open-memex@latest # 或 @alpha
|
|
|
391
391
|
|
|
392
392
|
## 配置
|
|
393
393
|
|
|
394
|
-
设置放在 `~/.config/opencode/open-memex.jsonc`——路径里的 `opencode` 是历史名;这一个文件由所有客户端共享。用 `
|
|
394
|
+
设置放在 `~/.config/opencode/open-memex.jsonc`——路径里的 `opencode` 是历史名;这一个文件由所有客户端共享。用 `OPEN_MEMEX_CONFIG` 覆盖配置路径,用 `OPEN_MEMEX_HOME` 覆盖存储根目录(改名之前的旧变量名 `MY_O_MEMORY_CONFIG` / `MY_O_MEMORY_HOME` 仍然有效,作为回退)。
|
|
395
395
|
|
|
396
396
|
默认值:
|
|
397
397
|
|
package/dist/cli.js
CHANGED
|
@@ -954,12 +954,18 @@ async function main() {
|
|
|
954
954
|
process.exit(1);
|
|
955
955
|
}
|
|
956
956
|
// Delete by indexed file_path — location-agnostic (appdata or in-repo, 2B/D24).
|
|
957
|
+
// Only ENOENT = success; anything else means the file survives and
|
|
958
|
+
// would be re-indexed — fail loudly (P0 review, 2026-10-03).
|
|
957
959
|
if (row.file_path) {
|
|
958
960
|
try {
|
|
959
961
|
fs.unlinkSync(row.file_path);
|
|
960
962
|
}
|
|
961
|
-
catch {
|
|
962
|
-
|
|
963
|
+
catch (e) {
|
|
964
|
+
const code = e.code;
|
|
965
|
+
if (code !== "ENOENT") {
|
|
966
|
+
console.error(`could not delete ${row.file_path} (${code ?? "error"}) — memory not forgotten`);
|
|
967
|
+
process.exit(1);
|
|
968
|
+
}
|
|
963
969
|
}
|
|
964
970
|
}
|
|
965
971
|
deleteFromIndex(id);
|
|
@@ -1176,6 +1182,9 @@ async function main() {
|
|
|
1176
1182
|
syncScope(PERSONAL_SCOPE.key, "cli");
|
|
1177
1183
|
}
|
|
1178
1184
|
console.log(`${dryRun ? "DRY RUN: " : ""}imported ${r.imported}, skipped ${r.skippedIdentical} identical`);
|
|
1185
|
+
for (const rp of r.rejectedPaths) {
|
|
1186
|
+
console.log(` rejected: ${rp} (path outside the bundle)`);
|
|
1187
|
+
}
|
|
1179
1188
|
for (const c of r.skippedConflict) {
|
|
1180
1189
|
console.log(` conflict (kept existing): ${c.id} from ${c.file}`);
|
|
1181
1190
|
}
|
package/dist/config.js
CHANGED
|
@@ -37,18 +37,73 @@ export const DEFAULT_CONFIG = {
|
|
|
37
37
|
memoryDir: ".ai/open-memex",
|
|
38
38
|
sync: { autoPull: false },
|
|
39
39
|
};
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Strip JSONC comments and trailing commas with a string-aware scanner.
|
|
42
|
+
* The previous regex version was not string-aware: a config value like
|
|
43
|
+
* "src/**\/secrets" had its `/**…*\/` eaten as a block comment, silently
|
|
44
|
+
* corrupting the user's redact pattern (P0 review, 2026-10-03). Shared
|
|
45
|
+
* with doctor.ts — one parser, not two.
|
|
46
|
+
*/
|
|
47
|
+
export function stripJsonComments(raw) {
|
|
48
|
+
let out = "";
|
|
49
|
+
let i = 0;
|
|
50
|
+
let inStr = false;
|
|
51
|
+
let esc = false;
|
|
52
|
+
while (i < raw.length) {
|
|
53
|
+
const c = raw[i];
|
|
54
|
+
const n = raw[i + 1];
|
|
55
|
+
if (inStr) {
|
|
56
|
+
out += c;
|
|
57
|
+
if (esc)
|
|
58
|
+
esc = false;
|
|
59
|
+
else if (c === "\\")
|
|
60
|
+
esc = true;
|
|
61
|
+
else if (c === '"')
|
|
62
|
+
inStr = false;
|
|
63
|
+
i++;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (c === '"') {
|
|
67
|
+
inStr = true;
|
|
68
|
+
out += c;
|
|
69
|
+
i++;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (c === "/" && n === "/") {
|
|
73
|
+
while (i < raw.length && raw[i] !== "\n")
|
|
74
|
+
i++;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (c === "/" && n === "*") {
|
|
78
|
+
i += 2;
|
|
79
|
+
while (i < raw.length && !(raw[i] === "*" && raw[i + 1] === "/"))
|
|
80
|
+
i++;
|
|
81
|
+
i += 2;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (c === ",") {
|
|
85
|
+
// Trailing comma: drop it when the next significant char closes a
|
|
86
|
+
// container. Whitespace/comments between are tolerated.
|
|
87
|
+
let j = i + 1;
|
|
88
|
+
while (j < raw.length && /\s/.test(raw[j]))
|
|
89
|
+
j++;
|
|
90
|
+
if (raw[j] === "}" || raw[j] === "]") {
|
|
91
|
+
i++;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
out += c;
|
|
96
|
+
i++;
|
|
97
|
+
}
|
|
47
98
|
return out;
|
|
48
99
|
}
|
|
49
|
-
/**
|
|
100
|
+
/**
|
|
101
|
+
* Where `config set` / interactive `init` persist. Respects OPEN_MEMEX_CONFIG
|
|
102
|
+
* (D62); MY_O_MEMORY_CONFIG is the pre-rename fallback, still honored.
|
|
103
|
+
*/
|
|
50
104
|
export function configFilePath() {
|
|
51
|
-
return (process.env.
|
|
105
|
+
return (process.env.OPEN_MEMEX_CONFIG ??
|
|
106
|
+
process.env.MY_O_MEMORY_CONFIG ??
|
|
52
107
|
path.join(os.homedir(), ".config", "opencode", "open-memex.jsonc"));
|
|
53
108
|
}
|
|
54
109
|
function toBool(v) {
|
|
@@ -111,6 +166,7 @@ export function saveConfig(patch) {
|
|
|
111
166
|
/** Path of the config file in effect, or null when using built-in defaults. */
|
|
112
167
|
export function configSource() {
|
|
113
168
|
const candidates = [
|
|
169
|
+
process.env.OPEN_MEMEX_CONFIG,
|
|
114
170
|
process.env.MY_O_MEMORY_CONFIG,
|
|
115
171
|
path.join(os.homedir(), ".config", "opencode", "open-memex.jsonc"),
|
|
116
172
|
path.join(os.homedir(), ".config", "opencode", "open-memex.json"),
|
|
@@ -127,7 +183,19 @@ export function loadConfig() {
|
|
|
127
183
|
try {
|
|
128
184
|
const raw = fs.readFileSync(p, "utf8");
|
|
129
185
|
const parsed = JSON.parse(stripJsonComments(raw));
|
|
130
|
-
|
|
186
|
+
const merged = { ...DEFAULT_CONFIG, ...parsed };
|
|
187
|
+
// The config file is hand-editable, so the load path must enforce
|
|
188
|
+
// the same invariants `config set` does (P0 review, 2026-10-03):
|
|
189
|
+
// an unvalidated memoryDir like "../../shared" would put
|
|
190
|
+
// committed project memories outside the repo.
|
|
191
|
+
try {
|
|
192
|
+
merged.memoryDir = toRelativeDir(merged.memoryDir);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
console.error(`[open-memex] invalid memoryDir "${merged.memoryDir}" in ${p} — using default "${DEFAULT_CONFIG.memoryDir}"`);
|
|
196
|
+
merged.memoryDir = DEFAULT_CONFIG.memoryDir;
|
|
197
|
+
}
|
|
198
|
+
return merged;
|
|
131
199
|
}
|
|
132
200
|
catch (err) {
|
|
133
201
|
console.error(`[open-memex] failed to parse ${p}:`, err);
|
package/dist/doctor.js
CHANGED
|
@@ -7,8 +7,8 @@ import os from "node:os";
|
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import { spawn, execFileSync } from "node:child_process";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
|
-
import { loadConfig, configSource, DEFAULT_CONFIG } from "./config.js";
|
|
11
|
-
import { paths } from "./paths.js";
|
|
10
|
+
import { loadConfig, configSource, DEFAULT_CONFIG, stripJsonComments } from "./config.js";
|
|
11
|
+
import { paths, homeOverride } from "./paths.js";
|
|
12
12
|
import { resolveCwdScope } from "./scope.js";
|
|
13
13
|
import { db, backendName } from "./store/db.js";
|
|
14
14
|
import { LEGACY_PACKAGE_NAME, opencodeGlobalConfigPath, userMcpConfigPath } from "./init.js";
|
|
@@ -54,7 +54,13 @@ function storageCheck() {
|
|
|
54
54
|
try {
|
|
55
55
|
const p = paths();
|
|
56
56
|
fs.accessSync(p.memories, fs.constants.W_OK);
|
|
57
|
-
|
|
57
|
+
// D62: when the root came from the legacy env var, say so (still ok —
|
|
58
|
+
// the fallback is supported, doctor just names the current one).
|
|
59
|
+
const via = homeOverride()?.via;
|
|
60
|
+
const note = via === "MY_O_MEMORY_HOME"
|
|
61
|
+
? " (via legacy MY_O_MEMORY_HOME — OPEN_MEMEX_HOME is the current name)"
|
|
62
|
+
: "";
|
|
63
|
+
return { name: "storage", ok: true, detail: `${p.root} (writable)${note}` };
|
|
58
64
|
}
|
|
59
65
|
catch (err) {
|
|
60
66
|
return { name: "storage", ok: false, detail: String(err) };
|
|
@@ -401,7 +407,8 @@ function mcpCheck() {
|
|
|
401
407
|
});
|
|
402
408
|
}
|
|
403
409
|
/** Parse JSON tolerating line/block comments plus trailing commas
|
|
404
|
-
* (VS Code's settings.json is JSONC).
|
|
410
|
+
* (VS Code's settings.json is JSONC). Uses config.ts's string-aware
|
|
411
|
+
* stripper — one parser, not two (P0 review, 2026-10-03). */
|
|
405
412
|
function parseLenientJson(raw) {
|
|
406
413
|
try {
|
|
407
414
|
return JSON.parse(raw);
|
|
@@ -409,47 +416,7 @@ function parseLenientJson(raw) {
|
|
|
409
416
|
catch {
|
|
410
417
|
/* fall through to comment stripping */
|
|
411
418
|
}
|
|
412
|
-
|
|
413
|
-
let i = 0;
|
|
414
|
-
let inStr = false;
|
|
415
|
-
let esc = false;
|
|
416
|
-
while (i < raw.length) {
|
|
417
|
-
const c = raw[i];
|
|
418
|
-
const n = raw[i + 1];
|
|
419
|
-
if (inStr) {
|
|
420
|
-
out += c;
|
|
421
|
-
if (esc)
|
|
422
|
-
esc = false;
|
|
423
|
-
else if (c === "\\")
|
|
424
|
-
esc = true;
|
|
425
|
-
else if (c === '"')
|
|
426
|
-
inStr = false;
|
|
427
|
-
i++;
|
|
428
|
-
continue;
|
|
429
|
-
}
|
|
430
|
-
if (c === '"') {
|
|
431
|
-
inStr = true;
|
|
432
|
-
out += c;
|
|
433
|
-
i++;
|
|
434
|
-
continue;
|
|
435
|
-
}
|
|
436
|
-
if (c === "/" && n === "/") {
|
|
437
|
-
while (i < raw.length && raw[i] !== "\n")
|
|
438
|
-
i++;
|
|
439
|
-
continue;
|
|
440
|
-
}
|
|
441
|
-
if (c === "/" && n === "*") {
|
|
442
|
-
i += 2;
|
|
443
|
-
while (i < raw.length && !(raw[i] === "*" && raw[i + 1] === "/"))
|
|
444
|
-
i++;
|
|
445
|
-
i += 2;
|
|
446
|
-
continue;
|
|
447
|
-
}
|
|
448
|
-
out += c;
|
|
449
|
-
i++;
|
|
450
|
-
}
|
|
451
|
-
out = out.replace(/,\s*([}\]])/g, "$1");
|
|
452
|
-
return JSON.parse(out);
|
|
419
|
+
return JSON.parse(stripJsonComments(raw));
|
|
453
420
|
}
|
|
454
421
|
function vscodeSettingsPath() {
|
|
455
422
|
const home = os.homedir();
|
|
@@ -559,9 +526,11 @@ export async function runDoctor() {
|
|
|
559
526
|
function legacyCheck() {
|
|
560
527
|
const name = "legacy my-o-memory";
|
|
561
528
|
const found = [];
|
|
562
|
-
|
|
529
|
+
// D62: the override may arrive via either env var name; check the value
|
|
530
|
+
// in effect, not just the legacy one.
|
|
531
|
+
const envHome = homeOverride()?.dir;
|
|
563
532
|
if (envHome && envHome.includes(LEGACY_PACKAGE_NAME))
|
|
564
|
-
found.push(`
|
|
533
|
+
found.push(`memory home points at a pre-rename dir (${envHome}) — unset it, then merge old data with \`open-memex migrate --to-v2\``);
|
|
565
534
|
const root = paths().root;
|
|
566
535
|
const legacyDir = path.join(path.dirname(root), LEGACY_PACKAGE_NAME);
|
|
567
536
|
if (fs.existsSync(legacyDir) && fs.statSync(legacyDir).isDirectory())
|
package/dist/export.js
CHANGED
|
@@ -117,10 +117,20 @@ export function importBundle(bundlePath, opts) {
|
|
|
117
117
|
if (manifest.format !== EXPORT_FORMAT) {
|
|
118
118
|
fail(`unsupported bundle format: ${manifest.format} (expected ${EXPORT_FORMAT})`);
|
|
119
119
|
}
|
|
120
|
-
const result = { imported: 0, skippedIdentical: 0, skippedConflict: [] };
|
|
120
|
+
const result = { imported: 0, skippedIdentical: 0, skippedConflict: [], rejectedPaths: [] };
|
|
121
121
|
const existingStmt = db().prepare(`SELECT content_hash FROM memories WHERE id = ?`);
|
|
122
|
+
// The manifest comes from whoever sent the bundle: entry.file must
|
|
123
|
+
// stay inside the staging dir (P0 review, 2026-10-03 — "../" entries
|
|
124
|
+
// otherwise read arbitrary .md files into the store). realpath also
|
|
125
|
+
// catches symlinks planted by the tarball.
|
|
126
|
+
const stageRoot = fs.realpathSync(stage);
|
|
122
127
|
for (const entry of manifest.memories ?? []) {
|
|
123
|
-
const
|
|
128
|
+
const joined = path.resolve(path.join(stage, entry.file));
|
|
129
|
+
const src = fs.existsSync(joined) ? fs.realpathSync(joined) : joined;
|
|
130
|
+
if (!src.startsWith(stageRoot + path.sep)) {
|
|
131
|
+
result.rejectedPaths.push(entry.file);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
124
134
|
const mf = readMemoryFile(src);
|
|
125
135
|
if (!mf)
|
|
126
136
|
continue;
|
package/dist/init.js
CHANGED
package/dist/paths.js
CHANGED
|
@@ -2,9 +2,23 @@ import path from "node:path";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import { execFileSync } from "node:child_process";
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* D62: the data-root override env var. `OPEN_MEMEX_HOME` is the current
|
|
7
|
+
* name; `MY_O_MEMORY_HOME` (pre-rename package name) is still honored as a
|
|
8
|
+
* fallback so existing setups and scripts keep working. New name wins
|
|
9
|
+
* when both are set.
|
|
10
|
+
*/
|
|
11
|
+
export function homeOverride() {
|
|
12
|
+
if (process.env.OPEN_MEMEX_HOME)
|
|
13
|
+
return { dir: path.resolve(process.env.OPEN_MEMEX_HOME), via: "OPEN_MEMEX_HOME" };
|
|
6
14
|
if (process.env.MY_O_MEMORY_HOME)
|
|
7
|
-
return path.resolve(process.env.MY_O_MEMORY_HOME);
|
|
15
|
+
return { dir: path.resolve(process.env.MY_O_MEMORY_HOME), via: "MY_O_MEMORY_HOME" };
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
function dataRoot() {
|
|
19
|
+
const override = homeOverride();
|
|
20
|
+
if (override)
|
|
21
|
+
return override.dir;
|
|
8
22
|
if (process.platform === "win32") {
|
|
9
23
|
const appData = process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming");
|
|
10
24
|
return path.join(appData, "open-memex");
|
package/dist/store/migrate.js
CHANGED
|
@@ -2,8 +2,9 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { memoriesDirFor, memoriesDirPath } from "../paths.js";
|
|
4
4
|
import { db } from "./db.js";
|
|
5
|
-
import { iterMemoryFiles, readMemoryFile, serialize, } from "./markdown.js";
|
|
5
|
+
import { iterMemoryFiles, readMemoryFile, serialize, timeToMs, } from "./markdown.js";
|
|
6
6
|
import { syncScope } from "./sync.js";
|
|
7
|
+
import { PERSONAL_SCOPE } from "../scope.js";
|
|
7
8
|
/** True if this scope has at least one `.md` file on disk. Does not create
|
|
8
9
|
* the directory if it doesn't already exist. */
|
|
9
10
|
export function scopeHasFiles(scopeKey) {
|
|
@@ -60,7 +61,10 @@ export function migrateScope(fromKey, toKey, opts = {}) {
|
|
|
60
61
|
else if (strategy === "overwrite") {
|
|
61
62
|
log(`conflict: overwriting destination (--on-conflict=overwrite): ${dstPath}`);
|
|
62
63
|
}
|
|
63
|
-
else if (dst.fm.updated_at >= mf.fm.updated_at) {
|
|
64
|
+
else if (timeToMs(dst.fm.updated_at) >= timeToMs(mf.fm.updated_at)) {
|
|
65
|
+
// Compare parsed instants, not RFC-3339 strings: lexicographic
|
|
66
|
+
// order breaks around fractional seconds ("…:00Z" > "…:00.005Z"),
|
|
67
|
+
// letting the older file win.
|
|
64
68
|
action = "skip";
|
|
65
69
|
log(`conflict: kept destination (newer updated_at): ${dstPath}`);
|
|
66
70
|
}
|
|
@@ -72,9 +76,16 @@ export function migrateScope(fromKey, toKey, opts = {}) {
|
|
|
72
76
|
stats.skipped++;
|
|
73
77
|
continue;
|
|
74
78
|
}
|
|
79
|
+
// Re-key AND re-scope: scope/visibility must follow the destination
|
|
80
|
+
// (P0 review, 2026-10-03). Rewriting only scope_key produced files
|
|
81
|
+
// submit rejects ("scope is personal") or personal memories carrying
|
|
82
|
+
// visibility: internal into exports.
|
|
83
|
+
const toPersonal = toKey === PERSONAL_SCOPE.key;
|
|
75
84
|
const newFm = {
|
|
76
85
|
...mf.fm,
|
|
77
86
|
scope_key: toKey,
|
|
87
|
+
scope: toPersonal ? "personal" : "project",
|
|
88
|
+
visibility: toPersonal ? "private" : "internal",
|
|
78
89
|
project_name: opts.toProjectName ?? mf.fm.project_name,
|
|
79
90
|
};
|
|
80
91
|
const raw = serialize(newFm, mf.body);
|
package/dist/tools/ops.js
CHANGED
|
@@ -273,12 +273,21 @@ export async function forgetMemory(args) {
|
|
|
273
273
|
return { title: "memory: not found", output: `No memory with id ${args.id}.` };
|
|
274
274
|
}
|
|
275
275
|
// Delete by indexed file_path — location-agnostic (appdata or in-repo, 2B/D24).
|
|
276
|
+
// Only ENOENT counts as "deleted": on any other unlink failure the file
|
|
277
|
+
// is still the source of truth and would be re-indexed on next sync, so
|
|
278
|
+
// report failure instead of lying (P0 review, 2026-10-03).
|
|
276
279
|
if (row.file_path) {
|
|
277
280
|
try {
|
|
278
281
|
fs.unlinkSync(row.file_path);
|
|
279
282
|
}
|
|
280
|
-
catch {
|
|
281
|
-
|
|
283
|
+
catch (e) {
|
|
284
|
+
const code = e.code;
|
|
285
|
+
if (code !== "ENOENT") {
|
|
286
|
+
return {
|
|
287
|
+
title: "memory: forget failed",
|
|
288
|
+
output: `Could not delete ${row.file_path} (${code ?? "error"}) — the memory was NOT forgotten and is still in the index. Close anything holding the file and retry.`,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
282
291
|
}
|
|
283
292
|
}
|
|
284
293
|
deleteFromIndex(args.id);
|
package/docs/V2-DESIGN.md
CHANGED
|
@@ -1304,13 +1304,42 @@ requirement: personal data never touches third-party services). Benchmarks to tr
|
|
|
1304
1304
|
distillation over host transcripts stays its own group; it will
|
|
1305
1305
|
reuse this rubric.
|
|
1306
1306
|
|
|
1307
|
+
- **D62 — environment variable names grow current-name aliases.** The
|
|
1308
|
+
data-root and config-path overrides predate the rename and kept the
|
|
1309
|
+
old package's name (`MY_O_MEMORY_HOME`, `MY_O_MEMORY_CONFIG`) — a
|
|
1310
|
+
stale-looking name is a trap for users and agents scripting against
|
|
1311
|
+
the current one. `OPEN_MEMEX_HOME` / `OPEN_MEMEX_CONFIG` are now the
|
|
1312
|
+
preferred names; the legacy names still work as fallbacks (new name
|
|
1313
|
+
wins when both are set), so existing setups and scripts never break.
|
|
1314
|
+
The data root itself does not move — only the variable names change.
|
|
1315
|
+
`doctor`'s storage check notes when the root arrived via the legacy
|
|
1316
|
+
variable, and its pre-rename-dir leftovers check reads whichever
|
|
1317
|
+
variable is in effect.
|
|
1318
|
+
|
|
1319
|
+
- **D63 — correctness fixes from a whole-project external review.** Six
|
|
1320
|
+
verified silent-wrong-answer bugs, fixed as one batch: (1) VS init
|
|
1321
|
+
dropped the parsed `.mcp.json` instead of merging it, wiping other
|
|
1322
|
+
servers; (2) forget reported success when unlink failed — only ENOENT
|
|
1323
|
+
is success now, both in the tool op and the CLI; (3) the config
|
|
1324
|
+
loader's regex comment-stripper was not string-aware and corrupted
|
|
1325
|
+
redact patterns (`src/**/secrets` → `srcsecrets`) — replaced with a
|
|
1326
|
+
string-aware scanner now shared with doctor (one parser, not two);
|
|
1327
|
+
(4) `loadConfig` enforces the `memoryDir` no-escape invariant on
|
|
1328
|
+
hand-edited files, falling back to the default with a warning;
|
|
1329
|
+
(5) `migrate` rewrites `scope`/`visibility` with the key, and
|
|
1330
|
+
conflict resolution compares parsed timestamps (lexicographic
|
|
1331
|
+
RFC-3339 order breaks at fractional seconds); (6) bundle import
|
|
1332
|
+
enforces staging-dir containment (resolve + realpath), rejecting
|
|
1333
|
+
`../` and symlink escapes. Rule going forward: any behavior that can
|
|
1334
|
+
fail must fail loudly in its return value; "already gone" is the only
|
|
1335
|
+
swallowed delete error.
|
|
1336
|
+
|
|
1307
1337
|
## Open Questions
|
|
1308
1338
|
|
|
1309
1339
|
_All resolved — see D10 (rename), D11 (type/role split), D12 (explicit pull)._
|
|
1340
|
+
- **Checkpoint proactivity for capture (open, 2026-10-02):** D53 retired checkpoint *polling* — the server pushes draft counts instead. But agent proactivity at task checkpoints genuinely helps knowledge capture (§3.5 distillation), so the open question is the degree: proactive enough to catch distillable moments, without nagging the user or burning tokens at every checkpoint. Candidate directions (server-pushed hints in tool results, client-side heuristics, or a middle setting) to be evaluated in a later version; default posture TBD. **Update (2026-10-03): decided in D61 — propose posture** (agent proposes 1–3 candidates at task checkpoints, saves only on approval; auto-draft deferred until memory visibility ships, see the MEMORY_VISIBILITY question below).
|
|
1310
1341
|
|
|
1311
|
-
- **
|
|
1312
|
-
|
|
1313
|
-
- **Retrieval robustness: phrasing, synonyms, extra words (open, 2026-10-03):** keyword search matches word forms, not meaning — the asker phrases it differently, adds a word, or uses a synonym and the memory is missed. The current FTS5 setup (porter stemming + pre-tokenized CJK bigrams) already covers morphology and substrings; it cannot bridge semantics. Layered candidate directions, cheapest first: (1) query construction — never feed a raw sentence to FTS; extract content terms, drop stopwords, OR them and let bm25 rank, so extra words hurt ranking instead of killing the match; (2) agent-side query expansion as an explicit protocol in the skill/instructions (decompose the question into concepts, generate 2–3 synonym variants per concept, run several searches before giving up — the LLM is the synonym dictionary, no embedding model needed); (3) capture-time aliasing — when a memory is written, the agent attaches keyword/synonym aliases so future phrasings can hit it. Embeddings are the design's reserved optional capability: real semantic matching, but they require a design decision and a local model to distribute, so they stay deferred unless (1)–(3) prove insufficient. Note (3) pairs naturally with the proactive-capture question below: the same capture pass that proposes a memory can attach its aliases. **Status (2026-10-03): layer (1) shipped (D56, 0.6.1)** — query construction now filters EN/CJK function words, dedupes, and caps terms. Layers (2) agent-side expansion and (3) capture-time aliasing remain open; embeddings stay deferred.
|
|
1342
|
+
- **Retrieval robustness: phrasing, synonyms, extra words (open, 2026-10-03):** keyword search matches word forms, not meaning — the asker phrases it differently, adds a word, or uses a synonym and the memory is missed. The current FTS5 setup (porter stemming + pre-tokenized CJK bigrams) already covers morphology and substrings; it cannot bridge semantics. Layered candidate directions, cheapest first: (1) query construction — never feed a raw sentence to FTS; extract content terms, drop stopwords, OR them and let bm25 rank, so extra words hurt ranking instead of killing the match; (2) agent-side query expansion as an explicit protocol in the skill/instructions (decompose the question into concepts, generate 2–3 synonym variants per concept, run several searches before giving up — the LLM is the synonym dictionary, no embedding model needed); (3) capture-time aliasing — when a memory is written, the agent attaches keyword/synonym aliases so future phrasings can hit it. Embeddings are the design's reserved optional capability: real semantic matching, but they require a design decision and a local model to distribute, so they stay deferred unless (1)–(3) prove insufficient. Note (3) pairs naturally with the proactive-capture question below: the same capture pass that proposes a memory can attach its aliases. **Status (2026-10-03): layer (1) shipped (D56, 0.6.1)** — query construction now filters EN/CJK function words, dedupes, and caps terms. Layers (2) agent-side expansion and (3) capture-time aliasing remain open; embeddings stay deferred. **Update (2026-10-03): layers (2) and (3) shipped in D61** — agent-side expansion as guidance text on every surface, capture-time aliases indexed in their own FTS column behind the `captureAliases` flag. Embeddings stay deferred.
|
|
1314
1343
|
|
|
1315
1344
|
- **Proactive capture: agent judgment of what is worth remembering (open, 2026-10-03):** companion to the checkpoint-proactivity question above — that one asks *when* to prompt; this one asks *what qualifies*. Agent hosts with good long-term memory (e.g. Muse) get their results from explicit memorability criteria plus checkpoint consolidation, not from silent background magic; open-memex can adopt both halves. Candidate criteria to teach via the skill/instructions: decisions **and their rationale**, preferences, conventions, gotchas, and abandoned paths ("we tried X, it failed because…") are worth proposing; one-off task details and anything re-derivable from the code are not. Two postures to evaluate: propose-at-checkpoint (agent asks "worth saving?", user vetoes, then `memory_add`) versus auto-draft (agent writes outbox drafts unasked and surfaces them in a batch at the next checkpoint — drafts are git-invisible and deletable, so the cost of a wrong guess is low). Hard boundaries either way: project scope never auto-submits, and no cross-session background consolidation is possible — the plugin lives inside sessions, so consolidation happens at in-session checkpoints and wrap-up passes only. This question also decides how much aliasing (retrieval question above) the capture pass should generate.
|
|
1316
1345
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "open-memex",
|
|
3
|
-
"version": "0.7.0-alpha.
|
|
3
|
+
"version": "0.7.0-alpha.4",
|
|
4
4
|
"description": "Local-first memory layer and protocol for AI coding agents. Markdown source of truth, SQLite FTS5 index, zero cloud.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
package/scripts/smoke-pure.ts
CHANGED
|
@@ -12,6 +12,42 @@ import { contentHash, similarity, NEAR_DUP_THRESHOLD } from "../src/store/lifecy
|
|
|
12
12
|
import { userMcpConfigPath, mergeServerEntry, detectInstalledClients, mergePluginEntry, mergeV2PluginEntry, removeV2PluginEntry, opencodeGlobalConfigPath, printManualEntryHint, parseJsonConfig, removeServerEntry, removePluginEntry, removeInstructionsSection, LEGACY_PACKAGE_NAME, shouldInstallSkill } from "../src/init.ts";
|
|
13
13
|
import { z } from "zod";
|
|
14
14
|
import { memoryAddArgs } from "../src/tools/ops.ts";
|
|
15
|
+
import { dataRootPath, homeOverride } from "../src/paths.ts";
|
|
16
|
+
import { configFilePath, loadConfig, stripJsonComments } from "../src/config.ts";
|
|
17
|
+
|
|
18
|
+
console.log("== jsonc parsing + config invariants (P0) ==");
|
|
19
|
+
{
|
|
20
|
+
// The pre-fix regex stripper ate "src/**/secrets" as a block comment,
|
|
21
|
+
// silently disabling the user's redact rule.
|
|
22
|
+
const doc = JSON.parse(stripJsonComments(`{
|
|
23
|
+
// a line comment with a "quote
|
|
24
|
+
"redactPatterns": ["src/**/secrets", "a /* not a comment */ b"],
|
|
25
|
+
/* a real block comment */
|
|
26
|
+
"memoryDir": ".ai/open-memex",
|
|
27
|
+
}`)) as { redactPatterns: string[] };
|
|
28
|
+
ok("redact pattern with ** survives comment stripping",
|
|
29
|
+
doc.redactPatterns[0] === "src/**/secrets" && doc.redactPatterns[1] === "a /* not a comment */ b",
|
|
30
|
+
JSON.stringify(doc.redactPatterns));
|
|
31
|
+
}
|
|
32
|
+
{
|
|
33
|
+
// loadConfig must enforce the memoryDir invariant on hand-edited files.
|
|
34
|
+
const saved = process.env.OPEN_MEMEX_CONFIG;
|
|
35
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "om-cfg-"));
|
|
36
|
+
const file = path.join(dir, "c.jsonc");
|
|
37
|
+
fs.writeFileSync(file, JSON.stringify({ memoryDir: "../../shared" }));
|
|
38
|
+
const errs: unknown[] = [];
|
|
39
|
+
const origErr = console.error;
|
|
40
|
+
console.error = (...a: unknown[]) => errs.push(a);
|
|
41
|
+
try {
|
|
42
|
+
process.env.OPEN_MEMEX_CONFIG = file;
|
|
43
|
+
ok("escaping memoryDir falls back to default", loadConfig().memoryDir === ".ai/open-memex");
|
|
44
|
+
} finally {
|
|
45
|
+
console.error = origErr;
|
|
46
|
+
if (saved === undefined) delete process.env.OPEN_MEMEX_CONFIG;
|
|
47
|
+
else process.env.OPEN_MEMEX_CONFIG = saved;
|
|
48
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
15
51
|
|
|
16
52
|
let fails = 0;
|
|
17
53
|
function ok(name: string, cond: boolean, info?: unknown) {
|
|
@@ -423,6 +459,38 @@ ok("failed backup leaves legacy dir for retry", fs.existsSync(legacyRoot2));
|
|
|
423
459
|
delete process.env.MY_O_MEMORY_HOME;
|
|
424
460
|
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
425
461
|
|
|
462
|
+
console.log("== env var names (D62) ==");
|
|
463
|
+
{
|
|
464
|
+
const saved = {
|
|
465
|
+
n: process.env.OPEN_MEMEX_HOME,
|
|
466
|
+
l: process.env.MY_O_MEMORY_HOME,
|
|
467
|
+
nc: process.env.OPEN_MEMEX_CONFIG,
|
|
468
|
+
lc: process.env.MY_O_MEMORY_CONFIG,
|
|
469
|
+
};
|
|
470
|
+
const restore = () => {
|
|
471
|
+
const set = (k: string, v: string | undefined) => (v === undefined ? delete process.env[k] : (process.env[k] = v));
|
|
472
|
+
set("OPEN_MEMEX_HOME", saved.n);
|
|
473
|
+
set("MY_O_MEMORY_HOME", saved.l);
|
|
474
|
+
set("OPEN_MEMEX_CONFIG", saved.nc);
|
|
475
|
+
set("MY_O_MEMORY_CONFIG", saved.lc);
|
|
476
|
+
};
|
|
477
|
+
try {
|
|
478
|
+
delete process.env.OPEN_MEMEX_HOME;
|
|
479
|
+
delete process.env.MY_O_MEMORY_HOME;
|
|
480
|
+
ok("no override -> homeOverride null", homeOverride() === null);
|
|
481
|
+
process.env.MY_O_MEMORY_HOME = "/tmp/legacy-home";
|
|
482
|
+
ok("legacy fallback honored", dataRootPath().endsWith("legacy-home") && homeOverride()?.via === "MY_O_MEMORY_HOME");
|
|
483
|
+
process.env.OPEN_MEMEX_HOME = "/tmp/new-home";
|
|
484
|
+
ok("new name wins over legacy", dataRootPath().endsWith("new-home") && homeOverride()?.via === "OPEN_MEMEX_HOME");
|
|
485
|
+
process.env.MY_O_MEMORY_CONFIG = "/tmp/legacy.jsonc";
|
|
486
|
+
ok("config legacy fallback", configFilePath().endsWith("legacy.jsonc"));
|
|
487
|
+
process.env.OPEN_MEMEX_CONFIG = "/tmp/new.jsonc";
|
|
488
|
+
ok("config new name wins", configFilePath().endsWith("new.jsonc"));
|
|
489
|
+
} finally {
|
|
490
|
+
restore();
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
426
494
|
console.log("== lifecycle pure: contentHash / similarity ==");
|
|
427
495
|
ok("hash deterministic + whitespace-insensitive", contentHash("hello world\n") === contentHash("hello world"));
|
|
428
496
|
ok("hash differs on content", contentHash("hello world") !== contentHash("hello mars"));
|
package/scripts/test-full.ts
CHANGED
|
@@ -176,6 +176,40 @@ ok("forget deletes", r.code === 0 && /delet/i.test(r.out), r.out.slice(0, 120));
|
|
|
176
176
|
r = cli(["list"], PROJ);
|
|
177
177
|
ok("forgotten id gone from list", !r.out.includes(idDecision));
|
|
178
178
|
|
|
179
|
+
// P0: forget must not report success when the file cannot be deleted —
|
|
180
|
+
// the file is the source of truth and would be re-indexed on next sync.
|
|
181
|
+
// (Simulated by putting a directory where the file belongs: unlink fails
|
|
182
|
+
// with EISDIR even for root, unlike permission bits.)
|
|
183
|
+
{
|
|
184
|
+
const idF = addMem(["fulltest forget failure"], PROJ);
|
|
185
|
+
const memRoot = path.join(TESTENV.MY_O_MEMORY_HOME as string, "memories");
|
|
186
|
+
let memFile = "";
|
|
187
|
+
const findFile = (d: string) => {
|
|
188
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
189
|
+
const p2 = path.join(d, e.name);
|
|
190
|
+
if (e.isDirectory()) findFile(p2);
|
|
191
|
+
else if (e.name === idF + ".md") memFile = p2;
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
findFile(memRoot);
|
|
195
|
+
ok("forget-failure setup found the file", !!memFile);
|
|
196
|
+
const backup = fs.readFileSync(memFile, "utf8");
|
|
197
|
+
fs.rmSync(memFile);
|
|
198
|
+
fs.mkdirSync(memFile);
|
|
199
|
+
try {
|
|
200
|
+
r = cli(["forget", idF], PROJ);
|
|
201
|
+
ok("forget reports failure when unlink fails",
|
|
202
|
+
r.code !== 0 && /could not delete/i.test(r.err + r.out),
|
|
203
|
+
(r.err || r.out).slice(0, 150));
|
|
204
|
+
} finally {
|
|
205
|
+
fs.rmSync(memFile, { recursive: true });
|
|
206
|
+
fs.writeFileSync(memFile, backup);
|
|
207
|
+
}
|
|
208
|
+
r = cli(["list"], PROJ);
|
|
209
|
+
ok("memory survives failed forget", r.out.includes(idF), r.out.slice(0, 150));
|
|
210
|
+
r = cli(["forget", idF], PROJ);
|
|
211
|
+
ok("forget succeeds once the file is deletable again", r.code === 0, (r.err || r.out).slice(0, 150));
|
|
212
|
+
}
|
|
179
213
|
// ---------- 4. propose / submit / promote / sync-status ----------
|
|
180
214
|
console.log("== review workflow: propose / submit / promote ==");
|
|
181
215
|
const idProp = addMem(["fulltest proposal candidate", "--scope", "personal"], PROJ);
|
|
@@ -231,6 +265,26 @@ const cfile = path.join(T, "conflict.tar.gz");
|
|
|
231
265
|
execFileSync("tar", ["-czf", cfile, "-C", cstage, "manifest.json", "memories"], { env: TESTENV });
|
|
232
266
|
r = cli2Retry(["import", cfile], PROJ2);
|
|
233
267
|
ok("import conflict reported, never overwritten", /conflict/.test(r.out) && /imported 0/.test(r.out), r.out.slice(0, 160));
|
|
268
|
+
// P0: a bundle manifest pointing outside the bundle must be rejected —
|
|
269
|
+
// it otherwise imports arbitrary local .md files into the store. The
|
|
270
|
+
// secret is planted next to the import staging dir (os.tmpdir()), which
|
|
271
|
+
// is where "../" from a manifest entry lands.
|
|
272
|
+
{
|
|
273
|
+
const secretName = `outside-secret-${process.pid}.md`;
|
|
274
|
+
const secretFile = path.join(os.tmpdir(), secretName);
|
|
275
|
+
const secretId = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
|
|
276
|
+
fs.writeFileSync(secretFile, `---\nid: ${secretId}\nscope: personal\nscope_key: personal\nvisibility: private\ntype: fact\nstatus: active\ncreated_at: 2026-01-01T00:00:00Z\nupdated_at: 2026-01-01T00:00:00Z\n---\n\nfulltest traversal secret\n`);
|
|
277
|
+
const tstage = path.join(T, "tstage");
|
|
278
|
+
fs.mkdirSync(tstage, { recursive: true });
|
|
279
|
+
fs.writeFileSync(path.join(tstage, "manifest.json"), JSON.stringify({ format: "open-memex-export/1", exported_at: "2026-01-01T00:00:00Z", open_memex_version: "t", include_private: true, filters: {}, memories: [{ id: secretId, scope: "personal", file: `../${secretName}` }] }));
|
|
280
|
+
const tfile = path.join(T, "traversal.tar.gz");
|
|
281
|
+
execFileSync("tar", ["-czf", tfile, "-C", tstage, "manifest.json"], { env: TESTENV });
|
|
282
|
+
r = cli2Retry(["import", tfile], PROJ2);
|
|
283
|
+
ok("import rejects path outside bundle", /imported 0/.test(r.out) && /rejected/.test(r.out), r.out.slice(0, 160));
|
|
284
|
+
r = cli2(["search", "traversal", "--scope", "personal"], PROJ2);
|
|
285
|
+
ok("traversed file never landed in store", !/traversal secret/.test(r.out), r.out.slice(0, 160));
|
|
286
|
+
fs.rmSync(secretFile, { force: true });
|
|
287
|
+
}
|
|
234
288
|
|
|
235
289
|
// ---------- 6. distill-agents ----------
|
|
236
290
|
console.log("== distill-agents ==");
|
|
@@ -279,6 +333,24 @@ try { dbOk = fs.existsSync(dbPath) && fs.statSync(dbPath).mtimeMs >= beforeReind
|
|
|
279
333
|
ok("reindex rebuilds", dbOk, `db exists: ${fs.existsSync(dbPath)}`);
|
|
280
334
|
r = cli(["list", "--scope", "personal"], PROJ);
|
|
281
335
|
ok("list works after reindex", r.out.includes(idMig) || r.out.includes(idPersonal));
|
|
336
|
+
// P0: a real migrate must re-scope the file, not just re-key it — a
|
|
337
|
+
// personal memory migrated to the project key must become submittable
|
|
338
|
+
// project memory (scope: project in its frontmatter).
|
|
339
|
+
r = cli(["migrate", "--from", "personal", "--to", "project"], PROJ);
|
|
340
|
+
ok("migrate personal→project runs", r.code === 0, (r.err || r.out).slice(0, 150));
|
|
341
|
+
{
|
|
342
|
+
const memRoot = path.join(TESTENV.MY_O_MEMORY_HOME as string, "memories");
|
|
343
|
+
let moved = "";
|
|
344
|
+
const walk = (d: string) => {
|
|
345
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
346
|
+
const p = path.join(d, e.name);
|
|
347
|
+
if (e.isDirectory()) walk(p);
|
|
348
|
+
else if (e.name === idMig + ".md") moved = fs.readFileSync(p, "utf8");
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
walk(memRoot);
|
|
352
|
+
ok("migrated file is now scope: project", /^scope: project$/m.test(moved), moved.slice(0, 200));
|
|
353
|
+
}
|
|
282
354
|
|
|
283
355
|
// ---------- 9. capture / doctor / mcp --print-config / init ----------
|
|
284
356
|
console.log("== capture / doctor / mcp / init ==");
|
|
@@ -290,6 +362,17 @@ for (const c of ["vscode", "cursor", "claude", "opencode", "visualstudio"]) {
|
|
|
290
362
|
}
|
|
291
363
|
r = cli(["init", "--client", "vscode", "--yes"], PROJ);
|
|
292
364
|
ok("init --client vscode --yes", r.code === 0 && fs.existsSync(path.join(PROJ, ".vscode", "mcp.json")), (r.err || r.out).slice(0, 150));
|
|
365
|
+
// P0: VS init must merge into an existing .mcp.json, not replace it —
|
|
366
|
+
// the parsed doc used to be dropped, wiping other MCP servers.
|
|
367
|
+
{
|
|
368
|
+
const mcpFile = path.join(PROJ, ".mcp.json");
|
|
369
|
+
fs.writeFileSync(mcpFile, JSON.stringify({ servers: { "other-server": { type: "stdio", command: "other" } } }));
|
|
370
|
+
r = cli(["init", "--client", "visualstudio", "--yes"], PROJ);
|
|
371
|
+
const after = JSON.parse(fs.readFileSync(mcpFile, "utf8")) as { servers?: Record<string, unknown> };
|
|
372
|
+
ok("VS init preserves other servers in .mcp.json",
|
|
373
|
+
r.code === 0 && !!after.servers?.["other-server"] && !!after.servers?.["open-memex"],
|
|
374
|
+
JSON.stringify(after).slice(0, 160));
|
|
375
|
+
}
|
|
293
376
|
r = cliRetry(["doctor"], PROJ);
|
|
294
377
|
ok("doctor", r.code === 0 && /All checks passed/.test(r.out), (r.err || r.out).slice(0, 200));
|
|
295
378
|
|
package/src/cli.ts
CHANGED
|
@@ -1059,11 +1059,17 @@ async function main() {
|
|
|
1059
1059
|
process.exit(1);
|
|
1060
1060
|
}
|
|
1061
1061
|
// Delete by indexed file_path — location-agnostic (appdata or in-repo, 2B/D24).
|
|
1062
|
+
// Only ENOENT = success; anything else means the file survives and
|
|
1063
|
+
// would be re-indexed — fail loudly (P0 review, 2026-10-03).
|
|
1062
1064
|
if (row.file_path) {
|
|
1063
1065
|
try {
|
|
1064
1066
|
fs.unlinkSync(row.file_path);
|
|
1065
|
-
} catch {
|
|
1066
|
-
|
|
1067
|
+
} catch (e) {
|
|
1068
|
+
const code = (e as NodeJS.ErrnoException).code;
|
|
1069
|
+
if (code !== "ENOENT") {
|
|
1070
|
+
console.error(`could not delete ${row.file_path} (${code ?? "error"}) — memory not forgotten`);
|
|
1071
|
+
process.exit(1);
|
|
1072
|
+
}
|
|
1067
1073
|
}
|
|
1068
1074
|
}
|
|
1069
1075
|
deleteFromIndex(id);
|
|
@@ -1281,6 +1287,9 @@ function positionalArgs(argv: string[]): string[] {
|
|
|
1281
1287
|
console.log(
|
|
1282
1288
|
`${dryRun ? "DRY RUN: " : ""}imported ${r.imported}, skipped ${r.skippedIdentical} identical`,
|
|
1283
1289
|
);
|
|
1290
|
+
for (const rp of r.rejectedPaths) {
|
|
1291
|
+
console.log(` rejected: ${rp} (path outside the bundle)`);
|
|
1292
|
+
}
|
|
1284
1293
|
for (const c of r.skippedConflict) {
|
|
1285
1294
|
console.log(` conflict (kept existing): ${c.id} from ${c.file}`);
|
|
1286
1295
|
}
|
package/src/config.ts
CHANGED
|
@@ -74,19 +74,68 @@ export const DEFAULT_CONFIG: MyOMemoryConfig = {
|
|
|
74
74
|
sync: { autoPull: false },
|
|
75
75
|
};
|
|
76
76
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
77
|
+
/**
|
|
78
|
+
* Strip JSONC comments and trailing commas with a string-aware scanner.
|
|
79
|
+
* The previous regex version was not string-aware: a config value like
|
|
80
|
+
* "src/**\/secrets" had its `/**…*\/` eaten as a block comment, silently
|
|
81
|
+
* corrupting the user's redact pattern (P0 review, 2026-10-03). Shared
|
|
82
|
+
* with doctor.ts — one parser, not two.
|
|
83
|
+
*/
|
|
84
|
+
export function stripJsonComments(raw: string): string {
|
|
85
|
+
let out = "";
|
|
86
|
+
let i = 0;
|
|
87
|
+
let inStr = false;
|
|
88
|
+
let esc = false;
|
|
89
|
+
while (i < raw.length) {
|
|
90
|
+
const c = raw[i]!;
|
|
91
|
+
const n = raw[i + 1];
|
|
92
|
+
if (inStr) {
|
|
93
|
+
out += c;
|
|
94
|
+
if (esc) esc = false;
|
|
95
|
+
else if (c === "\\") esc = true;
|
|
96
|
+
else if (c === '"') inStr = false;
|
|
97
|
+
i++;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (c === '"') {
|
|
101
|
+
inStr = true;
|
|
102
|
+
out += c;
|
|
103
|
+
i++;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (c === "/" && n === "/") {
|
|
107
|
+
while (i < raw.length && raw[i] !== "\n") i++;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (c === "/" && n === "*") {
|
|
111
|
+
i += 2;
|
|
112
|
+
while (i < raw.length && !(raw[i] === "*" && raw[i + 1] === "/")) i++;
|
|
113
|
+
i += 2;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (c === ",") {
|
|
117
|
+
// Trailing comma: drop it when the next significant char closes a
|
|
118
|
+
// container. Whitespace/comments between are tolerated.
|
|
119
|
+
let j = i + 1;
|
|
120
|
+
while (j < raw.length && /\s/.test(raw[j]!)) j++;
|
|
121
|
+
if (raw[j] === "}" || raw[j] === "]") {
|
|
122
|
+
i++;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
out += c;
|
|
127
|
+
i++;
|
|
128
|
+
}
|
|
84
129
|
return out;
|
|
85
130
|
}
|
|
86
131
|
|
|
87
|
-
/**
|
|
132
|
+
/**
|
|
133
|
+
* Where `config set` / interactive `init` persist. Respects OPEN_MEMEX_CONFIG
|
|
134
|
+
* (D62); MY_O_MEMORY_CONFIG is the pre-rename fallback, still honored.
|
|
135
|
+
*/
|
|
88
136
|
export function configFilePath(): string {
|
|
89
137
|
return (
|
|
138
|
+
process.env.OPEN_MEMEX_CONFIG ??
|
|
90
139
|
process.env.MY_O_MEMORY_CONFIG ??
|
|
91
140
|
path.join(os.homedir(), ".config", "opencode", "open-memex.jsonc")
|
|
92
141
|
);
|
|
@@ -156,6 +205,7 @@ export function saveConfig(patch: Record<string, unknown>): string {
|
|
|
156
205
|
/** Path of the config file in effect, or null when using built-in defaults. */
|
|
157
206
|
export function configSource(): string | null {
|
|
158
207
|
const candidates = [
|
|
208
|
+
process.env.OPEN_MEMEX_CONFIG,
|
|
159
209
|
process.env.MY_O_MEMORY_CONFIG,
|
|
160
210
|
path.join(os.homedir(), ".config", "opencode", "open-memex.jsonc"),
|
|
161
211
|
path.join(os.homedir(), ".config", "opencode", "open-memex.json"),
|
|
@@ -172,7 +222,20 @@ export function loadConfig(): MyOMemoryConfig {
|
|
|
172
222
|
try {
|
|
173
223
|
const raw = fs.readFileSync(p, "utf8");
|
|
174
224
|
const parsed = JSON.parse(stripJsonComments(raw)) as Partial<MyOMemoryConfig>;
|
|
175
|
-
|
|
225
|
+
const merged = { ...DEFAULT_CONFIG, ...parsed };
|
|
226
|
+
// The config file is hand-editable, so the load path must enforce
|
|
227
|
+
// the same invariants `config set` does (P0 review, 2026-10-03):
|
|
228
|
+
// an unvalidated memoryDir like "../../shared" would put
|
|
229
|
+
// committed project memories outside the repo.
|
|
230
|
+
try {
|
|
231
|
+
merged.memoryDir = toRelativeDir(merged.memoryDir);
|
|
232
|
+
} catch {
|
|
233
|
+
console.error(
|
|
234
|
+
`[open-memex] invalid memoryDir "${merged.memoryDir}" in ${p} — using default "${DEFAULT_CONFIG.memoryDir}"`,
|
|
235
|
+
);
|
|
236
|
+
merged.memoryDir = DEFAULT_CONFIG.memoryDir;
|
|
237
|
+
}
|
|
238
|
+
return merged;
|
|
176
239
|
} catch (err) {
|
|
177
240
|
console.error(`[open-memex] failed to parse ${p}:`, err);
|
|
178
241
|
}
|
package/src/doctor.ts
CHANGED
|
@@ -7,8 +7,8 @@ import os from "node:os";
|
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import { spawn, execFileSync } from "node:child_process";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
|
-
import { loadConfig, configSource, DEFAULT_CONFIG } from "./config.ts";
|
|
11
|
-
import { paths } from "./paths.ts";
|
|
10
|
+
import { loadConfig, configSource, DEFAULT_CONFIG, stripJsonComments } from "./config.ts";
|
|
11
|
+
import { paths, homeOverride } from "./paths.ts";
|
|
12
12
|
import { resolveCwdScope } from "./scope.ts";
|
|
13
13
|
import { db, backendName } from "./store/db.ts";
|
|
14
14
|
import { LEGACY_PACKAGE_NAME, opencodeGlobalConfigPath, userMcpConfigPath } from "./init.ts";
|
|
@@ -66,7 +66,14 @@ function storageCheck(): Check {
|
|
|
66
66
|
try {
|
|
67
67
|
const p = paths();
|
|
68
68
|
fs.accessSync(p.memories, fs.constants.W_OK);
|
|
69
|
-
|
|
69
|
+
// D62: when the root came from the legacy env var, say so (still ok —
|
|
70
|
+
// the fallback is supported, doctor just names the current one).
|
|
71
|
+
const via = homeOverride()?.via;
|
|
72
|
+
const note =
|
|
73
|
+
via === "MY_O_MEMORY_HOME"
|
|
74
|
+
? " (via legacy MY_O_MEMORY_HOME — OPEN_MEMEX_HOME is the current name)"
|
|
75
|
+
: "";
|
|
76
|
+
return { name: "storage", ok: true, detail: `${p.root} (writable)${note}` };
|
|
70
77
|
} catch (err) {
|
|
71
78
|
return { name: "storage", ok: false, detail: String(err) };
|
|
72
79
|
}
|
|
@@ -422,49 +429,15 @@ function mcpCheck(): Promise<Check> {
|
|
|
422
429
|
}
|
|
423
430
|
|
|
424
431
|
/** Parse JSON tolerating line/block comments plus trailing commas
|
|
425
|
-
* (VS Code's settings.json is JSONC).
|
|
432
|
+
* (VS Code's settings.json is JSONC). Uses config.ts's string-aware
|
|
433
|
+
* stripper — one parser, not two (P0 review, 2026-10-03). */
|
|
426
434
|
function parseLenientJson(raw: string): Record<string, unknown> {
|
|
427
435
|
try {
|
|
428
436
|
return JSON.parse(raw) as Record<string, unknown>;
|
|
429
437
|
} catch {
|
|
430
438
|
/* fall through to comment stripping */
|
|
431
439
|
}
|
|
432
|
-
|
|
433
|
-
let i = 0;
|
|
434
|
-
let inStr = false;
|
|
435
|
-
let esc = false;
|
|
436
|
-
while (i < raw.length) {
|
|
437
|
-
const c = raw[i];
|
|
438
|
-
const n = raw[i + 1];
|
|
439
|
-
if (inStr) {
|
|
440
|
-
out += c;
|
|
441
|
-
if (esc) esc = false;
|
|
442
|
-
else if (c === "\\") esc = true;
|
|
443
|
-
else if (c === '"') inStr = false;
|
|
444
|
-
i++;
|
|
445
|
-
continue;
|
|
446
|
-
}
|
|
447
|
-
if (c === '"') {
|
|
448
|
-
inStr = true;
|
|
449
|
-
out += c;
|
|
450
|
-
i++;
|
|
451
|
-
continue;
|
|
452
|
-
}
|
|
453
|
-
if (c === "/" && n === "/") {
|
|
454
|
-
while (i < raw.length && raw[i] !== "\n") i++;
|
|
455
|
-
continue;
|
|
456
|
-
}
|
|
457
|
-
if (c === "/" && n === "*") {
|
|
458
|
-
i += 2;
|
|
459
|
-
while (i < raw.length && !(raw[i] === "*" && raw[i + 1] === "/")) i++;
|
|
460
|
-
i += 2;
|
|
461
|
-
continue;
|
|
462
|
-
}
|
|
463
|
-
out += c;
|
|
464
|
-
i++;
|
|
465
|
-
}
|
|
466
|
-
out = out.replace(/,\s*([}\]])/g, "$1");
|
|
467
|
-
return JSON.parse(out) as Record<string, unknown>;
|
|
440
|
+
return JSON.parse(stripJsonComments(raw)) as Record<string, unknown>;
|
|
468
441
|
}
|
|
469
442
|
|
|
470
443
|
function vscodeSettingsPath(): string | null {
|
|
@@ -579,10 +552,12 @@ export async function runDoctor(): Promise<boolean> {
|
|
|
579
552
|
function legacyCheck(): Check {
|
|
580
553
|
const name = "legacy my-o-memory";
|
|
581
554
|
const found: string[] = [];
|
|
582
|
-
|
|
555
|
+
// D62: the override may arrive via either env var name; check the value
|
|
556
|
+
// in effect, not just the legacy one.
|
|
557
|
+
const envHome = homeOverride()?.dir;
|
|
583
558
|
if (envHome && envHome.includes(LEGACY_PACKAGE_NAME))
|
|
584
559
|
found.push(
|
|
585
|
-
`
|
|
560
|
+
`memory home points at a pre-rename dir (${envHome}) — unset it, then merge old data with \`open-memex migrate --to-v2\``,
|
|
586
561
|
);
|
|
587
562
|
const root = paths().root;
|
|
588
563
|
const legacyDir = path.join(path.dirname(root), LEGACY_PACKAGE_NAME);
|
package/src/export.ts
CHANGED
|
@@ -152,6 +152,8 @@ export interface ImportResult {
|
|
|
152
152
|
imported: number;
|
|
153
153
|
skippedIdentical: number;
|
|
154
154
|
skippedConflict: { id: string; file: string }[];
|
|
155
|
+
/** Manifest entries refused for pointing outside the bundle. */
|
|
156
|
+
rejectedPaths: string[];
|
|
155
157
|
}
|
|
156
158
|
|
|
157
159
|
export function importBundle(bundlePath: string, opts: ImportOptions): ImportResult {
|
|
@@ -172,10 +174,20 @@ export function importBundle(bundlePath: string, opts: ImportOptions): ImportRes
|
|
|
172
174
|
fail(`unsupported bundle format: ${manifest.format} (expected ${EXPORT_FORMAT})`);
|
|
173
175
|
}
|
|
174
176
|
|
|
175
|
-
const result: ImportResult = { imported: 0, skippedIdentical: 0, skippedConflict: [] };
|
|
177
|
+
const result: ImportResult = { imported: 0, skippedIdentical: 0, skippedConflict: [], rejectedPaths: [] };
|
|
176
178
|
const existingStmt = db().prepare(`SELECT content_hash FROM memories WHERE id = ?`);
|
|
179
|
+
// The manifest comes from whoever sent the bundle: entry.file must
|
|
180
|
+
// stay inside the staging dir (P0 review, 2026-10-03 — "../" entries
|
|
181
|
+
// otherwise read arbitrary .md files into the store). realpath also
|
|
182
|
+
// catches symlinks planted by the tarball.
|
|
183
|
+
const stageRoot = fs.realpathSync(stage);
|
|
177
184
|
for (const entry of manifest.memories ?? []) {
|
|
178
|
-
const
|
|
185
|
+
const joined = path.resolve(path.join(stage, entry.file));
|
|
186
|
+
const src = fs.existsSync(joined) ? fs.realpathSync(joined) : joined;
|
|
187
|
+
if (!src.startsWith(stageRoot + path.sep)) {
|
|
188
|
+
result.rejectedPaths.push(entry.file);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
179
191
|
const mf = readMemoryFile(src);
|
|
180
192
|
if (!mf) continue;
|
|
181
193
|
const fm = normalizeFrontmatter({ ...mf.fm } as unknown as Record<string, unknown>);
|
package/src/init.ts
CHANGED
|
@@ -508,6 +508,7 @@ function writeVisualStudioMcpJson(root: string, force: boolean): string | null {
|
|
|
508
508
|
});
|
|
509
509
|
return null;
|
|
510
510
|
}
|
|
511
|
+
doc = parsed;
|
|
511
512
|
}
|
|
512
513
|
const section = ((doc["servers"] ??= {}) as Record<string, unknown>);
|
|
513
514
|
// F30: drop the pre-rename server entry — it points at the OLD data dir.
|
package/src/paths.ts
CHANGED
|
@@ -3,8 +3,23 @@ import fs from "node:fs";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import { execFileSync } from "node:child_process";
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* D62: the data-root override env var. `OPEN_MEMEX_HOME` is the current
|
|
8
|
+
* name; `MY_O_MEMORY_HOME` (pre-rename package name) is still honored as a
|
|
9
|
+
* fallback so existing setups and scripts keep working. New name wins
|
|
10
|
+
* when both are set.
|
|
11
|
+
*/
|
|
12
|
+
export function homeOverride(): { dir: string; via: "OPEN_MEMEX_HOME" | "MY_O_MEMORY_HOME" } | null {
|
|
13
|
+
if (process.env.OPEN_MEMEX_HOME)
|
|
14
|
+
return { dir: path.resolve(process.env.OPEN_MEMEX_HOME), via: "OPEN_MEMEX_HOME" };
|
|
15
|
+
if (process.env.MY_O_MEMORY_HOME)
|
|
16
|
+
return { dir: path.resolve(process.env.MY_O_MEMORY_HOME), via: "MY_O_MEMORY_HOME" };
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
6
20
|
function dataRoot(): string {
|
|
7
|
-
|
|
21
|
+
const override = homeOverride();
|
|
22
|
+
if (override) return override.dir;
|
|
8
23
|
if (process.platform === "win32") {
|
|
9
24
|
const appData = process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming");
|
|
10
25
|
return path.join(appData, "open-memex");
|
package/src/store/migrate.ts
CHANGED
|
@@ -6,9 +6,11 @@ import {
|
|
|
6
6
|
iterMemoryFiles,
|
|
7
7
|
readMemoryFile,
|
|
8
8
|
serialize,
|
|
9
|
+
timeToMs,
|
|
9
10
|
type Frontmatter,
|
|
10
11
|
} from "./markdown.ts";
|
|
11
12
|
import { syncScope } from "./sync.ts";
|
|
13
|
+
import { PERSONAL_SCOPE } from "../scope.ts";
|
|
12
14
|
|
|
13
15
|
export type ConflictStrategy = "newer" | "overwrite" | "skip";
|
|
14
16
|
|
|
@@ -90,7 +92,10 @@ export function migrateScope(
|
|
|
90
92
|
log(`conflict: kept destination (--on-conflict=skip): ${dstPath}`);
|
|
91
93
|
} else if (strategy === "overwrite") {
|
|
92
94
|
log(`conflict: overwriting destination (--on-conflict=overwrite): ${dstPath}`);
|
|
93
|
-
} else if (dst.fm.updated_at >= mf.fm.updated_at) {
|
|
95
|
+
} else if (timeToMs(dst.fm.updated_at) >= timeToMs(mf.fm.updated_at)) {
|
|
96
|
+
// Compare parsed instants, not RFC-3339 strings: lexicographic
|
|
97
|
+
// order breaks around fractional seconds ("…:00Z" > "…:00.005Z"),
|
|
98
|
+
// letting the older file win.
|
|
94
99
|
action = "skip";
|
|
95
100
|
log(`conflict: kept destination (newer updated_at): ${dstPath}`);
|
|
96
101
|
} else {
|
|
@@ -103,9 +108,16 @@ export function migrateScope(
|
|
|
103
108
|
continue;
|
|
104
109
|
}
|
|
105
110
|
|
|
111
|
+
// Re-key AND re-scope: scope/visibility must follow the destination
|
|
112
|
+
// (P0 review, 2026-10-03). Rewriting only scope_key produced files
|
|
113
|
+
// submit rejects ("scope is personal") or personal memories carrying
|
|
114
|
+
// visibility: internal into exports.
|
|
115
|
+
const toPersonal = toKey === PERSONAL_SCOPE.key;
|
|
106
116
|
const newFm: Frontmatter = {
|
|
107
117
|
...mf.fm,
|
|
108
118
|
scope_key: toKey,
|
|
119
|
+
scope: toPersonal ? "personal" : "project",
|
|
120
|
+
visibility: toPersonal ? "private" : "internal",
|
|
109
121
|
project_name: opts.toProjectName ?? mf.fm.project_name,
|
|
110
122
|
};
|
|
111
123
|
const raw = serialize(newFm, mf.body);
|
package/src/tools/ops.ts
CHANGED
|
@@ -388,11 +388,20 @@ export async function forgetMemory(args: MemoryForgetArgs): Promise<ToolResult>
|
|
|
388
388
|
return { title: "memory: not found", output: `No memory with id ${args.id}.` };
|
|
389
389
|
}
|
|
390
390
|
// Delete by indexed file_path — location-agnostic (appdata or in-repo, 2B/D24).
|
|
391
|
+
// Only ENOENT counts as "deleted": on any other unlink failure the file
|
|
392
|
+
// is still the source of truth and would be re-indexed on next sync, so
|
|
393
|
+
// report failure instead of lying (P0 review, 2026-10-03).
|
|
391
394
|
if (row.file_path) {
|
|
392
395
|
try {
|
|
393
396
|
fs.unlinkSync(row.file_path);
|
|
394
|
-
} catch {
|
|
395
|
-
|
|
397
|
+
} catch (e) {
|
|
398
|
+
const code = (e as NodeJS.ErrnoException).code;
|
|
399
|
+
if (code !== "ENOENT") {
|
|
400
|
+
return {
|
|
401
|
+
title: "memory: forget failed",
|
|
402
|
+
output: `Could not delete ${row.file_path} (${code ?? "error"}) — the memory was NOT forgotten and is still in the index. Close anything holding the file and retry.`,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
396
405
|
}
|
|
397
406
|
}
|
|
398
407
|
deleteFromIndex(args.id);
|