@geekbeer/minion 2.68.6 → 2.73.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.
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * Memory management endpoints
3
3
  *
4
- * Provides CRUD for the minion's long-term memory entries.
5
- * Memory is stored as markdown files with YAML frontmatter in $DATA_DIR/memory/.
4
+ * Provides CRUD + search for the minion's long-term memory entries.
5
+ * Memory is stored in SQLite with FTS5 full-text search.
6
6
  *
7
7
  * Endpoints:
8
- * GET /api/memory - List all memory entries
8
+ * GET /api/memory - List all entries (or search with ?search=keyword)
9
9
  * GET /api/memory/:id - Get a single entry
10
10
  * POST /api/memory - Create a new entry
11
11
  * PUT /api/memory/:id - Update an entry
@@ -21,14 +21,20 @@ const memoryStore = require('../stores/memory-store')
21
21
  */
22
22
  async function memoryRoutes(fastify) {
23
23
 
24
- // GET /api/memory - List all entries
24
+ // GET /api/memory - List or search entries
25
25
  fastify.get('/api/memory', async (request, reply) => {
26
26
  if (!verifyToken(request)) {
27
27
  reply.code(401)
28
28
  return { success: false, error: 'Unauthorized' }
29
29
  }
30
30
 
31
- const entries = await memoryStore.listEntries()
31
+ const { search } = request.query || {}
32
+ if (search) {
33
+ const entries = memoryStore.searchEntries(search)
34
+ return { success: true, entries }
35
+ }
36
+
37
+ const entries = memoryStore.listEntries()
32
38
  return { success: true, entries }
33
39
  })
34
40
 
@@ -39,7 +45,7 @@ async function memoryRoutes(fastify) {
39
45
  return { success: false, error: 'Unauthorized' }
40
46
  }
41
47
 
42
- const entry = await memoryStore.loadEntry(request.params.id)
48
+ const entry = memoryStore.loadEntry(request.params.id)
43
49
  if (!entry) {
44
50
  reply.code(404)
45
51
  return { success: false, error: 'Entry not found' }
@@ -61,7 +67,7 @@ async function memoryRoutes(fastify) {
61
67
  return { success: false, error: 'title and content are required' }
62
68
  }
63
69
 
64
- const entry = await memoryStore.saveEntry({ title, category, content })
70
+ const entry = memoryStore.saveEntry({ title, category, content })
65
71
  console.log(`[Memory] Created entry: ${entry.id} (${entry.title})`)
66
72
  return { success: true, entry }
67
73
  })
@@ -73,14 +79,14 @@ async function memoryRoutes(fastify) {
73
79
  return { success: false, error: 'Unauthorized' }
74
80
  }
75
81
 
76
- const existing = await memoryStore.loadEntry(request.params.id)
82
+ const existing = memoryStore.loadEntry(request.params.id)
77
83
  if (!existing) {
78
84
  reply.code(404)
79
85
  return { success: false, error: 'Entry not found' }
80
86
  }
81
87
 
82
88
  const { title, category, content } = request.body || {}
