@geekbeer/minion 2.70.2 → 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,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 }
@@ -1,38 +1,11 @@
1
1
  /**
2
- * Daily Log Store
3
- * Stores daily conversation summaries as markdown files.
4
- * One file per day: $DATA_DIR/daily-logs/YYYY-MM-DD.md
5
- *
6
- * These logs serve as short-term memory (recent days),
7
- * bridging working memory (chat session) and long-term memory (memory-store).
2
+ * Daily Log Store (SQLite)
3
+ * Stores daily conversation summaries.
4
+ * One entry per day, keyed by YYYY-MM-DD date string.
5
+ * Supports full-text search via FTS5.
8
6
  */
9
7
 
10
- const fs = require('fs').promises
11
- const path = require('path')
12
-
13
- const { config } = require('../config')
14
- const { DATA_DIR } = require('../lib/platform')
15
-
16
- /**
17
- * Resolve daily logs directory path
18
- */
19
- function getLogsDir() {
20
- try {
21
- require('fs').accessSync(DATA_DIR)
22
- return path.join(DATA_DIR, 'daily-logs')
23
- } catch {
24
- return path.join(config.HOME_DIR, 'daily-logs')
25
- }
26
- }
27
-
28
- const LOGS_DIR = getLogsDir()
29
-
30
- /**
31
- * Ensure the daily-logs directory exists
32
- */
33
- async function ensureDir() {
34
- await fs.mkdir(LOGS_DIR, { recursive: true })
35
- }
8
+ const { getDb } = require('../db')
36
9
 
37
10
  /**
38
11
  * Validate date string format (YYYY-MM-DD)
@@ -42,114 +15,122 @@ function isValidDate(date) {
42
15
  }
43
16
 
44
17
  /**
45
- * List all daily logs with date, file size, and timestamps.
46
- * @returns {Promise<Array<{ date: string, size: number, created_at: string, updated_at: string }>>} Sorted descending by date
18
+ * List all daily logs with date, content size, and timestamps.
19
+ * @returns {Array<{ date, size, created_at, updated_at }>} Sorted descending by date
47
20
  */
