feat(memory): the model asks before remembering — remember holds a suggestion the user accepts, edits or dismisses; memory.proactive is ask, auto or off, and YOLO saves directly; the open goal panel joins the composer

This commit is contained in:
yhh committed 2026-11-06 08:17:42 -08:00
1 parent b0786368ca
commit 813af4958e
37 files changed
+840 -27

No files matched your search

+7
View File
@@ -354,6 +354,13 @@ export const ACTIONS: UIAction[] = [
{ id: "memory.save", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "memory.restore", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "memory.forget", kind: "destructive", target: "entity", proof: "interaction" },
{ id: "memory.proactive", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "memory.suggest-text", kind: "view", target: "entity", proof: "interaction" },
{ id: "memory.suggest-accept", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "memory.suggest-save", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "memory.suggest-edit", kind: "view", target: "entity", proof: "interaction" },
{ id: "memory.suggest-cancel", kind: "view", target: "entity", proof: "interaction" },
{ id: "memory.suggest-dismiss", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "memory.confirm", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "memory.filter", kind: "view", target: "optional", proof: "interaction" },
{ id: "memory.search", kind: "view", target: "none", proof: "interaction" },
@@ -18,5 +18,6 @@
@import "./feedback.css" layer(design);
@import "./seltools.css" layer(design);
@import "./onboarding.css" layer(design);
@import "./suggest.css" layer(design);
@import "./controls.css" layer(design);
@import "./motion.css" layer(design);
+2 -1
View File
@@ -63,7 +63,8 @@
border: 1px solid var(--border); border-bottom: 0; border-radius: var(--r-md) var(--r-md) 0 0;
background: var(--page); box-shadow: none;
}
.compose > .studio-todo[data-open] { background: var(--raised); box-shadow: var(--shadow-float); }
.compose > .studio-todo[data-open] { inset: auto 0 100%; border-color: transparent; border-radius: var(--r-lg) var(--r-lg) 0 0; background: var(--compose-surface, var(--raised)); box-shadow: 0 -3px 12px rgb(25 30 25 / .07); }
.compose > .studio-todo[data-open] > .studio-todo-body { border-bottom: 1px solid var(--hair); }
.compose > .studio-todo[data-open] > .studio-todo-sum { display: none; }
.compose > .studio-todo .studio-todo-sum {
height: 34px; min-height: 34px; gap: 8px; padding: 0 8px 0 12px; border-radius: inherit;
@@ -0,0 +1,22 @@
.msug { display: flex; flex-direction: column; gap: 6px; padding: 2px 4px; }
.msug-item { display: flex; flex-direction: column; gap: 4px; }
.msug-line { display: flex; align-items: center; gap: 8px; min-width: 0; font: var(--w-med) var(--fs-ui) var(--ui); color: var(--muted); }
.msug-line > .studio-icon { flex: none; width: 14px; height: 14px; }
.msug-line > b { min-width: 0; overflow: hidden; color: var(--text); font-weight: var(--w-semi); text-overflow: ellipsis; white-space: nowrap; }
.msug-meta { flex: none; color: var(--faint); font-size: var(--fs-meta); }
.msug-acts { display: flex; align-items: center; gap: 6px; margin-inline-start: 22px; }
.msug-acts > button { height: 28px; padding: 0 14px; border: 1px solid var(--border); border-radius: 7px; background: none; color: var(--text); font: var(--w-med) var(--fs-ui) var(--ui); cursor: pointer; }
.msug-acts > button[data-primary] { border-color: transparent; background: var(--accent); color: var(--on-accent, #fff); }
.msug-acts > button[data-action="memory.suggest-dismiss"] { border-color: transparent; color: var(--muted); }
.msug-acts > button:disabled { opacity: .5; cursor: default; }
.msug-edit { display: flex; flex-direction: column; gap: 6px; margin-inline-start: 22px; }
.msug-edit > textarea { width: 100%; resize: vertical; padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--raised); color: var(--text); font: var(--w-reg) var(--fs-ui)/1.5 var(--ui); }
.msug-edit .msug-acts { margin-inline-start: 0; }
.memproactive { display: flex; align-items: center; gap: 16px; padding: 10px 0 14px; }
.memproactive > div:first-child { display: flex; flex: 1; flex-direction: column; min-width: 0; }
.memproactive b { color: var(--text); font: var(--w-semi) var(--fs-ui) var(--ui); }
.memproactive span { color: var(--faint); font: var(--w-med) var(--fs-meta)/1.5 var(--ui); }
.memproactive-err { color: var(--danger, #c0392b) !important; }
.memproactive-seg { display: inline-flex; flex: none; overflow: hidden; border: 1px solid var(--border); border-radius: 8px; }
.memproactive-seg > button { padding: 6px 12px; border: 0; background: none; color: var(--muted); font: var(--w-med) var(--fs-meta) var(--ui); cursor: pointer; }
.memproactive-seg > button[data-on] { background: var(--overlay); color: var(--text); }
@@ -446,6 +446,11 @@ export const CHAT: Messages = {
"chat.submit_plan": { zh: "提交计划", en: "Submit plan" },
"chat.submitted": { zh: "已提交", en: "Submitted" },
"chat.subtask": { zh: "子任务", en: "Subtask" },
"chat.suggest_dismiss": { zh: "不用", en: "No thanks" },
"chat.suggest_edit": { zh: "改一下…", en: "Edit…" },
"chat.suggest_lead": { zh: "要记住吗", en: "Remember this?" },
"chat.suggest_remember": { zh: "记住", en: "Remember" },
"chat.suggest_text": { zh: "要记的内容", en: "What to remember" },
"chat.switch_to_full_access": { zh: "切换全部放行…", en: "Switch to full access…" },
"chat.switching": { zh: "正在切换…", en: "Switching…" },
"chat.take_it_back": { zh: "撤回", en: "Take it back" },
@@ -44,6 +44,11 @@ export const MEMORY: Messages = {
"memory.nothing_is_waiting_for_you": { zh: "没有等你确认的记忆。", en: "Nothing is waiting for you to confirm." },
"memory.one_line_summary": { zh: "简要说明", en: "One-line summary" },
"memory.only_the_current_revision_exists": { zh: "仅有当前版本。", en: "Only the current revision exists." },
"memory.proactive_ask": { zh: "先问我", en: "Ask me" },
"memory.proactive_auto": { zh: "直接记", en: "Just remember" },
"memory.proactive_hint": { zh: "看到值得长期记的事时怎么办", en: "What to do when something is worth keeping" },
"memory.proactive_off": { zh: "不记", en: "Never" },
"memory.proactive_title": { zh: "模型主动记事", en: "Model remembers on its own" },
"memory.reading_older_revisions": { zh: "正在读取历史版本…", en: "Reading older revisions…" },
"memory.recalled_n_days_ago": { zh: "{n} 天前被想起", en: "Recalled {n} days ago" },
"memory.recalled_today": { zh: "今天被想起", en: "Recalled today" },
+24
View File
@@ -38,6 +38,30 @@ export interface UnreadableMemory {
error: string;
}
// What the model may do on its own with memory: nothing, ask first, or write.
export type MemoryProactive = "off" | "ask" | "auto";
// A fact the model proposed and the user has not decided on.
export interface MemorySuggestion {
id: string;
name: string;
title?: string;
description: string;
body: string;
type?: string;
scope?: string;
}
// What the model may propose to keep, and the user's say over it.
export interface MemoryHoldPort {
memoryProactive(): Promise<MemoryProactive>;
setMemoryProactive(mode: MemoryProactive): Promise<void>;
memorySuggestions(): Promise<MemorySuggestion[]>;
/** Saves a held suggestion; fields given replace the model's wording. */
acceptMemorySuggestion(id: string, edit?: { title?: string; description?: string; body?: string }): Promise<void>;
dismissMemorySuggestion(id: string): Promise<void>;
}
export interface MemoryCatalog {
memories: MemoryEntry[];
recallQuery: string;
@@ -0,0 +1,22 @@
import type { MemoryHoldPort, MemoryProactive, MemorySuggestion } from "./memory";
import { MockTerminal } from "./mock_terminal";
export class MockMemoryHold extends MockTerminal implements MemoryHoldPort {
proactive: MemoryProactive = "ask";
suggestions: MemorySuggestion[] = [];
async memoryProactive() {
return this.proactive;
}
async setMemoryProactive(mode: MemoryProactive) {
this.proactive = mode;
}
async memorySuggestions() {
return this.suggestions;
}
async acceptMemorySuggestion(id: string) {
this.suggestions = this.suggestions.filter((s) => s.id !== id);
}
async dismissMemorySuggestion(id: string) {
this.suggestions = this.suggestions.filter((s) => s.id !== id);
}
}
@@ -2,7 +2,7 @@ import { reason } from "../i18n/kernel";
import { HttpError } from "./http_error";
import type { KeptText } from "./attachment";
import type { ChangeDiff, GitHistory, StageRequest, WorkspaceChange, WorkspaceChanges, WorkspaceGit, WorkspaceBranches } from "./workspace";
import { MockTerminal } from "./mock_terminal";
import { MockMemoryHold } from "./mock_memory_hold";
export function mockWorkspaces(root: string): import("./workspace").WorkspaceInfo {
return { current: root, canSwitch: true, canIsolate: true, recents: [
@@ -19,7 +19,7 @@ export function mockWorktreeSources(root: string, branch: string): import("./wor
] };
}
export class MockWorkspace extends MockTerminal {
export class MockWorkspace extends MockMemoryHold {
async workspaceFile(path: string, _root?: string): Promise<import("./workspace").WorkspaceFile> { return { path, content: `// ${path}\n`, revision: "fixture" }; }
async workspacePreviewInfo(ref: import("./workspace").FileRef): Promise<import("./workspace").PreviewInfo> {
const file = await this.workspaceFile(ref.path, ref.root);
+2 -2
View File
@@ -18,7 +18,7 @@ export type * from "./backup";
export type { AdjudicationEntry, AdjudicationState, Adjudications } from "./adjudication";
import type { HookCatalog, HookDryRun, HookEntry, HookEventInfo, HookSource } from "./hook";
import type { CapabilityScope, McpCatalog, McpDraft, McpDraftServer, McpEntry, McpInstallResult, McpInstallScope, McpLoad, McpRisk, McpTool, ScopeLayer } from "./mcp";
import type { MemoryCatalog, MemoryEdit, MemoryEntry, UnreadableMemory } from "./memory";
import type { MemoryHoldPort, MemoryCatalog, MemoryEdit, MemoryEntry, UnreadableMemory } from "./memory";
import type { UploadUsagePort, UsageQuery, UsageReport } from "./usage";
export { DEFAULT_USAGE_DAYS } from "./usage";
export type { MemoryEdit } from "./memory";
@@ -181,7 +181,7 @@ export interface TrayPrefs {
closeToTray: boolean;
}
export interface AgentPort extends WorkspacePreviewPort, SessionTailPort, UploadUsagePort, TerminalPort {
export interface AgentPort extends MemoryHoldPort, WorkspacePreviewPort, SessionTailPort, UploadUsagePort, TerminalPort {
providerSetup(): Promise<ProviderSetup | null>;
saveProviderKey(apiKey: string): Promise<void>;
// The models that answer the job asked about; "chat" when none is named.
+18
View File
@@ -1,6 +1,7 @@
import type { SurfaceActionRequest, SurfaceActionRecord } from "./surface";
import { PLAN_ACTIONS, type PlanAction } from "./session";
import type { AccountState, AgentPort, ChipCall, CompactionSettings, Completion, DeviceGrant, UpdateProgress, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindUndo, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, MemoryEntry, UsageQuery, UsageReport, UploadReport, Queue, Queued, NotifyPrefs, TrayPrefs, WorkspaceInfo } from "./port";
import type { MemoryProactive, MemorySuggestion } from "./memory";
import { HttpError, type CommitProposal, type CommitRequest, type CommitResult, type WorkspaceFile, type WorkspaceFiles } from "./port";
import { SseTerminal } from "./sse_terminal";
import type { StoragePlan, StorageQuery, StorageState } from "./storage";
@@ -105,6 +106,23 @@ export class SsePort extends SseTerminal implements AgentPort {
confirmMemory(name: string) {
return this.post("/memory/confirm", { name });
}
async memoryProactive() {
const r = await this.get<{ mode: MemoryProactive }>("/memory/proactive");
return r.mode;
}
async setMemoryProactive(mode: MemoryProactive) {
await this.post("/memory/proactive", { mode });
}
async memorySuggestions() {
const r = await this.get<{ suggestions: MemorySuggestion[] }>("/memory/suggestions");
return r.suggestions ?? [];
}
acceptMemorySuggestion(id: string, edit?: { title?: string; description?: string; body?: string }) {
return this.post("/memory/suggestions/accept", { id, ...edit });
}
dismissMemorySuggestion(id: string) {
return this.post("/memory/suggestions/dismiss", { id });
}
async memoryRevisions(name: string) {
const r = await this.get<{ revisions: MemoryEntry[] }>("/memory/revisions?name=" + encodeURIComponent(name));
return r.revisions ?? [];
+2
View File
@@ -1,3 +1,4 @@
import { MemoryProactiveRow } from "./MemoryProactiveRow";
import { useEffect, useMemo, useRef, useState } from "react";
import { t } from "../i18n";
import { tx } from "../i18n/rich";
@@ -184,6 +185,7 @@ function MemoryPanel({ port }: { port: AgentPort }) {
return (
<div className="mem" data-view={filter.kind}>
<MemoryProactiveRow port={port} />
{unreadableNotice}
{query && (
<p className="recall">
@@ -0,0 +1,40 @@
import { useEffect, useState } from "react";
import { t } from "../i18n";
import { reason } from "../i18n/kernel";
import type { AgentPort } from "../port/port";
import type { MemoryProactive } from "../port/memory";
const MODES: [MemoryProactive, string][] = [["off", "memory.proactive_off"], ["ask", "memory.proactive_ask"], ["auto", "memory.proactive_auto"]];
// One line at the top of the memory page: what the model does when it sees
// something worth keeping for good.
export function MemoryProactiveRow({ port }: { port: AgentPort }) {
const [mode, setMode] = useState<MemoryProactive | null>(null);
const [error, setError] = useState("");
useEffect(() => {
let live = true;
port.memoryProactive().then((m) => { if (live) setMode(m); }).catch((e) => { if (live) setError(reason(e)); });
return () => { live = false; };
}, [port]);
const pick = (next: MemoryProactive) => {
const was = mode;
setMode(next);
setError("");
port.setMemoryProactive(next).catch((e) => { setMode(was); setError(reason(e)); });
};
if (mode === null && !error) return null;
return (
<div className="memproactive">
<div>
<b>{t("memory.proactive_title")}</b>
<span>{t("memory.proactive_hint")}</span>
{error && <span role="alert" className="memproactive-err">{error}</span>}
</div>
<div className="memproactive-seg" role="radiogroup" aria-label={t("memory.proactive_title")}>
{MODES.map(([id, label]) => (
<button key={id} type="button" role="radio" aria-checked={mode === id} data-on={mode === id ? "" : undefined} data-action="memory.proactive" data-target={id} onClick={() => pick(id)}>{t(label)}</button>
))}
</div>
</div>
);
}
@@ -0,0 +1,72 @@
import { useCallback, useEffect, useState } from "react";
import { t } from "../i18n";
import { reason } from "../i18n/kernel";
import type { AgentPort } from "../port/port";
import type { MemorySuggestion } from "../port/memory";
import { StudioIcon } from "./StudioIcon";
const SCOPE: Record<string, string> = { project: "chat.remember_scope_project", global: "chat.remember_scope_global" };
// The facts the model proposed to keep, one line each above the composer.
// The kernel holds them until the user decides; the list is read again each
// time a turn ends, which is the only moment a new one can have appeared.
export function MemorySuggestRow({ port, running, onError }: { port: AgentPort; running: boolean; onError: (e: unknown) => void }) {
const [items, setItems] = useState<MemorySuggestion[]>([]);
const [editing, setEditing] = useState("");
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState("");
useEffect(() => {
if (running) return;
let live = true;
port.memorySuggestions().then((got) => { if (live) setItems(got); }).catch(() => {});
return () => { live = false; };
}, [port, running]);
const settle = useCallback(async (id: string, act: () => Promise<void>) => {
setBusy(id);
try {
await act();
setItems((was) => was.filter((s) => s.id !== id));
setEditing("");
} catch (e) {
onError(new Error(reason(e)));
} finally {
setBusy("");
}
}, [onError]);
if (items.length === 0) return null;
return (
<div className="msug">
{items.map((s) => (
<div className="msug-item" key={s.id}>
<div className="msug-line">
<StudioIcon name="sparkle" />
<span className="msug-lead">{t("chat.suggest_lead")}</span>
<b>{s.title || s.description}</b>
<span className="msug-meta">{`· ${t(SCOPE[s.scope ?? "project"] ?? "chat.remember_scope_project")}`}</span>
</div>
{editing === s.id ? (
<div className="msug-edit">
<textarea data-action="memory.suggest-text" aria-label={t("chat.suggest_text")} value={draft} onChange={(e) => setDraft(e.target.value)} rows={3} />
<div className="msug-acts">
<button type="button" data-primary data-action="memory.suggest-save" disabled={busy === s.id || !draft.trim()}
onClick={() => void settle(s.id, () => port.acceptMemorySuggestion(s.id, { body: draft }))}>{t("chat.suggest_remember")}</button>
<button type="button" data-action="memory.suggest-cancel" onClick={() => setEditing("")}>{t("common.cancel")}</button>
</div>
</div>
) : (
<div className="msug-acts">
<button type="button" data-primary data-action="memory.suggest-accept" disabled={busy === s.id}
onClick={() => void settle(s.id, () => port.acceptMemorySuggestion(s.id))}>{t("chat.suggest_remember")}</button>
<button type="button" data-action="memory.suggest-edit" disabled={busy === s.id} onClick={() => { setDraft(s.body); setEditing(s.id); }}>{t("chat.suggest_edit")}</button>
<button type="button" data-action="memory.suggest-dismiss" disabled={busy === s.id}
onClick={() => void settle(s.id, () => port.dismissMemorySuggestion(s.id))}>{t("chat.suggest_dismiss")}</button>
</div>
)}
</div>
))}
</div>
);
}
+2
View File
@@ -64,6 +64,7 @@ import { speedOf } from "./speed";
import { RuntimeBar } from "./RuntimeBar";
import { PeerHeldBar } from "./PeerHeldBar";
import { PostureNote, postureNote } from "./PostureNote";
import { MemorySuggestRow } from "./MemorySuggestRow";
import { SidePanel } from "./SidePanel";
import { cancelJob } from "./jobcancel";
import { JobsLiveContext } from "./JobRunView";
@@ -643,6 +644,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
<PeerHeldBar port={port} project={rt.host ? undefined : { path: rt.root, name: rt.name }} />
{connect && <ConnectNote offer={connect} />}
{!connect && <PostureNote port={port} status={status} onChanged={refreshStatus} />}
{!connect && !rt.readOnly && <MemorySuggestRow port={port} running={running} onError={notify} />}
{/* Views the user (or the extension) put next to the composer. They sit
above it rather than inside it: the input box is the one thing an
extension must never be able to crowd out. */}
@@ -35,6 +35,7 @@ const PATHS = {
spark: "m12 3 2.5 6.5L21 12l-6.5 2.5L12 21l-2.5-6.5L3 12l6.5-2.5L12 3z",
agent: "M11 3l1.7 4.3L17 9l-4.3 1.7L11 15l-1.7-4.3L5 9l4.3-1.7L11 3zM19 14l.8 2.2L22 17l-2.2.8L19 20l-.8-2.2L16 17l2.2-.8L19 14zM4 3l.5 1.5L6 5l-1.5.5L4 7l-.5-1.5L2 5l1.5-.5L4 3z",
refresh: "M20 7a9 9 0 1 0 1 8M20 3v5h-5",
sparkle: "M12 3l2.7 6 6.3.6-4.8 4.2 1.5 6.3L12 16.8 6.3 20.1l1.5-6.3L3 9.6l6.3-.6z",
target: "M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0zM15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0z",
terminal: "M3 5h18v14H3V5zM7.5 10l3 2.5-3 2.5M12.5 15H16",
eraser: "M4.5 19.5h15M7.5 15l9-9M12 6l4.5 4.5",
@@ -0,0 +1,78 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { Memory } from "./Memory";
import { MemorySuggestRow } from "./MemorySuggestRow";
import { MockPort } from "../port/mock";
import type { AgentPort } from "../port/port";
afterEach(cleanup);
const held = { id: "s1", name: "gbk", description: "导出文件一律 GBK 解码", body: "Exports are GBK.", scope: "project" };
function setup() {
const mock = new MockPort();
mock.suggestions = [held];
return { mock, port: mock as unknown as AgentPort };
}
it("shows a held suggestion only once the turn has ended", async () => {
const { port } = setup();
const { rerender } = render(<MemorySuggestRow port={port} running onError={vi.fn()} />);
expect(screen.queryByText("导出文件一律 GBK 解码")).toBeNull();
rerender(<MemorySuggestRow port={port} running={false} onError={vi.fn()} />);
expect(await screen.findByText("导出文件一律 GBK 解码")).toBeTruthy();
});
it("remember accepts and removes the row", async () => {
const { mock, port } = setup();
const accept = vi.spyOn(mock, "acceptMemorySuggestion");
render(<MemorySuggestRow port={port} running={false} onError={vi.fn()} />);
await userEvent.click(await screen.findByRole("button", { name: "记住" }));
await waitFor(() => expect(screen.queryByText("导出文件一律 GBK 解码")).toBeNull());
expect(accept).toHaveBeenCalledWith("s1");
});
it("edit sends the changed text", async () => {
const { mock, port } = setup();
const accept = vi.spyOn(mock, "acceptMemorySuggestion");
render(<MemorySuggestRow port={port} running={false} onError={vi.fn()} />);
await userEvent.click(await screen.findByRole("button", { name: "改一下…" }));
const box = screen.getByRole("textbox");
await userEvent.clear(box);
await userEvent.type(box, "GBK always");
await userEvent.click(screen.getByRole("button", { name: "记住" }));
await waitFor(() => expect(accept).toHaveBeenCalledWith("s1", { body: "GBK always" }));
});
it("dismiss drops the row without saving", async () => {
const { mock, port } = setup();
const accept = vi.spyOn(mock, "acceptMemorySuggestion");
render(<MemorySuggestRow port={port} running={false} onError={vi.fn()} />);
await userEvent.click(await screen.findByRole("button", { name: "不用" }));
await waitFor(() => expect(screen.queryByText("导出文件一律 GBK 解码")).toBeNull());
expect(accept).not.toHaveBeenCalled();
});
it("a failed save keeps the row and reports", async () => {
const { mock, port } = setup();
vi.spyOn(mock, "acceptMemorySuggestion").mockRejectedValue(new Error("disk"));
const onError = vi.fn();
render(<MemorySuggestRow port={port} running={false} onError={onError} />);
await userEvent.click(await screen.findByRole("button", { name: "记住" }));
await waitFor(() => expect(onError).toHaveBeenCalled());
expect(screen.getByText("导出文件一律 GBK 解码")).toBeTruthy();
});
it("the memory page's tri-state saves the chosen mode", async () => {
const { mock, port } = setup();
const save = vi.spyOn(mock, "setMemoryProactive");
render(<Memory port={port} />);
const ask = await screen.findByRole("radio", { name: "先问我" });
expect(ask.getAttribute("aria-checked")).toBe("true");
await userEvent.click(screen.getByRole("radio", { name: "直接记" }));
expect(save).toHaveBeenCalledWith("auto");
expect(screen.getByRole("radio", { name: "直接记" }).getAttribute("aria-checked")).toBe("true");
});
+1 -1
View File
@@ -423,7 +423,7 @@ func (b *builder) wireTools() error {
t.runner, t.skillRun = t.roles.delegation(delegationInputs{opts: opts, sub: t.sub, exec: b.execProv, entry: b.model.entry,
modelName: b.model.name, root: root, maxSteps: t.maxSteps, delivery: b.model.delivery, store: subagentStore,
session: b.session, bashEnforced: env.bash.Enforce, extraRoots: b.workspaceExtraRoots(), childTools: childViewImage(cfg, env, root)})
registerSessionTools(t.reg, opts.Ablation, b.roots, b.session.dir, b.prompt.memory.Store)
registerSessionTools(t.reg, opts.Ablation, b.roots, b.session.dir, b.prompt.memory.Store, b.cfg.MemoryProactive())
if opts.NativeSurfaces {
t.reg.Add(agent.NewRenderSurfaceTool())
}
+1
View File
@@ -33,6 +33,7 @@ func buildMemoryAssembly(opts Options, cfg *config.Config, root, sysPrompt strin
PinnedBudgetChars: cfg.Memory.PinnedBudgetChars,
RecallLimit: cfg.Memory.RecallLimit,
RecallMaxChars: cfg.Memory.RecallMaxChars,
Proactive: cfg.MemoryProactive(),
})
return memoryAssembly{
set: set,
+4 -2
View File
@@ -84,7 +84,7 @@ func hookRuntime(shell sandbox.Shell) hook.RuntimeOptions {
// registerSessionTools adds the product-docs, session and memory tools every
// role carries. The retrieval ablation drops only the BM25-backed history and
// recall, so a lost solve is attributable to retrieval, not a missing reader.
func registerSessionTools(reg *tool.Registry, set ablation.Set, roots config.Roots, sessionDir string, store memory.Store) {
func registerSessionTools(reg *tool.Registry, set ablation.Set, roots config.Roots, sessionDir string, store memory.Store, proactive string) {
reg.Add(productdocs.NewTool())
retrievalOff := set.Off(ablation.Retrieval)
if !retrievalOff {
@@ -95,7 +95,9 @@ func registerSessionTools(reg *tool.Registry, set ablation.Set, roots config.Roo
if !retrievalOff {
reg.Add(memory.NewRecallTool(store))
}
reg.Add(memory.NewRememberTool(store))
if proactive != "off" {
reg.Add(memory.NewRememberTool(store))
}
reg.Add(memory.NewForgetTool(store))
addTurnExitTools(reg)
}
-16
View File
@@ -782,22 +782,6 @@ func (c *Config) NetworkProxyMode() string {
// while keeping them manageable. DisableImplicitInvocation keeps skills
// discoverable to the host for explicit /skill use and management, but hides
// their index and model-facing invocation tools.
// MemoryConfig bounds what saved memory costs, and every axis ships off. The
// prefix index and pinned bodies are paid for once per session and automatic
// recall rides each turn's tail; how much of that is worth paying is the
// user's call, so unset means unbounded rather than a number picked here.
// `/memory` reports what the current store actually costs.
type MemoryConfig struct {
// PinnedBudgetChars caps total pinned-body runes at write time. 0 = off.
PinnedBudgetChars int `toml:"pinned_budget_chars"`
// RecallLimit is how many facts automatic recall may inject per turn.
// 0 selects the default (4).
RecallLimit int `toml:"recall_limit"`
// RecallMaxChars bounds that injection's total size. 0 selects the
// default (2400). Each hit's snippet gets an equal share.
RecallMaxChars int `toml:"recall_max_chars"`
}
type SkillsConfig struct {
Paths []string `toml:"paths"`
ExcludedPaths []string `toml:"excluded_paths"`
+22
View File
@@ -0,0 +1,22 @@
package config
import (
"fmt"
"strings"
)
// SetMemoryProactive sets whether the model asks before remembering, writes on
// its own, or is not offered the remember tool.
func (c *Config) SetMemoryProactive(mode string) error {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "ask", "":
c.Memory.Proactive = "ask"
case "auto":
c.Memory.Proactive = "auto"
case "off":
c.Memory.Proactive = "off"
default:
return fmt.Errorf("memory.proactive %q: must be ask|auto|off", mode)
}
return nil
}
+35
View File
@@ -0,0 +1,35 @@
package config
import "strings"
// MemoryConfig bounds what saved memory costs, and every axis ships off. The
// prefix index and pinned bodies are paid for once per session and automatic
// recall rides each turn's tail; how much of that is worth paying is the
// user's call, so unset means unbounded rather than a number picked here.
// `/memory` reports what the current store actually costs.
type MemoryConfig struct {
// PinnedBudgetChars caps total pinned-body runes at write time. 0 = off.
PinnedBudgetChars int `toml:"pinned_budget_chars"`
// RecallLimit is how many facts automatic recall may inject per turn.
// 0 selects the default (4).
RecallLimit int `toml:"recall_limit"`
// RecallMaxChars bounds that injection's total size. 0 selects the
// default (2400). Each hit's snippet gets an equal share.
RecallMaxChars int `toml:"recall_max_chars"`
// Proactive says what the model may do on its own: "ask" holds what it
// wants to keep for the user to accept, "auto" lets it write, "off" does
// not offer it the remember tool. Empty is "ask".
Proactive string `toml:"proactive"`
}
// MemoryProactive normalizes memory.proactive to ask, auto or off.
func (c *Config) MemoryProactive() string {
switch strings.ToLower(strings.TrimSpace(c.Memory.Proactive)) {
case "auto":
return "auto"
case "off":
return "off"
default:
return "ask"
}
}
@@ -54,6 +54,11 @@ func renderMemoryConfig(b *strings.Builder, m MemoryConfig) {
} else {
b.WriteString("# recall_limit = 4 # facts automatic recall may inject per turn\n")
}
if strings.TrimSpace(m.Proactive) != "" {
fmt.Fprintf(b, "proactive = %q # ask|auto|off; whether the model asks before remembering, writes on its own, or is not offered the remember tool\n", m.Proactive)
} else {
b.WriteString("# proactive = \"ask\" # ask|auto|off; whether the model asks before remembering\n")
}
if m.RecallMaxChars > 0 {
fmt.Fprintf(b, "recall_max_chars = %d # total size of that injection\n\n", m.RecallMaxChars)
} else {
@@ -27,6 +27,7 @@ var constraints = map[string]Entry{
"billing.display_currency": {Via: viaString((*config.Config).SetDisplayCurrency)},
"desktop.currency": {Via: viaString((*config.Config).SetDesktopCurrency)},
"desktop.reasoning_display_mode": {Via: viaString((*config.Config).SetDesktopReasoningDisplayMode)},
"memory.proactive": {Via: viaString((*config.Config).SetMemoryProactive)},
"agent.auto_plan": {Via: viaString((*config.Config).SetAutoPlan)},
"agent.default_mode": {Via: viaString((*config.Config).SetDefaultMode)},
"agent.web_search_model": {Via: viaString((*config.Config).SetWebSearchModel)},
+1
View File
@@ -221,6 +221,7 @@ var table = []Entry{
{ID: "skills.suppress_warnings", Class: Preference, Effect: OnOpen},
{ID: "skills.max_depth", Class: Preference, Effect: OnOpen},
{ID: "memory.pinned_budget_chars", Class: Preference, Effect: OnOpen},
{ID: "memory.proactive", Class: Preference, Effect: OnOpen},
{ID: "memory.recall_limit", Class: Preference, Effect: OnOpen},
{ID: "memory.recall_max_chars", Class: Preference, Effect: OnOpen},
{ID: "statusline.command", Class: Guarded, Effect: OnOpen},
+5
View File
@@ -21,6 +21,11 @@ func (s *Server) registerMachineSettingsRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /memory/forget", s.forgetMemory)
mux.HandleFunc("POST /memory/save", s.saveMemory)
mux.HandleFunc("POST /memory/confirm", s.confirmMemory)
mux.HandleFunc("GET /memory/proactive", s.memoryProactive)
mux.HandleFunc("POST /memory/proactive", s.saveMemoryProactive)
mux.HandleFunc("GET /memory/suggestions", s.memorySuggestions)
mux.HandleFunc("POST /memory/suggestions/accept", s.acceptMemorySuggestion)
mux.HandleFunc("POST /memory/suggestions/dismiss", s.dismissMemorySuggestion)
mux.HandleFunc("GET /memory/revisions", s.memoryRevisions)
mux.HandleFunc("POST /memory/restore", s.restoreMemory)
mux.HandleFunc("GET /network", s.network)
+133
View File
@@ -0,0 +1,133 @@
package serve
import (
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"reasonix/internal/session/control"
"reasonix/internal/state/memory"
)
type memorySuggestionEntry struct {
ID string `json:"id"`
Name string `json:"name"`
Title string `json:"title,omitempty"`
Description string `json:"description"`
Body string `json:"body"`
Type string `json:"type,omitempty"`
Scope string `json:"scope,omitempty"`
At string `json:"at"`
}
// memorySuggestions lists the facts the model proposed and the user has not
// decided on. The panel fetches it when a turn ends.
func (s *Server) memorySuggestions(w http.ResponseWriter, r *http.Request) {
held := s.ctl().MemorySuggestions()
out := make([]memorySuggestionEntry, 0, len(held))
for _, h := range held {
out = append(out, memorySuggestionEntry{
ID: h.ID, Name: h.Memory.Name, Title: h.Memory.Title,
Description: h.Memory.Description, Body: h.Memory.Body,
Type: string(h.Memory.Type), Scope: string(h.Memory.Scope),
At: h.At.UTC().Format(time.RFC3339),
})
}
writeJSON(w, map[string]any{"suggestions": out})
}
// acceptMemorySuggestion saves a held fact. Fields present in the body replace
// the model's wording, which is the "edit first" path.
func (s *Server) acceptMemorySuggestion(w http.ResponseWriter, r *http.Request) {
var body struct {
ID string `json:"id"`
Title *string `json:"title"`
Description *string `json:"description"`
Body *string `json:"body"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.ID) == "" {
missingField(w, "id")
return
}
id := strings.TrimSpace(body.ID)
var edited *memory.Memory
if body.Title != nil || body.Description != nil || body.Body != nil {
for _, h := range s.ctl().MemorySuggestions() {
if h.ID != id {
continue
}
m := h.Memory
if body.Title != nil {
m.Title = strings.TrimSpace(*body.Title)
}
if body.Description != nil {
m.Description = strings.TrimSpace(*body.Description)
}
if body.Body != nil {
m.Body = *body.Body
}
if m.Description == "" || strings.TrimSpace(m.Body) == "" {
badValue(w, "description and body", "non-empty")
return
}
edited = &m
}
}
path, err := s.ctl().AcceptMemorySuggestion(id, edited)
if err != nil {
if errors.Is(err, control.ErrNoSuchSuggestion) {
notFound(w, "memory suggestion", id)
return
}
saveFailed(w, http.StatusInternalServerError, "memory.save_failed", err)
return
}
writeJSON(w, map[string]any{"path": path})
}
func (s *Server) dismissMemorySuggestion(w http.ResponseWriter, r *http.Request) {
var body struct {
ID string `json:"id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.ID) == "" {
missingField(w, "id")
return
}
id := strings.TrimSpace(body.ID)
if err := s.ctl().DismissMemorySuggestion(id); err != nil {
if errors.Is(err, control.ErrNoSuchSuggestion) {
notFound(w, "memory suggestion", id)
return
}
writeErr(w, http.StatusInternalServerError, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) memoryProactive(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, s.ctl().MemoryProactiveSettings())
}
func (s *Server) saveMemoryProactive(w http.ResponseWriter, r *http.Request) {
var body struct {
Mode string `json:"mode"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10)).Decode(&body); err != nil {
badBody(w)
return
}
switch body.Mode {
case "ask", "auto", "off":
default:
badValue(w, "mode", "ask, auto, off")
return
}
if err := s.ctl().SaveMemoryProactive(body.Mode); err != nil {
saveFailed(w, http.StatusInternalServerError, "memory.proactive_save_failed", err)
return
}
writeJSON(w, s.ctl().MemoryProactiveSettings())
}
+5 -1
View File
@@ -49,6 +49,9 @@ type memoryManager struct {
// made the prefix diverge per project, and they need same-session freshness.
instructions projectionDebt
// book holds the facts the model proposed and the user has not decided on.
book suggestionBook
// writeMu serializes memory writes so each write+reload+swap is atomic with
// respect to the others. Taken OFF mu, so a read (current/drainPending) never
// blocks behind a write's disk I/O.
@@ -146,7 +149,8 @@ func (m *memoryManager) lastRecallResult() memory.RecallResult {
}
func newMemoryManager(set *memory.Set) memoryManager {
return memoryManager{set: set}
proactive := set.LoadOptions().Proactive
return memoryManager{set: set, book: suggestionBook{ask: proactive == "ask"}}
}
// memoryRecallAudit strips a recall decision to its content-free fingerprint
@@ -531,6 +531,9 @@ func (c *Controller) rememberProjectNote(note string) {
}
func (c *Controller) allowLowRiskRemember(args json.RawMessage) bool {
if c.asksBeforeRemembering() && !rememberTargetsExisting(args) {
return true
}
mem := c.Memory()
if mem != nil {
if assessment := memory.AssessRememberWrite(mem.Store, args); assessment.AutoAllow {
@@ -642,3 +645,12 @@ func ruleSubject(rule string) string {
}
return parsed.Tool + " · " + strings.TrimSpace(parsed.Subject)
}
// rememberTargetsExisting reports whether a remember call names an existing
// fact by id; only new facts are held as suggestions.
func rememberTargetsExisting(args json.RawMessage) bool {
var in struct {
ID string `json:"id"`
}
return json.Unmarshal(args, &in) == nil && in.ID != ""
}
@@ -0,0 +1,31 @@
package control
import "reasonix/internal/contract/config"
// MemoryProactiveSettings is what the model may do on its own with memory.
type MemoryProactiveSettings struct {
Mode string `json:"mode"`
Path string `json:"path"`
}
func (c *Controller) MemoryProactiveSettings() MemoryProactiveSettings {
path := config.UserConfigPath()
return MemoryProactiveSettings{Mode: config.LoadForEdit(path).MemoryProactive(), Path: path}
}
// SaveMemoryProactive takes effect for asking versus writing at once; whether
// the model is offered the remember tool at all is settled when the session opens.
func (c *Controller) SaveMemoryProactive(mode string) error {
unlock := config.LockUserConfigEdits()
defer unlock()
path := config.UserConfigPath()
cfg := config.LoadForEdit(path)
if err := cfg.SetMemoryProactive(mode); err != nil {
return err
}
if err := cfg.SaveTo(path); err != nil {
return err
}
c.memory.book.setAsk(cfg.MemoryProactive() == "ask")
return nil
}
+124
View File
@@ -0,0 +1,124 @@
package control
import (
"errors"
"fmt"
"strconv"
"sync"
"time"
"reasonix/internal/state/memory"
)
// ErrNoSuchSuggestion is returned when a suggestion was already decided or never existed.
var ErrNoSuchSuggestion = errors.New("memory.suggestion_not_found")
// MemorySuggestion is a fact the model wants to keep that waits for the user.
type MemorySuggestion struct {
ID string
Memory memory.Memory
At time.Time
}
// suggestionBook is the ask-first queue. It has its own lock and never calls
// back into the Controller.
type suggestionBook struct {
mu sync.Mutex
ask bool
seq int
items []MemorySuggestion
}
func (b *suggestionBook) add(m memory.Memory) string {
b.mu.Lock()
defer b.mu.Unlock()
b.seq++
id := "s" + strconv.Itoa(b.seq)
b.items = append(b.items, MemorySuggestion{ID: id, Memory: m, At: time.Now()})
return id
}
func (b *suggestionBook) list() []MemorySuggestion {
b.mu.Lock()
defer b.mu.Unlock()
return append([]MemorySuggestion(nil), b.items...)
}
func (b *suggestionBook) take(id string) (MemorySuggestion, bool) {
b.mu.Lock()
defer b.mu.Unlock()
for i, s := range b.items {
if s.ID == id {
b.items = append(b.items[:i], b.items[i+1:]...)
return s, true
}
}
return MemorySuggestion{}, false
}
func (b *suggestionBook) restore(s MemorySuggestion) {
b.mu.Lock()
b.items = append([]MemorySuggestion{s}, b.items...)
b.mu.Unlock()
}
func (b *suggestionBook) asking() bool {
b.mu.Lock()
defer b.mu.Unlock()
return b.ask
}
// SuggestMemory implements memory.Suggester: in ask mode the fact is held for
// the user instead of written.
func (c *Controller) SuggestMemory(m memory.Memory) (string, bool) {
if !c.asksBeforeRemembering() || c.memory.current() == nil {
return "", false
}
return c.memory.book.add(m), true
}
// MemorySuggestions lists the facts waiting for a decision, oldest first.
func (c *Controller) MemorySuggestions() []MemorySuggestion {
return c.memory.book.list()
}
// AcceptMemorySuggestion saves a held fact, with the user's edit when given,
// and returns the file written.
func (c *Controller) AcceptMemorySuggestion(id string, edited *memory.Memory) (string, error) {
s, ok := c.memory.book.take(id)
if !ok {
return "", fmt.Errorf("%w: %s", ErrNoSuchSuggestion, id)
}
fact := s.Memory
if edited != nil {
fact = *edited
if fact.Name == "" {
fact.Name = s.Memory.Name
}
}
path, err := c.memory.saveMemory(fact)
if err != nil {
c.memory.book.restore(s)
}
return path, err
}
// DismissMemorySuggestion drops a held fact without saving it.
func (c *Controller) DismissMemorySuggestion(id string) error {
if _, ok := c.memory.book.take(id); !ok {
return fmt.Errorf("%w: %s", ErrNoSuchSuggestion, id)
}
return nil
}
func (b *suggestionBook) setAsk(ask bool) {
b.mu.Lock()
b.ask = ask
b.mu.Unlock()
}
// asksBeforeRemembering is false under YOLO: a user who turned approvals off
// has already said the model may act, so its facts are saved, not held.
func (c *Controller) asksBeforeRemembering() bool {
return c.memory.book.asking() && c.ToolApprovalMode() != ToolApprovalYolo
}
@@ -0,0 +1,122 @@
package control
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/state/memory"
)
func suggestSession(t *testing.T, proactive string) (*Controller, memory.Store) {
t.Helper()
root := testenv.TempDir(t)
cwd := filepath.Join(root, "project")
userDir := filepath.Join(root, "user")
if err := os.MkdirAll(cwd, 0o755); err != nil {
t.Fatal(err)
}
c := New(Options{Memory: memory.Load(memory.Options{CWD: cwd, UserDir: userDir, Proactive: proactive})})
return c, memory.StoreFor(userDir, cwd)
}
const suggestArgs = `{"description":"staging deploys go to eu-west","body":"Staging is eu-west-1.","type":"project"}`
func runRemember(t *testing.T, c *Controller, store memory.Store, args string) string {
t.Helper()
ctx := memory.WithQueue(context.Background(), c)
out, err := memory.NewRememberTool(store).Execute(ctx, json.RawMessage(args))
if err != nil {
t.Fatalf("remember: %v", err)
}
return out
}
func TestAskModeHoldsRememberAsSuggestion(t *testing.T) {
c, store := suggestSession(t, "ask")
if !c.allowLowRiskRemember(json.RawMessage(suggestArgs)) {
t.Fatal("a suggestion writes nothing, so it needs no approval card")
}
out := runRemember(t, c, store, suggestArgs)
if !strings.Contains(out, "suggestion") {
t.Fatalf("result should say the fact was only held: %q", out)
}
if all, _ := store.ListAll(); len(all) != 0 {
t.Fatalf("ask mode wrote %d facts before the user decided", len(all))
}
held := c.MemorySuggestions()
if len(held) != 1 || held[0].Memory.Body != "Staging is eu-west-1." {
t.Fatalf("suggestions = %+v", held)
}
}
func TestAcceptWritesTheEditedFact(t *testing.T) {
c, store := suggestSession(t, "ask")
runRemember(t, c, store, suggestArgs)
held := c.MemorySuggestions()[0]
edited := held.Memory
edited.Body = "Staging is eu-west-2."
if _, err := c.AcceptMemorySuggestion(held.ID, &edited); err != nil {
t.Fatal(err)
}
all, _ := store.ListAll()
if len(all) != 1 || all[0].Body != "Staging is eu-west-2." {
t.Fatalf("saved = %+v", all)
}
if len(c.MemorySuggestions()) != 0 {
t.Fatal("an accepted suggestion must leave the queue")
}
if _, err := c.AcceptMemorySuggestion(held.ID, nil); !errors.Is(err, ErrNoSuchSuggestion) {
t.Fatalf("second accept = %v, want ErrNoSuchSuggestion", err)
}
}
func TestDismissDropsWithoutWriting(t *testing.T) {
c, store := suggestSession(t, "ask")
runRemember(t, c, store, suggestArgs)
id := c.MemorySuggestions()[0].ID
if err := c.DismissMemorySuggestion(id); err != nil {
t.Fatal(err)
}
if all, _ := store.ListAll(); len(all) != 0 {
t.Fatalf("dismiss wrote %d facts", len(all))
}
if err := c.DismissMemorySuggestion(id); !errors.Is(err, ErrNoSuchSuggestion) {
t.Fatalf("second dismiss = %v", err)
}
}
func TestAutoModeWritesDirectly(t *testing.T) {
c, store := suggestSession(t, "auto")
runRemember(t, c, store, suggestArgs)
if all, _ := store.ListAll(); len(all) != 1 {
t.Fatalf("auto mode saved %d facts, want 1", len(all))
}
if len(c.MemorySuggestions()) != 0 {
t.Fatal("auto mode holds nothing")
}
}
func TestAskModeStillUpdatesByID(t *testing.T) {
c, _ := suggestSession(t, "ask")
if c.allowLowRiskRemember(json.RawMessage(`{"id":"abc","description":"d","body":"b"}`)) {
t.Fatal("changing an existing fact is not a suggestion and stays gated")
}
}
func TestYoloSavesInsteadOfAsking(t *testing.T) {
c, store := suggestSession(t, "ask")
c.SetToolApprovalMode(ToolApprovalYolo)
runRemember(t, c, store, suggestArgs)
if all, _ := store.ListAll(); len(all) != 1 {
t.Fatalf("under YOLO the fact should be saved, got %d", len(all))
}
if len(c.MemorySuggestions()) != 0 {
t.Fatal("under YOLO nothing is held")
}
}
+5
View File
@@ -197,6 +197,9 @@ type MemoryControl interface {
SaveMemory(m memory.Memory) (string, error)
ForgetMemory(name string) error
ConfirmMemory(name string) error
MemorySuggestions() []MemorySuggestion
AcceptMemorySuggestion(id string, edited *memory.Memory) (string, error)
DismissMemorySuggestion(id string) error
QueueMemory(note string, delivered ...memory.Memory)
MemoryRevisions(ref string) ([]memory.Memory, error)
RestoreMemory(ref string, revision int) (memory.Memory, error)
@@ -328,6 +331,8 @@ type RuntimeSettings interface {
SaveObservabilitySettings(patch ObservabilityPatch) (ObservabilitySettings, error)
FoldWorkSettings() FoldWorkSettings
SaveFoldWorkSettings(fold bool) error
MemoryProactiveSettings() MemoryProactiveSettings
SaveMemoryProactive(mode string) error
ChangedSettings() ChangedSettings
TurnNarrationSettings() TurnNarrationSettings
SaveTurnNarrationSettings(level string) error
+2
View File
@@ -43,6 +43,8 @@ type Options struct {
PinnedBudgetChars int
RecallLimit int
RecallMaxChars int
// Proactive is ask, auto or off; empty keeps the approval-gated writes.
Proactive string
}
// Load discovers all memory for a session: the hierarchical docs and the
+18
View File
@@ -14,6 +14,13 @@ type Queue interface {
QueueMemory(note string, delivered ...Memory)
}
// Suggester holds a fact the model wants to keep instead of writing it: the
// user decides whether it becomes a memory. A Queue that also implements it
// puts remember into ask-first mode; the returned id names the held suggestion.
type Suggester interface {
SuggestMemory(m Memory) (id string, held bool)
}
type autoMemoryWriteClaimer interface {
ClaimAutoMemoryWrite(args json.RawMessage) bool
}
@@ -48,3 +55,14 @@ func ClaimAutoMemoryWriteFromContext(ctx context.Context, args json.RawMessage)
claimer, ok := q.(autoMemoryWriteClaimer)
return ok && claimer.ClaimAutoMemoryWrite(args)
}
// SuggesterFromContext returns the suggestion holder when the session asks
// before remembering.
func SuggesterFromContext(ctx context.Context) (Suggester, bool) {
q, ok := QueueFromContext(ctx)
if !ok {
return nil, false
}
s, ok := q.(Suggester)
return s, ok
}
+8 -2
View File
@@ -113,7 +113,7 @@ func (t rememberTool) Execute(ctx context.Context, args json.RawMessage) (string
if in.Verified {
verifiedAt = time.Now().UTC()
}
result, err := t.store.SaveWithOptions(Memory{
candidate := Memory{
ID: in.ID,
Name: name,
Title: in.Title,
@@ -129,7 +129,13 @@ func (t rememberTool) Execute(ctx context.Context, args json.RawMessage) (string
Galaxy: NormalizeGalaxy(in.Galaxy),
Importance: NormalizeImportance(in.Importance),
Body: in.Body,
}, SaveOptions{
}
if sg, ok := SuggesterFromContext(ctx); ok && in.ID == "" {
if id, held := sg.SuggestMemory(candidate); held {
return fmt.Sprintf("Held as suggestion %s: the user has not saved it yet and will accept, edit or dismiss it. Do not repeat it and do not assume it is remembered.", id), nil
}
}
result, err := t.store.SaveWithOptions(candidate, SaveOptions{
ExpectedRevision: in.ExpectedRevision,
RequireExpectedRevision: in.ExpectedRevision > 0,
RequireCreate: autoCreate,