toon-memory 1.0.5 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/setup.js +101 -16
- package/bin/toon-memory.js +15 -0
- package/mcp/server.js +27862 -0
- package/package.json +1 -1
- package/skills/toon-memory.md +113 -0
- package/src/mcp/server.ts +251 -0
- package/src/memory.ts +237 -0
package/package.json
CHANGED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# toon-memory — Persistent Memory for OpenCode
|
|
2
|
+
|
|
3
|
+
## What is this?
|
|
4
|
+
|
|
5
|
+
A persistent memory system for the OpenCode AI agent. It remembers decisions, patterns, bugs, and knowledge **between sessions** using TOON format (40% fewer tokens than JSON).
|
|
6
|
+
|
|
7
|
+
## Tools
|
|
8
|
+
|
|
9
|
+
| Tool | Description |
|
|
10
|
+
|------|-------------|
|
|
11
|
+
| `memory_remember` | Save a decision, pattern, bug, or knowledge |
|
|
12
|
+
| `memory_recall` | Search memory (use BEFORE reading files) |
|
|
13
|
+
| `memory_forget` | Remove an entry by key or id |
|
|
14
|
+
| `memory_stats` | View memory state |
|
|
15
|
+
| `memory_summary` | Save/retrieve file summaries |
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
### Option 1: Interactive installer (recommended)
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npx toon-memory
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
This will:
|
|
26
|
+
1. Detect installed agents (OpenCode, Claude, Cursor)
|
|
27
|
+
2. Ask if you want local or global installation
|
|
28
|
+
3. Configure the MCP server automatically
|
|
29
|
+
|
|
30
|
+
### Option 2: Global MCP Server
|
|
31
|
+
|
|
32
|
+
Add to `~/.config/opencode/opencode.json`:
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"mcp": {
|
|
37
|
+
"toon-memory": {
|
|
38
|
+
"type": "local",
|
|
39
|
+
"command": ["npx", "-y", "toon-memory", "mcp"],
|
|
40
|
+
"enabled": true
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Option 3: Project-level MCP Server
|
|
47
|
+
|
|
48
|
+
Add to `.opencode/opencode.json`:
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"mcp": {
|
|
53
|
+
"toon-memory": {
|
|
54
|
+
"type": "local",
|
|
55
|
+
"command": ["npx", "-y", "toon-memory", "mcp"],
|
|
56
|
+
"enabled": true
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## How to use
|
|
63
|
+
|
|
64
|
+
### At the START of every session
|
|
65
|
+
|
|
66
|
+
1. Run `memory_stats` to see what's in memory.
|
|
67
|
+
2. If the user asks something that might be in memory → `memory_recall(query)` BEFORE reading files.
|
|
68
|
+
|
|
69
|
+
### When making decisions
|
|
70
|
+
|
|
71
|
+
- Before implementing a non-trivial change → `memory_remember(category='decision')`
|
|
72
|
+
- When you resolve a complex bug → `memory_remember(category='bug')`
|
|
73
|
+
- When you observe a code pattern → `memory_remember(category='pattern')`
|
|
74
|
+
|
|
75
|
+
### When exploring the project
|
|
76
|
+
|
|
77
|
+
1. First: `memory_recall(query="what you need to know")`
|
|
78
|
+
2. If no result: use CodeGraph or grep
|
|
79
|
+
3. Only if both fail: read files directly
|
|
80
|
+
|
|
81
|
+
### At the END of every session
|
|
82
|
+
|
|
83
|
+
1. If you made design decisions → `memory_remember(category='decision')`
|
|
84
|
+
2. If you resolved a bug → `memory_remember(category='bug')`
|
|
85
|
+
3. If you observed a pattern → `memory_remember(category='pattern')`
|
|
86
|
+
|
|
87
|
+
## Categories
|
|
88
|
+
|
|
89
|
+
| Category | When | Example |
|
|
90
|
+
|----------|------|---------|
|
|
91
|
+
| `decision` | Non-trivial design decision | "Use pandas instead of numpy for time series" |
|
|
92
|
+
| `pattern` | Observed code pattern | "This project uses Pydantic v2 for configs" |
|
|
93
|
+
| `bug` | Complex bug resolved | "Redis pool exhaustion, missing max_connections" |
|
|
94
|
+
| `knowledge` | General project knowledge | "Broker uses RESP protocol, not Redis-specific" |
|
|
95
|
+
|
|
96
|
+
## File format
|
|
97
|
+
|
|
98
|
+
Data is stored in `.opencode/memory/data.toon` using TOON (Token-Oriented Object Notation):
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
version: 1
|
|
102
|
+
entries[2|]{id|category|key|content|file|tags|date}:
|
|
103
|
+
a1b2c3d4|decision|risk-priority|Risk Engine has priority|spec.md:145|risk;spec|2026-07-10
|
|
104
|
+
e5f6g7h8|pattern|pandas-over-numpy|Analytics uses pandas|indicators.py|python;analytics|2026-07-10
|
|
105
|
+
summaries:
|
|
106
|
+
path/to/big-file.py: Brief summary of what this file does
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Anti-patterns
|
|
110
|
+
|
|
111
|
+
- Don't store trivia in memory (only important facts)
|
|
112
|
+
- Don't forget to save a decision before implementing
|
|
113
|
+
- Don't re-read files that already have a summary in `memory_summary`
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/server"
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"
|
|
3
|
+
import { z } from "zod"
|
|
4
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs"
|
|
5
|
+
import { dirname, join } from "path"
|
|
6
|
+
import { fileURLToPath } from "url"
|
|
7
|
+
import { randomBytes } from "crypto"
|
|
8
|
+
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
10
|
+
const MEMORY_DIR = join(process.cwd(), ".opencode", "memory")
|
|
11
|
+
const MEMORY_FILE = join(MEMORY_DIR, "data.toon")
|
|
12
|
+
|
|
13
|
+
function ensureMemoryFile() {
|
|
14
|
+
if (!existsSync(MEMORY_DIR)) mkdirSync(MEMORY_DIR, { recursive: true })
|
|
15
|
+
if (!existsSync(MEMORY_FILE)) {
|
|
16
|
+
writeFileSync(MEMORY_FILE, "version: 1\nentries[0|]{id|category|key|content|file|tags|date}:\n")
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readMemory() {
|
|
21
|
+
ensureMemoryFile()
|
|
22
|
+
return readFileSync(MEMORY_FILE, "utf-8")
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function writeMemory(content: string) {
|
|
26
|
+
ensureMemoryFile()
|
|
27
|
+
writeFileSync(MEMORY_FILE, content)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function generateId(): string {
|
|
31
|
+
return randomBytes(4).toString("hex")
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const server = new McpServer(
|
|
35
|
+
{ name: "toon-memory", version: "1.0.7" },
|
|
36
|
+
{ capabilities: { tools: { listChanged: true } } }
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
server.registerTool(
|
|
40
|
+
"memory_remember",
|
|
41
|
+
{
|
|
42
|
+
title: "Save to Memory",
|
|
43
|
+
description: "Guarda un hecho en la memoria persistente del proyecto (decisiones, patrones, bugs, conocimiento). Se recuerda entre sesiones.",
|
|
44
|
+
inputSchema: {
|
|
45
|
+
category: z.enum(["decision", "pattern", "bug", "knowledge"]).describe("Categoría del hecho"),
|
|
46
|
+
key: z.string().describe("Título corto en kebab-case (ej: risk-engine-prioridad)"),
|
|
47
|
+
content: z.string().describe("Contenido detallado del hecho"),
|
|
48
|
+
file: z.string().optional().default("").describe("Archivo o línea relacionada (ej: spec.md:145)"),
|
|
49
|
+
tags: z.string().optional().default("").describe("Tags separados por punto y coma (ej: risk;spec)"),
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
async ({ category, key, content, file, tags }) => {
|
|
53
|
+
const data = readMemory()
|
|
54
|
+
const id = generateId()
|
|
55
|
+
const date = new Date().toISOString().split("T")[0]
|
|
56
|
+
const lines = data.split("\n")
|
|
57
|
+
|
|
58
|
+
let headerIdx = lines.findIndex((l) => l.startsWith("entries["))
|
|
59
|
+
if (headerIdx === -1) {
|
|
60
|
+
lines.push(`entries[0|]{id|category|key|content|file|tags|date}:`)
|
|
61
|
+
headerIdx = lines.length - 1
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const match = lines[headerIdx].match(/entries\[(\d+)\|/)
|
|
65
|
+
const count = match ? parseInt(match[1]) : 0
|
|
66
|
+
const newEntry = `${id}|${category}|${key}|${content}|${file || ""}|${tags || ""}|${date}`
|
|
67
|
+
|
|
68
|
+
lines.splice(headerIdx + 1, 0, ` ${newEntry}`)
|
|
69
|
+
lines[headerIdx] = lines[headerIdx].replace(/entries\[\d+\|/, `[${count + 1}|`)
|
|
70
|
+
|
|
71
|
+
writeMemory(lines.join("\n"))
|
|
72
|
+
return {
|
|
73
|
+
content: [{ type: "text" as const, text: `🧠 Guardado: ${category}/${key} (${id})\n${content}` }],
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
server.registerTool(
|
|
79
|
+
"memory_recall",
|
|
80
|
+
{
|
|
81
|
+
title: "Search Memory",
|
|
82
|
+
description: "Busca en la memoria persistente del proyecto. Devuelve entradas relevantes. Usar ANTES de leer archivos.",
|
|
83
|
+
inputSchema: {
|
|
84
|
+
query: z.string().describe("Texto a buscar"),
|
|
85
|
+
category: z.string().optional().default("").describe("Filtrar por categoría (vacío = todos)"),
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
async ({ query, category }) => {
|
|
89
|
+
const data = readMemory()
|
|
90
|
+
const lines = data.split("\n").filter((l) => l.startsWith(" ") && l.includes("|"))
|
|
91
|
+
const queryLower = query.toLowerCase()
|
|
92
|
+
|
|
93
|
+
const results = lines
|
|
94
|
+
.map((line) => {
|
|
95
|
+
const trimmed = line.trim()
|
|
96
|
+
const parts = trimmed.split("|")
|
|
97
|
+
if (parts.length < 7) return null
|
|
98
|
+
const [id, cat, key, content, file, tags, date] = parts
|
|
99
|
+
if (category && cat !== category) return null
|
|
100
|
+
const searchStr = `${id} ${cat} ${key} ${content} ${file} ${tags}`.toLowerCase()
|
|
101
|
+
if (!searchStr.includes(queryLower)) return null
|
|
102
|
+
return { id, cat, key, content, file, tags, date }
|
|
103
|
+
})
|
|
104
|
+
.filter(Boolean)
|
|
105
|
+
|
|
106
|
+
if (results.length === 0) {
|
|
107
|
+
return { content: [{ type: "text" as const, text: `No se encontraron resultados para "${query}"` }] }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const formatted = results
|
|
111
|
+
.map((r) => `[${r!.cat}] ${r!.key} (${r!.id})\n ${r!.content}\n File: ${r!.file} | Tags: ${r!.tags} | Date: ${r!.date}`)
|
|
112
|
+
.join("\n\n")
|
|
113
|
+
|
|
114
|
+
return { content: [{ type: "text" as const, text: formatted }] }
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
server.registerTool(
|
|
119
|
+
"memory_forget",
|
|
120
|
+
{
|
|
121
|
+
title: "Delete from Memory",
|
|
122
|
+
description: "Elimina una entrada de la memoria por su key o id.",
|
|
123
|
+
inputSchema: {
|
|
124
|
+
key: z.string().describe("Key o id de la entrada a eliminar"),
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
async ({ key }) => {
|
|
128
|
+
const data = readMemory()
|
|
129
|
+
const lines = data.split("\n")
|
|
130
|
+
const headerIdx = lines.findIndex((l) => l.startsWith("entries["))
|
|
131
|
+
|
|
132
|
+
if (headerIdx === -1) {
|
|
133
|
+
return { content: [{ type: "text" as const, text: "No hay entradas en memoria" }] }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const entryLines = lines.slice(headerIdx + 1).filter((l) => l.trim().length > 0)
|
|
137
|
+
const filtered = entryLines.filter((l) => {
|
|
138
|
+
const parts = l.trim().split("|")
|
|
139
|
+
return parts[0] !== key && parts[2] !== key
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
const removed = entryLines.length - filtered.length
|
|
143
|
+
const match = lines[headerIdx].match(/entries\[(\d+)\|/)
|
|
144
|
+
const count = match ? parseInt(match[1]) : 0
|
|
145
|
+
lines[headerIdx] = lines[headerIdx].replace(/entries\[\d+\|/, `[${count - removed}|`)
|
|
146
|
+
lines.splice(headerIdx + 1, entryLines.length, ...filtered.map((l) => ` ${l.trim()}`))
|
|
147
|
+
|
|
148
|
+
writeMemory(lines.join("\n"))
|
|
149
|
+
return {
|
|
150
|
+
content: [{ type: "text" as const, text: `"${key}" eliminado. Quedan ${count - removed} entradas.` }],
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
server.registerTool(
|
|
156
|
+
"memory_stats",
|
|
157
|
+
{
|
|
158
|
+
title: "Memory Stats",
|
|
159
|
+
description: "Muestra estadísticas de la memoria del proyecto.",
|
|
160
|
+
inputSchema: {},
|
|
161
|
+
},
|
|
162
|
+
async () => {
|
|
163
|
+
const data = readMemory()
|
|
164
|
+
const lines = data.split("\n").filter((l) => l.startsWith(" ") && l.includes("|"))
|
|
165
|
+
const entries = lines.map((l) => {
|
|
166
|
+
const parts = l.trim().split("|")
|
|
167
|
+
return { category: parts[1] || "unknown" }
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
const byCategory: Record<string, number> = {}
|
|
171
|
+
for (const e of entries) {
|
|
172
|
+
byCategory[e.category] = (byCategory[e.category] || 0) + 1
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const summaryLines = data.split("\n").filter((l) => l.includes(":") && !l.startsWith(" ") && !l.startsWith("version") && !l.startsWith("entries"))
|
|
176
|
+
const stats = [
|
|
177
|
+
`Entradas totales: ${entries.length}`,
|
|
178
|
+
`Resúmenes de archivos: ${summaryLines.length}`,
|
|
179
|
+
"",
|
|
180
|
+
"Por categoría:",
|
|
181
|
+
...Object.entries(byCategory).map(([k, v]) => ` ${k}: ${v}`),
|
|
182
|
+
"",
|
|
183
|
+
`Últimas 5 entradas:`,
|
|
184
|
+
...lines.slice(-5).map((l) => {
|
|
185
|
+
const parts = l.trim().split("|")
|
|
186
|
+
return ` [${parts[1]}] ${parts[2]} (${parts[0]})`
|
|
187
|
+
}),
|
|
188
|
+
]
|
|
189
|
+
|
|
190
|
+
return { content: [{ type: "text" as const, text: stats.join("\n") }] }
|
|
191
|
+
}
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
server.registerTool(
|
|
195
|
+
"memory_summary",
|
|
196
|
+
{
|
|
197
|
+
title: "File Summary",
|
|
198
|
+
description: "Guarda o recupera un resumen de un archivo grande para ahorrar tokens.",
|
|
199
|
+
inputSchema: {
|
|
200
|
+
action: z.enum(["get", "set"]).describe("get para leer, set para guardar"),
|
|
201
|
+
file: z.string().describe("Ruta del archivo"),
|
|
202
|
+
summary: z.string().optional().default("").describe("Resumen del archivo (solo para set)"),
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
async ({ action, file, summary }) => {
|
|
206
|
+
const data = readMemory()
|
|
207
|
+
|
|
208
|
+
if (action === "get") {
|
|
209
|
+
const lines = data.split("\n")
|
|
210
|
+
const summaryIdx = lines.findIndex((l) => l.trim().startsWith("summaries:"))
|
|
211
|
+
if (summaryIdx === -1) {
|
|
212
|
+
return { content: [{ type: "text" as const, text: `No hay resúmenes guardados para "${file}"` }] }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const summaryLines = lines.slice(summaryIdx + 1).filter((l) => l.includes(":"))
|
|
216
|
+
const match = summaryLines.find((l) => l.startsWith(` ${file}:`))
|
|
217
|
+
if (!match) {
|
|
218
|
+
return { content: [{ type: "text" as const, text: `No hay resumen para "${file}"` }] }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const summaryText = match.replace(` ${file}: `, "")
|
|
222
|
+
return { content: [{ type: "text" as const, text: summaryText }] }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const lines = data.split("\n")
|
|
226
|
+
let summaryIdx = lines.findIndex((l) => l.trim().startsWith("summaries:"))
|
|
227
|
+
|
|
228
|
+
if (summaryIdx === -1) {
|
|
229
|
+
lines.push("", "summaries:")
|
|
230
|
+
summaryIdx = lines.length - 1
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const summaryLines = lines.slice(summaryIdx + 1).filter((l) => l.includes(":"))
|
|
234
|
+
const existingIdx = summaryLines.findIndex((l) => l.startsWith(` ${file}:`))
|
|
235
|
+
|
|
236
|
+
if (existingIdx !== -1) {
|
|
237
|
+
summaryLines[existingIdx] = ` ${file}: ${summary}`
|
|
238
|
+
} else {
|
|
239
|
+
summaryLines.push(` ${file}: ${summary}`)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
lines.splice(summaryIdx + 1, lines.length - summaryIdx - 1, ...summaryLines)
|
|
243
|
+
writeMemory(lines.join("\n"))
|
|
244
|
+
return {
|
|
245
|
+
content: [{ type: "text" as const, text: `📝 Resumen guardado para ${file}` }],
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
const transport = new StdioServerTransport()
|
|
251
|
+
await server.connect(transport)
|
package/src/memory.ts
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin"
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"
|
|
3
|
+
import { dirname, join } from "path"
|
|
4
|
+
import { fileURLToPath } from "url"
|
|
5
|
+
import { randomBytes } from "crypto"
|
|
6
|
+
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
8
|
+
const MEMORY_DIR = join(__dirname, "..", "memory")
|
|
9
|
+
const MEMORY_FILE = join(MEMORY_DIR, "data.toon")
|
|
10
|
+
|
|
11
|
+
function ensureMemoryFile() {
|
|
12
|
+
if (!existsSync(MEMORY_DIR)) mkdirSync(MEMORY_DIR, { recursive: true })
|
|
13
|
+
if (!existsSync(MEMORY_FILE)) {
|
|
14
|
+
writeFileSync(MEMORY_FILE, "version: 1\nentries[0|]{id|category|key|content|file|tags|date}:\n")
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function readMemory() {
|
|
19
|
+
ensureMemoryFile()
|
|
20
|
+
return readFileSync(MEMORY_FILE, "utf-8")
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function writeMemory(content: string) {
|
|
24
|
+
ensureMemoryFile()
|
|
25
|
+
writeFileSync(MEMORY_FILE, content)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function generateId(): string {
|
|
29
|
+
return randomBytes(4).toString("hex")
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const memoryRemember = tool({
|
|
33
|
+
description: "Guarda un hecho en la memoria persistente del proyecto (decisiones, patrones, bugs, conocimiento). Se recuerda entre sesiones.",
|
|
34
|
+
args: {
|
|
35
|
+
category: tool.schema.enum({
|
|
36
|
+
description: "Categoría del hecho",
|
|
37
|
+
options: ["decision", "pattern", "bug", "knowledge"],
|
|
38
|
+
}),
|
|
39
|
+
key: tool.schema.string({
|
|
40
|
+
description: "Título corto en kebab-case (ej: risk-engine-prioridad)",
|
|
41
|
+
}),
|
|
42
|
+
content: tool.schema.string({
|
|
43
|
+
description: "Contenido detallado del hecho",
|
|
44
|
+
}),
|
|
45
|
+
file: tool.schema.string({
|
|
46
|
+
description: "Archivo o línea relacionada (ej: spec.md:145)",
|
|
47
|
+
}),
|
|
48
|
+
tags: tool.schema.string({
|
|
49
|
+
description: "Tags separados por punto y coma (ej: risk;spec)",
|
|
50
|
+
}),
|
|
51
|
+
},
|
|
52
|
+
async execute(args) {
|
|
53
|
+
const data = readMemory()
|
|
54
|
+
const id = generateId()
|
|
55
|
+
const date = new Date().toISOString().split("T")[0]
|
|
56
|
+
const lines = data.split("\n")
|
|
57
|
+
|
|
58
|
+
let headerIdx = lines.findIndex((l) => l.startsWith("entries["))
|
|
59
|
+
if (headerIdx === -1) {
|
|
60
|
+
lines.push(`entries[0|]{id|category|key|content|file|tags|date}:`)
|
|
61
|
+
headerIdx = lines.length - 1
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const match = lines[headerIdx].match(/entries\[(\d+)\|/)
|
|
65
|
+
const count = match ? parseInt(match[1]) : 0
|
|
66
|
+
const newEntry = `${id}|${args.category}|${args.key}|${args.content}|${args.file || ""}|${args.tags || ""}|${date}`
|
|
67
|
+
|
|
68
|
+
lines.splice(headerIdx + 1, 0, ` ${newEntry}`)
|
|
69
|
+
lines[headerIdx] = lines[headerIdx].replace(/entries\[\d+\|/, `[${count + 1}|`)
|
|
70
|
+
|
|
71
|
+
writeMemory(lines.join("\n"))
|
|
72
|
+
return `🧠 Guardado: ${args.category}/${args.key} (${id})\n${args.content}`
|
|
73
|
+
},
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
export const memoryRecall = tool({
|
|
77
|
+
description: "Busca en la memoria persistente del proyecto. Devuelve entradas relevantes por categoría, tag, o texto libre. Usar ANTES de leer archivos para evitar re-investigar.",
|
|
78
|
+
args: {
|
|
79
|
+
query: tool.schema.string({
|
|
80
|
+
description: "Texto a buscar (busca en key, content, tags)",
|
|
81
|
+
}),
|
|
82
|
+
category: tool.schema.string({
|
|
83
|
+
description: "Filtrar por categoría (decision|pattern|bug|knowledge). Vacío = todos.",
|
|
84
|
+
}),
|
|
85
|
+
},
|
|
86
|
+
async execute(args) {
|
|
87
|
+
const data = readMemory()
|
|
88
|
+
const lines = data.split("\n").filter((l) => l.startsWith(" ") && l.includes("|"))
|
|
89
|
+
const queryLower = args.query.toLowerCase()
|
|
90
|
+
|
|
91
|
+
const results = lines
|
|
92
|
+
.map((line) => {
|
|
93
|
+
const trimmed = line.trim()
|
|
94
|
+
const parts = trimmed.split("|")
|
|
95
|
+
if (parts.length < 7) return null
|
|
96
|
+
const [id, cat, key, content, file, tags, date] = parts
|
|
97
|
+
if (args.category && cat !== args.category) return null
|
|
98
|
+
const searchStr = `${id} ${cat} ${key} ${content} ${file} ${tags}`.toLowerCase()
|
|
99
|
+
if (!searchStr.includes(queryLower)) return null
|
|
100
|
+
return { id, cat, key, content, file, tags, date }
|
|
101
|
+
})
|
|
102
|
+
.filter(Boolean)
|
|
103
|
+
|
|
104
|
+
if (results.length === 0) {
|
|
105
|
+
return `No se encontraron resultados para "${args.query}"`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return results
|
|
109
|
+
.map((r) => `[${r!.cat}] ${r!.key} (${r!.id})\n ${r!.content}\n File: ${r!.file} | Tags: ${r!.tags} | Date: ${r!.date}`)
|
|
110
|
+
.join("\n\n")
|
|
111
|
+
},
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
export const memoryForget = tool({
|
|
115
|
+
description: "Elimina una entrada de la memoria por su key o id.",
|
|
116
|
+
args: {
|
|
117
|
+
key: tool.schema.string({
|
|
118
|
+
description: "Key o id de la entrada a eliminar",
|
|
119
|
+
}),
|
|
120
|
+
},
|
|
121
|
+
async execute(args) {
|
|
122
|
+
const data = readMemory()
|
|
123
|
+
const lines = data.split("\n")
|
|
124
|
+
const headerIdx = lines.findIndex((l) => l.startsWith("entries["))
|
|
125
|
+
|
|
126
|
+
if (headerIdx === -1) {
|
|
127
|
+
return "No hay entradas en memoria"
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const entryLines = lines.slice(headerIdx + 1).filter((l) => l.trim().length > 0)
|
|
131
|
+
const filtered = entryLines.filter((l) => {
|
|
132
|
+
const parts = l.trim().split("|")
|
|
133
|
+
return parts[0] !== args.key && parts[2] !== args.key
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
const removed = entryLines.length - filtered.length
|
|
137
|
+
const match = lines[headerIdx].match(/entries\[(\d+)\|/)
|
|
138
|
+
const count = match ? parseInt(match[1]) : 0
|
|
139
|
+
lines[headerIdx] = lines[headerIdx].replace(/entries\[\d+\|/, `[${count - removed}|`)
|
|
140
|
+
lines.splice(headerIdx + 1, entryLines.length, ...filtered.map((l) => ` ${l.trim()}`))
|
|
141
|
+
|
|
142
|
+
writeMemory(lines.join("\n"))
|
|
143
|
+
return `"${args.key}" eliminado. Quedan ${count - removed} entradas.`
|
|
144
|
+
},
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
export const memoryStats = tool({
|
|
148
|
+
description: "Muestra estadísticas de la memoria: total entradas, por categoría, y resúmenes de archivos.",
|
|
149
|
+
args: {},
|
|
150
|
+
async execute() {
|
|
151
|
+
const data = readMemory()
|
|
152
|
+
const lines = data.split("\n").filter((l) => l.startsWith(" ") && l.includes("|"))
|
|
153
|
+
const entries = lines.map((l) => {
|
|
154
|
+
const parts = l.trim().split("|")
|
|
155
|
+
return { category: parts[1] || "unknown" }
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
const byCategory: Record<string, number> = {}
|
|
159
|
+
for (const e of entries) {
|
|
160
|
+
byCategory[e.category] = (byCategory[e.category] || 0) + 1
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const summaryLines = data.split("\n").filter((l) => l.includes(":") && !l.startsWith(" ") && !l.startsWith("version") && !l.startsWith("entries"))
|
|
164
|
+
const stats = [
|
|
165
|
+
`Entradas totales: ${entries.length}`,
|
|
166
|
+
`Resúmenes de archivos: ${summaryLines.length}`,
|
|
167
|
+
"",
|
|
168
|
+
"Por categoría:",
|
|
169
|
+
...Object.entries(byCategory).map(([k, v]) => ` ${k}: ${v}`),
|
|
170
|
+
"",
|
|
171
|
+
`Últimas 5 entradas:`,
|
|
172
|
+
...lines.slice(-5).map((l) => {
|
|
173
|
+
const parts = l.trim().split("|")
|
|
174
|
+
return ` [${parts[1]}] ${parts[2]} (${parts[0]})`
|
|
175
|
+
}),
|
|
176
|
+
]
|
|
177
|
+
|
|
178
|
+
return stats.join("\n")
|
|
179
|
+
},
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
export const memorySummary = tool({
|
|
183
|
+
description: "Guarda o recupera un resumen de un archivo grande. El agente puede leer el resumen en vez del archivo completo para ahorrar tokens.",
|
|
184
|
+
args: {
|
|
185
|
+
action: tool.schema.enum({
|
|
186
|
+
description: "get para leer, set para guardar",
|
|
187
|
+
options: ["get", "set"],
|
|
188
|
+
}),
|
|
189
|
+
file: tool.schema.string({
|
|
190
|
+
description: "Ruta del archivo",
|
|
191
|
+
}),
|
|
192
|
+
summary: tool.schema.string({
|
|
193
|
+
description: "Resumen del archivo (solo para set)",
|
|
194
|
+
}),
|
|
195
|
+
},
|
|
196
|
+
async execute(args) {
|
|
197
|
+
const data = readMemory()
|
|
198
|
+
|
|
199
|
+
if (args.action === "get") {
|
|
200
|
+
const lines = data.split("\n")
|
|
201
|
+
const summaryIdx = lines.findIndex((l) => l.trim().startsWith("summaries:"))
|
|
202
|
+
if (summaryIdx === -1) {
|
|
203
|
+
return `No hay resúmenes guardados para "${args.file}"`
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const summaryLines = lines.slice(summaryIdx + 1).filter((l) => l.includes(":"))
|
|
207
|
+
const match = summaryLines.find((l) => l.startsWith(` ${args.file}:`))
|
|
208
|
+
if (!match) {
|
|
209
|
+
return `No hay resumen para "${args.file}"`
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const summaryText = match.replace(` ${args.file}: `, "")
|
|
213
|
+
return summaryText
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const lines = data.split("\n")
|
|
217
|
+
let summaryIdx = lines.findIndex((l) => l.trim().startsWith("summaries:"))
|
|
218
|
+
|
|
219
|
+
if (summaryIdx === -1) {
|
|
220
|
+
lines.push("", "summaries:")
|
|
221
|
+
summaryIdx = lines.length - 1
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const summaryLines = lines.slice(summaryIdx + 1).filter((l) => l.includes(":"))
|
|
225
|
+
const existingIdx = summaryLines.findIndex((l) => l.startsWith(` ${args.file}:`))
|
|
226
|
+
|
|
227
|
+
if (existingIdx !== -1) {
|
|
228
|
+
summaryLines[existingIdx] = ` ${args.file}: ${args.summary}`
|
|
229
|
+
} else {
|
|
230
|
+
summaryLines.push(` ${args.file}: ${args.summary}`)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
lines.splice(summaryIdx + 1, lines.length - summaryIdx - 1, ...summaryLines)
|
|
234
|
+
writeMemory(lines.join("\n"))
|
|
235
|
+
return `📝 Resumen guardado para ${args.file}`
|
|
236
|
+
},
|
|
237
|
+
})
|