@geekbeer/minion 2.70.2 → 3.4.7

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.
@@ -1,123 +1,98 @@
1
1
  /**
2
- * Execution Store
3
- * Persists skill execution history to local JSON file.
2
+ * Execution Store (SQLite)
3
+ * Persists skill execution history to local SQLite database.
4
4
  * Single source of truth for execution records.
5
5
  */
6
6
 
7
- const fs = require('fs').promises
8
- const path = require('path')
9
-
10
- const { config } = require('../config')
11
- const { DATA_DIR } = require('../lib/platform')
7
+ const { getDb } = require('../db')
12
8
 
13
9
  // Max executions to keep (older ones are pruned)
14
10
  const MAX_EXECUTIONS = 200
15
11
 
16
12
  /**
17
- * Get execution file path
18
- * Uses DATA_DIR if available (platform-aware), otherwise home dir
19
- */
20
- function getExecutionFilePath() {
21
- try {
22
- require('fs').accessSync(DATA_DIR)
23
- return path.join(DATA_DIR, 'executions.json')
24
- } catch {
25
- return path.join(config.HOME_DIR, 'executions.json')
26
- }
27
- }
28
-
29
- const EXECUTION_FILE = getExecutionFilePath()
30
-
31
- /**
32
- * Load executions from local file
33
- * @returns {Promise<Array>} Array of execution objects
34
- */
35
- async function load() {
36
- try {
37
- const data = await fs.readFile(EXECUTION_FILE, 'utf-8')
38
- return JSON.parse(data)
39
- } catch (err) {
40
- if (err.code === 'ENOENT') {
41
- return []
42
- }
43
- console.error(`[ExecutionStore] Failed to load executions: ${err.message}`)
44
- return []
45
- }
46
- }
47
-
48
- /**
49
- * Save executions to local file
50
- * @param {Array} executions - Array of execution objects
51
- */
52
- async function saveAll(executions) {
53
- try {
54
- await fs.writeFile(EXECUTION_FILE, JSON.stringify(executions, null, 2), 'utf-8')
55
- } catch (err) {
56
- console.error(`[ExecutionStore] Failed to save executions: ${err.message}`)
57
- throw err
58
- }
59
- }
60
-
61
- /**
62
- * Save a single execution record
63
- * Upserts by ID, prunes old records if over limit
13
+ * Save a single execution record.
14
+ * Upserts by ID, prunes old records if over limit.
64
15
  * @param {object} execution - Execution record
65
16
  */
