返回 DeepSeek-Reasonix
remember.go
根目录 / internal / memory / remember.go
1 package memory
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "strings"
8 "time"
9
10 "reasonix/internal/tool"
11 )
12
13 // rememberTool lets the model persist a durable fact to the auto-memory store.
14 // It is stateful (bound to one project's Store), so boot constructs it and adds
15 // it to the registry — the same pattern as the task tool — rather than
16 // self-registering as a stateless built-in.
17 type rememberTool struct{ store Store }
18
19 type rememberRequest struct {
20 ID string `json:"id"`
21 ExpectedRevision int `json:"expected_revision"`
22 Name string `json:"name"`
23 Title string `json:"title"`
24 Description string `json:"description"`
25 Type string `json:"type"`
26 Scope string `json:"scope"`
27 Activation string `json:"activation"`
28 Volatility string `json:"volatility"`
29 SubjectKey string `json:"subject_key"`
30 ExpiresAt string `json:"expires_at"`
31 Verified bool `json:"verified"`
32 Keywords string `json:"keywords"`
33 Body string `json:"body"`
34 }
35
36 // NewRememberTool returns the `remember` tool bound to store. A zero/disabled
37 // store yields a tool that reports the store is unavailable rather than silently
38 // dropping saves.
39 func NewRememberTool(store Store) tool.Tool { return rememberTool{store: store} }
40
41 func (rememberTool) Name() string { return tool.HostRemember }
42
43 func (rememberTool) Description() string {
44 return "Save a durable background fact so it survives across sessions. " +
45 "Use for things worth remembering long-term: who the user is and their preferences (type \"user\"); " +
46 "guidance on how to work, including the why (type \"feedback\"); ongoing goals or constraints not " +
47 "derivable from the code (type \"project\"); or pointers to external resources (type \"reference\"). " +
48 "For feedback/project, structure the body with a \"**Why:**\" line and a \"**How to apply:**\" line so the fact is actionable later; " +
49 "link related memories inline with [[their-name]]. " +
50 "Do NOT save what the repo already records (code structure, git history) or facts that only matter to the current conversation; " +
51 "if asked to remember one of those, save instead the non-obvious point behind it. " +
52 "Choose scope \"project\" for the current workspace (the safe default) or \"global\" only when the fact should affect every project. " +
53 "Standing rules that must always be followed belong in project or global REASONIX.md/AGENTS.md instructions, not background memory. " +
54 "Before saving, check the loaded memory index for an entry that already covers this — reuse that name to update it rather than create a near-duplicate, and use `forget` to drop one that is now wrong. " +
55 "The saved index loads into context at the start of each session."
56 }
57
58 func (rememberTool) Schema() json.RawMessage {
59 return json.RawMessage(`{
60 "type": "object",
61 "properties": {
62 "id": {"type": "string", "description": "Stable memory id for an update. Prefer this over name when supplied by memory search/read."},
63 "expected_revision": {"type": "integer", "minimum": 1, "description": "Revision returned by memory search/read. When set with id, the update fails instead of overwriting a newer change."},
64 "name": {"type": "string", "description": "Stable project/<name>.md or global/<name>.md reference returned by memory search/read/list, or a short kebab-case slug for a new fact. Reusing a reference updates that exact memory. Omit to derive a new slug from the description."},
65 "title": {"type": "string", "description": "Short human-readable label shown in the memory index, e.g. \"Prefers tabs\". Omit to derive one from the name."},
66 "description": {"type": "string", "description": "One-line hook shown in the index — the phrase a future session reads to decide whether to open this memory. Make it specific."},
67 "type": {"type": "string", "enum": ["user", "feedback", "project", "reference"], "description": "Category of the fact."},
68 "scope": {"type": "string", "enum": ["project", "global"], "description": "Where the fact applies. For a new fact, omit for the safe default, project. When updating an existing name, omit to preserve its current scope. Use global only when it should affect every workspace."},
69 "activation": {"type": "string", "enum": ["relevant", "pinned"], "description": "How the fact reaches the model: relevant (the default) is retrieval-only; pinned loads the body into every session's stable prefix. Use pinned ONLY when the user explicitly asks for an always-available fact — pinned space is budget-limited, and rules that must always hold belong in REASONIX.md/AGENTS.md instructions instead. Omit on update to preserve the current choice."},
70 "volatility": {"type": "string", "enum": ["evergreen", "stable", "volatile"], "description": "How fast the fact ages, independent of type: volatile for facts that die in days (a current release branch, this week's task), stable for slow-changing ones, evergreen for facts that never age (a README location, a fixed preference). Omit to use the type default, or on update to preserve the current choice."},
71 "subject_key": {"type": "string", "description": "Dotted key naming the question this fact answers, e.g. project.package_manager, project.release_branch, user.response_style. One active value per scope+subject: saving a new fact for a held subject is rejected with the holder's id — update that id so the change becomes a revision, not a contradiction. Search existing memories first and reuse their keys; omit for narrative facts that are not a single-valued answer."},
72 "expires_at": {"type": "string", "description": "Hard expiry as RFC3339 or YYYY-MM-DD. Past this moment the fact stops being auto-recalled entirely. Set it when the fact has a known end of life. Omit on update to preserve; \"never\" clears an existing expiry."},
73 "verified": {"type": "boolean", "description": "Set true only when you have JUST re-confirmed the fact still holds (checked the file, ran the command). Renews the freshness clock without changing the meaning of updated_at."},
74 "keywords": {"type": "string", "description": "Space-separated search aliases a future query might use where the body's own words would miss: synonyms, translations of key terms (recall matching is lexical, so give Chinese facts English aliases and vice versa), related command or tool names. Omit when the body already carries the likely query words. When updating, omit to preserve existing keywords."},
75 "body": {"type": "string", "description": "The fact itself (Markdown). For feedback/project, include a \"**Why:**\" line and a \"**How to apply:**\" line; link related memories with [[their-name]]."}
76 },
77 "required": ["description", "body"]
78 }`)
79 }
80
81 func (t rememberTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
82 in, err := parseRememberRequest(args)
83 if err != nil {
84 return "", err
85 }
86 if in.Description == "" || in.Body == "" {
87 return "", fmt.Errorf("description and body are required")
88 }
89 scope := strings.ToLower(strings.TrimSpace(in.Scope))
90 if scope != "" && scope != string(FactScopeProject) && scope != string(FactScopeGlobal) {
91 return "", fmt.Errorf("scope must be one of project, global")
92 }
93 factScope := FactScope(scope)
94 name := rememberRequestName(in)
95 autoCreate := ClaimAutoMemoryWriteFromContext(ctx, args)
96 activation := NormalizeActivation(in.Activation)
97 if strings.TrimSpace(in.Activation) != "" && activation == "" {
98 return "", fmt.Errorf("activation must be one of relevant, pinned")
99 }
100 volatility := NormalizeVolatility(in.Volatility)
101 if strings.TrimSpace(in.Volatility) != "" && volatility == "" {
102 return "", fmt.Errorf("volatility must be one of evergreen, stable, volatile")
103 }
104 expiresAt, clearExpiry, err := parseExpiry(in.ExpiresAt)
105 if err != nil {
106 return "", err
107 }
108 var verifiedAt time.Time
109 if in.Verified {
110 verifiedAt = time.Now().UTC()
111 }
112 result, err := t.store.SaveWithOptions(Memory{
113 ID: in.ID,
114 Name: name,
115 Title: in.Title,
116 Description: in.Description,
117 Type: NormalizeType(in.Type),
118 Scope: factScope,
119 Activation: activation,
120 Volatility: volatility,
121 SubjectKey: NormalizeSubjectKey(in.SubjectKey),
122 ExpiresAt: expiresAt,
123 LastVerifiedAt: verifiedAt,
124 Keywords: in.Keywords,
125 Body: in.Body,
126 }, SaveOptions{
127 ExpectedRevision: in.ExpectedRevision,
128 RequireExpectedRevision: in.ExpectedRevision > 0,
129 RequireCreate: autoCreate,
130 ClearExpiry: clearExpiry,
131 })
132 if err != nil {
133 return "", err
134 }
135 path := result.Path
136 if saved, ok := loadMemory(path); ok && saved.Scope != "" {
137 factScope = NormalizeFactScope(string(saved.Scope))
138 } else {
139 factScope = t.store.scopeForPath(path)
140 }
141 if q, ok := QueueFromContext(ctx); ok {
142 q.QueueMemory("Saved memory \"" + result.Memory.Name + "\" (" + string(factScope) + "): " + oneLine(result.Memory.Description) + "\n" + strings.TrimSpace(result.Memory.Body))
143 }
144 return fmt.Sprintf("Saved memory id=%s revision=%d (%s background) as %s (it applies now and its derived index loads automatically in future sessions).", result.Memory.ID, result.Memory.Revision, factScope, providerMemoryReference(result.Memory)), nil
145 }
146
147 func (rememberTool) ReadOnly() bool { return false }
148
149 // parseExpiry accepts RFC3339, a bare date, or "never"/"none" to clear an
150 // inherited expiry on update.
151 func parseExpiry(value string) (expires time.Time, clear bool, err error) {
152 value = strings.TrimSpace(value)
153 switch strings.ToLower(value) {
154 case "":
155 return time.Time{}, false, nil
156 case "never", "none":
157 return time.Time{}, true, nil
158 }
159 for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02"} {
160 if when, perr := time.Parse(layout, value); perr == nil {
161 return when.UTC(), false, nil
162 }
163 }
164 return time.Time{}, false, fmt.Errorf("expires_at must be RFC3339, YYYY-MM-DD, or \"never\"")
165 }
166
167 func parseRememberRequest(args json.RawMessage) (rememberRequest, error) {
168 var in rememberRequest
169 if err := json.Unmarshal(args, &in); err != nil {
170 return rememberRequest{}, fmt.Errorf("invalid arguments: %w", err)
171 }
172 return in, nil
173 }
174
175 func rememberRequestName(in rememberRequest) string {
176 if name := strings.TrimSpace(in.Name); name != "" {
177 return name
178 }
179 name := ""
180 if in.ID == "" {
181 name = in.Title
182 }
183 if name == "" && in.ID == "" {
184 name = in.Description
185 }
186 return slug(name)
187 }
188
188 lines GO