feat(coding-agent): added /memory mm handlers for mental-model seeds

- Added mental-model settings and config defaults for enablement, auto-seed, refresh interval, and render budget.
- Added built-in mental-model seeds and scope-aware rendering with `<mental_models>` extraction, truncation, and tag handling.
- Added `/memory mm` aliases and handlers for list, show, refresh, history, seed, reload, and delete commands.
- Added client APIs and bootstrap/cache wiring so snippets refresh and inject into prompts on startup.
- Added tests covering seed scope behavior, rendering caps, diffs, and backend/session reload behavior.
This commit is contained in:
can1357
2026-05-03 09:48:11 +02:00
parent e54441eaa2
commit 255ef926cf
16 changed files with 1475 additions and 15 deletions
@@ -17,6 +17,16 @@ import { clearClaudePluginRootsCache } from "../../discovery/helpers";
import { getGatewayStatus } from "../../eval/py/gateway-coordinator";
import { loadCustomShare } from "../../export/custom-share";
import type { CompactOptions } from "../../extensibility/extensions/types";
import {
diffMentalModelContent,
getHindsightSessionState,
type HindsightApi,
type HindsightSessionState,
loadHindsightConfig,
reloadMentalModelsForSession,
resolveSeedsForScope,
summarizeMentalModel,
} from "../../hindsight";
import { resolveMemoryBackend } from "../../memory-backend";
import { BashExecutionComponent } from "../../modes/components/bash-execution";
import { BorderedLoader } from "../../modes/components/bordered-loader";
@@ -609,7 +619,262 @@ export class CommandController {
return;
}
this.ctx.showError("Usage: /memory <view|clear|reset|enqueue|rebuild>");
if (action === "mm") {
await this.#handleMentalModelsSubcommand(argumentText);
return;
}
this.ctx.showError("Usage: /memory <view|clear|reset|enqueue|rebuild|mm ...>");
}
async #handleMentalModelsSubcommand(argumentText: string): Promise<void> {
// Parse: "mm <verb> [arg]"
const parts = argumentText.split(/\s+/).slice(1);
const verb = parts[0]?.toLowerCase() ?? "list";
const arg = parts[1];
const sessionId = this.ctx.session.sessionId;
if (!sessionId) {
this.ctx.showError("No active session.");
return;
}
const state = getHindsightSessionState(sessionId);
const primary = state && !state.aliasOf ? state : undefined;
if (!primary) {
this.ctx.showError("Hindsight backend is not active for this session.");
return;
}
if (!primary.config.mentalModelsEnabled) {
this.ctx.showError("Mental models are disabled (hindsight.mentalModelsEnabled = false).");
return;
}
switch (verb) {
case "list":
await this.#mmList(primary);
return;
case "show":
if (!arg) return this.ctx.showError("Usage: /memory mm show <id>");
await this.#mmShow(primary, arg);
return;
case "refresh":
await this.#mmRefresh(primary, arg);
return;
case "history":
if (!arg) return this.ctx.showError("Usage: /memory mm history <id>");
await this.#mmHistory(primary, arg);
return;
case "seed":
await this.#mmSeed(primary);
return;
case "reload":
await this.#mmReload(sessionId);
return;
case "delete":
case "remove":
if (!arg) return this.ctx.showError("Usage: /memory mm delete <id>");
await this.#mmDelete(primary, arg);
return;
default:
this.ctx.showError("Usage: /memory mm <list|show|refresh|history|seed|reload|delete>");
}
}
async #mmList(state: HindsightSessionState): Promise<void> {
const client: HindsightApi = state.client;
try {
const response = await client.listMentalModels(state.bankId, { detail: "metadata" });
const items = response.items ?? [];
if (items.length === 0) {
this.ctx.showStatus(`No mental models on bank ${state.bankId}.`);
return;
}
const lines = items
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
.map(summarizeMentalModel);
showMarkdownPanel(this.ctx, `Mental Models — ${state.bankId}`, lines.join("\n"));
} catch (error) {
this.ctx.showError(`mm list failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
async #mmShow(state: HindsightSessionState, id: string): Promise<void> {
try {
const model = await state.client.getMentalModel(state.bankId, id, { detail: "content" });
if (!model) {
this.ctx.showError(`Mental model not found: ${id}`);
return;
}
const tags = model.tags && model.tags.length > 0 ? `\n_tags: ${model.tags.join(", ")}_` : "";
const refreshed = model.last_refreshed_at ? `\n_last refreshed: ${model.last_refreshed_at}_` : "";
const sourceQuery = model.source_query ? `\n\n**Source query:** ${model.source_query}` : "";
const content = (model.content ?? "_(empty — background reflect may still be running)_").trim();
showMarkdownPanel(
this.ctx,
model.name,
`**id:** \`${model.id}\`${tags}${refreshed}${sourceQuery}\n\n${content}`,
);
} catch (error) {
this.ctx.showError(`mm show failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
async #mmRefresh(state: HindsightSessionState, id: string | undefined): Promise<void> {
try {
if (id) {
// Single-model refresh is explicit operator intent: bypass the
// auto-refresh filter so curated/manual models can still be
// refreshed on demand.
await state.client.refreshMentalModel(state.bankId, id);
this.ctx.showStatus(`Refresh queued for mental model ${id}.`);
} else {
// Bulk refresh: only touch models that opted into automatic
// refresh via `trigger.refresh_after_consolidation`. Curated
// models are reviewed before publishing and must not be
// silently regenerated by a bank-wide refresh sweep. Reading
// `detail: "content"` here is required because the trigger
// field is excluded from `detail: "metadata"`.
const list = await state.client.listMentalModels(state.bankId, { detail: "content" });
const items = list.items ?? [];
if (items.length === 0) {
this.ctx.showStatus(`No mental models on bank ${state.bankId}.`);
return;
}
const targets = items.filter(m => m.trigger?.refresh_after_consolidation === true);
const skipped = items.length - targets.length;
if (targets.length === 0) {
this.ctx.showStatus(
`No mental models opted into auto-refresh; ${skipped} curated model(s) left untouched. Pass an explicit id to refresh one of them.`,
);
return;
}
let queued = 0;
for (const item of targets) {
try {
await state.client.refreshMentalModel(state.bankId, item.id);
queued++;
} catch (error) {
this.ctx.showWarning(
`Refresh failed for ${item.id}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
const skippedSuffix = skipped > 0 ? `; skipped ${skipped} curated model(s)` : "";
this.ctx.showStatus(
`Refresh queued for ${queued}/${targets.length} auto-refresh model(s)${skippedSuffix}.`,
);
}
// Reload the cache after a brief grace so the new content (if the refresh
// completes synchronously on the server) flows into the system prompt.
await Bun.sleep(500);
await reloadMentalModelsForSession(state.session.sessionId ?? "");
} catch (error) {
this.ctx.showError(`mm refresh failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
async #mmHistory(state: HindsightSessionState, id: string): Promise<void> {
try {
const [model, history] = await Promise.all([
state.client.getMentalModel(state.bankId, id, { detail: "content" }),
state.client.getMentalModelHistory(state.bankId, id),
]);
if (!model) {
this.ctx.showError(`Mental model not found: ${id}`);
return;
}
if (history.length === 0) {
this.ctx.showStatus(`No history recorded for ${id}.`);
return;
}
// History is most-recent first. Each entry stores the content BEFORE that
// change. To diff "what changed at entry N", compare entry N's
// previous_content (= state before that change) with entry N-1's
// previous_content (= state after that change, which was state before
// the next change). For the most recent change, compare against the
// model's CURRENT content.
const sections: string[] = [];
for (let i = 0; i < history.length; i++) {
const before = history[i].previous_content ?? "";
const after = i === 0 ? (model.content ?? "") : (history[i - 1].previous_content ?? "");
const diff = diffMentalModelContent(before, after);
sections.push(`### ${history[i].changed_at}\n\n\`\`\`diff\n${diff}\n\`\`\``);
}
showMarkdownPanel(this.ctx, `History — ${model.name}`, sections.join("\n\n"));
} catch (error) {
this.ctx.showError(`mm history failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
async #mmSeed(state: HindsightSessionState): Promise<void> {
try {
const config = loadHindsightConfig(this.ctx.settings);
const seeds = resolveSeedsForScope(
{
bankId: state.bankId,
retainTags: state.retainTags,
recallTags: state.recallTags,
recallTagsMatch: state.recallTagsMatch,
},
config.scoping,
);
if (seeds.length === 0) {
this.ctx.showStatus(`No built-in seeds apply to scoping=${config.scoping}.`);
return;
}
const list = await state.client.listMentalModels(state.bankId, { detail: "metadata" });
const existing = new Set((list.items ?? []).map(m => m.id));
let created = 0;
let skipped = 0;
for (const seed of seeds) {
if (existing.has(seed.id)) {
skipped++;
continue;
}
try {
await state.client.createMentalModel(state.bankId, seed.name, seed.sourceQuery, {
id: seed.id,
tags: seed.tags.length > 0 ? seed.tags : undefined,
maxTokens: seed.maxTokens,
trigger: seed.trigger,
});
created++;
} catch (error) {
this.ctx.showWarning(
`Seed failed for ${seed.id}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
this.ctx.showStatus(`Seeded ${created} new mental model(s); ${skipped} already present.`);
} catch (error) {
this.ctx.showError(`mm seed failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
async #mmReload(sessionId: string): Promise<void> {
const ok = await reloadMentalModelsForSession(sessionId);
if (ok) {
this.ctx.showStatus("Mental-model cache reloaded.");
} else {
this.ctx.showError("Reload failed (Hindsight backend not active or mental models disabled).");
}
}
async #mmDelete(state: HindsightSessionState, id: string): Promise<void> {
try {
const removed = await state.client.deleteMentalModel(state.bankId, id);
if (!removed) {
this.ctx.showError(`Mental model not found: ${id}`);
return;
}
// Drop the cached snippet so the closing tag does not silently keep
// stale content in the system prompt until the next agent_end TTL.
await reloadMentalModelsForSession(state.session.sessionId ?? "");
this.ctx.showStatus(`Deleted mental model ${id} from bank ${state.bankId}.`);
} catch (error) {
this.ctx.showError(`mm delete failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
async #runNewSessionFlow(options?: NewSessionOptions, label: string = "New session started"): Promise<void> {