mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 02:36:01 -10:00
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:
1 parent
b0786368ca
commit
813af4958e
37 files changed
+840
-27
No files matched your search
@@ -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);
|
||||
@@ -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" },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ?? [];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)},
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user