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.
- package/package.json +7 -3
- 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.
|
|
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
|
-
"@
|
|
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
|
+
})
|