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 +3 -2
- package/CHANGELOG.md +5 -1
- package/dist/.build-stamp +1 -1
- package/dist/index.js +4 -2
- package/dist/scope.js +17 -0
- package/docs/V2-DESIGN.md +17 -13
- package/package.json +1 -1
- package/scripts/smoke-pure.ts +12 -1
- package/scripts/test-full.ts +141 -68
- package/src/index.ts +4 -2
- package/src/scope.ts +18 -0
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
|
|
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–
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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–
|
|
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
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
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.
|
|
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",
|
package/scripts/smoke-pure.ts
CHANGED
|
@@ -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中文"));
|
package/scripts/test-full.ts
CHANGED
|
@@ -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
|
-
|
|
23
|
-
|
|
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);
|