toon-memory 1.0.4 → 1.0.6

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/memory.ts CHANGED
@@ -1,244 +1,237 @@
1
1
  import { tool } from "@opencode-ai/plugin"
2
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs"
3
- import * as path from "path"
4
- import { encode, decode } from "@toon-format/toon"
5
- import { randomUUID } from "crypto"
6
-
7
- const MEMORY_DIR = path.join(process.cwd(), ".opencode", "memory")
8
- const MEMORY_FILE = path.join(MEMORY_DIR, "data.toon")
9
-
10
- interface MemoryEntry {
11
- id: string
12
- category: "decision" | "pattern" | "bug" | "knowledge"
13
- key: string
14
- content: string
15
- file: string
16
- tags: string
17
- date: string
18
- }
19
-
20
- interface MemoryData {
21
- version: number
22
- entries: MemoryEntry[]
23
- summaries: Record<string, string>
24
- }
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"
3
+ import { dirname, join } from "path"
4
+ import { fileURLToPath } from "url"
5
+ import { randomBytes } from "crypto"
25
6
 
26
- function defaultData(): MemoryData {
27
- return { version: 1, entries: [], summaries: {} }
28
- }
7
+ const __dirname = dirname(fileURLToPath(import.meta.url))
8
+ const MEMORY_DIR = join(__dirname, "..", "memory")
9
+ const MEMORY_FILE = join(MEMORY_DIR, "data.toon")
29
10
 
