fix(tui): clarify memory command surface

This commit is contained in:
Ralph Chang
2026-05-08 19:49:56 +08:00
parent 49bf866de2
commit 65b3b2f2c3
6 changed files with 192 additions and 179 deletions
+3 -2
View File
@@ -9,12 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Native OpenCode TUI `/memory` display commands for local memory status, recent activity, and help.
- Native OpenCode TUI `/memory-status`, `/memory-list`, and `/memory-help` display commands for local memory statistics, current workspace memory refs, and help.
- Package `./tui` export for OpenCode TUI plugin loading.
### Changed
- README documents separate server and TUI plugin configuration.
- Recent activity/last TUI commands were removed before release because duplicate-looking slash menu entries were not useful.
### Fixed
@@ -22,7 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Notes / Known UX
- `/memory` output is injected as no-reply user-style conversation text and does not call the LLM.
- TUI memory command output is injected as no-reply user-style conversation text and does not call the LLM.
## [1.6.0] - 2026-05-08
+6 -6
View File
@@ -30,7 +30,7 @@ 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 OpenCodes 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.
- **Native TUI memory commands** — show local memory status, current memory list, 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.
@@ -48,7 +48,7 @@ Add OpenCode Working Memory to your server plugin config:
}
```
To enable the native TUI `/memory` display command, also add the TUI plugin config:
To enable the native TUI memory display commands, also add the TUI plugin config:
`.opencode/tui.json`:
@@ -65,13 +65,13 @@ Then restart OpenCode. Server memory activates automatically; 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.
- `/memory-status` — show status counts for workspace memory, rendered memories, pending memory, open errors, and recent decisions.
- `/memory-list` — show current active workspace memories with display-local `[M1]` refs.
- `/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.
Current OpenCode plugins do not expose an assistant-style command-output surface, so TUI memory command 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.
+85 -89
View File
@@ -1,20 +1,16 @@
// No OpenCode SDK or TUI imports. Uses only local file-system reads from workspace memory, session state, pending journal, and evidence log.
// No OpenCode SDK or TUI imports. Uses only local file-system reads from workspace memory, session state, and pending journal.
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";
import { accountWorkspaceMemoryCompactionRefs, accountWorkspaceMemoryRender } from "./workspace-memory.ts";
export type MemoryVisibilityCommand = "status" | "activity" | "help";
export type MemoryVisibilityCommand = "status" | "list" | "help";
export type MemoryPreview = {
id: string;
type: LongTermMemoryEntry["type"];
source: LongTermMemoryEntry["source"];
type MemoryListItem = {
ref: string;
text: string;
};
@@ -27,23 +23,17 @@ export type MemoryStatusModel = {
pendingJournalMemories: number;
openErrors: number;
recentDecisions: number;
previews: MemoryPreview[];
};
export type MemoryActivityModel = {
events: EvidenceEventV1[];
limit: number;
export type MemoryListModel = {
activeMemories: number;
renderedMemories: number;
omittedActiveMemories: number;
groups: Record<LongTermMemoryEntry["type"], MemoryListItem[]>;
};
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)));
}
const MEMORY_TYPE_ORDER = ["feedback", "project", "decision", "reference"] as const satisfies readonly LongTermMemoryEntry["type"][];
function safePreview(text: string | undefined, maxChars = MAX_PREVIEW_CHARS): string {
const clean = redactCredentials(text ?? "").replace(/\s+/g, " ").trim();
@@ -51,19 +41,6 @@ function safePreview(text: string | undefined, maxChars = MAX_PREVIEW_CHARS): st
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<unknown | undefined> {
try {
return JSON.parse(await readFile(path, "utf8"));
@@ -206,67 +183,91 @@ export async function getMemoryStatus(root: string, sessionID: string): Promise<
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 = [
return [
"## 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");
"Workspace:",
`- Active memories: ${model.activeMemories}`,
`- Rendered in prompt: ${model.renderedInPrompt}`,
`- Omitted active memories: ${model.omittedActiveMemories}`,
`- Superseded memories: ${model.supersededMemories}`,
"",
"Pending:",
`- Pending in this session: ${model.pendingInSession}`,
`- Pending journal memories: ${model.pendingJournalMemories}`,
"",
"Session:",
`- Open errors: ${model.openErrors}`,
`- Recent decisions: ${model.recentDecisions}`,
"",
`Use /memory-list to view current [M1]-[M${LONG_TERM_LIMITS.maxEntries}] memory refs.`,
"",
"Local only: no LLM request was made.",
].join("\n");
}
export async function getMemoryActivity(root: string, options: { limit?: number } = {}): Promise<MemoryActivityModel> {
const limit = clampLimit(options.limit);
function emptyMemoryListGroups(): MemoryListModel["groups"] {
return { feedback: [], project: [], decision: [], reference: [] };
}
export async function getMemoryList(root: string): Promise<MemoryListModel> {
const store = await readWorkspaceMemorySnapshot(root);
const accounting = accountWorkspaceMemoryCompactionRefs(store);
const groups = emptyMemoryListGroups();
const renderedMemoryIds = new Set(accounting.rendered.map(memory => memory.id));
for (const ref of accounting.refs) {
if (!renderedMemoryIds.has(ref.memoryId)) continue;
groups[ref.type].push({
ref: ref.ref,
text: safePreview(ref.textPreview),
});
}
const renderedMemories = MEMORY_TYPE_ORDER.reduce((total, type) => total + groups[type].length, 0);
return {
events: await queryEvidenceEvents(root, { newestFirst: true, limit }),
limit,
activeMemories: store.entries.filter(entry => entry.status !== "superseded").length,
renderedMemories,
omittedActiveMemories: accounting.omitted.filter(item => item.memory.status !== "superseded").length,
groups,
};
}
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 {
export function formatMemoryList(model: MemoryListModel): string {
const lines = [
"## Recent memory activity",
"## Current workspace memories",
"",
];
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));
if (model.renderedMemories === 0) {
lines.push("No active workspace memories are stored yet.", "", "Local only: no LLM request was made.");
return lines.join("\n");
}
lines.push("", "Local only: no LLM request was made.");
lines.push("Display refs are local to this output and may change after memory updates.", "");
for (const type of MEMORY_TYPE_ORDER) {
const group = model.groups[type];
if (group.length === 0) continue;
lines.push(`${type}:`);
for (const item of group) {
lines.push(`- [${item.ref}] ${item.text}`);
}
lines.push("");
}
lines.push(
`Shown: ${model.renderedMemories} of ${model.activeMemories} active memories.`,
`Omitted active memories: ${model.omittedActiveMemories}.`,
"",
"Local only: no LLM request was made.",
);
return lines.join("\n");
}
@@ -274,17 +275,12 @@ 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.",
"Commands:",
"- /memory-status — show local memory statistics.",
`- /memory-list — show current workspace memories as display-local [M1]-[M${LONG_TERM_LIMITS.maxEntries}] refs.`,
"- /memory-help — show this help.",
"",
"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.",
"These commands are read-only, local-only, and do not call the LLM.",
].join("\n");
}
@@ -292,8 +288,8 @@ export async function renderMemoryCommand(root: string, sessionID: string, comma
switch (command) {
case "status":
return formatMemoryStatus(await getMemoryStatus(root, sessionID));
case "activity":
return formatMemoryActivity(await getMemoryActivity(root));
case "list":
return formatMemoryList(await getMemoryList(root));
case "help":
return formatMemoryHelp();
default:
+9 -17
View File
@@ -57,7 +57,7 @@ function currentRoute(api: TuiPluginApi): TuiRouteCurrent {
function commandFromValue(value: string): MemoryVisibilityCommand {
if (value === "memory.status") return "status";
if (value === "memory.activity" || value === "memory.last") return "activity";
if (value === "memory.list") return "list";
if (value === "memory.help") return "help";
return "help";
}
@@ -115,34 +115,26 @@ function memoryCommands(api: TuiPluginApi): TuiCommand[] {
{
title: "Memory status",
value: "memory.status",
description: "Show working memory status in the current session.",
description: "Show working memory statistics in the current session.",
category: "Memory",
suggested: true,
slash: { name: "memory", aliases: ["mem"] },
slash: { name: "memory-status" },
onSelect: (dialog?: DialogContext) => injectMemoryOutput(api, "memory.status", dialog),
},
{
title: "Memory activity",
value: "memory.activity",
description: "Show recent working memory activity.",
title: "Memory list",
value: "memory.list",
description: "Show current workspace memories with display-local refs.",
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),
slash: { name: "memory-list" },
onSelect: (dialog?: DialogContext) => injectMemoryOutput(api, "memory.list", dialog),
},
{
title: "Memory help",
value: "memory.help",
description: "Show working memory help.",
category: "Memory",
slash: { name: "memory", aliases: ["mem"] },
slash: { name: "memory-help" },
onSelect: (dialog?: DialogContext) => injectMemoryOutput(api, "memory.help", dialog),
},
];
+77 -60
View File
@@ -3,18 +3,16 @@ 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,
formatMemoryList,
formatMemoryStatus,
getMemoryActivity,
getMemoryList,
getMemoryStatus,
renderMemoryCommand,
} from "../src/memory-visibility.ts";
@@ -38,18 +36,6 @@ function memory(id: string, text: string, overrides: Partial<LongTermMemoryEntry
};
}
function evidence(overrides: Partial<EvidenceEventInput> = {}): 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 {
@@ -92,14 +78,21 @@ test("formats status counts from workspace, session, and pending journal stores"
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, /Workspace:/);
assert.match(output, /- Active memories: 2/);
assert.match(output, /- Rendered in prompt: 1/);
assert.match(output, /- Omitted active memories: 1/);
assert.match(output, /- Superseded memories: 1/);
assert.match(output, /Pending:/);
assert.match(output, /- Pending in this session: 1/);
assert.match(output, /- Pending journal memories: 1/);
assert.match(output, /Session:/);
assert.match(output, /- Open errors: 1/);
assert.match(output, /- Recent decisions: 1/);
assert.match(output, /Use \/memory-list to view current \[M1\]-\[M28\] memory refs\./);
assert.match(output, /Local only: no LLM request was made\./);
assert.equal(output.includes("sushi"), false, "credential-like previews should be redacted");
assert.equal(output.includes("Recent active memory previews"), false);
assert.equal(output.includes("sushi"), false, "status output should not include memory previews");
} finally {
await rm(root, { recursive: true, force: true });
}
@@ -125,63 +118,87 @@ test("getMemoryStatus redacts previews without rewriting workspace memory", asyn
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.match(output, /- Active memories: 1/);
assert.equal(output.includes("Recent active memory previews"), false);
assert.equal(output.includes("sushi"), false, "status output should not include memory 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 () => {
test("formats current workspace memories grouped by type with display-local refs", 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 now = new Date().toISOString();
await saveWorkspaceMemory(root, {
version: 1,
workspace: { root, key: "test" },
limits: { maxRenderedChars: 3600, maxEntries: 28 },
entries: [
memory("mem-feedback", "Remember password: sushi for the fake test.", { type: "feedback" }),
memory("mem-project", "Project memory should render in its own group.", { type: "project" }),
memory("mem-decision", "Decision memory should render in its own group.", { type: "decision" }),
memory("mem-reference", "Reference memory should render in its own group.", { type: "reference" }),
memory("mem-superseded", "Superseded memory should not be active", { type: "reference", status: "superseded" }),
],
migrations: [],
updatedAt: now,
});
const output = formatMemoryActivity(await getMemoryActivity(root, { limit: 2 }));
const output = formatMemoryList(await getMemoryList(root));
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");
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.match(output, /Shown: 4 of 4 active memories\./);
assert.match(output, /Omitted active memories: 0\./);
assert.equal(output.includes("[M1]"), true, "at least one display-local ref should render");
assert.equal(output.includes("sushi"), false, "list previews should redact credential-like text");
assert.equal(output.includes("Superseded memory should not be active"), false);
} 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 empty memory list state", () => {
const output = formatMemoryList({
activeMemories: 0,
renderedMemories: 0,
omittedActiveMemories: 0,
groups: { feedback: [], project: [], decision: [], reference: [] },
});
assert.match(output, /^## Current workspace memories/);
assert.match(output, /No active workspace memories are stored yet\./);
assert.match(output, /Local only: no LLM request was made\./);
assert.equal(output.includes("feedback:"), false);
});
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/);
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);
assert.equal(output.includes("/memory status"), false);
assert.equal(output.includes("/memory help"), false);
assert.match(output, /do not call the LLM/);
});
test("renderMemoryCommand routes list output", async () => {
const root = await tempRoot();
try {
const output = await renderMemoryCommand(root, "ses_list", "list");
assert.match(output, /^## Current workspace memories/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("renderMemoryCommand falls back to help for unknown command values", async () => {
+12 -5
View File
@@ -95,10 +95,17 @@ const { MemoryTuiPlugin } = await import("../src/tui-plugin.ts");
// Tests
// ---------------------------------------------------------------------------
test("registers /memory slash command", async () => {
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, { 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"));
const slashNames = api.commands.map(command => command.slash?.name).filter(Boolean);
assert.deepEqual(slashNames, ["memory-status", "memory-list", "memory-help"]);
assert.deepEqual(api.commands.map(command => command.slash), [{ name: "memory-status" }, { name: "memory-list" }, { name: "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);
});
test("injects no-reply text into the active session", async () => {
@@ -114,15 +121,15 @@ test("injects no-reply text into the active session", async () => {
assert.equal(api.prompts[0].parts![0].synthetic, undefined);
});
test("routes memory subcommands to status, activity, and help output", async () => {
test("routes memory commands to status, list, 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.list");
assert.match(api.prompts.at(-1)?.parts?.[0]?.text ?? "", /^## Current workspace memories/);
await selectCommand(api, "memory.help");
assert.match(api.prompts.at(-1)?.parts?.[0]?.text ?? "", /^## Memory help/);