@geekbeer/minion 2.46.1 → 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/end-of-day.js +113 -0
- package/core/lib/reflection-scheduler.js +157 -0
- package/core/routes/daily-logs.js +122 -0
- package/core/routes/memory.js +112 -0
- package/core/stores/daily-log-store.js +182 -0
- package/core/stores/memory-store.js +256 -0
- package/docs/api-reference.md +128 -0
- package/linux/minion-cli.sh +6 -0
- package/linux/routes/chat.js +48 -11
- package/linux/routes/config.js +11 -1
- package/linux/server.js +11 -2
- package/package.json +1 -1
- package/win/lib/process-manager.js +85 -8
- package/win/routes/chat.js +47 -8
- package/win/routes/config.js +12 -1
- package/win/server.js +10 -1
package/core/config.js
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* Other optional environment variables:
|
|
13
13
|
* - AGENT_PORT: Port for the local agent server (default: 8080)
|
|
14
14
|
* - MINION_USER: System user running the agent (used to resolve home directory)
|
|
15
|
+
* - REFLECTION_TIME: Daily self-reflection time in HH:MM format (e.g., "03:00")
|
|
16
|
+
* - TIMEZONE: Timezone for reflection schedule (default: "Asia/Tokyo")
|
|
15
17
|
*/
|
|
16
18
|
|
|
17
19
|
const os = require('os')
|
|
@@ -87,6 +89,11 @@ const config = {
|
|
|
87
89
|
// LLM_COMMAND="claude -p '{prompt}'"
|
|
88
90
|
// LLM_COMMAND="gemini-cli --prompt '{prompt}'"
|
|
89
91
|
LLM_COMMAND: process.env.LLM_COMMAND || '',
|
|
92
|
+
|
|
93
|
+
// Self-reflection schedule (自己反省時間)
|
|
94
|
+
// Daily time to automatically run end-of-day processing (log + memory extraction + session clear)
|
|
95
|
+
REFLECTION_TIME: process.env.REFLECTION_TIME ?? '03:00', // "HH:MM" format, empty = disabled
|
|
96
|
+
TIMEZONE: process.env.TIMEZONE || 'Asia/Tokyo',
|
|
90
97
|
}
|
|
91
98
|
|
|
92
99
|
/**
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared end-of-day processing logic
|
|
3
|
+
*
|
|
4
|
+
* Extracts daily conversation summary and long-term learnings from the chat session.
|
|
5
|
+
* Used by both the chat route (manual trigger) and the reflection scheduler (automatic).
|
|
6
|
+
*
|
|
7
|
+
* @module core/lib/end-of-day
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const chatStore = require('../stores/chat-store')
|
|
11
|
+
const memoryStore = require('../stores/memory-store')
|
|
12
|
+
const dailyLogStore = require('../stores/daily-log-store')
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Run end-of-day processing: generate daily log + extract memories from conversation.
|
|
16
|
+
*
|
|
17
|
+
* @param {Object} opts
|
|
18
|
+
* @param {(prompt: string) => Promise<string>} opts.runQuickLlmCall - Platform-specific LLM call function
|
|
19
|
+
* @param {boolean} [opts.clearSession=false] - Whether to clear the chat session after processing
|
|
20
|
+
* @returns {Promise<{ daily_log: string|null, memory_entries_added: number }>}
|
|
21
|
+
*/
|
|
22
|
+
async function runEndOfDay({ runQuickLlmCall, clearSession = false }) {
|
|
23
|
+
const session = await chatStore.load()
|
|
24
|
+
if (!session || session.messages.length === 0) {
|
|
25
|
+
return { daily_log: null, memory_entries_added: 0 }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Build conversation text for summarization (last 50 messages)
|
|
29
|
+
const messages = session.messages.slice(-50)
|
|
30
|
+
const conversationText = messages
|
|
31
|
+
.map(m => `${m.role === 'user' ? 'ユーザー' : 'アシスタント'}: ${m.content.substring(0, 500)}`)
|
|
32
|
+
.join('\n\n')
|
|
33
|
+
|
|
34
|
+
const endOfDayPrompt = `以下の会話を分析して、JSON形式で2つの情報を出力してください。
|
|
35
|
+
|
|
36
|
+
1. daily_summary: 今日の会話の要約(マークダウン形式)。以下を含めてください:
|
|
37
|
+
- 今日やったこと・話したこと
|
|
38
|
+
- 未解決の課題・明日への申し送り
|
|
39
|
+
- 重要な決定事項
|
|
40
|
+
|
|
41
|
+
2. learnings: 長期的に記憶すべき学び(配列)。各要素は以下の形式:
|
|
42
|
+
- title: 短いタイトル
|
|
43
|
+
- category: user(ユーザーの好み)/ feedback(フィードバック)/ project(プロジェクト情報)/ reference(参照情報)のいずれか
|
|
44
|
+
- content: 詳細な内容
|
|
45
|
+
|
|
46
|
+
学びがない場合は空配列にしてください。すでに常識的なことは含めないでください。
|
|
47
|
+
|
|
48
|
+
会話:
|
|
49
|
+
${conversationText}
|
|
50
|
+
|
|
51
|
+
JSON形式のみで回答してください(\`\`\`jsonブロックは不要):`
|
|
52
|
+
|
|
53
|
+
const today = new Date().toISOString().split('T')[0]
|
|
54
|
+
let dailyLog = null
|
|
55
|
+
let memoriesAdded = 0
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const result = await runQuickLlmCall(endOfDayPrompt)
|
|
59
|
+
|
|
60
|
+
// Parse JSON response
|
|
61
|
+
let parsed
|
|
62
|
+
try {
|
|
63
|
+
// Try to extract JSON from the response (may be wrapped in markdown code blocks)
|
|
64
|
+
const jsonMatch = result.match(/\{[\s\S]*\}/)
|
|
65
|
+
parsed = jsonMatch ? JSON.parse(jsonMatch[0]) : JSON.parse(result)
|
|
66
|
+
} catch {
|
|
67
|
+
// Fallback: use raw result as daily summary
|
|
68
|
+
parsed = { daily_summary: result, learnings: [] }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Save daily log
|
|
72
|
+
if (parsed.daily_summary) {
|
|
73
|
+
dailyLog = parsed.daily_summary
|
|
74
|
+
await dailyLogStore.saveLog(today, dailyLog)
|
|
75
|
+
console.log(`[EndOfDay] Saved daily log for ${today}`)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Save learnings to memory
|
|
79
|
+
if (Array.isArray(parsed.learnings)) {
|
|
80
|
+
for (const learning of parsed.learnings) {
|
|
81
|
+
if (learning.title && learning.content) {
|
|
82
|
+
await memoryStore.saveEntry({
|
|
83
|
+
title: learning.title,
|
|
84
|
+
category: learning.category || 'reference',
|
|
85
|
+
content: learning.content,
|
|
86
|
+
})
|
|
87
|
+
memoriesAdded++
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (memoriesAdded > 0) {
|
|
91
|
+
console.log(`[EndOfDay] Saved ${memoriesAdded} memory entries`)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
} catch (err) {
|
|
95
|
+
console.error('[EndOfDay] Summarization failed:', err.message)
|
|
96
|
+
|
|
97
|
+
// Fallback: save raw conversation summary as daily log
|
|
98
|
+
const fallback = messages.slice(-8)
|
|
99
|
+
.map(m => `**${m.role === 'user' ? 'ユーザー' : 'アシスタント'}**: ${m.content.substring(0, 300)}`)
|
|
100
|
+
.join('\n\n')
|
|
101
|
+
dailyLog = `# ${today} 会話ログ\n\n${fallback}`
|
|
102
|
+
await dailyLogStore.saveLog(today, dailyLog)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (clearSession) {
|
|
106
|
+
await chatStore.clear()
|
|
107
|
+
console.log('[EndOfDay] Session cleared')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { daily_log: today, memory_entries_added: memoriesAdded }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = { runEndOfDay }
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reflection Scheduler (自己反省スケジューラ)
|
|
3
|
+
*
|
|
4
|
+
* Built-in server-level scheduler that triggers end-of-day processing
|
|
5
|
+
* (daily log generation + memory extraction + session clear) at a configured time.
|
|
6
|
+
*
|
|
7
|
+
* Unlike routines (user-deletable), this is a core architectural feature
|
|
8
|
+
* that ensures the minion regularly consolidates its conversation history.
|
|
9
|
+
*
|
|
10
|
+
* Configuration:
|
|
11
|
+
* REFLECTION_TIME - Time to run daily reflection (format: "HH:MM", e.g., "03:00")
|
|
12
|
+
* TIMEZONE - Timezone for the schedule (default: "Asia/Tokyo")
|
|
13
|
+
*
|
|
14
|
+
* @module core/lib/reflection-scheduler
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const { Cron } = require('croner')
|
|
18
|
+
const { config } = require('../config')
|
|
19
|
+
const { runEndOfDay } = require('./end-of-day')
|
|
20
|
+
|
|
21
|
+
/** @type {import('croner').Cron | null} */
|
|
22
|
+
let cronJob = null
|
|
23
|
+
|
|
24
|
+
/** @type {boolean} */
|
|
25
|
+
let isRunning = false
|
|
26
|
+
|
|
27
|
+
/** @type {((prompt: string) => Promise<string>) | null} */
|
|
28
|
+
let llmCallFn = null
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Parse "HH:MM" into a cron expression "0 MM HH * * *"
|
|
32
|
+
* @param {string} timeStr - Time in "HH:MM" format
|
|
33
|
+
* @returns {string|null} Cron expression or null if invalid
|
|
34
|
+
*/
|
|
35
|
+
function parseToCron(timeStr) {
|
|
36
|
+
if (!timeStr || typeof timeStr !== 'string') return null
|
|
37
|
+
const match = timeStr.match(/^(\d{1,2}):(\d{2})$/)
|
|
38
|
+
if (!match) return null
|
|
39
|
+
const hour = parseInt(match[1], 10)
|
|
40
|
+
const minute = parseInt(match[2], 10)
|
|
41
|
+
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null
|
|
42
|
+
return `0 ${minute} ${hour} * * *`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Start the reflection scheduler.
|
|
47
|
+
* If REFLECTION_TIME is not configured, the scheduler remains inactive.
|
|
48
|
+
*
|
|
49
|
+
* @param {(prompt: string) => Promise<string>} runQuickLlmCall - Platform-specific LLM call function
|
|
50
|
+
*/
|
|
51
|
+
function start(runQuickLlmCall) {
|
|
52
|
+
llmCallFn = runQuickLlmCall
|
|
53
|
+
|
|
54
|
+
const reflectionTime = config.REFLECTION_TIME
|
|
55
|
+
if (!reflectionTime) {
|
|
56
|
+
console.log('[ReflectionScheduler] REFLECTION_TIME not configured, scheduler inactive')
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const cronExpr = parseToCron(reflectionTime)
|
|
61
|
+
if (!cronExpr) {
|
|
62
|
+
console.error(`[ReflectionScheduler] Invalid REFLECTION_TIME: "${reflectionTime}" (expected HH:MM)`)
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const timezone = config.TIMEZONE || 'Asia/Tokyo'
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
cronJob = new Cron(cronExpr, { timezone }, async () => {
|
|
70
|
+
await runReflection()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const nextRun = cronJob.nextRun()
|
|
74
|
+
console.log(`[ReflectionScheduler] Scheduled at ${reflectionTime} (${timezone}), next run: ${nextRun ? nextRun.toISOString() : 'unknown'}`)
|
|
75
|
+
} catch (err) {
|
|
76
|
+
console.error(`[ReflectionScheduler] Failed to start: ${err.message}`)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Stop the reflection scheduler.
|
|
82
|
+
*/
|
|
83
|
+
function stop() {
|
|
84
|
+
if (cronJob) {
|
|
85
|
+
cronJob.stop()
|
|
86
|
+
cronJob = null
|
|
87
|
+
console.log('[ReflectionScheduler] Stopped')
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Reschedule after config change (e.g., REFLECTION_TIME or TIMEZONE updated via API).
|
|
93
|
+
* Requires that start() was called previously with a runQuickLlmCall function.
|
|
94
|
+
*/
|
|
95
|
+
function reschedule() {
|
|
96
|
+
if (!llmCallFn) {
|
|
97
|
+
console.warn('[ReflectionScheduler] Cannot reschedule: no LLM call function registered')
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
stop()
|
|
101
|
+
start(llmCallFn)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Execute the reflection (end-of-day processing).
|
|
106
|
+
* Guards against concurrent execution.
|
|
107
|
+
*/
|
|
108
|
+
async function runReflection() {
|
|
109
|
+
if (isRunning) {
|
|
110
|
+
console.log('[ReflectionScheduler] Already running, skipping')
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!llmCallFn) {
|
|
115
|
+
console.error('[ReflectionScheduler] No LLM call function registered')
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
isRunning = true
|
|
120
|
+
console.log('[ReflectionScheduler] Starting daily reflection...')
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const result = await runEndOfDay({
|
|
124
|
+
runQuickLlmCall: llmCallFn,
|
|
125
|
+
clearSession: true,
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
if (result.daily_log) {
|
|
129
|
+
console.log(`[ReflectionScheduler] Completed: log=${result.daily_log}, memories=${result.memory_entries_added}`)
|
|
130
|
+
} else {
|
|
131
|
+
console.log('[ReflectionScheduler] Completed: no conversation to process')
|
|
132
|
+
}
|
|
133
|
+
} catch (err) {
|
|
134
|
+
console.error(`[ReflectionScheduler] Failed: ${err.message}`)
|
|
135
|
+
} finally {
|
|
136
|
+
isRunning = false
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Get the current scheduler status.
|
|
142
|
+
* @returns {{ enabled: boolean, reflection_time: string, timezone: string, next_run: string|null }}
|
|
143
|
+
*/
|
|
144
|
+
function getStatus() {
|
|
145
|
+
const reflectionTime = config.REFLECTION_TIME || ''
|
|
146
|
+
const timezone = config.TIMEZONE || 'Asia/Tokyo'
|
|
147
|
+
const nextRun = cronJob ? cronJob.nextRun() : null
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
enabled: !!cronJob,
|
|
151
|
+
reflection_time: reflectionTime,
|
|
152
|
+
timezone,
|
|
153
|
+
next_run: nextRun ? nextRun.toISOString() : null,
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
module.exports = { start, stop, reschedule, getStatus }
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daily log endpoints
|
|
3
|
+
*
|
|
4
|
+
* Provides CRUD access to daily conversation summaries.
|
|
5
|
+
* Logs are stored as markdown files in $DATA_DIR/daily-logs/YYYY-MM-DD.md.
|
|
6
|
+
*
|
|
7
|
+
* Endpoints:
|
|
8
|
+
* GET /api/daily-logs - List all logs (date + size)
|
|
9
|
+
* POST /api/daily-logs - Create a new daily log
|
|
10
|
+
* GET /api/daily-logs/:date - Get a specific day's log
|
|
11
|
+
* PUT /api/daily-logs/:date - Update a daily log
|
|
12
|
+
* DELETE /api/daily-logs/:date - Delete a specific day's log
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const { verifyToken } = require('../lib/auth')
|
|
16
|
+
const dailyLogStore = require('../stores/daily-log-store')
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Register daily log routes as Fastify plugin
|
|
20
|
+
* @param {import('fastify').FastifyInstance} fastify
|
|
21
|
+
*/
|
|
22
|
+
async function dailyLogRoutes(fastify) {
|
|
23
|
+
|
|
24
|
+
// GET /api/daily-logs - List all logs
|
|
25
|
+
fastify.get('/api/daily-logs', async (request, reply) => {
|
|
26
|
+
if (!verifyToken(request)) {
|
|
27
|
+
reply.code(401)
|
|
28
|
+
return { success: false, error: 'Unauthorized' }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const logs = await dailyLogStore.listLogs()
|
|
32
|
+
return { success: true, logs }
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
// POST /api/daily-logs - Create a new daily log
|
|
36
|
+
fastify.post('/api/daily-logs', async (request, reply) => {
|
|
37
|
+
if (!verifyToken(request)) {
|
|
38
|
+
reply.code(401)
|
|
39
|
+
return { success: false, error: 'Unauthorized' }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const { date, content } = request.body || {}
|
|
43
|
+
if (!date || !content) {
|
|
44
|
+
reply.code(400)
|
|
45
|
+
return { success: false, error: 'date and content are required' }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Check if log already exists
|
|
49
|
+
const existing = await dailyLogStore.loadLog(date)
|
|
50
|
+
if (existing !== null) {
|
|
51
|
+
reply.code(409)
|
|
52
|
+
return { success: false, error: 'Log already exists for this date. Use PUT to update.' }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
await dailyLogStore.saveLog(date, content)
|
|
56
|
+
console.log(`[DailyLogs] Created log: ${date}`)
|
|
57
|
+
return { success: true, log: { date, content } }
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
// GET /api/daily-logs/:date - Get a specific day's log
|
|
61
|
+
fastify.get('/api/daily-logs/:date', async (request, reply) => {
|
|
62
|
+
if (!verifyToken(request)) {
|
|
63
|
+
reply.code(401)
|
|
64
|
+
return { success: false, error: 'Unauthorized' }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const { date } = request.params
|
|
68
|
+
const content = await dailyLogStore.loadLog(date)
|
|
69
|
+
if (content === null) {
|
|
70
|
+
reply.code(404)
|
|
71
|
+
return { success: false, error: 'Log not found' }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { success: true, log: { date, content } }
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
// PUT /api/daily-logs/:date - Update a daily log
|
|
78
|
+
fastify.put('/api/daily-logs/:date', async (request, reply) => {
|
|
79
|
+
if (!verifyToken(request)) {
|
|
80
|
+
reply.code(401)
|
|
81
|
+
return { success: false, error: 'Unauthorized' }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const { date } = request.params
|
|
85
|
+
const { content } = request.body || {}
|
|
86
|
+
if (!content) {
|
|
87
|
+
reply.code(400)
|
|
88
|
+
return { success: false, error: 'content is required' }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Check if log exists
|
|
92
|
+
const existing = await dailyLogStore.loadLog(date)
|
|
93
|
+
if (existing === null) {
|
|
94
|
+
reply.code(404)
|
|
95
|
+
return { success: false, error: 'Log not found' }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
await dailyLogStore.saveLog(date, content)
|
|
99
|
+
console.log(`[DailyLogs] Updated log: ${date}`)
|
|
100
|
+
return { success: true, log: { date, content } }
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
// DELETE /api/daily-logs/:date - Delete a specific day's log
|
|
104
|
+
fastify.delete('/api/daily-logs/:date', async (request, reply) => {
|
|
105
|
+
if (!verifyToken(request)) {
|
|
106
|
+
reply.code(401)
|
|
107
|
+
return { success: false, error: 'Unauthorized' }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const { date } = request.params
|
|
111
|
+
const deleted = await dailyLogStore.deleteLog(date)
|
|
112
|
+
if (!deleted) {
|
|
113
|
+
reply.code(404)
|
|
114
|
+
return { success: false, error: 'Log not found' }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
console.log(`[DailyLogs] Deleted log: ${date}`)
|
|
118
|
+
return { success: true }
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
module.exports = { dailyLogRoutes }
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory management endpoints
|
|
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/.
|
|
6
|
+
*
|
|
7
|
+
* Endpoints:
|
|
8
|
+
* GET /api/memory - List all memory entries
|
|
9
|
+
* GET /api/memory/:id - Get a single entry
|
|
10
|
+
* POST /api/memory - Create a new entry
|
|
11
|
+
* PUT /api/memory/:id - Update an entry
|
|
12
|
+
* DELETE /api/memory/:id - Delete an entry
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const { verifyToken } = require('../lib/auth')
|
|
16
|
+
const memoryStore = require('../stores/memory-store')
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Register memory routes as Fastify plugin
|
|
20
|
+
* @param {import('fastify').FastifyInstance} fastify
|
|
21
|
+
*/
|
|
22
|
+
async function memoryRoutes(fastify) {
|
|
23
|
+
|
|
24
|
+
// GET /api/memory - List all entries
|
|
25
|
+
fastify.get('/api/memory', async (request, reply) => {
|
|
26
|
+
if (!verifyToken(request)) {
|
|
27
|
+
reply.code(401)
|
|
28
|
+
return { success: false, error: 'Unauthorized' }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const entries = await memoryStore.listEntries()
|
|
32
|
+
return { success: true, entries }
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
// GET /api/memory/:id - Get a single entry
|
|
36
|
+
fastify.get('/api/memory/:id', async (request, reply) => {
|
|
37
|
+
if (!verifyToken(request)) {
|
|
38
|
+
reply.code(401)
|
|
39
|
+
return { success: false, error: 'Unauthorized' }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const entry = await memoryStore.loadEntry(request.params.id)
|
|
43
|
+
if (!entry) {
|
|
44
|
+
reply.code(404)
|
|
45
|
+
return { success: false, error: 'Entry not found' }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { success: true, entry }
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
// POST /api/memory - Create a new entry
|
|
52
|
+
fastify.post('/api/memory', async (request, reply) => {
|
|
53
|
+
if (!verifyToken(request)) {
|
|
54
|
+
reply.code(401)
|
|
55
|
+
return { success: false, error: 'Unauthorized' }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const { title, category, content } = request.body || {}
|
|
59
|
+
if (!title || !content) {
|
|
60
|
+
reply.code(400)
|
|
61
|
+
return { success: false, error: 'title and content are required' }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const entry = await memoryStore.saveEntry({ title, category, content })
|
|
65
|
+
console.log(`[Memory] Created entry: ${entry.id} (${entry.title})`)
|
|
66
|
+
return { success: true, entry }
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
// PUT /api/memory/:id - Update an entry
|
|
70
|
+
fastify.put('/api/memory/:id', async (request, reply) => {
|
|
71
|
+
if (!verifyToken(request)) {
|
|
72
|
+
reply.code(401)
|
|
73
|
+
return { success: false, error: 'Unauthorized' }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const existing = await memoryStore.loadEntry(request.params.id)
|
|
77
|
+
if (!existing) {
|
|
78
|
+
reply.code(404)
|
|
79
|
+
return { success: false, error: 'Entry not found' }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const { title, category, content } = request.body || {}
|
|
83
|
+
const entry = await memoryStore.saveEntry({
|
|
84
|
+
id: request.params.id,
|
|
85
|
+
title: title || existing.title,
|
|
86
|
+
category: category || existing.category,
|
|
87
|
+
content: content !== undefined ? content : existing.content,
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
console.log(`[Memory] Updated entry: ${entry.id} (${entry.title})`)
|
|
91
|
+
return { success: true, entry }
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
// DELETE /api/memory/:id - Delete an entry
|
|
95
|
+
fastify.delete('/api/memory/:id', async (request, reply) => {
|
|
96
|
+
if (!verifyToken(request)) {
|
|
97
|
+
reply.code(401)
|
|
98
|
+
return { success: false, error: 'Unauthorized' }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const deleted = await memoryStore.deleteEntry(request.params.id)
|
|
102
|
+
if (!deleted) {
|
|
103
|
+
reply.code(404)
|
|
104
|
+
return { success: false, error: 'Entry not found' }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
console.log(`[Memory] Deleted entry: ${request.params.id}`)
|
|
108
|
+
return { success: true }
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
module.exports = { memoryRoutes }
|
|
@@ -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
|
+
}
|