@geekbeer/minion 4.4.0 → 4.7.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/db/migrations/20260607000000_chat_runs.js +48 -0
- package/core/db/migrations/20260607120000_page_recipes_ready_selector.js +22 -0
- package/core/lib/chat-run-manager.js +406 -0
- package/core/lib/web-extract/extractor.js +27 -7
- package/core/lib/web-extract/playwright-runner.js +199 -1
- package/core/lib/web-extract/recipe-generator.js +19 -2
- package/core/routes/variables.js +47 -5
- package/core/routes/web.js +12 -3
- package/core/stores/chat-store.js +119 -2
- package/core/stores/page-recipe-store.js +9 -7
- package/core/stores/variable-store.js +63 -0
- package/docs/api-reference.md +82 -4
- package/docs/task-guides.md +20 -2
- package/linux/routes/chat.js +159 -193
- package/package.json +1 -1
- package/rules/core.md +12 -2
- package/win/routes/chat.js +155 -157
package/linux/routes/chat.js
CHANGED
|
@@ -27,15 +27,15 @@ const todoStore = require('../../core/stores/todo-store')
|
|
|
27
27
|
const { runEndOfDay } = require('../../core/lib/end-of-day')
|
|
28
28
|
const { DATA_DIR } = require('../../core/lib/platform')
|
|
29
29
|
const { getActivePrimary } = require('../../core/llm-plugins/lib/active')
|
|
30
|
-
|
|
31
|
-
/** @type {import('child_process').ChildProcess | null} */
|
|
32
|
-
let activeChatChild = null
|
|
30
|
+
const chatRunManager = require('../../core/lib/chat-run-manager')
|
|
33
31
|
|
|
34
32
|
/**
|
|
35
33
|
* Register chat routes as Fastify plugin
|
|
36
34
|
* @param {import('fastify').FastifyInstance} fastify
|
|
37
35
|
*/
|
|
38
36
|
async function chatRoutes(fastify) {
|
|
37
|
+
// Sweep stale runs from a previous boot and prune old run logs.
|
|
38
|
+
chatRunManager.init()
|
|
39
39
|
|
|
40
40
|
// POST /api/chat - Send a message and get streaming response
|
|
41
41
|
fastify.post('/api/chat', async (request, reply) => {
|
|
@@ -71,25 +71,66 @@ async function chatRoutes(fastify) {
|
|
|
71
71
|
console.error('[Chat] failed to persist user message:', err.message)
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
// Start a DETACHED run. The LLM is owned by the run manager, not this HTTP
|
|
75
|
+
// request — if the connection drops the run keeps going. This response is
|
|
76
|
+
// just a live tail of the run's event log.
|
|
77
|
+
const runId = chatRunManager.start({
|
|
78
|
+
sessionId: currentSessionId,
|
|
79
|
+
pendingSessionId,
|
|
80
|
+
workspaceId,
|
|
81
|
+
invoke: buildInvoke(prompt, currentSessionId),
|
|
82
|
+
})
|
|
83
|
+
|
|
74
84
|
// Take over response handling from Fastify for SSE streaming
|
|
75
85
|
reply.hijack()
|
|
76
|
-
|
|
77
|
-
|
|
86
|
+
const res = reply.raw
|
|
87
|
+
res.writeHead(200, {
|
|
78
88
|
'Content-Type': 'text/event-stream',
|
|
79
89
|
'Cache-Control': 'no-cache',
|
|
80
90
|
'Connection': 'keep-alive',
|
|
81
91
|
})
|
|
82
|
-
|
|
92
|
+
res.flushHeaders()
|
|
83
93
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
94
|
+
// Tell the client the run id first so it can reconnect via
|
|
95
|
+
// GET /api/chat/stream after a drop without losing the in-flight run.
|
|
96
|
+
res.write(`data: ${JSON.stringify({ type: 'run', run_id: runId, session_id: currentSessionId })}\n\n`)
|
|
97
|
+
|
|
98
|
+
await tailRunToResponse(res, runId, 0)
|
|
99
|
+
res.end()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
// GET /api/chat/stream - Reconnect to an in-flight (or recently finished) run
|
|
103
|
+
// and resume tailing from `cursor`. This is what makes detached execution
|
|
104
|
+
// observable: a refreshed tab / dropped network reattaches here instead of
|
|
105
|
+
// starting a new run. Either run_id or workspace_id (to auto-find the active
|
|
106
|
+
// run) must be provided.
|
|
107
|
+
fastify.get('/api/chat/stream', async (request, reply) => {
|
|
108
|
+
if (!verifyToken(request)) {
|
|
109
|
+
reply.code(401)
|
|
110
|
+
return { success: false, error: 'Unauthorized' }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const workspaceId = request.query?.workspace_id || null
|
|
114
|
+
const runId = request.query?.run_id || chatRunManager.getActiveRunId(workspaceId)
|
|
115
|
+
const cursor = parseInt(request.query?.cursor, 10) || 0
|
|
116
|
+
|
|
117
|
+
if (!runId) {
|
|
118
|
+
reply.code(404)
|
|
119
|
+
return { success: false, error: 'No active run' }
|
|
90
120
|
}
|
|
91
121
|
|
|
92
|
-
reply.
|
|
122
|
+
reply.hijack()
|
|
123
|
+
const res = reply.raw
|
|
124
|
+
res.writeHead(200, {
|
|
125
|
+
'Content-Type': 'text/event-stream',
|
|
126
|
+
'Cache-Control': 'no-cache',
|
|
127
|
+
'Connection': 'keep-alive',
|
|
128
|
+
})
|
|
129
|
+
res.flushHeaders()
|
|
130
|
+
res.write(`data: ${JSON.stringify({ type: 'run', run_id: runId })}\n\n`)
|
|
131
|
+
|
|
132
|
+
await tailRunToResponse(res, runId, cursor)
|
|
133
|
+
res.end()
|
|
93
134
|
})
|
|
94
135
|
|
|
95
136
|
// GET /api/chat/session - Get active chat session for a workspace
|
|
@@ -101,8 +142,14 @@ async function chatRoutes(fastify) {
|
|
|
101
142
|
|
|
102
143
|
const workspaceId = request.query?.workspace_id || null
|
|
103
144
|
const session = chatStore.load(workspaceId)
|
|
145
|
+
|
|
146
|
+
// Surface any in-flight detached run so the client can attach to its live
|
|
147
|
+
// stream (GET /api/chat/stream) instead of rendering static history.
|
|
148
|
+
const activeRunId = chatRunManager.getActiveRunId(workspaceId)
|
|
149
|
+
const activeRun = activeRunId ? chatRunManager.getRunInfo(activeRunId) : null
|
|
150
|
+
|
|
104
151
|
if (!session) {
|
|
105
|
-
return { success: true, session: null }
|
|
152
|
+
return { success: true, session: null, active_run: activeRun }
|
|
106
153
|
}
|
|
107
154
|
|
|
108
155
|
return {
|
|
@@ -115,6 +162,7 @@ async function chatRoutes(fastify) {
|
|
|
115
162
|
created_at: session.created_at,
|
|
116
163
|
updated_at: session.updated_at,
|
|
117
164
|
},
|
|
165
|
+
active_run: activeRun,
|
|
118
166
|
}
|
|
119
167
|
})
|
|
120
168
|
|
|
@@ -159,31 +207,26 @@ async function chatRoutes(fastify) {
|
|
|
159
207
|
return { success: true }
|
|
160
208
|
})
|
|
161
209
|
|
|
162
|
-
// POST /api/chat/abort -
|
|
210
|
+
// POST /api/chat/abort - Explicitly kill the active run for a workspace.
|
|
211
|
+
// This is now the ONLY path that terminates the LLM. A dropped connection no
|
|
212
|
+
// longer does. Optionally accepts an explicit run_id.
|
|
163
213
|
fastify.post('/api/chat/abort', async (request, reply) => {
|
|
164
214
|
if (!verifyToken(request)) {
|
|
165
215
|
reply.code(401)
|
|
166
216
|
return { success: false, error: 'Unauthorized' }
|
|
167
217
|
}
|
|
168
218
|
|
|
169
|
-
|
|
219
|
+
const workspaceId = request.body?.workspace_id || null
|
|
220
|
+
const runId = request.body?.run_id || chatRunManager.getActiveRunId(workspaceId)
|
|
221
|
+
if (!runId) {
|
|
170
222
|
return { success: false, error: 'No active chat process' }
|
|
171
223
|
}
|
|
172
224
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
setTimeout(() => {
|
|
179
|
-
try {
|
|
180
|
-
if (activeChatChild && activeChatChild.pid === pid) {
|
|
181
|
-
activeChatChild.kill('SIGKILL')
|
|
182
|
-
}
|
|
183
|
-
} catch { /* already dead */ }
|
|
184
|
-
}, 2000)
|
|
185
|
-
|
|
186
|
-
return { success: true }
|
|
225
|
+
const aborted = chatRunManager.abort(runId)
|
|
226
|
+
if (!aborted) {
|
|
227
|
+
return { success: false, error: 'No active chat process' }
|
|
228
|
+
}
|
|
229
|
+
return { success: true, run_id: runId }
|
|
187
230
|
})
|
|
188
231
|
|
|
189
232
|
// POST /api/chat/reset - Carry over relevant messages and start fresh session
|
|
@@ -409,9 +452,11 @@ async function buildContextPrefix(message, context, sessionId, workspaceId, refe
|
|
|
409
452
|
'このAPIは内部で Playwright + Readability を回し、抽出済みJSONだけを返すため、',
|
|
410
453
|
'DOM全体がチャットに流れ込んでトークン肥大化することを防げる。',
|
|
411
454
|
'初回アクセスで学習したセレクタはSQLiteにキャッシュされ、2回目以降はLLM呼び出しなしで抽出される。',
|
|
455
|
+
'SPA(クライアント描画)も内部でDOM静止を待ってから抽出するためそのまま利用可。',
|
|
456
|
+
'無限スクロールで件数を増やしたい場合は `"scroll": {"strategy":"count","targetItems":50}` を付ける(どれだけ集めるかは呼び出し側が指定)。',
|
|
412
457
|
'',
|
|
413
|
-
'Playwright MCP (`mcp__playwright__*`) は
|
|
414
|
-
'
|
|
458
|
+
'Playwright MCP (`mcp__playwright__*`) は **ログイン・フォーム入力・複数画面の対話操作・「もっと見る」ボタン**が必要な場合のみ使用する。',
|
|
459
|
+
'単純な閲覧・要約・一覧取得用途(SPA・無限スクロール含む)ではMCPを使わない。',
|
|
415
460
|
'',
|
|
416
461
|
'HQ (`*.minion-agent.com`) のページURLは **Playwrightで開かずAPIで取得する**。ノートは `GET $HQ_URL/api/minion/workspaces/:wsId/notes/:id`、タスクは `GET $HQ_URL/api/minion/projects/:pid/tasks/:id`。チャットで参照されたノート/タスクの本文は上記「参照ノート」「参照チケット」ブロックに既に注入済み。',
|
|
417
462
|
''
|
|
@@ -425,6 +470,7 @@ async function buildContextPrefix(message, context, sessionId, workspaceId, refe
|
|
|
425
470
|
'成果物の保存先は以下のルールに従うこと。`/home/minion/` 直下にファイルを保存しないこと。',
|
|
426
471
|
'',
|
|
427
472
|
'- **テキスト成果物**(レポート、調査結果、要約等)→ ノートに保存: `hq note create <project_id> --title "タイトル" --content "本文"` (プロジェクト紐づけなしは `hq note create --workspace <ws_id> ...`)',
|
|
473
|
+
' - ロックされたノートの更新は `HTTP 423`(`note_locked`)で拒否される。解除は人間のみ可能なので、上書きを繰り返さずユーザーに依頼すること。',
|
|
428
474
|
'- **バイナリファイル**(PDF、画像、ZIP等)→ `~/files/` に配置(ユーザーがHQからダウンロード可能)',
|
|
429
475
|
'- **一時ファイル** → `/tmp/` に配置(作業後に削除)',
|
|
430
476
|
'',
|
|
@@ -569,99 +615,109 @@ function getLlmBinary() {
|
|
|
569
615
|
}
|
|
570
616
|
|
|
571
617
|
/**
|
|
572
|
-
*
|
|
573
|
-
*
|
|
574
|
-
*
|
|
575
|
-
*
|
|
618
|
+
* Tail a detached run into an SSE response. Subscribes from `cursor`, forwards
|
|
619
|
+
* each event (seq-stamped, so the client can resume), and resolves on the
|
|
620
|
+
* terminal `done` event OR when the client disconnects. Disconnecting only
|
|
621
|
+
* stops the tail — it never touches the underlying LLM process.
|
|
622
|
+
*
|
|
623
|
+
* @param {import('http').ServerResponse} res
|
|
624
|
+
* @param {string} runId
|
|
625
|
+
* @param {number} cursor last sequence the client already has
|
|
626
|
+
*/
|
|
627
|
+
function tailRunToResponse(res, runId, cursor) {
|
|
628
|
+
return new Promise(resolve => {
|
|
629
|
+
let settled = false
|
|
630
|
+
let unsubscribe = () => {}
|
|
631
|
+
const finish = () => {
|
|
632
|
+
if (settled) return
|
|
633
|
+
settled = true
|
|
634
|
+
try { unsubscribe() } catch { /* ignore */ }
|
|
635
|
+
resolve()
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
unsubscribe = chatRunManager.subscribe(runId, cursor, event => {
|
|
639
|
+
try { res.write(`data: ${JSON.stringify(event)}\n\n`) } catch { /* socket gone */ }
|
|
640
|
+
// The manager always closes a run with a terminal `done` (an `error`
|
|
641
|
+
// precedes it when the run failed), so `done` is our single stop signal.
|
|
642
|
+
if (event.type === 'done') finish()
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
// Client went away (tab close, navigation, proxy timeout, network blip):
|
|
646
|
+
// stop forwarding, but leave the run running so a reconnect can resume it.
|
|
647
|
+
res.on('close', finish)
|
|
648
|
+
})
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Build the LLM executor for a run. The executor is LLM-specific (plugin vs
|
|
653
|
+
* legacy CLI) but knows nothing about HTTP — it emits normalized wire events
|
|
654
|
+
* and returns the collected result. The run manager owns persistence, the
|
|
655
|
+
* terminal `done` event, and abort.
|
|
656
|
+
*
|
|
657
|
+
* @param {string} prompt
|
|
658
|
+
* @param {string|null} sessionId
|
|
659
|
+
* @returns {(emit: (e: object) => void, activeRef: { current: any }) => Promise<{ fullResponse: string, resolvedSessionId: string|null, turnCount: number }>}
|
|
576
660
|
*/
|
|
577
|
-
|
|
578
|
-
// Plugin system path: Primary is set → delegate to plugin
|
|
661
|
+
function buildInvoke(prompt, sessionId) {
|
|
579
662
|
const primary = getActivePrimary()
|
|
580
663
|
if (primary) {
|
|
581
|
-
return
|
|
664
|
+
return (emit, activeRef) => invokeViaPlugin(primary, prompt, sessionId, emit, activeRef)
|
|
582
665
|
}
|
|
583
|
-
return
|
|
666
|
+
return (emit, activeRef) => invokeViaLegacy(prompt, sessionId, emit, activeRef)
|
|
584
667
|
}
|
|
585
668
|
|
|
586
|
-
async function
|
|
669
|
+
async function invokeViaPlugin(plugin, prompt, sessionId, emit, activeRef) {
|
|
587
670
|
const input = { prompt }
|
|
588
|
-
const activeRef = { current: null }
|
|
589
|
-
activeChatChild = { kill: () => activeRef.current?.kill?.('SIGTERM') }
|
|
590
|
-
|
|
591
671
|
let fullResponse = ''
|
|
592
672
|
let resolvedSessionId = sessionId || null
|
|
593
673
|
let turnCount = 0
|
|
594
674
|
|
|
595
|
-
|
|
675
|
+
// Translate plugin events into wire events. `session` is captured (not
|
|
676
|
+
// forwarded); delta/text are accumulated; everything else is forwarded as-is.
|
|
677
|
+
const onEvent = event => {
|
|
596
678
|
if (event.type === 'session') {
|
|
597
679
|
resolvedSessionId = event.sessionId
|
|
598
680
|
} else if (event.type === 'delta') {
|
|
599
681
|
fullResponse += event.content
|
|
600
|
-
|
|
682
|
+
emit({ type: 'delta', content: event.content })
|
|
601
683
|
} else if (event.type === 'text') {
|
|
602
684
|
fullResponse += event.content
|
|
603
|
-
|
|
685
|
+
emit({ type: 'text', content: event.content })
|
|
604
686
|
turnCount++
|
|
605
687
|
} else {
|
|
606
|
-
|
|
688
|
+
emit(event)
|
|
607
689
|
}
|
|
608
690
|
}
|
|
609
691
|
|
|
610
|
-
res.on('close', () => { activeRef.current?.kill?.('SIGTERM') })
|
|
611
|
-
|
|
612
|
-
let pluginError = null
|
|
613
692
|
try {
|
|
614
693
|
let output
|
|
615
694
|
if (plugin.capabilities.streaming && typeof plugin.stream === 'function') {
|
|
616
|
-
output = await plugin.stream(input,
|
|
695
|
+
output = await plugin.stream(input, onEvent, { resumeSessionId: sessionId, activeChildRef: activeRef })
|
|
617
696
|
} else {
|
|
618
697
|
output = await plugin.invoke(input)
|
|
619
698
|
if (output.text) {
|
|
620
699
|
fullResponse = output.text
|
|
621
|
-
|
|
700
|
+
emit({ type: 'text', content: output.text })
|
|
622
701
|
turnCount = 1
|
|
623
702
|
}
|
|
624
703
|
if (output.error) {
|
|
625
|
-
|
|
704
|
+
emit({ type: 'error', error: output.error.message })
|
|
626
705
|
}
|
|
627
706
|
}
|
|
628
707
|
resolvedSessionId = output?.metadata?.sessionId || resolvedSessionId
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
pluginError = err
|
|
632
|
-
} finally {
|
|
633
|
-
activeChatChild = null
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
// For new sessions, the user message was persisted under pendingSessionId
|
|
637
|
-
// before the plugin call. Rekey it to the real session ID now that we
|
|
638
|
-
// know it. If the plugin never reported a session ID, leave the message
|
|
639
|
-
// under the pending key so the history isn't lost.
|
|
640
|
-
const persistSessionId = resolvedSessionId || pendingSessionId
|
|
641
|
-
try {
|
|
642
|
-
if (!sessionId && resolvedSessionId && pendingSessionId && pendingSessionId !== resolvedSessionId) {
|
|
643
|
-
chatStore.rekeySession(pendingSessionId, resolvedSessionId)
|
|
644
|
-
}
|
|
645
|
-
if (fullResponse && persistSessionId) {
|
|
646
|
-
await chatStore.addMessage(persistSessionId, { role: 'assistant', content: fullResponse }, turnCount, workspaceId)
|
|
708
|
+
if (output?.error) {
|
|
709
|
+
emit({ type: 'error', error: output.error.message, partial: !!fullResponse })
|
|
647
710
|
}
|
|
648
711
|
} catch (err) {
|
|
649
|
-
|
|
712
|
+
// Surface the error inline but still return the partial so the manager can
|
|
713
|
+
// persist it. (A spawn failure typically yields no partial.)
|
|
714
|
+
emit({ type: 'error', error: err.message, partial: !!fullResponse })
|
|
650
715
|
}
|
|
651
716
|
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
let totalTurnCount = turnCount
|
|
655
|
-
try {
|
|
656
|
-
const session = await chatStore.load(workspaceId)
|
|
657
|
-
totalTurnCount = session?.turn_count || turnCount
|
|
658
|
-
} catch (err) {
|
|
659
|
-
console.error('[Chat] failed to load session for done event:', err.message)
|
|
660
|
-
}
|
|
661
|
-
res.write(`data: ${JSON.stringify({ type: 'done', session_id: resolvedSessionId, turn_count: totalTurnCount })}\n\n`)
|
|
717
|
+
return { fullResponse, resolvedSessionId, turnCount }
|
|
662
718
|
}
|
|
663
719
|
|
|
664
|
-
function
|
|
720
|
+
function invokeViaLegacy(prompt, sessionId, emit, activeRef) {
|
|
665
721
|
return new Promise((resolve, reject) => {
|
|
666
722
|
const binaryName = getLlmBinary()
|
|
667
723
|
if (!binaryName) {
|
|
@@ -672,20 +728,8 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
|
|
|
672
728
|
const binary = fs.existsSync(binaryPath) ? binaryPath : binaryName
|
|
673
729
|
|
|
674
730
|
// Build CLI args (no --max-turns: allow unlimited turns for task completion)
|
|
675
|
-
const args = [
|
|
676
|
-
|
|
677
|
-
'--verbose',
|
|
678
|
-
'--model', 'sonnet',
|
|
679
|
-
'--output-format', 'stream-json',
|
|
680
|
-
]
|
|
681
|
-
|
|
682
|
-
// Resume existing session
|
|
683
|
-
if (sessionId) {
|
|
684
|
-
args.push('--resume', sessionId)
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
// Prompt is passed via stdin (not as CLI argument) to avoid
|
|
688
|
-
// shell argument parsing issues with spaces/special characters.
|
|
731
|
+
const args = ['-p', '--verbose', '--model', 'sonnet', '--output-format', 'stream-json']
|
|
732
|
+
if (sessionId) args.push('--resume', sessionId)
|
|
689
733
|
|
|
690
734
|
console.log(`[Chat] spawning: ${binary} ${sessionId ? `--resume ${sessionId}` : '(new session)'} (cwd: ${config.HOME_DIR})`)
|
|
691
735
|
|
|
@@ -697,21 +741,17 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
|
|
|
697
741
|
timeout: 3600000, // 60 min — allow long-running tasks to complete
|
|
698
742
|
})
|
|
699
743
|
|
|
700
|
-
//
|
|
701
|
-
|
|
744
|
+
// Expose the child to the run manager for abort. NOT tied to any HTTP request.
|
|
745
|
+
activeRef.current = child
|
|
702
746
|
|
|
703
|
-
// Write prompt to stdin and close — claude -p reads from stdin when no positional arg
|
|
704
747
|
child.stdin.write(prompt)
|
|
705
748
|
child.stdin.end()
|
|
706
|
-
|
|
707
749
|
console.log(`[Chat] child PID: ${child.pid}`)
|
|
708
750
|
|
|
709
751
|
let fullResponse = ''
|
|
710
752
|
let stderrBuffer = ''
|
|
711
753
|
let lineBuffer = ''
|
|
712
754
|
let resolvedSessionId = sessionId || null
|
|
713
|
-
|
|
714
|
-
// Block-type state tracking for correct event forwarding
|
|
715
755
|
let currentBlockType = null // 'text' | 'tool_use' | null
|
|
716
756
|
let currentToolName = null
|
|
717
757
|
let toolInputBuffer = ''
|
|
@@ -720,7 +760,6 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
|
|
|
720
760
|
child.stdout.on('data', (data) => {
|
|
721
761
|
lineBuffer += data.toString()
|
|
722
762
|
const parts = lineBuffer.split('\n')
|
|
723
|
-
// Keep the last (potentially incomplete) line in the buffer
|
|
724
763
|
lineBuffer = parts.pop() || ''
|
|
725
764
|
|
|
726
765
|
for (const line of parts) {
|
|
@@ -728,96 +767,69 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
|
|
|
728
767
|
try {
|
|
729
768
|
const parsed = JSON.parse(line)
|
|
730
769
|
|
|
731
|
-
// system init event — capture session_id
|
|
732
770
|
if (parsed.type === 'system' && parsed.session_id) {
|
|
733
771
|
resolvedSessionId = parsed.session_id
|
|
734
772
|
console.log(`[Chat] session_id: ${resolvedSessionId}`)
|
|
735
773
|
}
|
|
736
774
|
|
|
737
|
-
// content_block_start — track block type
|
|
738
775
|
if (parsed.type === 'content_block_start') {
|
|
739
776
|
const blockType = parsed.content_block?.type
|
|
740
777
|
if (blockType === 'tool_use') {
|
|
741
778
|
currentBlockType = 'tool_use'
|
|
742
779
|
currentToolName = parsed.content_block.name || 'unknown'
|
|
743
780
|
toolInputBuffer = ''
|
|
744
|
-
|
|
745
|
-
type: 'tool_start',
|
|
746
|
-
tool: currentToolName,
|
|
747
|
-
})
|
|
748
|
-
res.write(`data: ${event}\n\n`)
|
|
781
|
+
emit({ type: 'tool_start', tool: currentToolName })
|
|
749
782
|
} else if (blockType === 'text') {
|
|
750
783
|
currentBlockType = 'text'
|
|
751
784
|
}
|
|
752
785
|
}
|
|
753
786
|
|
|
754
|
-
// content_block_delta — handle both text and tool input
|
|
755
787
|
if (parsed.type === 'content_block_delta') {
|
|
756
788
|
const deltaType = parsed.delta?.type
|
|
757
789
|
if (deltaType === 'input_json_delta' && currentBlockType === 'tool_use') {
|
|
758
|
-
// Accumulate tool input JSON
|
|
759
790
|
const partial = parsed.delta.partial_json || ''
|
|
760
791
|
if (partial) {
|
|
761
792
|
toolInputBuffer += partial
|
|
762
|
-
|
|
763
|
-
res.write(`data: ${event}\n\n`)
|
|
793
|
+
emit({ type: 'tool_input_delta', partial_json: partial })
|
|
764
794
|
}
|
|
765
795
|
} else {
|
|
766
|
-
// Text delta
|
|
767
796
|
const delta = parsed.delta?.text || ''
|
|
768
797
|
if (delta) {
|
|
769
798
|
fullResponse += delta
|
|
770
|
-
|
|
771
|
-
res.write(`data: ${event}\n\n`)
|
|
799
|
+
emit({ type: 'delta', content: delta })
|
|
772
800
|
}
|
|
773
801
|
}
|
|
774
802
|
}
|
|
775
803
|
|
|
776
|
-
// content_block_stop — only emit tool_end for tool_use blocks (bug fix)
|
|
777
804
|
if (parsed.type === 'content_block_stop') {
|
|
778
805
|
if (currentBlockType === 'tool_use') {
|
|
779
|
-
// Try to parse the accumulated tool input
|
|
780
806
|
let parsedInput = null
|
|
781
807
|
try {
|
|
782
808
|
if (toolInputBuffer) parsedInput = JSON.parse(toolInputBuffer)
|
|
783
809
|
} catch { /* partial or invalid JSON */ }
|
|
784
|
-
|
|
785
|
-
type: 'tool_end',
|
|
786
|
-
tool: currentToolName,
|
|
787
|
-
input: parsedInput,
|
|
788
|
-
})
|
|
789
|
-
res.write(`data: ${event}\n\n`)
|
|
810
|
+
emit({ type: 'tool_end', tool: currentToolName, input: parsedInput })
|
|
790
811
|
}
|
|
791
812
|
currentBlockType = null
|
|
792
813
|
currentToolName = null
|
|
793
814
|
toolInputBuffer = ''
|
|
794
815
|
}
|
|
795
816
|
|
|
796
|
-
// assistant message — count turns and forward text blocks
|
|
797
817
|
if (parsed.type === 'assistant' && parsed.message) {
|
|
798
818
|
turnCount++
|
|
799
819
|
for (const block of (parsed.message.content || [])) {
|
|
800
820
|
if (block.type === 'text') {
|
|
801
821
|
fullResponse += block.text
|
|
802
|
-
|
|
803
|
-
res.write(`data: ${event}\n\n`)
|
|
822
|
+
emit({ type: 'text', content: block.text })
|
|
804
823
|
}
|
|
805
824
|
}
|
|
806
825
|
} else if (parsed.type === 'result') {
|
|
807
|
-
// result event — forward but do NOT overwrite accumulated fullResponse
|
|
808
826
|
const resultText = parsed.result || ''
|
|
809
827
|
if (resultText) {
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
// Use result as fullResponse only if nothing was accumulated
|
|
813
|
-
// (single-turn responses without deltas)
|
|
814
|
-
if (!fullResponse) {
|
|
815
|
-
fullResponse = resultText
|
|
816
|
-
}
|
|
828
|
+
emit({ type: 'result', content: resultText })
|
|
829
|
+
if (!fullResponse) fullResponse = resultText
|
|
817
830
|
}
|
|
818
831
|
}
|
|
819
832
|
} catch {
|
|
820
|
-
// Non-JSON line — ignore
|
|
821
833
|
console.warn(`[Chat] ignoring non-JSON line: ${line.substring(0, 80)}`)
|
|
822
834
|
}
|
|
823
835
|
}
|
|
@@ -829,72 +841,26 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
|
|
|
829
841
|
console.error(`[Chat] stderr: ${text}`)
|
|
830
842
|
})
|
|
831
843
|
|
|
832
|
-
child.on('close',
|
|
833
|
-
|
|
834
|
-
|
|
844
|
+
child.on('close', (code) => {
|
|
845
|
+
activeRef.current = null
|
|
835
846
|
console.log(`[Chat] child closed: code=${code}, response=${fullResponse.length}chars, turns=${turnCount}, stderr=${stderrBuffer.length}bytes, session=${resolvedSessionId}`)
|
|
836
847
|
if (stderrBuffer.trim()) {
|
|
837
848
|
console.log(`[Chat] final stderr (tail 500): ${stderrBuffer.slice(-500)}`)
|
|
838
849
|
}
|
|
839
|
-
|
|
840
|
-
// For new sessions, the user message was already persisted under
|
|
841
|
-
// pendingSessionId before spawn. Rekey it to the real session ID when
|
|
842
|
-
// Claude CLI reported one; otherwise leave the message under the
|
|
843
|
-
// pending key so the history is never lost on crash.
|
|
844
|
-
const persistSessionId = resolvedSessionId || pendingSessionId
|
|
845
|
-
try {
|
|
846
|
-
if (!sessionId && resolvedSessionId && pendingSessionId && pendingSessionId !== resolvedSessionId) {
|
|
847
|
-
chatStore.rekeySession(pendingSessionId, resolvedSessionId)
|
|
848
|
-
}
|
|
849
|
-
// Persist any partial response we managed to collect, even on error
|
|
850
|
-
if (fullResponse && persistSessionId) {
|
|
851
|
-
await chatStore.addMessage(persistSessionId, { role: 'assistant', content: fullResponse }, turnCount, workspaceId)
|
|
852
|
-
}
|
|
853
|
-
} catch (err) {
|
|
854
|
-
console.error('[Chat] failed to persist assistant message:', err.message)
|
|
855
|
-
}
|
|
856
|
-
|
|
857
850
|
if (code !== 0) {
|
|
858
851
|
const errorMsg = stderrBuffer.trim() || `Claude CLI exited with code ${code}`
|
|
859
852
|
console.error(`[Chat] CLI failed (exit ${code}, partial=${!!fullResponse}): ${errorMsg}`)
|
|
860
|
-
|
|
861
|
-
type: 'error',
|
|
862
|
-
error: errorMsg,
|
|
863
|
-
partial: !!fullResponse,
|
|
864
|
-
exit_code: code,
|
|
865
|
-
})
|
|
866
|
-
res.write(`data: ${errorEvent}\n\n`)
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
// Load current turn count from session for the done event
|
|
870
|
-
let totalTurnCount = turnCount
|
|
871
|
-
try {
|
|
872
|
-
const session = await chatStore.load(workspaceId)
|
|
873
|
-
totalTurnCount = session?.turn_count || turnCount
|
|
874
|
-
} catch (err) {
|
|
875
|
-
console.error('[Chat] failed to load session for done event:', err.message)
|
|
853
|
+
emit({ type: 'error', error: errorMsg, partial: !!fullResponse, exit_code: code })
|
|
876
854
|
}
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
session_id: resolvedSessionId,
|
|
881
|
-
turn_count: totalTurnCount,
|
|
882
|
-
})
|
|
883
|
-
res.write(`data: ${doneEvent}\n\n`)
|
|
884
|
-
resolve()
|
|
855
|
+
// Resolve (don't reject) on non-zero exit so the manager persists the
|
|
856
|
+
// partial response and emits a clean terminal `done`.
|
|
857
|
+
resolve({ fullResponse, resolvedSessionId, turnCount })
|
|
885
858
|
})
|
|
886
859
|
|
|
887
860
|
child.on('error', (err) => {
|
|
888
|
-
|
|
861
|
+
activeRef.current = null
|
|
889
862
|
console.error(`[Chat] spawn error: ${err.message}`)
|
|
890
|
-
|
|
891
|
-
res.write(`data: ${errorEvent}\n\n`)
|
|
892
|
-
reject(err)
|
|
893
|
-
})
|
|
894
|
-
|
|
895
|
-
// Handle client disconnect
|
|
896
|
-
res.on('close', () => {
|
|
897
|
-
child.kill('SIGTERM')
|
|
863
|
+
reject(new Error(`Failed to start Claude CLI: ${err.message}`))
|
|
898
864
|
})
|
|
899
865
|
})
|
|
900
866
|
}
|
package/package.json
CHANGED
package/rules/core.md
CHANGED
|
@@ -132,7 +132,15 @@ curl -X POST http://localhost:8080/api/web/extract \
|
|
|
132
132
|
|
|
133
133
|
レシピは初回アクセス時に LLM (Haiku) で生成・SQLite (`page_recipes` テーブル) に保存され、2回目以降の構造的に同じページでは LLM 呼び出しなしで抽出される。
|
|
134
134
|
|
|
135
|
-
|
|
135
|
+
**SPA / 無限スクロール (v4.7.0〜):** SPA (クライアント描画) は内部で DOM が静止するまで待ってから抽出するためそのまま利用できる。無限スクロールで件数を確保したい場合は `scroll` オプションを付ける(どれだけ集めるかは呼び出し側が指定。`scrollInfo.reachedTarget=false` なら上限打ち切りなので `maxScrolls`/`maxMs` を上げて再試行):
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
curl -X POST http://localhost:8080/api/web/extract \
|
|
139
|
+
-H "Authorization: Bearer $API_TOKEN" -H "Content-Type: application/json" \
|
|
140
|
+
-d '{"url": "対象URL", "scroll": {"strategy": "count", "targetItems": 50, "maxScrolls": 20}}'
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Playwright MCP (`mcp__playwright__*`) は **フォーム入力・クリック・複数画面遷移・「もっと見る」ボタンなど対話的な操作**が必要な場合のみ使用すること。単に「ページを読む」目的 (SPA・無限スクロール含む) では MCP を使わない。
|
|
136
144
|
|
|
137
145
|
**実験的機能**: レスポンス形状は予告なく変わる可能性がある。要件: (1) primary LLM 設定済み (`PUT /api/llm/config` で `claude` 等を選択、`hq llm primary <name>` でも可) または `ANTHROPIC_API_KEY` シークレット設定済み、(2) ホスト上で `npx playwright install chromium` 実行済み。primary LLM が設定されていれば API キー不要 (Claude Code CLI の認証情報を再利用)。
|
|
138
146
|
|
|
@@ -238,7 +246,7 @@ Routine 実行中は以下もtmuxセッション環境で利用可能:
|
|
|
238
246
|
- `MINION_ROUTINE_NAME` — ルーティン名
|
|
239
247
|
- `MINION_ROUTINE_WORKSPACE_ID` — ルーティンが特定ワークスペースにバインドされている場合のワークスペースUUID(未バインドなら空文字)
|
|
240
248
|
|
|
241
|
-
**変数**(ワークスペース変数・プロジェクト変数・ワークフロー変数・ミニオン変数)はスキル本文の `{{VAR_NAME}}` テンプレートとして実行時に展開される。スキル作成時にパラメータ化したい値は `{{変数名}}` で記述すること。展開優先順位: ワークスペース < プロジェクト < ワークフロー < ミニオン(後者が優先)。ミニオンが最終オーバーライドとなる設計で、ミニオン運用者がHQ側のデフォルトを差し替えられる経路を保証する。ミニオン変数はさらにミニオン全体スコープとワークスペース別スコープに分かれ、当該ワークスペースのコンテキストで動く実行時はWS別がミニオン全体を上書きする。`/api/variables/*` は `?workspace_id=<uuid>`
|
|
249
|
+
**変数**(ワークスペース変数・プロジェクト変数・ワークフロー変数・ミニオン変数)はスキル本文の `{{VAR_NAME}}` テンプレートとして実行時に展開される。スキル作成時にパラメータ化したい値は `{{変数名}}` で記述すること。展開優先順位: ワークスペース < プロジェクト < ワークフロー < ミニオン(後者が優先)。ミニオンが最終オーバーライドとなる設計で、ミニオン運用者がHQ側のデフォルトを差し替えられる経路を保証する。ミニオン変数はさらにミニオン全体スコープとワークスペース別スコープに分かれ、当該ワークスペースのコンテキストで動く実行時はWS別がミニオン全体を上書きする。`/api/variables/*` は `?workspace_id=<uuid>` でスコープ指定(省略時はミニオン全体)。誤ったスコープに登録してしまった変数・シークレットは `POST /api/variables/:key/move`・`POST /api/secrets/:key/move`(body: `{from_workspace_id, to_workspace_id}`)で削除・再登録せずに別スコープへ移動できる。移動先に同名キーがある場合は上書きせず `409` を返す。
|
|
242
250
|
|
|
243
251
|
DAGワークフローでは、HQ側スコープ(workspace/project/dag_workflow)の変数は **`POST /api/dag/minion/claim-node` のレスポンス `template_vars`** に乗って降ってきて、ミニオン側で minion-local 変数とマージしたうえで `POST /api/skills/fetch/:name?vars=...` の base64 JSON に詰めて HQ に渡し、HQ 側で SKILL.md の `{{VAR}}` を置換して返す。つまり HQ-scope 変数の更新は **次のノード claim から反映される**(既存のミニオン上 SKILL.md は次回 fetch で上書きされる)。DAGワークフロー単位の変数は `dag_workflow_variables` テーブルに格納され、DAGエディタの "Variables" ボタンから編集可能。
|
|
244
252
|
|
|
@@ -485,6 +493,8 @@ hq note search --workspace <workspace_id> "キーワード"
|
|
|
485
493
|
|
|
486
494
|
**削除はミニオンから実行できない**(事故防止のため人間操作限定)。不要なノートは `hq note update ... --status archived` (または PATCH の `status: "archived"`) でアーカイブすること。
|
|
487
495
|
|
|
496
|
+
**ロックされたノートは更新できない。** ユーザーがパスワード等を保護するために「ロック」したノートは、`hq note update` / PATCH が `HTTP 423`(`{"code":"note_locked"}`)で拒否される。**ロック解除は人間のみ**が HQ ダッシュボードで行えるため、ミニオンは解除できない。423 を受けたら上書きを繰り返さず、更新が必要な場合は threads でユーザーにロック解除を依頼すること。
|
|
497
|
+
|
|
488
498
|
### ノート内のユーザーメンション
|
|
489
499
|
|
|
490
500
|
人間ユーザーがノート編集UIで `Ctrl+I` を押すと、自分の発言を示すアバター付きチップが行頭に挿入される。マークダウン上は次の形式で永続化される:
|