48
- async function listLogs() {
49
- await ensureDir()
50
- let files
51
- try {
52
- files = await fs.readdir(LOGS_DIR)
53
- } catch {
54
- return []
55
- }
56
-
57
- const logs = []
58
- for (const file of files) {
59
- if (!file.endsWith('.md')) continue
60
- const date = file.replace('.md', '')
61
- if (!isValidDate(date)) continue
62
- try {
63
- const stat = await fs.stat(path.join(LOGS_DIR, file))
64
- logs.push({
65
- date,
66
- size: stat.size,
67
- created_at: stat.birthtime.toISOString(),
68
- updated_at: stat.mtime.toISOString(),
69
- })
70
- } catch {
71
- // Skip unreadable
72
- }
73
- }
74
-
75
- // Sort by date descending (most recent first)
76
- logs.sort((a, b) => b.date.localeCompare(a.date))
77
- return logs
21
+ function listLogs() {
22
+ const db = getDb()
23
+ const rows = db.prepare(`
24
+ SELECT date, length(content) as size, created_at, updated_at
25
+ FROM daily_logs
26
+ ORDER BY date DESC
27
+ `).all()
28
+ return rows
78
29
  }
79
30
 
80
31
  /**
81
32
  * Load a specific day's log.
82
33
  * @param {string} date - YYYY-MM-DD format
83
- * @returns {Promise<string|null>} Log content or null
34
+ * @returns {string|null} Log content or null
84
35
  */
85
- async function loadLog(date) {
36
+ function loadLog(date) {
86
37
  if (!isValidDate(date)) return null
87
- try {
88
- return await fs.readFile(path.join(LOGS_DIR, `${date}.md`), 'utf-8')
89
- } catch (err) {
90
- if (err.code === 'ENOENT') return null
91
- console.error(`[DailyLogStore] Failed to load log ${date}: ${err.message}`)
92
- return null
93
- }
38
+ const db = getDb()
39
+ const row = db.prepare('SELECT content FROM daily_logs WHERE date = ?').get(date)
40
+ return row ? row.content : null
94
41
  }
95
42
 
96
43
  /**
97
- * Save a daily log. Overwrites existing log for the same date.
44
+ * Save a daily log. Upserts (overwrites existing log for the same date).
98
45
  * @param {string} date - YYYY-MM-DD format
99
46
  * @param {string} content - Markdown content
100
47
  */
101
- async function saveLog(date, content) {
48
+ function saveLog(date, content) {
102
49
  if (!isValidDate(date)) {
103
50
  throw new Error(`Invalid date format: ${date}. Expected YYYY-MM-DD.`)
104
51
  }
105
- await ensureDir()
106
- await fs.writeFile(path.join(LOGS_DIR, `${date}.md`), content, 'utf-8')
52
+ const db = getDb()
53
+ const now = new Date().toISOString()
54
+ const existing = db.prepare('SELECT date FROM daily_logs WHERE date = ?').get(date)
55
+
56
+ if (existing) {
57
+ db.prepare('UPDATE daily_logs SET content = ?, updated_at = ? WHERE date = ?').run(content, now, date)
58
+ } else {
59
+ db.prepare('INSERT INTO daily_logs (date, content, created_at, updated_at) VALUES (?, ?, ?, ?)').run(
60
+ date, content, now, now
61
+ )
62
+ }
107
63
  }
108
64
 
109
65
  /**
110
66
  * Delete a daily log.
111
67
  * @param {string} date - YYYY-MM-DD format
112
- * @returns {Promise<boolean>}
68
+ * @returns {boolean}
113
69
  */
114
- async function deleteLog(date) {
70
+ function deleteLog(date) {
115
71
  if (!isValidDate(date)) return false
116
- try {
117
- await fs.unlink(path.join(LOGS_DIR, `${date}.md`))
118
- return true
119
- } catch (err) {
120
- if (err.code === 'ENOENT') return false
121
- console.error(`[DailyLogStore] Failed to delete log ${date}: ${err.message}`)
122
- return false
123
- }
72
+ const db = getDb()
73
+ const result = db.prepare('DELETE FROM daily_logs WHERE date = ?').run(date)
74
+ return result.changes > 0
124
75
  }
125
76
 
126
77
  /**
127
78
  * Get the most recent N days of logs.
128
79
  * @param {number} days - Number of recent days to fetch
129
- * @returns {Promise<Array<{ date: string, content: string }>>}
80
+ * @returns {Array<{ date, content }>}
130
81
  */
131
- async function getRecentLogs(days = 3) {
132
- const all = await listLogs()
133
- const recent = all.slice(0, days)
134
-
135
- const results = []
136
- for (const { date } of recent) {
137
- const content = await loadLog(date)
138
- if (content) {
139
- results.push({ date, content })
140
- }
82
+ function getRecentLogs(days = 3) {
83
+ const db = getDb()
84
+ const rows = db.prepare('SELECT date, content FROM daily_logs ORDER BY date DESC LIMIT ?').all(days)
85
+ return rows
86
+ }
87
+
88
+ /**
89
+ * Search daily logs 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<{ date, content, created_at, updated_at }>}
94
+ */
95
+ function searchLogs(query, limit = 20) {
96
+ const db = getDb()
97
+ const terms = query.split(/\s+/).filter(Boolean)
98
+ if (terms.length === 0) return []
99
+
100
+ const hasShortTerm = terms.some(t => [...t].length < 3)
101
+
102
+ if (hasShortTerm) {
103
+ const conditions = terms.map(() => 'content LIKE ?').join(' AND ')
104
+ const params = terms.map(t => `%${t}%`)
105
+ params.push(limit)
106
+ return db.prepare(`
107
+ SELECT date, content, created_at, updated_at
108
+ FROM daily_logs
109
+ WHERE ${conditions}
110
+ ORDER BY date DESC
111
+ LIMIT ?
112
+ `).all(...params)
141
113
  }
142
- return results
114
+
115
+ const sanitized = terms.map(t => `"${t.replace(/"/g, '')}"`).join(' ')
116
+ return db.prepare(`
117
+ SELECT d.date, d.content, d.created_at, d.updated_at
118
+ FROM daily_logs d
119
+ JOIN daily_logs_fts f ON d.rowid = f.rowid
120
+ WHERE daily_logs_fts MATCH ?
121
+ ORDER BY rank
122
+ LIMIT ?
123
+ `).all(sanitized, limit)
143
124
  }
144
125
 
145
126
  /**
146
127
  * Get a context snippet of recent daily logs for chat injection.
147
128
  * @param {number} days - Number of days to include
148
129
  * @param {number} maxChars - Maximum total characters
149
- * @returns {Promise<string>}
130
+ * @returns {string}
150
131
  */
151
- async function getContextSnippet(days = 3, maxChars = 1500) {
152
- const logs = await getRecentLogs(days)
132
+ function getContextSnippet(days = 3, maxChars = 1500) {
133
+ const logs = getRecentLogs(days)
153
134
  if (logs.length === 0) return ''
154
135
 
155
136
  const parts = []
@@ -161,7 +142,6 @@ async function getContextSnippet(days = 3, maxChars = 1500) {
161
142
  const section = `${header}\n${content}`
162
143
 
163
144
  if (totalLen + section.length > maxChars) {
164
- // Add truncated version
165
145
  const remaining = maxChars - totalLen
166
146
  if (remaining > 50) {
167
147
  parts.push(section.substring(0, remaining) + '...')
@@ -176,12 +156,11 @@ async function getContextSnippet(days = 3, maxChars = 1500) {
176
156
  }
177
157
 
178
158
  module.exports = {
179
- ensureDir,
180
159
  listLogs,
181
160
  loadLog,
182
161
  saveLog,
183
162
  deleteLog,
184
163
  getRecentLogs,
164
+ searchLogs,
185
165
  getContextSnippet,
186
- LOGS_DIR,
187
166
  }