toon-memory 1.7.0 → 1.8.0
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/README.md +270 -34
- package/dist/cli/setup.js +409 -96
- package/mcp/server.js +156 -10
- package/package.json +9 -1
- package/skills/toon-memory.md +25 -14
- package/src/cli/setup.ts +650 -335
- package/src/mcp/server.ts +192 -9
- package/uninstall.sh +33 -5
package/src/mcp/server.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { randomBytes, createCipheriv, createDecipheriv } from "crypto"
|
|
|
9
9
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
10
10
|
|
|
11
11
|
/** Base directory for memory storage */
|
|
12
|
-
const MEMORY_DIR = join(process.cwd(), ".
|
|
12
|
+
const MEMORY_DIR = join(process.cwd(), ".toon-memory", "memory")
|
|
13
13
|
|
|
14
14
|
/** Main memory data file */
|
|
15
15
|
const MEMORY_FILE = join(MEMORY_DIR, "data.toon")
|
|
@@ -91,7 +91,7 @@ function ensureMemoryDir(): void {
|
|
|
91
91
|
function ensureMemoryFile(): void {
|
|
92
92
|
ensureMemoryDir()
|
|
93
93
|
if (!existsSync(MEMORY_FILE)) {
|
|
94
|
-
writeFileSync(MEMORY_FILE, "version: 1\nentries[0|]{id|category|key|content|file|tags|date}:\n")
|
|
94
|
+
writeFileSync(MEMORY_FILE, "version: 1\nentries[0|]{id|category|key|content|file|tags|date|ttl}:\n")
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
|
|
@@ -207,6 +207,97 @@ function generateId(): string {
|
|
|
207
207
|
return randomBytes(4).toString("hex")
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
+
/**
|
|
211
|
+
* Parse a TTL value into an absolute date string (YYYY-MM-DD).
|
|
212
|
+
* Supports: exact dates (2026-07-17), relative days (7d, 30d).
|
|
213
|
+
* Returns empty string if no TTL.
|
|
214
|
+
*/
|
|
215
|
+
function parseTTL(ttl: string): string {
|
|
216
|
+
if (!ttl || !ttl.trim()) return ""
|
|
217
|
+
const trimmed = ttl.trim()
|
|
218
|
+
const dayMatch = trimmed.match(/^(\d+)d$/)
|
|
219
|
+
if (dayMatch) {
|
|
220
|
+
const days = parseInt(dayMatch[1])
|
|
221
|
+
const d = new Date()
|
|
222
|
+
d.setDate(d.getDate() + days)
|
|
223
|
+
return d.toISOString().split("T")[0]
|
|
224
|
+
}
|
|
225
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) return trimmed
|
|
226
|
+
return ""
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Check if a TTL value has expired (date is in the past).
|
|
231
|
+
*/
|
|
232
|
+
function isExpired(ttl: string): boolean {
|
|
233
|
+
if (!ttl) return false
|
|
234
|
+
const ttlDate = parseTTL(ttl) || ttl
|
|
235
|
+
const today = new Date().toISOString().split("T")[0]
|
|
236
|
+
return ttlDate <= today
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Built-in vocabulary for automatic tag inference */
|
|
240
|
+
const TAG_VOCABULARY: Record<string, string[]> = {
|
|
241
|
+
"redis": ["redis", "cache", "caching", "memcached"],
|
|
242
|
+
"auth": ["auth", "authentication", "authorization", "login", "token", "jwt", "session", "oauth"],
|
|
243
|
+
"api": ["api", "endpoint", "rest", "graphql", "route", "router", "controller"],
|
|
244
|
+
"db": ["database", "db", "sql", "postgres", "mysql", "mongo", "query", "migration", "schema"],
|
|
245
|
+
"security": ["security", "encrypt", "decrypt", "vulnerability", "xss", "csrf", "cors", "sanitiz"],
|
|
246
|
+
"test": ["test", "testing", "vitest", "jest", "spec", "mock", "assert", "coverage"],
|
|
247
|
+
"deploy": ["deploy", "docker", "ci/cd", "github actions", "pipeline", "kubernetes", "k8s"],
|
|
248
|
+
"config": ["config", "configuration", "settings", "env", "environment", "dotenv"],
|
|
249
|
+
"performance": ["performance", "optimize", "benchmark", "latency", "throughput", "cache"],
|
|
250
|
+
"refactor": ["refactor", "cleanup", "restructure", "reorganize", "rework"],
|
|
251
|
+
"error": ["error", "exception", "throw", "catch", "handling", "retry", "fallback"],
|
|
252
|
+
"logging": ["log", "logging", "logger", "debug", "trace", "monitor", "observability"],
|
|
253
|
+
"types": ["typescript", "types", "type", "interface", "generic", "enum", "zod", "schema"],
|
|
254
|
+
"async": ["async", "await", "promise", "concurrent", "parallel", "worker", "queue"],
|
|
255
|
+
"state": ["state", "store", "redux", "context", "reducer", "action", "observable"],
|
|
256
|
+
"ui": ["ui", "component", "render", "dom", "css", "style", "layout", "responsive"],
|
|
257
|
+
"storage": ["storage", "file", "filesystem", "s3", "blob", "upload", "download"],
|
|
258
|
+
"email": ["email", "mail", "smtp", "sendgrid", "newsletter", "notification"],
|
|
259
|
+
"payment": ["payment", "stripe", "billing", "invoice", "checkout", "subscription"],
|
|
260
|
+
"webhook": ["webhook", "callback", "event", "listener", "hook"],
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Infer tags from content and key by matching against vocabulary.
|
|
265
|
+
* Returns semicolon-separated tags.
|
|
266
|
+
*/
|
|
267
|
+
function inferTags(content: string, key: string): string {
|
|
268
|
+
const text = `${key} ${content}`.toLowerCase()
|
|
269
|
+
const matched: string[] = []
|
|
270
|
+
for (const [tag, keywords] of Object.entries(TAG_VOCABULARY)) {
|
|
271
|
+
if (keywords.some((kw) => text.includes(kw))) {
|
|
272
|
+
matched.push(tag)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return matched.join(";")
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Parse a relative date string into an absolute YYYY-MM-DD date.
|
|
280
|
+
* Supports: "24h", "7d", "30d", or an exact date "2026-07-10".
|
|
281
|
+
*/
|
|
282
|
+
function parseRelativeDate(since: string): string {
|
|
283
|
+
const trimmed = since.trim()
|
|
284
|
+
const hourMatch = trimmed.match(/^(\d+)h$/)
|
|
285
|
+
if (hourMatch) {
|
|
286
|
+
const d = new Date()
|
|
287
|
+
d.setHours(d.getHours() - parseInt(hourMatch[1]))
|
|
288
|
+
return d.toISOString().split("T")[0]
|
|
289
|
+
}
|
|
290
|
+
const dayMatch = trimmed.match(/^(\d+)d$/)
|
|
291
|
+
if (dayMatch) {
|
|
292
|
+
const d = new Date()
|
|
293
|
+
d.setDate(d.getDate() - parseInt(dayMatch[1]))
|
|
294
|
+
return d.toISOString().split("T")[0]
|
|
295
|
+
}
|
|
296
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) return trimmed
|
|
297
|
+
const today = new Date().toISOString().split("T")[0]
|
|
298
|
+
return today
|
|
299
|
+
}
|
|
300
|
+
|
|
210
301
|
/**
|
|
211
302
|
* Archive entries older than ARCHIVE_DAYS to archive.toon.
|
|
212
303
|
*
|
|
@@ -241,7 +332,10 @@ function archiveOldEntries(): { archived: number; kept: number } {
|
|
|
241
332
|
const parts = line.trim().split("|")
|
|
242
333
|
if (parts.length >= 7) {
|
|
243
334
|
const date = parts[6]
|
|
244
|
-
|
|
335
|
+
const ttl = parts[7] || ""
|
|
336
|
+
const isOld = date < cutoffStr
|
|
337
|
+
const isTtlExpired = ttl && isExpired(ttl)
|
|
338
|
+
if (isOld || isTtlExpired) {
|
|
245
339
|
toArchive.push(line.trim())
|
|
246
340
|
} else {
|
|
247
341
|
toKeep.push(line.trim())
|
|
@@ -316,9 +410,10 @@ server.registerTool(
|
|
|
316
410
|
content: z.string().describe("Contenido detallado del hecho"),
|
|
317
411
|
file: z.string().optional().default("").describe("Archivo o línea relacionada (ej: spec.md:145)"),
|
|
318
412
|
tags: z.string().optional().default("").describe("Tags separados por punto y coma (ej: risk;spec)"),
|
|
413
|
+
ttl: z.string().optional().default("").describe("Time to live (ej: 7d, 2026-07-17). Vacío = sin expiración"),
|
|
319
414
|
},
|
|
320
415
|
},
|
|
321
|
-
async ({ category, key, content, file, tags }) => {
|
|
416
|
+
async ({ category, key, content, file, tags, ttl }) => {
|
|
322
417
|
const data = readMemory()
|
|
323
418
|
const newId = generateId()
|
|
324
419
|
const date = new Date().toISOString().split("T")[0]
|
|
@@ -346,8 +441,11 @@ server.registerTool(
|
|
|
346
441
|
}
|
|
347
442
|
|
|
348
443
|
const entryId = existingIdx !== -1 ? existingId : newId
|
|
349
|
-
const
|
|
444
|
+
const resolvedTtl = parseTTL(ttl)
|
|
445
|
+
const resolvedTags = tags ? tags : inferTags(content, key)
|
|
446
|
+
const newEntry = `${entryId}|${category}|${key}|${content}|${file || ""}|${resolvedTags}|${date}|${resolvedTtl}`
|
|
350
447
|
let action = "Guardado"
|
|
448
|
+
const tagsInferred = !tags && resolvedTags ? true : false
|
|
351
449
|
|
|
352
450
|
if (existingIdx !== -1) {
|
|
353
451
|
// Update existing entry
|
|
@@ -362,8 +460,22 @@ server.registerTool(
|
|
|
362
460
|
}
|
|
363
461
|
|
|
364
462
|
writeMemory(lines.join("\n"))
|
|
463
|
+
|
|
464
|
+
// Auto-archive if we exceed MAX_ENTRIES
|
|
465
|
+
const headerMatch = lines[headerIdx].match(/\[(\d+)\|/)
|
|
466
|
+
const entryCount = headerMatch ? parseInt(headerMatch[1]) : 0
|
|
467
|
+
let archiveMsg = ""
|
|
468
|
+
if (entryCount > MAX_ENTRIES) {
|
|
469
|
+
const result = archiveOldEntries()
|
|
470
|
+
if (result.archived > 0) {
|
|
471
|
+
archiveMsg = `\n📦 Auto-archived ${result.archived} old entries (${result.kept} kept)`
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const ttlMsg = resolvedTtl ? `\n⏰ TTL: ${resolvedTtl}` : ""
|
|
476
|
+
const inferredMsg = tagsInferred ? `\n🏷️ Tags inferidos: ${resolvedTags}` : ""
|
|
365
477
|
return {
|
|
366
|
-
content: [{ type: "text" as const, text: `🧠 ${action}: ${category}/${key} (${entryId})\n${content}` }],
|
|
478
|
+
content: [{ type: "text" as const, text: `🧠 ${action}: ${category}/${key} (${entryId})\n${content}${ttlMsg}${inferredMsg}${archiveMsg}` }],
|
|
367
479
|
}
|
|
368
480
|
}
|
|
369
481
|
)
|
|
@@ -404,10 +516,11 @@ server.registerTool(
|
|
|
404
516
|
const trimmed = line.trim()
|
|
405
517
|
const parts = trimmed.split("|")
|
|
406
518
|
if (parts.length < 7) return null
|
|
407
|
-
const [id, cat, key, content, file, tags, date] = parts
|
|
519
|
+
const [id, cat, key, content, file, tags, date, ttl] = parts
|
|
408
520
|
if (category && cat !== category) return null
|
|
409
521
|
if (from_date && date < from_date) return null
|
|
410
522
|
if (to_date && date > to_date) return null
|
|
523
|
+
if (ttl && isExpired(ttl)) return null
|
|
411
524
|
const searchStr = normalize(`${id} ${cat} ${key} ${content} ${file} ${tags}`)
|
|
412
525
|
// All query tokens must match (AND logic)
|
|
413
526
|
if (!queryTokens.every((token) => searchStr.includes(token))) return null
|
|
@@ -494,7 +607,7 @@ server.registerTool(
|
|
|
494
607
|
const lines = data.split("\n").filter((l) => l.startsWith(" ") && l.includes("|") && !l.startsWith(" summaries:"))
|
|
495
608
|
const entries = lines.map((l) => {
|
|
496
609
|
const parts = l.trim().split("|")
|
|
497
|
-
return { category: parts[1] || "unknown" }
|
|
610
|
+
return { category: parts[1] || "unknown", ttl: parts[7] || "" }
|
|
498
611
|
})
|
|
499
612
|
|
|
500
613
|
const byCategory: Record<string, number> = {}
|
|
@@ -502,6 +615,9 @@ server.registerTool(
|
|
|
502
615
|
byCategory[e.category] = (byCategory[e.category] || 0) + 1
|
|
503
616
|
}
|
|
504
617
|
|
|
618
|
+
const withTtl = entries.filter((e) => e.ttl).length
|
|
619
|
+
const expired = entries.filter((e) => e.ttl && isExpired(e.ttl)).length
|
|
620
|
+
|
|
505
621
|
const summaryLines = data.split("\n").filter((l) => l.includes(":") && !l.startsWith(" ") && !l.startsWith("version") && !l.startsWith("entries") && !/^\[\d+\|]/.test(l))
|
|
506
622
|
const stats = [
|
|
507
623
|
`Entradas totales: ${entries.length}`,
|
|
@@ -510,10 +626,13 @@ server.registerTool(
|
|
|
510
626
|
"Por categoría:",
|
|
511
627
|
...Object.entries(byCategory).map(([k, v]) => ` ${k}: ${v}`),
|
|
512
628
|
"",
|
|
629
|
+
`TTL: ${withTtl} con expiración, ${expired} expiradas`,
|
|
630
|
+
"",
|
|
513
631
|
`Últimas 5 entradas:`,
|
|
514
632
|
...lines.slice(-5).map((l) => {
|
|
515
633
|
const parts = l.trim().split("|")
|
|
516
|
-
|
|
634
|
+
const ttlInfo = parts[7] ? ` | TTL: ${parts[7]}` : ""
|
|
635
|
+
return ` [${parts[1]}] ${parts[2]} (${parts[0]})${ttlInfo}`
|
|
517
636
|
}),
|
|
518
637
|
]
|
|
519
638
|
|
|
@@ -521,6 +640,70 @@ server.registerTool(
|
|
|
521
640
|
}
|
|
522
641
|
)
|
|
523
642
|
|
|
643
|
+
/**
|
|
644
|
+
* Register the memory_diff tool.
|
|
645
|
+
* Show what changed in memory since a given date.
|
|
646
|
+
*/
|
|
647
|
+
server.registerTool(
|
|
648
|
+
"memory_diff",
|
|
649
|
+
{
|
|
650
|
+
title: "Memory Diff",
|
|
651
|
+
description: "Muestra qué cambió en la memoria desde una fecha. Útil para saber qué se aprendió desde la última sesión.",
|
|
652
|
+
inputSchema: {
|
|
653
|
+
since: z.string().describe("Desde cuándo mostrar cambios (ej: 24h, 7d, 2026-07-10)"),
|
|
654
|
+
type: z.enum(["all", "created", "updated"]).optional().default("all").describe("Filtrar por tipo de cambio"),
|
|
655
|
+
},
|
|
656
|
+
},
|
|
657
|
+
async ({ since, type }) => {
|
|
658
|
+
const sinceDate = parseRelativeDate(since)
|
|
659
|
+
const today = new Date().toISOString().split("T")[0]
|
|
660
|
+
const data = readMemory()
|
|
661
|
+
const lines = data.split("\n").filter((l) => l.startsWith(" ") && l.includes("|") && !l.startsWith(" summaries:"))
|
|
662
|
+
|
|
663
|
+
const results = lines
|
|
664
|
+
.map((line) => {
|
|
665
|
+
const trimmed = line.trim()
|
|
666
|
+
const parts = trimmed.split("|")
|
|
667
|
+
if (parts.length < 7) return null
|
|
668
|
+
const [id, cat, key, content, file, tags, date] = parts
|
|
669
|
+
if (date < sinceDate) return null
|
|
670
|
+
// For "updated" we check if date is recent but key existed before
|
|
671
|
+
// For simplicity: same date as today = created today, otherwise "updated" if date >= sinceDate
|
|
672
|
+
const changeType = date === today ? "created" : "updated"
|
|
673
|
+
if (type !== "all" && changeType !== type) return null
|
|
674
|
+
return { id, cat, key, content, file, tags, date, changeType }
|
|
675
|
+
})
|
|
676
|
+
.filter(Boolean)
|
|
677
|
+
|
|
678
|
+
if (results.length === 0) {
|
|
679
|
+
return { content: [{ type: "text" as const, text: `No hay cambios desde ${sinceDate}` }] }
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const created = results.filter((r) => r!.changeType === "created")
|
|
683
|
+
const updated = results.filter((r) => r!.changeType === "updated")
|
|
684
|
+
|
|
685
|
+
const sections: string[] = [`📋 Cambios desde ${sinceDate}:`, ""]
|
|
686
|
+
|
|
687
|
+
if (created.length > 0 && (type === "all" || type === "created")) {
|
|
688
|
+
sections.push(`➕ Nuevas (${created.length}):`)
|
|
689
|
+
for (const r of created) {
|
|
690
|
+
sections.push(` [${r!.cat}] ${r!.key} (${r!.id})\n ${r!.content}`)
|
|
691
|
+
}
|
|
692
|
+
sections.push("")
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
if (updated.length > 0 && (type === "all" || type === "updated")) {
|
|
696
|
+
sections.push(`✏️ Actualizadas (${updated.length}):`)
|
|
697
|
+
for (const r of updated) {
|
|
698
|
+
sections.push(` [${r!.cat}] ${r!.key} (${r!.id}) — ${r!.date}`)
|
|
699
|
+
}
|
|
700
|
+
sections.push("")
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
return { content: [{ type: "text" as const, text: sections.join("\n") }] }
|
|
704
|
+
}
|
|
705
|
+
)
|
|
706
|
+
|
|
524
707
|
/**
|
|
525
708
|
* Register the memory_summary tool.
|
|
526
709
|
* Get or set file summaries to save tokens when reading large files.
|
package/uninstall.sh
CHANGED
|
@@ -24,7 +24,7 @@ remove_from_config ".opencode/opencode.json"
|
|
|
24
24
|
# VS Code
|
|
25
25
|
remove_from_config ".vscode/mcp.json"
|
|
26
26
|
|
|
27
|
-
# Claude
|
|
27
|
+
# Claude Code
|
|
28
28
|
remove_from_config "${HOME}/.claude/settings.json"
|
|
29
29
|
remove_from_config ".claude/settings.json"
|
|
30
30
|
|
|
@@ -40,7 +40,28 @@ remove_from_config ".cline/mcp.json"
|
|
|
40
40
|
# Continue
|
|
41
41
|
remove_from_config ".continue/config.json"
|
|
42
42
|
|
|
43
|
-
#
|
|
43
|
+
# Codex CLI
|
|
44
|
+
remove_from_config ".codex/config.toml"
|
|
45
|
+
|
|
46
|
+
# Gemini CLI
|
|
47
|
+
remove_from_config ".gemini/settings.json"
|
|
48
|
+
|
|
49
|
+
# Zed
|
|
50
|
+
remove_from_config "${HOME}/.config/zed/settings.json"
|
|
51
|
+
|
|
52
|
+
# Antigravity
|
|
53
|
+
remove_from_config ".gemini/config/mcp_config.json"
|
|
54
|
+
|
|
55
|
+
# KiloCode
|
|
56
|
+
remove_from_config "${HOME}/.kilocode/mcp_settings.json"
|
|
57
|
+
|
|
58
|
+
# OpenClaw
|
|
59
|
+
remove_from_config ".openclaw.json"
|
|
60
|
+
|
|
61
|
+
# Kiro
|
|
62
|
+
remove_from_config ".kiro/settings/mcp.json"
|
|
63
|
+
|
|
64
|
+
# Remove custom tools (legacy)
|
|
44
65
|
if [ -d ".opencode/tools" ]; then
|
|
45
66
|
if [ -f ".opencode/tools/memory.ts" ]; then
|
|
46
67
|
echo "Removing custom tools..."
|
|
@@ -49,13 +70,20 @@ if [ -d ".opencode/tools" ]; then
|
|
|
49
70
|
fi
|
|
50
71
|
fi
|
|
51
72
|
|
|
73
|
+
# Remove hook scripts
|
|
74
|
+
if [ -d ".toon-memory/hooks" ]; then
|
|
75
|
+
echo "Removing hooks..."
|
|
76
|
+
rm -rf ".toon-memory/hooks"
|
|
77
|
+
echo " ✅ Removed .toon-memory/hooks/"
|
|
78
|
+
fi
|
|
79
|
+
|
|
52
80
|
# Remove memory file (ask user)
|
|
53
|
-
if [ -f ".
|
|
81
|
+
if [ -f ".toon-memory/memory/data.toon" ]; then
|
|
54
82
|
echo ""
|
|
55
|
-
read -p "Remove memory file (.
|
|
83
|
+
read -p "Remove memory file (.toon-memory/memory/)? [y/N] " -n 1 -r
|
|
56
84
|
echo
|
|
57
85
|
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
|
58
|
-
rm -rf ".
|
|
86
|
+
rm -rf ".toon-memory/memory"
|
|
59
87
|
echo " ✅ Removed memory directory"
|
|
60
88
|
fi
|
|
61
89
|
fi
|