Skip to content

Commit ac48085

Browse files
committed
Update: add memory management tools and enhance documentation for memory operations
1 parent 04f3845 commit ac48085

6 files changed

Lines changed: 579 additions & 3 deletions

File tree

HOW_TO_START.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,17 +165,21 @@ This starts the agent loop, heartbeat, and any enabled channels (e.g., Telegram,
165165

166166
## Available Tools
167167

168-
The agent has access to 11 tools:
168+
The agent has access to 15 tools:
169169

170170
| Tool | Purpose |
171-
|------|---------|
171+
|------|--------|
172172
| `message` | Send messages to channels |
173173
| `filesystem` | Read, write, list files |
174174
| `exec` | Run shell commands |
175175
| `web` | Fetch web content from URLs |
176176
| `spawn` | Spawn background subagent |
177177
| `cron` | Schedule cron jobs |
178178
| `write_memory` | Persist information to memory |
179+
| `list_memory` | List all memory files |
180+
| `read_memory` | Read a specific memory file |
181+
| `edit_memory` | Find and replace text in a memory file |
182+
| `delete_memory` | Delete a daily memory file |
179183
| `create_skill` | Create a new skill |
180184
| `list_skills` | List available skills |
181185
| `read_skill` | Read a skill's content |

internal/agent/loop.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,12 @@ func NewAgentLoop(b *chat.Hub, provider providers.LLMProvider, model string, max
9797
sm := session.NewSessionManager(workspace)
9898
ctx := NewContextBuilder(workspace, memory.NewLLMRanker(provider, model), 5)
9999
mem := memory.NewMemoryStoreWithWorkspace(workspace, 100)
100-
// register memory tool (needs store instance)
100+
// register memory tools (all share the same store instance)
101101
reg.Register(tools.NewWriteMemoryTool(mem))
102+
reg.Register(tools.NewListMemoryTool(mem))
103+
reg.Register(tools.NewReadMemoryTool(mem))
104+
reg.Register(tools.NewEditMemoryTool(mem))
105+
reg.Register(tools.NewDeleteMemoryTool(mem))
102106

103107
// register skill management tools (share the same os.Root)
104108
skillMgr := tools.NewSkillManager(root)

internal/agent/memory/store.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,86 @@ func (s *MemoryStore) GetRecentMemories(days int) (string, error) {
192192
return strings.Join(parts, "\n---\n"), nil
193193
}
194194

195+
// isValidMemoryFile reports whether name is a safe, recognised memory filename
196+
// (either "MEMORY.md" or a date file "YYYY-MM-DD.md").
197+
func isValidMemoryFile(name string) bool {
198+
if name == "MEMORY.md" {
199+
return true
200+
}
201+
// Must be exactly "YYYY-MM-DD.md" (13 chars).
202+
if len(name) != 13 || name[4] != '-' || name[7] != '-' || name[10:] != ".md" {
203+
return false
204+
}
205+
_, err := time.Parse("2006-01-02", name[:10])
206+
return err == nil
207+
}
208+
209+
// ListFiles returns the filenames of all files in the memory directory.
210+
func (s *MemoryStore) ListFiles() ([]string, error) {
211+
entries, err := os.ReadDir(s.memoryDir)
212+
if err != nil {
213+
if os.IsNotExist(err) {
214+
return []string{}, nil
215+
}
216+
return nil, err
217+
}
218+
names := make([]string, 0, len(entries))
219+
for _, e := range entries {
220+
if !e.IsDir() {
221+
names = append(names, e.Name())
222+
}
223+
}
224+
return names, nil
225+
}
226+
227+
// ReadFile reads a named file from the memory directory.
228+
// name must be "MEMORY.md" or a date file "YYYY-MM-DD.md".
229+
// Returns ("", nil) if the file does not exist.
230+
func (s *MemoryStore) ReadFile(name string) (string, error) {
231+
if !isValidMemoryFile(name) {
232+
return "", fmt.Errorf("invalid memory filename: %q", name)
233+
}
234+
b, err := os.ReadFile(filepath.Join(s.memoryDir, name))
235+
if err != nil {
236+
if os.IsNotExist(err) {
237+
return "", nil
238+
}
239+
return "", err
240+
}
241+
return string(b), nil
242+
}
243+
244+
// WriteFile writes content to a named file in the memory directory.
245+
// name must be "MEMORY.md" or a date file "YYYY-MM-DD.md".
246+
func (s *MemoryStore) WriteFile(name, content string) error {
247+
if !isValidMemoryFile(name) {
248+
return fmt.Errorf("invalid memory filename: %q", name)
249+
}
250+
if err := os.MkdirAll(s.memoryDir, 0o755); err != nil {
251+
return err
252+
}
253+
return os.WriteFile(filepath.Join(s.memoryDir, name), []byte(content), 0o644)
254+
}
255+
256+
// DeleteFile deletes a dated memory file (YYYY-MM-DD.md only).
257+
// Long-term memory (MEMORY.md) is protected and cannot be deleted via this method.
258+
func (s *MemoryStore) DeleteFile(name string) error {
259+
// Only dated files may be deleted — never MEMORY.md.
260+
if len(name) != 13 || name[4] != '-' || name[7] != '-' || name[10:] != ".md" {
261+
return fmt.Errorf("delete_memory: only dated files (YYYY-MM-DD) can be deleted, got %q", name)
262+
}
263+
if _, err := time.Parse("2006-01-02", name[:10]); err != nil {
264+
return fmt.Errorf("delete_memory: only dated files (YYYY-MM-DD) can be deleted, got %q", name)
265+
}
266+
if err := os.Remove(filepath.Join(s.memoryDir, name)); err != nil {
267+
if os.IsNotExist(err) {
268+
return fmt.Errorf("memory file not found: %q", name)
269+
}
270+
return err
271+
}
272+
return nil
273+
}
274+
195275
// GetMemoryContext returns combined long-term memory + today's notes for the system prompt.
196276
func (s *MemoryStore) GetMemoryContext() (string, error) {
197277
lt, err := s.ReadLongTerm()

internal/agent/tools/memory.go

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
package tools
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"strings"
7+
"time"
8+
9+
"github.com/local/picobot/internal/agent/memory"
10+
)
11+
12+
// resolveMemoryTarget maps a user-friendly target to a memory filename.
13+
// target: "today", "long", or "YYYY-MM-DD".
14+
func resolveMemoryTarget(target string) (string, error) {
15+
switch target {
16+
case "today":
17+
return time.Now().UTC().Format("2006-01-02") + ".md", nil
18+
case "long":
19+
return "MEMORY.md", nil
20+
default:
21+
if _, err := time.Parse("2006-01-02", target); err == nil {
22+
return target + ".md", nil
23+
}
24+
return "", fmt.Errorf("invalid target %q: use 'today', 'long', or 'YYYY-MM-DD'", target)
25+
}
26+
}
27+
28+
// ─── list_memory ────
29+
30+
// ListMemoryTool lists all files in the agent's memory directory.
31+
type ListMemoryTool struct {
32+
mem *memory.MemoryStore
33+
}
34+
35+
func NewListMemoryTool(mem *memory.MemoryStore) *ListMemoryTool {
36+
return &ListMemoryTool{mem: mem}
37+
}
38+
39+
func (t *ListMemoryTool) Name() string { return "list_memory" }
40+
func (t *ListMemoryTool) Description() string {
41+
return "List all memory files (daily notes and long-term memory)"
42+
}
43+
func (t *ListMemoryTool) Parameters() map[string]interface{} { return nil }
44+
45+
func (t *ListMemoryTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
46+
files, err := t.mem.ListFiles()
47+
if err != nil {
48+
return "", err
49+
}
50+
if len(files) == 0 {
51+
return "No memory files found.", nil
52+
}
53+
today := time.Now().UTC().Format("2006-01-02") + ".md"
54+
var sb strings.Builder
55+
fmt.Fprintf(&sb, "Memory files (%d):\n", len(files))
56+
for _, f := range files {
57+
switch f {
58+
case "MEMORY.md":
59+
fmt.Fprintf(&sb, "- %s (long-term)\n", f)
60+
case today:
61+
fmt.Fprintf(&sb, "- %s (today)\n", f)
62+
default:
63+
fmt.Fprintf(&sb, "- %s\n", f)
64+
}
65+
}
66+
return strings.TrimRight(sb.String(), "\n"), nil
67+
}
68+
69+
// ─── read_memory ────
70+
71+
// ReadMemoryTool reads the contents of a specific memory file.
72+
type ReadMemoryTool struct {
73+
mem *memory.MemoryStore
74+
}
75+
76+
func NewReadMemoryTool(mem *memory.MemoryStore) *ReadMemoryTool {
77+
return &ReadMemoryTool{mem: mem}
78+
}
79+
80+
func (t *ReadMemoryTool) Name() string { return "read_memory" }
81+
func (t *ReadMemoryTool) Description() string { return "Read the contents of a memory file" }
82+
func (t *ReadMemoryTool) Parameters() map[string]interface{} {
83+
return map[string]interface{}{
84+
"type": "object",
85+
"properties": map[string]interface{}{
86+
"target": map[string]interface{}{
87+
"type": "string",
88+
"description": "'today' for today's note, 'long' for long-term memory, or a date 'YYYY-MM-DD'",
89+
},
90+
},
91+
"required": []string{"target"},
92+
}
93+
}
94+
95+
func (t *ReadMemoryTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
96+
target, ok := args["target"].(string)
97+
if !ok || target == "" {
98+
return "", fmt.Errorf("read_memory: 'target' argument required (today|long|YYYY-MM-DD)")
99+
}
100+
name, err := resolveMemoryTarget(target)
101+
if err != nil {
102+
return "", err
103+
}
104+
content, err := t.mem.ReadFile(name)
105+
if err != nil {
106+
return "", err
107+
}
108+
if content == "" {
109+
return fmt.Sprintf("(%s is empty or does not exist)", name), nil
110+
}
111+
return content, nil
112+
}
113+
114+
// ─── edit_memory ────
115+
116+
// EditMemoryTool finds and replaces text within a memory file.
117+
type EditMemoryTool struct {
118+
mem *memory.MemoryStore
119+
}
120+
121+
func NewEditMemoryTool(mem *memory.MemoryStore) *EditMemoryTool {
122+
return &EditMemoryTool{mem: mem}
123+
}
124+
125+
func (t *EditMemoryTool) Name() string { return "edit_memory" }
126+
func (t *EditMemoryTool) Description() string { return "Find and replace text within a memory file" }
127+
func (t *EditMemoryTool) Parameters() map[string]interface{} {
128+
return map[string]interface{}{
129+
"type": "object",
130+
"properties": map[string]interface{}{
131+
"target": map[string]interface{}{
132+
"type": "string",
133+
"description": "'today', 'long', or 'YYYY-MM-DD'",
134+
},
135+
"old_text": map[string]interface{}{
136+
"type": "string",
137+
"description": "Exact text to find and replace",
138+
},
139+
"new_text": map[string]interface{}{
140+
"type": "string",
141+
"description": "Replacement text (omit or set to empty string to delete the matched text)",
142+
},
143+
},
144+
"required": []string{"target", "old_text"},
145+
}
146+
}
147+
148+
func (t *EditMemoryTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
149+
target, ok := args["target"].(string)
150+
if !ok || target == "" {
151+
return "", fmt.Errorf("edit_memory: 'target' argument required (today|long|YYYY-MM-DD)")
152+
}
153+
oldText, ok := args["old_text"].(string)
154+
if !ok || oldText == "" {
155+
return "", fmt.Errorf("edit_memory: 'old_text' argument required")
156+
}
157+
newText, _ := args["new_text"].(string) // defaults to "" (deletion) if absent
158+
159+
name, err := resolveMemoryTarget(target)
160+
if err != nil {
161+
return "", err
162+
}
163+
content, err := t.mem.ReadFile(name)
164+
if err != nil {
165+
return "", err
166+
}
167+
if !strings.Contains(content, oldText) {
168+
return "", fmt.Errorf("edit_memory: text not found in %s", name)
169+
}
170+
updated := strings.ReplaceAll(content, oldText, newText)
171+
if err := t.mem.WriteFile(name, updated); err != nil {
172+
return "", err
173+
}
174+
return fmt.Sprintf("edited %s", name), nil
175+
}
176+
177+
// ─── delete_memory ────
178+
179+
// DeleteMemoryTool deletes a dated daily memory file.
180+
// Long-term memory (MEMORY.md) will be protected.
181+
type DeleteMemoryTool struct {
182+
mem *memory.MemoryStore
183+
}
184+
185+
func NewDeleteMemoryTool(mem *memory.MemoryStore) *DeleteMemoryTool {
186+
return &DeleteMemoryTool{mem: mem}
187+
}
188+
189+
func (t *DeleteMemoryTool) Name() string { return "delete_memory" }
190+
func (t *DeleteMemoryTool) Description() string {
191+
return "Delete a daily memory file (YYYY-MM-DD). Long-term memory (MEMORY.md) cannot be deleted this way."
192+
}
193+
func (t *DeleteMemoryTool) Parameters() map[string]interface{} {
194+
return map[string]interface{}{
195+
"type": "object",
196+
"properties": map[string]interface{}{
197+
"target": map[string]interface{}{
198+
"type": "string",
199+
"description": "Date of the daily note to delete, in 'YYYY-MM-DD' format",
200+
},
201+
},
202+
"required": []string{"target"},
203+
}
204+
}
205+
206+
func (t *DeleteMemoryTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
207+
target, ok := args["target"].(string)
208+
if !ok || target == "" {
209+
return "", fmt.Errorf("delete_memory: 'target' argument required (YYYY-MM-DD)")
210+
}
211+
// Only dated files are accepted — "long" / "today" are rejected here.
212+
if _, err := time.Parse("2006-01-02", target); err != nil {
213+
return "", fmt.Errorf("delete_memory: target must be a date in YYYY-MM-DD format, got %q", target)
214+
}
215+
if err := t.mem.DeleteFile(target + ".md"); err != nil {
216+
return "", err
217+
}
218+
return fmt.Sprintf("deleted %s.md", target), nil
219+
}

0 commit comments

Comments
 (0)