toon-memory 1.7.0 → 2.0.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/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(), ".opencode", "memory")
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,130 @@ 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
+
301
+ const normalize = (s: string) => s.toLowerCase().replace(/[-_]/g, " ").replace(/\s+/g, " ").trim()
302
+
303
+ /**
304
+ * Find entries related to the given text by fuzzy matching.
305
+ * Returns top N results ranked by match quality.
306
+ */
307
+ function findRelatedEntries(text: string, excludeKey: string = "", limit: number = 3): Array<{ id: string; cat: string; key: string; content: string; file: string; tags: string; date: string; score: number }> {
308
+ const data = readMemory()
309
+ const lines = data.split("\n").filter((l) => l.startsWith(" ") && l.includes("|") && !l.startsWith(" summaries:"))
310
+ const queryTokens = normalize(text).split(" ").filter(Boolean)
311
+
312
+ const scored = lines
313
+ .map((line) => {
314
+ const trimmed = line.trim()
315
+ const parts = trimmed.split("|")
316
+ if (parts.length < 7) return null
317
+ const [id, cat, key, content, file, tags, date] = parts
318
+ if (key === excludeKey) return null
319
+ const searchStr = normalize(`${id} ${cat} ${key} ${content} ${file} ${tags}`)
320
+ let score = 0
321
+ for (const token of queryTokens) {
322
+ if (searchStr.includes(token)) score++
323
+ }
324
+ if (score === 0) return null
325
+ return { id, cat, key, content, file, tags, date, score }
326
+ })
327
+ .filter(Boolean)
328
+ .sort((a, b) => b!.score - a!.score)
329
+ .slice(0, limit)
330
+
331
+ return scored as Array<{ id: string; cat: string; key: string; content: string; file: string; tags: string; date: string; score: number }>
332
+ }
333
+
210
334
  /**
211
335
  * Archive entries older than ARCHIVE_DAYS to archive.toon.
212
336
  *
@@ -241,7 +365,10 @@ function archiveOldEntries(): { archived: number; kept: number } {
241
365
  const parts = line.trim().split("|")
242
366
  if (parts.length >= 7) {
243
367
  const date = parts[6]
244
- if (date < cutoffStr) {
368
+ const ttl = parts[7] || ""
369
+ const isOld = date < cutoffStr
370
+ const isTtlExpired = ttl && isExpired(ttl)
371
+ if (isOld || isTtlExpired) {
245
372
  toArchive.push(line.trim())
246
373
  } else {
247
374
  toKeep.push(line.trim())
@@ -287,8 +414,71 @@ function archiveOldEntries(): { archived: number; kept: number } {
287
414
  }
288
415
 
289
416
  const server = new McpServer(
290
- { name: "toon-memory", version: "1.1.0" },
291
- { capabilities: { tools: { listChanged: true } } }
417
+ { name: "toon-memory", version: "2.0.0" },
418
+ { capabilities: { tools: { listChanged: true }, resources: { listChanged: true } } }
419
+ )
420
+
421
+ /**
422
+ * Register memory entries as an MCP resource.
423
+ * Agents can read this as context in the system prompt.
424
+ */
425
+ server.registerResource(
426
+ "memory-entries",
427
+ "toon://memory/entries",
428
+ { title: "Memory Entries", mimeType: "text/plain" },
429
+ async (uri) => {
430
+ const data = readMemory()
431
+ return { contents: [{ uri: uri.href, text: data }] }
432
+ }
433
+ )
434
+
435
+ /**
436
+ * Register memory stats as an MCP resource.
437
+ */
438
+ server.registerResource(
439
+ "memory-stats",
440
+ "toon://memory/stats",
441
+ { title: "Memory Stats", mimeType: "text/plain" },
442
+ async (uri) => {
443
+ const data = readMemory()
444
+ const lines = data.split("\n").filter((l) => l.startsWith(" ") && l.includes("|") && !l.startsWith(" summaries:"))
445
+ const entries = lines.map((l) => {
446
+ const parts = l.trim().split("|")
447
+ return { category: parts[1] || "unknown", ttl: parts[7] || "" }
448
+ })
449
+ const byCategory: Record<string, number> = {}
450
+ for (const e of entries) {
451
+ byCategory[e.category] = (byCategory[e.category] || 0) + 1
452
+ }
453
+ const withTtl = entries.filter((e) => e.ttl).length
454
+ const expired = entries.filter((e) => e.ttl && isExpired(e.ttl)).length
455
+ const text = [
456
+ `Entradas totales: ${entries.length}`,
457
+ `TTL: ${withTtl} con expiración, ${expired} expiradas`,
458
+ "Por categoría:",
459
+ ...Object.entries(byCategory).map(([k, v]) => ` ${k}: ${v}`),
460
+ ].join("\n")
461
+ return { contents: [{ uri: uri.href, text }] }
462
+ }
463
+ )
464
+
465
+ /**
466
+ * Register memory summaries as an MCP resource.
467
+ */
468
+ server.registerResource(
469
+ "memory-summaries",
470
+ "toon://memory/summaries",
471
+ { title: "Memory Summaries", mimeType: "text/plain" },
472
+ async (uri) => {
473
+ const data = readMemory()
474
+ const lines = data.split("\n")
475
+ const summaryIdx = lines.findIndex((l) => l.trim().startsWith("summaries:"))
476
+ if (summaryIdx === -1) {
477
+ return { contents: [{ uri: uri.href, text: "No hay resúmenes guardados" }] }
478
+ }
479
+ const summaryText = lines.slice(summaryIdx + 1).filter((l) => l.includes(":")).join("\n")
480
+ return { contents: [{ uri: uri.href, text: summaryText || "No hay resúmenes guardados" }] }
481
+ }
292
482
  )
