From 49bf866de2c1dbf244948ef9f68020efb5abcbd9 Mon Sep 17 00:00:00 2001 From: Ralph Chang Date: Fri, 8 May 2026 19:26:17 +0800 Subject: [PATCH] feat(tui): add native memory visibility commands --- CHANGELOG.md | 19 + README.md | 33 +- ...2026-05-08-native-tui-memory-command-ux.md | 491 ++++++++++++++++++ package.json | 6 +- src/memory-visibility.ts | 302 +++++++++++ src/tui-plugin.ts | 158 ++++++ src/workspace-memory.ts | 2 +- tests/memory-visibility.test.ts | 195 +++++++ tests/tui-plugin.test.ts | 153 ++++++ 9 files changed, 1354 insertions(+), 5 deletions(-) create mode 100644 docs/plans/2026-05-08-native-tui-memory-command-ux.md create mode 100644 src/memory-visibility.ts create mode 100644 src/tui-plugin.ts create mode 100644 tests/memory-visibility.test.ts create mode 100644 tests/tui-plugin.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a25789..660f813 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.6.1] - 2026-05-08 + +### Added + +- Native OpenCode TUI `/memory` display commands for local memory status, recent activity, and help. +- Package `./tui` export for OpenCode TUI plugin loading. + +### Changed + +- README documents separate server and TUI plugin configuration. + +### Fixed + +- Replaced a literal NUL byte in `workspace-memory.ts` regex source with a `\0` escape so source search tools treat the file as text. + +### Notes / Known UX + +- `/memory` output is injected as no-reply user-style conversation text and does not call the LLM. + ## [1.6.0] - 2026-05-08 ### Added diff --git a/README.md b/README.md index ffdc837..e620a2f 100644 --- a/README.md +++ b/README.md @@ -30,21 +30,50 @@ Use it when you want your agent to remember things like: - **Explicit memory triggers** — users can say “remember this”, “記住”, “覚えて”, or “기억해” to save durable facts. - **Compaction-based extraction** — memory extraction piggybacks on OpenCode’s existing compaction flow. - **Numbered memory refs** — compaction can `REINFORCE [M#]` useful memories or safely `REPLACE [M#]` obsolete compaction memories. +- **Native TUI `/memory` display** — show local memory status, recent activity, and help from the OpenCode TUI without an LLM/API call. - **No manual tools** — memory is injected automatically into the system prompt. - **Quality guards** — filters noisy memories, temporary progress snapshots, stack traces, raw errors, and credentials. - **Retention decay** — keeps the strongest memories in prompt context while older or weaker memories fade out naturally; important and reinforced memories decay more slowly. ## Installation -Add OpenCode Working Memory to your OpenCode config: +Add OpenCode Working Memory to your server plugin config: + +`.opencode/opencode.json`: ```json { + "$schema": "https://opencode.ai/config.json", "plugin": ["opencode-working-memory"] } ``` -Then restart OpenCode. It activates automatically. +To enable the native TUI `/memory` display command, also add the TUI plugin config: + +`.opencode/tui.json`: + +```json +{ + "$schema": "https://opencode.ai/tui.json", + "plugin": ["opencode-working-memory"] +} +``` + +Then restart OpenCode. Server memory activates automatically; TUI memory commands appear in slash command autocomplete when the TUI plugin is loaded. + +## Native TUI Memory Command + +The TUI plugin adds display-only local memory commands: + +- `/memory` or `/memory status` — show status counts for workspace memory, rendered memories, pending memory, open errors, and recent decisions. +- `/memory activity` or `/memory last` — show recent local evidence activity. Due to the current OpenCode command model, some versions may show separate autocomplete entries instead of typed subargs. +- `/memory help` — show command help. + +These commands are read-only and local-only. They read local memory files and inject output with OpenCode's no-reply session prompt path, so they do not make an LLM/API call. + +Current OpenCode plugins do not expose an assistant-style command-output surface, so `/memory` output appears as a user-style conversation message. The output becomes part of the session transcript and may be included in future compaction summaries; this is expected command output. + +Compaction output already appears through OpenCode's built-in conversation flow. This plugin does not add duplicate compaction notices. ## How It Works diff --git a/docs/plans/2026-05-08-native-tui-memory-command-ux.md b/docs/plans/2026-05-08-native-tui-memory-command-ux.md new file mode 100644 index 0000000..fd1cd8e --- /dev/null +++ b/docs/plans/2026-05-08-native-tui-memory-command-ux.md @@ -0,0 +1,491 @@ +# Native TUI Memory Command UX Implementation Plan + +> **For agentic workers:** Use `agenthub-writing-plans-skill` to create this plan and `agenthub-executing-plans-skill` to execute it task-by-task. Steps use checkbox (`- [ ]`) syntax. Wave checkpoints are gates. + +**Goal:** Replace the current ambiguous native OpenCode TUI memory slash-command surface with three visibly distinct hyphenated commands before commit/push. + +**User outcome:** OpenCode users see only `/memory-status`, `/memory-list`, and `/memory-help` in slash autocomplete; status shows memory statistics, list shows current active workspace memories as display-local `[M1]` refs, and duplicate recent-activity commands are no longer user-facing. + +**Architecture:** Keep the existing TUI plugin and no-reply session-message injection path. Change only the command registration/routing layer (`src/tui-plugin.ts`) and the local read/format core (`src/memory-visibility.ts`), then update focused tests and docs. Do not add storage, background jobs, LLM calls, command mutation, or a parallel UI surface. + +**Tech stack:** TypeScript ESM on Node >=22.6, OpenCode TUI plugin API, local JSON stores, Node built-in test runner with `--experimental-strip-types`. + +**Scope mode:** COMPLETE for the approved UX correction; no implementation code is changed by this plan. + +--- + +## Scope Challenge + +- Existing leverage: Reuse `src/tui-plugin.ts` command registration and `api.client.session.prompt({ noReply: true })`; reuse `src/memory-visibility.ts` local read snapshots, redaction helper, and `accountWorkspaceMemoryRender()`/`accountWorkspaceMemoryCompactionRefs()` accounting instead of creating a separate diagnostic subsystem. +- Minimum complete change: Register three unique top-level slash names, add/route a list formatter, remove visible activity/last commands, make status stats-only, and update README/CHANGELOG/tests to match the new public surface. +- Scope smell check: Expected code/docs/test touch set is 6 files: `src/tui-plugin.ts`, `src/memory-visibility.ts`, `tests/tui-plugin.test.ts`, `tests/memory-visibility.test.ts`, `README.md`, and `CHANGELOG.md`. `RELEASE_NOTES.md`, `docs/installation.md`, and `docs/configuration.md` do not currently mention the TUI memory commands and should stay unchanged unless implementation reveals new command mentions. +- Lake vs ocean: The lake is display-only status/list/help for existing local data. Ocean-sized extras remain out of scope: `/memory delete`, `/memory edit`, stable memory IDs in the TUI, interactive list selection, evidence activity dashboards, assistant-style output APIs, and upstream OpenCode TUI changes. +- Out of scope: No activity/last user-facing commands, no `/memory` space-subcommand autocomplete entries, no new aliases unless OpenCode proves they do not create duplicate menu rows, no persistence schema changes, no LLM/API calls, and no server plugin behavior changes. + +## Search and Prior Art + +- Layer 1 choices: + - `src/tui-plugin.ts:113-148` currently registers four commands with the same `slash.name: "memory"`, causing OpenCode to show multiple identical `/memory` rows. + - `src/tui-plugin.ts:58-63` maps internal values `memory.activity` and `memory.last` to the same `"activity"` command, confirming duplication. + - `src/memory-visibility.ts:12` currently exposes `MemoryVisibilityCommand = "status" | "activity" | "help"`; `formatMemoryHelp()` at lines 273-288 documents `/memory activity` and `/memory last`. + - `src/memory-visibility.ts:213-238` already formats status counts but also includes preview lines; the approved UX wants status focused on statistics and delegates memory content to `/memory-list`. + - `src/workspace-memory.ts:937-997` already has numbered ref accounting (`accountWorkspaceMemoryCompactionRefs`) that returns rendered entries, omitted entries, and `refs` with `M1`, `M2`, ... display labels. This is the best existing source for list refs because it respects the same selection/cap logic as compaction refs. + - `tests/tui-plugin.test.ts:98-153` covers TUI registration, no-reply injection, routing, no-session warning, dialog clearing, and injection failure. + - `tests/memory-visibility.test.ts:53-195` covers status/activity/help formatting and read-only redaction behavior. + - `README.md:64-76` and `CHANGELOG.md:8-25` document the current `/memory` status/activity/help UX and must be updated before commit/push. +- Layer 2 choices: None required. Do not add dependencies. +- Layer 3 choices: A small `MemoryListModel`/`formatMemoryList()` in `src/memory-visibility.ts` is justified because the TUI needs a user-facing grouped list shape that differs from compaction prompt text. +- Eureka findings: OpenCode's current slash menu does not visibly distinguish trailing subcommand text, so the technically elegant `/memory status` model is worse UX than three hyphenated top-level commands for this release. + +## Architecture + +### Components and responsibilities + +- `src/tui-plugin.ts`: Owns visible TUI command names and active-session/no-reply injection. It should register exactly three commands with `slash.name` values `memory-status`, `memory-list`, and `memory-help`; internal `value` strings may remain dot-form (`memory.status`, `memory.list`, `memory.help`) because they are not displayed to users. +- `src/memory-visibility.ts`: Owns read-only local models and markdown/plain-text formatting. It should expose command variants `status`, `list`, and `help`; status should report stats only; list should show active rendered workspace memories with display-local `[M#]` refs; help should list only the three public commands. `MemoryVisibilityCommand` is currently consumed by `src/tui-plugin.ts`; verify no other consumers exist before changing/removing exported command variants. +- `src/workspace-memory.ts`: No planned edit. Reuse `accountWorkspaceMemoryRender()` for stats and `accountWorkspaceMemoryCompactionRefs()` for capped/ref-capable list selection. If imports need updating, import only existing exported functions. +- `tests/tui-plugin.test.ts`: Assert unique slash names and removal of user-facing activity/last registrations. +- `tests/memory-visibility.test.ts`: Assert status/list/help output contracts, redaction/truncation, caps, and fallback routing. +- `README.md` and `CHANGELOG.md`: Align public docs with the new three-command UX. `RELEASE_NOTES.md` has no 1.6.1 TUI command section today; leave it unchanged unless a later release-notes pass adds one. + +### Data flow + +```text +User selects /memory-status, /memory-list, or /memory-help in OpenCode TUI + -> src/tui-plugin.ts registered command onSelect + -> determine active sessionID from api.route/current session route + -> commandFromValue(value) returns "status" | "list" | "help" + -> src/memory-visibility.ts renderMemoryCommand(root, sessionID, command) + status: read workspace/session/pending snapshots + render accounting counts + list: read workspace snapshot + accountWorkspaceMemoryCompactionRefs() + safePreview() + help: static command help + -> api.client.session.prompt({ sessionID, noReply: true, parts: [{ type: "text", text }] }) + -> OpenCode renders the report as local no-reply session text; no LLM call is made +``` + +### Output contracts + +#### `/memory-status` + +Required shape: + +```md +## Memory status + +Workspace: +- Active memories: +- Rendered in prompt: +- Omitted active memories: +- Superseded memories: + +Pending: +- Pending in this session: +- Pending journal memories: + +Session: +- Open errors: +- Recent decisions: + +Use /memory-list to view current [M1]-[M28] memory refs. + +Local only: no LLM request was made. +``` + +Notes: +- Remove preview lines from status. +- Keep zero/empty counts visible. +- Keep the local-only footer. + +#### `/memory-list` + +Required shape: + +```md +## Current workspace memories + +Display refs are local to this output and may change after memory updates. + +feedback: +- [M1] + +project: +- [M2] + +decision: +- [M3] + +reference: +- [M4] + +Shown: of active memories. +Omitted active memories: . + +Local only: no LLM request was made. +``` + +Notes: +- Use refs that are explicitly display-local, not stable IDs. +- Group by memory type/kind in the existing order: `feedback`, `project`, `decision`, `reference`. +- Show only active, non-superseded memories selected by the same caps/budget used for rendered memory refs. The default global cap is 28 (`src/types.ts` via `LONG_TERM_LIMITS.maxEntries`). +- Apply `safePreview()` or equivalent credential redaction/truncation to every displayed memory text. Do not dump raw JSON or full unbounded memory text. +- Empty state: `No active workspace memories are stored yet.` plus the local-only footer. + +#### `/memory-help` + +Required shape: + +```md +## Memory help + +Commands: +- /memory-status — show local memory statistics. +- /memory-list — show current workspace memories as display-local [M1]-[M28] refs. +- /memory-help — show this help. + +These commands are read-only, local-only, and do not call the LLM. +``` + +Notes: +- Do not mention `/memory`, `/memory status`, `/memory activity`, or `/memory last` as available commands. +- It is acceptable to keep a short note that mutation commands such as delete/edit are not available, but do not expand scope. + +### Error flow + +- No active session route: preserve existing warning toast behavior and do not write a message. +- Local read/format error with a session: preserve existing `## Memory error` stream-visible report. +- `api.client.session.prompt()` failure: preserve existing error toast and no retry. +- Unknown internal command value: route to help. Do not register unknown/legacy values in the visible command list. + +### Security and permissions + +- Commands are read-only over local memory/session/pending files and write only the user-invoked no-reply session output. +- Display memory text only after redaction and truncation. +- Do not introduce shell execution, network calls, LLM calls, or file writes to memory stores. +- Do not treat display-local `[M#]` refs as authorization or stable identity; they are only labels in the printed list. + +### Performance + +- Status remains O(number of workspace/session/pending entries), using existing bounded stores. +- List should format at most the rendered/ref-selected memories and must respect existing caps/budgets; avoid full evidence lifecycle joins. +- Removing activity from the visible UX avoids querying/formatting evidence logs during normal command use. + +### Production failure scenarios + +- OpenCode still displays aliases or duplicate slash names unexpectedly: keep only three primary `slash.name` values and avoid aliases until verified. +- User expects `/memory` from an unreleased local build: because this is before commit/push, prefer clean UX over compatibility debt; docs should clearly advertise the three hyphenated commands. +- Very long memories or credentials appear in stored data: list formatter must redacted/truncate via `safePreview()` and tests should assert credential-like fixture text is absent. +- More than 28 active memories exist: list reports shown vs active and omitted count; it must not imply refs cover hidden memories. + +## Backwards Compatibility Stance + +- Treat the current `/memory` space-subcommand surface as pre-public/unshipped for this commit because it produces duplicate-looking menu entries in OpenCode. +- Remove visible registrations for `/memory`, `/memory status`, `/memory activity`, `/memory last`, and `/memory help`. +- Do not document old spellings. +- Internal fallback may continue routing unknown values to help, but do not preserve hidden legacy command entries if OpenCode would show them in autocomplete. +- If a reviewer requests aliases, add only after confirming aliases do not create extra duplicate menu rows; otherwise defer aliases to a later OpenCode API capability discussion. + +## File Plan + +- Modify: `src/tui-plugin.ts:58-63` — route `memory.status`, `memory.list`, and `memory.help`; remove activity/last mapping from the public path. +- Modify: `src/tui-plugin.ts:113-148` — register exactly three commands with `slash.name` values `memory-status`, `memory-list`, `memory-help`; remove `Memory activity` and `Memory last` command objects. +- Modify: `src/memory-visibility.ts:12-40` — change command/model types from status/activity/help to status/list/help; add `MemoryListModel`; remove or unexport activity-only types/functions if no longer used. +- Modify: `src/memory-visibility.ts:190-238` — keep stats model but format status as grouped statistics with no previews. +- Modify: `src/memory-visibility.ts:240-271` — replace activity reader/formatter with list reader/formatter or remove activity code and add list code nearby. +- Modify: `src/memory-visibility.ts:273-288` — update help to list only `/memory-status`, `/memory-list`, `/memory-help`. +- Modify: `src/memory-visibility.ts:291-302` — route `"list"` to the new list formatter and remove `"activity"` routing. +- Modify: `tests/tui-plugin.test.ts` — assert three unique visible commands and route status/list/help. +- Modify: `tests/memory-visibility.test.ts` — replace activity tests with list tests; update status/help assertions. +- Modify: `README.md:33,51-76` — update feature copy and Native TUI command docs. +- Modify: `CHANGELOG.md:8-25` — amend unreleased/current 1.6.1 entry from status/activity/help to status/list/help and note hyphenated names. +- No planned change: `RELEASE_NOTES.md` — no 1.6.1 TUI command mention exists in current evidence. +- No planned change: `docs/installation.md`, `docs/configuration.md` — current grep found no TUI command mentions. + +## Test Strategy + +- Framework: Node built-in test runner via `npm test`; TypeScript via `npm run typecheck`. +- Unit coverage: + - `memory-visibility.ts` status counts with active/superseded/rendered/omitted entries, pending memories, pending journal entries, open errors, and recent decisions; assert no preview section remains. + - `memory-visibility.ts` list output with active memories grouped by type, display-local `[M#]` labels, shown/active/omitted summary, redacted credential-like text, empty state, and local-only footer. + - `memory-visibility.ts` help text lists only three hyphenated commands and omits `/memory activity` and `/memory last`. + - `renderMemoryCommand()` routes `status`, `list`, and `help`; unknown values fall back to help. +- TUI integration-style unit coverage: + - Registers exactly three command values. + - Slash names are exactly `memory-status`, `memory-list`, `memory-help` and unique. + - No registered command value is `memory.activity` or `memory.last`. + - Selecting `memory.status`, `memory.list`, and `memory.help` injects no-reply text with the expected heading. + - Existing no-session warning, dialog clearing, and prompt-injection failure behavior still passes. +- Docs verification: + - Grep for old public spellings in markdown and source tests after implementation; old spellings should remain only in this plan or intentionally in negative assertions. + +## Implementation Tasks + +## Wave 1: Failing Tests for the New Public Contract + +### Task 1.1: Update TUI command registration/routing tests first + +**Purpose:** Prove the slash command menu no longer contains duplicate-looking `/memory` entries. + +**Files:** +- Modify: `tests/tui-plugin.test.ts` + +**Behavior:** +- Given the TUI plugin registers commands, there are exactly three memory commands. +- Given autocomplete displays slash names, the names are unique hyphenated top-level commands. +- Given a command is selected, status/list/help route to distinct headings. + +- [ ] **Step 1: Write failing tests** + +Add or update assertions equivalent to: + +```ts +test("registers three unique hyphenated memory slash commands", async () => { + const api = makeMockTuiApi({ route: { name: "session", params: { sessionID: "ses_1" } } }); + await MemoryTuiPlugin(api as any, undefined, mockMeta); + + const slashNames = api.commands.map(command => command.slash?.name).filter(Boolean); + assert.deepEqual(slashNames, ["memory-status", "memory-list", "memory-help"]); + assert.equal(new Set(slashNames).size, slashNames.length); + assert.deepEqual(api.commands.map(command => command.value), ["memory.status", "memory.list", "memory.help"]); + assert.equal(api.commands.some(command => command.value === "memory.activity"), false); + assert.equal(api.commands.some(command => command.value === "memory.last"), false); +}); +``` + +Update the routing test to select `memory.list` and expect `## Current workspace memories`. + +- [ ] **Step 2: Run expected failure** + +Run: `node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/tui-plugin.test.ts` + +Expected: FAIL because current `src/tui-plugin.ts` registers repeated `slash.name: "memory"` and does not register `memory.list`. + +### Task 1.2: Update memory visibility formatter tests first + +**Purpose:** Lock the approved status/list/help output shape before implementation. + +**Files:** +- Modify: `tests/memory-visibility.test.ts` + +**Behavior:** +- Status is stats-only and points to `/memory-list`. +- List prints current active memories grouped by type with display-local refs and redaction. +- Help lists only three hyphenated commands. + +- [ ] **Step 1: Write failing tests** + +Required assertions: + +```ts +assert.match(output, /^## Memory status/); +assert.match(output, /Workspace:/); +assert.match(output, /Pending:/); +assert.match(output, /Session:/); +assert.match(output, /Use \/memory-list to view current \[M1\]-\[M28\] memory refs\./); +assert.equal(output.includes("Recent active memory previews"), false); +``` + +Replace activity tests with list tests that create at least one memory for each type and one superseded memory. The redaction fixture must include at least one short credential-like active memory that is guaranteed to render, such as `Remember password: sushi for the fake test.`, so `output.includes("sushi") === false` proves redaction rather than omission by caps/budget. Assert: + +```ts +assert.match(output, /^## Current workspace memories/); +assert.match(output, /Display refs are local to this output/); +assert.match(output, /feedback:\n- \[M\d+\]/); +assert.match(output, /project:\n- \[M\d+\]/); +assert.match(output, /decision:\n- \[M\d+\]/); +assert.match(output, /reference:\n- \[M\d+\]/); +assert.match(output, /Shown: \d+ of \d+ active memories\./); +assert.equal(output.includes("sushi"), false); +assert.equal(output.includes("Superseded memory should not be active"), false); +``` + +Update help assertions: + +```ts +assert.match(output, /\/memory-status/); +assert.match(output, /\/memory-list/); +assert.match(output, /\/memory-help/); +assert.equal(output.includes("/memory activity"), false); +assert.equal(output.includes("/memory last"), false); +``` + +- [ ] **Step 2: Run expected failure** + +Run: `node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/memory-visibility.test.ts` + +Expected: FAIL because current implementation still exposes activity/last and lacks list output. + +### Wave 1 Checkpoint + +- [ ] Confirm both focused test files fail for the expected missing behavior, not unrelated setup errors. +- [ ] Do not proceed if failures indicate fixture/storage regressions unrelated to command UX. + +## Wave 2: Implement the Visibility Core + +### Task 2.1: Add list model/formatter and simplify status/help + +**Purpose:** Make the local rendering core match the approved command set independently of TUI registration. + +**Files:** +- Modify: `src/memory-visibility.ts` + +**Implementation instructions:** +- Change `MemoryVisibilityCommand` to `"status" | "list" | "help"`. +- Add a `MemoryListModel` that contains: + - `activeMemories: number` + - `renderedMemories: number` + - `omittedActiveMemories: number` + - `groups: Record>` or equivalent typed structure preserving `feedback`, `project`, `decision`, `reference` order. +- Implement `getMemoryList(root: string)` using `readWorkspaceMemorySnapshot(root)` and `accountWorkspaceMemoryCompactionRefs(store)`. + - Count active memories from the raw/snapshot store by `status !== "superseded"`. + - Use accounting `refs` plus `rendered` entries to build display-local refs. + - Only use the `refs`, `rendered`, and `omitted` fields from `accountWorkspaceMemoryCompactionRefs()` for the list formatter; discard its `evidence` and `prompt` fields and do not call `appendEvidenceEvents()` from `/memory-list`. + - Display text must pass through `safePreview(ref.textPreview)`. + - `omittedActiveMemories` should count only `accounting.omitted` entries whose memory is not superseded; `accounting.omitted` can include superseded entries from selection accounting and those must not inflate active omissions. +- Implement `formatMemoryList(model)` with the required output contract. +- Update `formatMemoryStatus()` to remove preview output and use grouped stat sections. +- Update `formatMemoryHelp()` to list only `/memory-status`, `/memory-list`, `/memory-help`. +- Update `renderMemoryCommand()` switch to route `"list"`. +- Remove `MemoryActivityModel`, `DEFAULT_ACTIVITY_LIMIT`, `MAX_ACTIVITY_LIMIT`, `clampLimit`, `getMemoryActivity()`, `formatMemoryActivity()`, `formatActivityEvent()`, and `summarizeReasons()` if they become unused. Also remove unused `EvidenceEventV1`/`queryEvidenceEvents` imports. +- Before deleting activity-only exports/helpers, grep `src/` for `MemoryActivityModel`, `getMemoryActivity`, `formatMemoryActivity`, `formatActivityEvent`, and `summarizeReasons()`; remove them only after confirming there are no cross-module consumers outside `memory-visibility.ts`. + +- [ ] **Step 1: Implement minimal code** + +Do not modify `src/workspace-memory.ts` unless TypeScript proves an export is missing. Current evidence shows `accountWorkspaceMemoryCompactionRefs` is exported. + +- [ ] **Step 2: Run focused verification** + +Run: `node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/memory-visibility.test.ts` + +Expected: PASS. + +- [ ] **Step 3: Run typecheck for dead imports/types** + +Run: `npm run typecheck` + +Expected: PASS and output includes `TYPECHECK_PASS`. + +### Wave 2 Checkpoint + +- [ ] Status output contains no memory preview content. +- [ ] List output includes display-local `[M#]` refs, grouped by type, with redacted/truncated text. +- [ ] Activity/last formatter exports are either removed or no longer referenced by user-facing code. + +## Wave 3: Implement the TUI Command Surface + +### Task 3.1: Register only three hyphenated slash commands + +**Purpose:** Fix OpenCode autocomplete by ensuring visible slash names are unique top-level commands. + +**Files:** +- Modify: `src/tui-plugin.ts` + +**Implementation instructions:** +- Update `commandFromValue(value)`: + - `memory.status` -> `"status"` + - `memory.list` -> `"list"` + - `memory.help` -> `"help"` + - default -> `"help"` +- Update `memoryCommands(api)` to return exactly three objects: + - title `Memory status`, value `memory.status`, description `Show working memory statistics in the current session.`, category `Memory`, suggested `true`, `slash: { name: "memory-status" }` + - title `Memory list`, value `memory.list`, description `Show current workspace memories with display-local refs.`, category `Memory`, `slash: { name: "memory-list" }` + - title `Memory help`, value `memory.help`, description `Show working memory help.`, category `Memory`, `slash: { name: "memory-help" }` +- Remove `Memory activity` and `Memory last` command objects. +- Do not include `aliases: ["mem"]` in this wave; aliases can be reconsidered only after verifying they do not create duplicate menu entries. + +- [ ] **Step 1: Implement minimal code** + +Keep existing active-session guard, no-reply injection, dialog clearing, and prompt failure toast logic unchanged. + +- [ ] **Step 2: Run focused verification** + +Run: `node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/tui-plugin.test.ts` + +Expected: PASS. + +- [ ] **Step 3: Run adjacent focused verification** + +Run: `node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/tui-plugin.test.ts tests/memory-visibility.test.ts` + +Expected: PASS. + +### Wave 3 Checkpoint + +- [ ] TUI registration tests prove slash names are unique. +- [ ] No test expects or selects `memory.activity` or `memory.last`. +- [ ] No implementation path requires OpenCode to render trailing subcommand text. + +## Wave 4: Documentation and Release Metadata Alignment + +### Task 4.1: Update user-facing docs + +**Purpose:** Ensure install/usage docs no longer advertise broken or removed command spellings. + +**Files:** +- Modify: `README.md` +- Modify: `CHANGELOG.md` +- Verify/no change unless needed: `RELEASE_NOTES.md`, `docs/installation.md`, `docs/configuration.md` + +**Implementation instructions:** +- In `README.md` feature bullets, replace “status, recent activity, and help” with “status, current memory list, and help”. +- In the Native TUI Memory Command section, document: + - `/memory-status` — status counts/statistics + - `/memory-list` — current active workspace memories with display-local `[M1]` refs + - `/memory-help` — help +- Keep the existing local-only/no LLM/no-reply transcript caveat. +- Remove docs for `/memory`, `/memory status`, `/memory activity`, `/memory last`, and `/memory help` as available user commands. +- In `CHANGELOG.md` 1.6.1 entry, amend the current TUI command bullet to say hyphenated `/memory-status`, `/memory-list`, `/memory-help`, and note recent activity/last were removed before release because duplicate entries were not useful. +- `RELEASE_NOTES.md` currently has no 1.6.1 TUI command mention in the evidence read; do not add a release note unless the release process requires a 1.6.1 section. + +- [ ] **Step 1: Update markdown docs** + +Use exact command names consistently. + +- [ ] **Step 2: Run docs/source grep** + +Run equivalent local search: + +```bash +rg "/memory activity|/memory last|/memory status|/memory help|slash: \{ name: \"memory\"|memory\.activity|memory\.last" README.md CHANGELOG.md src tests +``` + +Expected: no matches except this plan file if searching the whole repo, or negative test assertions that intentionally verify old commands are absent. The space-separated forms in this grep are obsolete spellings; the correct hyphenated commands `/memory-status`, `/memory-list`, and `/memory-help` should remain present. + +### Wave 4 Checkpoint + +- [ ] Docs advertise only `/memory-status`, `/memory-list`, `/memory-help`. +- [ ] Changelog matches the pre-release UX correction. +- [ ] No release docs mention stale activity/last commands. + +## Final Verification + +- [ ] Run: `npm run typecheck` + Expected: PASS and output includes `TYPECHECK_PASS`. +- [ ] Run: `npm test` + Expected: PASS and output includes `TEST_PASS`. +- [ ] Run: `npm pack --dry-run` + Expected: package contains `index.ts`, `src/tui-plugin.ts`, `src/memory-visibility.ts`, README, LICENSE, and no unexpected generated artifacts. +- [ ] Manual OpenCode TUI smoke before commit/push: + - Configure `.opencode/tui.json` to load the local plugin target. + - Open slash command menu and confirm exactly three visible memory commands: `/memory-status`, `/memory-list`, `/memory-help`. + - Select `/memory-status`; expected no-reply session text headed `## Memory status`, no assistant response, no LLM/provider activity. + - Select `/memory-list`; expected no-reply session text headed `## Current workspace memories`, display-local `[M#]` refs, grouped memory types, redacted/truncated text. + - Select `/memory-help`; expected help lists only the three hyphenated commands. +- [ ] Review changed files for placeholders, dead code, unused activity imports, debug logging, stale docs, raw secret output, and accidental storage writes. + +## Review Readiness + +- [ ] Scope challenge resolved: this is a focused UX correction, not a memory subsystem rewrite. +- [ ] Architecture and data flow are explicit. +- [ ] Every changed behavior has a focused test or manual TUI smoke check. +- [ ] Failure paths and user-visible states are covered. +- [ ] Commands are exact and runnable. +- [ ] Backwards compatibility stance is explicit and pre-release-safe. +- [ ] Plan has no placeholders. + +## Risks and Mitigations + +- Risk: Hyphenated names are less elegant than `/memory status` subcommands. Mitigation: current OpenCode menu behavior makes hyphenated top-level names the only visible unambiguous option. +- Risk: Users may interpret `[M#]` as stable memory IDs. Mitigation: list output must explicitly say refs are display-local and may change after memory updates. +- Risk: Activity formatter code may be left as unused dead code. Mitigation: typecheck plus source grep should catch unused imports/references; remove activity-only exports unless a maintainer-only consumer is introduced later. +- Risk: List output may leak long or sensitive memory text. Mitigation: use redaction/truncation for each line and add regression assertions that credential-like fixture text is absent. +- Risk: Docs drift with the just-added 1.6.1 changelog. Mitigation: amend the same 1.6.1 entry before commit/push rather than adding contradictory release notes. diff --git a/package.json b/package.json index 7ed6f3d..08759cb 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,13 @@ { "name": "opencode-working-memory", - "version": "1.6.0", + "version": "1.6.1", "description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state", "type": "module", "main": "index.ts", "exports": { - ".": "./index.ts" + ".": "./index.ts", + "./server": "./index.ts", + "./tui": "./src/tui-plugin.ts" }, "bin": { "memory-diag": "./scripts/memory-diag-bin.cjs" diff --git a/src/memory-visibility.ts b/src/memory-visibility.ts new file mode 100644 index 0000000..dd32b5e --- /dev/null +++ b/src/memory-visibility.ts @@ -0,0 +1,302 @@ +// No OpenCode SDK or TUI imports. Uses only local file-system reads from workspace memory, session state, pending journal, and evidence log. + +import { readFile } from "node:fs/promises"; +import type { EvidenceEventV1 } from "./evidence-log.ts"; +import { queryEvidenceEvents } from "./evidence-log.ts"; +import { sessionStatePath, workspaceKey, workspaceMemoryPath, workspacePendingJournalPath } from "./paths.ts"; +import { redactCredentials } from "./redaction.ts"; +import type { LongTermMemoryEntry, PendingMemoryJournalStore, SessionState, WorkspaceMemoryStore } from "./types.ts"; +import { LONG_TERM_LIMITS } from "./types.ts"; +import { accountWorkspaceMemoryRender } from "./workspace-memory.ts"; + +export type MemoryVisibilityCommand = "status" | "activity" | "help"; + +export type MemoryPreview = { + id: string; + type: LongTermMemoryEntry["type"]; + source: LongTermMemoryEntry["source"]; + text: string; +}; + +export type MemoryStatusModel = { + activeMemories: number; + supersededMemories: number; + renderedInPrompt: number; + omittedActiveMemories: number; + pendingInSession: number; + pendingJournalMemories: number; + openErrors: number; + recentDecisions: number; + previews: MemoryPreview[]; +}; + +export type MemoryActivityModel = { + events: EvidenceEventV1[]; + limit: number; +}; + +const DEFAULT_ACTIVITY_LIMIT = 10; +const MAX_ACTIVITY_LIMIT = 50; +const MAX_PREVIEWS = 3; +const MAX_PREVIEW_CHARS = 120; + +function clampLimit(limit: number | undefined): number { + if (!Number.isFinite(limit)) return DEFAULT_ACTIVITY_LIMIT; + return Math.max(0, Math.min(MAX_ACTIVITY_LIMIT, Math.trunc(limit ?? DEFAULT_ACTIVITY_LIMIT))); +} + +function safePreview(text: string | undefined, maxChars = MAX_PREVIEW_CHARS): string { + const clean = redactCredentials(text ?? "").replace(/\s+/g, " ").trim(); + if (clean.length <= maxChars) return clean; + return `${clean.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`; +} + +function summarizeReasons(reasons: string[] | undefined): string { + return reasons && reasons.length > 0 ? reasons.join(", ") : "no_reason_recorded"; +} + +function memoryPreview(memory: LongTermMemoryEntry): MemoryPreview { + return { + id: memory.id, + type: memory.type, + source: memory.source, + text: safePreview(memory.text), + }; +} + +async function readJSONSnapshot(path: string): Promise { + try { + return JSON.parse(await readFile(path, "utf8")); + } catch { + return undefined; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isLongTermType(value: unknown): value is LongTermMemoryEntry["type"] { + return value === "feedback" || value === "project" || value === "decision" || value === "reference"; +} + +function isLongTermSource(value: unknown): value is LongTermMemoryEntry["source"] { + return value === "explicit" || value === "compaction" || value === "manual"; +} + +function isLongTermMemoryEntry(value: unknown): value is LongTermMemoryEntry { + if (!isRecord(value)) return false; + if (typeof value.id !== "string") return false; + if (!isLongTermType(value.type)) return false; + if (typeof value.text !== "string") return false; + if (!isLongTermSource(value.source)) return false; + if (typeof value.confidence !== "number") return false; + if (value.status !== "active" && value.status !== "superseded") return false; + if (typeof value.createdAt !== "string") return false; + return typeof value.updatedAt === "string"; +} + +function memoryEntries(value: unknown): LongTermMemoryEntry[] { + return Array.isArray(value) ? value.filter(isLongTermMemoryEntry) : []; +} + +async function emptyWorkspaceMemorySnapshot(root: string): Promise { + const nowIso = new Date().toISOString(); + return { + version: 1, + workspace: { root, key: await workspaceKey(root) }, + limits: { + maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, + maxEntries: LONG_TERM_LIMITS.maxEntries, + }, + entries: [], + migrations: [], + updatedAt: nowIso, + lastActivityAt: nowIso, + }; +} + +async function readWorkspaceMemorySnapshot(root: string): Promise { + const fallback = await emptyWorkspaceMemorySnapshot(root); + const loaded = await readJSONSnapshot(await workspaceMemoryPath(root)); + if (!isRecord(loaded)) return fallback; + const limits = isRecord(loaded.limits) ? loaded.limits : {}; + + return { + version: 1, + workspace: fallback.workspace, + limits: { + maxRenderedChars: typeof limits.maxRenderedChars === "number" ? limits.maxRenderedChars : LONG_TERM_LIMITS.maxRenderedChars, + maxEntries: typeof limits.maxEntries === "number" ? limits.maxEntries : LONG_TERM_LIMITS.maxEntries, + }, + entries: memoryEntries(loaded.entries), + migrations: Array.isArray(loaded.migrations) ? loaded.migrations.filter(item => typeof item === "string") : [], + updatedAt: typeof loaded.updatedAt === "string" ? loaded.updatedAt : fallback.updatedAt, + lastActivityAt: typeof loaded.lastActivityAt === "string" ? loaded.lastActivityAt : undefined, + }; +} + +async function emptyPendingJournalSnapshot(root: string): Promise { + return { + version: 1, + workspace: { root, key: await workspaceKey(root) }, + entries: [], + updatedAt: new Date().toISOString(), + }; +} + +async function readPendingJournalSnapshot(root: string): Promise { + const fallback = await emptyPendingJournalSnapshot(root); + const loaded = await readJSONSnapshot(await workspacePendingJournalPath(root)); + if (!isRecord(loaded)) return fallback; + return { + version: 1, + workspace: fallback.workspace, + entries: memoryEntries(loaded.entries), + updatedAt: typeof loaded.updatedAt === "string" ? loaded.updatedAt : fallback.updatedAt, + }; +} + +function emptySessionStateSnapshot(sessionID: string): SessionState { + return { + version: 1, + sessionID, + turn: 0, + updatedAt: new Date().toISOString(), + activeFiles: [], + openErrors: [], + recentDecisions: [], + pendingMemories: [], + compactionMemoryRefs: [], + }; +} + +async function readSessionStateSnapshot(root: string, sessionID: string): Promise { + const fallback = emptySessionStateSnapshot(sessionID); + const loaded = await readJSONSnapshot(await sessionStatePath(root, sessionID)); + if (!isRecord(loaded)) return fallback; + return { + ...fallback, + turn: typeof loaded.turn === "number" ? loaded.turn : fallback.turn, + updatedAt: typeof loaded.updatedAt === "string" ? loaded.updatedAt : fallback.updatedAt, + activeFiles: Array.isArray(loaded.activeFiles) ? loaded.activeFiles as SessionState["activeFiles"] : [], + openErrors: Array.isArray(loaded.openErrors) ? loaded.openErrors as SessionState["openErrors"] : [], + recentDecisions: Array.isArray(loaded.recentDecisions) ? loaded.recentDecisions as SessionState["recentDecisions"] : [], + pendingMemories: memoryEntries(loaded.pendingMemories), + compactionMemoryRefs: Array.isArray(loaded.compactionMemoryRefs) ? loaded.compactionMemoryRefs as SessionState["compactionMemoryRefs"] : [], + }; +} + +export async function getMemoryStatus(root: string, sessionID: string): Promise { + const [store, sessionState, pendingJournal] = await Promise.all([ + readWorkspaceMemorySnapshot(root), + readSessionStateSnapshot(root, sessionID), + readPendingJournalSnapshot(root), + ]); + const renderAccounting = accountWorkspaceMemoryRender(store); + const activeEntries = store.entries.filter(entry => entry.status !== "superseded"); + const supersededEntries = store.entries.filter(entry => entry.status === "superseded"); + + return { + activeMemories: activeEntries.length, + supersededMemories: supersededEntries.length, + renderedInPrompt: renderAccounting.rendered.length, + omittedActiveMemories: renderAccounting.omitted.filter(item => item.memory.status !== "superseded").length, + pendingInSession: sessionState.pendingMemories.length, + pendingJournalMemories: pendingJournal.entries.length, + openErrors: sessionState.openErrors.filter(error => error.status === "open").length, + recentDecisions: sessionState.recentDecisions.length, + previews: activeEntries.slice(0, MAX_PREVIEWS).map(memoryPreview), + }; +} + +export function formatMemoryStatus(model: MemoryStatusModel): string { + const lines = [ + "## Memory status", + "", + `Active memories: ${model.activeMemories}`, + `Rendered in prompt: ${model.renderedInPrompt}`, + `Omitted active memories: ${model.omittedActiveMemories}`, + `Superseded memories: ${model.supersededMemories}`, + `Pending in this session: ${model.pendingInSession}`, + `Pending journal memories: ${model.pendingJournalMemories}`, + `Open errors: ${model.openErrors}`, + `Recent decisions: ${model.recentDecisions}`, + ]; + + if (model.previews.length > 0) { + lines.push("", "Recent active memory previews:"); + for (const preview of model.previews) { + lines.push(`- ${preview.type}/${preview.source}: ${preview.text}`); + } + } else { + lines.push("", "No active workspace memories are stored yet."); + } + + lines.push("", "Local only: no LLM request was made."); + return lines.join("\n"); +} + +export async function getMemoryActivity(root: string, options: { limit?: number } = {}): Promise { + const limit = clampLimit(options.limit); + return { + events: await queryEvidenceEvents(root, { newestFirst: true, limit }), + limit, + }; +} + +function formatActivityEvent(event: EvidenceEventV1): string { + const time = event.createdAt || "unknown_time"; + const memoryType = event.memory?.type ? ` ${event.memory.type}` : ""; + const memoryId = event.memory?.memoryId ? ` ${event.memory.memoryId}` : ""; + const preview = safePreview(event.textPreview); + const previewText = preview ? ` — ${preview}` : ""; + return `- ${time} — ${event.outcome}/${event.phase}${memoryType}${memoryId} — ${summarizeReasons(event.reasonCodes)}${previewText}`; +} + +export function formatMemoryActivity(model: MemoryActivityModel): string { + const lines = [ + "## Recent memory activity", + "", + ]; + + if (model.events.length === 0) { + lines.push(`No retained memory activity exists in the local evidence log for the last ${model.limit} events.`); + } else { + lines.push(...model.events.map(formatActivityEvent)); + } + + lines.push("", "Local only: no LLM request was made."); + return lines.join("\n"); +} + +export function formatMemoryHelp(): string { + return [ + "## Memory help", + "", + "Available display commands:", + "- /memory status — show local workspace/session memory counts.", + "- /memory activity — show recent local memory evidence activity.", + "- /memory last — alias for /memory activity.", + "- /memory help — show this help text.", + "", + "Compaction output already appears in the conversation through OpenCode's built-in flow.", + "This command reads local memory files and does not call the LLM.", + "Future commands such as /memory delete and /memory edit are not available in v1.6.1.", + "", + "Local only: no LLM request was made.", + ].join("\n"); +} + +export async function renderMemoryCommand(root: string, sessionID: string, command: MemoryVisibilityCommand): Promise { + switch (command) { + case "status": + return formatMemoryStatus(await getMemoryStatus(root, sessionID)); + case "activity": + return formatMemoryActivity(await getMemoryActivity(root)); + case "help": + return formatMemoryHelp(); + default: + return formatMemoryHelp(); + } +} diff --git a/src/tui-plugin.ts b/src/tui-plugin.ts new file mode 100644 index 0000000..259eced --- /dev/null +++ b/src/tui-plugin.ts @@ -0,0 +1,158 @@ +import { renderMemoryCommand, type MemoryVisibilityCommand } from "./memory-visibility.ts"; + +type DialogContext = { + clear?: () => void; +}; + +type TextPartInput = { + type: "text"; + text: string; +}; + +type TuiCommand = { + title: string; + value: string; + description?: string; + category?: string; + suggested?: boolean; + slash?: { + name: string; + aliases?: string[]; + }; + onSelect?: (dialog?: DialogContext) => void | Promise; +}; + +type TuiRouteCurrent = + | { name: "home" } + | { name: "session"; params: { sessionID: string; prompt?: unknown } } + | { name: string; params?: Record }; + +type TuiPluginApi = { + command: { + register: (cb: () => TuiCommand[]) => () => void; + }; + route: ({ readonly current: TuiRouteCurrent } | TuiRouteCurrent); + ui: { + toast: (input: { variant?: "info" | "success" | "warning" | "error"; message: string }) => void; + dialog?: DialogContext; + }; + state: { + path: { + directory: string; + }; + }; + client: { + session: { + prompt: (parameters: { sessionID: string; noReply?: boolean; parts?: TextPartInput[] }) => Promise | unknown; + }; + }; +}; + +type TuiPlugin = (api: TuiPluginApi, options: unknown, meta: unknown) => Promise; + +function currentRoute(api: TuiPluginApi): TuiRouteCurrent { + const route = api.route as ({ readonly current?: TuiRouteCurrent } & Partial); + return route.current ?? (route as TuiRouteCurrent); +} + +function commandFromValue(value: string): MemoryVisibilityCommand { + if (value === "memory.status") return "status"; + if (value === "memory.activity" || value === "memory.last") return "activity"; + if (value === "memory.help") return "help"; + return "help"; +} + +function renderErrorReport(error: unknown): string { + const detail = error instanceof Error ? error.message : String(error); + return [ + "## Memory error", + "", + "Unable to render local memory visibility output.", + `Error: ${detail}`, + "", + "Local only: no LLM request was made.", + ].join("\n"); +} + +async function injectMemoryOutput(api: TuiPluginApi, value: string, dialog?: DialogContext): Promise { + const route = currentRoute(api); + + if (route.name !== "session" || typeof route.params?.sessionID !== "string") { + api.ui.toast({ + variant: "warning", + message: "Open a session to use memory commands.", + }); + return; + } + + const sessionID = route.params.sessionID; + let text: string; + + try { + text = await renderMemoryCommand(api.state.path.directory, sessionID, commandFromValue(value)); + } catch (error) { + text = renderErrorReport(error); + } + + try { + await api.client.session.prompt({ + sessionID, + noReply: true, + parts: [{ type: "text", text }], + }); + dialog?.clear?.(); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + api.ui.toast({ + variant: "error", + message: `Unable to inject memory text: ${detail}`, + }); + } +} + +function memoryCommands(api: TuiPluginApi): TuiCommand[] { + return [ + { + title: "Memory status", + value: "memory.status", + description: "Show working memory status in the current session.", + category: "Memory", + suggested: true, + slash: { name: "memory", aliases: ["mem"] }, + onSelect: (dialog?: DialogContext) => injectMemoryOutput(api, "memory.status", dialog), + }, + { + title: "Memory activity", + value: "memory.activity", + description: "Show recent working memory activity.", + category: "Memory", + slash: { name: "memory", aliases: ["mem"] }, + onSelect: (dialog?: DialogContext) => injectMemoryOutput(api, "memory.activity", dialog), + }, + { + title: "Memory last", + value: "memory.last", + description: "Show recent working memory activity.", + category: "Memory", + slash: { name: "memory", aliases: ["mem"] }, + onSelect: (dialog?: DialogContext) => injectMemoryOutput(api, "memory.last", dialog), + }, + { + title: "Memory help", + value: "memory.help", + description: "Show working memory help.", + category: "Memory", + slash: { name: "memory", aliases: ["mem"] }, + onSelect: (dialog?: DialogContext) => injectMemoryOutput(api, "memory.help", dialog), + }, + ]; +} + +export const MemoryTuiPlugin: TuiPlugin = async (api) => { + api.command.register(() => memoryCommands(api)); +}; + +export default { + id: "working-memory-tui", + tui: MemoryTuiPlugin, +}; diff --git a/src/workspace-memory.ts b/src/workspace-memory.ts index e1d0e8c..b7e97c6 100644 --- a/src/workspace-memory.ts +++ b/src/workspace-memory.ts @@ -528,7 +528,7 @@ function extractConcreteIdentityKey(text: string): string | null { if (pathIdentity) return pathIdentity; } - const pathMatch = text.match(/(?:\/[^\s`"'<>]+|(?:\.{1,2}[\\/]|[A-Za-z0-9_.-]+[\\/])[^\s`"'<>]+|[A-Za-z0-9_.-]+\.(?:json|jsonc|ts|tsx|js|jsx|mjs|cjs|md|yaml|yml|toml|lock|config))(?:\b|$)/); + const pathMatch = text.match(/(?:\/[^\0\s`"'<>]+|(?:\.{1,2}[\\/]|[A-Za-z0-9_.-]+[\\/])[^\s`"'<>]+|[A-Za-z0-9_.-]+\.(?:json|jsonc|ts|tsx|js|jsx|mjs|cjs|md|yaml|yml|toml|lock|config))(?:\b|$)/); if (!pathMatch) return null; return normalizeConcretePathIdentity(pathMatch[0]); diff --git a/tests/memory-visibility.test.ts b/tests/memory-visibility.test.ts new file mode 100644 index 0000000..32e6099 --- /dev/null +++ b/tests/memory-visibility.test.ts @@ -0,0 +1,195 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { tmpdir } from "node:os"; +import type { EvidenceEventInput } from "../src/evidence-log.ts"; +import { appendEvidenceEvents } from "../src/evidence-log.ts"; +import { appendPendingMemories } from "../src/pending-journal.ts"; +import { saveSessionState } from "../src/session-state.ts"; +import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "../src/types.ts"; +import { workspaceMemoryPath } from "../src/paths.ts"; +import { saveWorkspaceMemory } from "../src/workspace-memory.ts"; +import { + formatMemoryActivity, + formatMemoryHelp, + formatMemoryStatus, + getMemoryActivity, + getMemoryStatus, + renderMemoryCommand, +} from "../src/memory-visibility.ts"; + +async function tempRoot(): Promise { + return mkdtemp(join(tmpdir(), "memory-visibility-test-")); +} + +function memory(id: string, text: string, overrides: Partial = {}): LongTermMemoryEntry { + const now = new Date().toISOString(); + return { + id, + type: "decision", + text, + source: "compaction", + confidence: 0.8, + status: "active", + createdAt: now, + updatedAt: now, + ...overrides, + }; +} + +function evidence(overrides: Partial = {}): EvidenceEventInput { + return { + type: "promotion_promoted", + phase: "promotion", + outcome: "promoted", + reasonCodes: ["new_workspace_entry"], + memory: { memoryId: "mem-a", type: "decision", source: "compaction", status: "active" }, + textPreview: "Use npm test before release", + ...overrides, + }; +} + +test("formats status counts from workspace, session, and pending journal stores", async () => { + const root = await tempRoot(); + try { + const now = new Date().toISOString(); + const store: WorkspaceMemoryStore = { + version: 1, + workspace: { root, key: "test" }, + limits: { maxRenderedChars: 115, maxEntries: 28 }, + entries: [ + memory("mem-short", "Keep tests focused."), + memory("mem-long", "Long memory with password: sushi ".repeat(20), { type: "reference" }), + memory("mem-old", "Superseded memory should not be active.", { status: "superseded" }), + ], + migrations: [], + updatedAt: now, + }; + await saveWorkspaceMemory(root, store); + await saveSessionState(root, { + version: 1, + sessionID: "ses_status", + turn: 1, + updatedAt: now, + activeFiles: [], + openErrors: [{ + id: "err-1", + category: "typecheck", + summary: "Typecheck failed", + fingerprint: "typecheck", + status: "open", + firstSeen: Date.now(), + lastSeen: Date.now(), + seenCount: 1, + }], + recentDecisions: [{ id: "dec-1", text: "Prefer local rendering", source: "user", createdAt: Date.now() }], + pendingMemories: [memory("pending-session", "Pending for this session", { source: "explicit", pendingOwnerSessionID: "ses_status" })], + compactionMemoryRefs: [], + }); + await appendPendingMemories(root, [memory("pending-journal", "Pending in durable journal", { source: "explicit", pendingOwnerSessionID: "ses_status" })]); + + const output = formatMemoryStatus(await getMemoryStatus(root, "ses_status")); + + assert.match(output, /^## Memory status/); + assert.match(output, /Active memories: 2/); + assert.match(output, /Rendered in prompt: 1/); + assert.match(output, /Pending in this session: 1/); + assert.match(output, /Pending journal memories: 1/); + assert.match(output, /Open errors: 1/); + assert.match(output, /Recent decisions: 1/); + assert.match(output, /Local only: no LLM request was made\./); + assert.equal(output.includes("sushi"), false, "credential-like previews should be redacted"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("getMemoryStatus redacts previews without rewriting workspace memory", async () => { + const root = await tempRoot(); + try { + const now = new Date().toISOString(); + const path = await workspaceMemoryPath(root); + await mkdir(dirname(path), { recursive: true }); + const store: WorkspaceMemoryStore = { + version: 1, + workspace: { root, key: "test" }, + limits: { maxRenderedChars: 3600, maxEntries: 28 }, + entries: [memory("mem-secret", "Remember password: sushi for the fake test fixture.", { createdAt: now, updatedAt: now })], + migrations: [], + updatedAt: now, + }; + const before = JSON.stringify(store, null, 2); + await writeFile(path, before, "utf8"); + + const output = formatMemoryStatus(await getMemoryStatus(root, "ses_readonly")); + const after = await readFile(path, "utf8"); + + assert.match(output, /Active memories: 1/); + assert.equal(output.includes("sushi"), false, "status output should redact credential-like previews"); + assert.equal(after, before, "status display must not persist normalization, migration, or redaction changes"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("formats recent memory activity newest first with reason summaries", async () => { + const root = await tempRoot(); + try { + await appendEvidenceEvents(root, [ + evidence({ + type: "render_omitted", + phase: "render", + outcome: "omitted", + reasonCodes: ["char_budget"], + memory: { memoryId: "old-render", type: "reference", source: "compaction", status: "active" }, + textPreview: "Older preview", + }), + evidence({ + type: "promotion_promoted", + phase: "promotion", + outcome: "promoted", + reasonCodes: ["new_workspace_entry"], + memory: { memoryId: "new-memory", type: "decision", source: "explicit", status: "active" }, + textPreview: "Newest password: sushi preview", + }), + ]); + + const output = formatMemoryActivity(await getMemoryActivity(root, { limit: 2 })); + + assert.match(output, /^## Recent memory activity/); + assert.ok(output.indexOf("promoted") < output.indexOf("omitted"), "newest event should be formatted first"); + assert.match(output, /new_workspace_entry/); + assert.match(output, /char_budget/); + assert.equal(output.includes("sushi"), false, "activity previews should be redacted"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("formats empty activity state", () => { + const output = formatMemoryActivity({ events: [], limit: 10 }); + assert.match(output, /^## Recent memory activity/); + assert.match(output, /No retained memory activity exists/); +}); + +test("formats help text for available display commands", () => { + const output = formatMemoryHelp(); + assert.match(output, /^## Memory help/); + assert.match(output, /\/memory status/); + assert.match(output, /\/memory activity/); + assert.match(output, /\/memory last/); + assert.match(output, /\/memory help/); + assert.match(output, /Future commands such as \/memory delete and \/memory edit are not available in v1\.6\.1\./); + assert.match(output, /does not call the LLM/); +}); + +test("renderMemoryCommand falls back to help for unknown command values", async () => { + const root = await tempRoot(); + try { + const output = await renderMemoryCommand(root, "ses_unknown", "unknown" as never); + assert.match(output, /^## Memory help/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/tests/tui-plugin.test.ts b/tests/tui-plugin.test.ts new file mode 100644 index 0000000..638e13f --- /dev/null +++ b/tests/tui-plugin.test.ts @@ -0,0 +1,153 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import type { TuiCommand } from "@opencode-ai/plugin/tui"; + +// --------------------------------------------------------------------------- +// Mock infrastructure +// --------------------------------------------------------------------------- + +type MockDialogContext = { clear: () => void; replace: (...args: unknown[]) => void; stack: unknown[] }; +type RuntimeCommand = { value: string; slash?: { name: string; aliases?: string[] }; onSelect?: (dialog: MockDialogContext) => void | Promise }; + +interface MockPromptCall { + sessionID: string; + noReply?: boolean; + parts?: Array<{ type: string; text?: string; synthetic?: boolean }>; +} + +interface MockTuiApi { + commands: RuntimeCommand[]; + prompts: MockPromptCall[]; + toasts: Array<{ variant?: string; message: string }>; + dialog: MockDialogContext; + route: { name: string; params?: Record }; + state: { path: { directory: string } }; + command: { register: (cb: () => TuiCommand[]) => () => void }; + ui: { toast: (input: { variant?: string; message: string }) => void; dialog: MockDialogContext }; + client: { session: { prompt: (input: MockPromptCall) => Promise } }; +} + +function makeMockTuiApi(options: { + route: { name: string; params?: Record }; + directory?: string; +}): MockTuiApi { + const commands: RuntimeCommand[] = []; + const prompts: MockPromptCall[] = []; + const toasts: Array<{ variant?: string; message: string }> = []; + const dialog: MockDialogContext = { + clear: () => { dialog.stack.push("clear"); }, + replace: (...args: unknown[]) => { dialog.stack.push(args); }, + stack: [], + }; + + return { + commands, + prompts, + toasts, + dialog, + route: options.route, + state: { path: { directory: options.directory ?? "/mock/workspace" } }, + command: { + register: (cb: () => TuiCommand[]) => { + const items = cb(); + for (const item of items) { + const runtimeItem: RuntimeCommand = { + value: item.value, + slash: item.slash, + onSelect: item.onSelect + ? (dialogContext: MockDialogContext = dialog) => (item.onSelect as (dialog: MockDialogContext) => void | Promise)(dialogContext) + : undefined, + }; + commands.push(runtimeItem); + } + return () => {}; + }, + }, + ui: { + toast: (input: { variant?: string; message: string }) => { toasts.push(input); }, + dialog, + }, + client: { + session: { + prompt: async (input: MockPromptCall) => { prompts.push(input); }, + }, + }, + }; +} + +async function selectCommand(api: MockTuiApi, value: string): Promise { + const command = api.commands.find((item): item is RuntimeCommand => item.value === value); + assert.ok(command, `registered command ${value}`); + await command.onSelect?.(api.dialog); +} + +// --------------------------------------------------------------------------- +// We must import MemoryTuiPlugin after setting up mocks. +// Mock the @opencode-ai/plugin import to avoid real SDK dependency in tests. +// Since the TUI plugin imports from @opencode-ai/plugin/tui, we need to +// provide a minimal mock. +// --------------------------------------------------------------------------- + +// Dynamic import to allow module-level mocking +const { MemoryTuiPlugin } = await import("../src/tui-plugin.ts"); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test("registers /memory slash command", async () => { + const api = makeMockTuiApi({ route: { name: "session", params: { sessionID: "ses_1" } } }); + await MemoryTuiPlugin(api as any, undefined, { id: "test", source: "file", spec: "test", target: "./tui", first_time: Date.now(), last_time: Date.now(), time_changed: Date.now(), load_count: 1, fingerprint: "test", state: "first" }); + assert.ok(api.commands.some(command => command.slash?.name === "memory")); +}); + +test("injects no-reply text into the active session", async () => { + const api = makeMockTuiApi({ route: { name: "session", params: { sessionID: "ses_1" } } }); + await MemoryTuiPlugin(api as any, undefined, { id: "test", source: "file", spec: "test", target: "./tui", first_time: Date.now(), last_time: Date.now(), time_changed: Date.now(), load_count: 1, fingerprint: "test", state: "first" }); + await selectCommand(api, "memory.status"); + assert.equal(api.prompts.length, 1); + assert.equal(api.prompts[0].sessionID, "ses_1"); + assert.equal(api.prompts[0].noReply, true); + assert.equal(api.prompts[0].parts![0].type, "text"); + assert.match(api.prompts[0].parts![0].text ?? "", /^## Memory status/); + // synthetic must be undefined (not true) so TUI renders the text + assert.equal(api.prompts[0].parts![0].synthetic, undefined); +}); + +test("routes memory subcommands to status, activity, and help output", async () => { + const api = makeMockTuiApi({ route: { name: "session", params: { sessionID: "ses_1" } } }); + await MemoryTuiPlugin(api as any, undefined, { id: "test", source: "file", spec: "test", target: "./tui", first_time: Date.now(), last_time: Date.now(), time_changed: Date.now(), load_count: 1, fingerprint: "test", state: "first" }); + + await selectCommand(api, "memory.status"); + assert.match(api.prompts.at(-1)?.parts?.[0]?.text ?? "", /^## Memory status/); + + await selectCommand(api, "memory.activity"); + assert.match(api.prompts.at(-1)?.parts?.[0]?.text ?? "", /^## Recent memory activity/); + + await selectCommand(api, "memory.help"); + assert.match(api.prompts.at(-1)?.parts?.[0]?.text ?? "", /^## Memory help/); +}); + +test("shows warning toast when no active session", async () => { + const api = makeMockTuiApi({ route: { name: "home" } }); + await MemoryTuiPlugin(api as any, undefined, { id: "test", source: "file", spec: "test", target: "./tui", first_time: Date.now(), last_time: Date.now(), time_changed: Date.now(), load_count: 1, fingerprint: "test", state: "first" }); + await selectCommand(api, "memory.status"); + assert.equal(api.prompts.length, 0, "should not call prompt without session"); + assert.ok(api.toasts.some(t => t.variant === "warning"), "should show warning toast"); +}); + +test("clears dialog after successful command", async () => { + const api = makeMockTuiApi({ route: { name: "session", params: { sessionID: "ses_1" } } }); + await MemoryTuiPlugin(api as any, undefined, { id: "test", source: "file", spec: "test", target: "./tui", first_time: Date.now(), last_time: Date.now(), time_changed: Date.now(), load_count: 1, fingerprint: "test", state: "first" }); + await selectCommand(api, "memory.status"); + assert.ok(api.dialog.stack.includes("clear"), "dialog should be cleared after command"); +}); + +test("shows error toast when prompt injection fails", async () => { + const api = makeMockTuiApi({ route: { name: "session", params: { sessionID: "ses_1" } } }); + // Override prompt to reject + api.client.session.prompt = async () => { throw new Error("SDK failure"); }; + await MemoryTuiPlugin(api as any, undefined, { id: "test", source: "file", spec: "test", target: "./tui", first_time: Date.now(), last_time: Date.now(), time_changed: Date.now(), load_count: 1, fingerprint: "test", state: "first" }); + await selectCommand(api, "memory.status"); + assert.ok(api.toasts.some(t => t.variant === "error"), "should show error toast on SDK failure"); +});