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:
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user