open-memex 0.7.0-alpha.3 → 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 +1 -0
- package/CHANGELOG.md +11 -0
- package/dist/cli.js +11 -2
- package/dist/config.js +71 -8
- package/dist/doctor.js +4 -43
- package/dist/export.js +12 -2
- package/dist/init.js +1 -0
- package/dist/store/migrate.js +13 -2
- package/dist/tools/ops.js +11 -2
- package/docs/V2-DESIGN.md +18 -0
- package/package.json +1 -1
- package/scripts/smoke-pure.ts +35 -1
- package/scripts/test-full.ts +83 -0
- package/src/cli.ts +11 -2
- package/src/config.ts +66 -8
- package/src/doctor.ts +4 -38
- package/src/export.ts +14 -2
- package/src/init.ts +1 -0
- package/src/store/migrate.ts +13 -1
- package/src/tools/ops.ts +11 -2
package/AGENTS.md
CHANGED
|
@@ -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,17 @@ 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
|
+
|
|
12
23
|
## Environment variable names (D62)
|
|
13
24
|
|
|
14
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.
|
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,13 +37,64 @@ 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
|
/**
|
|
@@ -132,7 +183,19 @@ export function loadConfig() {
|
|
|
132
183
|
try {
|
|
133
184
|
const raw = fs.readFileSync(p, "utf8");
|
|
134
185
|
const parsed = JSON.parse(stripJsonComments(raw));
|
|
135
|
-
|
|
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;
|
|
136
199
|
}
|
|
137
200
|
catch (err) {
|
|
138
201
|
console.error(`[open-memex] failed to parse ${p}:`, err);
|
package/dist/doctor.js
CHANGED
|
@@ -7,7 +7,7 @@ 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";
|
|
10
|
+
import { loadConfig, configSource, DEFAULT_CONFIG, stripJsonComments } from "./config.js";
|
|
11
11
|
import { paths, homeOverride } from "./paths.js";
|
|
12
12
|
import { resolveCwdScope } from "./scope.js";
|
|
13
13
|
import { db, backendName } from "./store/db.js";
|
|
@@ -407,7 +407,8 @@ function mcpCheck() {
|
|
|
407
407
|
});
|
|
408
408
|
}
|
|
409
409
|
/** Parse JSON tolerating line/block comments plus trailing commas
|
|
410
|
-
* (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). */
|
|
411
412
|
function parseLenientJson(raw) {
|
|
412
413
|
try {
|
|
413
414
|
return JSON.parse(raw);
|
|
@@ -415,47 +416,7 @@ function parseLenientJson(raw) {
|
|
|
415
416
|
catch {
|
|
416
417
|
/* fall through to comment stripping */
|
|
417
418
|
}
|
|
418
|
-
|
|
419
|
-
let i = 0;
|
|
420
|
-
let inStr = false;
|
|
421
|
-
let esc = false;
|
|
422
|
-
while (i < raw.length) {
|
|
423
|
-
const c = raw[i];
|
|
424
|
-
const n = raw[i + 1];
|
|
425
|
-
if (inStr) {
|
|
426
|
-
out += c;
|
|
427
|
-
if (esc)
|
|
428
|
-
esc = false;
|
|
429
|
-
else if (c === "\\")
|
|
430
|
-
esc = true;
|
|
431
|
-
else if (c === '"')
|
|
432
|
-
inStr = false;
|
|
433
|
-
i++;
|
|
434
|
-
continue;
|
|
435
|
-
}
|
|
436
|
-
if (c === '"') {
|
|
437
|
-
inStr = true;
|
|
438
|
-
out += c;
|
|
439
|
-
i++;
|
|
440
|
-
continue;
|
|
441
|
-
}
|
|
442
|
-
if (c === "/" && n === "/") {
|
|
443
|
-
while (i < raw.length && raw[i] !== "\n")
|
|
444
|
-
i++;
|
|
445
|
-
continue;
|
|
446
|
-
}
|
|
447
|
-
if (c === "/" && n === "*") {
|
|
448
|
-
i += 2;
|
|
449
|
-
while (i < raw.length && !(raw[i] === "*" && raw[i + 1] === "/"))
|
|
450
|
-
i++;
|
|
451
|
-
i += 2;
|
|
452
|
-
continue;
|
|
453
|
-
}
|
|
454
|
-
out += c;
|
|
455
|
-
i++;
|
|
456
|
-
}
|
|
457
|
-
out = out.replace(/,\s*([}\]])/g, "$1");
|
|
458
|
-
return JSON.parse(out);
|
|
419
|
+
return JSON.parse(stripJsonComments(raw));
|
|
459
420
|
}
|
|
460
421
|
function vscodeSettingsPath() {
|
|
461
422
|
const home = os.homedir();
|
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/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
|
@@ -1316,6 +1316,24 @@ requirement: personal data never touches third-party services). Benchmarks to tr
|
|
|
1316
1316
|
variable, and its pre-rename-dir leftovers check reads whichever
|
|
1317
1317
|
variable is in effect.
|
|
1318
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
|
+
|
|
1319
1337
|
## Open Questions
|
|
1320
1338
|
|
|
1321
1339
|
_All resolved — see D10 (rename), D11 (type/role split), D12 (explicit pull)._
|
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
|
@@ -13,7 +13,41 @@ import { userMcpConfigPath, mergeServerEntry, detectInstalledClients, mergePlugi
|
|
|
13
13
|
import { z } from "zod";
|
|
14
14
|
import { memoryAddArgs } from "../src/tools/ops.ts";
|
|
15
15
|
import { dataRootPath, homeOverride } from "../src/paths.ts";
|
|
16
|
-
import { configFilePath } from "../src/config.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
|
+
}
|
|
17
51
|
|
|
18
52
|
let fails = 0;
|
|
19
53
|
function ok(name: string, cond: boolean, info?: unknown) {
|
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,13 +74,58 @@ 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
|
|
|
@@ -177,7 +222,20 @@ export function loadConfig(): MyOMemoryConfig {
|
|
|
177
222
|
try {
|
|
178
223
|
const raw = fs.readFileSync(p, "utf8");
|
|
179
224
|
const parsed = JSON.parse(stripJsonComments(raw)) as Partial<MyOMemoryConfig>;
|
|
180
|
-
|
|
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;
|
|
181
239
|
} catch (err) {
|
|
182
240
|
console.error(`[open-memex] failed to parse ${p}:`, err);
|
|
183
241
|
}
|
package/src/doctor.ts
CHANGED
|
@@ -7,7 +7,7 @@ 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";
|
|
10
|
+
import { loadConfig, configSource, DEFAULT_CONFIG, stripJsonComments } from "./config.ts";
|
|
11
11
|
import { paths, homeOverride } from "./paths.ts";
|
|
12
12
|
import { resolveCwdScope } from "./scope.ts";
|
|
13
13
|
import { db, backendName } from "./store/db.ts";
|
|
@@ -429,49 +429,15 @@ function mcpCheck(): Promise<Check> {
|
|
|
429
429
|
}
|
|
430
430
|
|
|
431
431
|
/** Parse JSON tolerating line/block comments plus trailing commas
|
|
432
|
-
* (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). */
|
|
433
434
|
function parseLenientJson(raw: string): Record<string, unknown> {
|
|
434
435
|
try {
|
|
435
436
|
return JSON.parse(raw) as Record<string, unknown>;
|
|
436
437
|
} catch {
|
|
437
438
|
/* fall through to comment stripping */
|
|
438
439
|
}
|
|
439
|
-
|
|
440
|
-
let i = 0;
|
|
441
|
-
let inStr = false;
|
|
442
|
-
let esc = false;
|
|
443
|
-
while (i < raw.length) {
|
|
444
|
-
const c = raw[i];
|
|
445
|
-
const n = raw[i + 1];
|
|
446
|
-
if (inStr) {
|
|
447
|
-
out += c;
|
|
448
|
-
if (esc) esc = false;
|
|
449
|
-
else if (c === "\\") esc = true;
|
|
450
|
-
else if (c === '"') inStr = false;
|
|
451
|
-
i++;
|
|
452
|
-
continue;
|
|
453
|
-
}
|
|
454
|
-
if (c === '"') {
|
|
455
|
-
inStr = true;
|
|
456
|
-
out += c;
|
|
457
|
-
i++;
|
|
458
|
-
continue;
|
|
459
|
-
}
|
|
460
|
-
if (c === "/" && n === "/") {
|
|
461
|
-
while (i < raw.length && raw[i] !== "\n") i++;
|
|
462
|
-
continue;
|
|
463
|
-
}
|
|
464
|
-
if (c === "/" && n === "*") {
|
|
465
|
-
i += 2;
|
|
466
|
-
while (i < raw.length && !(raw[i] === "*" && raw[i + 1] === "/")) i++;
|
|
467
|
-
i += 2;
|
|
468
|
-
continue;
|
|
469
|
-
}
|
|
470
|
-
out += c;
|
|
471
|
-
i++;
|
|
472
|
-
}
|
|
473
|
-
out = out.replace(/,\s*([}\]])/g, "$1");
|
|
474
|
-
return JSON.parse(out) as Record<string, unknown>;
|
|
440
|
+
return JSON.parse(stripJsonComments(raw)) as Record<string, unknown>;
|
|
475
441
|
}
|
|
476
442
|
|
|
477
443
|
function vscodeSettingsPath(): string | null {
|
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/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);
|