@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.
- package/core/config.js +7 -0
- package/core/lib/capability-checker.js +105 -0
- package/core/lib/end-of-day.js +113 -0
- package/core/lib/reflection-scheduler.js +157 -0
- package/core/lib/step-poller.js +4 -0
- package/core/routes/daily-logs.js +122 -0
- package/core/routes/health.js +2 -0
- package/core/routes/memory.js +112 -0
- package/core/routes/skills.js +2 -1
- package/core/routes/variables.js +131 -0
- package/core/stores/daily-log-store.js +182 -0
- package/core/stores/memory-store.js +256 -0
- package/core/stores/variable-store.js +152 -0
- package/docs/api-reference.md +128 -0
- package/linux/minion-cli.sh +6 -0
- package/linux/routes/chat.js +54 -14
- package/linux/routes/config.js +11 -1
- package/linux/routine-runner.js +7 -0
- package/linux/server.js +19 -5
- package/linux/workflow-runner.js +7 -0
- package/package.json +1 -1
- package/win/lib/process-manager.js +85 -8
- package/win/routes/chat.js +53 -10
- package/win/routes/config.js +12 -1
- package/win/routine-runner.js +4 -0
- package/win/server.js +16 -4
- package/win/workflow-runner.js +4 -1
|
@@ -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
|
+
}
|