toon-memory 1.0.1 → 1.0.2

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.
Files changed (2) hide show
  1. package/package.json +7 -3
  2. package/src/mcp/server.ts +307 -0
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "toon-memory",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
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": {
7
- "toon-memory": "./bin/setup.js"
7
+ "toon-memory": "./bin/setup.js",
8
+ "toon-memory-mcp": "./src/mcp/server.ts"
8
9
  },
9
10
  "files": [
10
11
  "src/",
@@ -12,13 +13,16 @@
12
13
  "skills/"
13
14
  ],
14
15
  "dependencies": {
15
- "@toon-format/toon": "^2.3.0"
16
+ "@modelcontextprotocol/server": "^2.0.0-beta.3",
17
+ "@toon-format/toon": "^2.3.0",
18
+ "zod": "^4.4.3"
16
19
  },
17
20
  "keywords": [
18
21
  "opencode",
19
22
  "ai-agent",
20
23
  "memory",
21
24
  "toon",
25
+ "mcp",
22
26
  "llm",
23
27
  "token-efficiency"
24
28
  ],
@@ -0,0 +1,307 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { McpServer } from "@modelcontextprotocol/server"
4
+ import { serveStdio } from "@modelcontextprotocol/server/stdio"
5
+ 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
+ }
25
+
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
+ }
35
+
36
+ function ensureDir() {
37
+ if (!existsSync(MEMORY_DIR)) mkdirSync(MEMORY_DIR, { recursive: true })
38
+ }
39
+
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
+ }
53
+ }
54
+
55
+ function writeMemory(data: MemoryData): void {
56
+ ensureDir()
57
+ const toon = encode(data, { delimiter: "|", indent: 2 })
58
+ writeFileSync(MEMORY_FILE, toon, "utf-8")
59
+ }
60
+
61
+ function genId(): string {
62
+ return randomUUID().slice(0, 8)
63
+ }
64
+
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
+ }),
97
+ },
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
+ }),
144
+ },
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
155
+ })
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
+ }
168
+
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")
175
+
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
+ }
258
+ },
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(""),
275
+ }),
276
+ },
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 }] }
292
+ }
293
+ state.summaries[file] = summary
294
+ writeMemory(state)
295
+ return {
296
+ content: [
297
+ {
298
+ type: "text" as const,
299
+ text: `${file} actualizado.`,
300
+ },
301
+ ],
302
+ }
303
+ },
304
+ )
305
+
306
+ return server
307
+ })