toon-memory 1.0.4 → 1.0.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toon-memory",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "Persistent memory system for OpenCode AI agent using TOON format (40% fewer tokens than JSON)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,6 +14,40 @@ A persistent memory system for the OpenCode AI agent. It remembers decisions, pa
14
14
  | `memory_stats` | View memory state |
15
15
  | `memory_summary` | Save/retrieve file summaries |
16
16
 
17
+ ## Installation
18
+
19
+ ### Option 1: Global MCP Server (recommended)
20
+
21
+ Add to `~/.config/opencode/opencode.json`:
22
+
23
+ ```json
24
+ {
25
+ "mcp": {
26
+ "toon-memory": {
27
+ "type": "local",
28
+ "command": ["npx", "-y", "toon-memory", "mcp"],
29
+ "enabled": true
30
+ }
31
+ }
32
+ }
33
+ ```
34
+
35
+ ### Option 2: Project-level MCP Server
36
+
37
+ Add to `.opencode/opencode.json`:
38
+
39
+ ```json
40
+ {
41
+ "mcp": {
42
+ "toon-memory": {
43
+ "type": "local",
44
+ "command": ["npx", "-y", "toon-memory", "mcp"],
45
+ "enabled": true
46
+ }
47
+ }
48
+ }
49
+ ```
50
+
17
51
  ## How to use
18
52
 
19
53
  ### At the START of every session
package/src/mcp/server.ts CHANGED
@@ -1,307 +1,251 @@
1
- #!/usr/bin/env node
2
-
3
1
  import { McpServer } from "@modelcontextprotocol/server"
4
- import { serveStdio } from "@modelcontextprotocol/server/stdio"
2
+ import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"
3
+ import { z } from "zod"
5
4
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs"
6
- import * as path from "path"
7
- import { encode, decode } from "@toon-format/toon"
8
- import { randomUUID } from "crypto"
9
- import * as z from "zod/v4"
10
-
11
- // ── Memory storage ──────────────────────────────
12
-
13
- const MEMORY_DIR = path.join(process.cwd(), ".opencode", "memory")
14
- const MEMORY_FILE = path.join(MEMORY_DIR, "data.toon")
15
-
16
- interface MemoryEntry {
17
- id: string
18
- category: "decision" | "pattern" | "bug" | "knowledge"
19
- key: string
20
- content: string
21
- file: string
22
- tags: string
23
- date: string
24
- }
5
+ import { dirname, join } from "path"
6
+ import { fileURLToPath } from "url"
7
+ import { randomBytes } from "crypto"
25
8
 
26
- interface MemoryData {
27
- version: number
28
- entries: MemoryEntry[]
29
- summaries: Record<string, string>
30
- }
31
-
32
- function defaultData(): MemoryData {
33
- return { version: 1, entries: [], summaries: {} }
34
- }
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")
35
12
 
36
- function ensureDir() {
13
+ function ensureMemoryFile() {
37
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
+ }
38
18
  }
39
19
 
