mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-06-01 22:11:08 +02:00
fix(tui): clarify memory command surface
This commit is contained in:
+3
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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 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.
|
||||
- **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
@@ -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
@@ -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),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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/);
|
||||
|
||||
Reference in New Issue
Block a user