| 1 | package memory |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "strings" |
| 8 | |
| 9 | "reasonix/internal/tool" |
| 10 | ) |
| 11 | |
| 12 | // rememberTool lets the model persist a durable fact to the auto-memory store. |
| 13 | // It is stateful (bound to one project's Store), so boot constructs it and adds |
| 14 | // it to the registry — the same pattern as the task tool — rather than |
| 15 | // self-registering as a stateless built-in. |
| 16 | type rememberTool struct{ store Store } |
| 17 | |
| 18 | type rememberRequest struct { |
| 19 | ID string `json:"id"` |
| 20 | ExpectedRevision int `json:"expected_revision"` |
| 21 | Name string `json:"name"` |
| 22 | Title string `json:"title"` |
| 23 | Description string `json:"description"` |
| 24 | Type string `json:"type"` |
| 25 | Scope string `json:"scope"` |
| 26 | Body string `json:"body"` |
| 27 | } |
| 28 | |
| 29 | // NewRememberTool returns the `remember` tool bound to store. A zero/disabled |
| 30 | // store yields a tool that reports the store is unavailable rather than silently |
| 31 | // dropping saves. |
| 32 | func NewRememberTool(store Store) tool.Tool { return rememberTool{store: store} } |
| 33 | |
| 34 | func (rememberTool) Name() string { return "remember" } |
| 35 | |
| 36 | func (rememberTool) Description() string { |
| 37 | return "Save a durable background fact so it survives across sessions. " + |
| 38 | "Use for things worth remembering long-term: who the user is and their preferences (type \"user\"); " + |
| 39 | "guidance on how to work, including the why (type \"feedback\"); ongoing goals or constraints not " + |
| 40 | "derivable from the code (type \"project\"); or pointers to external resources (type \"reference\"). " + |
| 41 | "For feedback/project, structure the body with a \"**Why:**\" line and a \"**How to apply:**\" line so the fact is actionable later; " + |
| 42 | "link related memories inline with [[their-name]]. " + |
| 43 | "Do NOT save what the repo already records (code structure, git history) or facts that only matter to the current conversation; " + |
| 44 | "if asked to remember one of those, save instead the non-obvious point behind it. " + |
| 45 | "Choose scope \"project\" for the current workspace (the safe default) or \"global\" only when the fact should affect every project. " + |
| 46 | "Standing rules that must always be followed belong in project or global REASONIX.md/AGENTS.md instructions, not background memory. " + |
| 47 | "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. " + |
| 48 | "The saved index loads into context at the start of each session." |
| 49 | } |
| 50 | |
| 51 | func (rememberTool) Schema() json.RawMessage { |
| 52 | return json.RawMessage(`{ |
| 53 | "type": "object", |
| 54 | "properties": { |
| 55 | "id": {"type": "string", "description": "Stable memory id for an update. Prefer this over name when supplied by memory search/read."}, |
| 56 | "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."}, |
| 57 | "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."}, |
| 58 | "title": {"type": "string", "description": "Short human-readable label shown in the memory index, e.g. \"Prefers tabs\". Omit to derive one from the name."}, |
| 59 | "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."}, |
| 60 | "type": {"type": "string", "enum": ["user", "feedback", "project", "reference"], "description": "Category of the fact."}, |
| 61 | "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."}, |
| 62 | "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]]."} |
| 63 | }, |
| 64 | "required": ["description", "body"] |
| 65 | }`) |
| 66 | } |
| 67 | |
| 68 | func (t rememberTool) Execute(ctx context.Context, args json.RawMessage) (string, error) { |
| 69 | in, err := parseRememberRequest(args) |
| 70 | if err != nil { |
| 71 | return "", err |
| 72 | } |
| 73 | if in.Description == "" || in.Body == "" { |
| 74 | return "", fmt.Errorf("description and body are required") |
| 75 | } |
| 76 | scope := strings.ToLower(strings.TrimSpace(in.Scope)) |
| 77 | if scope != "" && scope != string(FactScopeProject) && scope != string(FactScopeGlobal) { |
| 78 | return "", fmt.Errorf("scope must be one of project, global") |
| 79 | } |
| 80 | factScope := FactScope(scope) |
| 81 | name := rememberRequestName(in) |
| 82 | autoCreate := ClaimAutoMemoryWriteFromContext(ctx, args) |
| 83 | result, err := t.store.SaveWithOptions(Memory{ |
| 84 | ID: in.ID, |
| 85 | Name: name, |
| 86 | Title: in.Title, |
| 87 | Description: in.Description, |
| 88 | Type: NormalizeType(in.Type), |
| 89 | Scope: factScope, |
| 90 | Body: in.Body, |
| 91 | }, SaveOptions{ |
| 92 | ExpectedRevision: in.ExpectedRevision, |
| 93 | RequireExpectedRevision: in.ExpectedRevision > 0, |
| 94 | RequireCreate: autoCreate, |
| 95 | }) |
| 96 | if err != nil { |
| 97 | return "", err |
| 98 | } |
| 99 | path := result.Path |
| 100 | if saved, ok := loadMemory(path); ok && saved.Scope != "" { |
| 101 | factScope = NormalizeFactScope(string(saved.Scope)) |
| 102 | } else { |
| 103 | factScope = t.store.scopeForPath(path) |
| 104 | } |
| 105 | if q, ok := QueueFromContext(ctx); ok { |
| 106 | q.QueueMemory("Saved memory \"" + result.Memory.Name + "\" (" + string(factScope) + "): " + oneLine(result.Memory.Description) + "\n" + strings.TrimSpace(result.Memory.Body)) |
| 107 | } |
| 108 | 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 |
| 109 | } |
| 110 | |
| 111 | func (rememberTool) ReadOnly() bool { return false } |
| 112 | |
| 113 | func parseRememberRequest(args json.RawMessage) (rememberRequest, error) { |
| 114 | var in rememberRequest |
| 115 | if err := json.Unmarshal(args, &in); err != nil { |
| 116 | return rememberRequest{}, fmt.Errorf("invalid arguments: %w", err) |
| 117 | } |
| 118 | return in, nil |
| 119 | } |
| 120 | |
| 121 | func rememberRequestName(in rememberRequest) string { |
| 122 | if name := strings.TrimSpace(in.Name); name != "" { |
| 123 | return name |
| 124 | } |
| 125 | name := "" |
| 126 | if in.ID == "" { |
| 127 | name = in.Title |
| 128 | } |
| 129 | if name == "" && in.ID == "" { |
| 130 | name = in.Description |
| 131 | } |
| 132 | return slug(name) |
| 133 | } |
| 134 |