40
- function readMemory(): MemoryData {
41
- ensureDir()
42
- if (!existsSync(MEMORY_FILE)) return defaultData()
43
- try {
44
- const raw = readFileSync(MEMORY_FILE, "utf-8")
45
- const parsed = decode(raw) as MemoryData
46
- if (!parsed || typeof parsed !== "object") return defaultData()
47
- if (!Array.isArray(parsed.entries)) parsed.entries = []
48
- if (!parsed.summaries || typeof parsed.summaries !== "object") parsed.summaries = {}
49
- return parsed
50
- } catch {
51
- return defaultData()
52
- }
20
+ function readMemory() {
21
+ ensureMemoryFile()
22
+ return readFileSync(MEMORY_FILE, "utf-8")
53
23
  }
54
24
 
55
- function writeMemory(data: MemoryData): void {
56
- ensureDir()
57
- const toon = encode(data, { delimiter: "|", indent: 2 })
58
- writeFileSync(MEMORY_FILE, toon, "utf-8")
25
+ function writeMemory(content: string) {
26
+ ensureMemoryFile()
27
+ writeFileSync(MEMORY_FILE, content)
59
28
  }
60
29
 
61
- function genId(): string {
62
- return randomUUID().slice(0, 8)
30
+ function generateId(): string {
31
+ return randomBytes(4).toString("hex")
63
32
  }
64
33
 
65
- // ── MCP Server ──────────────────────────────────
66
-
67
- serveStdio(() => {
68
- const server = new McpServer({
69
- name: "toon-memory",
70
- version: "1.0.1",
71
- })
72
-
73
- // Tool: memory_remember
74
- server.registerTool(
75
- "memory_remember",
76
- {
77
- title: "Save to Memory",
78
- description:
79
- "Guarda un hecho en la memoria persistente del proyecto (decisiones, patrones, bugs, conocimiento). Se recuerda entre sesiones.",
80
- inputSchema: z.object({
81
- category: z
82
- .enum(["decision", "pattern", "bug", "knowledge"])
83
- .describe("Categoría del hecho"),
84
- key: z
85
- .string()
86
- .describe("Título corto en kebab-case (ej: risk-engine-prioridad)"),
87
- content: z.string().describe("Contenido detallado del hecho"),
88
- file: z
89
- .string()
90
- .describe("Archivo o línea relacionada (ej: spec.md:145)")
91
- .default(""),
92
- tags: z
93
- .string()
94
- .describe("Tags separados por punto y coma (ej: risk;spec)")
95
- .default(""),
96
- }),
34
+ const server = new McpServer(
35
+ { name: "toon-memory", version: "1.0.5" },
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)"),
97
50
  },
98
- async ({ category, key, content, file, tags }) => {
99
- const state = readMemory()
100
- const existing = state.entries.find(
101
- (e) => e.category === category && e.key === key,
102
- )
103
- const entry: MemoryEntry = {
104
- id: existing?.id || genId(),
105
- category,
106
- key,
107
- content,
108
- file,
109
- tags,
110
- date: new Date().toISOString().slice(0, 10),
111
- }
112
- if (existing) {
113
- const idx = state.entries.indexOf(existing)
114
- state.entries[idx] = entry
115
- } else {
116
- state.entries.push(entry)
117
- }
118
- writeMemory(state)
119
- return {
120
- content: [
121
- {
122
- type: "text" as const,
123
- text: `${existing ? "🧠 Actualizado" : "🧠 Guardado"}: ${category}/${key} (${entry.id})\n${content}`,
124
- },
125
- ],
126
- }
127
- },
128
- )
129
-
130
- // Tool: memory_recall
131
- server.registerTool(
132
- "memory_recall",
133
- {
134
- title: "Search Memory",
135
- description:
136
- "Busca en la memoria persistente del proyecto. Devuelve entradas relevantes. Usar ANTES de leer archivos.",
137
- inputSchema: z.object({
138
- query: z.string().describe("Texto a buscar"),
139
- category: z
140
- .string()
141
- .describe("Filtrar por categoría (vacío = todos)")
142
- .default(""),
143
- }),
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)"),
144
86
  },
145
- async ({ query, category }) => {
146
- const state = readMemory()
147
- const q = query.toLowerCase()
148
- let results = state.entries.filter((e) => {
149
- const matchText =
150
- e.key.toLowerCase().includes(q) ||
151
- e.content.toLowerCase().includes(q) ||
152
- e.tags.toLowerCase().includes(q)
153
- const matchCat = category ? e.category === category : true
154
- return matchText && matchCat
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 }
155
103
  })
156
- results = results.slice(0, 10)
157
-
158
- if (results.length === 0) {
159
- return {
160
- content: [
161
- {
162
- type: "text" as const,
163
- text: `No encontré nada para "${query}"${category ? ` en "${category}"` : ""}.`,
164
- },
165
- ],
166
- }
167
- }
104
+ .filter(Boolean)
168
105
 
169
- const output = results
170
- .map(
171
- (e) =>
172
- `[${e.category}] ${e.key} (${e.id})\n ${e.content}\n File: ${e.file || "N/A"} | Tags: ${e.tags || "N/A"} | Date: ${e.date}`,
173
- )
174
- .join("\n\n")
106
+ if (results.length === 0) {
107
+ return { content: [{ type: "text" as const, text: `No se encontraron resultados para "${query}"` }] }
108
+ }
175
109
 
176
- return { content: [{ type: "text" as const, text: output }] }
177
- },
178
- )
179
-
180
- // Tool: memory_forget
181
- server.registerTool(
182
- "memory_forget",
183
- {
184
- title: "Delete from Memory",
185
- description: "Elimina una entrada de la memoria por su key o id.",
186
- inputSchema: z.object({
187
- key: z.string().describe("Key o id de la entrada a eliminar"),
188
- }),
189
- },
190
- async ({ key }) => {
191
- const state = readMemory()
192
- const before = state.entries.length
193
- state.entries = state.entries.filter(
194
- (e) => e.key !== key && e.id !== key,
195
- )
196
- if (state.entries.length === before) {
197
- return {
198
- content: [
199
- {
200
- type: "text" as const,
201
- text: `No encontré "${key}" en la memoria.`,
202
- },
203
- ],
204
- }
205
- }
206
- writeMemory(state)
207
- return {
208
- content: [
209
- {
210
- type: "text" as const,
211
- text: `"${key}" eliminado. Quedan ${state.entries.length} entradas.`,
212
- },
213
- ],
214
- }
215
- },
216
- )
217
-
218
- // Tool: memory_stats
219
- server.registerTool(
220
- "memory_stats",
221
- {
222
- title: "Memory Stats",
223
- description: "Muestra estadísticas de la memoria del proyecto.",
224
- inputSchema: z.object({}),
225
- },
226
- async () => {
227
- const state = readMemory()
228
- const byCategory: Record<string, number> = {}
229
- for (const e of state.entries) {
230
- byCategory[e.category] = (byCategory[e.category] || 0) + 1
231
- }
232
- const catStr = Object.entries(byCategory)
233
- .map(([k, v]) => ` ${k}: ${v}`)
234
- .join("\n")
235
- const recent = state.entries
236
- .slice(-5)
237
- .reverse()
238
- .map((e) => ` [${e.category}] ${e.key} (${e.date})`)
239
- .join("\n")
240
-
241
- return {
242
- content: [
243
- {
244
- type: "text" as const,
245
- text: [
246
- `Entradas totales: ${state.entries.length}`,
247
- `Resúmenes de archivos: ${Object.keys(state.summaries).length}`,
248
- "",
249
- "Por categoría:",
250
- catStr || " (vacío)",
251
- "",
252
- "Últimas 5:",
253
- recent || " (ninguna)",
254
- ].join("\n"),
255
- },
256
- ],
257
- }
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"),
258
125
  },
259
- )
260
-
261
- // Tool: memory_summary
262
- server.registerTool(
263
- "memory_summary",
264
- {
265
- title: "File Summary",
266
- description:
267
- "Guarda o recupera un resumen de un archivo grande para ahorrar tokens.",
268
- inputSchema: z.object({
269
- action: z.enum(["get", "set"]).describe("get para leer, set para guardar"),
270
- file: z.string().describe("Ruta del archivo"),
271
- summary: z
272
- .string()
273
- .describe("Resumen del archivo (solo para set)")
274
- .default(""),
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]})`
275
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)"),
276
203
  },
277
- async ({ action, file, summary }) => {
278
- const state = readMemory()
279
- if (action === "get") {
280
- const s = state.summaries[file]
281
- if (!s) {
282
- return {
283
- content: [
284
- {
285
- type: "text" as const,
286
- text: `No hay resumen para "${file}".`,
287
- },
288
- ],
289
- }
290
- }
291
- return { content: [{ type: "text" as const, text: s }] }
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}"` }] }
292
213
  }
293
- state.summaries[file] = summary
294
- writeMemory(state)
295
- return {
296
- content: [
297
- {
298
- type: "text" as const,
299
- text: `${file} actualizado.`,
300
- },
301
- ],
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}"` }] }
302
219
  }
303
- },
304
- )
305
220
 
306
- return server
307
- })
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)