30
- function ensureDir() {
11
+ function ensureMemoryFile() {
31
12
  if (!existsSync(MEMORY_DIR)) mkdirSync(MEMORY_DIR, { recursive: true })
13
+ if (!existsSync(MEMORY_FILE)) {
14
+ writeFileSync(MEMORY_FILE, "version: 1\nentries[0|]{id|category|key|content|file|tags|date}:\n")
15
+ }
32
16
  }
33
17
 
34
- function readMemory(): MemoryData {
35
- ensureDir()
36
- if (!existsSync(MEMORY_FILE)) return defaultData()
37
- try {
38
- const raw = readFileSync(MEMORY_FILE, "utf-8")
39
- const parsed = decode(raw) as MemoryData
40
- if (!parsed || typeof parsed !== "object") return defaultData()
41
- if (!Array.isArray(parsed.entries)) parsed.entries = []
42
- if (!parsed.summaries || typeof parsed.summaries !== "object") parsed.summaries = {}
43
- return parsed
44
- } catch {
45
- return defaultData()
46
- }
18
+ function readMemory() {
19
+ ensureMemoryFile()
20
+ return readFileSync(MEMORY_FILE, "utf-8")
47
21
  }
48
22
 
49
- function writeMemory(data: MemoryData): void {
50
- ensureDir()
51
- const toon = encode(data, { delimiter: "|", indent: 2 })
52
- writeFileSync(MEMORY_FILE, toon, "utf-8")
23
+ function writeMemory(content: string) {
24
+ ensureMemoryFile()
25
+ writeFileSync(MEMORY_FILE, content)
53
26
  }
54
27
 
55
- function genId(): string {
56
- return randomUUID().slice(0, 8)
28
+ function generateId(): string {
29
+ return randomBytes(4).toString("hex")
57
30
  }
58
31
 
59
- export const memory_remember = tool({
60
- description:
61
- "Guarda un hecho en la memoria persistente del proyecto (decisiones, patrones, bugs, conocimiento). Se recuerda entre sesiones. Usar cuando el agente tome una decisión importante, resuelva un bug, o observe un patrón.",
32
+ export const memoryRemember = tool({
33
+ description: "Guarda un hecho en la memoria persistente del proyecto (decisiones, patrones, bugs, conocimiento). Se recuerda entre sesiones.",
62
34
  args: {
63
- category: tool.schema
64
- .enum(["decision", "pattern", "bug", "knowledge"])
65
- .describe("Categoría: decision (diseño), pattern (código), bug (resolución), knowledge (general)"),
66
- key: tool.schema
67
- .string()
68
- .describe("Título corto en kebab-case (ej: risk-engine-prioridad)"),
69
- content: tool.schema
70
- .string()
71
- .describe("Contenido detallado del hecho"),
72
- file: tool.schema
73
- .string()
74
- .describe("Archivo o línea relacionada (ej: spec.md:145)")
75
- .default(""),
76
- tags: tool.schema
77
- .string()
78
- .describe("Tags separados por punto y coma (ej: risk;spec)")
79
- .default(""),
35
+ category: tool.schema.enum({
36
+ description: "Categoría del hecho",
37
+ options: ["decision", "pattern", "bug", "knowledge"],
38
+ }),
39
+ key: tool.schema.string({
40
+ description: "Título corto en kebab-case (ej: risk-engine-prioridad)",
41
+ }),
42
+ content: tool.schema.string({
43
+ description: "Contenido detallado del hecho",
44
+ }),
45
+ file: tool.schema.string({
46
+ description: "Archivo o línea relacionada (ej: spec.md:145)",
47
+ }),
48
+ tags: tool.schema.string({
49
+ description: "Tags separados por punto y coma (ej: risk;spec)",
50
+ }),
80
51
  },
81
- async execute({ category, key, content, file, tags }) {
82
- const state = readMemory()
83
- const existing = state.entries.find(
84
- (e) => e.category === category && e.key === key,
85
- )
86
- const entry: MemoryEntry = {
87
- id: existing?.id || genId(),
88
- category,
89
- key,
90
- content,
91
- file,
92
- tags,
93
- date: new Date().toISOString().slice(0, 10),
94
- }
95
- if (existing) {
96
- const idx = state.entries.indexOf(existing)
97
- state.entries[idx] = entry
98
- } else {
99
- state.entries.push(entry)
100
- }
101
- writeMemory(state)
102
- return {
103
- title: existing ? "🧠 Actualizado" : "🧠 Guardado",
104
- output: `${category}/${key} (${entry.id})\n${content}`,
52
+ async execute(args) {
53
+ const data = readMemory()
54
+ const id = generateId()
55
+ const date = new Date().toISOString().split("T")[0]
56
+ const lines = data.split("\n")
57
+
58
+ let headerIdx = lines.findIndex((l) => l.startsWith("entries["))
59
+ if (headerIdx === -1) {
60
+ lines.push(`entries[0|]{id|category|key|content|file|tags|date}:`)
61
+ headerIdx = lines.length - 1
105
62
  }
63
+
64
+ const match = lines[headerIdx].match(/entries\[(\d+)\|/)
65
+ const count = match ? parseInt(match[1]) : 0
66
+ const newEntry = `${id}|${args.category}|${args.key}|${args.content}|${args.file || ""}|${args.tags || ""}|${date}`
67
+
68
+ lines.splice(headerIdx + 1, 0, ` ${newEntry}`)
69
+ lines[headerIdx] = lines[headerIdx].replace(/entries\[\d+\|/, `[${count + 1}|`)
70
+
71
+ writeMemory(lines.join("\n"))
72
+ return `🧠 Guardado: ${args.category}/${args.key} (${id})\n${args.content}`
106
73
  },
107
74
  })
108
75
 
109
- export const memory_recall = tool({
110
- description:
111
- "Busca en la memoria persistente del proyecto. Devuelve entradas relevantes por categoría, tag, o texto libre. Usar ANTES de leer archivos para evitar re-investigar.",
76
+ export const memoryRecall = tool({
77
+ description: "Busca en la memoria persistente del proyecto. Devuelve entradas relevantes por categoría, tag, o texto libre. Usar ANTES de leer archivos para evitar re-investigar.",
112
78
  args: {
113
- query: tool.schema
114
- .string()
115
- .describe("Texto a buscar (busca en key, content, tags)"),
116
- category: tool.schema
117
- .string()
118
- .describe("Filtrar por categoría (decision|pattern|bug|knowledge). Vacío = todos.")
119
- .default(""),
79
+ query: tool.schema.string({
80
+ description: "Texto a buscar (busca en key, content, tags)",
81
+ }),
82
+ category: tool.schema.string({
83
+ description: "Filtrar por categoría (decision|pattern|bug|knowledge). Vacío = todos.",
84
+ }),
120
85
  },
121
- async execute({ query, category }) {
122
- const state = readMemory()
123
- const q = query.toLowerCase()
124
- let results = state.entries.filter((e) => {
125
- const matchText =
126
- e.key.toLowerCase().includes(q) ||
127
- e.content.toLowerCase().includes(q) ||
128
- e.tags.toLowerCase().includes(q)
129
- const matchCat = category ? e.category === category : true
130
- return matchText && matchCat
131
- })
132
- results = results.slice(0, 10)
86
+ async execute(args) {
87
+ const data = readMemory()
88
+ const lines = data.split("\n").filter((l) => l.startsWith(" ") && l.includes("|"))
89
+ const queryLower = args.query.toLowerCase()
90
+
91
+ const results = lines
92
+ .map((line) => {
93
+ const trimmed = line.trim()
94
+ const parts = trimmed.split("|")
95
+ if (parts.length < 7) return null
96
+ const [id, cat, key, content, file, tags, date] = parts
97
+ if (args.category && cat !== args.category) return null
98
+ const searchStr = `${id} ${cat} ${key} ${content} ${file} ${tags}`.toLowerCase()
99
+ if (!searchStr.includes(queryLower)) return null
100
+ return { id, cat, key, content, file, tags, date }
101
+ })
102
+ .filter(Boolean)
103
+
133
104
  if (results.length === 0) {
134
- return {
135
- title: "🔍 Sin resultados",
136
- output: `No encontré nada para "${query}"${category ? ` en categoría "${category}"` : ""}.`,
137
- }
105
+ return `No se encontraron resultados para "${args.query}"`
138
106
  }
139
- const output = results
140
- .map(
141
- (e) =>
142
- `[${e.category}] ${e.key} (${e.id})\n ${e.content}\n File: ${e.file || "N/A"} | Tags: ${e.tags || "N/A"} | Date: ${e.date}`,
143
- )
107
+
108
+ return results
109
+ .map((r) => `[${r!.cat}] ${r!.key} (${r!.id})\n ${r!.content}\n File: ${r!.file} | Tags: ${r!.tags} | Date: ${r!.date}`)
144
110
  .join("\n\n")
145
- return { title: `🔍 ${results.length} resultados`, output }
146
111
  },
147
112
  })
148
113
 
149
- export const memory_forget = tool({
114
+ export const memoryForget = tool({
150
115
  description: "Elimina una entrada de la memoria por su key o id.",
151
116
  args: {
152
- key: tool.schema
153
- .string()
154
- .describe("Key o id de la entrada a eliminar"),
117
+ key: tool.schema.string({
118
+ description: "Key o id de la entrada a eliminar",
119
+ }),
155
120
  },
156
- async execute({ key }) {
157
- const state = readMemory()
158
- const before = state.entries.length
159
- state.entries = state.entries.filter(
160
- (e) => e.key !== key && e.id !== key,
161
- )
162
- if (state.entries.length === before) {
163
- return {
164
- title: "⚠️ No encontrado",
165
- output: `No encontré "${key}" en la memoria.`,
166
- }
167
- }
168
- writeMemory(state)
169
- return {
170
- title: "🗑️ Eliminado",
171
- output: `"${key}" eliminado. Quedan ${state.entries.length} entradas.`,
121
+ async execute(args) {
122
+ const data = readMemory()
123
+ const lines = data.split("\n")
124
+ const headerIdx = lines.findIndex((l) => l.startsWith("entries["))
125
+
126
+ if (headerIdx === -1) {
127
+ return "No hay entradas en memoria"
172
128
  }
129
+
130
+ const entryLines = lines.slice(headerIdx + 1).filter((l) => l.trim().length > 0)
131
+ const filtered = entryLines.filter((l) => {
132
+ const parts = l.trim().split("|")
133
+ return parts[0] !== args.key && parts[2] !== args.key
134
+ })
135
+
136
+ const removed = entryLines.length - filtered.length
137
+ const match = lines[headerIdx].match(/entries\[(\d+)\|/)
138
+ const count = match ? parseInt(match[1]) : 0
139
+ lines[headerIdx] = lines[headerIdx].replace(/entries\[\d+\|/, `[${count - removed}|`)
140
+ lines.splice(headerIdx + 1, entryLines.length, ...filtered.map((l) => ` ${l.trim()}`))
141
+
142
+ writeMemory(lines.join("\n"))
143
+ return `"${args.key}" eliminado. Quedan ${count - removed} entradas.`
173
144
  },
174
145
  })
175
146
 
176
- export const memory_stats = tool({
177
- description:
178
- "Muestra estadísticas de la memoria: total entradas, por categoría, y resúmenes de archivos.",
147
+ export const memoryStats = tool({
148
+ description: "Muestra estadísticas de la memoria: total entradas, por categoría, y resúmenes de archivos.",
179
149
  args: {},
180
150
  async execute() {
181
- const state = readMemory()
151
+ const data = readMemory()
152
+ const lines = data.split("\n").filter((l) => l.startsWith(" ") && l.includes("|"))
153
+ const entries = lines.map((l) => {
154
+ const parts = l.trim().split("|")
155
+ return { category: parts[1] || "unknown" }
156
+ })
157
+
182
158
  const byCategory: Record<string, number> = {}
183
- for (const e of state.entries) {
159
+ for (const e of entries) {
184
160
  byCategory[e.category] = (byCategory[e.category] || 0) + 1
185
161
  }
186
- const catStr = Object.entries(byCategory)
187
- .map(([k, v]) => ` ${k}: ${v}`)
188
- .join("\n")
189
- const recent = state.entries
190
- .slice(-5)
191
- .reverse()
192
- .map((e) => ` [${e.category}] ${e.key} (${e.date})`)
193
- .join("\n")
194
- return {
195
- title: "📊 Memoria del proyecto",
196
- output: [
197
- `Entradas totales: ${state.entries.length}`,
198
- `Resúmenes de archivos: ${Object.keys(state.summaries).length}`,
199
- "",
200
- "Por categoría:",
201
- catStr || " (vacío)",
202
- "",
203
- "Últimas 5 entradas:",
204
- recent || " (ninguna)",
205
- ].join("\n"),
206
- }
162
+
163
+ const summaryLines = data.split("\n").filter((l) => l.includes(":") && !l.startsWith(" ") && !l.startsWith("version") && !l.startsWith("entries"))
164
+ const stats = [
165
+ `Entradas totales: ${entries.length}`,
166
+ `Resúmenes de archivos: ${summaryLines.length}`,
167
+ "",
168
+ "Por categoría:",
169
+ ...Object.entries(byCategory).map(([k, v]) => ` ${k}: ${v}`),
170
+ "",
171
+ `Últimas 5 entradas:`,
172
+ ...lines.slice(-5).map((l) => {
173
+ const parts = l.trim().split("|")
174
+ return ` [${parts[1]}] ${parts[2]} (${parts[0]})`
175
+ }),
176
+ ]
177
+
178
+ return stats.join("\n")
207
179
  },
208
180
  })
209
181
 
210
- export const memory_summary = tool({
211
- description:
212
- "Guarda o recupera un resumen de un archivo grande. El agente puede leer el resumen en vez del archivo completo para ahorrar tokens.",
182
+ export const memorySummary = tool({
183
+ description: "Guarda o recupera un resumen de un archivo grande. El agente puede leer el resumen en vez del archivo completo para ahorrar tokens.",
213
184
  args: {
214
- action: tool.schema
215
- .enum(["get", "set"])
216
- .describe("get para leer, set para guardar"),
217
- file: tool.schema
218
- .string()
219
- .describe("Ruta del archivo"),
220
- summary: tool.schema
221
- .string()
222
- .describe("Resumen del archivo (solo para set)")
223
- .default(""),
185
+ action: tool.schema.enum({
186
+ description: "get para leer, set para guardar",
187
+ options: ["get", "set"],
188
+ }),
189
+ file: tool.schema.string({
190
+ description: "Ruta del archivo",
191
+ }),
192
+ summary: tool.schema.string({
193
+ description: "Resumen del archivo (solo para set)",
194
+ }),
224
195
  },
225
- async execute({ action, file, summary }) {
226
- const state = readMemory()
227
- if (action === "get") {
228
- const s = state.summaries[file]
229
- if (!s) {
230
- return {
231
- title: "📄 Sin resumen",
232
- output: `No hay resumen para "${file}". Usar memory_summary action=set para guardar uno.`,
233
- }
196
+ async execute(args) {
197
+ const data = readMemory()
198
+
199
+ if (args.action === "get") {
200
+ const lines = data.split("\n")
201
+ const summaryIdx = lines.findIndex((l) => l.trim().startsWith("summaries:"))
202
+ if (summaryIdx === -1) {
203
+ return `No hay resúmenes guardados para "${args.file}"`
234
204
  }
235
- return { title: `📄 ${file}`, output: s }
205
+
206
+ const summaryLines = lines.slice(summaryIdx + 1).filter((l) => l.includes(":"))
207
+ const match = summaryLines.find((l) => l.startsWith(` ${args.file}:`))
208
+ if (!match) {
209
+ return `No hay resumen para "${args.file}"`
210
+ }
211
+
212
+ const summaryText = match.replace(` ${args.file}: `, "")
213
+ return summaryText
214
+ }
215
+
216
+ const lines = data.split("\n")
217
+ let summaryIdx = lines.findIndex((l) => l.trim().startsWith("summaries:"))
218
+
219
+ if (summaryIdx === -1) {
220
+ lines.push("", "summaries:")
221
+ summaryIdx = lines.length - 1
236
222
  }
237
- state.summaries[file] = summary
238
- writeMemory(state)
239
- return {
240
- title: "📄 Resumen guardado",
241
- output: `${file} actualizado.`,
223
+
224
+ const summaryLines = lines.slice(summaryIdx + 1).filter((l) => l.includes(":"))
225
+ const existingIdx = summaryLines.findIndex((l) => l.startsWith(` ${args.file}:`))
226
+
227
+ if (existingIdx !== -1) {
228
+ summaryLines[existingIdx] = ` ${args.file}: ${args.summary}`
229
+ } else {
230
+ summaryLines.push(` ${args.file}: ${args.summary}`)
242
231
  }
232
+
233
+ lines.splice(summaryIdx + 1, lines.length - summaryIdx - 1, ...summaryLines)
234
+ writeMemory(lines.join("\n"))
235
+ return `📝 Resumen guardado para ${args.file}`
243
236
  },
244
237
  })
package/README.md DELETED
@@ -1,66 +0,0 @@
1
- # toon-memory
2
-
3
- Persistent memory system for [OpenCode](https://opencode.ai) AI agent using TOON format.
4
-
5
- **40% fewer tokens than JSON** for the same data. The agent remembers decisions, patterns, and bugs between sessions.
6
-
7
- ## Install
8
-
9
- ```bash
10
- # In your project
11
- npm install toon-memory @toon-format/toon
12
- npx toon-memory
13
- ```
14
-
15
- Or globally:
16
-
17
- ```bash
18
- npm install -g toon-memory @toon-format/toon
19
- # Then in any project:
20
- npx toon-memory
21
- ```
22
-
23
- ## What it does
24
-
25
- Copies 3 files to your project:
26
-
27
- | File | Purpose |
28
- |------|---------|
29
- | `.opencode/tools/memory.ts` | 5 tools for the AI agent |
30
- | `.opencode/memory/data.toon` | Your project's memory (TOON format) |
31
- | `.opencode/skills/toon-memory.md` | Usage instructions |
32
-
33
- ## Tools
34
-
35
- | Tool | Description |
36
- |------|-------------|
37
- | `memory_remember` | Save a decision, pattern, bug, or knowledge |
38
- | `memory_recall` | Search memory (use BEFORE reading files) |
39
- | `memory_forget` | Remove an entry by key or id |
40
- | `memory_stats` | View memory state |
41
- | `memory_summary` | Save/retrieve file summaries |
42
-
43
- ## How it works
44
-
45
- The agent stores entries in TOON format (Token-Oriented Object Notation):
46
-
47
- ```
48
- version: 1
49
- entries[2|]{id|category|key|content|file|tags|date}:
50
- a1b2c3d4|decision|use-pandas|Use pandas for time series|indicators.py|python;analytics|2026-07-10
51
- e5f6g7h8|bug|redis-pool|max_connections missing|redis.adapter.ts|redis;pool|2026-07-10
52
- summaries:
53
- path/to/file.py: Brief summary of what this file does
54
- ```
55
-
56
- ## Token savings
57
-
58
- | Format | Tokens (10 entries) | vs JSON |
59
- |--------|---------------------|---------|
60
- | JSON | ~800 | baseline |
61
- | YAML | ~650 | -19% |
62
- | **TOON** | **~480** | **-40%** |
63
-
64
- ## License
65
-
66
- MIT