open-memex 0.7.0-alpha.8 → 0.7.0
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 +8 -3
- package/CHANGELOG.md +40 -0
- package/README.md +17 -3
- package/README.zh-CN.md +12 -3
- package/dist/.build-stamp +1 -1
- package/dist/capture/handoff.js +59 -0
- package/dist/cli.js +188 -19
- package/dist/distill-agents.js +4 -1
- package/dist/index.js +14 -6
- package/dist/inventory-html.js +150 -0
- package/dist/inventory.js +176 -0
- package/dist/mcp.js +9 -0
- package/dist/opencode-v2.js +8 -3
- package/dist/paths.js +18 -0
- package/dist/plugin-core.js +48 -3
- package/dist/retrieve/display.js +161 -0
- package/dist/retrieve/search.js +33 -3
- package/dist/scope.js +17 -0
- package/dist/store/lifecycle.js +42 -0
- package/dist/tools/ops.js +92 -22
- package/docs/TEST-PLAN.md +1 -1
- package/docs/V2-DESIGN.md +117 -2
- package/docs/memory-visibility-design.md +130 -0
- package/package.json +2 -2
- package/scripts/build-stamp.mjs +62 -62
- package/scripts/smoke-mcp.ts +47 -1
- package/scripts/smoke-pure.ts +85 -1
- package/scripts/test-full.ts +192 -5
- package/skills/open-memex/SKILL.md +29 -0
- package/src/capture/handoff.ts +74 -0
- package/src/cli.ts +202 -19
- package/src/distill-agents.ts +7 -0
- package/src/index.ts +13 -5
- package/src/inventory-html.ts +191 -0
- package/src/inventory.ts +242 -0
- package/src/mcp.ts +9 -0
- package/src/opencode-v2.ts +7 -2
- package/src/paths.ts +19 -0
- package/src/plugin-core.ts +62 -3
- package/src/retrieve/display.ts +174 -0
- package/src/retrieve/search.ts +49 -4
- package/src/scope.ts +18 -0
- package/src/store/lifecycle.ts +52 -0
- package/src/tools/ops.ts +99 -27
package/AGENTS.md
CHANGED
|
@@ -14,7 +14,7 @@ via `prepublishOnly` — Node refuses `--experimental-strip-types` for files und
|
|
|
14
14
|
- **CLI** (`src/cli.ts`) and smoke tests run under **Node 22+** with `--experimental-strip-types`. SQLite here is `better-sqlite3` (native module).
|
|
15
15
|
- **MCP server** (`src/mcp.ts`, stdio) runs under **Node 22+** with `--experimental-strip-types`. It exposes the same eleven memory tools to any MCP client (VS Code Copilot, Cursor, Claude Code). **stdout is the protocol channel — never log to stdout in `mcp.ts`; diagnostics go to stderr.**
|
|
16
16
|
|
|
17
|
-
`src/store/db.ts` picks the backend at runtime by sniffing `globalThis.Bun`. Both backends share the same surface (`new Database(path)`, `.exec`, `.prepare().run/all/get`, `.close`). Any DB code you write must stay on that common subset — do not import `better-sqlite3` or `bun:sqlite` directly outside `db.ts`.
|
|
17
|
+
`src/store/db.ts` picks the backend at runtime by sniffing `globalThis.Bun`. Both backends share the same surface (`new Database(path)`, `.exec`, `.prepare().run/all/get`, `.close`). Any DB code you write must stay on that common subset — do not import `better-sqlite3` or `bun:sqlite` directly outside `db.ts`. The Node driver is pinned to the `better-sqlite3` 13 N-API line (D72): the pre-13 `node::ObjectWrap` build aborts intermittently on Node 24.19+ when a prepared statement is finalized by GC.
|
|
18
18
|
|
|
19
19
|
Tool logic is host-agnostic and lives in `src/tools/ops.ts` (plain functions + shared zod arg shapes + `TOOL_DESCRIPTIONS`). `src/tools/memory.ts` (opencode) and `src/mcp.ts` are thin adapters — when adding or changing a tool, change `ops.ts` once and both hosts pick it up.
|
|
20
20
|
|
|
@@ -33,6 +33,7 @@ npm run typecheck # tsc --noEmit — the only
|
|
|
33
33
|
npm run cli -- where | list | search "q" | add ... | forget <id> | reindex
|
|
34
34
|
npm run cli -- <command> --help # per-command help (AI assistants discover flags this way)
|
|
35
35
|
npm run cli -- sync-status # last sync time/kind + outbox drafts + repo review states + uncommitted files
|
|
36
|
+
npm run cli -- inventory [--format text|json|html] [--scope both] # what is remembered, in the open (D68); personal-bearing output refused inside a git worktree without --allow-personal
|
|
36
37
|
npm run cli -- audit [--scope project|personal|both] # memory health: near-dup pairs, stale actives, broken chains, personal-in-repo (read-only)
|
|
37
38
|
npm run cli -- pull # fetch + fast-forward only (explicit; diverged = clean failure, never force-merge)
|
|
38
39
|
npm run cli -- push # push current branch to remote (explicit only; open-memex never auto-pushes)
|
|
@@ -125,6 +126,7 @@ Layout: `memories/<scope_key>/<id>.md` (YAML frontmatter + body) + `index.db` (S
|
|
|
125
126
|
|
|
126
127
|
- `personal` scope key is literal `"personal"` (v1 called this `user`; renamed in v2, design §19).
|
|
127
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.
|
|
128
130
|
- `org`, `team`, `public` are reserved — the schema rejects writes. See `docs/SCOPES.md`.
|
|
129
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.
|
|
130
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.
|
|
@@ -153,21 +155,24 @@ AGENTS.md hygiene footer, the MCP handshake, and the tool descriptions.
|
|
|
153
155
|
- Never hand-write `status: superseded` or half a chain. Use the `supersede` code path (`src/store/lifecycle.ts`): the old record keeps its file, flips to `superseded`, and both sides get `supersedes`/`superseded_by`. Only `active` memories can be superseded.
|
|
154
156
|
- Superseding a memory that had entered review (proposed/approved/published/rejected) starts the replacement at `review_state: proposed` so `promote` can advance it (D64). Never reset it to `draft` — a draft in the repo dir is stranded (submit only moves outbox files).
|
|
155
157
|
- `setStatus` refuses `retracted → active` (D64): retracted content must not silently return to recall. Save a new memory instead.
|
|
158
|
+
- **Visibility surfaces share one contract (D70).** `memory_list`, `cli list` and `inventory` render the same `formatInventoryLine`; numbering is a running counter across every section of one listing (never per scope), and a cut list discloses how many entries it dropped. Any *correction* that touches an in-repo memory (`memory_forget`, `soft=true`, `forget --soft`) must say the change is local until committed and pushed — use `isInRepoMemoryFile` (`src/paths.ts`). Report output paths go through `inventoryWriteRefusal` (`src/cli.ts`), which fails closed on an unresolvable directory.
|
|
156
159
|
- **Every memory-file write goes through `atomicWriteTextSync`** (markdown.ts; tmp + rename). The create path, supersede flips, review transitions, submit landings, migrate/v2migrate rewrites — no direct `fs.writeFileSync` on a memory file. A torn read mid-rewrite parses as a deletion and drops the memory from the index.
|
|
157
160
|
- Chain integrity is self-healing: on read, a missing counterpart is auto-completed with a warning; a dangling pointer warns but is never fabricated. Don't "fix" chains by editing frontmatter directly — let the read path do it.
|
|
158
161
|
- Frontmatter is `schema_version: 2`. The SQLite index schema is versioned separately and rebuilds automatically on version change — never hand-edit `index.db`.
|
|
159
162
|
|
|
160
163
|
Keyword capture fires from `chat.message` on the user's message parts (`UserMessage`). Patterns live in `src/capture/keywords.ts` / config `keywordPatterns`; regex group 1 is the memory body. Scope routing: personal patterns (`cfg.keywordPersonalPatterns`) force the personal scope — the rule is 我 → personal (记住我/替我记/帮我记/我觉得/我喜欢/remember for me/help me remember), 我们 → current scope (我们认为/我们决定/帮我们记住); personal patterns run first and claim their line so a generic trigger can't double-fire (a rejected personal match keeps the claim — see D67).
|
|
161
164
|
|
|
165
|
+
D73 — one user statement, one memory. The hook and the agent are two writers for one turn, and only the hook knows the sentence is stored. Both hooks therefore take the session id (`chat.message` on v1, `prompt` on v2), `captureFromText` records each capture in `PluginState.captures`, and the system/context hook pushes the hand-off note (`captureHandoff` → `formatHandoffBlock`, `src/capture/handoff.ts`) once per capture. `memory_add` additionally refuses a write overlapping a `source: keyword`/`user` memory created in the same scope within `TURN_ECHO_WINDOW_MS` (`findTurnEcho`, threshold `TURN_ECHO_THRESHOLD` = 0.28) and returns the stored id plus the supersede route. Keep the note, the refusal text, `TOOL_DESCRIPTIONS.memory_add`, the skill, and this paragraph saying the same thing — an agent that reads only one of them still has to behave. Never lower `TURN_ECHO_THRESHOLD` to catch more: the measured reason it is high is that an agent's gloss (0.211) and a genuinely new fact from the same sentence (0.214) are lexically indistinguishable.
|
|
166
|
+
|
|
162
167
|
D67 rules for any new pattern: a trigger must be a **statement to the store**, not narration — 记得… / `remind me to…` are deliberately out, and the ambiguous forms require an explicit marker (`别忘了:…`, `don't forget: …`, `don't forget that …`). The 帮/替/我们 family takes the full verb phrase (`记` + optional `住|录`, never followed by a verb continuation) + optional `一下`; the separator boundary applies to the **bare** verb only, so `帮我记一下这个配置` captures while `帮我记得带伞` cannot produce mid-word garbage. Bodies under `MIN_CAPTURE_CHARS` (3) are rejected as fragments — never lower the floor silently; `scanKeywords` returns the drop so `capture --dry-run` and the debug log can report it.
|
|
163
168
|
|
|
164
169
|
Context injection happens exactly once per session in `experimental.chat.system.transform`, guarded by an in-memory `Set<sessionID>` in `src/index.ts`. It is not persisted — restarting opencode re-injects on the next first turn.
|
|
165
170
|
|
|
166
171
|
## Design constraints — read the frozen design first
|
|
167
172
|
|
|
168
|
-
`docs/V2-DESIGN.md` is the frozen protocol v0.2 (decisions
|
|
173
|
+
`docs/V2-DESIGN.md` is the frozen protocol v0.2 (decisions settled through D73; open questions tracked at the end of the doc). Per its §12:
|
|
169
174
|
AGENTS.md answers "how should AI work here"; the design doc answers "why is it
|
|
170
|
-
built this way" (principles, iron rules, the append-only decision log, currently D1–
|
|
175
|
+
built this way" (principles, iron rules, the append-only decision log, currently D1–D73). Before changing
|
|
171
176
|
architecture, scope semantics, lifecycle, or the protocol surface (frontmatter
|
|
172
177
|
schema, MCP tools, CLI contract), read the relevant design section — the decision
|
|
173
178
|
log records what was already considered and rejected.
|
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,46 @@ the `@alpha` tag are development snapshots and are not listed individually.
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
## One thing you said is one memory (D73)
|
|
13
|
+
|
|
14
|
+
Saying "remember ." used to save the same statement up to three times: the keyword hook stored your sentence verbatim with no agent in the loop, then the agent - which could see the chat but not the store - saved its own paraphrase beside it, and `memory_add`'s near-duplicate check scored the two too far apart (0.21-0.38 against a 0.80 bar) to notice. `open-memex audit` reported zero near-duplicates while three copies sat in the folder, and search returned all of them.
|
|
15
|
+
|
|
16
|
+
- **The agent is told what was already stored.** When the keyword hook captures a sentence, the plugin records it against the session and the next system context carries a short note naming the stored memory. Do not save that text again in any rewording; save only what it does not contain, or `memory_supersede` it. Delivered once per capture, in memory only (nothing new is written to disk).
|
|
17
|
+
- **`memory_add` refuses the echo anyway.** A write that overlaps a memory captured from your own words in the same scope within the last 10 minutes is refused with the stored id, as a deterministic backstop for when the note is not enough. The bar is deliberately high (0.28 token overlap) and never applies to the agent's own inferences - a distinct fact the same sentence carried still saves normally. A refusal is recoverable: supersede the stored one, or re-save with only the new information.
|
|
18
|
+
- Reports nothing and changes no data: existing memories are not merged or rewritten, and the near-duplicate notice for ordinary agent saves is unchanged.
|
|
19
|
+
|
|
20
|
+
## Node 24 CLI aborts: SQLite driver moves to the N-API line (D72)
|
|
21
|
+
|
|
22
|
+
- **Fixed: CLI processes intermittently aborted on Node 24.19+.** `better-sqlite3` 11 could finalize a prepared statement from a garbage-collection callback after Node's environment was gone (`RemoveEnvironmentCleanupHook ... Assertion failed`), killing the process with `SIGABRT` and losing its output even though the memory work had already landed. The driver is now `better-sqlite3` 13 (the N-API rewrite; Node �22 unchanged) - same API surface, no protocol or data change. If you build from source on Node 24.19+, reinstall dependencies so the new driver is picked up.
|
|
23
|
+
|
|
24
|
+
## Non-git folders shared one scope bucket (D71)
|
|
25
|
+
|
|
26
|
+
- **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>`.
|
|
27
|
+
|
|
28
|
+
## Visibility follow-ups: one number, one guard, one truth (D70)
|
|
29
|
+
|
|
30
|
+
Review of the D68/D69 inventory surfaces — each was honest on its own, and they disagreed with each other:
|
|
31
|
+
|
|
32
|
+
- `open-memex list` no longer truncates silently: it renders the same structured line the agent sees and says how many entries it left out (20 of 25 used to look like all 25).
|
|
33
|
+
- Numbers are unique within one listing. `memory_list(scope: both)` and the text report restarted the counter per scope, so a single listing held two `#1`s and "delete #3" was a guess.
|
|
34
|
+
- Hiding an in-repo memory now says what the delete path already said: the retraction is a local working-tree edit until you commit and push it — until then the team still sees the memory.
|
|
35
|
+
- The report's worktree guard fails closed (it used to skip the check when the output directory did not exist yet), explains a missing directory instead of dying with a raw `ENOENT`, and is shared by all three formats instead of existing twice.
|
|
36
|
+
- `inventory --format json` is now `open-memex-inventory/2`: the absolute `file` path is gone — that artifact is meant for an agent, and it carried your home directory with it.
|
|
37
|
+
- An empty scope is reported as `(0)` rather than disappearing; only the HTML audit view loads the hidden rows themselves; the page enforces its "no network requests" promise with a `default-src 'none'` CSP.
|
|
38
|
+
|
|
39
|
+
## Inventory HTML report (D68 follow-up)
|
|
40
|
+
|
|
41
|
+
- **`open-memex inventory --format html`** renders the same data layer as the text/JSON formats into a single local page: current memories grouped by scope, outbox drafts in their own section, and the replaced/hidden history folded at the bottom with supersede-chain pointers. All content is escaped; the filter box is local show/hide only. Default output is `<data dir>/inventory.html`; the worktree refusal (`--allow-personal`) applies here too. Sections fold when the store grows past 50 current entries.
|
|
42
|
+
|
|
43
|
+
## Memory visibility: see it, hide it, delete it (D68)
|
|
44
|
+
|
|
45
|
+
Group 3-C — the surface a user reads to learn what the store actually remembers:
|
|
46
|
+
|
|
47
|
+
- **`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.
|
|
48
|
+
- **`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`.
|
|
49
|
+
- **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.
|
|
50
|
+
- **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.
|
|
51
|
+
|
|
12
52
|
## Keyword capture: what counts as a trigger (D67)
|
|
13
53
|
|
|
14
54
|
The D66 review left two questions open and introduced one regression. Ruled, not deferred:
|
package/README.md
CHANGED
|
@@ -375,7 +375,11 @@ Three ways memories get in:
|
|
|
375
375
|
`don't forget the wifi password` never fire (add a separator — `别忘了:…`,
|
|
376
376
|
`don't forget: …` — or `that` to make it an instruction); and a captured body
|
|
377
377
|
under 3 characters is rejected as a fragment rather than saved — `capture
|
|
378
|
-
--dry-run` says so instead of dropping it silently.
|
|
378
|
+
--dry-run` says so instead of dropping it silently. A trigger also owns its
|
|
379
|
+
own sentence: the plugin tells the agent what it just stored, and a
|
|
380
|
+
`memory_add` that re-saves the same words within the next few minutes is
|
|
381
|
+
refused with the stored id (D73) — one thing you said is one memory, not
|
|
382
|
+
three copies of it.
|
|
379
383
|
- **The agent saves it**: in any editor, ask your agent to remember something
|
|
380
384
|
(or it saves on its own when you state a fact worth keeping) — it calls
|
|
381
385
|
`memory_add`. The routing above is a heuristic; you can always say "save this
|
|
@@ -416,9 +420,9 @@ they only matter once you share memories through Git.
|
|
|
416
420
|
|---|---|
|
|
417
421
|
| ● `memory_add` | Save a fact, preference, decision, note |
|
|
418
422
|
| ● `memory_search` | Keyword search (BM25) across project + personal memories |
|
|
419
|
-
| ● `memory_list` | List memories in a scope (`project`, `personal`, or `both`)
|
|
423
|
+
| ● `memory_list` | List memories in a scope (`project`, `personal`, or `both`) as a numbered inventory; `include=all` is the audit view |
|
|
420
424
|
| ● `memory_supersede` | Replace a memory with a newer version (keeps a supersede chain) |
|
|
421
|
-
| ● `memory_forget` | Delete a memory by id |
|
|
425
|
+
| ● `memory_forget` | Delete a memory by id (`soft=true` hides it instead — retracted, one-way) |
|
|
422
426
|
| `memory_status` | Show the sync queue: outbox drafts, repo review states, uncommitted files |
|
|
423
427
|
| `memory_submit` | Move named drafts into the repo memory dir (local branch + commit) |
|
|
424
428
|
| `memory_propose` | Copy personal memories into the project scope as review candidates |
|
|
@@ -643,9 +647,19 @@ open-memex search "auth flow"
|
|
|
643
647
|
open-memex search "auth flow" --explain # show FTS expression, scores, lifecycle-hidden counts
|
|
644
648
|
open-memex list --scope project
|
|
645
649
|
open-memex list --scope both # each scope's newest under its own header
|
|
650
|
+
open-memex list --include all # audit view: superseded versions, retracted, archived
|
|
646
651
|
open-memex supersede <id> "Updated content"
|
|
647
652
|
open-memex status <id> deprecated
|
|
648
653
|
open-memex forget <id>
|
|
654
|
+
open-memex forget <id> --soft # hide instead of delete (retracted; one-way)
|
|
655
|
+
|
|
656
|
+
open-memex inventory # everything remembered, as readable text
|
|
657
|
+
open-memex inventory --format json # the same data, for agents
|
|
658
|
+
open-memex inventory --format html # the same data as a local page (writes <data dir>/inventory.html)
|
|
659
|
+
# Personal + current project, outbox drafts in their own section, hidden
|
|
660
|
+
# history counted. Refuses to write a personal-bearing report inside a
|
|
661
|
+
# git working tree unless you pass --allow-personal.
|
|
662
|
+
# `list` numbers every entry in one listing and says how many it left out.
|
|
649
663
|
```
|
|
650
664
|
|
|
651
665
|
Team review workflow (two homes, one per stage):
|
package/README.zh-CN.md
CHANGED
|
@@ -252,7 +252,7 @@ open-memex uninstall --yes
|
|
|
252
252
|
|
|
253
253
|
三种存入方式:
|
|
254
254
|
|
|
255
|
-
- **关键词触发**(仅 opencode 原生插件):说 `remember …`、`note that …`、`don't forget: …`、`TIL …`、`save this …`——中文 `记住…` / `记一下…` / `记录一下…` / `别忘了:…`——这句话就被存下来,不需要任何工具调用。捕获默认进当前 **project**;`remember for me …`、`help me remember: …`、`记住我…`、`替我记…`、`帮我记…`、`我觉得…`、`我喜欢…` 这类"这是关于我"的信号会改为进 **personal**;`我们决定…`、`帮我们记住…` 等团队语境仍留在项目里。两条降噪规则(D67):触发词必须是**对存储说的话**,所以叙述型的 `记得…` / `remind me to…` / `别忘了带伞` / `don't forget the wifi password` 一律不触发(加一个分隔符——`别忘了:…`、`don't forget: …`——或用 `that`,才算指令);捕获内容不足 3 个字符按碎片丢弃,不会存下来——`capture --dry-run`
|
|
255
|
+
- **关键词触发**(仅 opencode 原生插件):说 `remember …`、`note that …`、`don't forget: …`、`TIL …`、`save this …`——中文 `记住…` / `记一下…` / `记录一下…` / `别忘了:…`——这句话就被存下来,不需要任何工具调用。捕获默认进当前 **project**;`remember for me …`、`help me remember: …`、`记住我…`、`替我记…`、`帮我记…`、`我觉得…`、`我喜欢…` 这类"这是关于我"的信号会改为进 **personal**;`我们决定…`、`帮我们记住…` 等团队语境仍留在项目里。两条降噪规则(D67):触发词必须是**对存储说的话**,所以叙述型的 `记得…` / `remind me to…` / `别忘了带伞` / `don't forget the wifi password` 一律不触发(加一个分隔符——`别忘了:…`、`don't forget: …`——或用 `that`,才算指令);捕获内容不足 3 个字符按碎片丢弃,不会存下来——`capture --dry-run` 会明确说明,不再悄悄吞掉。另外,一句触发捕获的话只归一条记忆:插件会把它刚存下的内容告诉助手,几分钟内再用 `memory_add` 重复存同样的字眼会被拒绝(并给出已存的 id,D73)——你说的一件事就是一条记忆,不是它的三个副本。
|
|
256
256
|
- **助手主动保存**:在任何编辑器里,你让助手记住某件事(或者你陈述了一个值得记的事实、它自己判断该保存),它会调用 `memory_add`。上面的路由是启发式规则;你可以明说"存到我的个人记忆",或用 CLI 加 `--scope` 明确指定。
|
|
257
257
|
- **检查点提议**:一个任务收尾时,助手会提议 1–3 条从这轮工作里提炼的候选记忆——决定及其理由、约定、踩过的坑、走过又放弃的路——只有你点头的那几条才存。不会有任何东西在你不知情时被写下来。
|
|
258
258
|
- **CLI**:`open-memex add "…"`,可选 `--scope` / `--tag` / `--type` / `--aliases`。
|
|
@@ -275,9 +275,9 @@ MCP 服务器暴露十一个工具;opencode 原生插件暴露其中五个核
|
|
|
275
275
|
|---|---|
|
|
276
276
|
| ● `memory_add` | 保存一条事实、偏好、决策、笔记 |
|
|
277
277
|
| ● `memory_search` | 关键词搜索(BM25),横跨项目 + 个人记忆 |
|
|
278
|
-
| ● `memory_list` |
|
|
278
|
+
| ● `memory_list` | 以编号清单列出某个作用域(`project`、`personal` 或 `both`)的记忆;`include=all` 是审计视图 |
|
|
279
279
|
| ● `memory_supersede` | 用新版本替换一条记忆(保留 supersede 链) |
|
|
280
|
-
| ● `memory_forget` | 按 id
|
|
280
|
+
| ● `memory_forget` | 按 id 删除一条记忆(`soft=true` 改为隐藏——retracted,单向) |
|
|
281
281
|
| `memory_status` | 显示同步队列:outbox 草稿、仓库评审状态、未提交文件 |
|
|
282
282
|
| `memory_submit` | 把指定的草稿移进仓库记忆目录(本地分支 + 提交) |
|
|
283
283
|
| `memory_propose` | 把个人记忆复制到项目作用域,作为评审候选 |
|
|
@@ -443,9 +443,18 @@ open-memex search "auth flow"
|
|
|
443
443
|
open-memex search "auth flow" --explain # 显示 FTS 表达式、分数、被生命周期藏掉的计数
|
|
444
444
|
open-memex list --scope project
|
|
445
445
|
open-memex list --scope both # 两个作用域各自最新的,各带一个标题
|
|
446
|
+
open-memex list --include all # 审计视图:被替换的版本、已撤回、已归档
|
|
446
447
|
open-memex supersede <id> "Updated content"
|
|
447
448
|
open-memex status <id> deprecated
|
|
448
449
|
open-memex forget <id>
|
|
450
|
+
open-memex forget <id> --soft # 隐藏而不是删除(retracted,单向)
|
|
451
|
+
|
|
452
|
+
open-memex inventory # 记住的一切,纯文本清单
|
|
453
|
+
open-memex inventory --format json # 同一份数据,JSON 格式给 agent 用
|
|
454
|
+
open-memex inventory --format html # 同一份数据的本地网页(写到 <数据目录>/inventory.html)
|
|
455
|
+
# 个人 + 当前项目,outbox 草稿单独成节,被藏起的历史只计数。含个人内容的
|
|
456
|
+
# 报告拒绝写进 git 工作树,除非显式 --allow-personal。
|
|
457
|
+
# `list` 在同一份清单里连续编号,并说明自己少显示了哪些条目。
|
|
449
458
|
```
|
|
450
459
|
|
|
451
460
|
团队评审工作流(两个家,一个阶段一个):
|
package/dist/.build-stamp
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
c84b05d4afadebb4346f31a95ca00c4075a700269fc5019453799d0eeeb8b09e
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn hand-off between keyword capture and the agent (D73).
|
|
3
|
+
*
|
|
4
|
+
* The keyword hook and the agent are two writers for one user turn. The hook
|
|
5
|
+
* stores the user's sentence verbatim, with no agent in the loop; the agent
|
|
6
|
+
* then sees only the chat transcript and cannot tell the sentence is already
|
|
7
|
+
* stored, so it saves its own paraphrase — the same statement lands two or
|
|
8
|
+
* three times (observed 2026-10-04: one `remember …` sentence → one
|
|
9
|
+
* `source: keyword` file plus two `source: tool` files).
|
|
10
|
+
*
|
|
11
|
+
* Two mechanisms, in this order:
|
|
12
|
+
* (a) the hand-off note — this module's `formatHandoffBlock`, pushed into the
|
|
13
|
+
* turn's system context so the agent knows what is already stored;
|
|
14
|
+
* (b) the turn-echo guard — `TURN_ECHO_THRESHOLD` below, enforced in
|
|
15
|
+
* `addMemory` (src/tools/ops.ts) over what the hook just wrote.
|
|
16
|
+
*
|
|
17
|
+
* Pure module: no db, no fs, no config, so the threshold behavior is testable
|
|
18
|
+
* in scripts/smoke-pure.ts.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* Overlap bar for refusing a re-save of a just-captured user statement.
|
|
22
|
+
*
|
|
23
|
+
* Deliberately high, and only ever applied to memories the *hook* wrote
|
|
24
|
+
* (`source: keyword` / `user`) inside `TURN_ECHO_WINDOW_MS`. Measured on the
|
|
25
|
+
* reported case and its near neighbours (token Jaccard, see
|
|
26
|
+
* `similarity` in src/store/lifecycle.ts): a wholesale re-save of the captured
|
|
27
|
+
* sentence scores 0.29–0.36, while a genuinely distinct fact the same
|
|
28
|
+
* sentence also contained scores 0.21 and below. Lexical overlap cannot
|
|
29
|
+
* separate an agent's gloss from a new fact — those two measured 0.211 and
|
|
30
|
+
* 0.214 — so the guard sits at the high-confidence end and everything under
|
|
31
|
+
* the bar is left to (a) and the agent's judgment. A refusal is always
|
|
32
|
+
* reported with the stored id, so the caller can supersede or rephrase.
|
|
33
|
+
*/
|
|
34
|
+
export const TURN_ECHO_THRESHOLD = 0.28;
|
|
35
|
+
/** How long a hook capture counts as "this turn" (10 minutes). */
|
|
36
|
+
export const TURN_ECHO_WINDOW_MS = 10 * 60 * 1000;
|
|
37
|
+
export function isTurnEcho(score) {
|
|
38
|
+
return score >= TURN_ECHO_THRESHOLD;
|
|
39
|
+
}
|
|
40
|
+
/** Records still inside the window. */
|
|
41
|
+
export function freshCaptures(records, now) {
|
|
42
|
+
return records.filter((r) => now - r.at <= TURN_ECHO_WINDOW_MS);
|
|
43
|
+
}
|
|
44
|
+
function preview(content) {
|
|
45
|
+
const one = content.replace(/\s+/g, " ").trim();
|
|
46
|
+
return one.length > 160 ? `${one.slice(0, 160)}…` : one;
|
|
47
|
+
}
|
|
48
|
+
/** The hand-off note (a). Empty string when there is nothing to hand off. */
|
|
49
|
+
export function formatHandoffBlock(records) {
|
|
50
|
+
if (records.length === 0)
|
|
51
|
+
return "";
|
|
52
|
+
const lines = records.map((r) => `- id=${r.id} (${r.scopeKey}) — "${preview(r.content)}"`);
|
|
53
|
+
return [
|
|
54
|
+
"[open-memex] The user's own wording in this turn was ALREADY stored verbatim, before you saw it. It is not unsaved.",
|
|
55
|
+
...lines,
|
|
56
|
+
"Do not call memory_add to save that text again in any rewording — one statement is already one memory, and a rewording only adds a second copy that retrieval will return alongside the first.",
|
|
57
|
+
"Save a memory only for information those lines do NOT contain, as its own self-contained statement. If your version is meant to replace one of them, call memory_supersede with that id instead of memory_add.",
|
|
58
|
+
].join("\n");
|
|
59
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -9,15 +9,62 @@ import { findDuplicates, supersede, setStatus } from "./store/lifecycle.js";
|
|
|
9
9
|
import { proposeMemories, promoteMemory, listConflicts, resolveConflict, formatReviewHistory } from "./review.js";
|
|
10
10
|
import { getSyncStatus, formatSyncStatus, submitMemories } from "./submit.js";
|
|
11
11
|
import { getPrStatus, formatPrStatus, applyPrStatus } from "./github.js";
|
|
12
|
-
import { search, list, hitStateLabel } from "./retrieve/search.js";
|
|
12
|
+
import { search, list, countList, hitStateLabel } from "./retrieve/search.js";
|
|
13
|
+
import { formatInventoryLine, truncationNote } from "./retrieve/display.js";
|
|
13
14
|
import { writeMemoryFile, readMemoryFile, ulid, msToRfc3339, normalizeAliases, } from "./store/markdown.js";
|
|
14
15
|
import { loadConfig, configSource } from "./config.js";
|
|
15
|
-
import { paths, projectRoot } from "./paths.js";
|
|
16
|
+
import { paths, projectRoot, isInRepoMemoryFile } from "./paths.js";
|
|
16
17
|
import { redact } from "./redact.js";
|
|
17
18
|
import { resolveMcpCommand } from "./init.js";
|
|
18
19
|
import fs from "node:fs";
|
|
19
20
|
import path from "node:path";
|
|
21
|
+
import { execFileSync } from "node:child_process";
|
|
20
22
|
import { fileURLToPath } from "node:url";
|
|
23
|
+
/**
|
|
24
|
+
* True when `target` sits inside a git working tree (D68 guard).
|
|
25
|
+
*
|
|
26
|
+
* D70: resolve the nearest EXISTING ancestor first. `git -C` on a
|
|
27
|
+
* directory that does not exist throws, and the old code turned that
|
|
28
|
+
* into "not in a worktree" — a fail-open guard on the one path where
|
|
29
|
+
* personal memories would ride a commit.
|
|
30
|
+
*/
|
|
31
|
+
function isInsideWorkTree(target) {
|
|
32
|
+
try {
|
|
33
|
+
let dir = path.dirname(path.resolve(target));
|
|
34
|
+
while (!fs.existsSync(dir)) {
|
|
35
|
+
const parent = path.dirname(dir);
|
|
36
|
+
if (parent === dir)
|
|
37
|
+
break;
|
|
38
|
+
dir = parent;
|
|
39
|
+
}
|
|
40
|
+
const out = execFileSync("git", ["-C", dir, "rev-parse", "--is-inside-work-tree"], {
|
|
41
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
42
|
+
encoding: "utf8",
|
|
43
|
+
});
|
|
44
|
+
return out.trim() === "true";
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* D70: the one place that decides where a report may be written, for
|
|
52
|
+
* every `inventory` format. Returns an error message to print, or null
|
|
53
|
+
* when the path is safe. Kept single so the text/json/html paths cannot
|
|
54
|
+
* drift apart (they already had two copies, D68).
|
|
55
|
+
*/
|
|
56
|
+
function inventoryWriteRefusal(target, includesPersonal, allowPersonal) {
|
|
57
|
+
// Personal content must not ride a commit (the design's hardest rule).
|
|
58
|
+
if (includesPersonal && !allowPersonal && isInsideWorkTree(target)) {
|
|
59
|
+
return (`inventory: ${target} is inside a git working tree and this report includes personal memories. ` +
|
|
60
|
+
`Refusing to write it there. Pass --allow-personal if you really want that, or write outside the repo.`);
|
|
61
|
+
}
|
|
62
|
+
const parent = path.dirname(path.resolve(target));
|
|
63
|
+
if (!fs.existsSync(parent)) {
|
|
64
|
+
return `inventory: the directory ${parent} does not exist — create it first, or choose another --out path.`;
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
21
68
|
/** Per-command help, printed by `open-memex <command> --help`.
|
|
22
69
|
AI assistants discover the CLI through --help, so every command needs one. */
|
|
23
70
|
const COMMAND_HELP = {
|
|
@@ -27,14 +74,20 @@ Usage: open-memex where
|
|
|
27
74
|
|
|
28
75
|
Example:
|
|
29
76
|
open-memex where`,
|
|
30
|
-
list: `List memories in a scope, newest first.
|
|
77
|
+
list: `List memories in a scope, newest first, as a numbered inventory.
|
|
31
78
|
|
|
32
|
-
Usage: open-memex list [--scope project|personal|both] [--type T] [--limit N]
|
|
79
|
+
Usage: open-memex list [--scope project|personal|both] [--type T] [--limit N] [--include active|all]
|
|
33
80
|
|
|
34
81
|
Flags:
|
|
35
|
-
--scope
|
|
36
|
-
--type
|
|
37
|
-
--limit
|
|
82
|
+
--scope project (default), personal, or both
|
|
83
|
+
--type filter by memory type
|
|
84
|
+
--limit max results (default 20, max 100)
|
|
85
|
+
--include active (default: what is currently remembered) or all (audit
|
|
86
|
+
view: superseded versions, retracted and archived included)
|
|
87
|
+
|
|
88
|
+
A cut list says how many entries it left out. Numbers are unique within one
|
|
89
|
+
listing, so "the third one" names exactly one memory - but they are only
|
|
90
|
+
valid for the listing you just ran.
|
|
38
91
|
|
|
39
92
|
Examples:
|
|
40
93
|
open-memex list
|
|
@@ -85,12 +138,16 @@ Retraction is one-way: a retracted memory cannot be set back to active
|
|
|
85
138
|
|
|
86
139
|
Example:
|
|
87
140
|
open-memex status 01ABC deprecated`,
|
|
88
|
-
forget: `Delete a memory by id.
|
|
141
|
+
forget: `Delete a memory by id. With --soft, hide it instead: the memory
|
|
142
|
+
is retracted (out of lists and search, file kept). Retraction is one-way —
|
|
143
|
+
save the content as a new memory to bring it back. If the memory lives in
|
|
144
|
+
the repo, either action is only local until you commit and push it.
|
|
89
145
|
|
|
90
|
-
Usage: open-memex forget <id>
|
|
146
|
+
Usage: open-memex forget <id> [--soft]
|
|
91
147
|
|
|
92
148
|
Example:
|
|
93
|
-
open-memex forget 01ABC
|
|
149
|
+
open-memex forget 01ABC
|
|
150
|
+
open-memex forget 01ABC --soft`,
|
|
94
151
|
propose: `Copy personal memories into the project outbox as review drafts.
|
|
95
152
|
The personal originals stay put. Nothing enters git at this step.
|
|
96
153
|
|
|
@@ -137,6 +194,21 @@ Usage: open-memex sync-status
|
|
|
137
194
|
|
|
138
195
|
Example:
|
|
139
196
|
open-memex sync-status`,
|
|
197
|
+
inventory: `Show everything remembered — personal + this project — in the
|
|
198
|
+
open: what each memory says, where it came from, and whether it has reached
|
|
199
|
+
the repo yet. Read-only; deleting stays a confirmed conversation with your
|
|
200
|
+
agent. Formats: text (default, diffable), json (for agents), html (a local
|
|
201
|
+
page for your browser; default writes <data dir>/inventory.html).
|
|
202
|
+
|
|
203
|
+
Usage: open-memex inventory [--format text|json|html] [--scope project|personal|both] [--out <path>] [--allow-personal]
|
|
204
|
+
|
|
205
|
+
Default scope: both. Without --out, text/json go to stdout. A report that
|
|
206
|
+
includes personal memories is refused inside a git working tree unless
|
|
207
|
+
--allow-personal is passed — personal content must not ride a commit.
|
|
208
|
+
|
|
209
|
+
Example:
|
|
210
|
+
open-memex inventory --format json --out ~/inventory.json
|
|
211
|
+
open-memex inventory --format html`,
|
|
140
212
|
pull: `Pull shared project memories from the git remote: fetch + fast-forward
|
|
141
213
|
only. Never auto-merges — a diverged branch fails with a clear message and is
|
|
142
214
|
left for you to resolve by hand. On success the local index re-syncs.
|
|
@@ -334,16 +406,17 @@ function usage(exitCode = 1) {
|
|
|
334
406
|
|
|
335
407
|
Usage:
|
|
336
408
|
open-memex where
|
|
337
|
-
open-memex list [--scope project|personal] [--type T] [--limit N]
|
|
409
|
+
open-memex list [--scope project|personal|both] [--type T] [--limit N] [--include active|all]
|
|
338
410
|
open-memex search "query" [--scope project|personal|both] [--type T] [--limit N] [--explain]
|
|
339
411
|
open-memex add "content" [--scope project|personal] [--type T] [--tag t1,t2]
|
|
340
412
|
open-memex supersede <id> "new content" [--type T] [--tag t1,t2]
|
|
341
413
|
open-memex status <id> active|deprecated|retracted|archived
|
|
342
|
-
open-memex forget <id>
|
|
414
|
+
open-memex forget <id> [--soft]
|
|
343
415
|
open-memex propose <id...> --to project [--local-approve]
|
|
344
416
|
open-memex promote <id> [--reject] [--resubmit] [--note "..."] [--by NAME]
|
|
345
417
|
open-memex resolve [id-or-path]
|
|
346
418
|
open-memex sync-status
|
|
419
|
+
open-memex inventory [--format text|json|html] [--scope project|personal|both] [--out <path>] [--allow-personal]
|
|
347
420
|
open-memex pull
|
|
348
421
|
open-memex push
|
|
349
422
|
open-memex export [--scope project|personal|both] [--type T] [--tag t] [--all] [-o <file>]
|
|
@@ -778,24 +851,38 @@ async function main() {
|
|
|
778
851
|
const flags = parseFlags(rest);
|
|
779
852
|
// D66: --scope both, on par with the memory_list tool — prints each
|
|
780
853
|
// scope's newest under its own header.
|
|
781
|
-
const scopes = flags.scope === "both" ? [
|
|
854
|
+
const scopes = flags.scope === "both" ? [PERSONAL_SCOPE, project] : [resolveCliScope(flags, project)];
|
|
855
|
+
const include = flags.include === "all" ? "all" : "active";
|
|
856
|
+
// D70: the CLI renders the SAME structured inventory the agent sees,
|
|
857
|
+
// numbering runs across the whole listing, and a cut list says how
|
|
858
|
+
// many entries it dropped — `open-memex list` is the surface a
|
|
859
|
+
// sceptical user runs first, so it may not look complete when it isn't.
|
|
860
|
+
let seq = 0;
|
|
782
861
|
for (const s of scopes) {
|
|
783
862
|
syncScope(s.key, "cli");
|
|
784
863
|
const hits = list(s.key, {
|
|
785
864
|
type: flags.type,
|
|
786
865
|
limit: flags.limit ? Number(flags.limit) : undefined,
|
|
866
|
+
include,
|
|
787
867
|
});
|
|
788
|
-
|
|
789
|
-
|
|
868
|
+
const total = countList(s.key, { type: flags.type, include });
|
|
869
|
+
if (scopes.length > 1) {
|
|
870
|
+
console.log(s.key === PERSONAL_SCOPE.key
|
|
871
|
+
? "## About you (personal — stays on this machine, applies everywhere)"
|
|
872
|
+
: `## This project (${s.projectName || s.key})`);
|
|
873
|
+
}
|
|
790
874
|
if (hits.length === 0) {
|
|
791
|
-
console.log(
|
|
875
|
+
console.log(scopes.length > 1
|
|
876
|
+
? "(nothing remembered in this scope yet)"
|
|
877
|
+
: `(no memories in ${s.key})`);
|
|
792
878
|
continue;
|
|
793
879
|
}
|
|
794
880
|
for (const h of hits) {
|
|
795
|
-
|
|
796
|
-
const rev = hitStateLabel(h);
|
|
797
|
-
console.log(`[${h.type}]${dep}${rev} ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
|
|
881
|
+
console.log(formatInventoryLine(seq++, h, PERSONAL_SCOPE.key));
|
|
798
882
|
}
|
|
883
|
+
const note = truncationNote(hits.length, total);
|
|
884
|
+
if (note)
|
|
885
|
+
console.log(note);
|
|
799
886
|
}
|
|
800
887
|
return;
|
|
801
888
|
}
|
|
@@ -964,6 +1051,26 @@ async function main() {
|
|
|
964
1051
|
const id = rest[0];
|
|
965
1052
|
if (!id)
|
|
966
1053
|
usage();
|
|
1054
|
+
// D68: --soft hides (retracts) instead of deleting — same action as
|
|
1055
|
+
// the memory_forget tool's soft flag. One-way (D64).
|
|
1056
|
+
if (rest.includes("--soft")) {
|
|
1057
|
+
const target = db().prepare(`SELECT file_path FROM memories WHERE id = ?`).get(id);
|
|
1058
|
+
try {
|
|
1059
|
+
const mf = setStatus(id, "retracted");
|
|
1060
|
+
upsertFromFile(mf);
|
|
1061
|
+
console.log(`hidden ${id} (retracted — out of lists and search; save it again as a new memory to bring it back)`);
|
|
1062
|
+
// D70: same honesty as the hard delete — a retraction of an in-repo
|
|
1063
|
+
// memory is a local working-tree edit until it is committed and pushed.
|
|
1064
|
+
if (target && isInRepoMemoryFile(target.file_path)) {
|
|
1065
|
+
console.log(`note: that memory lives in the repo (.ai/open-memex/) — the retraction is only local until you commit and push it; until then the team still sees it.`);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
catch (e) {
|
|
1069
|
+
console.error(`hide failed: ${e.message}`);
|
|
1070
|
+
process.exit(1);
|
|
1071
|
+
}
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
967
1074
|
const row = db().prepare(`SELECT file_path FROM memories WHERE id = ?`).get(id);
|
|
968
1075
|
if (!row) {
|
|
969
1076
|
console.error("not found");
|
|
@@ -1101,6 +1208,68 @@ async function main() {
|
|
|
1101
1208
|
}
|
|
1102
1209
|
return;
|
|
1103
1210
|
}
|
|
1211
|
+
if (cmd === "inventory") {
|
|
1212
|
+
// D68: refresh both scopes first so the report matches the markdown
|
|
1213
|
+
// on disk. Default scope: personal + this project.
|
|
1214
|
+
syncScope(project.key, "cli");
|
|
1215
|
+
syncScope(PERSONAL_SCOPE.key, "cli");
|
|
1216
|
+
const flags = parseFlags(rest);
|
|
1217
|
+
const format = flags.format ?? "text";
|
|
1218
|
+
if (format !== "text" && format !== "json" && format !== "html") {
|
|
1219
|
+
console.error(`inventory: unknown --format '${format}' (text|json|html)`);
|
|
1220
|
+
process.exit(2);
|
|
1221
|
+
}
|
|
1222
|
+
const scopeKeys = flags.scope === "personal"
|
|
1223
|
+
? [PERSONAL_SCOPE.key]
|
|
1224
|
+
: flags.scope === "project"
|
|
1225
|
+
? [project.key]
|
|
1226
|
+
: [PERSONAL_SCOPE.key, project.key];
|
|
1227
|
+
const outPath = flags["out"] ?? flags["output"];
|
|
1228
|
+
const includesPersonal = scopeKeys.includes(PERSONAL_SCOPE.key);
|
|
1229
|
+
const allowPersonal = flags["allow-personal"] === "true";
|
|
1230
|
+
try {
|
|
1231
|
+
const { loadInventory, renderInventoryText, renderInventoryJson } = await import("./inventory.js");
|
|
1232
|
+
if (format === "html") {
|
|
1233
|
+
const { renderInventoryHtml, defaultHtmlOutPath } = await import("./inventory-html.js");
|
|
1234
|
+
const target = outPath ?? defaultHtmlOutPath(paths().root);
|
|
1235
|
+
// D70: one guard for every format (this path had its own copy).
|
|
1236
|
+
const refusal = inventoryWriteRefusal(target, includesPersonal, allowPersonal);
|
|
1237
|
+
if (refusal) {
|
|
1238
|
+
console.error(refusal);
|
|
1239
|
+
process.exit(2);
|
|
1240
|
+
}
|
|
1241
|
+
// D70: only the audit surface needs the hidden rows themselves;
|
|
1242
|
+
// text/json report counts, so they must not pay to load them.
|
|
1243
|
+
const data = loadInventory(scopeKeys, { hidden: "entries" });
|
|
1244
|
+
fs.writeFileSync(target, renderInventoryHtml(data), "utf8");
|
|
1245
|
+
console.log(`inventory: ${data.total} memories → ${target}`);
|
|
1246
|
+
console.log(`open it in any browser; the report is read-only.`);
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
const data = loadInventory(scopeKeys);
|
|
1250
|
+
const rendered = format === "json" ? renderInventoryJson(data) : renderInventoryText(data);
|
|
1251
|
+
if (outPath) {
|
|
1252
|
+
// D68/3-C v2: the report contains personal content. Writing it into
|
|
1253
|
+
// a git working tree would ride the next commit off this machine —
|
|
1254
|
+
// refuse unless the user explicitly accepts that.
|
|
1255
|
+
const refusal = inventoryWriteRefusal(outPath, includesPersonal, allowPersonal);
|
|
1256
|
+
if (refusal) {
|
|
1257
|
+
console.error(refusal);
|
|
1258
|
+
process.exit(2);
|
|
1259
|
+
}
|
|
1260
|
+
fs.writeFileSync(outPath, rendered, "utf8");
|
|
1261
|
+
console.log(`inventory: ${data.total} memories → ${outPath}`);
|
|
1262
|
+
}
|
|
1263
|
+
else {
|
|
1264
|
+
process.stdout.write(rendered);
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
catch (e) {
|
|
1268
|
+
console.error(`inventory failed: ${e.message}`);
|
|
1269
|
+
process.exit(2);
|
|
1270
|
+
}
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1104
1273
|
if (cmd === "sync-status") {
|
|
1105
1274
|
syncScope(project.key, "cli");
|
|
1106
1275
|
syncScope(PERSONAL_SCOPE.key, "cli");
|
package/dist/distill-agents.js
CHANGED
|
@@ -62,7 +62,10 @@ export function distillAgentsMarkdown(opts) {
|
|
|
62
62
|
// inferred captures source: "inference"; this footer omitted it, so
|
|
63
63
|
// opencode agents saved distilled conclusions as if the user had
|
|
64
64
|
// stated them ("tool"). Keep the rubric one voice across surfaces.
|
|
65
|
-
`- Mark captures you infer (rather than the user stating) with`, ` \`source: "inference"\` — provenance matters more than polish
|
|
65
|
+
`- Mark captures you infer (rather than the user stating) with`, ` \`source: "inference"\` — provenance matters more than polish.`,
|
|
66
|
+
// D68: same voice for the visibility surface — opencode agents reading
|
|
67
|
+
// this footer never see the MCP handshake either.
|
|
68
|
+
`- When the user asks what you remember, list it conversationally`, ` (\`memory_list\` with scope both, or \`open-memex inventory\`) — plain`, ` words, not raw ids. Before deleting an entry they point at, read it`, ` back in full and confirm; deletion is permanent. Offer hiding`, ` (\`memory_forget\` soft / \`forget --soft\`) when they hesitate.`);
|
|
66
69
|
lines.push(``);
|
|
67
70
|
return lines.join("\n");
|
|
68
71
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { PLUGIN_ID, bootstrapPlugin, captureFromText, injectOnce, } from "./plugin-core.js";
|
|
1
|
+
import { PLUGIN_ID, bootstrapPlugin, captureFromText, captureHandoff, 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,23 +13,30 @@ 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,
|
|
20
|
-
async "chat.message"(
|
|
22
|
+
async "chat.message"(input, output) {
|
|
21
23
|
const parts = (output.parts ?? []);
|
|
22
24
|
const text = parts
|
|
23
25
|
.map((p) => (p?.type === "text" ? p.text ?? "" : ""))
|
|
24
26
|
.filter(Boolean)
|
|
25
27
|
.join("\n");
|
|
26
|
-
captureFromText(text, state);
|
|
28
|
+
captureFromText(text, state, input.sessionID ?? "");
|
|
27
29
|
},
|
|
28
30
|
async "experimental.chat.system.transform"(input, output) {
|
|
29
|
-
|
|
31
|
+
const sessionID = input.sessionID ?? "";
|
|
32
|
+
injectOnce(sessionID, state, (block) => {
|
|
30
33
|
output.system.push(block);
|
|
31
34
|
});
|
|
35
|
+
// D73: runs on every request, so the note lands on the very turn whose
|
|
36
|
+
// message the hook just captured. Delivered once per capture.
|
|
37
|
+
const handoff = captureHandoff(sessionID, state);
|
|
38
|
+
if (handoff)
|
|
39
|
+
output.system.push(handoff);
|
|
32
40
|
},
|
|
33
41
|
};
|
|
34
42
|
};
|