open-memex 0.7.0-alpha.11 → 0.7.0-alpha.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -126,6 +126,7 @@ Layout: `memories/<scope_key>/<id>.md` (YAML frontmatter + body) + `index.db` (S
126
126
 
127
127
  - `personal` scope key is literal `"personal"` (v1 called this `user`; renamed in v2, design §19).
128
128
  - `project` scope key: `project__<sanitized-name>__<12-hex-sha256>`, seeded from normalized git origin URL, else lowercased cwd. See `src/scope.ts`. Same repo across machines → same key (intentional; enables future git-commit of memories).
129
+ - The root a host adapter seeds on matters: OpenCode v1 reports the **filesystem root** as `worktree` for non-git folders, so the v1 adapter goes through `pickScopeRoot` (src/scope.ts) — `worktree` only when it is a real directory below the filesystem root, else the opened `directory` (D71). Seeding on a filesystem root merges every non-git folder on a drive into one `project__workspace__…` bucket.
129
130
  - `org`, `team`, `public` are reserved — the schema rejects writes. See `docs/SCOPES.md`.
130
131
  - The scope key **changes** when a repo gains/loses a git origin. `src/index.ts` logs a one-shot warning on load if it finds files under the legacy cwd-only key (`resolveCwdScope`). Use `cli scopes` to enumerate all scope dirs and `cli migrate --from <old>` to reconcile — see `src/store/migrate.ts`. No auto-migration; two unrelated repos at the same cwd would silently merge.
131
132
  - Read-only callers must use `memoriesDirPath` (in `src/paths.ts`), never `memoriesDirFor`. The latter `mkdir -p`s the directory as a side effect and will pollute storage with empty scope dirs.
@@ -167,9 +168,9 @@ Context injection happens exactly once per session in `experimental.chat.system.
167
168
 
168
169
  ## Design constraints — read the frozen design first
169
170
 
170
- `docs/V2-DESIGN.md` is the frozen protocol v0.2 (decisions settled through D69; open questions tracked at the end of the doc). Per its §12:
171
+ `docs/V2-DESIGN.md` is the frozen protocol v0.2 (decisions settled through D71; open questions tracked at the end of the doc). Per its §12:
171
172
  AGENTS.md answers "how should AI work here"; the design doc answers "why is it
172
- built this way" (principles, iron rules, the append-only decision log, currently D1–D70). Before changing
173
+ built this way" (principles, iron rules, the append-only decision log, currently D1–D71). Before changing
173
174
  architecture, scope semantics, lifecycle, or the protocol surface (frontmatter
174
175
  schema, MCP tools, CLI contract), read the relevant design section — the decision
175
176
  log records what was already considered and rejected.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,10 @@ the `@alpha` tag are development snapshots and are not listed individually.
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## Non-git folders shared one scope bucket (D71)
13
+
14
+ - **Fixed: every non-git folder on a drive shared a single project scope.** OpenCode v1 reports the filesystem root (`C:\`, `/`) as the project `worktree` when the opened folder is not a git repo, and the plugin seeded its scope on that root — so unrelated folders like `C:\temp` and `C:\scratch` all wrote to one `project__workspace__…` bucket. The scope now seeds on the folder actually opened (`pickScopeRoot` in `src/scope.ts`); real repo worktrees are unchanged. Memories already saved under the shared bucket are not moved automatically: run `open-memex scopes` to spot it, then `open-memex migrate --from <old-key>`.
15
+
12
16
  ## Visibility follow-ups: one number, one guard, one truth (D70)
13
17
 
14
18
  Review of the D68/D69 inventory surfaces — each was honest on its own, and they disagreed with each other:
@@ -29,7 +33,7 @@ Review of the D68/D69 inventory surfaces — each was honest on its own, and the
29
33
  Group 3-C — the surface a user reads to learn what the store actually remembers:
30
34
 
31
35
  - **`memory_list` is now a numbered inventory.** Lines are structured (`[type] id=… created=… source=…`), keep the raw provenance, and every listing states its true total — a truncated list says how many entries it is not showing instead of quietly looking complete. New input `include: "active" | "all"` (default `active`): **behavior change** — superseded versions, retracted and archived memories no longer appear unless you pass `include=all` (the audit view). The CLI gains the matching `list --include` flag.
32
- - **`open-memex inventory`** renders the same data as readable text (default) or JSON (`--format json`, format `open-memex-inventory/2` — bumped from `/1` in D70, which dropped the absolute file path) — personal plus the current project, outbox drafts in their own section, hidden history counted rather than silently dropped. A report containing personal memories refuses to be written inside a git working tree without `--allow-personal`.
36
+ - **`open-memex inventory`** renders the same data as readable text (default) or JSON (`--format json`, format `open-memex-inventory/1`) — personal plus the current project, outbox drafts in their own section, hidden history counted rather than silently dropped. A report containing personal memories refuses to be written inside a git working tree without `--allow-personal`.
33
37
  - **Hide instead of delete:** `memory_forget` gains `soft=true` (CLI: `forget --soft`) — the memory is retracted: out of lists and search, file kept. Still one-way (D64): bringing the fact back means saving it again.
34
38
  - **Guidance kept in one voice:** the tool descriptions, the MCP session-start instructions, SKILL.md and the `distill-agents` AGENTS.md snippet all teach the same flow — answer "what do you remember?" conversationally from the inventory, re-list before acting on a number, read an entry back before deleting it.
35
39
 
package/dist/.build-stamp CHANGED
@@ -1 +1 @@
1
- 2800e45cb31d71e3355d41c293cc51de833adec76995ac95b42d10707d04bb92
1
+ f8a813363fbd1433ce149e78defa533d42be7ccbb7f37f1d53be64cd48b90c14
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { PLUGIN_ID, bootstrapPlugin, captureFromText, injectOnce, } from "./plugin-core.js";
2
+ import { pickScopeRoot } from "./scope.js";
2
3
  import { makeTools } from "./tools/memory.js";
3
4
  import { v2Plugin } from "./opencode-v2.js";
4
5
  /**
@@ -12,8 +13,9 @@ import { v2Plugin } from "./opencode-v2.js";
12
13
  * type-only (D58), and v2's decode is structural.
13
14
  */
14
15
  const server = async ({ worktree, directory }) => {
15
- const roots = worktree || directory || process.cwd();
16
- const state = bootstrapPlugin(roots);
16
+ // v1's `worktree` is the filesystem root for non-git folders — never seed a
17
+ // scope on it (D71). `pickScopeRoot` falls back to the opened `directory`.
18
+ const state = bootstrapPlugin(pickScopeRoot(worktree, directory));
17
19
  const tools = makeTools(() => state.scope, state.cfg);
18
20
  return {
19
21
  tool: tools,
package/dist/scope.js CHANGED
@@ -57,6 +57,23 @@ export function resolveCwdScope(worktree) {
57
57
  projectName = "workspace";
58
58
  return scopeFromSeed(seed, projectName);
59
59
  }
60
+ /** Pick the directory that seeds scope resolution from a host's candidate
61
+ * roots. OpenCode v1 hands the plugin both `worktree` (the repo root — but
62
+ * the filesystem root, e.g. `C:\` or `/`, when the opened folder is not a
63
+ * git repo) and `directory` (the folder actually opened). Seeding on a
64
+ * filesystem root collapses every non-git folder on a drive into one shared
65
+ * `project__workspace__…` scope (D71), so prefer `worktree` only when it is
66
+ * a real directory below the filesystem root. */
67
+ export function pickScopeRoot(worktree, directory) {
68
+ const isFsRoot = (p) => path.parse(p).root === p;
69
+ if (worktree && !isFsRoot(worktree))
70
+ return worktree;
71
+ if (directory)
72
+ return directory;
73
+ if (worktree)
74
+ return worktree;
75
+ return process.cwd();
76
+ }
60
77
  export function resolveProjectScope(worktree) {
61
78
  const target = worktree && worktree.length > 0 ? worktree : ".";
62
79
  const remote = tryGitRemote(target);
package/docs/V2-DESIGN.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # OpenMemex — Design Document (protocol v0.2)
2
2
 
3
- **Status:** FROZEN — protocol v0.2 (2026-09-26). Decisions D1–D70 are settled; open questions are tracked at the end of this document.
3
+ **Status:** FROZEN — protocol v0.2 (2026-09-26). Decisions D1–D71 are settled; open questions are tracked at the end of this document.
4
4
  **Author:** Stone, with 小沐
5
5
  **Changelog vs v1:** incorporates round-3 review from Perplexity, Grok, Gemini, ChatGPT, DeepSeek.
6
6
  Key changes: Design Principles section; `role` separated from `type`; two iron rules;
@@ -1469,18 +1469,22 @@ requirement: personal data never touches third-party services). Benchmarks to tr
1469
1469
  entry back before deleting. The HTML rendering of the same data layer
1470
1470
  follows as a separate change; auto-draft stays gated on the unlock
1471
1471
  conditions in the design doc.
1472
-
1473
- - **D69 — inventory HTML rendering lands (3-C complete on the visibility side).**
1474
- `open-memex inventory --format html` renders the D68 data layer into a
1475
- single local page: scopes grouped, outbox drafts in their own section,
1476
- replaced/hidden history folded with supersede-chain pointers, all
1477
- content escaped, filter box is local show/hide (no innerHTML, no
1478
- network). Default output is the data dir's `inventory.html`; the D68
1479
- worktree refusal applies unchanged. Sections fold above 50 current
1480
- entries. With this, the 3-C visibility surfaces are complete (agent
1481
- inventory in chat, text/JSON reports, HTML page; hide + confirmed
1482
- delete as the correction actions). Auto-draft remains gated on the
1483
- unlock conditions in `docs/memory-visibility-design.md`.
1472
+ - **D71 — the scope seed is the folder opened, never a filesystem root.**
1473
+ OpenCode v1 hands the plugin two roots: `worktree` (the repo root — but
1474
+ the filesystem root itself, `C:\` or `/`, when the opened folder is not a
1475
+ git repo) and `directory` (the folder actually opened). The v1 adapter
1476
+ preferred `worktree`, so every non-git folder on a drive seeded its scope
1477
+ on the drive root and collapsed into one shared
1478
+ `project__workspace__<hash of the root>` bucket — a `c:\temp` project
1479
+ landing in `project__workspace__3dc8472eaebe` (seed `c:\`) is what
1480
+ surfaced it. `pickScopeRoot` (src/scope.ts) now takes `worktree` only when
1481
+ it is a real directory below the filesystem root, else `directory`. The
1482
+ repo-root behavior is unchanged: a real worktree still wins, so opening a
1483
+ repo subfolder keeps the repo's scope. Memories already written under a
1484
+ root-seeded key stay where they are (no auto-migration, per the existing
1485
+ rule); `open-memex scopes` shows the stray bucket and
1486
+ `open-memex migrate --from <old-key>` moves them. The v2 adapter already
1487
+ seeded on the opened directory and is unaffected.
1484
1488
 
1485
1489
  - **D70 — visibility follow-ups: one number, one guard, one truth.** The
1486
1490
  3-C review of D68/D69 found the surfaces honest individually and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-memex",
3
- "version": "0.7.0-alpha.11",
3
+ "version": "0.7.0-alpha.13",
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",
@@ -5,7 +5,7 @@ import { planConversion, isV2File, migrateV2 } from "../src/store/v2migrate.ts";
5
5
  import { redact, findSecret } from "../src/redact.ts";
6
6
  import { detectKeywords, scanKeywords } from "../src/capture/keywords.ts";
7
7
  import { DEFAULT_CONFIG } from "../src/config.ts";
8
- import { resolveProjectScope, resolveCwdScope, PERSONAL_SCOPE } from "../src/scope.ts";
8
+ import { resolveProjectScope, resolveCwdScope, pickScopeRoot, PERSONAL_SCOPE } from "../src/scope.ts";
9
9
  import { cjkIndexText, cjkQueryExpr, hasCjk } from "../src/retrieve/cjk.ts";
10
10
  import { toFtsQuery } from "../src/retrieve/query.ts";
11
11
  import { contentHash, similarity, NEAR_DUP_THRESHOLD } from "../src/store/lifecycle.ts";
@@ -379,6 +379,17 @@ ok("cwd scope deterministic", cwdOnly.key === cwdOnly2.key);
379
379
  // (This repo does have an origin after `git push`.)
380
380
  console.log(" cwd.key =", cwdOnly.key);
381
381
 
382
+ console.log("== scope (pickScopeRoot, D71) ==");
383
+ // OpenCode v1 reports the filesystem root as `worktree` for non-git folders;
384
+ // seeding a scope on it merges every non-git folder on the drive into one
385
+ // shared `project__workspace__…` bucket. A real worktree still wins.
386
+ const fsRoot = path.parse(process.cwd()).root;
387
+ ok("real worktree wins", pickScopeRoot(process.cwd(), "/elsewhere/opened") === process.cwd());
388
+ ok("fs-root worktree yields to directory", pickScopeRoot(fsRoot, process.cwd()) === process.cwd());
389
+ ok("missing worktree uses directory", pickScopeRoot(null, process.cwd()) === process.cwd());
390
+ ok("nothing but fs root: last resort", pickScopeRoot(fsRoot, null) === fsRoot);
391
+ ok("no candidates falls back to cwd", pickScopeRoot(null, null) === process.cwd());
392
+
382
393
  console.log("== cjk ==");
383
394
  ok("detects han", hasCjk("中文记忆"));
384
395
  ok("detects mixed", hasCjk("open中文"));
@@ -358,74 +358,6 @@ const inTreePage = path.join(PROJ, "page.html");
358
358
  r = cliRetry(["inventory", "--format", "html", "--out", inTreePage], PROJ);
359
359
  ok("html refuses a worktree path without --allow-personal", r.code !== 0 && !fs.existsSync(inTreePage));
360
360
 
361
- // ---------- 7b. D70: visibility fixes from the 3-C review ----------
362
- console.log("== D70: honest counts, stable numbers, one guard ==");
363
- // The guard must not fail open: a --out whose parent directory does not
364
- // exist used to skip the worktree check (git -C on a missing dir throws)
365
- // and then die with a raw ENOENT. Outside a worktree the missing directory
366
- // is the only problem left, so it gets its own message.
367
- const missingDir = path.join(T, "no-such-dir", "report.json");
368
- r = cliRetry(["inventory", "--out", missingDir], PROJ);
369
- ok(
370
- "inventory explains a missing parent directory",
371
- r.code !== 0 && /does not exist/.test(r.err) && !r.err.includes("ENOENT"),
372
- (r.err || r.out).slice(0, 160),
373
- );
374
- // ...and inside a worktree the personal-content refusal wins over it.
375
- const missingInTree = path.join(PROJ, "no-such-dir", "report.json");
376
- r = cliRetry(["inventory", "--out", missingInTree], PROJ);
377
- ok(
378
- "worktree refusal wins over a missing directory",
379
- r.code !== 0 && /git working tree/.test(r.err) && !fs.existsSync(missingInTree),
380
- (r.err || r.out).slice(0, 160),
381
- );
382
-
383
- // `open-memex list` is the surface a sceptical user runs first: it must
384
- // not look complete when it isn't (the tool already discloses).
385
- const fillerIds: string[] = [];
386
- for (let i = 0; i < 22; i++) fillerIds.push(addMem([`fulltest d70 filler ${i} padding text`], PROJ));
387
- r = cliRetry(["list", "--limit", "5"], PROJ);
388
- ok("cli list discloses truncation", /… and \d+ more not shown/.test(r.out), r.out.slice(-160));
389
- ok("cli list renders the structured inventory line", /^\d+\. \[[a-z]+\] id=\S+ created=\S+ source=/m.test(r.out), r.out.slice(0, 200));
390
- // Numbering runs across the whole listing, so a number names one entry.
391
- r = cliRetry(["list", "--scope", "both", "--limit", "5"], PROJ);
392
- const nums = [...r.out.matchAll(/^(\d+)\. \[/gm)].map((m) => Number(m[1]));
393
- ok(
394
- "numbers are unique and ascending across scopes",
395
- nums.length > 2 && nums.every((n, i) => i === 0 || n === nums[i - 1]! + 1) && new Set(nums).size === nums.length,
396
- JSON.stringify(nums),
397
- );
398
- for (const id of fillerIds) cliRetry(["forget", id], PROJ);
399
-
400
- // A retraction of an in-repo memory is a local working-tree edit until it
401
- // is committed - "hidden" must not read as "gone for the team".
402
- const repoHideId = addMem(["fulltest d70 repo retraction"], PROJ);
403
- cliRetry(["submit", repoHideId], PROJ);
404
- r = cliRetry(["forget", repoHideId, "--soft"], PROJ);
405
- ok(
406
- "soft hide of a repo memory warns about the commit",
407
- /commit and push/i.test(r.out) && /team still sees it/i.test(r.out),
408
- r.out.slice(0, 200),
409
- );
410
- r = cliRetry(["forget", repoHideId], PROJ);
411
-
412
- // An empty scope must still appear: "this project has nothing" is an answer.
413
- const emptyProj = mkproj("empty-proj");
414
- r = cliRetry(["inventory", "--scope", "project"], emptyProj);
415
- ok("inventory shows an empty scope as 0", /Project:.*\(0\)/.test(r.out) && /nothing remembered/i.test(r.out), r.out.slice(0, 200));
416
-
417
- // JSON: no absolute local paths, and the format id records the change.
418
- r = cliRetry(["inventory", "--format", "json"], PROJ);
419
- const invJson2 = JSON.parse(r.out.slice(r.out.indexOf("{")));
420
- ok(
421
- "inventory json is /2 and carries no absolute file paths",
422
- invJson2.format === "open-memex-inventory/2" &&
423
- !JSON.stringify(invJson2).includes(TESTENV.MY_O_MEMORY_HOME as string) &&
424
- !/"file":/.test(JSON.stringify(invJson2)),
425
- `${invJson2.format} ${JSON.stringify(invJson2).slice(0, 120)}`,
426
- );
427
- ok("inventory json counts hidden without the entries", invJson2.hidden !== undefined && !("hiddenEntries" in invJson2));
428
-
429
361
  // ---------- 8. pull / push ----------
430
362
  console.log("== pull / push ==");
431
363
  r = cliRetry(["push"], PROJ);
@@ -696,6 +628,147 @@ console.log("== D64: p1 fixes (propose visibility / supersede published / status
696
628
  ok("uninstall finds and cleans .jsonc global config", r.code === 0 && !/open-memex/.test(afterText), (r.out + r.err).slice(0, 160) + " || " + afterText.slice(0, 120));
697
629
  }
698
630
 
631
+ // ---------- 12. D70: the visibility fixes from the 3-C review ----------
632
+ // Last section on purpose: nothing here may perturb the flows above it.
633
+ // It writes only into a scratch project and into appdata fixtures (no
634
+ // commits in PROJ, no extra CLI spawns beyond a handful) so the suite stays
635
+ // order-independent and cheap — the shared PROJ git state is never dirtied.
636
+ console.log("== D70: honest counts, stable numbers, one guard ==");
637
+ {
638
+ const fixture = (dir: string, id: string, body: string, scopeKey: string) => {
639
+ const now = new Date().toISOString();
640
+ fs.mkdirSync(dir, { recursive: true });
641
+ fs.writeFileSync(
642
+ path.join(dir, `${id}.md`),
643
+ `---
644
+ id: ${id}
645
+ schema_version: 2
646
+ scope_key: ${scopeKey}
647
+ scope: project
648
+ visibility: internal
649
+ project_name: d70
650
+ type: fact
651
+ role: knowledge
652
+ importance: normal
653
+ status: active
654
+ tags: []
655
+ source: cli
656
+ created_at: "${now}"
657
+ updated_at: "${now}"
658
+ supersedes: null
659
+ superseded_by: null
660
+ review_state: draft
661
+ proposed_by: null
662
+ approved_by: null
663
+ derived_from: null
664
+ review_note: null
665
+ review_history: []
666
+ ---
667
+
668
+ ${body}
669
+ `,
670
+ "utf8",
671
+ );
672
+ };
673
+
674
+ // The guard must not fail open: a --out whose parent directory does not
675
+ // exist used to skip the worktree check (git -C on a missing dir throws)
676
+ // and then die with a raw ENOENT. Outside a worktree the missing directory
677
+ // is the only problem left, so it gets its own message.
678
+ const missingDir = path.join(T, "no-such-dir", "report.json");
679
+ r = cliRetry(["inventory", "--out", missingDir], PROJ);
680
+ ok(
681
+ "inventory explains a missing parent directory",
682
+ r.code !== 0 && /does not exist/.test(r.err) && !r.err.includes("ENOENT"),
683
+ (r.err || r.out).slice(0, 160),
684
+ );
685
+ // ...and inside a worktree the personal-content refusal wins over it.
686
+ const missingInTree = path.join(PROJ, "no-such-dir", "report.json");
687
+ r = cliRetry(["inventory", "--out", missingInTree], PROJ);
688
+ ok(
689
+ "worktree refusal wins over a missing directory",
690
+ r.code !== 0 && /git working tree/.test(r.err) && !fs.existsSync(missingInTree),
691
+ (r.err || r.out).slice(0, 160),
692
+ );
693
+
694
+ // `open-memex list` is the surface a sceptical user runs first: it must
695
+ // not look complete when it isn't. Fixtures go straight into the outbox
696
+ // (appdata, never the repo), so this needs no commits and no cleanup in git.
697
+ const d70Key = (/project:\s*(\S+)/.exec(cli(["where"], PROJ).out) || [])[1] ?? "";
698
+ const outboxDir = path.join(TESTENV.MY_O_MEMORY_HOME as string, "memories", d70Key);
699
+ const fillerIds: string[] = [];
700
+ for (let i = 0; i < 22; i++) {
701
+ const cid = `01P1D70${String(i).padStart(19, "0")}`;
702
+ fillerIds.push(cid);
703
+ fixture(outboxDir, cid, `fulltest d70 filler ${i} padding text`, d70Key);
704
+ }
705
+ cli(["reindex"], PROJ);
706
+ r = cliRetry(["list", "--limit", "5"], PROJ);
707
+ ok("cli list discloses truncation", /… and \d+ more not shown/.test(r.out), r.out.slice(-160));
708
+ ok(
709
+ "cli list renders the structured inventory line",
710
+ /^\d+\. \[[a-z]+\] id=\S+ created=\S+ source=/m.test(r.out),
711
+ r.out.slice(0, 200),
712
+ );
713
+ // Numbering runs across the whole listing, so a number names one entry.
714
+ r = cliRetry(["list", "--scope", "both", "--limit", "5"], PROJ);
715
+ const nums = [...r.out.matchAll(/^(\d+)\. \[/gm)].map((m) => Number(m[1]));
716
+ ok(
717
+ "numbers are unique and ascending across scopes",
718
+ nums.length > 2 && new Set(nums).size === nums.length && nums[0] === 1 && nums.every((n, i) => i === 0 || n === nums[i - 1]! + 1),
719
+ JSON.stringify(nums),
720
+ );
721
+ // The text report numbers the same way.
722
+ r = cliRetry(["inventory", "--scope", "project"], PROJ);
723
+ const textNums = [...r.out.matchAll(/^(\d+)\. \[/gm)].map((m) => Number(m[1]));
724
+ ok(
725
+ "inventory text numbers continuously too",
726
+ textNums.length > 2 && new Set(textNums).size === textNums.length && textNums.every((n, i) => i === 0 || n === textNums[i - 1]! + 1),
727
+ JSON.stringify(textNums.slice(0, 8)),
728
+ );
729
+ for (const id of fillerIds) fs.rmSync(path.join(outboxDir, `${id}.md`), { force: true });
730
+ cli(["reindex"], PROJ);
731
+
732
+ // A retraction of an in-repo memory is a local working-tree edit until it
733
+ // is committed - "hidden" must not read as "gone for the team". Runs in its
734
+ // own project so PROJ's git state stays clean for the sections above.
735
+ const d70Proj = mkproj("d70-proj");
736
+ const repoHideId = addMem(["fulltest d70 repo retraction"], d70Proj);
737
+ cliRetry(["submit", repoHideId], d70Proj);
738
+ r = cliRetry(["forget", repoHideId, "--soft"], d70Proj);
739
+ ok(
740
+ "soft hide of a repo memory warns about the commit",
741
+ /commit and push/i.test(r.out) && /team still sees it/i.test(r.out),
742
+ r.out.slice(0, 200),
743
+ );
744
+ r = cliRetry(["forget", repoHideId], d70Proj);
745
+ ok("hard forget of a hidden repo memory still deletes", r.code === 0 && /deleted/i.test(r.out), (r.err || r.out).slice(0, 120));
746
+
747
+ // An empty scope must still appear: "this project has nothing" is an answer.
748
+ const emptyProj = mkproj("empty-proj");
749
+ r = cliRetry(["inventory", "--scope", "project"], emptyProj);
750
+ ok(
751
+ "inventory shows an empty scope as 0",
752
+ /Project:.*\(0\)/.test(r.out) && /nothing remembered/i.test(r.out),
753
+ r.out.slice(0, 200),
754
+ );
755
+
756
+ // JSON: no absolute local paths, and the format id records the change.
757
+ r = cliRetry(["inventory", "--format", "json"], PROJ);
758
+ const invJson2 = JSON.parse(r.out.slice(r.out.indexOf("{")));
759
+ ok(
760
+ "inventory json is /2 and carries no absolute file paths",
761
+ invJson2.format === "open-memex-inventory/2" &&
762
+ !JSON.stringify(invJson2).includes(TESTENV.MY_O_MEMORY_HOME as string) &&
763
+ !/"file":/.test(JSON.stringify(invJson2)),
764
+ `${invJson2.format} ${JSON.stringify(invJson2).slice(0, 120)}`,
765
+ );
766
+ ok(
767
+ "inventory json counts hidden without the entries",
768
+ invJson2.hidden !== undefined && !("hiddenEntries" in invJson2),
769
+ );
770
+ }
771
+
699
772
  console.log("\n================ SUMMARY ================");
700
773
  console.log(`pass: ${pass}, fail: ${fail}`);
701
774
  if (failures.length) { console.log("failed tests:"); for (const f of failures) console.log(" - " + f); }
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  captureFromText,
6
6
  injectOnce,
7
7
  } from "./plugin-core.ts";
8
+ import { pickScopeRoot } from "./scope.ts";
8
9
  import { makeTools } from "./tools/memory.ts";
9
10
  import { v2Plugin } from "./opencode-v2.ts";
10
11
 
@@ -19,8 +20,9 @@ import { v2Plugin } from "./opencode-v2.ts";
19
20
  * type-only (D58), and v2's decode is structural.
20
21
  */
21
22
  const server: Plugin = async ({ worktree, directory }) => {
22
- const roots = worktree || directory || process.cwd();
23
- const state = bootstrapPlugin(roots);
23
+ // v1's `worktree` is the filesystem root for non-git folders — never seed a
24
+ // scope on it (D71). `pickScopeRoot` falls back to the opened `directory`.
25
+ const state = bootstrapPlugin(pickScopeRoot(worktree, directory));
24
26
  const tools = makeTools(() => state.scope, state.cfg);
25
27
 
26
28
  return {
package/src/scope.ts CHANGED
@@ -68,6 +68,24 @@ export function resolveCwdScope(worktree: string): Scope {
68
68
  return scopeFromSeed(seed, projectName);
69
69
  }
70
70
 
71
+ /** Pick the directory that seeds scope resolution from a host's candidate
72
+ * roots. OpenCode v1 hands the plugin both `worktree` (the repo root — but
73
+ * the filesystem root, e.g. `C:\` or `/`, when the opened folder is not a
74
+ * git repo) and `directory` (the folder actually opened). Seeding on a
75
+ * filesystem root collapses every non-git folder on a drive into one shared
76
+ * `project__workspace__…` scope (D71), so prefer `worktree` only when it is
77
+ * a real directory below the filesystem root. */
78
+ export function pickScopeRoot(
79
+ worktree?: string | null,
80
+ directory?: string | null,
81
+ ): string {
82
+ const isFsRoot = (p: string) => path.parse(p).root === p;
83
+ if (worktree && !isFsRoot(worktree)) return worktree;
84
+ if (directory) return directory;
85
+ if (worktree) return worktree;
86
+ return process.cwd();
87
+ }
88
+
71
89
  export function resolveProjectScope(worktree: string): Scope {
72
90
  const target = worktree && worktree.length > 0 ? worktree : ".";
73
91
  const remote = tryGitRemote(target);