293
483
 
294
484
  /**
@@ -316,9 +506,10 @@ server.registerTool(
316
506
  content: z.string().describe("Contenido detallado del hecho"),
317
507
  file: z.string().optional().default("").describe("Archivo o línea relacionada (ej: spec.md:145)"),
318
508
  tags: z.string().optional().default("").describe("Tags separados por punto y coma (ej: risk;spec)"),
509
+ ttl: z.string().optional().default("").describe("Time to live (ej: 7d, 2026-07-17). Vacío = sin expiración"),
319
510
  },
320
511
  },
321
- async ({ category, key, content, file, tags }) => {
512
+ async ({ category, key, content, file, tags, ttl }) => {
322
513
  const data = readMemory()
323
514
  const newId = generateId()
324
515
  const date = new Date().toISOString().split("T")[0]
@@ -346,8 +537,11 @@ server.registerTool(
346
537
  }
347
538
 
348
539
  const entryId = existingIdx !== -1 ? existingId : newId
349
- const newEntry = `${entryId}|${category}|${key}|${content}|${file || ""}|${tags || ""}|${date}`
540
+ const resolvedTtl = parseTTL(ttl)
541
+ const resolvedTags = tags ? tags : inferTags(content, key)
542
+ const newEntry = `${entryId}|${category}|${key}|${content}|${file || ""}|${resolvedTags}|${date}|${resolvedTtl}`
350
543
  let action = "Guardado"
544
+ const tagsInferred = !tags && resolvedTags ? true : false
351
545
 
352
546
  if (existingIdx !== -1) {
353
547
  // Update existing entry
@@ -362,8 +556,30 @@ server.registerTool(
362
556
  }
363
557
 
364
558
  writeMemory(lines.join("\n"))
559
+
560
+ // Auto-archive if we exceed MAX_ENTRIES
561
+ const headerMatch = lines[headerIdx].match(/\[(\d+)\|/)
562
+ const entryCount = headerMatch ? parseInt(headerMatch[1]) : 0
563
+ let archiveMsg = ""
564
+ if (entryCount > MAX_ENTRIES) {
565
+ const result = archiveOldEntries()
566
+ if (result.archived > 0) {
567
+ archiveMsg = `\n📦 Auto-archived ${result.archived} old entries (${result.kept} kept)`
568
+ }
569
+ }
570
+
571
+ const ttlMsg = resolvedTtl ? `\n⏰ TTL: ${resolvedTtl}` : ""
572
+ const inferredMsg = tagsInferred ? `\n🏷️ Tags inferidos: ${resolvedTags}` : ""
573
+
574
+ const related = findRelatedEntries(`${key} ${content} ${resolvedTags}`, key, 3)
575
+ let relatedMsg = ""
576
+ if (related.length > 0) {
577
+ const items = related.map((r) => ` [${r.cat}] ${r.key} — ${r.content.slice(0, 80)}`).join("\n")
578
+ relatedMsg = `\n\n🔗 Entradas relacionadas:\n${items}`
579
+ }
580
+
365
581
  return {
366
- content: [{ type: "text" as const, text: `🧠 ${action}: ${category}/${key} (${entryId})\n${content}` }],
582
+ content: [{ type: "text" as const, text: `🧠 ${action}: ${category}/${key} (${entryId})\n${content}${ttlMsg}${inferredMsg}${archiveMsg}${relatedMsg}` }],
367
583
  }
368
584
  }
369
585
  )
@@ -404,10 +620,11 @@ server.registerTool(
404
620
  const trimmed = line.trim()
405
621
  const parts = trimmed.split("|")
406
622
  if (parts.length < 7) return null
407
- const [id, cat, key, content, file, tags, date] = parts
623
+ const [id, cat, key, content, file, tags, date, ttl] = parts
408
624
  if (category && cat !== category) return null
409
625
  if (from_date && date < from_date) return null
410
626
  if (to_date && date > to_date) return null
627
+ if (ttl && isExpired(ttl)) return null
411
628
  const searchStr = normalize(`${id} ${cat} ${key} ${content} ${file} ${tags}`)
412
629
  // All query tokens must match (AND logic)
413
630
  if (!queryTokens.every((token) => searchStr.includes(token))) return null
@@ -494,7 +711,7 @@ server.registerTool(
494
711
  const lines = data.split("\n").filter((l) => l.startsWith(" ") && l.includes("|") && !l.startsWith(" summaries:"))
495
712
  const entries = lines.map((l) => {
496
713
  const parts = l.trim().split("|")
497
- return { category: parts[1] || "unknown" }
714
+ return { category: parts[1] || "unknown", ttl: parts[7] || "" }
498
715
  })
499
716
 
500
717
  const byCategory: Record<string, number> = {}
@@ -502,6 +719,9 @@ server.registerTool(
502
719
  byCategory[e.category] = (byCategory[e.category] || 0) + 1
503
720
  }
504
721
 
722
+ const withTtl = entries.filter((e) => e.ttl).length
723
+ const expired = entries.filter((e) => e.ttl && isExpired(e.ttl)).length
724
+
505
725
  const summaryLines = data.split("\n").filter((l) => l.includes(":") && !l.startsWith(" ") && !l.startsWith("version") && !l.startsWith("entries") && !/^\[\d+\|]/.test(l))
506
726
  const stats = [
507
727
  `Entradas totales: ${entries.length}`,
@@ -510,10 +730,13 @@ server.registerTool(
510
730
  "Por categoría:",
511
731
  ...Object.entries(byCategory).map(([k, v]) => ` ${k}: ${v}`),
512
732
  "",
733
+ `TTL: ${withTtl} con expiración, ${expired} expiradas`,
734
+ "",
513
735
  `Últimas 5 entradas:`,
514
736
  ...lines.slice(-5).map((l) => {
515
737
  const parts = l.trim().split("|")
516
- return ` [${parts[1]}] ${parts[2]} (${parts[0]})`
738
+ const ttlInfo = parts[7] ? ` | TTL: ${parts[7]}` : ""
739
+ return ` [${parts[1]}] ${parts[2]} (${parts[0]})${ttlInfo}`
517
740
  }),
518
741
  ]
519
742
 
@@ -521,6 +744,99 @@ server.registerTool(
521
744
  }
522
745
  )
523
746
 
747
+ /**
748
+ * Register the memory_diff tool.
749
+ * Show what changed in memory since a given date.
750
+ */
751
+ server.registerTool(
752
+ "memory_diff",
753
+ {
754
+ title: "Memory Diff",
755
+ description: "Muestra qué cambió en la memoria desde una fecha. Útil para saber qué se aprendió desde la última sesión.",
756
+ inputSchema: {
757
+ since: z.string().describe("Desde cuándo mostrar cambios (ej: 24h, 7d, 2026-07-10)"),
758
+ type: z.enum(["all", "created", "updated"]).optional().default("all").describe("Filtrar por tipo de cambio"),
759
+ },
760
+ },
761
+ async ({ since, type }) => {
762
+ const sinceDate = parseRelativeDate(since)
763
+ const today = new Date().toISOString().split("T")[0]
764
+ const data = readMemory()
765
+ const lines = data.split("\n").filter((l) => l.startsWith(" ") && l.includes("|") && !l.startsWith(" summaries:"))
766
+
767
+ const results = lines
768
+ .map((line) => {
769
+ const trimmed = line.trim()
770
+ const parts = trimmed.split("|")
771
+ if (parts.length < 7) return null
772
+ const [id, cat, key, content, file, tags, date] = parts
773
+ if (date < sinceDate) return null
774
+ // For "updated" we check if date is recent but key existed before
775
+ // For simplicity: same date as today = created today, otherwise "updated" if date >= sinceDate
776
+ const changeType = date === today ? "created" : "updated"
777
+ if (type !== "all" && changeType !== type) return null
778
+ return { id, cat, key, content, file, tags, date, changeType }
779
+ })
780
+ .filter(Boolean)
781
+
782
+ if (results.length === 0) {
783
+ return { content: [{ type: "text" as const, text: `No hay cambios desde ${sinceDate}` }] }
784
+ }
785
+
786
+ const created = results.filter((r) => r!.changeType === "created")
787
+ const updated = results.filter((r) => r!.changeType === "updated")
788
+
789
+ const sections: string[] = [`📋 Cambios desde ${sinceDate}:`, ""]
790
+
791
+ if (created.length > 0 && (type === "all" || type === "created")) {
792
+ sections.push(`➕ Nuevas (${created.length}):`)
793
+ for (const r of created) {
794
+ sections.push(` [${r!.cat}] ${r!.key} (${r!.id})\n ${r!.content}`)
795
+ }
796
+ sections.push("")
797
+ }
798
+
799
+ if (updated.length > 0 && (type === "all" || type === "updated")) {
800
+ sections.push(`✏️ Actualizadas (${updated.length}):`)
801
+ for (const r of updated) {
802
+ sections.push(` [${r!.cat}] ${r!.key} (${r!.id}) — ${r!.date}`)
803
+ }
804
+ sections.push("")
805
+ }
806
+
807
+ return { content: [{ type: "text" as const, text: sections.join("\n") }] }
808
+ }
809
+ )
810
+
811
+ /**
812
+ * Register the memory_suggest tool.
813
+ * Suggest related entries for a given text context.
814
+ */
815
+ server.registerTool(
816
+ "memory_suggest",
817
+ {
818
+ title: "Suggest Related Memories",
819
+ description: "Sugiere entradas de memoria relacionadas con un contexto dado. Útil para obtener contexto antes de una tarea.",
820
+ inputSchema: {
821
+ context: z.string().describe("Texto o contexto para buscar sugerencias"),
822
+ limit: z.number().optional().default(5).describe("Máximo de sugerencias"),
823
+ },
824
+ },
825
+ async ({ context, limit }) => {
826
+ const related = findRelatedEntries(context, "", limit)
827
+
828
+ if (related.length === 0) {
829
+ return { content: [{ type: "text" as const, text: `No se encontraron entradas relacionadas con "${context}"` }] }
830
+ }
831
+
832
+ const formatted = related
833
+ .map((r) => `[${r.cat}] ${r.key} (${r.id})\n ${r.content}\n File: ${r.file} | Tags: ${r.tags} | Date: ${r.date}`)
834
+ .join("\n\n")
835
+
836
+ return { content: [{ type: "text" as const, text: `🔍 Sugerencias para "${context}":\n\n${formatted}` }] }
837
+ }
838
+ )
839
+
524
840
  /**
525
841
  * Register the memory_summary tool.
526
842
  * 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
- # Remove custom tools if they exist
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 ".opencode/memory/data.toon" ]; then
81
+ if [ -f ".toon-memory/memory/data.toon" ]; then
54
82
  echo ""
55
- read -p "Remove memory file (.opencode/memory/data.toon)? [y/N] " -n 1 -r
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 ".opencode/memory"
86
+ rm -rf ".toon-memory/memory"
59
87
  echo " ✅ Removed memory directory"
60
88
  fi
61
89
  fi