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/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,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
- if (date < cutoffStr) {
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 newEntry = `${entryId}|${category}|${key}|${content}|${file || ""}|${tags || ""}|${date}`
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
- return ` [${parts[1]}] ${parts[2]} (${parts[0]})`
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
- # 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