83
- const entry = await memoryStore.saveEntry({
89
+ const entry = memoryStore.saveEntry({
84
90
  id: request.params.id,
85
91
  title: title || existing.title,
86
92
  category: category || existing.category,
@@ -98,7 +104,7 @@ async function memoryRoutes(fastify) {
98
104
  return { success: false, error: 'Unauthorized' }
99
105
  }
100
106
 
101
- const deleted = await memoryStore.deleteEntry(request.params.id)
107
+ const deleted = memoryStore.deleteEntry(request.params.id)
102
108
  if (!deleted) {
103
109
  reply.code(404)
104
110
  return { success: false, error: 'Entry not found' }
@@ -2,13 +2,13 @@
2
2
  * Project thread routes (local API on minion)
3
3
  *
4
4
  * Endpoints:
5
- * - GET /api/help-threads - List open threads in minion's projects
6
- * - POST /api/help-threads - Create a new thread (proxied to HQ)
7
- * - GET /api/help-threads/:id - Get thread detail with messages
8
- * - POST /api/help-threads/:id/messages - Post a message to a thread
9
- * - POST /api/help-threads/:id/resolve - Resolve a thread
10
- * - POST /api/help-threads/:id/cancel - Cancel a thread
11
- * - DELETE /api/help-threads/:id - Permanently delete a thread (PM only)
5
+ * - GET /api/threads - List open threads in minion's projects
6
+ * - POST /api/threads - Create a new thread (proxied to HQ)
7
+ * - GET /api/threads/:id - Get thread detail with messages
8
+ * - POST /api/threads/:id/messages - Post a message to a thread
9
+ * - POST /api/threads/:id/resolve - Resolve a thread
10
+ * - POST /api/threads/:id/cancel - Cancel a thread
11
+ * - DELETE /api/threads/:id - Permanently delete a thread (PM only)
12
12
  * - GET /api/project-memories - Search project memories
13
13
  * - POST /api/project-memories - Create a project memory
14
14
  */
@@ -19,77 +19,77 @@ const api = require('../api')
19
19
  /**
20
20
  * @param {import('fastify').FastifyInstance} fastify
21
21
  */
22
- async function helpThreadRoutes(fastify) {
22
+ async function threadRoutes(fastify) {
23
23
  // List open threads
24
- fastify.get('/api/help-threads', async (request, reply) => {
24
+ fastify.get('/api/threads', async (request, reply) => {
25
25
  if (!verifyToken(request)) {
26
26
  reply.code(401)
27
27
  return { success: false, error: 'Unauthorized' }
28
28
  }
29
29
 
30
30
  try {
31
- const data = await api.getOpenHelpThreads()
31
+ const data = await api.getOpenThreads()
32
32
  return { success: true, threads: data.threads || [] }
33
33
  } catch (error) {
34
- console.error(`[HelpThreads] List error: ${error.message}`)
34
+ console.error(`[Threads] List error: ${error.message}`)
35
35
  reply.code(500)
36
36
  return { success: false, error: error.message }
37
37
  }
38
38
  })
39
39
 
40
40
  // Create thread
41
- fastify.post('/api/help-threads', async (request, reply) => {
41
+ fastify.post('/api/threads', async (request, reply) => {
42
42
  if (!verifyToken(request)) {
43
43
  reply.code(401)
44
44
  return { success: false, error: 'Unauthorized' }
45
45
  }
46
46
 
47
47
  try {
48
- const data = await api.createHelpThread(request.body)
48
+ const data = await api.createThread(request.body)
49
49
  return { success: true, thread: data.thread }
50
50
  } catch (error) {
51
- console.error(`[HelpThreads] Create error: ${error.message}`)
51
+ console.error(`[Threads] Create error: ${error.message}`)
52
52
  reply.code(error.statusCode || 500)
53
53
  return { success: false, error: error.message }
54
54
  }
55
55
  })
56
56
 
57
57
  // Get thread detail
58
- fastify.get('/api/help-threads/:id', async (request, reply) => {
58
+ fastify.get('/api/threads/:id', async (request, reply) => {
59
59
  if (!verifyToken(request)) {
60
60
  reply.code(401)
61
61
  return { success: false, error: 'Unauthorized' }
62
62
  }
63
63
 
64
64
  try {
65
- const data = await api.getHelpThread(request.params.id)
65
+ const data = await api.getThread(request.params.id)
66
66
  return { success: true, thread: data.thread, messages: data.messages }
67
67
  } catch (error) {
68
- console.error(`[HelpThreads] Get error: ${error.message}`)
68
+ console.error(`[Threads] Get error: ${error.message}`)
69
69
  reply.code(error.statusCode || 500)
70
70
  return { success: false, error: error.message }
71
71
  }
72
72
  })
73
73
 
74
74
  // Post message
75
- fastify.post('/api/help-threads/:id/messages', async (request, reply) => {
75
+ fastify.post('/api/threads/:id/messages', async (request, reply) => {
76
76
  if (!verifyToken(request)) {
77
77
  reply.code(401)
78
78
  return { success: false, error: 'Unauthorized' }
79
79
  }
80
80
 
81
81
  try {
82
- const data = await api.postHelpMessage(request.params.id, request.body)
82
+ const data = await api.postThreadMessage(request.params.id, request.body)
83
83
  return { success: true, message: data.message }
84
84
  } catch (error) {
85
- console.error(`[HelpThreads] Message error: ${error.message}`)
85
+ console.error(`[Threads] Message error: ${error.message}`)
86
86
  reply.code(error.statusCode || 500)
87
87
  return { success: false, error: error.message }
88
88
  }
89
89
  })
90
90
 
91
91
  // Resolve thread
92
- fastify.post('/api/help-threads/:id/resolve', async (request, reply) => {
92
+ fastify.post('/api/threads/:id/resolve', async (request, reply) => {
93
93
  if (!verifyToken(request)) {
94
94
  reply.code(401)
95
95
  return { success: false, error: 'Unauthorized' }
@@ -97,34 +97,34 @@ async function helpThreadRoutes(fastify) {
97
97
 
98
98
  try {
99
99
  const { resolution } = request.body || {}
100
- const data = await api.resolveHelpThread(request.params.id, resolution)
100
+ const data = await api.resolveThread(request.params.id, resolution)
101
101
  return { success: true, thread: data.thread }
102
102
  } catch (error) {
103
- console.error(`[HelpThreads] Resolve error: ${error.message}`)
103
+ console.error(`[Threads] Resolve error: ${error.message}`)
104
104
  reply.code(error.statusCode || 500)
105
105
  return { success: false, error: error.message }
106
106
  }
107
107
  })
108
108
 
109
109
  // Permanently delete thread (PM only)
110
- fastify.delete('/api/help-threads/:id', async (request, reply) => {
110
+ fastify.delete('/api/threads/:id', async (request, reply) => {
111
111
  if (!verifyToken(request)) {
112
112
  reply.code(401)
113
113
  return { success: false, error: 'Unauthorized' }
114
114
  }
115
115
 
116
116
  try {
117
- const data = await api.deleteHelpThread(request.params.id)
117
+ const data = await api.deleteThread(request.params.id)
118
118
  return { success: true, deleted: data.deleted }
119
119
  } catch (error) {
120
- console.error(`[HelpThreads] Delete error: ${error.message}`)
120
+ console.error(`[Threads] Delete error: ${error.message}`)
121
121
  reply.code(error.statusCode || 500)
122
122
  return { success: false, error: error.message }
123
123
  }
124
124
  })
125
125
 
126
126
  // Cancel thread
127
- fastify.post('/api/help-threads/:id/cancel', async (request, reply) => {
127
+ fastify.post('/api/threads/:id/cancel', async (request, reply) => {
128
128
  if (!verifyToken(request)) {
129
129
  reply.code(401)
130
130
  return { success: false, error: 'Unauthorized' }
@@ -132,10 +132,10 @@ async function helpThreadRoutes(fastify) {
132
132
 
133
133
  try {
134
134
  const { reason } = request.body || {}
135
- const data = await api.cancelHelpThread(request.params.id, reason)
135
+ const data = await api.cancelThread(request.params.id, reason)
136
136
  return { success: true, thread: data.thread }
137
137
  } catch (error) {
138
- console.error(`[HelpThreads] Cancel error: ${error.message}`)
138
+ console.error(`[Threads] Cancel error: ${error.message}`)
139
139
  reply.code(error.statusCode || 500)
140
140
  return { success: false, error: error.message }
141
141
  }
@@ -186,4 +186,4 @@ async function helpThreadRoutes(fastify) {
186
186
  })
187
187
  }
188
188
 
189
- module.exports = { helpThreadRoutes }
189
+ module.exports = { threadRoutes }
@@ -1,113 +1,175 @@
1
1
  /**
2
- * Chat Session Store
3
- * Persists the active chat session (session_id + messages) to local JSON file.
4
- * One active session per minion. Claude CLI manages conversation context via --resume.
2
+ * Chat Session Store (SQLite)
3
+ * Persists chat sessions and messages.
4
+ * Supports multiple sessions: one active + archived past sessions.
5
+ * Claude CLI manages conversation context via --resume.
5
6
  */
6
7
 
7
- const fs = require('fs').promises
8
- const path = require('path')
9
-
10
- const { config } = require('../config')
11
- const { DATA_DIR } = require('../lib/platform')
8
+ const { getDb } = require('../db')
12
9
 
13
10
  const MAX_MESSAGES = 100
14
11
 
15
12
  /**
16
- * Get chat session file path
17
- * Uses DATA_DIR if available (platform-aware), otherwise home dir
13
+ * Load the active (most recent) chat session
14
+ * @returns {object|null} Session object or null if none exists
18
15
  */
19
- function getFilePath() {
20
- try {
21
- require('fs').accessSync(DATA_DIR)
22
- return path.join(DATA_DIR, 'chat-session.json')
23
- } catch {
24
- return path.join(config.HOME_DIR, 'chat-session.json')
16
+ function load() {
17
+ const db = getDb()
18
+ const session = db.prepare(
19
+ 'SELECT * FROM chat_sessions ORDER BY updated_at DESC LIMIT 1'
20
+ ).get()
21
+ if (!session) return null
22
+
23
+ const messages = db.prepare(
24
+ 'SELECT role, content, timestamp FROM chat_messages WHERE session_id = ? ORDER BY id'
25
+ ).all(session.session_id)
26
+
27
+ return {
28
+ session_id: session.session_id,
29
+ messages,
30
+ turn_count: session.turn_count,
31
+ created_at: session.created_at,
32
+ updated_at: session.updated_at,
25
33
  }
26
34
  }
27
35
 
28
- const SESSION_FILE = getFilePath()
29
-
30
36
  /**
31
- * Load the active chat session
32
- * @returns {Promise<object|null>} Session object or null if none exists
37
+ * Load a specific session by ID
38
+ * @param {string} sessionId
39
+ * @returns {object|null}
33
40
  */
34
- async function load() {
35
- try {
36
- const data = await fs.readFile(SESSION_FILE, 'utf-8')
37
- return JSON.parse(data)
38
- } catch (err) {
39
- if (err.code === 'ENOENT') {
40
- return null
41
- }
42
- console.error(`[ChatStore] Failed to load session: ${err.message}`)
43
- return null
41
+ function loadById(sessionId) {
42
+ const db = getDb()
43
+ const session = db.prepare('SELECT * FROM chat_sessions WHERE session_id = ?').get(sessionId)
44
+ if (!session) return null
45
+
46
+ const messages = db.prepare(
47
+ 'SELECT role, content, timestamp FROM chat_messages WHERE session_id = ? ORDER BY id'
48
+ ).all(session.session_id)
49
+
50
+ return {
51
+ session_id: session.session_id,
52
+ messages,
53
+ turn_count: session.turn_count,
54
+ created_at: session.created_at,
55
+ updated_at: session.updated_at,
44
56
  }
45
57
  }
46
58
 
47
59
  /**
48
- * Save the active chat session
60
+ * List all sessions (metadata only, no messages)
61
+ * @param {number} limit
62
+ * @returns {Array<{ session_id, turn_count, message_count, created_at, updated_at }>}
63
+ */
64
+ function listSessions(limit = 50) {
65
+ const db = getDb()
66
+ return db.prepare(`
67
+ SELECT
68
+ s.session_id,
69
+ s.turn_count,
70
+ s.created_at,
71
+ s.updated_at,
72
+ (SELECT COUNT(*) FROM chat_messages WHERE session_id = s.session_id) as message_count
73
+ FROM chat_sessions s
74
+ ORDER BY s.updated_at DESC
75
+ LIMIT ?
76
+ `).all(limit)
77
+ }
78
+
79
+ /**
80
+ * Save the active chat session (full replace of this session only)
49
81
  * @param {object} session - Session object
50
82
  */
51
- async function save(session) {
52
- try {
53
- await fs.writeFile(SESSION_FILE, JSON.stringify(session, null, 2), 'utf-8')
54
- } catch (err) {
55
- console.error(`[ChatStore] Failed to save session: ${err.message}`)
56
- }
83
+ function save(session) {
84
+ const db = getDb()
85
+ const tx = db.transaction(() => {
86
+ // Remove only this session (preserve others)
87
+ db.prepare('DELETE FROM chat_sessions WHERE session_id = ?').run(session.session_id)
88
+
89
+ // Insert session
90
+ db.prepare(
91
+ 'INSERT INTO chat_sessions (session_id, turn_count, created_at, updated_at) VALUES (?, ?, ?, ?)'
92
+ ).run(session.session_id, session.turn_count || 0, session.created_at, session.updated_at)
93
+
94
+ // Insert messages
95
+ const insertMsg = db.prepare(
96
+ 'INSERT INTO chat_messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)'
97
+ )
98
+ for (const msg of (session.messages || [])) {
99
+ insertMsg.run(session.session_id, msg.role, msg.content, msg.timestamp)
100
+ }
101
+ })
102
+ tx()
57
103
  }
58
104
 
59
105
  /**
60
- * Add a message to the active session
61
- * Creates a new session if none exists
106
+ * Add a message to the active session.
107
+ * Creates a new session if none exists or session_id changed.
108
+ * Past sessions are preserved (not deleted).
62
109
  * @param {string} sessionId - Claude CLI session ID
63
110
  * @param {{ role: string, content: string }} msg - Message to add
64
- * @param {number} [turnCount] - Optional turn count to update session with
111
+ * @param {number} [turnCount] - Optional turn count to add
65
112
  */
66
- async function addMessage(sessionId, msg, turnCount) {
67
- let session = await load()
68
-
69
- // If session_id changed, start a new session
70
- if (!session || session.session_id !== sessionId) {
71
- session = {
72
- session_id: sessionId,
73
- messages: [],
74
- turn_count: 0,
75
- created_at: Date.now(),
76
- updated_at: Date.now(),
77
- }
113
+ function addMessage(sessionId, msg, turnCount) {
114
+ const db = getDb()
115
+ const existing = db.prepare('SELECT * FROM chat_sessions WHERE session_id = ?').get(sessionId)
116
+
117
+ // If session doesn't exist, create a new one (past sessions remain in DB)
118
+ if (!existing) {
119
+ const now = Date.now()
120
+ db.prepare(
121
+ 'INSERT INTO chat_sessions (session_id, turn_count, created_at, updated_at) VALUES (?, ?, ?, ?)'
122
+ ).run(sessionId, 0, now, now)
78
123
  }
79
124
 
80
- session.messages.push({
81
- role: msg.role,
82
- content: msg.content,
83
- timestamp: Date.now(),
84
- })
85
- session.updated_at = Date.now()
125
+ // Insert message
126
+ db.prepare(
127
+ 'INSERT INTO chat_messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)'
128
+ ).run(sessionId, msg.role, msg.content, Date.now())
86
129
 
87
- // Update turn count if provided
88
- if (typeof turnCount === 'number' && turnCount > 0) {
89
- session.turn_count = (session.turn_count || 0) + turnCount
90
- }
130
+ // Update session metadata
131
+ const newTurnCount = (turnCount && turnCount > 0) ? turnCount : 0
132
+ db.prepare(`
133
+ UPDATE chat_sessions
134
+ SET updated_at = ?,
135
+ turn_count = turn_count + ?
136
+ WHERE session_id = ?
137
+ `).run(Date.now(), newTurnCount, sessionId)
91
138
 
92
- // Prune old messages
93
- if (session.messages.length > MAX_MESSAGES) {
94
- session.messages = session.messages.slice(-MAX_MESSAGES)
139
+ // Prune old messages for this session (keep only MAX_MESSAGES most recent)
140
+ const count = db.prepare('SELECT COUNT(*) as cnt FROM chat_messages WHERE session_id = ?').get(sessionId).cnt
141
+ if (count > MAX_MESSAGES) {
142
+ db.prepare(`
143
+ DELETE FROM chat_messages WHERE id IN (
144
+ SELECT id FROM chat_messages WHERE session_id = ?
145
+ ORDER BY id ASC LIMIT ?
146
+ )
147
+ `).run(sessionId, count - MAX_MESSAGES)
95
148
  }
96
-
97
- await save(session)
98
149
  }
99
150
 
100
151
  /**
101
- * Clear the active session
152
+ * Clear the active session (remove from DB).
153
+ * This deletes only the most recent session.
154
+ * For reset flows, use clearActive() which preserves the session in history.
102
155
  */
103
- async function clear() {
104
- try {
105
- await fs.unlink(SESSION_FILE)
106
- } catch (err) {
107
- if (err.code !== 'ENOENT') {
108
- console.error(`[ChatStore] Failed to clear session: ${err.message}`)
109
- }
156
+ function clear() {
157
+ const db = getDb()
158
+ const active = db.prepare('SELECT session_id FROM chat_sessions ORDER BY updated_at DESC LIMIT 1').get()
159
+ if (active) {
160
+ db.prepare('DELETE FROM chat_sessions WHERE session_id = ?').run(active.session_id)
110
161
  }
111
162
  }
112
163
 
113
- module.exports = { load, save, addMessage, clear }
164
+ /**
165
+ * Delete a specific session by ID
166
+ * @param {string} sessionId
167
+ * @returns {boolean}
168
+ */
169
+ function deleteSession(sessionId) {
170
+ const db = getDb()
171
+ const result = db.prepare('DELETE FROM chat_sessions WHERE session_id = ?').run(sessionId)
172
+ return result.changes > 0
173
+ }
174
+
175
+ module.exports = { load, loadById, listSessions, save, addMessage, clear, deleteSession }