@geekbeer/minion 2.44.0 → 2.48.1

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.
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Variables & Secrets routes (shared between Linux and Windows)
3
+ *
4
+ * Minion Variables:
5
+ * GET /api/variables - List all variables (key + value)
6
+ * GET /api/variables/:key - Get a single variable
7
+ * PUT /api/variables/:key - Set a variable
8
+ * DELETE /api/variables/:key - Delete a variable
9
+ *
10
+ * Minion Secrets:
11
+ * GET /api/secrets - List secret keys only (no values)
12
+ * PUT /api/secrets/:key - Set a secret
13
+ * DELETE /api/secrets/:key - Delete a secret
14
+ *
15
+ * All endpoints require Bearer token authentication.
16
+ */
17
+
18
+ const variableStore = require('../stores/variable-store')
19
+ const { verifyToken } = require('../lib/auth')
20
+
21
+ /** Validate a key name: alphanumeric + underscores, 1-100 chars */
22
+ function isValidKey(key) {
23
+ return /^[A-Za-z_][A-Za-z0-9_]{0,99}$/.test(key)
24
+ }
25
+
26
+ /** Validate a value: no newlines, max 2000 chars */
27
+ function isValidValue(value) {
28
+ if (typeof value !== 'string') return false
29
+ if (value.includes('\n') || value.includes('\r')) return false
30
+ if (value.length > 2000) return false
31
+ return true
32
+ }
33
+
34
+ function variableRoutes(fastify, _opts, done) {
35
+ // ─── Variables (non-sensitive) ───────────────────────────────────────
36
+
37
+ fastify.get('/api/variables', async (request, reply) => {
38
+ if (!verifyToken(request)) {
39
+ return reply.code(401).send({ error: 'Unauthorized' })
40
+ }
41
+ const variables = variableStore.getAll('variables')
42
+ return { success: true, variables }
43
+ })
44
+
45
+ fastify.get('/api/variables/:key', async (request, reply) => {
46
+ if (!verifyToken(request)) {
47
+ return reply.code(401).send({ error: 'Unauthorized' })
48
+ }
49
+ const { key } = request.params
50
+ const value = variableStore.get('variables', key)
51
+ if (value === null) {
52
+ return reply.code(404).send({ error: `Variable not found: ${key}` })
53
+ }
54
+ return { success: true, key, value }
55
+ })
56
+
57
+ fastify.put('/api/variables/:key', async (request, reply) => {
58
+ if (!verifyToken(request)) {
59
+ return reply.code(401).send({ error: 'Unauthorized' })
60
+ }
61
+ const { key } = request.params
62
+ const { value } = request.body || {}
63
+
64
+ if (!isValidKey(key)) {
65
+ return reply.code(400).send({ error: 'Invalid key. Use alphanumeric characters and underscores (1-100 chars).' })
66
+ }
67
+ if (!isValidValue(value)) {
68
+ return reply.code(400).send({ error: 'Invalid value. Must be a string, no newlines, max 2000 chars.' })
69
+ }
70
+
71
+ variableStore.set('variables', key, value)
72
+ return { success: true, key, value }
73
+ })
74
+
75
+ fastify.delete('/api/variables/:key', async (request, reply) => {
76
+ if (!verifyToken(request)) {
77
+ return reply.code(401).send({ error: 'Unauthorized' })
78
+ }
79
+ const { key } = request.params
80
+ const removed = variableStore.remove('variables', key)
81
+ if (!removed) {
82
+ return reply.code(404).send({ error: `Variable not found: ${key}` })
83
+ }
84
+ return { success: true, key }
85
+ })
86
+
87
+ // ─── Secrets (sensitive) ─────────────────────────────────────────────
88
+
89
+ fastify.get('/api/secrets', async (request, reply) => {
90
+ if (!verifyToken(request)) {
91
+ return reply.code(401).send({ error: 'Unauthorized' })
92
+ }
93
+ // Return keys only — never expose secret values via API
94
+ const keys = variableStore.listKeys('secrets')
95
+ return { success: true, keys }
96
+ })
97
+
98
+ fastify.put('/api/secrets/:key', async (request, reply) => {
99
+ if (!verifyToken(request)) {
100
+ return reply.code(401).send({ error: 'Unauthorized' })
101
+ }
102
+ const { key } = request.params
103
+ const { value } = request.body || {}
104
+
105
+ if (!isValidKey(key)) {
106
+ return reply.code(400).send({ error: 'Invalid key. Use alphanumeric characters and underscores (1-100 chars).' })
107
+ }
108
+ if (!isValidValue(value)) {
109
+ return reply.code(400).send({ error: 'Invalid value. Must be a string, no newlines, max 2000 chars.' })
110
+ }
111
+
112
+ variableStore.set('secrets', key, value)
113
+ return { success: true, key }
114
+ })
115
+
116
+ fastify.delete('/api/secrets/:key', async (request, reply) => {
117
+ if (!verifyToken(request)) {
118
+ return reply.code(401).send({ error: 'Unauthorized' })
119
+ }
120
+ const { key } = request.params
121
+ const removed = variableStore.remove('secrets', key)
122
+ if (!removed) {
123
+ return reply.code(404).send({ error: `Secret not found: ${key}` })
124
+ }
125
+ return { success: true, key }
126
+ })
127
+
128
+ done()
129
+ }
130
+
131
+ module.exports = { variableRoutes }
@@ -0,0 +1,182 @@
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).
8
+ */
9
+
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
+ }
36
+
37
+ /**
38
+ * Validate date string format (YYYY-MM-DD)
39
+ */
40
+ function isValidDate(date) {
41
+ return /^\d{4}-\d{2}-\d{2}$/.test(date)
42
+ }
43
+
44
+ /**
45
+ * List all daily logs with date and file size.
46
+ * @returns {Promise<Array<{ date: string, size: number }>>} Sorted descending by date
47
+ */
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({ date, size: stat.size })
65
+ } catch {
66
+ // Skip unreadable
67
+ }
68
+ }
69
+
70
+ // Sort by date descending (most recent first)
71
+ logs.sort((a, b) => b.date.localeCompare(a.date))
72
+ return logs
73
+ }
74
+
75
+ /**
76
+ * Load a specific day's log.
77
+ * @param {string} date - YYYY-MM-DD format
78
+ * @returns {Promise<string|null>} Log content or null
79
+ */
80
+ async function loadLog(date) {
81
+ if (!isValidDate(date)) return null
82
+ try {
83
+ return await fs.readFile(path.join(LOGS_DIR, `${date}.md`), 'utf-8')
84
+ } catch (err) {
85
+ if (err.code === 'ENOENT') return null
86
+ console.error(`[DailyLogStore] Failed to load log ${date}: ${err.message}`)
87
+ return null
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Save a daily log. Overwrites existing log for the same date.
93
+ * @param {string} date - YYYY-MM-DD format
94
+ * @param {string} content - Markdown content
95
+ */
96
+ async function saveLog(date, content) {
97
+ if (!isValidDate(date)) {
98
+ throw new Error(`Invalid date format: ${date}. Expected YYYY-MM-DD.`)
99
+ }
100
+ await ensureDir()
101
+ await fs.writeFile(path.join(LOGS_DIR, `${date}.md`), content, 'utf-8')
102
+ }
103
+
104
+ /**
105
+ * Delete a daily log.
106
+ * @param {string} date - YYYY-MM-DD format
107
+ * @returns {Promise<boolean>}
108
+ */
109
+ async function deleteLog(date) {
110
+ if (!isValidDate(date)) return false
111
+ try {
112
+ await fs.unlink(path.join(LOGS_DIR, `${date}.md`))
113
+ return true
114
+ } catch (err) {
115
+ if (err.code === 'ENOENT') return false
116
+ console.error(`[DailyLogStore] Failed to delete log ${date}: ${err.message}`)
117
+ return false
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Get the most recent N days of logs.
123
+ * @param {number} days - Number of recent days to fetch
124
+ * @returns {Promise<Array<{ date: string, content: string }>>}
125
+ */
126
+ async function getRecentLogs(days = 3) {
127
+ const all = await listLogs()
128
+ const recent = all.slice(0, days)
129
+
130
+ const results = []
131
+ for (const { date } of recent) {
132
+ const content = await loadLog(date)
133
+ if (content) {
134
+ results.push({ date, content })
135
+ }
136
+ }
137
+ return results
138
+ }
139
+
140
+ /**
141
+ * Get a context snippet of recent daily logs for chat injection.
142
+ * @param {number} days - Number of days to include
143
+ * @param {number} maxChars - Maximum total characters
144
+ * @returns {Promise<string>}
145
+ */
146
+ async function getContextSnippet(days = 3, maxChars = 1500) {
147
+ const logs = await getRecentLogs(days)
148
+ if (logs.length === 0) return ''
149
+
150
+ const parts = []
151
+ let totalLen = 0
152
+
153
+ for (const log of logs) {
154
+ const header = `### ${log.date}`
155
+ const content = log.content.substring(0, Math.floor(maxChars / days))
156
+ const section = `${header}\n${content}`
157
+
158
+ if (totalLen + section.length > maxChars) {
159
+ // Add truncated version
160
+ const remaining = maxChars - totalLen
161
+ if (remaining > 50) {
162
+ parts.push(section.substring(0, remaining) + '...')
163
+ }
164
+ break
165
+ }
166
+ parts.push(section)
167
+ totalLen += section.length + 2
168
+ }
169
+
170
+ return parts.join('\n\n')
171
+ }
172
+
173
+ module.exports = {
174
+ ensureDir,
175
+ listLogs,
176
+ loadLog,
177
+ saveLog,
178
+ deleteLog,
179
+ getRecentLogs,
180
+ getContextSnippet,
181
+ LOGS_DIR,
182
+ }
@@ -0,0 +1,256 @@
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.
6
+ *
7
+ * Storage layout:
8
+ * $DATA_DIR/memory/
9
+ * ├── MEMORY.md # Index (auto-generated)
10
+ * └── {id}.md # Individual entries
11
+ */
12
+
13
+ const fs = require('fs').promises
14
+ const path = require('path')
15
+ const crypto = require('crypto')
16
+
17
+ const { config } = require('../config')
18
+ const { DATA_DIR } = require('../lib/platform')
19
+
20
+ const VALID_CATEGORIES = ['user', 'feedback', 'project', 'reference']
21
+
22
+ /**
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
65
+ */
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
118
+ }
119
+
120
+ /**
121
+ * Load a single memory entry by ID.
122
+ * @param {string} id
123
+ * @returns {Promise<object|null>}
124
+ */
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
+ }
142
+ }
143
+
144
+ /**
145
+ * Save (create or update) a memory entry.
146
+ * @param {{ id?, title, category, content }} entry
147
+ * @returns {Promise<object>} The saved entry
148
+ */
149
+ async function saveEntry(entry) {
150
+ await ensureDir()
151
+
152
+ const now = new Date().toISOString()
153
+ const id = entry.id || crypto.randomBytes(6).toString('hex')
154
+ const category = VALID_CATEGORIES.includes(entry.category) ? entry.category : 'reference'
155
+
156
+ // Check if existing
157
+ const existing = await loadEntry(id)
158
+
159
+ const full = {
160
+ id,
161
+ title: entry.title || 'Untitled',
162
+ category,
163
+ content: entry.content || '',
164
+ created_at: existing ? existing.created_at : now,
165
+ updated_at: now,
166
+ }
167
+
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()
173
+
174
+ return full
175
+ }
176
+
177
+ /**
178
+ * Delete a memory entry.
179
+ * @param {string} id
180
+ * @returns {Promise<boolean>}
181
+ */
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
+ }
192
+ }
193
+
194
+ /**
195
+ * Regenerate MEMORY.md index file from all entries.
196
+ */
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
+ }
220
+ }
221
+
222
+ await fs.writeFile(INDEX_FILE, lines.join('\n'), 'utf-8')
223
+ }
224
+
225
+ /**
226
+ * Get a context snippet suitable for chat injection.
227
+ * Returns the most important memory entries as a formatted string.
228
+ * @param {number} maxChars - Maximum characters to return
229
+ * @returns {Promise<string>}
230
+ */
231
+ async function getContextSnippet(maxChars = 2000) {
232
+ const entries = await listEntries()
233
+ if (entries.length === 0) return ''
234
+
235
+ const parts = []
236
+ let totalLen = 0
237
+
238
+ for (const e of entries) {
239
+ const line = `[${e.category}] ${e.title}: ${e.excerpt}`
240
+ if (totalLen + line.length > maxChars) break
241
+ parts.push(line)
242
+ totalLen += line.length + 1
243
+ }
244
+
245
+ return parts.join('\n')
246
+ }
247
+
248
+ module.exports = {
249
+ ensureDir,
250
+ listEntries,
251
+ loadEntry,
252
+ saveEntry,
253
+ deleteEntry,
254
+ getContextSnippet,
255
+ MEMORY_DIR,
256
+ }