feat(tui): add native memory visibility commands

This commit is contained in:
Ralph Chang
2026-05-08 19:26:17 +08:00
parent 8b46150fab
commit 49bf866de2
9 changed files with 1354 additions and 5 deletions
+302
View File
@@ -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<unknown | undefined> {
try {
return JSON.parse(await readFile(path, "utf8"));
} catch {
return undefined;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
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<WorkspaceMemoryStore> {
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<WorkspaceMemoryStore> {
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<PendingMemoryJournalStore> {
return {
version: 1,
workspace: { root, key: await workspaceKey(root) },
entries: [],
updatedAt: new Date().toISOString(),
};
}
async function readPendingJournalSnapshot(root: string): Promise<PendingMemoryJournalStore> {
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<SessionState> {
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<MemoryStatusModel> {
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<MemoryActivityModel> {
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<string> {
switch (command) {
case "status":
return formatMemoryStatus(await getMemoryStatus(root, sessionID));
case "activity":
return formatMemoryActivity(await getMemoryActivity(root));
case "help":
return formatMemoryHelp();
default:
return formatMemoryHelp();
}
}
+158
View File
@@ -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<void>;
};
type TuiRouteCurrent =
| { name: "home" }
| { name: "session"; params: { sessionID: string; prompt?: unknown } }
| { name: string; params?: Record<string, unknown> };
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> | unknown;
};
};
};
type TuiPlugin = (api: TuiPluginApi, options: unknown, meta: unknown) => Promise<void>;
function currentRoute(api: TuiPluginApi): TuiRouteCurrent {
const route = api.route as ({ readonly current?: TuiRouteCurrent } & Partial<TuiRouteCurrent>);
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<void> {
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,
};
+1 -1
View File
@@ -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]);