@geekbeer/minion 2.60.0 → 2.67.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.
- package/core/api.js +110 -0
- package/core/lib/config-warnings.js +85 -0
- package/core/lib/reflection-scheduler.js +1 -5
- package/core/lib/revision-watcher.js +12 -1
- package/core/lib/step-poller.js +12 -1
- package/core/lib/thread-watcher.js +359 -0
- package/core/routes/daemons.js +34 -0
- package/core/routes/help-threads.js +189 -0
- package/docs/api-reference.md +195 -0
- package/docs/environment-setup.md +75 -7
- package/linux/minion-cli.sh +6 -0
- package/linux/server.js +15 -2
- package/package.json +1 -1
- package/rules/core.md +45 -0
- package/win/minion-cli.ps1 +12 -2
- package/win/server.js +15 -2
package/core/api.js
CHANGED
|
@@ -86,10 +86,120 @@ async function sendHeartbeat(data) {
|
|
|
86
86
|
})
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Create a project thread (help or discussion) on HQ.
|
|
91
|
+
* @param {object} data - { project_id, title, description, thread_type?, mentions?, context? }
|
|
92
|
+
* @returns {Promise<{ thread: object }>}
|
|
93
|
+
*/
|
|
94
|
+
async function createHelpThread(data) {
|
|
95
|
+
return request('/help-threads', {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
body: JSON.stringify(data),
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Get open help threads in this minion's projects.
|
|
103
|
+
* @returns {Promise<{ threads: object[] }>}
|
|
104
|
+
*/
|
|
105
|
+
async function getOpenHelpThreads() {
|
|
106
|
+
return request('/help-threads/open')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Get a help thread by ID with messages.
|
|
111
|
+
* @param {string} threadId
|
|
112
|
+
* @returns {Promise<{ thread: object, messages: object[] }>}
|
|
113
|
+
*/
|
|
114
|
+
async function getHelpThread(threadId) {
|
|
115
|
+
return request(`/help-threads/${threadId}`)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Post a message to a help thread.
|
|
120
|
+
* @param {string} threadId
|
|
121
|
+
* @param {object} data - { content, attachments?, mentions? }
|
|
122
|
+
*/
|
|
123
|
+
async function postHelpMessage(threadId, data) {
|
|
124
|
+
return request(`/help-threads/${threadId}/messages`, {
|
|
125
|
+
method: 'POST',
|
|
126
|
+
body: JSON.stringify(data),
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Resolve a help thread.
|
|
132
|
+
* @param {string} threadId
|
|
133
|
+
* @param {string} resolution
|
|
134
|
+
*/
|
|
135
|
+
async function resolveHelpThread(threadId, resolution) {
|
|
136
|
+
return request(`/help-threads/${threadId}/resolve`, {
|
|
137
|
+
method: 'PATCH',
|
|
138
|
+
body: JSON.stringify({ resolution }),
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Cancel a help thread.
|
|
144
|
+
* @param {string} threadId
|
|
145
|
+
* @param {string} [reason]
|
|
146
|
+
*/
|
|
147
|
+
async function cancelHelpThread(threadId, reason) {
|
|
148
|
+
return request(`/help-threads/${threadId}/cancel`, {
|
|
149
|
+
method: 'PATCH',
|
|
150
|
+
body: JSON.stringify({ reason: reason || null }),
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Permanently delete a help thread (PM only).
|
|
156
|
+
* @param {string} threadId
|
|
157
|
+
*/
|
|
158
|
+
async function deleteHelpThread(threadId) {
|
|
159
|
+
return request(`/help-threads/${threadId}`, {
|
|
160
|
+
method: 'DELETE',
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Create a project memory entry.
|
|
166
|
+
* @param {object} data - { project_id, title, content, category, tags?, source_thread_id? }
|
|
167
|
+
*/
|
|
168
|
+
async function createProjectMemory(data) {
|
|
169
|
+
return request('/project-memories', {
|
|
170
|
+
method: 'POST',
|
|
171
|
+
body: JSON.stringify(data),
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Search project memories.
|
|
177
|
+
* @param {string} projectId
|
|
178
|
+
* @param {object} [opts] - { search?, category?, tags?, status? }
|
|
179
|
+
*/
|
|
180
|
+
async function searchProjectMemories(projectId, opts = {}) {
|
|
181
|
+
const params = new URLSearchParams({ project_id: projectId })
|
|
182
|
+
if (opts.search) params.set('search', opts.search)
|
|
183
|
+
if (opts.category) params.set('category', opts.category)
|
|
184
|
+
if (opts.tags) params.set('tags', opts.tags.join(','))
|
|
185
|
+
if (opts.status) params.set('status', opts.status)
|
|
186
|
+
return request(`/project-memories?${params.toString()}`)
|
|
187
|
+
}
|
|
188
|
+
|
|
89
189
|
module.exports = {
|
|
90
190
|
request,
|
|
91
191
|
reportExecution,
|
|
92
192
|
reportStepComplete,
|
|
93
193
|
reportIssue,
|
|
94
194
|
sendHeartbeat,
|
|
195
|
+
createHelpThread,
|
|
196
|
+
getOpenHelpThreads,
|
|
197
|
+
getHelpThread,
|
|
198
|
+
postHelpMessage,
|
|
199
|
+
resolveHelpThread,
|
|
200
|
+
|
|
201
|
+
cancelHelpThread,
|
|
202
|
+
deleteHelpThread,
|
|
203
|
+
createProjectMemory,
|
|
204
|
+
searchProjectMemories,
|
|
95
205
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config Warnings - checks for missing required configurations
|
|
3
|
+
*
|
|
4
|
+
* Computes a list of warning objects that indicate missing or misconfigured
|
|
5
|
+
* settings that prevent the minion from functioning properly.
|
|
6
|
+
* Results are included in heartbeat payloads so HQ can display them on the dashboard.
|
|
7
|
+
*
|
|
8
|
+
* Warning categories:
|
|
9
|
+
* - llm_not_authenticated: No LLM service is authenticated (Claude/Gemini/Codex)
|
|
10
|
+
* - llm_command_not_set: LLM_COMMAND is not configured (required for workflow/routine execution)
|
|
11
|
+
* - tunnel_not_running: Cloudflare tunnel is not running (required for VNC/terminal access)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { execSync } = require('child_process')
|
|
15
|
+
const { config } = require('../config')
|
|
16
|
+
const { getLlmServices, isLlmCommandConfigured } = require('./llm-checker')
|
|
17
|
+
const { IS_WINDOWS } = require('./platform')
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Check if Cloudflare tunnel is running.
|
|
21
|
+
* @returns {boolean}
|
|
22
|
+
*/
|
|
23
|
+
function isTunnelRunning() {
|
|
24
|
+
try {
|
|
25
|
+
if (IS_WINDOWS) {
|
|
26
|
+
const out = execSync('tasklist /FI "IMAGENAME eq cloudflared.exe" /NH', {
|
|
27
|
+
encoding: 'utf-8',
|
|
28
|
+
timeout: 5000,
|
|
29
|
+
stdio: 'pipe',
|
|
30
|
+
})
|
|
31
|
+
return out.toLowerCase().includes('cloudflared.exe')
|
|
32
|
+
} else {
|
|
33
|
+
execSync('pgrep -f "cloudflared"', {
|
|
34
|
+
encoding: 'utf-8',
|
|
35
|
+
timeout: 5000,
|
|
36
|
+
stdio: 'pipe',
|
|
37
|
+
})
|
|
38
|
+
return true
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Compute config warnings for the current minion state.
|
|
47
|
+
* Each warning is an object with:
|
|
48
|
+
* - code: string identifier (e.g. 'llm_not_authenticated')
|
|
49
|
+
* - message: human-readable description (Japanese)
|
|
50
|
+
*
|
|
51
|
+
* @returns {{ code: string, message: string }[]}
|
|
52
|
+
*/
|
|
53
|
+
function getConfigWarnings() {
|
|
54
|
+
const warnings = []
|
|
55
|
+
|
|
56
|
+
// Check LLM authentication
|
|
57
|
+
const services = getLlmServices()
|
|
58
|
+
const hasAuthenticatedLlm = services.some(s => s.authenticated)
|
|
59
|
+
if (!hasAuthenticatedLlm) {
|
|
60
|
+
warnings.push({
|
|
61
|
+
code: 'llm_not_authenticated',
|
|
62
|
+
message: 'LLM\u304C\u672A\u8A8D\u8A3C\u3067\u3059\u3002Claude/Gemini/Codex\u306E\u3044\u305A\u308C\u304B\u3092\u8A8D\u8A3C\u3057\u3066\u304F\u3060\u3055\u3044',
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Check LLM_COMMAND
|
|
67
|
+
if (!isLlmCommandConfigured()) {
|
|
68
|
+
warnings.push({
|
|
69
|
+
code: 'llm_command_not_set',
|
|
70
|
+
message: 'LLM_COMMAND\u304C\u672A\u8A2D\u5B9A\u3067\u3059\u3002\u30EF\u30FC\u30AF\u30D5\u30ED\u30FC/\u30EB\u30FC\u30C6\u30A3\u30F3\u5B9F\u884C\u306B\u5FC5\u8981\u3067\u3059',
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Check tunnel (only for HQ-connected minions)
|
|
75
|
+
if (config.HQ_URL && !isTunnelRunning()) {
|
|
76
|
+
warnings.push({
|
|
77
|
+
code: 'tunnel_not_running',
|
|
78
|
+
message: 'Cloudflare\u30C8\u30F3\u30CD\u30EB\u304C\u505C\u6B62\u3057\u3066\u3044\u307E\u3059\u3002VNC/\u30BF\u30FC\u30DF\u30CA\u30EB\u63A5\u7D9A\u304C\u3067\u304D\u307E\u305B\u3093',
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return warnings
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { getConfigWarnings }
|
|
@@ -156,13 +156,9 @@ async function runReflection() {
|
|
|
156
156
|
* @returns {{ enabled: boolean, reflection_time: string, timezone: string, next_run: string|null }}
|
|
157
157
|
*/
|
|
158
158
|
function getStatus() {
|
|
159
|
-
const reflectionTime = config.REFLECTION_TIME || ''
|
|
160
159
|
const nextRun = cronJob ? cronJob.nextRun() : null
|
|
161
|
-
|
|
162
160
|
return {
|
|
163
|
-
|
|
164
|
-
reflection_time: reflectionTime,
|
|
165
|
-
timezone: getSystemTimezone(),
|
|
161
|
+
running: !!cronJob,
|
|
166
162
|
next_run: nextRun ? nextRun.toISOString() : null,
|
|
167
163
|
}
|
|
168
164
|
}
|
|
@@ -22,6 +22,9 @@ const POLL_INTERVAL_MS = 30_000
|
|
|
22
22
|
let polling = false
|
|
23
23
|
let pollTimer = null
|
|
24
24
|
|
|
25
|
+
// Last successful poll timestamp
|
|
26
|
+
let lastPollAt = null
|
|
27
|
+
|
|
25
28
|
// Track revisions being processed to avoid duplicate handling
|
|
26
29
|
const processingRevisions = new Set()
|
|
27
30
|
|
|
@@ -63,6 +66,7 @@ async function pollOnce() {
|
|
|
63
66
|
}
|
|
64
67
|
} finally {
|
|
65
68
|
polling = false
|
|
69
|
+
lastPollAt = new Date().toISOString()
|
|
66
70
|
}
|
|
67
71
|
}
|
|
68
72
|
|
|
@@ -249,4 +253,11 @@ function stop() {
|
|
|
249
253
|
}
|
|
250
254
|
}
|
|
251
255
|
|
|
252
|
-
|
|
256
|
+
function getStatus() {
|
|
257
|
+
return {
|
|
258
|
+
running: pollTimer !== null,
|
|
259
|
+
last_poll_at: lastPollAt,
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
module.exports = { start, stop, pollOnce, getStatus }
|
package/core/lib/step-poller.js
CHANGED
|
@@ -33,6 +33,9 @@ let pollTimer = null
|
|
|
33
33
|
// Track currently executing step to avoid double-dispatch
|
|
34
34
|
let activeStepExecutionId = null
|
|
35
35
|
|
|
36
|
+
// Last successful poll timestamp
|
|
37
|
+
let lastPollAt = null
|
|
38
|
+
|
|
36
39
|
/**
|
|
37
40
|
* Poll HQ for pending steps and execute them.
|
|
38
41
|
*/
|
|
@@ -73,6 +76,7 @@ async function pollOnce() {
|
|
|
73
76
|
}
|
|
74
77
|
} finally {
|
|
75
78
|
polling = false
|
|
79
|
+
lastPollAt = new Date().toISOString()
|
|
76
80
|
}
|
|
77
81
|
}
|
|
78
82
|
|
|
@@ -224,4 +228,11 @@ function stop() {
|
|
|
224
228
|
}
|
|
225
229
|
}
|
|
226
230
|
|
|
227
|
-
|
|
231
|
+
function getStatus() {
|
|
232
|
+
return {
|
|
233
|
+
running: pollTimer !== null,
|
|
234
|
+
last_poll_at: lastPollAt,
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
module.exports = { start, stop, pollOnce, getStatus }
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project Thread Watcher
|
|
3
|
+
*
|
|
4
|
+
* Polling daemon that monitors open help/discussion threads in the minion's
|
|
5
|
+
* projects. Uses LLM to decide whether to participate in conversations.
|
|
6
|
+
*
|
|
7
|
+
* Token-saving strategies:
|
|
8
|
+
* 1. Read tracking: only evaluate threads with new messages since last check
|
|
9
|
+
* 2. Mention targeting: if thread/message has mentions, only targeted minions evaluate
|
|
10
|
+
* 3. Rate limiting: max 1 LLM evaluation per thread per 5 minutes
|
|
11
|
+
*
|
|
12
|
+
* Flow per poll cycle:
|
|
13
|
+
* 1. GET /api/minion/help-threads/open → list of open threads
|
|
14
|
+
* 2. For each thread with new activity:
|
|
15
|
+
* a. Check mentions → if mentioned, must evaluate
|
|
16
|
+
* b. Check read state → skip if no new messages
|
|
17
|
+
* c. Fetch thread detail + messages
|
|
18
|
+
* d. LLM evaluates relevance + generates response if appropriate
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const { config, isHqConfigured, isLlmConfigured } = require('../config')
|
|
22
|
+
const api = require('../api')
|
|
23
|
+
|
|
24
|
+
// Poll every 15 seconds
|
|
25
|
+
const POLL_INTERVAL_MS = 15_000
|
|
26
|
+
|
|
27
|
+
// Minimum interval between LLM evaluations for the same thread (5 minutes)
|
|
28
|
+
const EVAL_COOLDOWN_MS = 5 * 60 * 1000
|
|
29
|
+
|
|
30
|
+
// Prevent concurrent poll cycles
|
|
31
|
+
let polling = false
|
|
32
|
+
let pollTimer = null
|
|
33
|
+
|
|
34
|
+
// Injected LLM function (set via start())
|
|
35
|
+
let llmCallFn = null
|
|
36
|
+
|
|
37
|
+
// Last successful poll timestamp
|
|
38
|
+
let lastPollAt = null
|
|
39
|
+
|
|
40
|
+
// Read tracking: threadId → { lastMessageCount, lastEvalAt }
|
|
41
|
+
const readState = new Map()
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Get minion's role and project memberships for context.
|
|
45
|
+
* Cached per poll cycle.
|
|
46
|
+
*/
|
|
47
|
+
let cachedProjects = null
|
|
48
|
+
let cachedProjectsAt = 0
|
|
49
|
+
|
|
50
|
+
async function getMyProjects() {
|
|
51
|
+
// Cache for 60 seconds
|
|
52
|
+
if (cachedProjects && Date.now() - cachedProjectsAt < 60_000) {
|
|
53
|
+
return cachedProjects
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const data = await api.request('/me/projects')
|
|
57
|
+
cachedProjects = data.projects || []
|
|
58
|
+
cachedProjectsAt = Date.now()
|
|
59
|
+
return cachedProjects
|
|
60
|
+
} catch {
|
|
61
|
+
return cachedProjects || []
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Check if this minion is mentioned in a thread or its messages.
|
|
67
|
+
* Mention formats: 'role:engineer', 'role:pm', 'minion:<minion_id>'
|
|
68
|
+
*
|
|
69
|
+
* @param {object} thread
|
|
70
|
+
* @param {object[]} messages - Only new messages to check
|
|
71
|
+
* @param {string} myRole - This minion's role in the project
|
|
72
|
+
* @returns {boolean}
|
|
73
|
+
*/
|
|
74
|
+
function isMentioned(thread, messages, myRole) {
|
|
75
|
+
const myMinionId = config.MINION_ID
|
|
76
|
+
const targets = [
|
|
77
|
+
`role:${myRole}`,
|
|
78
|
+
`minion:${myMinionId}`,
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
// Check thread-level mentions
|
|
82
|
+
if (thread.mentions?.some(m => targets.includes(m))) return true
|
|
83
|
+
|
|
84
|
+
// Check message-level mentions
|
|
85
|
+
for (const msg of messages) {
|
|
86
|
+
if (msg.mentions?.some(m => targets.includes(m))) return true
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return false
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Poll for open threads and evaluate new activity.
|
|
94
|
+
*/
|
|
95
|
+
async function pollOnce() {
|
|
96
|
+
if (!isHqConfigured()) return
|
|
97
|
+
if (polling) return
|
|
98
|
+
|
|
99
|
+
polling = true
|
|
100
|
+
try {
|
|
101
|
+
const data = await api.request('/help-threads/open')
|
|
102
|
+
|
|
103
|
+
if (!data.threads || data.threads.length === 0) {
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const now = Date.now()
|
|
108
|
+
const myProjects = await getMyProjects()
|
|
109
|
+
|
|
110
|
+
for (const thread of data.threads) {
|
|
111
|
+
try {
|
|
112
|
+
await processThread(thread, myProjects, now)
|
|
113
|
+
} catch (err) {
|
|
114
|
+
console.error(`[ThreadWatcher] Error processing thread ${thread.id}: ${err.message}`)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
} catch (err) {
|
|
118
|
+
if (err.message?.includes('fetch failed') || err.message?.includes('ECONNREFUSED')) {
|
|
119
|
+
// HQ unreachable — silent retry
|
|
120
|
+
} else {
|
|
121
|
+
console.error(`[ThreadWatcher] Poll error: ${err.message}`)
|
|
122
|
+
}
|
|
123
|
+
} finally {
|
|
124
|
+
polling = false
|
|
125
|
+
lastPollAt = new Date().toISOString()
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Process a single thread: check for new activity and evaluate if needed.
|
|
131
|
+
*/
|
|
132
|
+
async function processThread(thread, myProjects, now) {
|
|
133
|
+
// Find my role in this project
|
|
134
|
+
const myProject = myProjects.find(p => p.id === thread.project_id)
|
|
135
|
+
if (!myProject) return // Not a member
|
|
136
|
+
|
|
137
|
+
const state = readState.get(thread.id) || { lastMessageCount: 0, lastEvalAt: 0 }
|
|
138
|
+
|
|
139
|
+
// Fetch thread detail to get message count and check for new messages
|
|
140
|
+
let detail
|
|
141
|
+
try {
|
|
142
|
+
detail = await api.request(`/help-threads/${thread.id}`)
|
|
143
|
+
} catch {
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const messages = detail.messages || []
|
|
148
|
+
const messageCount = messages.length
|
|
149
|
+
|
|
150
|
+
// Skip if no new messages since last check
|
|
151
|
+
if (messageCount <= state.lastMessageCount) {
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// New messages detected
|
|
156
|
+
const newMessages = messages.slice(state.lastMessageCount)
|
|
157
|
+
|
|
158
|
+
// Update read state (message count only — eval timestamp updated after LLM call)
|
|
159
|
+
readState.set(thread.id, { ...state, lastMessageCount: messageCount })
|
|
160
|
+
|
|
161
|
+
// Check mentions first (no LLM needed to decide relevance)
|
|
162
|
+
const mentioned = isMentioned(thread, newMessages, myProject.role)
|
|
163
|
+
|
|
164
|
+
// If not mentioned and cooldown not expired, skip LLM evaluation
|
|
165
|
+
if (!mentioned && now - state.lastEvalAt < EVAL_COOLDOWN_MS) {
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// If LLM is not configured, fall back to memory-only matching
|
|
170
|
+
if (!llmCallFn || !isLlmConfigured()) {
|
|
171
|
+
await fallbackMemoryMatch(thread, messages)
|
|
172
|
+
readState.set(thread.id, { lastMessageCount: messageCount, lastEvalAt: now })
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// LLM evaluation
|
|
177
|
+
await evaluateWithLlm(thread, detail.thread, messages, newMessages, myProject, mentioned)
|
|
178
|
+
readState.set(thread.id, { lastMessageCount: messageCount, lastEvalAt: now })
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Use LLM to evaluate thread and potentially respond.
|
|
183
|
+
*/
|
|
184
|
+
async function evaluateWithLlm(threadSummary, threadDetail, allMessages, newMessages, myProject, mentioned) {
|
|
185
|
+
// Build conversation context (limit to last 20 messages to control tokens)
|
|
186
|
+
const recentMessages = allMessages.slice(-20)
|
|
187
|
+
const messageHistory = recentMessages
|
|
188
|
+
.map(m => {
|
|
189
|
+
const sender = m.sender_minion_id
|
|
190
|
+
? (m.sender_minion_id === config.MINION_ID ? 'You' : `Minion(${m.sender_minion_id.slice(0, 8)})`)
|
|
191
|
+
: 'User'
|
|
192
|
+
return `[${sender}] ${m.content}`
|
|
193
|
+
})
|
|
194
|
+
.join('\n')
|
|
195
|
+
|
|
196
|
+
const threadType = threadDetail.thread_type || 'help'
|
|
197
|
+
const mentionNote = mentioned
|
|
198
|
+
? '\n\nあなたはこのスレッドでメンションされています。必ず返信してください。'
|
|
199
|
+
: ''
|
|
200
|
+
|
|
201
|
+
// Extract optional context metadata
|
|
202
|
+
const ctx = threadDetail.context || {}
|
|
203
|
+
const contextInfo = [
|
|
204
|
+
ctx.category ? `カテゴリ: ${ctx.category}` : '',
|
|
205
|
+
ctx.attempted_resolution ? `試行済み: ${ctx.attempted_resolution}` : '',
|
|
206
|
+
].filter(Boolean).join('\n')
|
|
207
|
+
|
|
208
|
+
const prompt = `あなたはプロジェクト「${myProject.name}」のチームメンバー(ロール: ${myProject.role})です。
|
|
209
|
+
以下のスレッドに対して、あなたが返信すべきかどうかを判断し、返信する場合はその内容を生成してください。
|
|
210
|
+
|
|
211
|
+
スレッドタイプ: ${threadType}
|
|
212
|
+
タイトル: ${threadDetail.title}
|
|
213
|
+
説明: ${threadDetail.description}
|
|
214
|
+
${contextInfo}${mentionNote}
|
|
215
|
+
|
|
216
|
+
--- メッセージ履歴 ---
|
|
217
|
+
${messageHistory || '(メッセージなし)'}
|
|
218
|
+
--- 履歴ここまで ---
|
|
219
|
+
|
|
220
|
+
以下のJSON形式で回答してください:
|
|
221
|
+
{
|
|
222
|
+
"should_respond": true/false,
|
|
223
|
+
"reason": "判断の理由(1行)",
|
|
224
|
+
"response": "返信内容(should_respondがtrueの場合のみ)"
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
判断基準:
|
|
228
|
+
- 自分のロール(${myProject.role})に関連する話題か
|
|
229
|
+
- 自分が貢献できる知見や意見があるか
|
|
230
|
+
- 既に十分な回答がある場合は重複を避ける
|
|
231
|
+
- メンションされている場合は必ず返信する
|
|
232
|
+
- 人間に聞くべき場合は @user メンションを含めて返信する`
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
const result = await llmCallFn(prompt)
|
|
236
|
+
|
|
237
|
+
let parsed
|
|
238
|
+
try {
|
|
239
|
+
// LLM may return JSON wrapped in markdown code block
|
|
240
|
+
const cleaned = result.replace(/```json?\n?/g, '').replace(/```/g, '').trim()
|
|
241
|
+
parsed = JSON.parse(cleaned)
|
|
242
|
+
} catch {
|
|
243
|
+
console.log(`[ThreadWatcher] LLM returned non-JSON for thread ${threadSummary.id}, skipping`)
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (parsed.should_respond && parsed.response) {
|
|
248
|
+
// Check if the response contains @user mention
|
|
249
|
+
const mentions = []
|
|
250
|
+
if (parsed.response.includes('@user')) {
|
|
251
|
+
mentions.push('user')
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
await api.request(`/help-threads/${threadSummary.id}/messages`, {
|
|
255
|
+
method: 'POST',
|
|
256
|
+
body: JSON.stringify({
|
|
257
|
+
content: parsed.response,
|
|
258
|
+
mentions,
|
|
259
|
+
}),
|
|
260
|
+
})
|
|
261
|
+
console.log(`[ThreadWatcher] Responded to thread "${threadDetail.title}" (reason: ${parsed.reason})`)
|
|
262
|
+
} else {
|
|
263
|
+
console.log(`[ThreadWatcher] Skipped thread "${threadDetail.title}" (reason: ${parsed.reason || 'not relevant'})`)
|
|
264
|
+
}
|
|
265
|
+
} catch (err) {
|
|
266
|
+
console.error(`[ThreadWatcher] LLM evaluation failed for thread ${threadSummary.id}: ${err.message}`)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Fallback: memory-only matching when LLM is not available.
|
|
272
|
+
*/
|
|
273
|
+
async function fallbackMemoryMatch(thread) {
|
|
274
|
+
try {
|
|
275
|
+
const ctx = thread.context || {}
|
|
276
|
+
const category = ctx.category || ''
|
|
277
|
+
const searchParam = category ? `&category=${category}` : ''
|
|
278
|
+
const memData = await api.request(
|
|
279
|
+
`/project-memories?project_id=${thread.project_id}${searchParam}`
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
if (!memData.memories || memData.memories.length === 0) return
|
|
283
|
+
|
|
284
|
+
const keywords = (thread.title + ' ' + thread.description)
|
|
285
|
+
.toLowerCase()
|
|
286
|
+
.split(/[\s,.\-_/]+/)
|
|
287
|
+
.filter(w => w.length >= 3)
|
|
288
|
+
.filter((w, i, arr) => arr.indexOf(w) === i)
|
|
289
|
+
.slice(0, 10)
|
|
290
|
+
|
|
291
|
+
const relevant = memData.memories.filter(m =>
|
|
292
|
+
keywords.some(kw =>
|
|
293
|
+
m.title.toLowerCase().includes(kw) ||
|
|
294
|
+
m.content.toLowerCase().includes(kw)
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
if (relevant.length === 0) return
|
|
299
|
+
|
|
300
|
+
const knowledgeSummary = relevant
|
|
301
|
+
.map(m => `[${m.title}] ${m.content}`)
|
|
302
|
+
.join('\n\n')
|
|
303
|
+
|
|
304
|
+
await api.request(`/help-threads/${thread.id}/messages`, {
|
|
305
|
+
method: 'POST',
|
|
306
|
+
body: JSON.stringify({
|
|
307
|
+
content: `関連するプロジェクトメモリーが見つかりました:\n\n${knowledgeSummary}`,
|
|
308
|
+
}),
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
console.log(`[ThreadWatcher] Posted ${relevant.length} relevant memories for thread ${thread.id}`)
|
|
312
|
+
} catch (err) {
|
|
313
|
+
console.error(`[ThreadWatcher] Memory check failed for thread ${thread.id}: ${err.message}`)
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Start the thread watcher daemon.
|
|
319
|
+
* @param {Function} runQuickLlmCall - LLM function for evaluating threads
|
|
320
|
+
*/
|
|
321
|
+
function start(runQuickLlmCall) {
|
|
322
|
+
llmCallFn = runQuickLlmCall || null
|
|
323
|
+
|
|
324
|
+
if (!isHqConfigured()) {
|
|
325
|
+
console.log('[ThreadWatcher] HQ not configured, thread watcher disabled')
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (!llmCallFn) {
|
|
330
|
+
console.log('[ThreadWatcher] LLM not available, using memory-only matching')
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Initial poll after a short delay
|
|
334
|
+
setTimeout(() => pollOnce(), 8000)
|
|
335
|
+
|
|
336
|
+
// Periodic polling
|
|
337
|
+
pollTimer = setInterval(() => pollOnce(), POLL_INTERVAL_MS)
|
|
338
|
+
console.log(`[ThreadWatcher] Started (polling every ${POLL_INTERVAL_MS / 1000}s)`)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Stop the thread watcher daemon.
|
|
343
|
+
*/
|
|
344
|
+
function stop() {
|
|
345
|
+
if (pollTimer) {
|
|
346
|
+
clearInterval(pollTimer)
|
|
347
|
+
pollTimer = null
|
|
348
|
+
console.log('[ThreadWatcher] Stopped')
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function getStatus() {
|
|
353
|
+
return {
|
|
354
|
+
running: pollTimer !== null,
|
|
355
|
+
last_poll_at: lastPollAt,
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
module.exports = { start, stop, pollOnce, getStatus }
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon status route
|
|
3
|
+
*
|
|
4
|
+
* GET /api/daemons/status - Returns the status of all background daemons
|
|
5
|
+
*
|
|
6
|
+
* No auth required (local CLI access, same as /api/health and /api/status).
|
|
7
|
+
* Requires opts.heartbeatStatus (injected from server.js) for heartbeat state.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const stepPoller = require('../lib/step-poller')
|
|
11
|
+
const revisionWatcher = require('../lib/revision-watcher')
|
|
12
|
+
const threadWatcher = require('../lib/thread-watcher')
|
|
13
|
+
const reflectionScheduler = require('../lib/reflection-scheduler')
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {import('fastify').FastifyInstance} fastify
|
|
17
|
+
* @param {{ heartbeatStatus: () => object }} opts
|
|
18
|
+
*/
|
|
19
|
+
async function daemonRoutes(fastify, opts) {
|
|
20
|
+
fastify.get('/api/daemons/status', async () => {
|
|
21
|
+
return {
|
|
22
|
+
success: true,
|
|
23
|
+
daemons: {
|
|
24
|
+
step_poller: stepPoller.getStatus(),
|
|
25
|
+
revision_watcher: revisionWatcher.getStatus(),
|
|
26
|
+
thread_watcher: threadWatcher.getStatus(),
|
|
27
|
+
reflection_scheduler: reflectionScheduler.getStatus(),
|
|
28
|
+
heartbeat: opts.heartbeatStatus ? opts.heartbeatStatus() : { running: false },
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = { daemonRoutes }
|