66
- async function save(execution) {
17
+ function save(execution) {
18
+ const db = getDb()
67
19
  console.log(`[ExecutionStore] save() called: id=${execution.id}, skill=${execution.skill_name}, status=${execution.status}`)
68
20
 
69
- const executions = await load()
70
- console.log(`[ExecutionStore] Loaded ${executions.length} existing executions`)
71
-
72
- // Find existing by ID and update, or add new
73
- const existingIndex = executions.findIndex(e => e.id === execution.id)
74
- if (existingIndex >= 0) {
75
- const oldStatus = executions[existingIndex].status
76
- executions[existingIndex] = { ...executions[existingIndex], ...execution }
77
- console.log(`[ExecutionStore] Updated existing record at index ${existingIndex}: ${oldStatus} → ${execution.status}`)
21
+ const existing = db.prepare('SELECT data FROM executions WHERE id = ?').get(execution.id)
22
+
23
+ if (existing) {
24
+ const merged = { ...JSON.parse(existing.data), ...execution }
25
+ db.prepare(
26
+ 'UPDATE executions SET skill_name = ?, workflow_id = ?, status = ?, data = ? WHERE id = ?'
27
+ ).run(
28
+ merged.skill_name || null,
29
+ merged.workflow_id || null,
30
+ merged.status || null,
31
+ JSON.stringify(merged),
32
+ execution.id
33
+ )
34
+ console.log(`[ExecutionStore] Updated existing record: ${execution.status}`)
78
35
  } else {
79
- executions.unshift(execution) // Add to beginning (newest first)
80
- console.log(`[ExecutionStore] Added new record (now ${executions.length} total)`)
36
+ db.prepare(
37
+ 'INSERT INTO executions (id, skill_name, workflow_id, status, created_at, data) VALUES (?, ?, ?, ?, ?, ?)'
38
+ ).run(
39
+ execution.id,
40
+ execution.skill_name || null,
41
+ execution.workflow_id || null,
42
+ execution.status || null,
43
+ execution.created_at || new Date().toISOString(),
44
+ JSON.stringify(execution)
45
+ )
46
+ console.log(`[ExecutionStore] Added new record`)
81
47
  }
82
48
 
83
- // Prune old executions
84
- const pruned = executions.slice(0, MAX_EXECUTIONS)
49
+ // Prune old executions beyond MAX_EXECUTIONS
50
+ const count = db.prepare('SELECT COUNT(*) as cnt FROM executions').get().cnt
51
+ if (count > MAX_EXECUTIONS) {
52
+ db.prepare(`
53
+ DELETE FROM executions WHERE id NOT IN (
54
+ SELECT id FROM executions ORDER BY created_at DESC LIMIT ?
55
+ )
56
+ `).run(MAX_EXECUTIONS)
57
+ }
85
58
 
86
- await saveAll(pruned)
87
- console.log(`[ExecutionStore] ✓ Saved to file: ${execution.id} (${execution.skill_name} → ${execution.status})`)
59
+ console.log(`[ExecutionStore] ✓ Saved: ${execution.id} (${execution.skill_name} → ${execution.status})`)
88
60
  }
89
61
 
90
62
  /**
91
63
  * List executions with optional limit
92
64
  * @param {number} limit - Max number of executions to return
93
- * @returns {Promise<Array>} Array of execution objects (newest first)
65
+ * @returns {Array} Array of execution objects (newest first)
94
66
  */
95
- async function list(limit = 50) {
96
- const executions = await load()
97
- return executions.slice(0, limit)
67
+ function list(limit = 50) {
68
+ const db = getDb()
69
+ const rows = db.prepare('SELECT data FROM executions ORDER BY created_at DESC LIMIT ?').all(limit)
70
+ return rows.map(r => JSON.parse(r.data))
98
71
  }
99
72
 
100
73
  /**
101
74
  * Get executions for a specific workflow
102
75
  * @param {string} workflowId - Workflow UUID
103
76
  * @param {number} limit - Max number to return
104
- * @returns {Promise<Array>} Array of execution objects
77
+ * @returns {Array} Array of execution objects
105
78
  */
106
- async function getByWorkflow(workflowId, limit = 20) {
107
- const executions = await load()
108
- return executions
109
- .filter(e => e.workflow_id === workflowId)
110
- .slice(0, limit)
79
+ function getByWorkflow(workflowId, limit = 20) {
80
+ const db = getDb()
81
+ const rows = db.prepare(
82
+ 'SELECT data FROM executions WHERE workflow_id = ? ORDER BY created_at DESC LIMIT ?'
83
+ ).all(workflowId, limit)
84
+ return rows.map(r => JSON.parse(r.data))
111
85
  }
112
86
 
113
87
  /**
114
88
  * Get a single execution by ID
115
89
  * @param {string} id - Execution UUID
116
- * @returns {Promise<object|null>} Execution object or null
90
+ * @returns {object|null} Execution object or null
117
91
  */
118
- async function getById(id) {
119
- const executions = await load()
120
- return executions.find(e => e.id === id) || null
92
+ function getById(id) {
93
+ const db = getDb()
94
+ const row = db.prepare('SELECT data FROM executions WHERE id = ?').get(id)
95
+ return row ? JSON.parse(row.data) : null
121
96
  }
122
97
 
123
98
  module.exports = {
@@ -1,160 +1,54 @@
1
1
  /**
2
- * Memory Store
3
- * Persistent long-term memory for the minion, stored as markdown files.
4
- * Each entry has YAML frontmatter (id, title, category, timestamps) + body.
5
- * An index file (MEMORY.md) provides a quick summary of all entries.
2
+ * Memory Store (SQLite)
3
+ * Persistent long-term memory for the minion.
4
+ * Supports full-text search via FTS5.
6
5
  *
7
- * Storage layout:
8
- * $DATA_DIR/memory/
9
- * ├── MEMORY.md # Index (auto-generated)
10
- * └── {id}.md # Individual entries
6
+ * Categories: user, feedback, project, reference
11
7
  */
12
8
 
13
- const fs = require('fs').promises
14
- const path = require('path')
15
9
  const crypto = require('crypto')
16
-
17
- const { config } = require('../config')
18
- const { DATA_DIR } = require('../lib/platform')
10
+ const { getDb } = require('../db')
19
11
 
20
12
  const VALID_CATEGORIES = ['user', 'feedback', 'project', 'reference']
21
13
 
22
14
  /**
23
- * Resolve memory directory path
24
- */
25
- function getMemoryDir() {
26
- try {
27
- require('fs').accessSync(DATA_DIR)
28
- return path.join(DATA_DIR, 'memory')
29
- } catch {
30
- return path.join(config.HOME_DIR, 'memory')
31
- }
32
- }
33
-
34
- const MEMORY_DIR = getMemoryDir()
35
- const INDEX_FILE = path.join(MEMORY_DIR, 'MEMORY.md')
36
-
37
- /**
38
- * Ensure the memory directory exists
39
- */
40
- async function ensureDir() {
41
- await fs.mkdir(MEMORY_DIR, { recursive: true })
42
- }
43
-
44
- /**
45
- * Parse YAML-style frontmatter from markdown content.
46
- * Returns { meta: {}, body: '' }
47
- */
48
- function parseFrontmatter(content) {
49
- const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/)
50
- if (!match) return { meta: {}, body: content }
51
-
52
- const meta = {}
53
- for (const line of match[1].split('\n')) {
54
- const idx = line.indexOf(':')
55
- if (idx === -1) continue
56
- const key = line.slice(0, idx).trim()
57
- const value = line.slice(idx + 1).trim()
58
- meta[key] = value
59
- }
60
- return { meta, body: match[2].trim() }
61
- }
62
-
63
- /**
64
- * Serialize entry to markdown with YAML frontmatter
15
+ * List all memory entries (metadata + excerpt, no full body).
16
+ * @returns {Array<{ id, title, category, created_at, updated_at, excerpt }>}
65
17
  */
66
- function serializeEntry(entry) {
67
- const lines = [
68
- '---',
69
- `id: ${entry.id}`,
70
- `title: ${entry.title}`,
71
- `category: ${entry.category}`,
72
- `created_at: ${entry.created_at}`,
73
- `updated_at: ${entry.updated_at}`,
74
- '---',
75
- '',
76
- entry.content || '',
77
- ]
78
- return lines.join('\n')
79
- }
80
-
81
- /**
82
- * List all memory entries (frontmatter only, no body).
83
- * @returns {Promise<Array<{ id, title, category, created_at, updated_at }>>}
84
- */
85
- async function listEntries() {
86
- await ensureDir()
87
- let files
88
- try {
89
- files = await fs.readdir(MEMORY_DIR)
90
- } catch {
91
- return []
92
- }
93
-
94
- const entries = []
95
- for (const file of files) {
96
- if (file === 'MEMORY.md' || !file.endsWith('.md')) continue
97
- try {
98
- const raw = await fs.readFile(path.join(MEMORY_DIR, file), 'utf-8')
99
- const { meta, body } = parseFrontmatter(raw)
100
- if (meta.id) {
101
- entries.push({
102
- id: meta.id,
103
- title: meta.title || '',
104
- category: meta.category || 'reference',
105
- created_at: meta.created_at || '',
106
- updated_at: meta.updated_at || '',
107
- excerpt: body.substring(0, 200),
108
- })
109
- }
110
- } catch {
111
- // Skip unreadable files
112
- }
113
- }
114
-
115
- // Sort by updated_at descending
116
- entries.sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || ''))
117
- return entries
18
+ function listEntries() {
19
+ const db = getDb()
20
+ const rows = db.prepare(`
21
+ SELECT id, title, category, created_at, updated_at,
22
+ substr(content, 1, 200) as excerpt
23
+ FROM memories
24
+ ORDER BY updated_at DESC
25
+ `).all()
26
+ return rows
118
27
  }
119
28
 
120
29
  /**
121
30
  * Load a single memory entry by ID.
122
31
  * @param {string} id
123
- * @returns {Promise<object|null>}
32
+ * @returns {object|null}
124
33
  */
125
- async function loadEntry(id) {
126
- try {
127
- const raw = await fs.readFile(path.join(MEMORY_DIR, `${id}.md`), 'utf-8')
128
- const { meta, body } = parseFrontmatter(raw)
129
- return {
130
- id: meta.id || id,
131
- title: meta.title || '',
132
- category: meta.category || 'reference',
133
- content: body,
134
- created_at: meta.created_at || '',
135
- updated_at: meta.updated_at || '',
136
- }
137
- } catch (err) {
138
- if (err.code === 'ENOENT') return null
139
- console.error(`[MemoryStore] Failed to load entry ${id}: ${err.message}`)
140
- return null
141
- }
34
+ function loadEntry(id) {
35
+ const db = getDb()
36
+ const row = db.prepare('SELECT * FROM memories WHERE id = ?').get(id)
37
+ return row || null
142
38
  }
143
39
 
144
40
  /**
145
41
  * Save (create or update) a memory entry.
146
42
  * @param {{ id?, title, category, content }} entry
147
- * @returns {Promise<object>} The saved entry
43
+ * @returns {object} The saved entry
148
44
  */
149
- async function saveEntry(entry) {
150
- await ensureDir()
151
-
45
+ function saveEntry(entry) {
46
+ const db = getDb()
152
47
  const now = new Date().toISOString()
153
48
  const id = entry.id || crypto.randomBytes(6).toString('hex')
154
49
  const category = VALID_CATEGORIES.includes(entry.category) ? entry.category : 'reference'
155
50
 
156
- // Check if existing
157
- const existing = await loadEntry(id)
51
+ const existing = db.prepare('SELECT created_at FROM memories WHERE id = ?').get(id)
158
52
 
159
53
  const full = {
160
54
  id,
@@ -165,11 +59,17 @@ async function saveEntry(entry) {
165
59
  updated_at: now,
166
60
  }
167
61
 
168
- const md = serializeEntry(full)
169
- await fs.writeFile(path.join(MEMORY_DIR, `${id}.md`), md, 'utf-8')
170
-
171
- // Regenerate index
172
- await regenerateIndex()
62
+ if (existing) {
63
+ db.prepare(`
64
+ UPDATE memories SET title = ?, category = ?, content = ?, updated_at = ?
65
+ WHERE id = ?
66
+ `).run(full.title, full.category, full.content, full.updated_at, full.id)
67
+ } else {
68
+ db.prepare(`
69
+ INSERT INTO memories (id, title, category, content, created_at, updated_at)
70
+ VALUES (?, ?, ?, ?, ?, ?)
71
+ `).run(full.id, full.title, full.category, full.content, full.created_at, full.updated_at)
72
+ }
173
73
 
174
74
  return full
175
75
  }
@@ -177,59 +77,67 @@ async function saveEntry(entry) {
177
77
  /**
178
78
  * Delete a memory entry.
179
79
  * @param {string} id
180
- * @returns {Promise<boolean>}
80
+ * @returns {boolean}
181
81
  */
182
- async function deleteEntry(id) {
183
- try {
184
- await fs.unlink(path.join(MEMORY_DIR, `${id}.md`))
185
- await regenerateIndex()
186
- return true
187
- } catch (err) {
188
- if (err.code === 'ENOENT') return false
189
- console.error(`[MemoryStore] Failed to delete entry ${id}: ${err.message}`)
190
- return false
191
- }
82
+ function deleteEntry(id) {
83
+ const db = getDb()
84
+ const result = db.prepare('DELETE FROM memories WHERE id = ?').run(id)
85
+ return result.changes > 0
192
86
  }
193
87
 
194
88
  /**
195
- * Regenerate MEMORY.md index file from all entries.
89
+ * Search memory entries using FTS5 full-text search (trigram tokenizer).
90
+ * Falls back to LIKE for queries shorter than 3 characters (trigram minimum).
91
+ * @param {string} query - Search query
92
+ * @param {number} limit - Max results
93
+ * @returns {Array<{ id, title, category, content, created_at, updated_at }>}
196
94
  */
197
- async function regenerateIndex() {
198
- const entries = await listEntries()
199
- const lines = ['# Minion Memory', '']
200
-
201
- if (entries.length === 0) {
202
- lines.push('No memories stored yet.')
203
- } else {
204
- // Group by category
205
- const grouped = {}
206
- for (const e of entries) {
207
- const cat = e.category || 'reference'
208
- if (!grouped[cat]) grouped[cat] = []
209
- grouped[cat].push(e)
210
- }
211
-
212
- for (const cat of VALID_CATEGORIES) {
213
- if (!grouped[cat] || grouped[cat].length === 0) continue
214
- lines.push(`## ${cat}`, '')
215
- for (const e of grouped[cat]) {
216
- lines.push(`- **${e.title}** (${e.id}) - ${e.excerpt.substring(0, 80)}`)
217
- }
218
- lines.push('')
219
- }
95
+ function searchEntries(query, limit = 20) {
96
+ const db = getDb()
97
+ const terms = query.split(/\s+/).filter(Boolean)
98
+ if (terms.length === 0) return []
99
+
100
+ // Trigram tokenizer requires 3+ character terms.
101
+ // If any term is shorter, use LIKE fallback for the entire query.
102
+ const hasShortTerm = terms.some(t => [...t].length < 3)
103
+
104
+ if (hasShortTerm) {
105
+ // LIKE fallback: match all terms with AND
106
+ const conditions = terms.map(() => '(title LIKE ? OR content LIKE ?)').join(' AND ')
107
+ const params = terms.flatMap(t => {
108
+ const like = `%${t}%`
109
+ return [like, like]
110
+ })
111
+ params.push(limit)
112
+ return db.prepare(`
113
+ SELECT id, title, category, content, created_at, updated_at
114
+ FROM memories
115
+ WHERE ${conditions}
116
+ ORDER BY updated_at DESC
117
+ LIMIT ?
118
+ `).all(...params)
220
119
  }
221
120
 
222
- await fs.writeFile(INDEX_FILE, lines.join('\n'), 'utf-8')
121
+ // FTS5 trigram search: wrap each term in double quotes for exact substring match
122
+ const sanitized = terms.map(t => `"${t.replace(/"/g, '')}"`).join(' ')
123
+ return db.prepare(`
124
+ SELECT m.id, m.title, m.category, m.content, m.created_at, m.updated_at
125
+ FROM memories m
126
+ JOIN memories_fts f ON m.rowid = f.rowid
127
+ WHERE memories_fts MATCH ?
128
+ ORDER BY rank
129
+ LIMIT ?
130
+ `).all(sanitized, limit)
223
131
  }
224
132
 
225
133
  /**
226
134
  * Get a context snippet suitable for chat injection.
227
135
  * Returns the most important memory entries as a formatted string.
228
136
  * @param {number} maxChars - Maximum characters to return
229
- * @returns {Promise<string>}
137
+ * @returns {string}
230
138
  */
231
- async function getContextSnippet(maxChars = 2000) {
232
- const entries = await listEntries()
139
+ function getContextSnippet(maxChars = 2000) {
140
+ const entries = listEntries()
233
141
  if (entries.length === 0) return ''
234
142
 
235
143
  const parts = []
@@ -246,11 +154,10 @@ async function getContextSnippet(maxChars = 2000) {
246
154
  }
247
155
 
248
156
  module.exports = {
249
- ensureDir,
250
157
  listEntries,
251
158
  loadEntry,
252
159
  saveEntry,
253
160
  deleteEntry,
161
+ searchEntries,
254
162
  getContextSnippet,
255
- MEMORY_DIR,
256
163
  }