diff --git a/desktop/app.go b/desktop/app.go index c8a21f3f7..60d94db6a 100644 --- a/desktop/app.go +++ b/desktop/app.go @@ -4213,32 +4213,44 @@ type MemoryFact struct { Body string `json:"body"` } +// MemoryArchive is one archived auto-memory kept only for inspection. +type MemoryArchive struct { + Name string `json:"name"` + Title string `json:"title,omitempty"` + Description string `json:"description"` + Type string `json:"type"` + Body string `json:"body"` + Path string `json:"path"` + ArchivedAt string `json:"archivedAt,omitempty"` +} + // MemoryScope is one writable quick-add target (scope id + the file it writes to). type MemoryScope struct { Scope string `json:"scope"` Path string `json:"path"` } -// MemoryView is the whole memory panel payload: hierarchical docs, saved facts, -// and the writable scopes for the quick-add selector. +// MemoryView is the whole memory panel payload: hierarchical docs, active saved +// facts, archived facts, and the writable scopes for the quick-add selector. type MemoryView struct { - Docs []MemoryDoc `json:"docs"` - Facts []MemoryFact `json:"facts"` - Scopes []MemoryScope `json:"scopes"` - StoreDir string `json:"storeDir"` - Available bool `json:"available"` + Docs []MemoryDoc `json:"docs"` + Facts []MemoryFact `json:"facts"` + Archives []MemoryArchive `json:"archives"` + Scopes []MemoryScope `json:"scopes"` + StoreDir string `json:"storeDir"` + Available bool `json:"available"` } // writableScopes are the quick-add targets the panel offers, broad → specific. var writableScopes = []memory.Scope{memory.ScopeUser, memory.ScopeProject, memory.ScopeLocal} -// Memory returns the loaded memory for the panel: the REASONIX.md hierarchy, the -// saved auto-memories, and the writable scopes. Read-only; mutations go through -// Remember / SaveDoc. +// Memory returns the loaded memory for the panel: the REASONIX.md hierarchy, +// active/archived auto-memories, and the writable scopes. Read-only; mutations +// go through Remember / SaveDoc. func (a *App) Memory() MemoryView { // Always return non-nil slices: a nil Go slice marshals to JSON `null`, which // would crash the panel's `view.facts.length` / `.map`. - view := MemoryView{Docs: []MemoryDoc{}, Facts: []MemoryFact{}, Scopes: []MemoryScope{}} + view := MemoryView{Docs: []MemoryDoc{}, Facts: []MemoryFact{}, Archives: []MemoryArchive{}, Scopes: []MemoryScope{}} a.mu.RLock() ctrl := a.activeCtrlLocked() a.mu.RUnlock() @@ -4259,6 +4271,16 @@ func (a *App) Memory() MemoryView { Name: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body, }) } + for _, f := range set.Store.ListArchived() { + archivedAt := "" + if !f.ArchivedAt.IsZero() { + archivedAt = f.ArchivedAt.Format(time.RFC3339) + } + view.Archives = append(view.Archives, MemoryArchive{ + Name: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body, + Path: f.Path, ArchivedAt: archivedAt, + }) + } for _, sc := range writableScopes { if p := set.DocPath(sc); p != "" { // user scope yields "" when no config dir view.Scopes = append(view.Scopes, MemoryScope{Scope: string(sc), Path: p}) diff --git a/desktop/app_test.go b/desktop/app_test.go index 67f02c3fa..fe5819849 100644 --- a/desktop/app_test.go +++ b/desktop/app_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "errors" "fmt" "os" @@ -16,6 +17,7 @@ import ( "reasonix/internal/config" "reasonix/internal/control" "reasonix/internal/event" + "reasonix/internal/memory" "reasonix/internal/plugin" "reasonix/internal/provider" ) @@ -92,6 +94,77 @@ func TestEffortDefaultsBeforeStartup(t *testing.T) { } } +func TestMemoryViewReturnsNonNilArraysBeforeStartup(t *testing.T) { + isolateDesktopUserDirs(t) + + view := NewApp().Memory() + if view.Docs == nil || view.Facts == nil || view.Archives == nil || view.Scopes == nil { + t.Fatalf("Memory() arrays must be non-nil before startup: %+v", view) + } + raw, err := json.Marshal(view) + if err != nil { + t.Fatalf("marshal Memory(): %v", err) + } + for _, bad := range []string{`"docs":null`, `"facts":null`, `"archives":null`, `"scopes":null`} { + if strings.Contains(string(raw), bad) { + t.Fatalf("Memory() JSON contains %s; frontend expects []: %s", bad, raw) + } + } +} + +func TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) { + isolateDesktopUserDirs(t) + userDir := t.TempDir() + cwd := t.TempDir() + store := memory.Store{Dir: filepath.Join(userDir, "projects", "test", "memory")} + if _, err := store.Save(memory.Memory{ + Name: "active-fact", + Title: "Active fact", + Description: "Still applies", + Type: memory.TypeProject, + Body: "Active body", + }); err != nil { + t.Fatal(err) + } + if _, err := store.Save(memory.Memory{ + Name: "archived-fact", + Description: "No longer applies", + Type: memory.TypeFeedback, + Body: "Archived body", + }); err != nil { + t.Fatal(err) + } + if _, err := store.Archive("archived-fact"); err != nil { + t.Fatalf("Archive: %v", err) + } + + app := NewApp() + app.setTestCtrl(control.New(control.Options{Memory: &memory.Set{ + Docs: []memory.Source{{Path: filepath.Join(cwd, "AGENTS.md"), Scope: memory.ScopeProject, Body: "Project instructions"}}, + Store: store, + CWD: cwd, + UserDir: userDir, + }}), "test-model") + + view := app.Memory() + if !view.Available || view.StoreDir != store.Dir { + t.Fatalf("Memory() availability/store = %v/%q, want true/%q", view.Available, view.StoreDir, store.Dir) + } + if len(view.Docs) != 1 || view.Docs[0].Scope != "project" || !strings.Contains(view.Docs[0].Body, "Project instructions") { + t.Fatalf("Memory() docs = %+v", view.Docs) + } + if len(view.Facts) != 1 || view.Facts[0].Name != "active-fact" || view.Facts[0].Type != "project" { + t.Fatalf("Memory() active facts = %+v", view.Facts) + } + if len(view.Archives) != 1 || view.Archives[0].Name != "archived-fact" || view.Archives[0].Type != "feedback" || + view.Archives[0].Path == "" || view.Archives[0].ArchivedAt == "" { + t.Fatalf("Memory() archived facts = %+v", view.Archives) + } + if len(view.Scopes) != 3 { + t.Fatalf("Memory() scopes = %+v, want user/project/local", view.Scopes) + } +} + func TestBeforeCloseAllowsSystemQuitWhenBackgroundCloseEnabled(t *testing.T) { isolateDesktopUserDirs(t) consumeSystemQuitRequested() diff --git a/desktop/frontend/src/components/MemoryPanel.tsx b/desktop/frontend/src/components/MemoryPanel.tsx index a7edf7e22..3c2626dde 100644 --- a/desktop/frontend/src/components/MemoryPanel.tsx +++ b/desktop/frontend/src/components/MemoryPanel.tsx @@ -1,8 +1,8 @@ -import { ChevronDown, ChevronRight, FileText, Search, Trash2 } from "lucide-react"; +import { Check, ChevronDown, ChevronRight, FileText, Pencil, Plus, RefreshCw, Search, Sparkles, Trash2 } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { app } from "../lib/bridge"; import { useT } from "../lib/i18n"; -import type { MemoryFact, MemoryView } from "../lib/types"; +import type { MemoryArchive, MemoryFact, MemorySuggestion, MemorySuggestionsView, MemoryView, SkillSuggestion } from "../lib/types"; import { ResizableDrawer } from "./ResizableDrawer"; import { Tooltip } from "./Tooltip"; import { ModalCloseButton } from "./ModalCloseButton"; @@ -16,6 +16,101 @@ function displayTitle(fact: MemoryFact): string { return fact.title || fact.name.replaceAll("-", " "); } +function memoryMatches(fact: MemoryFact, normalizedQuery: string, typeFilter: string): boolean { + if (typeFilter !== "all" && fact.type !== typeFilter) return false; + if (!normalizedQuery) return true; + return [displayTitle(fact), fact.name, fact.description, fact.type, fact.body] + .join(" ") + .toLowerCase() + .includes(normalizedQuery); +} + +function archiveKey(fact: MemoryArchive): string { + return `${fact.path || fact.name}:${fact.archivedAt || ""}`; +} + +function formatArchivedAt(value?: string): string { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString(); +} + +function ArchivedMemoryList({ + archives, + totalArchives, + expanded, + setExpanded, + renderWithLinks, + t, + hideHeader = false, +}: { + archives: MemoryArchive[]; + totalArchives: number; + expanded: string | null; + setExpanded: (key: string | null) => void; + renderWithLinks: (text: string) => ReactNode[]; + t: ReturnType; + hideHeader?: boolean; +}) { + if (totalArchives === 0) return null; + return ( +
+ {!hideHeader &&
+
+
{t("memory.archivedMemories")}
+
{t("memory.archivedHint")}
+
+ {totalArchives} +
} + {archives.length === 0 ? ( +
{t("memory.noArchivedMatches")}
+ ) : ( +
+ {archives.map((f) => { + const key = archiveKey(f); + const isOpen = expanded === key; + return ( +
+ + {isOpen && ( +
+ {f.body ? ( +
{renderWithLinks(f.body)}
+ ) : ( +
{t("memory.noBody")}
+ )} +
{f.path}
+
+ )} +
+ ); + })} +
+ )} +
+ ); +} + function uniqueLinks(body: string, names: Set): LinkInfo[] { const links: LinkInfo[] = []; const seen = new Set(); @@ -45,6 +140,21 @@ function memoryScopeLabel(scope: string, t: ReturnType): string { } } +function memoryTypeLabel(type: string, t: ReturnType): string { + switch ((type || "").toLowerCase()) { + case "project": + return t("memory.type.project"); + case "user": + return t("memory.type.user"); + case "feedback": + return t("memory.type.feedback"); + case "reference": + return t("memory.type.reference"); + default: + return type || t("memory.type.other"); + } +} + function memoryDocTitle(scope: string, t: ReturnType): string { switch (scope) { case "project": @@ -75,17 +185,46 @@ function memoryDocHint(scope: string, t: ReturnType): string { } } -function memoryDocPreview(body: string): string { - const lines = body.split(/\r?\n/); - const preview = lines.slice(0, 6).join("\n"); - return lines.length > 6 ? `${preview}\n...` : preview; -} - function errorMessage(err: unknown): string { if (err instanceof Error) return err.message; return String(err || "Unknown error"); } +function suggestionTotal(view: MemorySuggestionsView | null): number { + return (view?.memories?.length ?? 0) + (view?.skills?.length ?? 0); +} + +function suggestionStamp(value?: string): string { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString(); +} + +const AUTO_MEMORY_SUGGESTIONS_KEY = "reasonix.memory.autoSuggestions"; + +function readAutoSuggestionsPreference(): boolean { + if (typeof window === "undefined") return false; + try { + return window.localStorage.getItem(AUTO_MEMORY_SUGGESTIONS_KEY) === "1"; + } catch { + return false; + } +} + +function writeAutoSuggestionsPreference(enabled: boolean) { + if (typeof window === "undefined") return; + try { + if (enabled) { + window.localStorage.setItem(AUTO_MEMORY_SUGGESTIONS_KEY, "1"); + } else { + window.localStorage.removeItem(AUTO_MEMORY_SUGGESTIONS_KEY); + } + } catch { + // Ignore storage failures; the toggle still works for this render. + } +} + // MemoryPanel is the desktop memory manager: a right-side drawer over the loaded // REASONIX.md hierarchy and saved auto-memories. Unlike Claude Code's /memory // (which shells out to $EDITOR) it edits docs in place, and unlike Codex (no UI @@ -116,6 +255,7 @@ export function MemoryPanel({ const [query, setQuery] = useState(""); const [typeFilter, setTypeFilter] = useState("all"); const [expanded, setExpanded] = useState(null); + const [expandedArchive, setExpandedArchive] = useState(null); const [confirmForget, setConfirmForget] = useState(null); const [error, setError] = useState(null); const factRefs = useRef>({}); @@ -127,29 +267,36 @@ export function MemoryPanel({ const [filter, setFilter] = useState(""); const facts = view?.facts ?? []; + const archives = view?.archives ?? []; const factNames = useMemo(() => new Set(facts.map((f) => f.name)), [facts]); const factTypes = useMemo( - () => Array.from(new Set(facts.map((f) => f.type).filter(Boolean))).sort(), - [facts], + () => Array.from(new Set([...facts, ...archives].map((f) => f.type).filter(Boolean))).sort(), + [facts, archives], ); const normalizedQuery = query.trim().toLowerCase(); const normalizedFilter = filter.trim().toLowerCase(); const filteredFacts = useMemo( () => facts.filter((f) => { - if (typeFilter !== "all" && f.type !== typeFilter) return false; if (normalizedFilter) { const hay = [f.name, f.description, f.body].join(" ").toLowerCase(); if (!hay.includes(normalizedFilter)) return false; } - if (!normalizedQuery) return true; - return [displayTitle(f), f.name, f.description, f.type, f.body] - .join(" ") - .toLowerCase() - .includes(normalizedQuery); + return memoryMatches(f, normalizedQuery, typeFilter); }), [facts, normalizedQuery, normalizedFilter, typeFilter], ); + const filteredArchives = useMemo( + () => + archives.filter((f) => { + if (normalizedFilter) { + const hay = [f.name, f.description, f.body, f.path].join(" ").toLowerCase(); + if (!hay.includes(normalizedFilter)) return false; + } + return memoryMatches(f, normalizedQuery, typeFilter); + }), + [archives, normalizedQuery, normalizedFilter, typeFilter], + ); const scrollToFact = (name: string) => { const el = factRefs.current[name]; @@ -270,7 +417,7 @@ export function MemoryPanel({
{t("memory.title")}
{view?.available && (
- {t("memory.summary", { facts: facts.length, docs: view.docs.length })} + {t("memory.summary", { facts: facts.length, archives: archives.length, docs: view.docs.length })}
)} @@ -315,7 +462,7 @@ export function MemoryPanel({ type="button" key={type} > - {type} + {memoryTypeLabel(type, t)} ))} @@ -364,7 +511,7 @@ export function MemoryPanel({ {displayTitle(f)} - {f.type && {f.type}} + {f.type && {memoryTypeLabel(f.type, t)}} {f.name} {f.description} @@ -449,6 +596,17 @@ export function MemoryPanel({ )} + {archives.length > 0 &&
+ +
} + {/* Quick-add: scope selector + note, mirroring the "#" shortcut. */}
{t("memory.quickAdd")}
@@ -505,7 +663,7 @@ export function MemoryPanel({ {filteredDocs.map((d) => { const editing = editingPath === d.path; return ( -
+
@@ -565,7 +723,7 @@ export function MemoryPanel({ ) : ( filteredFacts.map((f) => (
- {f.type} + {memoryTypeLabel(f.type, t)}
{f.name}
{f.description}
@@ -599,11 +757,19 @@ export function MemorySettingsPage() { const [query, setQuery] = useState(""); const [typeFilter, setTypeFilter] = useState("all"); const [expanded, setExpanded] = useState(null); + const [expandedArchive, setExpandedArchive] = useState(null); const [expandedDoc, setExpandedDoc] = useState(null); const [confirmForget, setConfirmForget] = useState(null); const [error, setError] = useState(null); - const [tab, setTab] = useState<"memories" | "docs">("memories"); + const [tab, setTab] = useState<"saved" | "archived" | "docs" | "suggestions">("saved"); const [showAdd, setShowAdd] = useState(false); + const [showStorage, setShowStorage] = useState(false); + const [suggestions, setSuggestions] = useState(null); + const [suggestionBusy, setSuggestionBusy] = useState(false); + const [expandedSuggestion, setExpandedSuggestion] = useState(null); + const [acceptedSuggestions, setAcceptedSuggestions] = useState>({}); + const [autoSuggestions, setAutoSuggestions] = useState(readAutoSuggestionsPreference); + const autoSuggestionsRequested = useRef(false); const factRefs = useRef>({}); const reload = useCallback(async () => { @@ -611,24 +777,60 @@ export function MemorySettingsPage() { }, []); useEffect(() => { void reload(); }, [reload]); + const refreshSuggestions = useCallback(async () => { + if (suggestionBusy) return; + setSuggestionBusy(true); + setError(null); + try { + const next = await app.MemorySuggestions(); + setSuggestions({ + memories: next.memories ?? [], + skills: next.skills ?? [], + generatedAt: next.generatedAt || "", + available: !!next.available, + source: next.source || "", + }); + setAcceptedSuggestions({}); + } catch (err) { + setError(errorMessage(err)); + } finally { + setSuggestionBusy(false); + } + }, [suggestionBusy]); + + const setAutoSuggestionsPreference = useCallback((enabled: boolean) => { + autoSuggestionsRequested.current = false; + setAutoSuggestions(enabled); + writeAutoSuggestionsPreference(enabled); + }, []); + + useEffect(() => { + if (tab !== "suggestions" || !autoSuggestions || suggestions || suggestionBusy || autoSuggestionsRequested.current) return; + autoSuggestionsRequested.current = true; + void refreshSuggestions(); + }, [autoSuggestions, refreshSuggestions, suggestionBusy, suggestions, tab]); + const facts = view?.facts ?? []; + const archives = view?.archives ?? []; const factNames = useMemo(() => new Set(facts.map((f) => f.name)), [facts]); const factTypes = useMemo( - () => Array.from(new Set(facts.map((f) => f.type).filter(Boolean))).sort(), - [facts], + () => Array.from(new Set([...facts, ...archives].map((f) => f.type).filter(Boolean))).sort(), + [facts, archives], ); const normalizedQuery = query.trim().toLowerCase(); const filteredFacts = useMemo( () => - facts.filter((f) => { + facts.filter((f) => memoryMatches(f, normalizedQuery, typeFilter)), + [facts, normalizedQuery, typeFilter], + ); + const filteredArchives = useMemo( + () => + archives.filter((f) => { if (typeFilter !== "all" && f.type !== typeFilter) return false; if (!normalizedQuery) return true; - return [displayTitle(f), f.name, f.description, f.type, f.body] - .join(" ") - .toLowerCase() - .includes(normalizedQuery); + return memoryMatches(f, normalizedQuery, "all") || [f.path, f.archivedAt].join(" ").toLowerCase().includes(normalizedQuery); }), - [facts, normalizedQuery, typeFilter], + [archives, normalizedQuery, typeFilter], ); const scrollToFact = useCallback((name: string) => { @@ -736,50 +938,121 @@ export function MemorySettingsPage() { } }, [editingPath, busy, draft, reload]); + const acceptMemorySuggestion = useCallback(async (candidate: MemorySuggestion) => { + if (busy) return; + setBusy(true); + setError(null); + try { + const path = await app.AcceptMemorySuggestion(candidate); + setAcceptedSuggestions((prev) => ({ ...prev, [candidate.id]: path || candidate.name })); + await reload(); + } catch (err) { + setError(errorMessage(err)); + } finally { + setBusy(false); + } + }, [busy, reload]); + + const acceptSkillSuggestion = useCallback(async (candidate: SkillSuggestion) => { + if (busy) return; + setBusy(true); + setError(null); + try { + const path = await app.AcceptSkillSuggestion(candidate); + setAcceptedSuggestions((prev) => ({ ...prev, [candidate.id]: path || candidate.name })); + } catch (err) { + setError(errorMessage(err)); + } finally { + setBusy(false); + } + }, [busy]); + if (!view?.available) { return
{t("memory.unavailable")}
; } + const hasSavedFilters = facts.length > 0; + const hasArchivedFilters = archives.length > 0; + return ( <> -
- +
+
+ {t("memory.summarySettings", { facts: facts.length, archives: archives.length, docs: view.docs.length })} +
+ {view.storeDir && ( + + )} +
+ {showStorage && view.storeDir && ( +
+ {t("memory.storagePathLabel")} + {view.storeDir} +
+ )} +
+
+ + + +
- {tab === "memories" &&
+ {tab === "saved" &&
-
{t("memory.memoryEntries")}
+
{t("memory.savedMemories")}
{t("memory.fallibleNote")}
- {facts.length}
@@ -827,7 +1100,7 @@ export function MemorySettingsPage() {
)} -
+ {hasSavedFilters &&
-
+
} {error &&
{error}
} {facts.length === 0 ? ( -
{t("memory.noFacts")}
+
+ {t("memory.emptySavedTitle")} + {t("memory.emptySavedBody")} + +
) : filteredFacts.length === 0 ? (
{t("memory.noMatches")} @@ -900,7 +1185,7 @@ export function MemorySettingsPage() { {displayTitle(f)} - {f.type && {f.type}} + {f.type && {memoryTypeLabel(f.type, t)}} {f.name} {f.description} @@ -980,8 +1265,240 @@ export function MemorySettingsPage() { })}
)} - {view.storeDir && ( -
{t("memory.storedUnder", { dir: view.storeDir })}
+
} + + {tab === "suggestions" &&
+
+
+
{t("memory.suggestions")}
+
{t("memory.suggestionsHint")}
+
+
+ +
+
+
+
+ {t("memory.autoSuggestions")} + {t("memory.autoSuggestionsHint")} +
+ + + +
+ {error &&
{error}
} + {!suggestions ? ( +
+ {t("memory.suggestionsEmptyTitle")} + {t("memory.suggestionsEmptyBody")} + +
+ ) : suggestionTotal(suggestions) === 0 ? ( +
+ {t("memory.noSuggestionsTitle")} + {t("memory.noSuggestionsBody")} +
+ ) : ( +
+ {suggestions.generatedAt && ( +
+ {t("memory.suggestionsGenerated", { time: suggestionStamp(suggestions.generatedAt) })} +
+ )} + {suggestions.memories.length > 0 && ( +
+
{t("memory.memoryCandidates")}
+
+ {suggestions.memories.map((candidate) => { + const open = expandedSuggestion === candidate.id; + const accepted = acceptedSuggestions[candidate.id]; + return ( +
+ + {open && ( +
+
{candidate.body}
+ {candidate.reason &&
{candidate.reason}
} + {candidate.evidence.length > 0 && ( +
    + {candidate.evidence.map((item) =>
  • {item}
  • )} +
+ )} +
+ {t("memory.confirmBeforeApply")} + {accepted ? ( + {t("memory.savedSuggestion")} + ) : ( + + )} +
+
+ )} +
+ ); + })} +
+
+ )} + {suggestions.skills.length > 0 && ( +
+
{t("memory.skillCandidates")}
+
+ {suggestions.skills.map((candidate) => { + const open = expandedSuggestion === candidate.id; + const accepted = acceptedSuggestions[candidate.id]; + return ( +
+ + {open && ( +
+
{candidate.body}
+ {candidate.reason &&
{candidate.reason}
} + {candidate.evidence.length > 0 && ( +
    + {candidate.evidence.map((item) =>
  • {item}
  • )} +
+ )} +
+ {t("memory.confirmBeforeApply")} + {accepted ? ( + {t("memory.createdSkillSuggestion")} + ) : ( + + )} +
+
+ )} +
+ ); + })} +
+
+ )} +
+ )} +
} + + {tab === "archived" &&
+
+
+
{t("memory.archivedMemories")}
+
{t("memory.archivedHint")}
+
+
+ {hasArchivedFilters &&
+ +
+ + {factTypes.map((type) => ( + + ))} +
+
} + {archives.length === 0 ? ( +
+ {t("memory.emptyArchivedTitle")} + {t("memory.emptyArchivedBody")} +
+ ) : ( + )}
} @@ -991,7 +1508,6 @@ export function MemorySettingsPage() {
{t("memory.instructionFiles")}
{t("memory.instructionFilesHint")}
- {view.docs.length} {view.docs.length === 0 && (
{t("memory.noDocs")}
@@ -1000,32 +1516,35 @@ export function MemorySettingsPage() { const editing = editingPath === d.path; const open = expandedDoc === d.path || editing; return ( -
+
-
+
+
{memoryScopeLabel(d.scope, t)} - {!editing && ( - - )} {!editing && ( )} @@ -1056,11 +1575,9 @@ export function MemorySettingsPage() {
- ) : ( -
-									{open ? d.body : memoryDocPreview(d.body)}
-								
- )} + ) : open ? ( +
{d.body}
+ ) : null}
); })} diff --git a/desktop/frontend/src/lib/bridge.ts b/desktop/frontend/src/lib/bridge.ts index 91236a2e4..ab264e81b 100644 --- a/desktop/frontend/src/lib/bridge.ts +++ b/desktop/frontend/src/lib/bridge.ts @@ -30,6 +30,8 @@ import type { HooksSettingsView, JobView, MCPServerInput, + MemorySuggestion, + MemorySuggestionsView, MemoryView, Meta, ModelInfo, @@ -41,6 +43,7 @@ import type { SessionMeta, SettingsView, SkillRootView, + SkillSuggestion, SkillView, SlashArgsResult, TabMeta, @@ -189,6 +192,9 @@ export interface AppBindings { EffortForTab(tabID: string): Promise; SetEffortForTab(tabID: string, level: string): Promise; Memory(): Promise; + MemorySuggestions(): Promise; + AcceptMemorySuggestion(suggestion: MemorySuggestion): Promise; + AcceptSkillSuggestion(suggestion: SkillSuggestion): Promise; Remember(scope: string, note: string): Promise; Forget(name: string): Promise; SaveDoc(path: string, body: string): Promise; @@ -2045,6 +2051,16 @@ function makeMockApp(): AppBindings { body: "Indent with tabs.", }, ], + archives: [ + { + name: "old-plan", + description: "Superseded planning note", + type: "project", + body: "This plan was archived after the implementation changed.", + path: "~/.config/reasonix/projects/-mock/memory/.archive/20260612-021500.000-old-plan.md", + archivedAt: "2026-06-12T02:15:00Z", + }, + ], scopes: [ { scope: "user", path: "~/.config/reasonix/REASONIX.md" }, { scope: "project", path: "REASONIX.md" }, @@ -2052,6 +2068,44 @@ function makeMockApp(): AppBindings { ], }; }, + async MemorySuggestions() { + return { + memories: [ + { + id: "memory-prefers-concise-replies", + name: "prefers-concise-replies", + title: "Prefers concise replies", + description: "User prefers concise replies unless detail is requested.", + type: "user", + body: "User prefers concise replies unless detail is requested.\n\n**Why:** Suggested from recent local history.\n**How to apply:** Keep answers brief by default.", + reason: "future-facing preference", + evidence: ["mock-session: always keep replies concise"], + }, + ], + skills: [ + { + id: "skill-reasonix-pr-followup", + name: "reasonix-pr-followup", + description: "Review or update a Reasonix GitHub PR, address feedback, verify, and publish safely.", + scope: "project", + body: "# Reasonix PR Followup\n\nUse this skill for repeated Reasonix PR work.\n\n## Workflow\n\n1. Confirm branch and PR state.\n2. Inspect the diff.\n3. Fix actionable feedback.\n4. Verify and update the PR.\n", + reason: "recent history repeatedly touched PR workflows", + evidence: ["mock-pr-session: 提交到pr,并更新内容", "mock-review-session: 解决该pr下机器人提出来的问题"], + }, + ], + generatedAt: new Date().toISOString(), + available: true, + source: "mock", + }; + }, + async AcceptMemorySuggestion(suggestion: MemorySuggestion) { + emit({ kind: "notice", level: "info", text: `saved suggested memory → ${suggestion.name}` }); + return `${suggestion.name}.md`; + }, + async AcceptSkillSuggestion(suggestion: SkillSuggestion) { + emit({ kind: "notice", level: "info", text: `created suggested skill → ${suggestion.name}` }); + return `.reasonix/skills/${suggestion.name}/SKILL.md`; + }, async Remember(scope: string, note: string) { emit({ kind: "notice", level: "info", text: `remembered → ${scope}` }); return `${scope} REASONIX.md (mock): ${note}`; diff --git a/desktop/frontend/src/lib/types.ts b/desktop/frontend/src/lib/types.ts index ceb6c8f41..786c0a121 100644 --- a/desktop/frontend/src/lib/types.ts +++ b/desktop/frontend/src/lib/types.ts @@ -513,14 +513,49 @@ export interface MemoryFact { body: string; } +export interface MemoryArchive extends MemoryFact { + path: string; + archivedAt?: string; +} + export interface MemoryScope { scope: string; // "user" | "project" | "local" path: string; } +export interface MemorySuggestion { + id: string; + name: string; + title: string; + description: string; + type: string; + body: string; + reason: string; + evidence: string[]; +} + +export interface SkillSuggestion { + id: string; + name: string; + description: string; + scope: string; + body: string; + reason: string; + evidence: string[]; +} + +export interface MemorySuggestionsView { + memories: MemorySuggestion[]; + skills: SkillSuggestion[]; + generatedAt: string; + available: boolean; + source: string; +} + export interface MemoryView { docs: MemoryDoc[]; facts: MemoryFact[]; + archives: MemoryArchive[]; scopes: MemoryScope[]; storeDir: string; available: boolean; diff --git a/desktop/frontend/src/lib/useController.ts b/desktop/frontend/src/lib/useController.ts index 36b08c40a..4f55f18c7 100644 --- a/desktop/frontend/src/lib/useController.ts +++ b/desktop/frontend/src/lib/useController.ts @@ -929,7 +929,7 @@ export function useController() { }, [activeTabId, dispatchTo]); const fetchMemory = useCallback((): Promise => - app.Memory().catch(() => ({ docs: [], facts: [], scopes: [], storeDir: "", available: false })), []); + app.Memory().catch(() => ({ docs: [], facts: [], archives: [], scopes: [], storeDir: "", available: false })), []); const remember = useCallback(async (scope: string, note: string) => { await app.Remember(scope, note).catch(() => {}); }, []); const forget = useCallback(async (name: string) => { await app.Forget(name).catch(() => {}); }, []); const saveDoc = useCallback(async (path: string, body: string) => { await app.SaveDoc(path, body).catch(() => {}); }, []); diff --git a/desktop/frontend/src/locales/en.ts b/desktop/frontend/src/locales/en.ts index fcdb3a537..390a75b9e 100644 --- a/desktop/frontend/src/locales/en.ts +++ b/desktop/frontend/src/locales/en.ts @@ -526,27 +526,36 @@ export const en = { // memory drawer "memory.title": "Memory", - "memory.summary": "{facts} saved · {docs} files", + "memory.summary": "{facts} saved · {archives} archived · {docs} files", + "memory.summarySettings": "{facts} saved · {archives} archived · {docs} instruction files · current workspace", "memory.unavailable": "Memory unavailable.", "memory.quickAdd": "Quick add", "memory.whereToSave": "Where to save", "memory.notePlaceholder": "Remember that…", "memory.remember": "Remember", + "memory.showStorage": "Storage location", + "memory.hideStorage": "Hide location", + "memory.storagePathLabel": "Saved under", "memory.instructionFiles": "Instruction files", "memory.instructionFilesHint": "These Markdown files stay in context and are best for durable rules and project conventions.", "memory.expandDoc": "Expand preview", "memory.noDocs": "No REASONIX.md found. Quick-add one above.", "memory.savedMemories": "Saved memories", - "memory.memoryEntries": "Memory entries", - "memory.addMemory": "+ Add memory", + "memory.memoryEntries": "Saved memories", + "memory.addMemory": "Add memory", "memory.addMemoryHint": "Save a preference, project convention, or long-lived fact to a chosen scope.", "memory.scope.project": "Project", - "memory.scope.user": "User", + "memory.scope.user": "Global", "memory.scope.local": "Local", "memory.scope.ancestor": "Ancestor", + "memory.type.project": "Project convention", + "memory.type.user": "Global preference", + "memory.type.feedback": "Feedback", + "memory.type.reference": "Reference", + "memory.type.other": "Other", "memory.doc.projectTitle": "Project instructions", - "memory.doc.projectHint": "Applies only to the current project and takes precedence over user instructions.", - "memory.doc.userTitle": "User instructions", + "memory.doc.projectHint": "Applies only to the current project and takes precedence over global instructions.", + "memory.doc.userTitle": "Global instructions", "memory.doc.userHint": "Applies by default across projects; best for long-term personal preferences.", "memory.doc.localTitle": "Local instructions", "memory.doc.localHint": "Applies only on this machine; best for private rules that should not be committed.", @@ -561,14 +570,43 @@ export const en = { "memory.noMatches": "No memories match the current filters.", "memory.clearFilters": "Clear filters", "memory.noFacts": "Nothing saved yet. The agent writes these with the remember tool.", + "memory.emptySavedTitle": "No saved memories yet", + "memory.emptySavedBody": "Save durable background only when it should help future Reasonix sessions in this project.", + "memory.suggestions": "Candidate suggestions", + "memory.suggestionsHint": "Drafted from recent local history; nothing is written until you confirm.", + "memory.scanSuggestions": "Scan history manually", + "memory.refreshSuggestions": "Rescan history", + "memory.autoSuggestions": "Auto-generate candidates", + "memory.autoSuggestionsHint": "When enabled, opening Suggestions scans recent local history automatically. Drafts still require manual confirmation.", + "memory.enableAutoSuggestions": "Scan automatically when opening Suggestions", + "memory.disableAutoSuggestions": "Use manual scans only", + "memory.suggestionsEmptyTitle": "Generate candidates from history", + "memory.suggestionsEmptyBody": "Scan recent sessions for memories worth saving and repeated workflows worth packaging. Results are shown as drafts.", + "memory.noSuggestionsTitle": "No high-confidence candidates yet", + "memory.noSuggestionsBody": "Recent history did not contain a clear long-term memory or repeated workflow. Scan again later.", + "memory.suggestionsGenerated": "Generated {time}", + "memory.memoryCandidates": "Memory candidates", + "memory.skillCandidates": "Skill candidates", + "memory.skillCandidate": "Skill", + "memory.confirmBeforeApply": "Only applies after confirmation.", + "memory.saveAsMemory": "Save memory", + "memory.createSkill": "Create skill", + "memory.savedSuggestion": "Saved", + "memory.createdSkillSuggestion": "Created", + "memory.archivedMemories": "Archived memories", + "memory.archivedHint": "Kept for traceability. Archived memories are not loaded or retrieved as active memory.", + "memory.archivedAt": "archived {time}", + "memory.noArchivedMatches": "No archived memories match the current filters.", + "memory.emptyArchivedTitle": "No archived memories", + "memory.emptyArchivedBody": "When a memory is archived, it stops being active but remains here for audit and recovery.", "memory.links": "Memory links", "memory.noBody": "No body saved for this memory.", "memory.missingLinks": "{n} missing linked memory", "memory.appliesNow": "Changes apply in this session.", "memory.storedUnder": "stored under {dir}", - "memory.forget": "Forget", - "memory.confirmForget": "Confirm forget", + "memory.forget": "Archive", + "memory.confirmForget": "Confirm archive", "memory.deadLink": "No memory named “{name}”", "memory.filterPlaceholder": "Filter docs and facts…", diff --git a/desktop/frontend/src/locales/zh.ts b/desktop/frontend/src/locales/zh.ts index bd1e97e56..9175b3c65 100644 --- a/desktop/frontend/src/locales/zh.ts +++ b/desktop/frontend/src/locales/zh.ts @@ -527,27 +527,36 @@ export const zh: Record = { // 记忆抽屉 "memory.title": "记忆", - "memory.summary": "{facts} 条已保存 · {docs} 个文件", + "memory.summary": "{facts} 条已保存 · {archives} 条归档 · {docs} 个文件", + "memory.summarySettings": "{facts} 条已保存 · {archives} 条归档 · {docs} 个指令文件 · 当前工作区", "memory.unavailable": "记忆功能不可用。", "memory.quickAdd": "快速添加", "memory.whereToSave": "保存到哪里", "memory.notePlaceholder": "记住…", "memory.remember": "记住", + "memory.showStorage": "存储位置", + "memory.hideStorage": "隐藏位置", + "memory.storagePathLabel": "保存目录", "memory.instructionFiles": "指令文件", "memory.instructionFilesHint": "这些 Markdown 文件会持续参与上下文,适合保存长期规则和项目约定。", "memory.expandDoc": "展开预览", "memory.noDocs": "未找到 REASONIX.md。可在上方快速添加一条。", "memory.savedMemories": "已保存的记忆", - "memory.memoryEntries": "记忆条目", - "memory.addMemory": "+ 添加记忆", + "memory.memoryEntries": "已保存的记忆", + "memory.addMemory": "添加记忆", "memory.addMemoryHint": "把一条偏好、项目约定或长期事实保存到指定作用域。", "memory.scope.project": "项目", - "memory.scope.user": "用户", + "memory.scope.user": "全局", "memory.scope.local": "本地", "memory.scope.ancestor": "上级", + "memory.type.project": "项目约定", + "memory.type.user": "全局偏好", + "memory.type.feedback": "反馈", + "memory.type.reference": "引用", + "memory.type.other": "其他", "memory.doc.projectTitle": "项目指令", - "memory.doc.projectHint": "仅当前项目生效,优先于用户指令。", - "memory.doc.userTitle": "用户指令", + "memory.doc.projectHint": "仅当前项目生效,优先于全局指令。", + "memory.doc.userTitle": "全局指令", "memory.doc.userHint": "所有项目默认生效,适合个人长期偏好。", "memory.doc.localTitle": "本地指令", "memory.doc.localHint": "仅本机生效,适合不提交到仓库的私有规则。", @@ -562,14 +571,43 @@ export const zh: Record = { "memory.noMatches": "没有符合当前筛选的记忆。", "memory.clearFilters": "清空筛选", "memory.noFacts": "还没有保存任何内容。智能体会通过 remember 工具写入这些。", + "memory.emptySavedTitle": "还没有保存的记忆", + "memory.emptySavedBody": "只保存对后续 Reasonix 会话有帮助的长期背景。", + "memory.suggestions": "候选建议", + "memory.suggestionsHint": "从近期本地历史中提取候选;确认前不会写入记忆或 Skill。", + "memory.scanSuggestions": "手动扫描历史", + "memory.refreshSuggestions": "重新扫描历史", + "memory.autoSuggestions": "自动生成候选", + "memory.autoSuggestionsHint": "开启后进入建议页会自动扫描近期本地历史;候选仍需手动确认后才会保存。", + "memory.enableAutoSuggestions": "进入建议页时自动扫描", + "memory.disableAutoSuggestions": "改为只手动扫描", + "memory.suggestionsEmptyTitle": "从历史生成候选", + "memory.suggestionsEmptyBody": "扫描近期会话,找出可能值得保存的记忆和重复出现的工作流。结果只作为草稿展示。", + "memory.noSuggestionsTitle": "暂时没有高置信候选", + "memory.noSuggestionsBody": "近期历史里没有足够明确的长期记忆或重复工作流。可以稍后再扫描。", + "memory.suggestionsGenerated": "生成于 {time}", + "memory.memoryCandidates": "记忆候选", + "memory.skillCandidates": "Skill 候选", + "memory.skillCandidate": "Skill", + "memory.confirmBeforeApply": "确认后才会生效。", + "memory.saveAsMemory": "保存为记忆", + "memory.createSkill": "创建 Skill", + "memory.savedSuggestion": "已保存", + "memory.createdSkillSuggestion": "已创建", + "memory.archivedMemories": "归档的记忆", + "memory.archivedHint": "仅用于追溯来源。归档记忆不会作为 active memory 被加载或检索。", + "memory.archivedAt": "归档于 {time}", + "memory.noArchivedMatches": "没有符合当前筛选的归档记忆。", + "memory.emptyArchivedTitle": "还没有归档记忆", + "memory.emptyArchivedBody": "记忆被归档后会停止生效,但仍会保留在这里用于审计和恢复。", "memory.links": "记忆互链", "memory.noBody": "这条记忆没有正文。", "memory.missingLinks": "{n} 条互链目标不存在", "memory.appliesNow": "变更会在当前会话生效。", "memory.storedUnder": "存放于 {dir}", - "memory.forget": "删除", - "memory.confirmForget": "确认删除", + "memory.forget": "归档", + "memory.confirmForget": "确认归档", "memory.deadLink": "没有名为「{name}」的记忆", "memory.filterPlaceholder": "筛选指令文件与记忆…", diff --git a/desktop/frontend/src/styles.css b/desktop/frontend/src/styles.css index e1b7abe67..6594bec67 100644 --- a/desktop/frontend/src/styles.css +++ b/desktop/frontend/src/styles.css @@ -7463,17 +7463,40 @@ a[href] { line-height: 1.4; } .mem-doc { - border: 1px solid var(--border); + position: relative; + border: 1px solid var(--border-soft); border-radius: 8px; margin-bottom: 10px; + background: color-mix(in srgb, var(--bg-elev) 78%, var(--bg) 22%); overflow: hidden; + transition: border-color 0.12s, background 0.12s, transform 0.12s cubic-bezier(0.34, 1.4, 0.5, 1), box-shadow 0.12s; +} +.mem-doc:hover { + border-color: var(--border); + transform: translateY(-1px); + box-shadow: 0 4px 14px -8px rgba(0, 0, 0, 0.5); } +.mem-doc::before { + content: ""; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + background: var(--mem-doc-scope-color, var(--fg-faint)); + opacity: 0.85; +} +.mem-doc[data-doc-scope="user"] { --mem-doc-scope-color: #3b82f6; } +.mem-doc[data-doc-scope="project"] { --mem-doc-scope-color: var(--accent); } +.mem-doc[data-doc-scope="local"] { --mem-doc-scope-color: #2dd4bf; } +.mem-doc[data-doc-scope="ancestor"] { --mem-doc-scope-color: #e0a23a; } +.mem-doc[data-doc-scope="other"] { --mem-doc-scope-color: var(--fg-faint); } .mem-doc__head { display: flex; align-items: center; gap: 10px; - padding: 10px 12px; - background: var(--bg-soft); + padding: 10px 12px 10px 13px; + background: transparent; } /* M3: file icon + name/path + scope tag layout. */ .mem-doc__icon { @@ -7517,11 +7540,35 @@ a[href] { background: color-mix(in srgb, currentColor 14%, transparent); } .mem-doc__identity { + flex: 1 1 auto; display: flex; align-items: flex-start; gap: 10px; min-width: 0; } +.mem-doc__toggle { + --wails-draggable: no-drag; + border: 0; + padding: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} +.mem-doc__toggle:disabled { + cursor: default; +} +.mem-doc__toggle:not(:disabled):hover strong { + color: var(--accent); +} +.mem-doc__chevron { + flex: 0 0 16px; + display: grid; + place-items: center; + min-height: 28px; + color: var(--fg-muted); +} .mem-doc__identity > div { display: flex; min-width: 0; @@ -7550,6 +7597,8 @@ a[href] { .mem-doc__body { margin: 0; padding: 10px; + border-top: 1px solid var(--border-soft); + background: color-mix(in srgb, var(--bg) 54%, transparent); font-family: var(--mono); font-size: 12px; color: var(--fg-dim); @@ -7558,10 +7607,6 @@ a[href] { max-height: 220px; overflow-y: auto; } -.mem-doc__body--preview { - max-height: none; - overflow: hidden; -} .mem-doc__edit { padding: 10px; } @@ -7571,6 +7616,9 @@ a[href] { align-items: center; gap: 8px; } +.mem-doc__head-actions .btn { + gap: 6px; +} .mem-textarea { width: 100%; min-height: 180px; @@ -7595,6 +7643,14 @@ a[href] { flex-direction: column; gap: 8px; } +.mem-archive-block { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--border-soft); +} +.mem-facts--archive { + gap: 6px; +} .mem-fact { position: relative; border: 1px solid var(--border-soft); @@ -7608,6 +7664,14 @@ a[href] { transform: translateY(-1px); box-shadow: 0 4px 14px -8px rgba(0, 0, 0, 0.5); } +.mem-fact--archived { + --mem-type-color: var(--fg-faint); + background: color-mix(in srgb, var(--bg-elev) 52%, var(--bg) 48%); + opacity: 0.86; +} +.mem-fact--archived:hover { + opacity: 1; +} /* M2: per-type accent stripe down the card's left edge. */ .mem-fact::before { content: ""; @@ -7619,6 +7683,10 @@ a[href] { background: var(--mem-type-color, var(--fg-faint)); opacity: 0.85; } +.mem-fact--archived::before { + background: var(--fg-faint); + opacity: 0.55; +} /* M1: type-keyed accent colors, shared by the stripe and the chip. */ .mem-fact[data-mem-type="user"] { --mem-type-color: #3b82f6; } .mem-fact[data-mem-type="feedback"] { --mem-type-color: #e0a23a; } @@ -7690,6 +7758,10 @@ a[href] { text-overflow: ellipsis; white-space: nowrap; } +.mem-fact__archived { + flex: none; + color: var(--fg-faint); +} .mem-fact__desc { min-width: 0; font-size: 12px; @@ -7741,6 +7813,16 @@ a[href] { white-space: pre-wrap; word-break: break-word; } +.mem-archive__path { + margin-top: 10px; + padding-top: 8px; + border-top: 1px solid var(--border-soft); + color: var(--fg-faint); + font-family: var(--mono); + font-size: 11px; + line-height: 1.45; + word-break: break-all; +} .mem-fact__forget { display: inline-flex; align-items: center; @@ -7793,6 +7875,99 @@ a[href] { background: var(--accent-soft, rgba(120, 170, 255, 0.12)); border-color: color-mix(in srgb, var(--accent) 42%, transparent); } +.mem-suggestions { + display: flex; + flex-direction: column; + gap: 14px; +} +.mem-suggestions__stamp { + color: var(--fg-faint); + font-size: var(--font-caption); +} +.mem-suggestion-settings { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + margin-bottom: 12px; + padding: 10px 11px; + border: 1px solid var(--border-soft); + border-radius: 7px; + background: color-mix(in srgb, var(--bg-elev) 58%, transparent); +} +.mem-suggestion-settings > div { + display: flex; + min-width: 0; + flex-direction: column; + gap: 3px; +} +.mem-suggestion-settings strong { + color: var(--fg); + font-size: var(--font-control-small); + font-weight: 700; + line-height: 1.35; +} +.mem-suggestion-settings span { + color: var(--fg-dim); + font-size: var(--font-caption); + line-height: 1.45; +} +.mem-suggestion-group { + display: flex; + flex-direction: column; + gap: 8px; +} +.mem-suggestion-group__title { + color: var(--fg); + font-size: var(--font-control-small); + font-weight: 700; +} +.mem-suggestion { + --mem-type-color: var(--accent); +} +.mem-suggestion--skill { + --mem-type-color: #2dd4bf; +} +.mem-suggestion--skill .mem-fact__summary { + align-items: flex-start; +} +.mem-suggestion__body { + padding: 9px; + border: 1px solid var(--border-soft); + border-radius: 7px; + background: color-mix(in srgb, var(--bg-elev) 70%, transparent); + color: var(--fg-dim); + font-size: 12px; + line-height: 1.5; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.mem-suggestion__body--code { + margin: 0; + max-height: 280px; + overflow: auto; + font-family: var(--mono); +} +.mem-suggestion__reason { + margin-top: 8px; + color: var(--fg-dim); + font-size: var(--font-caption); +} +.mem-suggestion__evidence { + margin: 8px 0 0; + padding-left: 18px; + color: var(--fg-faint); + font-size: var(--font-caption); + line-height: 1.45; +} +.mem-suggestion__accepted { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--success, #2aa36b); + font-size: var(--font-caption); + font-weight: 650; +} .mem-link { background: none; border: none; @@ -9392,6 +9567,7 @@ a[href] { .settings-center__content { min-width: 0; overflow-y: auto; + scrollbar-gutter: stable; padding: 24px 32px; background: var(--bg-elev); } @@ -9443,7 +9619,8 @@ a[href] { box-shadow: 0 10px 24px rgba(0, 0, 0, 0.08); } -.settings-section__head { +.settings-section__head, +.settings-page--manager .mem-section__head { display: flex; align-items: flex-start; justify-content: space-between; @@ -9451,6 +9628,10 @@ a[href] { margin-bottom: 12px; } +.settings-page--manager .mem-section__head > div:first-child { + min-width: 0; +} + .settings-section__title, .settings-page--manager .mem-section__title { font-size: var(--font-control); @@ -9610,6 +9791,72 @@ a[href] { margin-bottom: 12px; } +.memory-overview { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + margin-bottom: 12px; + padding: 12px 14px; + border: 1px solid var(--border-soft); + border-radius: 8px; + background: color-mix(in srgb, var(--bg-elev-2) 52%, transparent); +} + +.memory-overview__copy { + min-width: 0; + display: flex; + align-items: center; +} + +.memory-overview__copy span { + color: var(--fg-dim); + font-size: var(--font-caption); + line-height: 1.35; +} + +.memory-storage-toggle { + --wails-draggable: no-drag; + flex: 0 0 auto; + border: 1px solid var(--border); + border-radius: 7px; + padding: 6px 9px; + color: var(--fg-dim); + background: var(--bg); + font: inherit; + font-size: var(--font-control-small); +} + +.memory-storage-toggle:hover { + color: var(--fg); + border-color: var(--accent); +} + +.memory-storage-path { + display: grid; + gap: 5px; + margin: -4px 0 12px; + padding: 10px 12px; + border: 1px dashed var(--border); + border-radius: 8px; + background: var(--bg); +} + +.memory-storage-path span { + color: var(--fg-dim); + font-size: var(--text-2xs); + font-weight: 650; + text-transform: uppercase; +} + +.memory-storage-path code { + min-width: 0; + color: var(--fg); + font-size: var(--font-caption); + white-space: normal; + overflow-wrap: anywhere; +} + .settings-subtabs { display: inline-flex; max-width: 100%; @@ -9621,11 +9868,60 @@ a[href] { background: var(--bg); } +.memory-tabs-row { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 12px; + max-width: 100%; + margin: 0 0 14px; +} + +.memory-tabs-row__primary { + flex: 0 1 auto; + margin: 0; +} + +.memory-suggestion-tab { + --wails-draggable: no-drag; + flex: 0 0 auto; + margin-left: auto; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + min-height: 36px; + padding: 6px 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg); + color: var(--fg-dim); + font: inherit; + font-size: var(--font-control-small); + white-space: nowrap; +} + +.memory-suggestion-tab svg { + color: var(--accent); +} + +.memory-suggestion-tab:hover { + color: var(--fg); + background: var(--button-bg-hover); +} + +.memory-suggestion-tab--active { + color: var(--accent); + background: var(--bg-elev-2); + box-shadow: inset 0 0 0 1px var(--border-soft); + font-weight: 650; +} + .settings-subtab { --wails-draggable: no-drag; display: inline-flex; align-items: center; - gap: 8px; + gap: 6px; min-height: 30px; padding: 5px 10px; border: 0; @@ -9648,15 +9944,44 @@ a[href] { font-weight: 650; } -.settings-subtab small { - min-width: 18px; - padding: 1px 6px; +.settings-subtab__count { + display: inline-grid; + place-items: center; + min-width: 17px; + height: 17px; + padding: 0 5px; border-radius: 999px; - color: var(--fg-dim); background: var(--bg-soft); - font-size: var(--text-2xs); + color: var(--fg-dim); font-family: var(--mono); - text-align: center; + font-size: 10px; + font-weight: 650; +} + +.settings-page--manager .mem-empty--cta { + min-height: 156px; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: 9px; + padding: 24px; + border: 1px dashed var(--border); + border-radius: 8px; + background: color-mix(in srgb, var(--bg) 72%, transparent); + text-align: left; +} + +.settings-page--manager .mem-empty--cta strong { + color: var(--fg); + font-size: var(--font-control); +} + +.settings-page--manager .mem-empty--cta span { + max-width: 560px; + color: var(--fg-dim); + font-size: var(--font-control-small); + line-height: 1.45; } .settings-page .cap-search .mem-input, @@ -9859,6 +10184,15 @@ a[href] { overflow-x: auto; } + .memory-tabs-row { + align-items: stretch; + flex-direction: column; + } + + .memory-suggestion-tab { + align-self: flex-start; + } + .settings-subtab { flex: 0 0 auto; } @@ -17958,6 +18292,7 @@ a[href] { } :root[data-theme-style] .settings-subtabs, +:root[data-theme-style] .memory-suggestion-tab, :root[data-theme-style] .set-seg, :root[data-theme-style] .provider-add-segmented, :root[data-theme-style] .cap-tabs { @@ -17967,6 +18302,7 @@ a[href] { } :root[data-theme-style] .settings-subtab--active, +:root[data-theme-style] .memory-suggestion-tab--active, :root[data-theme-style] .set-seg__btn--on, :root[data-theme-style] .provider-add-segmented__item--active, :root[data-theme-style] .cap-tab--active { diff --git a/desktop/memory_suggestions.go b/desktop/memory_suggestions.go new file mode 100644 index 000000000..e37ecc93a --- /dev/null +++ b/desktop/memory_suggestions.go @@ -0,0 +1,553 @@ +package main + +import ( + "fmt" + "path/filepath" + "strings" + "time" + "unicode" + + "reasonix/internal/agent" + "reasonix/internal/config" + "reasonix/internal/control" + "reasonix/internal/memory" + "reasonix/internal/provider" + "reasonix/internal/skill" +) + +const ( + suggestionSessionLimit = 12 + memorySuggestionLimit = 6 +) + +// MemorySuggestion is a user-confirmed candidate for an active saved memory. +// It is generated read-only from recent local history and only persisted through +// AcceptMemorySuggestion. +type MemorySuggestion struct { + ID string `json:"id"` + Name string `json:"name"` + Title string `json:"title"` + Description string `json:"description"` + Type string `json:"type"` + Body string `json:"body"` + Reason string `json:"reason"` + Evidence []string `json:"evidence"` +} + +// SkillSuggestion is a user-confirmed candidate for a reusable skill. +type SkillSuggestion struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Scope string `json:"scope"` + Body string `json:"body"` + Reason string `json:"reason"` + Evidence []string `json:"evidence"` +} + +// MemorySuggestionsView is the desktop Memory page's suggestion payload. +type MemorySuggestionsView struct { + Memories []MemorySuggestion `json:"memories"` + Skills []SkillSuggestion `json:"skills"` + GeneratedAt string `json:"generatedAt"` + Available bool `json:"available"` + Source string `json:"source"` +} + +type suggestionSession struct { + Path string + ID string + Preview string + LastSeen time.Time + Messages []provider.Message +} + +type workflowCategory struct { + Name string + Description string + Reason string + Keywords []string + Steps []string +} + +// MemorySuggestions scans recent local history and returns draft memory/skill +// candidates. It does not modify memory, skills, sessions, or model context. +func (a *App) MemorySuggestions() MemorySuggestionsView { + view := MemorySuggestionsView{ + Memories: []MemorySuggestion{}, + Skills: []SkillSuggestion{}, + GeneratedAt: time.Now().UTC().Format(time.RFC3339), + } + + a.mu.RLock() + tab := a.activeTabLocked() + var ctrl *control.Controller + workspaceRoot := "" + sessionDir := "" + if tab != nil { + ctrl = tab.Ctrl + workspaceRoot = tab.WorkspaceRoot + sessionDir = tabSessionDir(tab) + } + a.mu.RUnlock() + if ctrl == nil { + return view + } + set := ctrl.Memory() + if set == nil { + return view + } + view.Available = true + view.Source = "local-history" + + sessions := loadSuggestionSessions(sessionDir, suggestionSessionLimit) + view.Memories = suggestMemories(set, sessions) + view.Skills = suggestSkills(workspaceRoot, ctrl.AllSkills(), sessions) + return view +} + +// AcceptMemorySuggestion persists a previously previewed memory candidate. +func (a *App) AcceptMemorySuggestion(in MemorySuggestion) (string, error) { + a.mu.RLock() + ctrl := a.activeCtrlLocked() + a.mu.RUnlock() + if ctrl == nil { + return "", nil + } + desc := oneLine(in.Description) + body := strings.TrimSpace(in.Body) + if desc == "" || body == "" { + return "", fmt.Errorf("memory suggestion requires description and body") + } + name := suggestionName(in.Name, desc, "memory-candidate") + return ctrl.SaveMemory(memory.Memory{ + Name: name, + Title: oneLine(in.Title), + Description: desc, + Type: memory.NormalizeType(in.Type), + Body: body, + }) +} + +// AcceptSkillSuggestion writes a previewed skill candidate. It uses the regular +// skill store so name validation, scope handling, and no-overwrite behavior stay +// centralized. +func (a *App) AcceptSkillSuggestion(in SkillSuggestion) (string, error) { + a.mu.RLock() + tab := a.activeTabLocked() + workspaceRoot := "" + if tab != nil { + workspaceRoot = tab.WorkspaceRoot + } + a.mu.RUnlock() + + name := strings.TrimSpace(in.Name) + desc := oneLine(in.Description) + body := strings.TrimSpace(in.Body) + if name == "" || desc == "" || body == "" { + return "", fmt.Errorf("skill suggestion requires name, description, and body") + } + st := skillStoreForWorkspace(workspaceRoot) + scope := skill.ScopeProject + if strings.TrimSpace(in.Scope) == "global" || !st.HasProjectScope() { + scope = skill.ScopeGlobal + } + content := renderSkillSuggestionFile(name, desc, body) + return st.CreateWithContent(name, scope, content) +} + +func loadSuggestionSessions(dir string, limit int) []suggestionSession { + if strings.TrimSpace(dir) == "" || limit <= 0 { + return nil + } + infos, err := agent.ListSessions(dir) + if err != nil { + return nil + } + var out []suggestionSession + for _, info := range infos { + if len(out) >= limit { + break + } + loaded, err := agent.LoadSession(info.Path) + if err != nil { + continue + } + out = append(out, suggestionSession{ + Path: info.Path, + ID: strings.TrimSuffix(filepath.Base(info.Path), filepath.Ext(info.Path)), + Preview: info.Preview, + LastSeen: info.LastActivityAt, + Messages: loaded.Snapshot(), + }) + } + return out +} + +func suggestMemories(set *memory.Set, sessions []suggestionSession) []MemorySuggestion { + if set == nil || len(sessions) == 0 { + return []MemorySuggestion{} + } + existing := existingMemoryText(set) + seen := map[string]bool{} + var out []MemorySuggestion + for _, sess := range sessions { + for _, msg := range sess.Messages { + if msg.Role != provider.RoleUser { + continue + } + statement, reason := extractMemoryStatement(msg.Content) + if statement == "" { + continue + } + key := normalizeSuggestionKey(statement) + if key == "" || seen[key] || existingCovers(existing, key) { + continue + } + seen[key] = true + name := suggestionName("", statement, fmt.Sprintf("memory-candidate-%d", len(out)+1)) + title := suggestionTitle(statement, "Memory candidate") + typ := inferMemoryType(statement) + out = append(out, MemorySuggestion{ + ID: "memory-" + name, + Name: name, + Title: title, + Description: oneLine(statement), + Type: string(typ), + Body: memoryCandidateBody(statement, reason, sess), + Reason: reason, + Evidence: []string{sessionEvidence(sess, statement)}, + }) + if len(out) >= memorySuggestionLimit { + return out + } + } + } + return out +} + +func suggestSkills(workspaceRoot string, existing []skill.Skill, sessions []suggestionSession) []SkillSuggestion { + if len(sessions) == 0 { + return []SkillSuggestion{} + } + existingNames := map[string]bool{} + for _, sk := range existing { + existingNames[config.SkillNameKey(sk.Name)] = true + } + scope := "project" + if strings.TrimSpace(workspaceRoot) == "" { + scope = "global" + } + + var out []SkillSuggestion + for _, cat := range workflowCategories() { + if existingNames[config.SkillNameKey(cat.Name)] { + continue + } + evidence := workflowEvidence(cat, sessions) + if len(evidence) < 2 { + continue + } + out = append(out, SkillSuggestion{ + ID: "skill-" + cat.Name, + Name: cat.Name, + Description: cat.Description, + Scope: scope, + Body: skillCandidateBody(cat, evidence), + Reason: cat.Reason, + Evidence: evidence, + }) + } + return out +} + +func existingMemoryText(set *memory.Set) []string { + var out []string + for _, d := range set.Docs { + out = append(out, normalizeSuggestionKey(d.Body)) + } + for _, f := range set.Store.List() { + out = append(out, normalizeSuggestionKey(strings.Join([]string{f.Name, f.Title, f.Description, f.Body}, " "))) + } + return out +} + +func existingCovers(existing []string, key string) bool { + if key == "" { + return true + } + for _, text := range existing { + if text != "" && (strings.Contains(text, key) || strings.Contains(key, text)) { + return true + } + } + return false +} + +func extractMemoryStatement(content string) (string, string) { + text := oneLine(content) + if len([]rune(text)) < 8 || len([]rune(text)) > 420 { + return "", "" + } + lower := strings.ToLower(text) + type marker struct { + value string + reason string + } + markers := []marker{ + {"记住", "explicit remember request"}, + {"以后", "future-facing preference"}, + {"始终", "persistent working rule"}, + {"总是", "persistent working rule"}, + {"每次", "repeated workflow preference"}, + {"默认", "default behavior preference"}, + {"不要", "negative working preference"}, + {"偏好", "user preference"}, + {"规则", "durable rule"}, + {"约定", "project convention"}, + {"remember", "explicit remember request"}, + {"always", "persistent working rule"}, + {"never", "negative working preference"}, + {"prefer", "user preference"}, + {"preference", "user preference"}, + {"by default", "default behavior preference"}, + } + for _, m := range markers { + if strings.Contains(lower, m.value) { + return trimMemoryLead(text, m.value), m.reason + } + } + return "", "" +} + +func trimMemoryLead(text, marker string) string { + idx := strings.Index(strings.ToLower(text), marker) + if idx < 0 { + return text + } + trimmed := strings.TrimSpace(text[idx:]) + for _, sep := range []string{":", ":", "-", "—"} { + trimmed = strings.TrimPrefix(trimmed, marker+sep) + } + return strings.TrimSpace(trimmed) +} + +func inferMemoryType(statement string) memory.Type { + lower := strings.ToLower(statement) + if strings.Contains(lower, "http://") || strings.Contains(lower, "https://") || strings.Contains(lower, "github.com/") { + return memory.TypeReference + } + if hasAny(lower, "反馈", "回复", "回答", "不要", "always", "never", "始终", "总是") { + return memory.TypeFeedback + } + if hasAny(lower, "项目", "分支", "pr", "pull request", "仓库", "repo", "约定") { + return memory.TypeProject + } + return memory.TypeUser +} + +func memoryCandidateBody(statement, reason string, sess suggestionSession) string { + var b strings.Builder + b.WriteString(strings.TrimSpace(statement)) + b.WriteString("\n\n**Why:** Suggested from recent local history") + if reason != "" { + b.WriteString(" (" + reason + ")") + } + b.WriteString(".\n") + b.WriteString("**How to apply:** Treat this as durable guidance only after the user confirms it still applies.\n") + if sess.ID != "" { + b.WriteString("\nEvidence: [" + sess.ID + "] " + truncateRunes(statement, 180)) + } + return b.String() +} + +func workflowCategories() []workflowCategory { + return []workflowCategory{ + { + Name: "reasonix-pr-followup", + Description: "Review or update a Reasonix GitHub PR, address feedback, verify, and publish safely.", + Reason: "recent history repeatedly touched PR review, bot feedback, commits, or GitHub publication", + Keywords: []string{"pr", "pull request", "github", "review", "机器人", "评审", "提交到pr", "更新pr", "code rabbit", "coderabbit"}, + Steps: []string{ + "Fetch the live PR state and confirm branch, base, head SHA, and review status.", + "Inspect the real diff and related implementation before changing code.", + "Fix only actionable feedback, run focused verification, and keep cache-sensitive surfaces stable.", + "Stage intended files, commit with an English behavior-focused message, push to the verified PR head, and update the PR.", + }, + }, + { + Name: "reasonix-memory-ui", + Description: "Iterate on the Reasonix desktop Memory page with source-backed UI decisions and browser verification.", + Reason: "recent history repeatedly discussed Memory page layout, labels, filters, and interaction details", + Keywords: []string{"memory", "记忆", "设置-记忆", "memory panel", "指令文件", "归档", "全局", "项目", "添加记忆"}, + Steps: []string{ + "Identify the active Memory settings component and current browser-rendered state before editing.", + "Keep active memories, archived memories, instruction files, and suggestions visually distinct.", + "Use neutral secondary actions and confirmation for persistent writes or archive operations.", + "Run frontend checks and verify the affected Memory page in the in-app browser.", + }, + }, + { + Name: "desktop-ui-iteration", + Description: "Apply focused desktop UI layout feedback, preserve existing design tokens, and verify in browser.", + Reason: "recent history repeatedly involved screenshot-driven desktop UI layout and interaction feedback", + Keywords: []string{"ui", "布局", "设计", "交互", "红框", "页面", "按钮", "浏览器", "frontend", "desktop"}, + Steps: []string{ + "Map the screenshot target to the exact component, selector, and state in source.", + "Patch the smallest component and CSS surface using existing settings/page recipes.", + "Check responsive behavior and text overflow for the changed controls.", + "Verify with the running local UI instead of relying only on code inspection.", + }, + }, + } +} + +func workflowEvidence(cat workflowCategory, sessions []suggestionSession) []string { + seenSession := map[string]bool{} + var evidence []string + for _, sess := range sessions { + for _, msg := range sess.Messages { + if msg.Role != provider.RoleUser { + continue + } + text := oneLine(msg.Content) + if text == "" || !hasAny(strings.ToLower(text), cat.Keywords...) { + continue + } + if seenSession[sess.ID] { + continue + } + seenSession[sess.ID] = true + evidence = append(evidence, sessionEvidence(sess, text)) + break + } + } + if len(evidence) > 4 { + return evidence[:4] + } + return evidence +} + +func skillCandidateBody(cat workflowCategory, evidence []string) string { + var b strings.Builder + title := strings.TrimPrefix(strings.ReplaceAll(cat.Name, "-", " "), "reasonix ") + b.WriteString("# " + strings.Title(title) + "\n\n") + b.WriteString("Use this skill when the user asks for this repeated Reasonix workflow.\n\n") + b.WriteString("## Evidence\n\n") + for _, ev := range evidence { + b.WriteString("- " + ev + "\n") + } + b.WriteString("\n## Workflow\n\n") + for i, step := range cat.Steps { + fmt.Fprintf(&b, "%d. %s\n", i+1, step) + } + b.WriteString("\n## Stop Condition\n\n") + b.WriteString("Finish only after the requested change is implemented, verified, and any requested PR or UI update is delivered.\n") + return b.String() +} + +func skillStoreForWorkspace(workspaceRoot string) *skill.Store { + cfg, err := config.LoadForRoot(workspaceRoot) + var custom, excluded []string + maxDepth := 3 + if err == nil && cfg != nil { + custom = cfg.SkillCustomPaths() + excluded = cfg.SkillExcludedPaths() + maxDepth = cfg.SkillMaxDepth() + } + return skill.New(skill.Options{ + ProjectRoot: strings.TrimSpace(workspaceRoot), + CustomPaths: custom, + ExcludedPaths: excluded, + MaxDepth: maxDepth, + }) +} + +func renderSkillSuggestionFile(name, desc, body string) string { + return "---\nname: " + name + "\ndescription: " + desc + "\n---\n\n" + strings.TrimSpace(body) + "\n" +} + +func suggestionName(given, source, fallback string) string { + if name := asciiSlug(given); name != "" { + return name + } + if name := asciiSlug(source); name != "" { + return name + } + if name := asciiSlug(fallback); name != "" { + return name + } + return "candidate" +} + +func asciiSlug(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + var b strings.Builder + lastDash := false + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + b.WriteRune(r) + lastDash = false + case r == '-' || r == '_' || r == '.': + if b.Len() > 0 && !lastDash { + b.WriteRune('-') + lastDash = true + } + case unicode.IsSpace(r): + if b.Len() > 0 && !lastDash { + b.WriteRune('-') + lastDash = true + } + } + if b.Len() >= 56 { + break + } + } + return strings.Trim(b.String(), "-") +} + +func suggestionTitle(s, fallback string) string { + title := truncateRunes(oneLine(s), 64) + if title == "" { + return fallback + } + return title +} + +func sessionEvidence(sess suggestionSession, text string) string { + label := sess.ID + if label == "" { + label = filepath.Base(sess.Path) + } + return label + ": " + truncateRunes(oneLine(text), 160) +} + +func normalizeSuggestionKey(s string) string { + return strings.ToLower(strings.Join(strings.Fields(s), " ")) +} + +func oneLine(s string) string { + return strings.Join(strings.Fields(s), " ") +} + +func truncateRunes(s string, n int) string { + if n <= 0 { + return "" + } + r := []rune(strings.TrimSpace(s)) + if len(r) <= n { + return string(r) + } + return string(r[:n-1]) + "..." +} + +func hasAny(hay string, needles ...string) bool { + hay = strings.ToLower(hay) + for _, needle := range needles { + if strings.Contains(hay, strings.ToLower(needle)) { + return true + } + } + return false +} diff --git a/desktop/memory_suggestions_test.go b/desktop/memory_suggestions_test.go new file mode 100644 index 000000000..4bf77295a --- /dev/null +++ b/desktop/memory_suggestions_test.go @@ -0,0 +1,131 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "reasonix/internal/agent" + "reasonix/internal/control" + "reasonix/internal/memory" + "reasonix/internal/provider" +) + +func TestMemorySuggestionsReturnsNonNilArraysBeforeStartup(t *testing.T) { + isolateDesktopUserDirs(t) + + view := NewApp().MemorySuggestions() + if view.Memories == nil || view.Skills == nil { + t.Fatalf("MemorySuggestions() arrays must be non-nil before startup: %+v", view) + } + raw, err := json.Marshal(view) + if err != nil { + t.Fatalf("marshal MemorySuggestions(): %v", err) + } + for _, bad := range []string{`"memories":null`, `"skills":null`} { + if strings.Contains(string(raw), bad) { + t.Fatalf("MemorySuggestions() JSON contains %s; frontend expects []: %s", bad, raw) + } + } +} + +func TestMemorySuggestionsAcceptMemoryCandidate(t *testing.T) { + isolateDesktopUserDirs(t) + userDir := t.TempDir() + cwd := t.TempDir() + sessionDir := t.TempDir() + store := memory.StoreFor(userDir, cwd) + writeSuggestionSession(t, sessionDir, "pref.jsonl", + provider.Message{Role: provider.RoleUser, Content: "以后请始终用中文回复,除非我明确要求英文。"}, + provider.Message{Role: provider.RoleAssistant, Content: "好的。"}, + ) + + app := NewApp() + app.setTestCtrl(control.New(control.Options{ + Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir}, + SessionDir: sessionDir, + }), "test-model") + app.tabs["test"].WorkspaceRoot = cwd + + view := app.MemorySuggestions() + if len(view.Memories) == 0 { + t.Fatalf("MemorySuggestions() memories = %+v, want at least one candidate", view.Memories) + } + path, err := app.AcceptMemorySuggestion(view.Memories[0]) + if err != nil { + t.Fatalf("AcceptMemorySuggestion: %v", err) + } + if path == "" { + t.Fatal("AcceptMemorySuggestion returned empty path") + } + got := store.List() + if len(got) != 1 || !strings.Contains(got[0].Body, "中文回复") { + t.Fatalf("saved memories = %+v, want confirmed candidate body", got) + } +} + +func TestMemorySuggestionsAcceptSkillCandidate(t *testing.T) { + isolateDesktopUserDirs(t) + userDir := t.TempDir() + cwd := t.TempDir() + sessionDir := t.TempDir() + store := memory.StoreFor(userDir, cwd) + writeSuggestionSession(t, sessionDir, "pr-a.jsonl", + provider.Message{Role: provider.RoleUser, Content: "把这个 PR 合并到本地并说明主要做了什么。"}, + provider.Message{Role: provider.RoleAssistant, Content: "已检查。"}, + ) + writeSuggestionSession(t, sessionDir, "pr-b.jsonl", + provider.Message{Role: provider.RoleUser, Content: "解决该 pr 下机器人提出来的问题,合理的问题进行修复。"}, + provider.Message{Role: provider.RoleAssistant, Content: "已处理。"}, + ) + + app := NewApp() + app.setTestCtrl(control.New(control.Options{ + Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir}, + SessionDir: sessionDir, + }), "test-model") + app.tabs["test"].WorkspaceRoot = cwd + + view := app.MemorySuggestions() + var candidate SkillSuggestion + for _, item := range view.Skills { + if item.Name == "reasonix-pr-followup" { + candidate = item + break + } + } + if candidate.Name == "" { + t.Fatalf("MemorySuggestions() skills = %+v, want reasonix-pr-followup", view.Skills) + } + path, err := app.AcceptSkillSuggestion(candidate) + if err != nil { + t.Fatalf("AcceptSkillSuggestion: %v", err) + } + wantSuffix := filepath.Join(".reasonix", "skills", "reasonix-pr-followup", "SKILL.md") + if !strings.HasSuffix(path, wantSuffix) { + t.Fatalf("skill path = %q, want suffix %q", path, wantSuffix) + } + body, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read skill: %v", err) + } + if !strings.Contains(string(body), "Review or update a Reasonix GitHub PR") { + t.Fatalf("skill body missing description: %s", body) + } +} + +func writeSuggestionSession(t *testing.T, dir, name string, messages ...provider.Message) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + sess := agent.NewSession("") + for _, msg := range messages { + sess.Add(msg) + } + if err := sess.Save(filepath.Join(dir, name)); err != nil { + t.Fatalf("save session %s: %v", name, err) + } +} diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 74e8c7691..e5fa7456f 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -206,6 +206,20 @@ another branch. **Custom commands** are Markdown files under `.reasonix/commands `/review`, a subdirectory namespaces it (`git/commit.md` → `/git:commit`). The body is a prompt template; invoking the command sends it as a turn. +`/memory` lists both memory documents (`REASONIX.md` / `AGENTS.md`) and saved +auto-memory facts. During agent turns, the read-only `history` and `memory` +tools let the model retrieve prior session decisions, compacted-history +archives, and saved facts on demand instead of injecting that dynamic state into +the stable system prompt. `/forget ` archives a saved fact rather than +deleting it permanently; the CLI/TUI and desktop memory panel can show those +archived files for traceability, but they are not searched as active memory. +Agent-initiated `remember` and `forget` calls always ask for fresh approval and +show a compact preview of the saved or archived memory before they run. +Retrieval keeps the top BM25 result while trimming weak common-word matches, and +0-result responses suggest narrower, more distinctive follow-up searches. +For implementation details, see +[`SESSION_MEMORY_RETRIEVAL.md`](SESSION_MEMORY_RETRIEVAL.md). + ```markdown --- description: Review the staged diff diff --git a/docs/GUIDE.zh-CN.md b/docs/GUIDE.zh-CN.md index ad39b1c22..a19c8d98f 100644 --- a/docs/GUIDE.zh-CN.md +++ b/docs/GUIDE.zh-CN.md @@ -186,6 +186,15 @@ headers = { Authorization = "Bearer ${STRIPE_KEY}" } `review.md` 即 `/review`,子目录构成命名空间(`git/commit.md` → `/git:commit`)。文件正文 是 prompt 模板,调用即作为一轮对话发出。 +`/memory` 会同时列出记忆文档(`REASONIX.md` / `AGENTS.md`)和已保存的 auto-memory 条目。 +在 agent 回合中,只读的 `history` 和 `memory` 工具可以按需检索历史 session 决策、 +compaction archive 和已保存事实;这些动态内容不会被塞进稳定的 system prompt 前缀。 +`/forget ` 会把已保存事实归档而不是永久删除;CLI/TUI 和桌面记忆面板能显示归档文件用于追溯, +但它们不会作为 active memory 被检索。检索会保留 BM25 最强命中,同时裁掉弱的泛词命中; +agent 发起的 `remember` 和 `forget` 每次都会要求新的人工确认,并在执行前展示将保存或归档的记忆摘要。 +0 结果会提示 agent 改用更少、更有区分度的词继续查。 +技术实现细节见 [`SESSION_MEMORY_RETRIEVAL.md`](SESSION_MEMORY_RETRIEVAL.md)。 + ```markdown --- description: Review the staged diff diff --git a/docs/SESSION_MEMORY_RETRIEVAL.md b/docs/SESSION_MEMORY_RETRIEVAL.md new file mode 100644 index 000000000..153e98fcc --- /dev/null +++ b/docs/SESSION_MEMORY_RETRIEVAL.md @@ -0,0 +1,240 @@ +# Session History and Synthesis Memory Retrieval + +This document describes the lightweight retrieval layer added for session +history and saved memories. It is the implementation note behind the `history` +and `memory` tools, the archive-on-forget behavior, and the fresh human approval +gate for agent-written memory. + +## Goals + +- Bring useful past-session context back without injecting dynamic history into + the stable system prompt. +- Keep Reasonix cache-first: stable prompt bytes stay stable across turns, while + history and saved facts are fetched on demand. +- Avoid a heavy retrieval dependency. The implementation is pure Go and does not + introduce SQLite, CGO, a vector database, or an embedding model. +- Make memory trustworthy. Agent-initiated memory writes and archives must be + visible to the user and approved every time. +- Preserve traceability. A wrong memory should stop affecting the agent, but the + removed document should remain inspectable. + +## Non-Goals + +- This is not semantic embedding search. It is lexical retrieval with BM25, + tuned for code, commands, error phrases, filenames, and explicit decisions. +- This does not auto-summarize every session into memory. The `memory` layer is a + synthesis cache: only stable conclusions that the user approves become saved + documents. +- Archived memories are not active knowledge. They exist for audit and recovery, + not for recall. + +## Retrieval Core + +`internal/retrieval` contains the shared retrieval primitives: + +- tokenization for Latin words and CJK runes; +- document-frequency and BM25 scoring; +- compact snippets around query terms; +- `KeepTopRelativeScore`, which keeps the best hit but trims weak trailing hits + below a relative score floor. + +The relative score floor is intentionally small (`0.15`) and applied after +sorting. It prevents common-word-only matches from crowding out the useful hit, +while still preserving multiple close matches when they are genuinely relevant. + +## Session History Tool + +The `history` tool is read-only and lives in `internal/history`. + +It supports two operations: + +- `search`: rank saved session records by BM25. +- `around`: read a bounded transcript window around a returned hit. + +Search input can be scoped: + +- `project`: current session directory only. +- `global`: current session directory, user-global session directory, and + compacted-history archive directory. + +Search indexes these record kinds by default: + +- user text; +- assistant text; +- tool inputs; +- tool errors. + +Normal tool output is excluded by default because it can be large and noisy. It +can be requested explicitly with `kind=["tool_output"]`, optionally filtered by +`tool_name`. + +`around` enforces path confinement. It only accepts paths under the configured +session or archive roots, so a model cannot use the tool as a general file reader. + +When search returns no hits, the tool explicitly tells the agent that zero +results are not proof that an event never happened. It suggests retrying with +rarer terms, widening scope, or including tool output only when needed. + +## Saved Memory Recall Tool + +The `memory` tool is read-only and lives in `internal/memory/recall.go`. + +It supports: + +- `search`: BM25 over active memory files. +- `read`: return one full active memory by name. +- `list`: show the active memory index, optionally filtered by type. + +Only active memories from the project memory store participate. Archived memory +files are excluded from `search`, `read`, and `list`. + +The searchable text combines: + +- slug/name; +- title; +- normalized type; +- description; +- body. + +This makes short user-facing descriptions useful, while still allowing recall by +the detailed body when the agent knows a rare phrase from the saved fact. + +## Memory as Synthesis Cache + +Reasonix treats saved memory as a synthesis cache rather than as a raw transcript +cache. + +The intended workflow is: + +1. The agent searches `history` or `memory` when it needs old context. +2. If retrieval produces a stable reusable conclusion, the agent proposes a + `remember` write. +3. The user reviews and approves or denies the write. +4. Future sessions can reuse the saved document directly. + +This avoids repeatedly paying retrieval cost for the same stable conclusion, +while keeping the saved set small and auditable. + +## Desktop Candidate Suggestions + +The desktop Memory page can scan recent local sessions and produce draft +candidates: + +- memory candidates from explicit long-lived preferences, rules, or project + conventions in recent user turns; +- skill candidates from repeated workflow categories across recent sessions. + +This is intentionally a suggestion layer, not an automatic writer: + +- scanning can be run manually from the Memory page. Users may also enable a + desktop UI preference that scans automatically when the Suggestions tab opens; +- candidates show their proposed body plus short evidence snippets before any + write; +- accepting a memory candidate writes through the controller's active memory + path, so the current session gets the same transient turn-tail update as a + `remember` write; +- accepting a skill candidate writes through the normal skill store, preserving + skill name validation, scope handling, and no-overwrite behavior. + +No candidate scan changes the stable system prompt or provider-visible tool +schema. Saved memories and created skills become part of the stable prefix only +through the existing next-session discovery path. + +## Archive-on-Forget + +`forget` no longer permanently deletes the memory file. It removes the memory +from the active index and moves the file into `.archive/` with a timestamped +filename: + +``` +.archive/-.md +``` + +The active store and recall tool ignore archived files. Local management +surfaces still expose them for traceability: + +- `/memory`; +- CLI/TUI memory views; +- desktop memory panel. + +This is important because an incorrect memory can be more disruptive than no +memory, but a hard delete makes it difficult to audit how the agent reached a +bad conclusion. + +## Human Approval Contract + +Agent-initiated `remember` and `forget` calls require a fresh approval every +time. + +The controller treats these tools like plan approval: + +- Auto approval and YOLO/full-access mode do not bypass them. +- Session grants and persistent allow rules are not created for them. +- Pending memory approvals are not drained when the user toggles auto approval. + +The approval subject is generated from the tool arguments before the +`ApprovalRequest` event is emitted: + +- `remember` shows a compact preview of the name/title, normalized type, + description, and body. +- `forget` shows the memory name being archived. + +External notification hooks only receive the tool name, not the memory body, +because notification channels may be less private than the local UI. + +User-initiated memory edits in the desktop panel or CLI remain direct user +actions and do not go through the agent approval prompt. + +## Boot Wiring + +`internal/boot` registers the tools in the shared registry: + +- `history`; +- `memory`; +- `remember`; +- `forget`. + +The saved memory index still folds into the system prompt once at session start, +after the base prompt. This preserves the cache-first prefix contract. Mid-session +memory changes are injected only as transient turn-tail notes and become part of +the stable prefix on the next session. + +## UI and CLI Surfaces + +Local management surfaces distinguish active and archived memory: + +- Active memories can be searched, read, and used by the agent. +- Archived memories are read-only audit entries. +- Candidate suggestions are drafts until the user confirms them. + +The desktop `Memory()` payload always returns non-nil arrays for docs, facts, +archives, and scopes. This is a Wails JSON contract: nil Go slices encode as +`null`, while the frontend expects arrays for `.map` and `.length`. + +## Test Coverage + +The change is covered across layers: + +- retrieval scoring, snippets, tokenizer behavior, and relative-score trimming; +- `history` search, global/archive scope, tool input/error indexing, + common-word-noise trimming, path confinement, and `around`; +- `memory` search/read/list, type filtering, 0-result fallback guidance, archived + memory exclusion, and validation; +- archive-on-forget file movement, index updates, timestamp parsing, ordering, + and read-only file repair; +- controller approval behavior under ask/auto/YOLO, including fresh approval and + approval-preview visibility; +- boot-level tool registration and real model tool-call execution; +- desktop `Memory()` payload shape for active and archived facts; +- desktop memory/skill candidate generation, confirmation writes, and non-nil + suggestion arrays; +- frontend CSS and TypeScript checks with generated Wails bindings. + +## Operational Notes + +- Prefer distinctive search terms: function names, command fragments, error + text, ticket IDs, file names, and decision keywords. +- Use `history` when the original wording or tool output matters. +- Use `memory` when looking for approved, stable conclusions. +- Archive wrong facts instead of overwriting them when the old fact should no + longer influence the agent and should remain traceable. diff --git a/docs/SPEC.md b/docs/SPEC.md index e635a92b0..6a2031ca4 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -192,6 +192,28 @@ Long tasks eventually fill the model's context window. Reasonix manages this wit never begins with an orphan tool message whose `tool_calls` were summarized away. - The dropped originals are archived to `~/.config/reasonix/archive/.jsonl` (one message per line), so the full history stays traceable. +- The read-only `history` tool gives the agent on-demand BM25 retrieval over + saved session JSONL files. `scope="project"` searches the current controller's + session directory; `scope="global"` also searches the user-global session + directory and compacted-history archives. `operation="around"` can then read a + bounded transcript window around a returned hit. Search keeps the best hit and + trims trailing common-word-only noise with a relative score floor; a 0-result + response tells the agent how to retry with rarer terms or widen scope. +- The read-only `memory` tool gives the agent on-demand search/list/read access + to saved auto-memory files. It complements the writer tools: `memory` checks + what already exists, `remember` saves or updates a fact, and `forget` removes + a stale one from the active index while archiving the file for traceability. + Archived memory files are visible in local management surfaces (`/memory`, + TUI, desktop panel) but are excluded from active-memory retrieval. Memory + search uses the same relative BM25 floor and guides the agent to fall back to + history when exact original wording or tool output matters. +- Agent-initiated `remember` and `forget` calls require a fresh human approval + each time, even when tool auto-approval or YOLO/full-access mode is enabled. + The approval request includes a compact preview of the memory being saved or + archived, while external notification hooks only receive the tool name. + User-initiated memory edits in the local UI are already explicit user actions. + See [`SESSION_MEMORY_RETRIEVAL.md`](SESSION_MEMORY_RETRIEVAL.md) for the + detailed implementation contract. **What survives a fold.** A fact the user states in a normal-sized turn is kept verbatim and is never summarized away — at any point in the session, across any diff --git a/internal/boot/boot.go b/internal/boot/boot.go index 4347a5074..cfd3b4e4f 100644 --- a/internal/boot/boot.go +++ b/internal/boot/boot.go @@ -26,6 +26,7 @@ import ( "reasonix/internal/config" "reasonix/internal/control" "reasonix/internal/event" + "reasonix/internal/history" "reasonix/internal/hook" "reasonix/internal/installsource" "reasonix/internal/instruction" @@ -193,6 +194,11 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) { allSkills := allSkillStore.List() sysPrompt = skill.ApplyIndex(sysPrompt, skills) + sessionDir := opts.SessionDir + if sessionDir == "" { + sessionDir = config.SessionDir() + } + reg := tool.NewRegistry() bashSpec := sandbox.Spec{Mode: cfg.BashMode(), WriteRoots: cfg.WriteRootsForRoot(root), Network: cfg.Sandbox.Network} shell := sandbox.ResolveShell(cfg.Tools.Shell.Prefer, cfg.Tools.Shell.Path, stderr) @@ -457,9 +463,11 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) { WithTranscripts(subagentStore, root, modelName, entry.Effort). WithTranscriptIdentityResolver(subagentIdentity)) - // The `remember` tool lets the model persist durable facts to the project's - // auto-memory store; `forget` prunes ones that turn out wrong. The saved index - // loads into the prefix on the next session. + // The `memory` tool searches/reads saved facts on demand; `remember` persists + // durable facts to the project's auto-memory store; `forget` prunes ones that + // turn out wrong. The saved index loads into the prefix on the next session. + reg.Add(history.NewTool(history.Options{SessionDir: sessionDir, GlobalSessionDir: config.SessionDir(), ArchiveDir: config.ArchiveDir()})) + reg.Add(memory.NewRecallTool(mem.Store)) reg.Add(memory.NewRememberTool(mem.Store)) reg.Add(memory.NewForgetTool(mem.Store)) @@ -701,11 +709,6 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) { classifier = control.NewProviderAutoPlanClassifier(classifierProv) } - sessionDir := opts.SessionDir - if sessionDir == "" { - sessionDir = config.SessionDir() - } - ctrlOpts := control.Options{ Runner: runner, Executor: executor, diff --git a/internal/boot/boot_test.go b/internal/boot/boot_test.go index c1572f9e8..c8345133c 100644 --- a/internal/boot/boot_test.go +++ b/internal/boot/boot_test.go @@ -19,9 +19,11 @@ import ( "time" "reasonix/internal/agent" + "reasonix/internal/agent/testutil" "reasonix/internal/builtinmcp" "reasonix/internal/config" "reasonix/internal/event" + "reasonix/internal/memory" "reasonix/internal/netclient" "reasonix/internal/plugin" "reasonix/internal/provider" @@ -85,6 +87,168 @@ api_key_env = "REASONIX_TEST_KEY_UNSET" } } +func TestBuildRegistersUsableHistoryAndMemoryRetrievalTools(t *testing.T) { + isolateConfigHome(t) + dir := robustTempDir(t) + t.Chdir(dir) + + writeFile(t, dir, "reasonix.toml", ` +default_model = "test-model" + +[codegraph] +enabled = false + +[agent] +system_prompt = "BASE" + +[[providers]] +name = "test-model" +kind = "boot-retrieval-tool-test" +model = "x" +`) + + sessionDir := filepath.Join(t.TempDir(), "sessions") + if err := os.MkdirAll(sessionDir, 0o755); err != nil { + t.Fatal(err) + } + past := agent.NewSession("") + past.Add(provider.Message{Role: provider.RoleUser, Content: "Should the history layer use vector embeddings?"}) + past.Add(provider.Message{Role: provider.RoleAssistant, Content: "Decision: port lightweight BM25 history retrieval without a vector database."}) + if err := past.Save(filepath.Join(sessionDir, "past.jsonl")); err != nil { + t.Fatalf("save past session: %v", err) + } + + store := memory.StoreFor(config.MemoryUserDir(), dir) + if _, err := store.Save(memory.Memory{ + Name: "synthesis-cache-policy", + Description: "Stable conclusions should be reused from memory", + Type: memory.TypeFeedback, + Body: "Use a synthesis cache document when expensive retrieval produced a stable conclusion.", + }); err != nil { + t.Fatalf("save memory: %v", err) + } + + registerBootRetrievalToolTestProvider() + prov := testutil.NewMock("boot-retrieval-tool-test", + testutil.Turn{ToolCalls: []provider.ToolCall{ + {ID: "history-1", Name: "history", Arguments: `{"operation":"search","query":"BM25 vector database","scope":"project","limit":5}`}, + {ID: "memory-1", Name: "memory", Arguments: `{"operation":"search","query":"synthesis cache stable conclusion","limit":5}`}, + }}, + testutil.Turn{Text: "done"}, + ) + setBootRetrievalToolTestProvider(t, prov) + + ctrl, err := Build(context.Background(), Options{Sink: event.Discard, SessionDir: sessionDir}) + if err != nil { + t.Fatalf("Build: %v", err) + } + defer ctrl.Close() + + sys := systemMessage(ctrl.History()) + for _, forbidden := range []string{ + "Decision: port lightweight BM25 history retrieval without a vector database.", + "Use a synthesis cache document when expensive retrieval produced a stable conclusion.", + } { + if strings.Contains(sys, forbidden) { + t.Fatalf("retrieval content should stay behind on-demand tools, not enter the cache-stable system prompt:\n%s", sys) + } + } + + if err := ctrl.Run(context.Background(), "recover past context"); err != nil { + t.Fatalf("Run: %v", err) + } + reqs := prov.Requests() + if len(reqs) == 0 { + t.Fatal("provider received no requests") + } + for _, want := range []string{"history", "memory", "remember", "forget"} { + if !requestHasTool(reqs[0], want) { + t.Fatalf("first request missing tool %q; tools=%v", want, toolSchemaNames(reqs[0].Tools)) + } + } + assertToolOrder(t, reqs[0].Tools, []string{"forget", "history", "memory", "remember"}) + + toolResults := map[string]string{} + for _, msg := range ctrl.History() { + if msg.Role == provider.RoleTool { + toolResults[msg.Name] += "\n" + msg.Content + } + } + if !strings.Contains(toolResults["history"], "port lightweight BM25 history retrieval") { + t.Fatalf("history tool result did not include saved session decision:\n%s", toolResults["history"]) + } + if !strings.Contains(toolResults["memory"], "synthesis-cache-policy") || + !strings.Contains(toolResults["memory"], "stable conclusion") { + t.Fatalf("memory tool result did not include saved memory:\n%s", toolResults["memory"]) + } +} + +const bootRetrievalToolTestProviderKind = "boot-retrieval-tool-test" + +var ( + bootRetrievalToolTestProviderOnce sync.Once + bootRetrievalToolTestProviderCurrent *testutil.MockProvider + bootRetrievalToolTestProviderMu sync.Mutex +) + +func registerBootRetrievalToolTestProvider() { + bootRetrievalToolTestProviderOnce.Do(func() { + provider.Register(bootRetrievalToolTestProviderKind, func(provider.Config) (provider.Provider, error) { + bootRetrievalToolTestProviderMu.Lock() + defer bootRetrievalToolTestProviderMu.Unlock() + if bootRetrievalToolTestProviderCurrent == nil { + return nil, errors.New("boot retrieval tool test provider is not installed") + } + return bootRetrievalToolTestProviderCurrent, nil + }) + }) +} + +func setBootRetrievalToolTestProvider(t *testing.T, p *testutil.MockProvider) { + t.Helper() + bootRetrievalToolTestProviderMu.Lock() + bootRetrievalToolTestProviderCurrent = p + bootRetrievalToolTestProviderMu.Unlock() + t.Cleanup(func() { + bootRetrievalToolTestProviderMu.Lock() + if bootRetrievalToolTestProviderCurrent == p { + bootRetrievalToolTestProviderCurrent = nil + } + bootRetrievalToolTestProviderMu.Unlock() + }) +} + +func requestHasTool(req provider.Request, name string) bool { + for _, schema := range req.Tools { + if schema.Name == name { + return true + } + } + return false +} + +func toolSchemaNames(tools []provider.ToolSchema) []string { + names := make([]string, 0, len(tools)) + for _, schema := range tools { + names = append(names, schema.Name) + } + return names +} + +func assertToolOrder(t *testing.T, tools []provider.ToolSchema, want []string) { + t.Helper() + names := toolSchemaNames(tools) + next := 0 + for _, name := range names { + if next < len(want) && name == want[next] { + next++ + } + } + if next != len(want) { + t.Fatalf("tool order changed; provider-visible tool schema order affects prompt-cache shape.\nwant subsequence: %v\n got: %v", want, names) + } +} + func TestBuildSubagentSkillFailedContinuationPersistsTranscript(t *testing.T) { isolateConfigHome(t) dir := robustTempDir(t) diff --git a/internal/cli/memory.go b/internal/cli/memory.go index 8d720f89e..8ee733cbd 100644 --- a/internal/cli/memory.go +++ b/internal/cli/memory.go @@ -12,7 +12,7 @@ import ( // doesn't shell out to an editor. func (m *chatTUI) showMemory() { set := m.ctrl.Memory() - if set == nil || set.Empty() { + if set == nil || (set.Empty() && len(set.Store.ListArchived()) == 0) { m.notice(i18n.M.MemoryNone) return } diff --git a/internal/cli/memory_view.go b/internal/cli/memory_view.go index a2568efad..605d29bde 100644 --- a/internal/cli/memory_view.go +++ b/internal/cli/memory_view.go @@ -19,7 +19,9 @@ func renderMemory(width int, set *memory.Set) string { } } facts := set.Store.List() - if len(facts) > 0 || strings.TrimSpace(set.Index) != "" { + archived := set.Store.ListArchived() + hasSaved := len(facts) > 0 || strings.TrimSpace(set.Index) != "" + if hasSaved { if len(set.Docs) > 0 { b.WriteByte('\n') } @@ -36,9 +38,24 @@ func renderMemory(width int, set *memory.Set) string { } fmt.Fprintf(&b, " %s%s\n", viewCompactText(f.Name, viewBudget(width, 2+visibleWidth(meta))), meta) } - if set.Store.Dir != "" { - fmt.Fprintf(&b, " %s\n", viewCompactText(strings.TrimSpace(fmt.Sprintf(i18n.M.MemoryStoredUnderFmt, set.Store.Dir)), viewBudget(width, 2))) + } + if len(archived) > 0 { + if len(set.Docs) > 0 || hasSaved { + b.WriteByte('\n') } + b.WriteString(viewSubhead(viewCompactText(i18n.M.ListMemoryArchived, viewBudget(width, 2))) + "\n") + for _, f := range archived { + meta := string(f.Type) + if !f.ArchivedAt.IsZero() { + meta += " · " + f.ArchivedAt.Format("2006-01-02 15:04:05Z") + } + name := viewCompactText(f.Name, viewBudget(width, 2)) + fmt.Fprintf(&b, " %s %s\n", name, viewMeta(viewCompactText(meta, min(48, viewBudget(width, 2+visibleWidth(name)+2))))) + fmt.Fprintf(&b, " %s\n", viewCompactPath(f.Path, viewBudget(width, 4))) + } + } + if (hasSaved || len(archived) > 0) && set.Store.Dir != "" { + fmt.Fprintf(&b, " %s\n", viewCompactText(strings.TrimSpace(fmt.Sprintf(i18n.M.MemoryStoredUnderFmt, set.Store.Dir)), viewBudget(width, 2))) } b.WriteString("\n") b.WriteString(viewHint(viewCompactText(i18n.M.MemoryEditHint, viewBudget(width, 2)))) diff --git a/internal/control/controller.go b/internal/control/controller.go index 986c0e97a..99d33823f 100644 --- a/internal/control/controller.go +++ b/internal/control/controller.go @@ -197,6 +197,11 @@ const ( ToolApprovalYolo = "yolo" ) +const ( + memoryRememberTool = "remember" + memoryForgetTool = "forget" +) + const ( maxGoalAutoTurns = 50 goalContinueTurn = "Continue pursuing the active goal. If it is complete, provide the concise final result and end with [goal:complete]. If it is truly blocked on a user-owned decision after trying sensible defaults, end with [goal:blocked:]. Otherwise do the next useful work and end with [goal:continue]." @@ -1137,6 +1142,10 @@ func (c *Controller) newInteractiveGate() *permission.Gate { default: policy.Mode = permission.Ask } + policy.Ask = append(policy.Ask, + permission.Rule{Tool: memoryRememberTool}, + permission.Rule{Tool: memoryForgetTool}, + ) gate := permission.NewGate(policy, gateApprover{c}) gate.OnRemember = func(rule string) { if c.onRemember != nil { @@ -2416,7 +2425,7 @@ func (c *Controller) SetMode(plan, autoApproveTools bool) { func (c *Controller) drainApprovalsLocked(includeExplicitAsk bool) []chan approvalReply { pending := make([]chan approvalReply, 0, len(c.approvals)) for id, approval := range c.approvals { - if approval.tool == planApprovalTool { + if requiresFreshApprovalTool(approval.tool) { continue } if !includeExplicitAsk && !approval.autoDrain { @@ -2490,10 +2499,30 @@ func (c *Controller) SaveDoc(path, body string) (string, error) { return written, nil } -// ForgetMemory deletes a saved auto-memory by name — the panel/TUI delete action, +// SaveMemory writes an active auto-memory fact and refreshes the in-session +// snapshot. It is the explicit user-confirmed counterpart to the model-owned +// remember tool, used by management surfaces that preview a candidate first. +func (c *Controller) SaveMemory(m memory.Memory) (string, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.mem == nil { + return "", nil + } + path, err := c.mem.Store.Save(m) + if err != nil { + return "", err + } + c.pendingMemory = append(c.pendingMemory, + "Saved memory \""+m.Name+"\": "+strings.Join(strings.Fields(m.Description), " ")) + c.refreshMemoryLocked() + return path, nil +} + +// ForgetMemory removes a saved auto-memory by name — the panel/TUI forget action, // the manual counterpart to the model's `forget` tool. It queues a turn-tail note -// so the deletion applies this session (the cached prefix still lists the fact -// until the next session re-folds the index). +// so the removal applies this session (the cached prefix still lists the fact +// until the next session re-folds the index). The file is archived for +// traceability by Store.Delete. func (c *Controller) ForgetMemory(name string) error { c.mu.Lock() defer c.mu.Unlock() @@ -2504,7 +2533,7 @@ func (c *Controller) ForgetMemory(name string) error { return err } c.pendingMemory = append(c.pendingMemory, - "Deleted memory \""+name+"\" — disregard its line still shown in the saved-memories index until next session.") + "Forgot memory \""+name+"\" — disregard its line still shown in the saved-memories index until next session.") c.refreshMemoryLocked() return nil } @@ -2545,6 +2574,7 @@ func (c *Controller) refreshMemoryLocked() { type gateApprover struct{ c *Controller } func (g gateApprover) Approve(ctx context.Context, tool, subject string, args json.RawMessage) (bool, bool, error) { + subject = approvalDisplaySubject(tool, subject, args) // Auto-allow without prompting while executing a just-approved plan (the plan // was the approval) or while YOLO/full-access tool auto-approval is on. Deny // rules already bit before this point, so they still block. @@ -2557,6 +2587,104 @@ func (g gateApprover) Approve(ctx context.Context, tool, subject string, args js return g.c.requestApproval(ctx, tool, subject) } +func approvalDisplaySubject(tool, subject string, args json.RawMessage) string { + switch tool { + case memoryRememberTool: + return rememberApprovalSubject(subject, args) + case memoryForgetTool: + return forgetApprovalSubject(subject, args) + default: + return subject + } +} + +func rememberApprovalSubject(fallback string, args json.RawMessage) string { + if len(args) == 0 { + return fallback + } + var in struct { + Name string `json:"name"` + Title string `json:"title"` + Description string `json:"description"` + Type string `json:"type"` + Body string `json:"body"` + } + if err := json.Unmarshal(args, &in); err != nil { + return fallback + } + name := approvalCompactText(firstNonEmpty(in.Name, in.Title)) + desc := approvalTruncate(approvalCompactText(in.Description), 180) + body := approvalTruncate(approvalCompactText(in.Body), 240) + typ := string(memory.NormalizeType(in.Type)) + + var b strings.Builder + b.WriteString("Save/update memory") + if name != "" { + fmt.Fprintf(&b, " %q", name) + } + if typ != "" { + fmt.Fprintf(&b, " [%s]", typ) + } + if desc != "" { + b.WriteString(": ") + b.WriteString(desc) + } + if body != "" { + if desc == "" { + b.WriteString(": ") + } else { + b.WriteString(" | ") + } + b.WriteString("body: ") + b.WriteString(body) + } + if b.Len() == len("Save/update memory") && fallback != "" { + return fallback + } + return b.String() +} + +func forgetApprovalSubject(fallback string, args json.RawMessage) string { + if len(args) == 0 { + return fallback + } + var in struct { + Name string `json:"name"` + } + if err := json.Unmarshal(args, &in); err != nil { + return fallback + } + name := approvalCompactText(in.Name) + if name == "" { + return fallback + } + return fmt.Sprintf("Archive memory %q", name) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +func approvalCompactText(s string) string { + return strings.Join(strings.Fields(s), " ") +} + +func approvalTruncate(s string, maxRunes int) string { + if maxRunes <= 0 { + return "" + } + runes := []rune(s) + if len(runes) <= maxRunes { + return s + } + return string(runes[:maxRunes]) + "..." +} + type seedTodo struct { Content string `json:"content"` Status string `json:"status"` @@ -2783,23 +2911,19 @@ func (c *Controller) requestApproval(ctx context.Context, tool, subject string) c.sink.Emit(event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: id, Tool: tool, Subject: subject}}) // The agent now needs the user's attention; a Notification hook can ping an // external channel (desktop notice, phone) while the run blocks on the reply. - if subject != "" { - go c.hooks.Notification(ctx, "approval needed: "+tool+" "+subject) - } else { - go c.hooks.Notification(ctx, "approval needed: "+tool) - } + go c.hooks.Notification(ctx, approvalNotificationText(tool, subject)) select { case r := <-reply: // Plan approvals are one-shot — never persist a session grant for them, or // every future plan would auto-approve. - if r.allow && r.session && tool != planApprovalTool { + if r.allow && r.session && !requiresFreshApprovalTool(tool) { rule := permission.SessionGrantRuleForScope(tool, subject) c.mu.Lock() c.granted[rule] = true c.mu.Unlock() } - if r.allow && r.persist && tool != planApprovalTool && c.onRemember != nil { + if r.allow && r.persist && !requiresFreshApprovalTool(tool) && c.onRemember != nil { c.emitRememberResult(c.onRemember(permission.RememberRuleForScope(tool, subject))) } return r.allow, false, nil @@ -2811,12 +2935,22 @@ func (c *Controller) requestApproval(ctx context.Context, tool, subject string) } } +func approvalNotificationText(tool, subject string) string { + if requiresFreshApprovalTool(tool) { + return "approval needed: " + tool + } + if subject == "" { + return "approval needed: " + tool + } + return "approval needed: " + tool + " " + subject +} + func (c *Controller) approvalBypassAllowsLocked(tool string) bool { - return tool != planApprovalTool && (c.toolApprovalMode == ToolApprovalYolo || c.approvedPlanAutoApproveTools) + return !requiresFreshApprovalTool(tool) && (c.toolApprovalMode == ToolApprovalYolo || c.approvedPlanAutoApproveTools) } func (c *Controller) autoApprovalWouldAllowLocked(tool, subject string) bool { - if tool == planApprovalTool { + if requiresFreshApprovalTool(tool) { return false } policy := c.policy @@ -2825,6 +2959,9 @@ func (c *Controller) autoApprovalWouldAllowLocked(tool, subject string) bool { } func (c *Controller) sessionGrantAllowsLocked(tool, subject string) bool { + if requiresFreshApprovalTool(tool) { + return false + } for rule := range c.granted { if permission.RuleMatchesString(rule, tool, subject) { return true @@ -2833,6 +2970,15 @@ func (c *Controller) sessionGrantAllowsLocked(tool, subject string) bool { return false } +func requiresFreshApprovalTool(tool string) bool { + switch tool { + case planApprovalTool, memoryRememberTool, memoryForgetTool: + return true + default: + return false + } +} + func (c *Controller) emitRememberResult(r RememberResult) { if r.Err != nil { c.sink.Emit(event.Event{ diff --git a/internal/control/controller_test.go b/internal/control/controller_test.go index 4256bcc94..bf251f9e0 100644 --- a/internal/control/controller_test.go +++ b/internal/control/controller_test.go @@ -308,6 +308,82 @@ func TestApprovalAllowOnce(t *testing.T) { } } +func TestMemoryApprovalRequestShowsRememberPayload(t *testing.T) { + approvals := make(chan event.Approval, 1) + c := New(Options{Sink: event.FuncSink(func(e event.Event) { + if e.Kind == event.ApprovalRequest { + approvals <- e.Approval + } + })}) + + args := json.RawMessage(`{ + "name": "stable-retrieval-conclusion", + "description": "History retrieval should reuse stable synthesized conclusions.", + "type": "feedback", + "body": "**Why:** repeated history scans are expensive.\n\n**How to apply:** save the stable summary as a memory document." + }`) + result := make(chan string, 1) + go func() { + allow, _, err := gateApprover{c}.Approve(context.Background(), "remember", "", args) + if err != nil { + result <- err.Error() + return + } + if !allow { + result <- "memory approval denied" + return + } + result <- "" + }() + + var approval event.Approval + select { + case approval = <-approvals: + case <-time.After(2 * time.Second): + t.Fatal("memory approval request was not emitted") + } + for _, want := range []string{ + `Save/update memory "stable-retrieval-conclusion"`, + "[feedback]", + "History retrieval should reuse stable synthesized conclusions.", + "repeated history scans are expensive", + "save the stable summary", + } { + if !strings.Contains(approval.Subject, want) { + t.Fatalf("approval subject %q does not contain %q", approval.Subject, want) + } + } + if strings.Contains(approval.Subject, "\n") { + t.Fatalf("approval subject should be compact for TUI rendering, got %q", approval.Subject) + } + + c.Approve(approval.ID, true, true, true) + select { + case msg := <-result: + if msg != "" { + t.Fatalf("Approve returned %s", msg) + } + case <-time.After(2 * time.Second): + t.Fatal("memory approval stayed blocked after Approve") + } +} + +func TestMemoryApprovalSubjectsAndNotifications(t *testing.T) { + forgetSubject := approvalDisplaySubject("forget", "", json.RawMessage(`{"name":"wrong-memory"}`)) + if forgetSubject != `Archive memory "wrong-memory"` { + t.Fatalf("forget approval subject = %q", forgetSubject) + } + if got := approvalNotificationText("remember", "Save/update memory with private details"); got != "approval needed: remember" { + t.Fatalf("remember notification = %q", got) + } + if got := approvalNotificationText("forget", `Archive memory "wrong-memory"`); got != "approval needed: forget" { + t.Fatalf("forget notification = %q", got) + } + if got := approvalNotificationText("bash", "go test ./..."); got != "approval needed: bash go test ./..." { + t.Fatalf("bash notification = %q", got) + } +} + // TestApprovalDeny confirms a declined call returns allow=false. func TestApprovalDeny(t *testing.T) { c, ids, _ := approvalIDs() diff --git a/internal/control/slash.go b/internal/control/slash.go index 18f51157b..721583025 100644 --- a/internal/control/slash.go +++ b/internal/control/slash.go @@ -486,17 +486,57 @@ func (c *Controller) providerSwitchText(name string) string { } func (c *Controller) memoryListText() string { - if c.mem == nil || len(c.mem.Docs) == 0 { + if c.mem == nil { + return i18n.M.ListMemoryNone + } + saved := c.mem.Store.List() + archived := c.mem.Store.ListArchived() + if len(c.mem.Docs) == 0 && len(saved) == 0 && len(archived) == 0 { return i18n.M.ListMemoryNone } var b strings.Builder - b.WriteString(i18n.M.ListMemoryHeader + "\n") - for _, d := range c.mem.Docs { - fmt.Fprintf(&b, " (%s) %s\n", d.Scope, d.Path) + if len(c.mem.Docs) > 0 { + b.WriteString(i18n.M.ListMemoryHeader + "\n") + for _, d := range c.mem.Docs { + fmt.Fprintf(&b, " (%s) %s\n", d.Scope, d.Path) + } + } + if len(saved) > 0 { + if b.Len() > 0 { + b.WriteString("\n") + } + b.WriteString(i18n.M.ListMemorySaved + "\n") + for _, m := range saved { + fmt.Fprintf(&b, " [%s](%s.md) (%s) %s\n", memoryDisplayTitle(m.Title, m.Name), m.Name, m.Type, memoryOneLine(m.Description)) + } + } + if len(archived) > 0 { + if b.Len() > 0 { + b.WriteString("\n") + } + b.WriteString(i18n.M.ListMemoryArchived + "\n") + for _, m := range archived { + when := "" + if !m.ArchivedAt.IsZero() { + when = " — " + m.ArchivedAt.Format("2006-01-02 15:04:05Z") + } + fmt.Fprintf(&b, " [%s](%s) (%s)%s %s\n", memoryDisplayTitle(m.Title, m.Name), m.Path, m.Type, when, memoryOneLine(m.Description)) + } } return strings.TrimRight(b.String(), "\n") } +func memoryDisplayTitle(title, name string) string { + if t := memoryOneLine(title); t != "" { + return t + } + return strings.ReplaceAll(name, "-", " ") +} + +func memoryOneLine(s string) string { + return strings.Join(strings.Fields(s), " ") +} + func (c *Controller) skillListText() string { if len(c.skills) == 0 { return i18n.M.ListSkillsNone diff --git a/internal/control/slash_test.go b/internal/control/slash_test.go index 153db2164..4fded3a72 100644 --- a/internal/control/slash_test.go +++ b/internal/control/slash_test.go @@ -1,9 +1,11 @@ package control import ( + "strings" "testing" "reasonix/internal/hook" + "reasonix/internal/memory" "reasonix/internal/skill" ) @@ -160,6 +162,53 @@ func TestSlashArgItems(t *testing.T) { } } +func TestMemoryListTextIncludesSavedMemories(t *testing.T) { + store := memory.Store{Dir: t.TempDir()} + if _, err := store.Save(memory.Memory{ + Name: "cache-first", + Title: "Cache first", + Description: "Preserve prompt cache stability", + Type: memory.TypeProject, + Body: "Use retrieval tools instead of dynamic prefix injection.", + }); err != nil { + t.Fatal(err) + } + c := New(Options{Memory: &memory.Set{Store: store}}) + out := c.memoryListText() + for _, want := range []string{"saved memories", "[Cache first](cache-first.md)", "Preserve prompt cache stability"} { + if !strings.Contains(out, want) { + t.Fatalf("/memory output missing %q:\n%s", want, out) + } + } +} + +func TestMemoryListTextIncludesArchivedMemories(t *testing.T) { + store := memory.Store{Dir: t.TempDir()} + if _, err := store.Save(memory.Memory{ + Name: "stale-plan", + Title: "Stale plan", + Description: "Superseded by the new retrieval design", + Type: memory.TypeProject, + Body: "Old plan body.", + }); err != nil { + t.Fatal(err) + } + archive, err := store.Archive("stale-plan") + if err != nil { + t.Fatal(err) + } + c := New(Options{Memory: &memory.Set{Store: store}}) + out := c.memoryListText() + for _, want := range []string{"archived memories", "[Stale plan](" + archive + ")", "Superseded by the new retrieval design"} { + if !strings.Contains(out, want) { + t.Fatalf("/memory output missing %q:\n%s", want, out) + } + } + if strings.Contains(out, "saved memories\n [Stale plan]") { + t.Fatalf("archived memory should not appear as active saved memory:\n%s", out) + } +} + func TestManagementHooksTrustUsesWorkspaceRoot(t *testing.T) { home := t.TempDir() project := t.TempDir() diff --git a/internal/control/yolo_test.go b/internal/control/yolo_test.go index a58c16605..e92fac674 100644 --- a/internal/control/yolo_test.go +++ b/internal/control/yolo_test.go @@ -125,6 +125,59 @@ func TestRequestApprovalHonorsAutoApproveTools(t *testing.T) { } } +func TestMemoryApprovalIgnoresAutoApproveTools(t *testing.T) { + approvalRequests := make(chan event.Approval, 1) + c := New(Options{ + Sink: event.FuncSink(func(e event.Event) { + if e.Kind == event.ApprovalRequest { + approvalRequests <- e.Approval + } + }), + }) + c.SetAutoApproveTools(true) + + done := make(chan bool, 1) + errs := make(chan error, 1) + go func() { + allow, _, err := c.requestApproval(context.Background(), "remember", "") + if err != nil { + errs <- err + return + } + done <- allow + }() + + var approval event.Approval + select { + case approval = <-approvalRequests: + case <-time.After(2 * time.Second): + t.Fatal("memory approval request was not emitted under tool auto-approval") + } + if approval.Tool != "remember" { + t.Fatalf("approval tool = %q, want remember", approval.Tool) + } + + select { + case err := <-errs: + t.Fatalf("requestApproval: %v", err) + case allow := <-done: + t.Fatalf("memory approval must wait for manual approval, got allow=%v", allow) + case <-time.After(50 * time.Millisecond): + } + + c.Approve(approval.ID, true, true, true) + select { + case err := <-errs: + t.Fatalf("requestApproval: %v", err) + case allow := <-done: + if !allow { + t.Fatal("manual approval should allow memory write") + } + case <-time.After(2 * time.Second): + t.Fatal("memory approval stayed blocked after Approve") + } +} + func TestToolApprovalModeAutoKeepsAskRules(t *testing.T) { c := New(Options{ Policy: permission.New("ask", nil, []string{"bash(git commit*)"}, []string{"bash(rm*)"}), @@ -146,6 +199,18 @@ func TestToolApprovalModeAutoKeepsAskRules(t *testing.T) { } } +func TestToolApprovalModeAutoForcesMemoryAskRules(t *testing.T) { + c := New(Options{}) + c.SetToolApprovalMode(ToolApprovalAuto) + + gate := c.newInteractiveGate() + for _, toolName := range []string{"remember", "forget"} { + if got := gate.Policy.Decide(toolName, false, json.RawMessage(`{}`)); got != permission.Ask { + t.Fatalf("%s under auto mode = %v, want ask", toolName, got) + } + } +} + func TestToolApprovalModeAutoDrainsPendingFallbackApproval(t *testing.T) { approvalRequests := make(chan event.Approval, 1) c := New(Options{ @@ -404,6 +469,57 @@ func TestSetAutoApproveToolsDoesNotDrainPendingPlanApproval(t *testing.T) { } } +func TestSetAutoApproveToolsDoesNotDrainPendingMemoryApproval(t *testing.T) { + approvalRequests := make(chan event.Approval, 1) + c := New(Options{ + Sink: event.FuncSink(func(e event.Event) { + if e.Kind == event.ApprovalRequest { + approvalRequests <- e.Approval + } + }), + }) + + done := make(chan bool, 1) + errs := make(chan error, 1) + go func() { + allow, _, err := c.requestApproval(context.Background(), "forget", "") + if err != nil { + errs <- err + return + } + done <- allow + }() + + var approval event.Approval + select { + case approval = <-approvalRequests: + case <-time.After(2 * time.Second): + t.Fatal("memory approval request was not emitted") + } + + c.SetAutoApproveTools(true) + + select { + case err := <-errs: + t.Fatalf("requestApproval: %v", err) + case allow := <-done: + t.Fatalf("SetAutoApproveTools must not auto-answer pending memory approval; got allow=%v", allow) + case <-time.After(50 * time.Millisecond): + } + + c.Approve(approval.ID, true, true, true) + select { + case err := <-errs: + t.Fatalf("requestApproval: %v", err) + case allow := <-done: + if !allow { + t.Fatal("manual approval should allow memory archive") + } + case <-time.After(2 * time.Second): + t.Fatal("memory approval stayed blocked after Approve") + } +} + // TestSetModeYoloDrainsPendingApproval is the SetMode-path twin of the // SetAutoApproveTools case: applying YOLO atomically must also unblock an // approval already waiting. diff --git a/internal/history/search.go b/internal/history/search.go new file mode 100644 index 000000000..4571443bb --- /dev/null +++ b/internal/history/search.go @@ -0,0 +1,527 @@ +package history + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "reasonix/internal/agent" + "reasonix/internal/provider" + "reasonix/internal/retrieval" +) + +// Kind identifies the part of a saved message indexed for retrieval. +type Kind string + +const ( + KindUserText Kind = "user_text" + KindAssistantText Kind = "assistant_text" + KindToolInput Kind = "tool_input" + KindToolError Kind = "tool_error" + KindToolOutput Kind = "tool_output" +) + +const ( + scopeProject = "project" + scopeGlobal = "global" + + defaultLimit = 8 + maxLimit = 20 + defaultAround = 3 + maxAround = 10 + maxSnippet = 240 + scoreFloor = 0.15 +) + +var defaultKinds = map[Kind]bool{ + KindUserText: true, + KindAssistantText: true, + KindToolInput: true, + KindToolError: true, +} + +// Options binds a Searcher to the session/history roots it may read. +type Options struct { + // SessionDir is the current controller's session directory. In desktop this + // is usually project-scoped; in CLI it is often the user-global session dir. + SessionDir string + // GlobalSessionDir is the user-global session directory. It is searched only + // when the caller asks for global scope, and may equal SessionDir. + GlobalSessionDir string + ArchiveDir string +} + +// Searcher performs lightweight BM25 retrieval over saved session JSONL files. +type Searcher struct { + sessionDir string + globalSessionDir string + archiveDir string +} + +// NewSearcher returns a searcher confined to the supplied directories. +func NewSearcher(opts Options) *Searcher { + return &Searcher{ + sessionDir: strings.TrimSpace(opts.SessionDir), + globalSessionDir: strings.TrimSpace(opts.GlobalSessionDir), + archiveDir: strings.TrimSpace(opts.ArchiveDir), + } +} + +// SearchRequest describes a history search. +type SearchRequest struct { + Query string + Scope string + Kinds []Kind + ToolName string + Limit int +} + +// AroundRequest fetches messages adjacent to a search hit. +type AroundRequest struct { + SessionPath string + MessageIndex int + Before int + After int +} + +// Hit is a ranked search result. +type Hit struct { + Score float64 + SessionPath string + SessionID string + Source string + MessageIndex int + Role provider.Role + Kind Kind + ToolName string + Snippet string +} + +// MessageContext is one message returned by Around. +type MessageContext struct { + Index int + Text string +} + +type sourceFile struct { + path string + source string + mod int64 +} + +type document struct { + source sourceFile + messageIndex int + role provider.Role + kind Kind + toolName string + text string + counts map[string]int + length int +} + +// Search ranks saved history by BM25. It indexes only the selected documents for +// this call, which keeps the implementation dependency-free and cache-neutral. +func (s *Searcher) Search(ctx context.Context, req SearchRequest) ([]Hit, error) { + query := strings.TrimSpace(req.Query) + if query == "" { + return nil, fmt.Errorf("query is required") + } + queryTerms, err := retrieval.QueryTerms(query) + if err != nil { + return nil, err + } + scope, err := normalizeScope(req.Scope) + if err != nil { + return nil, err + } + limit := clamp(req.Limit, defaultLimit, maxLimit) + kindSet, err := normalizeKinds(req.Kinds) + if err != nil { + return nil, err + } + toolName := strings.TrimSpace(req.ToolName) + + sources, err := s.sources(scope) + if err != nil { + return nil, err + } + var docs []document + for _, src := range sources { + if err := ctx.Err(); err != nil { + return nil, err + } + msgs, err := loadMessages(src.path) + if err != nil { + continue + } + docs = append(docs, extractDocuments(src, msgs, kindSet, toolName)...) + } + if len(docs) == 0 { + return nil, nil + } + + df := map[string]int{} + totalLen := 0 + for i := range docs { + totalLen += docs[i].length + seen := map[string]bool{} + for term := range docs[i].counts { + if !seen[term] { + df[term]++ + seen[term] = true + } + } + } + avgLen := float64(totalLen) / float64(len(docs)) + if avgLen <= 0 { + avgLen = 1 + } + + var hits []Hit + for _, doc := range docs { + score := retrieval.BM25Score(doc.counts, doc.length, queryTerms, df, len(docs), avgLen) + if score <= 0 { + continue + } + hits = append(hits, Hit{ + Score: score, + SessionPath: doc.source.path, + SessionID: sessionID(doc.source.path), + Source: doc.source.source, + MessageIndex: doc.messageIndex, + Role: doc.role, + Kind: doc.kind, + ToolName: doc.toolName, + Snippet: retrieval.MakeSnippet(doc.text, query, queryTerms, maxSnippet), + }) + } + sort.Slice(hits, func(i, j int) bool { + if hits[i].Score == hits[j].Score { + if hits[i].SessionPath == hits[j].SessionPath { + return hits[i].MessageIndex < hits[j].MessageIndex + } + return hits[i].SessionPath < hits[j].SessionPath + } + return hits[i].Score > hits[j].Score + }) + hits = retrieval.KeepTopRelativeScore(hits, scoreFloor, func(hit Hit) float64 { + return hit.Score + }) + if len(hits) > limit { + hits = hits[:limit] + } + return hits, nil +} + +// Around returns a compact transcript window around a saved message. +func (s *Searcher) Around(ctx context.Context, req AroundRequest) ([]MessageContext, error) { + path := strings.TrimSpace(req.SessionPath) + if path == "" { + return nil, fmt.Errorf("session_path is required") + } + if req.MessageIndex < 0 { + return nil, fmt.Errorf("message_index must be non-negative") + } + if !s.allowedPath(path) { + return nil, fmt.Errorf("session_path is outside the configured history roots") + } + if err := ctx.Err(); err != nil { + return nil, err + } + msgs, err := loadMessages(path) + if err != nil { + return nil, err + } + if req.MessageIndex >= len(msgs) { + return nil, fmt.Errorf("message_index %d is outside session length %d", req.MessageIndex, len(msgs)) + } + before := clamp(req.Before, defaultAround, maxAround) + after := clamp(req.After, defaultAround, maxAround) + start := req.MessageIndex - before + if start < 0 { + start = 0 + } + end := req.MessageIndex + after + 1 + if end > len(msgs) { + end = len(msgs) + } + out := make([]MessageContext, 0, end-start) + for i := start; i < end; i++ { + out = append(out, MessageContext{Index: i, Text: renderMessage(i, msgs[i])}) + } + return out, nil +} + +func normalizeScope(scope string) (string, error) { + switch strings.TrimSpace(scope) { + case "", scopeProject: + return scopeProject, nil + case scopeGlobal: + return scopeGlobal, nil + default: + return "", fmt.Errorf("scope must be %q or %q", scopeProject, scopeGlobal) + } +} + +func normalizeKinds(kinds []Kind) (map[Kind]bool, error) { + if len(kinds) == 0 { + out := make(map[Kind]bool, len(defaultKinds)) + for k, v := range defaultKinds { + out[k] = v + } + return out, nil + } + out := map[Kind]bool{} + for _, k := range kinds { + switch k { + case KindUserText, KindAssistantText, KindToolInput, KindToolError, KindToolOutput: + out[k] = true + default: + return nil, fmt.Errorf("unknown kind %q", k) + } + } + return out, nil +} + +func (s *Searcher) sources(scope string) ([]sourceFile, error) { + var out []sourceFile + seen := map[string]bool{} + out = appendSessionSources(out, seen, s.sessionDir, scopeProject) + if scope == scopeGlobal { + out = appendSessionSources(out, seen, s.globalSessionDir, scopeGlobal) + out = appendFiles(out, seen, listJSONL(s.archiveDir, "archive")...) + } + sort.Slice(out, func(i, j int) bool { + if out[i].mod == out[j].mod { + return out[i].path < out[j].path + } + return out[i].mod > out[j].mod + }) + return out, nil +} + +func appendSessionSources(out []sourceFile, seen map[string]bool, dir, source string) []sourceFile { + out = appendFiles(out, seen, listJSONL(dir, source)...) + if strings.TrimSpace(dir) != "" { + out = appendFiles(out, seen, listJSONL(filepath.Join(dir, "subagents"), source)...) + } + return out +} + +func appendFiles(out []sourceFile, seen map[string]bool, files ...sourceFile) []sourceFile { + for _, file := range files { + key := file.path + if abs, err := filepath.Abs(file.path); err == nil { + key = abs + } + if seen[key] { + continue + } + seen[key] = true + out = append(out, file) + } + return out +} + +func listJSONL(dir, source string) []sourceFile { + if strings.TrimSpace(dir) == "" { + return nil + } + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + var out []sourceFile + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".jsonl" { + continue + } + info, err := entry.Info() + if err != nil { + continue + } + out = append(out, sourceFile{ + path: filepath.Join(dir, entry.Name()), + source: source, + mod: info.ModTime().UnixNano(), + }) + } + return out +} + +func loadMessages(path string) ([]provider.Message, error) { + sess, err := agent.LoadSession(path) + if err != nil { + return nil, err + } + return sess.Snapshot(), nil +} + +func extractDocuments(src sourceFile, msgs []provider.Message, kinds map[Kind]bool, toolName string) []document { + var docs []document + for i, msg := range msgs { + switch msg.Role { + case provider.RoleUser: + if kinds[KindUserText] && strings.TrimSpace(msg.Content) != "" { + docs = appendDoc(docs, src, i, msg.Role, KindUserText, "", stripComposePrefixes(msg.Content)) + } + case provider.RoleAssistant: + if kinds[KindAssistantText] && strings.TrimSpace(msg.Content) != "" { + docs = appendDoc(docs, src, i, msg.Role, KindAssistantText, "", msg.Content) + } + if kinds[KindToolInput] { + for _, call := range msg.ToolCalls { + if toolName != "" && call.Name != toolName { + continue + } + text := strings.TrimSpace(call.Name + " " + call.Arguments) + docs = appendDoc(docs, src, i, msg.Role, KindToolInput, call.Name, text) + } + } + case provider.RoleTool: + if toolName != "" && msg.Name != toolName { + continue + } + if kinds[KindToolError] && isToolError(msg.Content) { + docs = appendDoc(docs, src, i, msg.Role, KindToolError, msg.Name, msg.Name+" "+msg.Content) + } + if kinds[KindToolOutput] { + docs = appendDoc(docs, src, i, msg.Role, KindToolOutput, msg.Name, msg.Name+" "+msg.Content) + } + } + } + return docs +} + +func appendDoc(docs []document, src sourceFile, idx int, role provider.Role, kind Kind, toolName, text string) []document { + text = strings.TrimSpace(text) + if text == "" { + return docs + } + terms := retrieval.Tokens(text) + if len(terms) == 0 { + return docs + } + counts := retrieval.Counts(terms) + return append(docs, document{ + source: src, + messageIndex: idx, + role: role, + kind: kind, + toolName: toolName, + text: text, + counts: counts, + length: len(terms), + }) +} + +func isToolError(content string) bool { + s := strings.ToLower(strings.TrimSpace(content)) + return strings.HasPrefix(s, "error:") || + strings.HasPrefix(s, "blocked:") || + strings.Contains(s, "permission denied") +} + +func sessionID(path string) string { + base := filepath.Base(path) + return strings.TrimSuffix(base, filepath.Ext(base)) +} + +func renderMessage(idx int, msg provider.Message) string { + var b strings.Builder + switch msg.Role { + case provider.RoleUser: + fmt.Fprintf(&b, "[%d user]\n%s", idx, truncate(stripComposePrefixes(msg.Content), 2000)) + case provider.RoleAssistant: + if strings.TrimSpace(msg.Content) != "" { + fmt.Fprintf(&b, "[%d assistant]\n%s", idx, truncate(msg.Content, 2000)) + } else { + fmt.Fprintf(&b, "[%d assistant]", idx) + } + for _, call := range msg.ToolCalls { + fmt.Fprintf(&b, "\n[tool call: %s]\n%s", call.Name, truncate(call.Arguments, 1200)) + } + case provider.RoleTool: + fmt.Fprintf(&b, "[%d tool %s result]\n%s", idx, msg.Name, truncate(msg.Content, 2000)) + case provider.RoleSystem: + fmt.Fprintf(&b, "[%d system]\n%s", idx, truncate(msg.Content, 1200)) + default: + fmt.Fprintf(&b, "[%d %s]\n%s", idx, msg.Role, truncate(msg.Content, 2000)) + } + return strings.TrimSpace(b.String()) +} + +func truncate(s string, maxRunes int) string { + s = strings.TrimSpace(s) + runes := []rune(s) + if len(runes) <= maxRunes { + return s + } + return string(runes[:maxRunes]) + "..." +} + +func clamp(n, def, max int) int { + if n <= 0 { + return def + } + if n > max { + return max + } + return n +} + +func (s *Searcher) allowedPath(path string) bool { + roots := []string{s.sessionDir, s.globalSessionDir, s.archiveDir} + if s.sessionDir != "" { + roots = append(roots, filepath.Join(s.sessionDir, "subagents")) + } + if s.globalSessionDir != "" { + roots = append(roots, filepath.Join(s.globalSessionDir, "subagents")) + } + for _, root := range roots { + if underRoot(path, root) { + return true + } + } + return false +} + +func underRoot(path, root string) bool { + if strings.TrimSpace(path) == "" || strings.TrimSpace(root) == "" { + return false + } + absPath, err := filepath.Abs(path) + if err != nil { + return false + } + absRoot, err := filepath.Abs(root) + if err != nil { + return false + } + rel, err := filepath.Rel(absRoot, absPath) + if err != nil { + return false + } + return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) +} + +// MarshalJSON keeps Hit stable if frontends choose to expose the same data later. +func (h Hit) MarshalJSON() ([]byte, error) { + type hit struct { + Score float64 `json:"score"` + SessionPath string `json:"session_path"` + SessionID string `json:"session_id"` + Source string `json:"source"` + MessageIndex int `json:"message_index"` + Role provider.Role `json:"role"` + Kind Kind `json:"kind"` + ToolName string `json:"tool_name,omitempty"` + Snippet string `json:"snippet"` + } + return json.Marshal(hit(h)) +} diff --git a/internal/history/search_test.go b/internal/history/search_test.go new file mode 100644 index 000000000..f786167f2 --- /dev/null +++ b/internal/history/search_test.go @@ -0,0 +1,197 @@ +package history + +import ( + "context" + "path/filepath" + "strings" + "testing" + + "reasonix/internal/agent" + "reasonix/internal/provider" +) + +func TestSearchRanksSavedSessionHistory(t *testing.T) { + sessionDir := t.TempDir() + archiveDir := t.TempDir() + + writeSession(t, filepath.Join(sessionDir, "first.jsonl"), []provider.Message{ + {Role: provider.RoleUser, Content: "We need a cache-first implementation."}, + {Role: provider.RoleAssistant, Content: "Decision: keep the prefix stable and avoid CGO SQLite for Reasonix history retrieval."}, + }) + writeSession(t, filepath.Join(sessionDir, "second.jsonl"), []provider.Message{ + {Role: provider.RoleUser, Content: "Talk about dashboard colors."}, + {Role: provider.RoleAssistant, Content: "No database decision here."}, + }) + + searcher := NewSearcher(Options{SessionDir: sessionDir, ArchiveDir: archiveDir}) + hits, err := searcher.Search(context.Background(), SearchRequest{Query: "SQLite CGO cache", Limit: 5}) + if err != nil { + t.Fatalf("Search() error = %v", err) + } + if len(hits) == 0 { + t.Fatal("Search() returned no hits") + } + if got := filepath.Base(hits[0].SessionPath); got != "first.jsonl" { + t.Fatalf("top hit path = %q, want first.jsonl", got) + } + if hits[0].Kind != KindAssistantText { + t.Fatalf("top hit kind = %q, want %q", hits[0].Kind, KindAssistantText) + } +} + +func TestSearchGlobalIncludesArchives(t *testing.T) { + sessionDir := t.TempDir() + archiveDir := t.TempDir() + writeSession(t, filepath.Join(archiveDir, "archive.jsonl"), []provider.Message{ + {Role: provider.RoleUser, Content: "Old decision: Obelisk retrieval query runtime stays code-driven."}, + }) + + searcher := NewSearcher(Options{SessionDir: sessionDir, ArchiveDir: archiveDir}) + projectHits, err := searcher.Search(context.Background(), SearchRequest{Query: "Obelisk runtime", Scope: "project"}) + if err != nil { + t.Fatalf("project Search() error = %v", err) + } + if len(projectHits) != 0 { + t.Fatalf("project Search() hits = %d, want 0", len(projectHits)) + } + globalHits, err := searcher.Search(context.Background(), SearchRequest{Query: "Obelisk runtime", Scope: "global"}) + if err != nil { + t.Fatalf("global Search() error = %v", err) + } + if len(globalHits) != 1 { + t.Fatalf("global Search() hits = %d, want 1", len(globalHits)) + } + if globalHits[0].Source != "archive" { + t.Fatalf("global hit source = %q, want archive", globalHits[0].Source) + } +} + +func TestSearchGlobalIncludesGlobalSessionDir(t *testing.T) { + projectDir := t.TempDir() + globalDir := t.TempDir() + writeSession(t, filepath.Join(projectDir, "project.jsonl"), []provider.Message{ + {Role: provider.RoleUser, Content: "Project-only decision about local UI spacing."}, + }) + globalPath := filepath.Join(globalDir, "global.jsonl") + writeSession(t, globalPath, []provider.Message{ + {Role: provider.RoleUser, Content: "Global-only decision about synthesis cache reuse."}, + }) + + searcher := NewSearcher(Options{SessionDir: projectDir, GlobalSessionDir: globalDir}) + projectHits, err := searcher.Search(context.Background(), SearchRequest{Query: "synthesis cache reuse", Scope: "project"}) + if err != nil { + t.Fatalf("project Search() error = %v", err) + } + if len(projectHits) != 0 { + t.Fatalf("project Search() hits = %d, want 0", len(projectHits)) + } + globalHits, err := searcher.Search(context.Background(), SearchRequest{Query: "synthesis cache reuse", Scope: "global"}) + if err != nil { + t.Fatalf("global Search() error = %v", err) + } + if len(globalHits) != 1 { + t.Fatalf("global Search() hits = %d, want 1", len(globalHits)) + } + if globalHits[0].Source != "global" || globalHits[0].SessionPath != globalPath { + t.Fatalf("global hit = %+v, want source=global path=%s", globalHits[0], globalPath) + } + if _, err := searcher.Around(context.Background(), AroundRequest{SessionPath: globalPath, MessageIndex: 0}); err != nil { + t.Fatalf("Around() for global session path failed: %v", err) + } +} + +func TestSearchIndexesToolInputsAndErrors(t *testing.T) { + sessionDir := t.TempDir() + writeSession(t, filepath.Join(sessionDir, "tools.jsonl"), []provider.Message{ + {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "1", Name: "bash", Arguments: `{"cmd":"go test ./internal/history"}`}}}, + {Role: provider.RoleTool, ToolCallID: "1", Name: "bash", Content: "error: command exited: exit status 1\nFAIL"}, + }) + + searcher := NewSearcher(Options{SessionDir: sessionDir}) + hits, err := searcher.Search(context.Background(), SearchRequest{Query: "go test fail", ToolName: "bash", Limit: 5}) + if err != nil { + t.Fatalf("Search() error = %v", err) + } + if len(hits) < 2 { + t.Fatalf("Search() hits = %d, want at least 2", len(hits)) + } + kinds := map[Kind]bool{} + for _, hit := range hits { + kinds[hit.Kind] = true + } + if !kinds[KindToolInput] || !kinds[KindToolError] { + t.Fatalf("hits kinds = %#v, want tool input and tool error", kinds) + } +} + +func TestSearchDropsCommonWordNoise(t *testing.T) { + sessionDir := t.TempDir() + writeSession(t, filepath.Join(sessionDir, "rare.jsonl"), []provider.Message{ + {Role: provider.RoleUser, Content: "rareterm common common common"}, + }) + for i := 0; i < 12; i++ { + writeSession(t, filepath.Join(sessionDir, "common-"+string(rune('a'+i))+".jsonl"), []provider.Message{ + {Role: provider.RoleUser, Content: "common"}, + }) + } + + searcher := NewSearcher(Options{SessionDir: sessionDir}) + hits, err := searcher.Search(context.Background(), SearchRequest{Query: "rareterm common", Limit: 20}) + if err != nil { + t.Fatalf("Search() error = %v", err) + } + if len(hits) != 1 { + t.Fatalf("Search() hits = %d, want only rare hit: %+v", len(hits), hits) + } + if got := filepath.Base(hits[0].SessionPath); got != "rare.jsonl" { + t.Fatalf("hit path = %q, want rare.jsonl", got) + } +} + +func TestAroundRequiresPathUnderHistoryRoots(t *testing.T) { + sessionDir := t.TempDir() + outside := t.TempDir() + path := filepath.Join(outside, "outside.jsonl") + writeSession(t, path, []provider.Message{{Role: provider.RoleUser, Content: "secret"}}) + + searcher := NewSearcher(Options{SessionDir: sessionDir}) + if _, err := searcher.Around(context.Background(), AroundRequest{SessionPath: path, MessageIndex: 0}); err == nil { + t.Fatal("Around() error = nil, want path confinement error") + } +} + +func TestAroundRendersNearbyMessages(t *testing.T) { + sessionDir := t.TempDir() + path := filepath.Join(sessionDir, "nearby.jsonl") + writeSession(t, path, []provider.Message{ + {Role: provider.RoleUser, Content: "first"}, + {Role: provider.RoleAssistant, Content: "second"}, + {Role: provider.RoleUser, Content: "third"}, + }) + + searcher := NewSearcher(Options{SessionDir: sessionDir}) + msgs, err := searcher.Around(context.Background(), AroundRequest{SessionPath: path, MessageIndex: 1, Before: 1, After: 1}) + if err != nil { + t.Fatalf("Around() error = %v", err) + } + if len(msgs) != 3 { + t.Fatalf("Around() returned %d messages, want 3", len(msgs)) + } + joined := msgs[0].Text + "\n" + msgs[1].Text + "\n" + msgs[2].Text + for _, want := range []string{"[0 user]", "[1 assistant]", "[2 user]"} { + if !strings.Contains(joined, want) { + t.Fatalf("Around() output missing %q:\n%s", want, joined) + } + } +} + +func writeSession(t *testing.T, path string, msgs []provider.Message) { + t.Helper() + sess := agent.NewSession("") + for _, msg := range msgs { + sess.Add(msg) + } + if err := sess.Save(path); err != nil { + t.Fatalf("Save(%s) error = %v", path, err) + } +} diff --git a/internal/history/strip.go b/internal/history/strip.go new file mode 100644 index 000000000..cc6b388a5 --- /dev/null +++ b/internal/history/strip.go @@ -0,0 +1,28 @@ +package history + +import ( + "regexp" + "strings" +) + +var reComposeBlock = regexp.MustCompile(`(?s)^\s*<(?:memory-update|background-jobs|active-goal)>.*?\s*\n`) + +const planModeMarkerPrefix = "[Plan mode" + +func stripComposePrefixes(content string) string { + s := content + for { + next := reComposeBlock.ReplaceAllString(s, "") + if next == s { + break + } + s = next + } + trimmed := strings.TrimSpace(s) + if strings.HasPrefix(trimmed, planModeMarkerPrefix) { + if _, rest, ok := strings.Cut(trimmed, "]"); ok { + return strings.TrimSpace(rest) + } + } + return trimmed +} diff --git a/internal/history/tool.go b/internal/history/tool.go new file mode 100644 index 000000000..8769f351a --- /dev/null +++ b/internal/history/tool.go @@ -0,0 +1,138 @@ +package history + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "reasonix/internal/tool" +) + +type historyTool struct { + searcher *Searcher +} + +// NewTool returns a read-only history retrieval tool bound to local sessions. +func NewTool(opts Options) tool.Tool { + return historyTool{searcher: NewSearcher(opts)} +} + +func (historyTool) Name() string { return "history" } + +func (historyTool) Description() string { + return "Search saved local session history with lightweight BM25 retrieval, then read messages around a hit. " + + "Use search when past decisions, failed attempts, commands, or tool inputs may help the current task; use around with a returned session_path and message_index to inspect the nearby transcript. " + + "By default it searches user text, assistant text, tool inputs, and tool errors; normal tool outputs are excluded unless kind includes tool_output." +} + +func (historyTool) Schema() json.RawMessage { + return json.RawMessage(`{ + "type": "object", + "properties": { + "operation": {"type": "string", "enum": ["search", "around"], "description": "search ranks saved history; around returns nearby messages for a search hit."}, + "query": {"type": "string", "description": "Search query for operation=search."}, + "scope": {"type": "string", "enum": ["project", "global"], "description": "project searches the current session directory; global also includes compacted-history archives."}, + "kind": {"type": "array", "items": {"type": "string", "enum": ["user_text", "assistant_text", "tool_input", "tool_error", "tool_output"]}, "description": "History parts to search. Defaults to user_text, assistant_text, tool_input, and tool_error."}, + "tool_name": {"type": "string", "description": "Optional tool-name filter for tool_input, tool_error, or tool_output."}, + "limit": {"type": "integer", "description": "Maximum search hits to return, default 8, max 20."}, + "session_path": {"type": "string", "description": "Path from a search hit. Required for operation=around."}, + "message_index": {"type": "integer", "description": "Message index from a search hit. Required for operation=around."}, + "before": {"type": "integer", "description": "Messages before message_index for operation=around, default 3, max 10."}, + "after": {"type": "integer", "description": "Messages after message_index for operation=around, default 3, max 10."} + }, + "required": ["operation"] + }`) +} + +func (t historyTool) Execute(ctx context.Context, args json.RawMessage) (string, error) { + var in struct { + Operation string `json:"operation"` + Query string `json:"query"` + Scope string `json:"scope"` + Kind []Kind `json:"kind"` + ToolName string `json:"tool_name"` + Limit int `json:"limit"` + SessionPath string `json:"session_path"` + MessageIndex *int `json:"message_index"` + Before int `json:"before"` + After int `json:"after"` + } + if err := json.Unmarshal(args, &in); err != nil { + return "", fmt.Errorf("invalid arguments: %w", err) + } + switch strings.TrimSpace(in.Operation) { + case "search": + hits, err := t.searcher.Search(ctx, SearchRequest{ + Query: in.Query, + Scope: in.Scope, + Kinds: in.Kind, + ToolName: in.ToolName, + Limit: in.Limit, + }) + if err != nil { + return "", err + } + return formatHits(in.Query, hits), nil + case "around": + if in.MessageIndex == nil { + return "", fmt.Errorf("message_index is required for operation=around") + } + msgs, err := t.searcher.Around(ctx, AroundRequest{ + SessionPath: in.SessionPath, + MessageIndex: *in.MessageIndex, + Before: in.Before, + After: in.After, + }) + if err != nil { + return "", err + } + return formatAround(in.SessionPath, *in.MessageIndex, msgs), nil + case "": + return "", fmt.Errorf("operation is required") + default: + return "", fmt.Errorf("unknown operation %q", in.Operation) + } +} + +func (historyTool) ReadOnly() bool { return true } + +func formatHits(query string, hits []Hit) string { + if len(hits) == 0 { + return strings.Join([]string{ + "No saved session history matched " + strconvQuote(query) + ".", + "", + "0 results does not prove the event never happened. Try:", + "1. Retry with fewer, rarer terms such as a function name, command, error phrase, ticket id, or decision keyword.", + "2. Widen scope from project to global when cross-project or compacted-history context may matter.", + "3. If you need tool output, include kind=[\"tool_output\"] or filter by tool_name for tool input/error/output searches.", + }, "\n") + } + var b strings.Builder + fmt.Fprintf(&b, "History search results for %s:\n", strconvQuote(query)) + for i, hit := range hits { + fmt.Fprintf(&b, "\n%d. score=%.3f source=%s session_id=%s message_index=%d kind=%s role=%s", + i+1, hit.Score, hit.Source, hit.SessionID, hit.MessageIndex, hit.Kind, hit.Role) + if hit.ToolName != "" { + fmt.Fprintf(&b, " tool=%s", hit.ToolName) + } + fmt.Fprintf(&b, "\n session_path: %s\n snippet: %s\n", + hit.SessionPath, hit.Snippet) + } + b.WriteString("\nUse operation=\"around\" with a session_path and message_index to read nearby messages.") + return strings.TrimSpace(b.String()) +} + +func formatAround(path string, idx int, msgs []MessageContext) string { + var b strings.Builder + fmt.Fprintf(&b, "History around %s message_index=%d:\n", path, idx) + for _, msg := range msgs { + fmt.Fprintf(&b, "\n%s\n", msg.Text) + } + return strings.TrimSpace(b.String()) +} + +func strconvQuote(s string) string { + b, _ := json.Marshal(s) + return string(b) +} diff --git a/internal/history/tool_test.go b/internal/history/tool_test.go new file mode 100644 index 000000000..b8c0def91 --- /dev/null +++ b/internal/history/tool_test.go @@ -0,0 +1,112 @@ +package history + +import ( + "context" + "encoding/json" + "path/filepath" + "strings" + "testing" + + "reasonix/internal/provider" +) + +func TestHistoryToolSearchAndAroundAreUsable(t *testing.T) { + sessionDir := t.TempDir() + path := filepath.Join(sessionDir, "decision.jsonl") + writeSession(t, path, []provider.Message{ + {Role: provider.RoleUser, Content: "Should history use vector embeddings?"}, + {Role: provider.RoleAssistant, Content: "Decision: keep history retrieval lightweight with BM25 and no vector database."}, + {Role: provider.RoleUser, Content: "Great, port that to Reasonix."}, + }) + + tl := NewTool(Options{SessionDir: sessionDir}) + if tl.Name() != "history" || !tl.ReadOnly() { + t.Fatalf("unexpected tool identity: name=%q readonly=%v", tl.Name(), tl.ReadOnly()) + } + if !json.Valid(tl.Schema()) { + t.Fatal("history schema is not valid JSON") + } + + out, err := tl.Execute(context.Background(), []byte(`{"operation":"search","query":"BM25 vector database","limit":5}`)) + if err != nil { + t.Fatalf("Execute search: %v", err) + } + for _, want := range []string{ + "History search results", + "decision.jsonl", + "message_index=1", + "keep history retrieval lightweight", + `Use operation="around"`, + } { + if !strings.Contains(out, want) { + t.Fatalf("search output missing %q:\n%s", want, out) + } + } + + args, _ := json.Marshal(map[string]any{ + "operation": "around", + "session_path": path, + "message_index": 1, + "before": 1, + "after": 1, + }) + out, err = tl.Execute(context.Background(), args) + if err != nil { + t.Fatalf("Execute around: %v", err) + } + for _, want := range []string{ + "History around", + "[0 user]", + "[1 assistant]", + "[2 user]", + } { + if !strings.Contains(out, want) { + t.Fatalf("around output missing %q:\n%s", want, out) + } + } +} + +func TestHistoryToolSchemaIsCacheStable(t *testing.T) { + tl := NewTool(Options{SessionDir: t.TempDir()}) + if got, want := tl.Description(), "Search saved local session history with lightweight BM25 retrieval, then read messages around a hit. Use search when past decisions, failed attempts, commands, or tool inputs may help the current task; use around with a returned session_path and message_index to inspect the nearby transcript. By default it searches user text, assistant text, tool inputs, and tool errors; normal tool outputs are excluded unless kind includes tool_output."; got != want { + t.Fatalf("history description changed; this is provider-visible and affects prompt-cache shape.\nwant: %q\n got: %q", want, got) + } + const wantSchema = `{ + "type": "object", + "properties": { + "operation": {"type": "string", "enum": ["search", "around"], "description": "search ranks saved history; around returns nearby messages for a search hit."}, + "query": {"type": "string", "description": "Search query for operation=search."}, + "scope": {"type": "string", "enum": ["project", "global"], "description": "project searches the current session directory; global also includes compacted-history archives."}, + "kind": {"type": "array", "items": {"type": "string", "enum": ["user_text", "assistant_text", "tool_input", "tool_error", "tool_output"]}, "description": "History parts to search. Defaults to user_text, assistant_text, tool_input, and tool_error."}, + "tool_name": {"type": "string", "description": "Optional tool-name filter for tool_input, tool_error, or tool_output."}, + "limit": {"type": "integer", "description": "Maximum search hits to return, default 8, max 20."}, + "session_path": {"type": "string", "description": "Path from a search hit. Required for operation=around."}, + "message_index": {"type": "integer", "description": "Message index from a search hit. Required for operation=around."}, + "before": {"type": "integer", "description": "Messages before message_index for operation=around, default 3, max 10."}, + "after": {"type": "integer", "description": "Messages after message_index for operation=around, default 3, max 10."} + }, + "required": ["operation"] + }` + if got := string(tl.Schema()); got != wantSchema { + t.Fatalf("history schema changed; this is provider-visible and affects prompt-cache shape.\nwant:\n%s\n got:\n%s", wantSchema, got) + } +} + +func TestHistoryToolValidatesInputs(t *testing.T) { + tl := NewTool(Options{SessionDir: t.TempDir()}) + for _, tc := range []struct { + name string + args string + }{ + {"missing operation", `{}`}, + {"unknown operation", `{"operation":"scan"}`}, + {"around missing index", `{"operation":"around","session_path":"/tmp/session.jsonl"}`}, + {"bad json", `{"operation":`}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := tl.Execute(context.Background(), []byte(tc.args)); err == nil { + t.Fatalf("Execute(%s) error = nil, want validation error", tc.args) + } + }) + } +} diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go index ffd9cecb2..b54810d63 100644 --- a/internal/i18n/i18n.go +++ b/internal/i18n/i18n.go @@ -204,6 +204,8 @@ type Messages struct { ListModelsHeaderFmt string // "models (active: %s)" ListModelsHint string // how to switch ListMemoryHeader string // "memory files" + ListMemorySaved string // "saved memories" + ListMemoryArchived string // "archived memories" ListMemoryNone string // no memory docs ListSkillsHeaderFmt string // "skills (%d)" ListSkillsNone string // no skills diff --git a/internal/i18n/messages_en.go b/internal/i18n/messages_en.go index f913c44ac..7d23219c9 100644 --- a/internal/i18n/messages_en.go +++ b/internal/i18n/messages_en.go @@ -173,7 +173,7 @@ var English = Messages{ CmdMemory: "show memory files", CmdGoal: "set or clear the active goal", CmdRemember: "save a memory note", - CmdForget: "delete a saved memory", + CmdForget: "archive a saved memory", CmdMcp: "MCP servers", CmdHooks: "manage hooks", CmdPasteImage: "paste clipboard image", @@ -214,6 +214,8 @@ var English = Messages{ ListModelsHeaderFmt: "models (active: %s)", ListModelsHint: "switch with the model switcher, or type /model ", ListMemoryHeader: "memory files", + ListMemorySaved: "saved memories", + ListMemoryArchived: "archived memories", ListMemoryNone: "memory: none — add with “/remember ” or run /init to generate AGENTS.md", ListSkillsHeaderFmt: "skills (%d)", ListSkillsNone: "skills: none defined — invoke a built-in like /init, or author one with install_skill", @@ -224,11 +226,11 @@ var English = Messages{ MemoryNone: "memory: none — add with “/remember ” or create REASONIX.md in the project root", MemoryLoaded: "memory loaded:", - MemorySavedHeader: " saved memories (delete with “/forget ”):", + MemorySavedHeader: " saved memories (archive with “/forget ”):", MemoryStoredUnderFmt: " stored under %s", MemoryEditHint: "edit doc files or use “/remember ”; doc edits apply next session", ForgetUsage: "usage: /forget — the slug shown under “saved memories” in /memory", - ForgetDoneFmt: "forgot memory: %s", + ForgetDoneFmt: "forgot and archived memory: %s", QuickRememberEmpty: "nothing to remember", QuickRememberDoneFmt: "remembered → %s", GoalEmpty: "goal: none — set one with /goal ", diff --git a/internal/i18n/messages_zh.go b/internal/i18n/messages_zh.go index 6d3853b99..b5ffa116a 100644 --- a/internal/i18n/messages_zh.go +++ b/internal/i18n/messages_zh.go @@ -174,7 +174,7 @@ var Chinese = Messages{ CmdMemory: "查看记忆文件", CmdGoal: "设置或清除当前目标", CmdRemember: "保存一条记忆", - CmdForget: "删除一条已存记忆", + CmdForget: "归档一条已存记忆", CmdMcp: "MCP 服务器", CmdHooks: "管理 hooks", CmdPasteImage: "粘贴剪贴板图片", @@ -215,6 +215,8 @@ var Chinese = Messages{ ListModelsHeaderFmt: "模型(当前:%s)", ListModelsHint: "用底部的模型切换器,或输入 /model ", ListMemoryHeader: "记忆文件", + ListMemorySaved: "保存的记忆", + ListMemoryArchived: "归档的记忆", ListMemoryNone: "暂无记忆 — 用 “/remember <内容>” 添加,或运行 /init 生成 AGENTS.md", ListSkillsHeaderFmt: "skills(%d 个)", ListSkillsNone: "暂无 skill — 调用内置的(如 /init),或用 install_skill 创建一个", @@ -225,11 +227,11 @@ var Chinese = Messages{ MemoryNone: "还没有加载任何记忆 — 输入 “/remember 内容” 可快速记录,也可以在项目根目录创建 REASONIX.md", MemoryLoaded: "当前已加载的记忆:", - MemorySavedHeader: " 已记录的条目(用 “/forget ” 删除):", + MemorySavedHeader: " 已记录的条目(用 “/forget ” 归档):", MemoryStoredUnderFmt: " 存放于 %s", MemoryEditHint: "可直接编辑记忆文档,或输入 “/remember 内容” 快速记录;文档改动会在下次会话生效", ForgetUsage: "用法:/forget — name 是 /memory 中显示的条目标识", - ForgetDoneFmt: "已删除记忆:%s", + ForgetDoneFmt: "已归档记忆:%s", QuickRememberEmpty: "没有要记录的内容", QuickRememberDoneFmt: "已记住 → %s", GoalEmpty: "目标:无 — 用 /goal <目标> 设置", diff --git a/internal/memory/forget.go b/internal/memory/forget.go index ca586b738..138d59bb6 100644 --- a/internal/memory/forget.go +++ b/internal/memory/forget.go @@ -44,11 +44,15 @@ func (t forgetTool) Execute(ctx context.Context, args json.RawMessage) (string, if in.Name == "" { return "", fmt.Errorf("name is required") } - if err := t.store.Delete(in.Name); err != nil { + archive, err := t.store.Archive(in.Name) + if err != nil { return "", err } if q, ok := QueueFromContext(ctx); ok { - q.QueueMemory("Deleted memory \"" + slug(in.Name) + "\" — disregard its line still shown in the saved-memories index until next session.") + q.QueueMemory("Forgot memory \"" + slug(in.Name) + "\" — disregard its line still shown in the saved-memories index until next session.") + } + if archive != "" { + return fmt.Sprintf("Forgot memory %q (it no longer applies and will not load in future sessions; archived to %s).", in.Name, archive), nil } return fmt.Sprintf("Forgot memory %q (it no longer applies and will not load in future sessions).", in.Name), nil } diff --git a/internal/memory/forget_test.go b/internal/memory/forget_test.go index 4bf0a166e..f538a5ca4 100644 --- a/internal/memory/forget_test.go +++ b/internal/memory/forget_test.go @@ -27,7 +27,7 @@ func TestForgetToolDeletes(t *testing.T) { if err != nil { t.Fatalf("Execute: %v", err) } - if !strings.Contains(out, "Forgot memory") { + if !strings.Contains(out, "Forgot memory") || !strings.Contains(out, "archived to") { t.Fatalf("unexpected tool output: %q", out) } if len(store.List()) != 0 { diff --git a/internal/memory/recall.go b/internal/memory/recall.go new file mode 100644 index 000000000..eebdcecec --- /dev/null +++ b/internal/memory/recall.go @@ -0,0 +1,284 @@ +package memory + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + + "reasonix/internal/retrieval" + "reasonix/internal/tool" +) + +const ( + defaultRecallLimit = 8 + maxRecallLimit = 20 + maxRecallSnippet = 260 + recallScoreFloor = 0.15 +) + +type recallTool struct{ store Store } + +// NewRecallTool returns the read-only `memory` tool for searching saved facts. +func NewRecallTool(store Store) tool.Tool { return recallTool{store: store} } + +func (recallTool) Name() string { return "memory" } + +func (recallTool) Description() string { + return "Search, list, and read saved project memories. " + + "Use this before saving a new memory to avoid duplicates, and when a saved memory from the index looks relevant but needs its full body. " + + "This tool is read-only; use remember to save or update a memory, and forget to delete one." +} + +func (recallTool) Schema() json.RawMessage { + return json.RawMessage(`{ + "type": "object", + "properties": { + "operation": {"type": "string", "enum": ["search", "read", "list"], "description": "search ranks saved memories; read returns one full memory by name; list returns the saved-memory index."}, + "query": {"type": "string", "description": "Search query for operation=search."}, + "name": {"type": "string", "description": "Memory slug for operation=read, e.g. the name in [Label](name.md)."}, + "type": {"type": "string", "enum": ["user", "feedback", "project", "reference"], "description": "Optional memory type filter for search or list."}, + "limit": {"type": "integer", "description": "Maximum search/list results to return, default 8, max 20."} + }, + "required": ["operation"] + }`) +} + +func (t recallTool) Execute(ctx context.Context, args json.RawMessage) (string, error) { + var in struct { + Operation string `json:"operation"` + Query string `json:"query"` + Name string `json:"name"` + Type string `json:"type"` + Limit int `json:"limit"` + } + if err := json.Unmarshal(args, &in); err != nil { + return "", fmt.Errorf("invalid arguments: %w", err) + } + if t.store.Dir == "" { + return "Memory store is unavailable.", nil + } + memType, err := recallTypeFilter(in.Type) + if err != nil { + return "", err + } + limit := clampRecallLimit(in.Limit) + switch strings.TrimSpace(in.Operation) { + case "search": + hits, err := searchMemories(ctx, t.store, in.Query, memType, limit) + if err != nil { + return "", err + } + return formatMemoryHits(in.Query, hits), nil + case "read": + m, ok := readMemoryByName(t.store, in.Name) + if !ok { + return "", fmt.Errorf("memory %q not found", slug(in.Name)) + } + return formatMemory(t.store, m), nil + case "list": + return formatMemoryList(t.store, filterMemories(t.store.List(), memType), limit), nil + case "": + return "", fmt.Errorf("operation is required") + default: + return "", fmt.Errorf("unknown operation %q", in.Operation) + } +} + +func (recallTool) ReadOnly() bool { return true } + +type memoryHit struct { + Memory Memory + Path string + Score float64 + Snippet string +} + +type memoryDoc struct { + memory Memory + path string + text string + counts map[string]int + length int +} + +func searchMemories(ctx context.Context, store Store, query string, typ Type, limit int) ([]memoryHit, error) { + query = strings.TrimSpace(query) + if query == "" { + return nil, fmt.Errorf("query is required") + } + queryTerms, err := retrieval.QueryTerms(query) + if err != nil { + return nil, err + } + memories := filterMemories(store.List(), typ) + docs := make([]memoryDoc, 0, len(memories)) + for _, m := range memories { + if err := ctx.Err(); err != nil { + return nil, err + } + text := memorySearchText(m) + terms := retrieval.Tokens(text) + if len(terms) == 0 { + continue + } + docs = append(docs, memoryDoc{ + memory: m, + path: store.Path(m.Name), + text: text, + counts: retrieval.Counts(terms), + length: len(terms), + }) + } + if len(docs) == 0 { + return nil, nil + } + counts := make([]map[string]int, 0, len(docs)) + totalLen := 0 + for _, doc := range docs { + counts = append(counts, doc.counts) + totalLen += doc.length + } + df := retrieval.DocumentFrequency(counts) + avgLen := float64(totalLen) / float64(len(docs)) + + var hits []memoryHit + for _, doc := range docs { + score := retrieval.BM25Score(doc.counts, doc.length, queryTerms, df, len(docs), avgLen) + if score <= 0 { + continue + } + hits = append(hits, memoryHit{ + Memory: doc.memory, + Path: doc.path, + Score: score, + Snippet: retrieval.MakeSnippet(doc.text, query, queryTerms, maxRecallSnippet), + }) + } + sort.Slice(hits, func(i, j int) bool { + if hits[i].Score == hits[j].Score { + return hits[i].Memory.Name < hits[j].Memory.Name + } + return hits[i].Score > hits[j].Score + }) + hits = retrieval.KeepTopRelativeScore(hits, recallScoreFloor, func(hit memoryHit) float64 { + return hit.Score + }) + if len(hits) > limit { + hits = hits[:limit] + } + return hits, nil +} + +func recallTypeFilter(s string) (Type, error) { + if strings.TrimSpace(s) == "" { + return "", nil + } + t := Type(strings.ToLower(strings.TrimSpace(s))) + if !validTypes[t] { + return "", fmt.Errorf("type must be one of user, feedback, project, reference") + } + return t, nil +} + +func filterMemories(memories []Memory, typ Type) []Memory { + if typ == "" { + return memories + } + out := memories[:0] + for _, m := range memories { + if NormalizeType(string(m.Type)) == typ { + out = append(out, m) + } + } + return out +} + +func readMemoryByName(store Store, name string) (Memory, bool) { + name = slug(name) + if name == "" { + return Memory{}, false + } + m, ok := loadMemory(store.Path(name)) + if !ok || slug(m.Name) != name { + return Memory{}, false + } + m.Name = name + return m, true +} + +func memorySearchText(m Memory) string { + return strings.Join([]string{ + m.Name, + m.Title, + string(NormalizeType(string(m.Type))), + m.Description, + m.Body, + }, "\n") +} + +func formatMemoryHits(query string, hits []memoryHit) string { + if len(hits) == 0 { + return strings.Join([]string{ + "No saved memories matched " + strconvQuote(query) + ".", + "", + "0 results does not prove the fact was never recorded. Try:", + "1. Retry with 1-3 distinctive terms (function name, task id, rare phrase) instead of a long generic sentence.", + "2. For exact literals that punctuation splits (URLs, ports, file paths, command flags), search one distinctive token or inspect the memory directory directly.", + "3. For verbatim original wording or exact command output, use the history tool; saved memories may paraphrase.", + }, "\n") + } + var b strings.Builder + fmt.Fprintf(&b, "Memory search results for %s:\n", strconvQuote(query)) + for i, hit := range hits { + m := hit.Memory + fmt.Fprintf(&b, "\n%d. score=%.3f name=%s type=%s title=%s\n description: %s\n path: %s\n snippet: %s\n", + i+1, hit.Score, m.Name, NormalizeType(string(m.Type)), displayTitle(m.Title, m.Name), oneLine(m.Description), hit.Path, hit.Snippet) + } + b.WriteString("\nUse operation=\"read\" with a memory name to inspect the full saved fact.") + return strings.TrimSpace(b.String()) +} + +func formatMemory(store Store, m Memory) string { + var b strings.Builder + fmt.Fprintf(&b, "Memory %s\n", m.Name) + fmt.Fprintf(&b, "title: %s\n", displayTitle(m.Title, m.Name)) + fmt.Fprintf(&b, "type: %s\n", NormalizeType(string(m.Type))) + if desc := oneLine(m.Description); desc != "" { + fmt.Fprintf(&b, "description: %s\n", desc) + } + fmt.Fprintf(&b, "path: %s\n\n%s", store.Path(m.Name), strings.TrimSpace(m.Body)) + return strings.TrimSpace(b.String()) +} + +func formatMemoryList(store Store, memories []Memory, limit int) string { + if len(memories) == 0 { + return "No saved memories found." + } + if len(memories) > limit { + memories = memories[:limit] + } + var b strings.Builder + fmt.Fprintf(&b, "Saved memories in %s:\n", store.Dir) + for _, m := range memories { + fmt.Fprintf(&b, "- [%s](%s.md) type=%s - %s\n", + displayTitle(m.Title, m.Name), m.Name, NormalizeType(string(m.Type)), oneLine(m.Description)) + } + return strings.TrimSpace(b.String()) +} + +func clampRecallLimit(n int) int { + if n <= 0 { + return defaultRecallLimit + } + if n > maxRecallLimit { + return maxRecallLimit + } + return n +} + +func strconvQuote(s string) string { + b, _ := json.Marshal(s) + return string(b) +} diff --git a/internal/memory/recall_test.go b/internal/memory/recall_test.go new file mode 100644 index 000000000..720a89570 --- /dev/null +++ b/internal/memory/recall_test.go @@ -0,0 +1,193 @@ +package memory + +import ( + "context" + "encoding/json" + "strings" + "testing" +) + +func TestRecallToolSearchesSavedMemories(t *testing.T) { + store := Store{Dir: t.TempDir()} + saveMemory(t, store, Memory{ + Name: "cache-first-history", + Title: "Cache first history", + Description: "History retrieval must preserve prompt cache stability", + Type: TypeProject, + Body: "Use a read-only BM25 retrieval tool instead of injecting dynamic history into the system prompt.", + }) + saveMemory(t, store, Memory{ + Name: "frontend-colors", + Description: "Dashboard color preference", + Type: TypeUser, + Body: "Avoid one-note palettes.", + }) + + tl := NewRecallTool(store) + if tl.Name() != "memory" || !tl.ReadOnly() { + t.Fatalf("unexpected tool identity: name=%q readonly=%v", tl.Name(), tl.ReadOnly()) + } + if !json.Valid(tl.Schema()) { + t.Fatal("memory schema is not valid JSON") + } + + out, err := tl.Execute(context.Background(), []byte(`{"operation":"search","query":"BM25 prompt cache","limit":5}`)) + if err != nil { + t.Fatalf("Execute search: %v", err) + } + if !strings.Contains(out, "cache-first-history") { + t.Fatalf("search output missing expected memory:\n%s", out) + } + if strings.Contains(out, "frontend-colors") { + t.Fatalf("unrelated memory should not match strongly enough:\n%s", out) + } +} + +func TestRecallToolSchemaIsCacheStable(t *testing.T) { + tl := NewRecallTool(Store{Dir: t.TempDir()}) + if got, want := tl.Description(), "Search, list, and read saved project memories. Use this before saving a new memory to avoid duplicates, and when a saved memory from the index looks relevant but needs its full body. This tool is read-only; use remember to save or update a memory, and forget to delete one."; got != want { + t.Fatalf("memory description changed; this is provider-visible and affects prompt-cache shape.\nwant: %q\n got: %q", want, got) + } + const wantSchema = `{ + "type": "object", + "properties": { + "operation": {"type": "string", "enum": ["search", "read", "list"], "description": "search ranks saved memories; read returns one full memory by name; list returns the saved-memory index."}, + "query": {"type": "string", "description": "Search query for operation=search."}, + "name": {"type": "string", "description": "Memory slug for operation=read, e.g. the name in [Label](name.md)."}, + "type": {"type": "string", "enum": ["user", "feedback", "project", "reference"], "description": "Optional memory type filter for search or list."}, + "limit": {"type": "integer", "description": "Maximum search/list results to return, default 8, max 20."} + }, + "required": ["operation"] + }` + if got := string(tl.Schema()); got != wantSchema { + t.Fatalf("memory schema changed; this is provider-visible and affects prompt-cache shape.\nwant:\n%s\n got:\n%s", wantSchema, got) + } +} + +func TestRecallToolDropsCommonWordNoise(t *testing.T) { + store := Store{Dir: t.TempDir()} + saveMemory(t, store, Memory{ + Name: "rare-cache-rule", + Description: "Rare synthesis-cache rule", + Type: TypeProject, + Body: "rareterm common common common", + }) + for i := 0; i < 12; i++ { + saveMemory(t, store, Memory{ + Name: "common-note-" + string(rune('a'+i)), + Description: "Common note", + Type: TypeProject, + Body: "common", + }) + } + + out, err := NewRecallTool(store).Execute(context.Background(), []byte(`{"operation":"search","query":"rareterm common","limit":20}`)) + if err != nil { + t.Fatalf("Execute search: %v", err) + } + if !strings.Contains(out, "rare-cache-rule") { + t.Fatalf("top rare hit missing:\n%s", out) + } + if strings.Contains(out, "common-note-") { + t.Fatalf("common-word-only noise should be dropped:\n%s", out) + } +} + +func TestRecallToolNoResultsGuidesFallbackSearches(t *testing.T) { + store := Store{Dir: t.TempDir()} + out, err := NewRecallTool(store).Execute(context.Background(), []byte(`{"operation":"search","query":"postgres://host:5433"}`)) + if err != nil { + t.Fatalf("Execute search: %v", err) + } + for _, want := range []string{"0 results does not prove", "Retry with 1-3 distinctive terms", "use the history tool"} { + if !strings.Contains(out, want) { + t.Fatalf("no-result output missing %q:\n%s", want, out) + } + } +} + +func TestRecallToolExcludesArchivedMemories(t *testing.T) { + store := Store{Dir: t.TempDir()} + saveMemory(t, store, Memory{ + Name: "stale-synthesis-cache", + Description: "Stale synthesis-cache conclusion", + Type: TypeProject, + Body: "This archived conclusion should no longer affect agent recall.", + }) + if _, err := store.Archive("stale-synthesis-cache"); err != nil { + t.Fatalf("Archive: %v", err) + } + + tl := NewRecallTool(store) + for _, args := range []string{ + `{"operation":"search","query":"stale synthesis cache","limit":5}`, + `{"operation":"list"}`, + } { + out, err := tl.Execute(context.Background(), []byte(args)) + if err != nil { + t.Fatalf("Execute(%s): %v", args, err) + } + if strings.Contains(out, "stale-synthesis-cache") { + t.Fatalf("archived memory leaked into active recall for %s:\n%s", args, out) + } + } + if _, err := tl.Execute(context.Background(), []byte(`{"operation":"read","name":"stale-synthesis-cache"}`)); err == nil { + t.Fatal("read should not find archived memory as active memory") + } +} + +func TestRecallToolReadsMemoryByName(t *testing.T) { + store := Store{Dir: t.TempDir()} + saveMemory(t, store, Memory{ + Name: "user-prefers-tabs", + Title: "Prefers tabs", + Description: "User prefers tabs for indentation", + Type: TypeUser, + Body: "Use tabs unless the repository style clearly says otherwise.", + }) + + out, err := NewRecallTool(store).Execute(context.Background(), []byte(`{"operation":"read","name":"user-prefers-tabs"}`)) + if err != nil { + t.Fatalf("Execute read: %v", err) + } + for _, want := range []string{"Memory user-prefers-tabs", "type: user", "Use tabs"} { + if !strings.Contains(out, want) { + t.Fatalf("read output missing %q:\n%s", want, out) + } + } +} + +func TestRecallToolListsAndFiltersByType(t *testing.T) { + store := Store{Dir: t.TempDir()} + saveMemory(t, store, Memory{Name: "one", Description: "project fact", Type: TypeProject, Body: "body"}) + saveMemory(t, store, Memory{Name: "two", Description: "user fact", Type: TypeUser, Body: "body"}) + + out, err := NewRecallTool(store).Execute(context.Background(), []byte(`{"operation":"list","type":"user"}`)) + if err != nil { + t.Fatalf("Execute list: %v", err) + } + if !strings.Contains(out, "two") || strings.Contains(out, "one") { + t.Fatalf("type filter did not apply:\n%s", out) + } +} + +func TestRecallToolValidatesInputs(t *testing.T) { + store := Store{Dir: t.TempDir()} + tl := NewRecallTool(store) + if _, err := tl.Execute(context.Background(), []byte(`{"operation":"search"}`)); err == nil { + t.Fatal("search without query should fail") + } + if _, err := tl.Execute(context.Background(), []byte(`{"operation":"read"}`)); err == nil { + t.Fatal("read without name should fail") + } + if _, err := tl.Execute(context.Background(), []byte(`{"operation":"list","type":"unknown"}`)); err == nil { + t.Fatal("unknown type should fail") + } +} + +func saveMemory(t *testing.T, store Store, m Memory) { + t.Helper() + if _, err := store.Save(m); err != nil { + t.Fatalf("Save(%s): %v", m.Name, err) + } +} diff --git a/internal/memory/store.go b/internal/memory/store.go index c779d92ea..c413c4cbd 100644 --- a/internal/memory/store.go +++ b/internal/memory/store.go @@ -7,6 +7,7 @@ import ( "regexp" "sort" "strings" + "time" "reasonix/internal/frontmatter" ) @@ -54,6 +55,14 @@ type Memory struct { Body string // the fact itself (Markdown) } +// ArchivedMemory is a saved fact that has been removed from active memory but +// kept on disk for traceability. +type ArchivedMemory struct { + Memory + Path string + ArchivedAt time.Time +} + // StoreFor resolves the auto-memory directory for a project working dir under // the user config root, e.g. ~/.config/reasonix/projects/-Users-me-proj/memory. // A "" userDir (config dir unresolvable) yields a zero Store, which all methods @@ -91,7 +100,8 @@ func (s Store) Index() string { // Path returns the absolute file path a memory with the given name lives at. func (s Store) Path(name string) string { - return filepath.Join(s.Dir, slug(name)+".md") + path, _ := safeJoin(s.Dir, slug(name)+".md") + return path } // Save writes (or overwrites) a memory file and refreshes its MEMORY.md index @@ -119,42 +129,136 @@ func (s Store) Save(m Memory) (string, error) { return path, nil } -// Delete removes a memory file and its MEMORY.md line — the model's `forget` -// path and the user's way to prune a stale fact. A missing file is not an error; -// the goal state (gone) holds either way. -func (s Store) Delete(name string) error { +// Archive removes a memory from the active store and moves its file under +// .archive/ for traceability. A missing file is not an error; the goal state +// (not active) already holds. It returns the archive path, or "" when no file +// existed to archive. +func (s Store) Archive(name string) (string, error) { if s.Dir == "" { - return fmt.Errorf("memory store unavailable (no user config dir)") + return "", fmt.Errorf("memory store unavailable (no user config dir)") } name = slug(name) if name == "" { - return fmt.Errorf("memory needs a name") + return "", fmt.Errorf("memory needs a name") } - if err := removeMemoryFile(filepath.Join(s.Dir, name+".md")); err != nil { - return err + path, err := s.archiveMemoryFile(name) + if err != nil { + return "", err + } + return path, s.flushIndex(s.indexLinesExcept(name)) +} + +// Delete removes a memory from the active store and its MEMORY.md line — the +// model's `forget` path and the user's way to prune a stale fact. It archives +// the file instead of permanently deleting it so wrong memories remain +// traceable. A missing file is not an error; the goal state (gone) holds either +// way. +func (s Store) Delete(name string) error { + _, err := s.Archive(name) + return err +} + +func (s Store) archiveMemoryFile(name string) (string, error) { + if s.Dir == "" { + return "", fmt.Errorf("memory store unavailable (no user config dir)") + } + root, err := os.OpenRoot(s.Dir) + if os.IsNotExist(err) { + return "", nil + } + if err != nil { + return "", err + } + defer root.Close() + + file := name + ".md" + if _, err := root.Stat(file); err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", err + } + if err := root.MkdirAll(".archive", 0o755); err != nil { + return "", err + } + dest, err := archivePath(root, name, time.Now().UTC()) + if err != nil { + return "", err } - return s.flushIndex(s.indexLinesExcept(name)) + if err := renameMemoryFile(root, file, dest); err != nil { + return "", err + } + out, err := safeJoin(s.Dir, dest) + if err != nil { + return "", err + } + return out, nil } -func removeMemoryFile(path string) error { - err := os.Remove(path) +func archivePath(root *os.Root, name string, when time.Time) (string, error) { + stem := when.Format("20060102-150405.000") + "-" + name + path := filepath.Join(".archive", stem+".md") + if _, err := root.Stat(path); os.IsNotExist(err) { + return path, nil + } else if err != nil { + return "", err + } + for i := 1; ; i++ { + path = filepath.Join(".archive", fmt.Sprintf("%s-%d.md", stem, i)) + if _, err := root.Stat(path); os.IsNotExist(err) { + return path, nil + } else if err != nil { + return "", err + } + } +} + +func safeJoin(base, name string) (string, error) { + if base == "" { + return "", fmt.Errorf("memory store unavailable (no user config dir)") + } + if !filepath.IsLocal(name) { + return "", fmt.Errorf("memory path escapes store: %s", name) + } + baseAbs, err := filepath.Abs(base) + if err != nil { + return "", err + } + path := filepath.Join(baseAbs, name) + pathAbs, err := filepath.Abs(path) + if err != nil { + return "", err + } + rel, err := filepath.Rel(baseAbs, pathAbs) + if err != nil { + return "", err + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || filepath.IsAbs(rel) { + return "", fmt.Errorf("memory path escapes store: %s", name) + } + return pathAbs, nil +} + +func renameMemoryFile(root *os.Root, path, dest string) error { + err := root.Rename(path, dest) if err == nil || os.IsNotExist(err) { return nil } if !os.IsPermission(err) { return err } - repairOwnerWrite(path, false) - repairOwnerWrite(filepath.Dir(path), true) - err = os.Remove(path) + repairOwnerWrite(root, path, false) + repairOwnerWrite(root, filepath.Dir(path), true) + repairOwnerWrite(root, filepath.Dir(dest), true) + err = root.Rename(path, dest) if err == nil || os.IsNotExist(err) { return nil } return err } -func repairOwnerWrite(path string, dir bool) { - info, err := os.Stat(path) +func repairOwnerWrite(root *os.Root, path string, dir bool) { + info, err := root.Stat(path) if err != nil { return } @@ -162,7 +266,7 @@ func repairOwnerWrite(path string, dir bool) { if dir { need = 0o700 } - _ = os.Chmod(path, info.Mode().Perm()|need) + _ = root.Chmod(path, info.Mode().Perm()|need) } // render serializes a memory to frontmatter + body. The frontmatter mirrors the @@ -252,6 +356,60 @@ func (s Store) List() []Memory { return out } +// ListArchived returns archived memories parsed from .archive/, newest first. +// Archived files stay out of List() and the prompt index, so stale facts remain +// inspectable without being reused as active truth. +func (s Store) ListArchived() []ArchivedMemory { + if s.Dir == "" { + return nil + } + dir := filepath.Join(s.Dir, ".archive") + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + var out []ArchivedMemory + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") { + continue + } + path := filepath.Join(dir, e.Name()) + m, ok := loadMemory(path) + if !ok { + continue + } + when := archiveTimeFromName(e.Name()) + if when.IsZero() { + if info, err := e.Info(); err == nil { + when = info.ModTime() + } + } + out = append(out, ArchivedMemory{Memory: m, Path: path, ArchivedAt: when}) + } + sort.Slice(out, func(i, j int) bool { + if !out[i].ArchivedAt.Equal(out[j].ArchivedAt) { + return out[i].ArchivedAt.After(out[j].ArchivedAt) + } + if out[i].Name != out[j].Name { + return out[i].Name < out[j].Name + } + return out[i].Path < out[j].Path + }) + return out +} + +func archiveTimeFromName(name string) time.Time { + const stampLen = len("20060102-150405.000") + if len(name) <= stampLen || name[stampLen] != '-' { + return time.Time{} + } + when, err := time.ParseInLocation("20060102-150405.000", name[:stampLen], time.UTC) + if err != nil { + return time.Time{} + } + return when +} + // loadMemory parses one fact file back into a Memory. It tolerates the minimal // frontmatter render writes; a file without frontmatter still loads with its // body and a name derived from the filename. diff --git a/internal/memory/store_test.go b/internal/memory/store_test.go index 13e3a8940..5869aad19 100644 --- a/internal/memory/store_test.go +++ b/internal/memory/store_test.go @@ -111,7 +111,8 @@ func TestStoreIndexLabelFallsBackToDeKebabbedName(t *testing.T) { } } -// TestStoreDelete removes a fact's file and its index line while leaving others. +// TestStoreDelete archives a fact's file and removes its index line while +// leaving others. func TestStoreDelete(t *testing.T) { s := Store{Dir: t.TempDir()} for _, n := range []string{"alpha", "beta"} { @@ -125,6 +126,10 @@ func TestStoreDelete(t *testing.T) { if _, err := os.Stat(filepath.Join(s.Dir, "alpha.md")); !os.IsNotExist(err) { t.Fatalf("alpha.md should be gone, stat err = %v", err) } + archived := archivedFiles(t, s.Dir) + if len(archived) != 1 || !strings.HasSuffix(archived[0], "-alpha.md") { + t.Fatalf("archive files = %v, want one alpha archive", archived) + } idx := s.Index() if strings.Contains(idx, "alpha.md") { t.Fatalf("deleted entry still in index:\n%s", idx) @@ -146,6 +151,34 @@ func TestStoreDeleteMissingIsNoError(t *testing.T) { } } +func TestSafeJoinRejectsStoreEscape(t *testing.T) { + dir := t.TempDir() + if _, err := safeJoin(dir, filepath.Join("..", "outside.md")); err == nil { + t.Fatal("safeJoin should reject paths outside the store") + } + if _, err := safeJoin(dir, filepath.Join(t.TempDir(), "outside.md")); err == nil { + t.Fatal("safeJoin should reject absolute paths outside the store") + } +} + +func TestStoreArchiveSanitizesNameBeforePathUse(t *testing.T) { + root := t.TempDir() + s := Store{Dir: filepath.Join(root, "memory")} + if err := os.MkdirAll(s.Dir, 0o755); err != nil { + t.Fatal(err) + } + outside := filepath.Join(root, "outside.md") + if err := os.WriteFile(outside, []byte("do not move"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := s.Archive("../outside"); err != nil { + t.Fatalf("Archive with path-like name should be treated as a slug, not a path: %v", err) + } + if _, err := os.Stat(outside); err != nil { + t.Fatalf("outside file should remain untouched: %v", err) + } +} + func TestStoreDeleteRepairsReadOnlyMemoryFile(t *testing.T) { s := Store{Dir: t.TempDir()} if _, err := s.Save(Memory{Name: "locked", Description: "d", Type: TypeProject, Body: "b"}); err != nil { @@ -161,11 +194,95 @@ func TestStoreDeleteRepairsReadOnlyMemoryFile(t *testing.T) { if _, err := os.Stat(path); !os.IsNotExist(err) { t.Fatalf("locked.md should be gone, stat err = %v", err) } + archived := archivedFiles(t, s.Dir) + if len(archived) != 1 || !strings.HasSuffix(archived[0], "-locked.md") { + t.Fatalf("archive files = %v, want one locked archive", archived) + } if strings.Contains(s.Index(), "locked.md") { t.Fatalf("deleted read-only entry still in index:\n%s", s.Index()) } } +func TestStoreArchiveReturnsArchivePath(t *testing.T) { + s := Store{Dir: t.TempDir()} + if _, err := s.Save(Memory{Name: "old-fact", Description: "d", Type: TypeProject, Body: "body"}); err != nil { + t.Fatal(err) + } + archive, err := s.Archive("old-fact") + if err != nil { + t.Fatalf("Archive: %v", err) + } + if archive == "" { + t.Fatal("Archive returned empty path for existing memory") + } + body, err := os.ReadFile(archive) + if err != nil { + t.Fatalf("read archive: %v", err) + } + if !strings.Contains(string(body), "body") { + t.Fatalf("archive missing memory body:\n%s", body) + } + if strings.Contains(s.Index(), "old-fact.md") { + t.Fatalf("archived memory still in index:\n%s", s.Index()) + } + archived := s.ListArchived() + if len(archived) != 1 { + t.Fatalf("ListArchived = %+v, want one entry", archived) + } + if archived[0].Name != "old-fact" || archived[0].Path != archive { + t.Fatalf("archived entry mismatch: %+v, path %q", archived[0], archive) + } + if archived[0].ArchivedAt.IsZero() { + t.Fatalf("archived entry missing timestamp: %+v", archived[0]) + } + if len(s.List()) != 0 { + t.Fatalf("active List should exclude archived memories: %+v", s.List()) + } +} + +func TestStoreListArchivedNewestFirst(t *testing.T) { + s := Store{Dir: t.TempDir()} + dir := filepath.Join(s.Dir, ".archive") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + files := []struct { + name string + body string + }{ + {"20260101-010000.000-old.md", render(Memory{Name: "old", Description: "old d", Type: TypeProject, Body: "old body"}, "old")}, + {"20260102-010000.000-new.md", render(Memory{Name: "new", Description: "new d", Type: TypeFeedback, Body: "new body"}, "new")}, + } + for _, f := range files { + if err := os.WriteFile(filepath.Join(dir, f.name), []byte(f.body), 0o644); err != nil { + t.Fatal(err) + } + } + archived := s.ListArchived() + if len(archived) != 2 { + t.Fatalf("ListArchived len = %d, want 2: %+v", len(archived), archived) + } + if archived[0].Name != "new" || archived[1].Name != "old" { + t.Fatalf("ListArchived order = %+v, want newest first", archived) + } + if archived[0].Type != TypeFeedback || !strings.Contains(archived[1].Body, "old body") { + t.Fatalf("archived memory did not round-trip metadata/body: %+v", archived) + } +} + +func archivedFiles(t *testing.T, dir string) []string { + t.Helper() + entries, err := os.ReadDir(filepath.Join(dir, ".archive")) + if err != nil { + t.Fatalf("read archive dir: %v", err) + } + var out []string + for _, entry := range entries { + out = append(out, entry.Name()) + } + return out +} + // TestNormalizeType maps unknown types to project and keeps known ones. func TestNormalizeType(t *testing.T) { if got := NormalizeType("feedback"); got != TypeFeedback { diff --git a/internal/retrieval/bm25.go b/internal/retrieval/bm25.go new file mode 100644 index 000000000..007468701 --- /dev/null +++ b/internal/retrieval/bm25.go @@ -0,0 +1,207 @@ +package retrieval + +import ( + "fmt" + "math" + "strings" + "unicode" + "unicode/utf8" +) + +// Tokens lowercases Latin words and splits CJK text into single-rune terms. It +// is intentionally simple: a local, dependency-free approximation of FTS token +// matching for saved agent history and memory. +func Tokens(s string) []string { + var out []string + var b strings.Builder + flush := func() { + if b.Len() == 0 { + return + } + out = append(out, b.String()) + b.Reset() + } + for _, r := range s { + switch { + case isCJK(r): + flush() + out = append(out, string(r)) + case unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_': + b.WriteRune(unicode.ToLower(r)) + default: + flush() + } + } + flush() + return out +} + +func isCJK(r rune) bool { + return unicode.In(r, unicode.Han, unicode.Hiragana, unicode.Katakana, unicode.Hangul) +} + +// Unique returns terms in first-seen order. +func Unique(in []string) []string { + seen := map[string]bool{} + out := make([]string, 0, len(in)) + for _, s := range in { + if s == "" || seen[s] { + continue + } + seen[s] = true + out = append(out, s) + } + return out +} + +// Counts returns a term-frequency map. +func Counts(terms []string) map[string]int { + counts := map[string]int{} + for _, term := range terms { + counts[term]++ + } + return counts +} + +// BM25Score scores a document against query terms. +func BM25Score(counts map[string]int, length int, queryTerms []string, df map[string]int, totalDocs int, avgLen float64) float64 { + const ( + k1 = 1.2 + b = 0.75 + ) + if length <= 0 || totalDocs <= 0 { + return 0 + } + if avgLen <= 0 { + avgLen = 1 + } + var score float64 + docLen := float64(length) + for _, term := range queryTerms { + tf := counts[term] + if tf == 0 { + continue + } + termDF := df[term] + if termDF == 0 { + continue + } + idf := math.Log(1 + (float64(totalDocs)-float64(termDF)+0.5)/(float64(termDF)+0.5)) + freq := float64(tf) + score += idf * (freq * (k1 + 1)) / (freq + k1*(1-b+b*docLen/avgLen)) + } + return score +} + +// DocumentFrequency counts how many documents contain each term. +func DocumentFrequency(docs []map[string]int) map[string]int { + df := map[string]int{} + for _, counts := range docs { + for term := range counts { + df[term]++ + } + } + return df +} + +// KeepTopRelativeScore keeps the best item and drops trailing items whose score +// falls below ratio * topScore. Callers must pass items already sorted best +// first. This mirrors SQLite FTS/BM25 search UIs that over-fetch, then trim +// common-word-only noise without imposing an absolute score threshold. +func KeepTopRelativeScore[T any](items []T, ratio float64, score func(T) float64) []T { + if len(items) == 0 || ratio <= 0 { + return items + } + top := score(items[0]) + if top <= 0 { + return items + } + cutoff := top * ratio + out := items[:0] + for i, item := range items { + if i == 0 || score(item) >= cutoff { + out = append(out, item) + } + } + return out +} + +// QueryTerms normalizes a search string and reports an error when nothing +// searchable remains. +func QueryTerms(query string) ([]string, error) { + terms := Unique(Tokens(strings.TrimSpace(query))) + if len(terms) == 0 { + return nil, fmt.Errorf("query must contain at least one letter or number") + } + return terms, nil +} + +// MakeSnippet returns a whitespace-compacted excerpt centered near the query. +func MakeSnippet(text, query string, terms []string, maxRunes int) string { + text = CompactWhitespace(text) + if maxRunes <= 0 || utf8.RuneCountInString(text) <= maxRunes { + return text + } + lower := strings.ToLower(text) + query = strings.ToLower(strings.TrimSpace(query)) + idx := -1 + if query != "" { + idx = strings.Index(lower, query) + } + if idx < 0 { + for _, term := range terms { + runes := []rune(term) + if len(runes) == 1 && !isCJK(runes[0]) { + continue + } + if i := strings.Index(lower, term); i >= 0 { + idx = i + break + } + } + } + if idx < 0 { + idx = 0 + } + return snippetAround(text, idx, maxRunes) +} + +func snippetAround(text string, byteIdx, maxRunes int) string { + if byteIdx < 0 { + byteIdx = 0 + } + if byteIdx > len(text) { + byteIdx = len(text) + } + for byteIdx > 0 && byteIdx < len(text) && !utf8.RuneStart(text[byteIdx]) { + byteIdx-- + } + runes := []rune(text) + pos := utf8.RuneCountInString(text[:byteIdx]) + start := pos - maxRunes/2 + if start < 0 { + start = 0 + } + end := start + maxRunes + if end > len(runes) { + end = len(runes) + start = end - maxRunes + if start < 0 { + start = 0 + } + } + prefix := "" + suffix := "" + if start > 0 { + prefix = "..." + } + if end < len(runes) { + suffix = "..." + } + return prefix + string(runes[start:end]) + suffix +} + +// CompactWhitespace collapses runs of whitespace into one ASCII space. +func CompactWhitespace(s string) string { + return strings.Join(strings.Fields(s), " ") +} diff --git a/internal/retrieval/bm25_test.go b/internal/retrieval/bm25_test.go new file mode 100644 index 000000000..23db76c47 --- /dev/null +++ b/internal/retrieval/bm25_test.go @@ -0,0 +1,68 @@ +package retrieval + +import ( + "strings" + "testing" + "unicode/utf8" +) + +func TestTokensHandlesLatinAndCJK(t *testing.T) { + got := Tokens("BM25 检索 cache-first") + want := []string{"bm25", "检", "索", "cache", "first"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("Tokens() = %#v, want %#v", got, want) + } +} + +func TestBM25ScoreRanksMatchingDocument(t *testing.T) { + query := Unique(Tokens("prompt cache")) + doc1 := Counts(Tokens("prompt cache cache stability")) + doc2 := Counts(Tokens("dashboard colors")) + df := DocumentFrequency([]map[string]int{doc1, doc2}) + score1 := BM25Score(doc1, 4, query, df, 2, 3) + score2 := BM25Score(doc2, 2, query, df, 2, 3) + if score1 <= score2 { + t.Fatalf("matching score %.3f should exceed unrelated score %.3f", score1, score2) + } +} + +func TestKeepTopRelativeScoreKeepsTopAndDropsWeakTail(t *testing.T) { + items := []struct { + name string + score float64 + }{ + {name: "top", score: 10}, + {name: "near", score: 2}, + {name: "noise", score: 1.4}, + {name: "zero", score: 0}, + } + got := KeepTopRelativeScore(items, 0.15, func(item struct { + name string + score float64 + }) float64 { + return item.score + }) + if len(got) != 2 || got[0].name != "top" || got[1].name != "near" { + t.Fatalf("KeepTopRelativeScore() = %#v, want top and near", got) + } +} + +func TestMakeSnippetHandlesMultibyteBoundary(t *testing.T) { + text := strings.Repeat("前缀", 80) + "稳定结论 synthesis cache " + strings.Repeat("后缀", 80) + out := MakeSnippet(text, "synthesis cache", QueryTermsForTest(t, "synthesis cache"), 60) + if !strings.Contains(out, "synthesis cache") { + t.Fatalf("snippet missing query: %q", out) + } + if strings.ContainsRune(out, utf8.RuneError) { + t.Fatalf("snippet contains replacement rune: %q", out) + } +} + +func QueryTermsForTest(t *testing.T, query string) []string { + t.Helper() + terms, err := QueryTerms(query) + if err != nil { + t.Fatal(err) + } + return terms +}