@geekbeer/minion 4.1.3 → 4.2.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/stores/chat-store.js +63 -31
- package/docs/api-reference.md +57 -37
- package/linux/bin/hq +68 -41
- package/linux/routes/chat.js +62 -27
- package/package.json +1 -1
- package/rules/core.md +16 -12
- package/win/routes/chat.js +81 -36
|
@@ -133,6 +133,7 @@ function save(session) {
|
|
|
133
133
|
* Add a message to a session.
|
|
134
134
|
* Creates a new session if none exists for the given session_id.
|
|
135
135
|
* Past sessions are preserved (not deleted).
|
|
136
|
+
* Wrapped in a transaction so partial saves can't happen on failure.
|
|
136
137
|
* @param {string} sessionId - Claude CLI session ID
|
|
137
138
|
* @param {{ role: string, content: string }} msg - Message to add
|
|
138
139
|
* @param {number} [turnCount] - Optional turn count to add
|
|
@@ -140,40 +141,71 @@ function save(session) {
|
|
|
140
141
|
*/
|
|
141
142
|
function addMessage(sessionId, msg, turnCount, workspaceId) {
|
|
142
143
|
const db = getDb()
|
|
143
|
-
const
|
|
144
|
+
const tx = db.transaction(() => {
|
|
145
|
+
const existing = db.prepare('SELECT session_id FROM chat_sessions WHERE session_id = ?').get(sessionId)
|
|
146
|
+
|
|
147
|
+
if (!existing) {
|
|
148
|
+
const now = Date.now()
|
|
149
|
+
db.prepare(
|
|
150
|
+
'INSERT INTO chat_sessions (session_id, workspace_id, turn_count, created_at, updated_at) VALUES (?, ?, ?, ?, ?)'
|
|
151
|
+
).run(sessionId, workspaceId || null, 0, now, now)
|
|
152
|
+
}
|
|
144
153
|
|
|
145
|
-
// If session doesn't exist, create a new one (past sessions remain in DB)
|
|
146
|
-
if (!existing) {
|
|
147
|
-
const now = Date.now()
|
|
148
154
|
db.prepare(
|
|
149
|
-
'INSERT INTO
|
|
150
|
-
).run(sessionId,
|
|
151
|
-
}
|
|
155
|
+
'INSERT INTO chat_messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)'
|
|
156
|
+
).run(sessionId, msg.role, msg.content, Date.now())
|
|
152
157
|
|
|
153
|
-
|
|
154
|
-
db.prepare(
|
|
155
|
-
'INSERT INTO chat_messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)'
|
|
156
|
-
).run(sessionId, msg.role, msg.content, Date.now())
|
|
157
|
-
|
|
158
|
-
// Update session metadata
|
|
159
|
-
const newTurnCount = (turnCount && turnCount > 0) ? turnCount : 0
|
|
160
|
-
db.prepare(`
|
|
161
|
-
UPDATE chat_sessions
|
|
162
|
-
SET updated_at = ?,
|
|
163
|
-
turn_count = turn_count + ?
|
|
164
|
-
WHERE session_id = ?
|
|
165
|
-
`).run(Date.now(), newTurnCount, sessionId)
|
|
166
|
-
|
|
167
|
-
// Prune old messages for this session (keep only MAX_MESSAGES most recent)
|
|
168
|
-
const count = db.prepare('SELECT COUNT(*) as cnt FROM chat_messages WHERE session_id = ?').get(sessionId).cnt
|
|
169
|
-
if (count > MAX_MESSAGES) {
|
|
158
|
+
const newTurnCount = (turnCount && turnCount > 0) ? turnCount : 0
|
|
170
159
|
db.prepare(`
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
`).run(
|
|
176
|
-
|
|
160
|
+
UPDATE chat_sessions
|
|
161
|
+
SET updated_at = ?,
|
|
162
|
+
turn_count = turn_count + ?
|
|
163
|
+
WHERE session_id = ?
|
|
164
|
+
`).run(Date.now(), newTurnCount, sessionId)
|
|
165
|
+
|
|
166
|
+
const count = db.prepare('SELECT COUNT(*) as cnt FROM chat_messages WHERE session_id = ?').get(sessionId).cnt
|
|
167
|
+
if (count > MAX_MESSAGES) {
|
|
168
|
+
db.prepare(`
|
|
169
|
+
DELETE FROM chat_messages WHERE id IN (
|
|
170
|
+
SELECT id FROM chat_messages WHERE session_id = ?
|
|
171
|
+
ORDER BY id ASC LIMIT ?
|
|
172
|
+
)
|
|
173
|
+
`).run(sessionId, count - MAX_MESSAGES)
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
tx()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Rename a session's ID, carrying over all messages.
|
|
181
|
+
* Used when a pending session ID (generated locally before the LLM call) is
|
|
182
|
+
* replaced by the real session_id returned by Claude CLI.
|
|
183
|
+
* If `newSessionId` already exists, messages from `oldSessionId` are merged
|
|
184
|
+
* into it and the old session row is dropped.
|
|
185
|
+
* @param {string} oldSessionId
|
|
186
|
+
* @param {string} newSessionId
|
|
187
|
+
* @returns {boolean} true if any rows were touched
|
|
188
|
+
*/
|
|
189
|
+
function rekeySession(oldSessionId, newSessionId) {
|
|
190
|
+
if (!oldSessionId || !newSessionId || oldSessionId === newSessionId) return false
|
|
191
|
+
const db = getDb()
|
|
192
|
+
const tx = db.transaction(() => {
|
|
193
|
+
const oldSession = db.prepare('SELECT * FROM chat_sessions WHERE session_id = ?').get(oldSessionId)
|
|
194
|
+
if (!oldSession) return false
|
|
195
|
+
|
|
196
|
+
const existingNew = db.prepare('SELECT session_id FROM chat_sessions WHERE session_id = ?').get(newSessionId)
|
|
197
|
+
if (existingNew) {
|
|
198
|
+
db.prepare('UPDATE chat_messages SET session_id = ? WHERE session_id = ?').run(newSessionId, oldSessionId)
|
|
199
|
+
db.prepare('UPDATE chat_sessions SET updated_at = ?, turn_count = turn_count + ? WHERE session_id = ?')
|
|
200
|
+
.run(Date.now(), oldSession.turn_count || 0, newSessionId)
|
|
201
|
+
db.prepare('DELETE FROM chat_sessions WHERE session_id = ?').run(oldSessionId)
|
|
202
|
+
} else {
|
|
203
|
+
db.prepare('UPDATE chat_sessions SET session_id = ? WHERE session_id = ?').run(newSessionId, oldSessionId)
|
|
204
|
+
db.prepare('UPDATE chat_messages SET session_id = ? WHERE session_id = ?').run(newSessionId, oldSessionId)
|
|
205
|
+
}
|
|
206
|
+
return true
|
|
207
|
+
})
|
|
208
|
+
return tx() === true
|
|
177
209
|
}
|
|
178
210
|
|
|
179
211
|
/**
|
|
@@ -208,4 +240,4 @@ function deleteSession(sessionId) {
|
|
|
208
240
|
return result.changes > 0
|
|
209
241
|
}
|
|
210
242
|
|
|
211
|
-
module.exports = { load, loadById, listSessions, save, addMessage, clear, deleteSession }
|
|
243
|
+
module.exports = { load, loadById, listSessions, save, addMessage, rekeySession, clear, deleteSession }
|
package/docs/api-reference.md
CHANGED
|
@@ -809,20 +809,22 @@ Base path: All endpoints are prefixed with `/api/minion` by the agent's API clie
|
|
|
809
809
|
|--------|----------|-------------|
|
|
810
810
|
| GET | `/api/minion/me/projects` | 参加プロジェクト一覧(role 含む) |
|
|
811
811
|
|
|
812
|
+
Query parameters:
|
|
813
|
+
- `workspace_id` (optional, UUID): 指定したワークスペースに属するプロジェクトのみ返す。省略すると参加している全プロジェクト(複数ワークスペース横断)を返す。
|
|
814
|
+
|
|
812
815
|
Response:
|
|
813
816
|
```json
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
]
|
|
817
|
+
{
|
|
818
|
+
"projects": [
|
|
819
|
+
{
|
|
820
|
+
"id": "uuid",
|
|
821
|
+
"name": "project-name",
|
|
822
|
+
"description": "...",
|
|
823
|
+
"workspace_id": "uuid",
|
|
824
|
+
"role": "pm"
|
|
825
|
+
}
|
|
826
|
+
]
|
|
827
|
+
}
|
|
826
828
|
```
|
|
827
829
|
|
|
828
830
|
`role` is one of `"pm"` (project manager), `"engineer"`, or `"accountant"`.
|
|
@@ -2107,56 +2109,74 @@ DAGスキルノードの `skill_version_id` は **オプション** です。
|
|
|
2107
2109
|
ローカルエージェントの `/api/project-memories` は上記 HQ API へのプロキシ。
|
|
2108
2110
|
詳細なリクエスト/レスポンス仕様はローカル API セクションの「Project Memories」を参照。
|
|
2109
2111
|
|
|
2110
|
-
### Notes (HQ,
|
|
2112
|
+
### Notes (HQ, ワークスペース/プロジェクトノート)
|
|
2113
|
+
|
|
2114
|
+
ノートはワークスペース直下に保存され、任意で 1 つ以上のプロジェクトにリンクできる(`note_project_links`)。
|
|
2115
|
+
**テキストベースの成果物(レポート、調査結果、要約等)はノートに保存すること。** HQダッシュボードでユーザーが即座に閲覧・編集・検索でき、バージョン管理もされる。
|
|
2116
|
+
|
|
2117
|
+
ミニオンには**ワークスペース経由API**と**プロジェクト経由API**の2系統がある:
|
|
2111
2118
|
|
|
2112
|
-
|
|
2113
|
-
|
|
2119
|
+
- **ワークスペース経由** — 自分が所属するワークスペース内のすべてのノートにアクセス可能 (プロジェクト未リンクのノート含む)。一般的な検索・整理に使う
|
|
2120
|
+
- **プロジェクト経由** — 作業コンテキストが特定プロジェクトに紐づいているときに、そのプロジェクトのノートだけを絞り込む用途。プロジェクトメンバーシップで認可される
|
|
2121
|
+
|
|
2122
|
+
**削除エンドポイントは存在しない**(事故防止のため人間操作限定)。アーカイブが必要な場合は `status=archived` で PATCH すること。
|
|
2123
|
+
|
|
2124
|
+
#### Workspace-scoped Notes
|
|
2114
2125
|
|
|
2115
2126
|
| Method | Endpoint | Description |
|
|
2116
2127
|
|--------|----------|-------------|
|
|
2117
|
-
| GET | `/api/minion/
|
|
2118
|
-
| POST | `/api/minion/
|
|
2119
|
-
| GET | `/api/minion/
|
|
2120
|
-
| PATCH | `/api/minion/
|
|
2121
|
-
| GET | `/api/minion/
|
|
2128
|
+
| GET | `/api/minion/workspaces/:id/notes` | ワークスペースのノート一覧。Query: `?status=active&search=&project_id=&include_content=true` |
|
|
2129
|
+
| POST | `/api/minion/workspaces/:id/notes` | ノート作成。Body: `{title, content?, project_ids?: string[]}`。`project_ids` 指定時は自動でリンク |
|
|
2130
|
+
| GET | `/api/minion/workspaces/:id/notes/:noteId` | ノート詳細(全文) |
|
|
2131
|
+
| PATCH | `/api/minion/workspaces/:id/notes/:noteId` | 更新(内容変更時にバージョン自動作成)。Body: `{title?, content?, status?, change_summary?}` |
|
|
2132
|
+
| GET | `/api/minion/workspaces/:id/notes/search?q=keyword` | ワークスペース全体を全文検索 |
|
|
2122
2133
|
|
|
2123
|
-
POST `/api/minion/
|
|
2134
|
+
POST `/api/minion/workspaces/:id/notes` body:
|
|
2124
2135
|
```json
|
|
2125
2136
|
{
|
|
2126
2137
|
"title": "調査レポート: ユーザー行動分析",
|
|
2127
|
-
"content": "## 概要\n..."
|
|
2138
|
+
"content": "## 概要\n...",
|
|
2139
|
+
"project_ids": ["<uuid>"]
|
|
2128
2140
|
}
|
|
2129
2141
|
```
|
|
2130
2142
|
|
|
2131
2143
|
| Field | Type | Required | Description |
|
|
2132
2144
|
|-------|------|----------|-------------|
|
|
2133
|
-
| `title` | string | Yes |
|
|
2134
|
-
| `content` | string | No |
|
|
2145
|
+
| `title` | string | Yes | ワークスペース内で一意である必要がある |
|
|
2146
|
+
| `content` | string | No | Markdown本文 |
|
|
2147
|
+
| `project_ids` | string[] | No | リンクするプロジェクト(ワークスペースに属している必要あり) |
|
|
2135
2148
|
|
|
2136
|
-
|
|
2137
|
-
```json
|
|
2138
|
-
{
|
|
2139
|
-
"title": "更新されたタイトル",
|
|
2140
|
-
"content": "更新された本文",
|
|
2141
|
-
"change_summary": "データを追加"
|
|
2142
|
-
}
|
|
2143
|
-
```
|
|
2149
|
+
#### Project-scoped Notes
|
|
2144
2150
|
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
|
2148
|
-
|
|
2149
|
-
|
|
|
2151
|
+
特定プロジェクトのノートだけを絞り込みたいときに使う。認可は `project_members` を経由。
|
|
2152
|
+
|
|
2153
|
+
| Method | Endpoint | Description |
|
|
2154
|
+
|--------|----------|-------------|
|
|
2155
|
+
| GET | `/api/minion/projects/:projectId/notes` | プロジェクトのノート一覧。Query: `?status=active&include_content=true` |
|
|
2156
|
+
| POST | `/api/minion/projects/:projectId/notes` | ノート作成 + 当該プロジェクトに自動リンク |
|
|
2157
|
+
| GET | `/api/minion/projects/:projectId/notes/:noteId` | ノート詳細(全文含む) |
|
|
2158
|
+
| PATCH | `/api/minion/projects/:projectId/notes/:noteId` | ノートを更新(内容変更時にバージョン自動作成) |
|
|
2159
|
+
| GET | `/api/minion/projects/:projectId/notes/search?q=keyword` | プロジェクト内のノートを全文検索 |
|
|
2160
|
+
|
|
2161
|
+
PATCH body は workspace 版と共通: `{title?, content?, change_summary?}` (status は workspace 経由のみ受理)。
|
|
2150
2162
|
|
|
2151
2163
|
#### hq CLI ラッパー
|
|
2152
2164
|
|
|
2153
2165
|
```bash
|
|
2166
|
+
# プロジェクトスコープ
|
|
2154
2167
|
hq note create <project_id> --title "タイトル" --content "本文"
|
|
2155
2168
|
hq note create <project_id> --title "タイトル" --file /tmp/report.md
|
|
2156
2169
|
hq note update <project_id> <note_id> --content "更新内容" --change-summary "修正理由"
|
|
2157
2170
|
hq note list <project_id>
|
|
2158
2171
|
hq note get <project_id> <note_id>
|
|
2159
2172
|
hq note search <project_id> "キーワード"
|
|
2173
|
+
|
|
2174
|
+
# ワークスペーススコープ (プロジェクト紐づけなしも対象)
|
|
2175
|
+
hq note create --workspace <workspace_id> --title "タイトル" --content "本文"
|
|
2176
|
+
hq note update --workspace <workspace_id> <note_id> --content "更新内容"
|
|
2177
|
+
hq note list --workspace <workspace_id>
|
|
2178
|
+
hq note get --workspace <workspace_id> <note_id>
|
|
2179
|
+
hq note search --workspace <workspace_id> "キーワード"
|
|
2160
2180
|
```
|
|
2161
2181
|
|
|
2162
2182
|
### Meeting Rooms 🧪 (HQ, experimental, v3.59.0〜)
|
package/linux/bin/hq
CHANGED
|
@@ -19,12 +19,17 @@
|
|
|
19
19
|
# hq list workspaces - List workspaces this minion belongs to
|
|
20
20
|
# hq list dag-workflows [project_id] - List DAG workflows (optionally filter by project)
|
|
21
21
|
# hq note create <project_id> --title "Title" --content "Body" [--file path]
|
|
22
|
-
#
|
|
22
|
+
# hq note create --workspace <workspace_id> --title "Title" --content "Body" [--file path] [--project-ids <id,id>]
|
|
23
|
+
# - Create a note (project- or workspace-scoped)
|
|
23
24
|
# hq note update <project_id> <note_id> --content "Body" [--title "Title"] [--file path] [--change-summary "summary"]
|
|
25
|
+
# hq note update --workspace <workspace_id> <note_id> --content "Body" [--title "Title"] [--status active|archived]
|
|
24
26
|
# - Update an existing note
|
|
25
27
|
# hq note list <project_id> - List notes in the project
|
|
28
|
+
# hq note list --workspace <workspace_id> - List notes in the workspace (incl. project-unlinked)
|
|
26
29
|
# hq note get <project_id> <note_id> - Get a single note with full content
|
|
30
|
+
# hq note get --workspace <workspace_id> <note_id> - Same, workspace-scoped
|
|
27
31
|
# hq note search <project_id> <query> - Search notes in the project
|
|
32
|
+
# hq note search --workspace <workspace_id> <query> - Search across the whole workspace
|
|
28
33
|
# hq create dag-workflow <body.json> - Create a DAG workflow (PM only). Body: {project_id, name, graph?, ...}
|
|
29
34
|
# hq put dag-workflow <id> <body.json> - Update DAG workflow draft (PM only). Body: {graph?, content?, ...}
|
|
30
35
|
# hq publish dag-workflow <id> - Publish the draft as a new version (PM only, validated)
|
|
@@ -163,10 +168,15 @@ print_usage() {
|
|
|
163
168
|
echo " hq list workspaces - List workspaces this minion belongs to" >&2
|
|
164
169
|
echo " hq list dag-workflows [project_id] - List DAG workflows (optionally by project)" >&2
|
|
165
170
|
echo " hq note create <project_id> --title \"Title\" --content \"Body\" [--file path]" >&2
|
|
171
|
+
echo " hq note create --workspace <ws_id> --title \"Title\" --content \"Body\" [--project-ids <id,id>]" >&2
|
|
166
172
|
echo " hq note update <project_id> <note_id> --content \"Body\" [--title \"Title\"] [--file path]" >&2
|
|
167
|
-
echo " hq note
|
|
168
|
-
echo " hq note
|
|
169
|
-
echo " hq note
|
|
173
|
+
echo " hq note update --workspace <ws_id> <note_id> --content \"Body\" [--title \"Title\"] [--status active|archived]" >&2
|
|
174
|
+
echo " hq note list <project_id> - List notes in the project" >&2
|
|
175
|
+
echo " hq note list --workspace <ws_id> - List notes in the workspace" >&2
|
|
176
|
+
echo " hq note get <project_id> <note_id> - Get a single note" >&2
|
|
177
|
+
echo " hq note get --workspace <ws_id> <note_id> - Get a single note (workspace-scoped)" >&2
|
|
178
|
+
echo " hq note search <project_id> <query> - Search notes in the project" >&2
|
|
179
|
+
echo " hq note search --workspace <ws_id> <query> - Search notes across the workspace" >&2
|
|
170
180
|
echo " hq create dag-workflow <body.json> - Create a DAG workflow (PM only)" >&2
|
|
171
181
|
echo " hq put dag-workflow <id> <body.json> - Update DAG workflow draft (PM only)" >&2
|
|
172
182
|
echo " hq publish dag-workflow <id> - Publish the draft as a new version (PM only)" >&2
|
|
@@ -310,22 +320,38 @@ case "${1:-}" in
|
|
|
310
320
|
|
|
311
321
|
note)
|
|
312
322
|
action="${2:-}"
|
|
323
|
+
shift 2 || true
|
|
324
|
+
# Resolve scope: either "--workspace <ws_id>" (must be first) or positional <project_id>.
|
|
325
|
+
note_base=""
|
|
326
|
+
if [ "${1:-}" = "--workspace" ]; then
|
|
327
|
+
ws_id="${2:-}"
|
|
328
|
+
if [ -z "$ws_id" ]; then
|
|
329
|
+
echo "Error: --workspace requires a workspace_id" >&2
|
|
330
|
+
exit 1
|
|
331
|
+
fi
|
|
332
|
+
note_base="$BASE_URL/workspaces/$ws_id/notes"
|
|
333
|
+
shift 2
|
|
334
|
+
else
|
|
335
|
+
project_id="${1:-}"
|
|
336
|
+
if [ -z "$project_id" ] || [ "${project_id#--}" != "$project_id" ]; then
|
|
337
|
+
echo "Usage: hq note $action <project_id> ... | hq note $action --workspace <ws_id> ..." >&2
|
|
338
|
+
exit 1
|
|
339
|
+
fi
|
|
340
|
+
note_base="$BASE_URL/projects/$project_id/notes"
|
|
341
|
+
shift
|
|
342
|
+
fi
|
|
313
343
|
case "$action" in
|
|
314
344
|
create)
|
|
315
|
-
project_id="${3:-}"
|
|
316
|
-
if [ -z "$project_id" ]; then
|
|
317
|
-
echo "Usage: hq note create <project_id> --title \"Title\" --content \"Body\" [--file path]" >&2
|
|
318
|
-
exit 1
|
|
319
|
-
fi
|
|
320
|
-
shift 3
|
|
321
345
|
note_title=""
|
|
322
346
|
note_content=""
|
|
323
347
|
note_file=""
|
|
348
|
+
project_ids_csv=""
|
|
324
349
|
while [ $# -gt 0 ]; do
|
|
325
350
|
case "$1" in
|
|
326
351
|
--title) note_title="$2"; shift 2 ;;
|
|
327
352
|
--content) note_content="$2"; shift 2 ;;
|
|
328
353
|
--file) note_file="$2"; shift 2 ;;
|
|
354
|
+
--project-ids) project_ids_csv="$2"; shift 2 ;;
|
|
329
355
|
*) echo "Unknown option: $1" >&2; exit 1 ;;
|
|
330
356
|
esac
|
|
331
357
|
done
|
|
@@ -344,38 +370,47 @@ case "${1:-}" in
|
|
|
344
370
|
echo "Error: --content or --file is required" >&2
|
|
345
371
|
exit 1
|
|
346
372
|
fi
|
|
347
|
-
# Build JSON body using jq or python3
|
|
348
373
|
if command -v jq &>/dev/null; then
|
|
349
374
|
body=$(jq -n --arg t "$note_title" --arg c "$note_content" '{title: $t, content: $c}')
|
|
375
|
+
if [ -n "$project_ids_csv" ]; then
|
|
376
|
+
body=$(echo "$body" | jq --arg ids "$project_ids_csv" '. + {project_ids: ($ids | split(","))}')
|
|
377
|
+
fi
|
|
350
378
|
elif command -v python3 &>/dev/null; then
|
|
351
|
-
body=$(python3 -c "
|
|
379
|
+
body=$(python3 -c "
|
|
380
|
+
import json, sys
|
|
381
|
+
d = {'title': sys.argv[1], 'content': sys.argv[2]}
|
|
382
|
+
if sys.argv[3]:
|
|
383
|
+
d['project_ids'] = [s for s in sys.argv[3].split(',') if s]
|
|
384
|
+
print(json.dumps(d))
|
|
385
|
+
" "$note_title" "$note_content" "$project_ids_csv")
|
|
352
386
|
else
|
|
353
387
|
echo "Error: jq or python3 is required to build JSON" >&2
|
|
354
388
|
exit 1
|
|
355
389
|
fi
|
|
356
390
|
tmpfile=$(mktemp)
|
|
357
391
|
echo "$body" > "$tmpfile"
|
|
358
|
-
send_json_request POST "$
|
|
392
|
+
send_json_request POST "$note_base" "$tmpfile"
|
|
359
393
|
rm -f "$tmpfile"
|
|
360
394
|
;;
|
|
361
395
|
update)
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
echo "Usage: hq note update <project_id> <note_id> --content \"Body\" [--title \"Title\"] [--file path] [--change-summary \"summary\"]" >&2
|
|
396
|
+
note_id="${1:-}"
|
|
397
|
+
if [ -z "$note_id" ]; then
|
|
398
|
+
echo "Error: <note_id> is required" >&2
|
|
366
399
|
exit 1
|
|
367
400
|
fi
|
|
368
|
-
shift
|
|
401
|
+
shift
|
|
369
402
|
note_title=""
|
|
370
403
|
note_content=""
|
|
371
404
|
note_file=""
|
|
372
405
|
change_summary=""
|
|
406
|
+
note_status=""
|
|
373
407
|
while [ $# -gt 0 ]; do
|
|
374
408
|
case "$1" in
|
|
375
409
|
--title) note_title="$2"; shift 2 ;;
|
|
376
410
|
--content) note_content="$2"; shift 2 ;;
|
|
377
411
|
--file) note_file="$2"; shift 2 ;;
|
|
378
412
|
--change-summary) change_summary="$2"; shift 2 ;;
|
|
413
|
+
--status) note_status="$2"; shift 2 ;;
|
|
379
414
|
*) echo "Unknown option: $1" >&2; exit 1 ;;
|
|
380
415
|
esac
|
|
381
416
|
done
|
|
@@ -386,16 +421,16 @@ case "${1:-}" in
|
|
|
386
421
|
fi
|
|
387
422
|
note_content=$(cat "$note_file")
|
|
388
423
|
fi
|
|
389
|
-
if [ -z "$note_content" ] && [ -z "$note_title" ]; then
|
|
390
|
-
echo "Error: at least --content, --file, or --
|
|
424
|
+
if [ -z "$note_content" ] && [ -z "$note_title" ] && [ -z "$note_status" ]; then
|
|
425
|
+
echo "Error: at least --content, --file, --title, or --status is required" >&2
|
|
391
426
|
exit 1
|
|
392
427
|
fi
|
|
393
|
-
# Build JSON body
|
|
394
428
|
if command -v jq &>/dev/null; then
|
|
395
429
|
body="{}"
|
|
396
430
|
[ -n "$note_title" ] && body=$(echo "$body" | jq --arg t "$note_title" '. + {title: $t}')
|
|
397
431
|
[ -n "$note_content" ] && body=$(echo "$body" | jq --arg c "$note_content" '. + {content: $c}')
|
|
398
432
|
[ -n "$change_summary" ] && body=$(echo "$body" | jq --arg s "$change_summary" '. + {change_summary: $s}')
|
|
433
|
+
[ -n "$note_status" ] && body=$(echo "$body" | jq --arg st "$note_status" '. + {status: $st}')
|
|
399
434
|
elif command -v python3 &>/dev/null; then
|
|
400
435
|
body=$(python3 -c "
|
|
401
436
|
import json, sys
|
|
@@ -403,20 +438,20 @@ d = {}
|
|
|
403
438
|
if sys.argv[1]: d['title'] = sys.argv[1]
|
|
404
439
|
if sys.argv[2]: d['content'] = sys.argv[2]
|
|
405
440
|
if sys.argv[3]: d['change_summary'] = sys.argv[3]
|
|
441
|
+
if sys.argv[4]: d['status'] = sys.argv[4]
|
|
406
442
|
print(json.dumps(d))
|
|
407
|
-
" "$note_title" "$note_content" "$change_summary")
|
|
443
|
+
" "$note_title" "$note_content" "$change_summary" "$note_status")
|
|
408
444
|
else
|
|
409
445
|
echo "Error: jq or python3 is required to build JSON" >&2
|
|
410
446
|
exit 1
|
|
411
447
|
fi
|
|
412
448
|
tmpfile=$(mktemp)
|
|
413
449
|
echo "$body" > "$tmpfile"
|
|
414
|
-
# Use PATCH for update
|
|
415
450
|
response=$(curl -s -w "\n%{http_code}" -X PATCH \
|
|
416
451
|
-H "Authorization: Bearer $API_TOKEN" \
|
|
417
452
|
-H "Content-Type: application/json" \
|
|
418
453
|
--data-binary "@$tmpfile" \
|
|
419
|
-
"$
|
|
454
|
+
"$note_base/$note_id")
|
|
420
455
|
rm -f "$tmpfile"
|
|
421
456
|
http_code=$(echo "$response" | tail -1)
|
|
422
457
|
body_out=$(echo "$response" | sed '$d')
|
|
@@ -429,30 +464,22 @@ print(json.dumps(d))
|
|
|
429
464
|
fi
|
|
430
465
|
;;
|
|
431
466
|
list)
|
|
432
|
-
|
|
433
|
-
if [ -z "$project_id" ]; then
|
|
434
|
-
echo "Usage: hq note list <project_id>" >&2
|
|
435
|
-
exit 1
|
|
436
|
-
fi
|
|
437
|
-
fetch_resource "$BASE_URL/projects/$project_id/notes?include_content=false"
|
|
467
|
+
fetch_resource "$note_base?include_content=false"
|
|
438
468
|
;;
|
|
439
469
|
get)
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
echo "Usage: hq note get <project_id> <note_id>" >&2
|
|
470
|
+
note_id="${1:-}"
|
|
471
|
+
if [ -z "$note_id" ]; then
|
|
472
|
+
echo "Error: <note_id> is required" >&2
|
|
444
473
|
exit 1
|
|
445
474
|
fi
|
|
446
|
-
fetch_resource "$
|
|
475
|
+
fetch_resource "$note_base/$note_id"
|
|
447
476
|
;;
|
|
448
477
|
search)
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
echo "Usage: hq note search <project_id> <query>" >&2
|
|
478
|
+
query="${1:-}"
|
|
479
|
+
if [ -z "$query" ]; then
|
|
480
|
+
echo "Error: <query> is required" >&2
|
|
453
481
|
exit 1
|
|
454
482
|
fi
|
|
455
|
-
# URL-encode the query
|
|
456
483
|
if command -v jq &>/dev/null; then
|
|
457
484
|
encoded=$(printf '%s' "$query" | jq -sRr @uri)
|
|
458
485
|
elif command -v python3 &>/dev/null; then
|
|
@@ -460,7 +487,7 @@ print(json.dumps(d))
|
|
|
460
487
|
else
|
|
461
488
|
encoded="$query"
|
|
462
489
|
fi
|
|
463
|
-
fetch_resource "$
|
|
490
|
+
fetch_resource "$note_base/search?q=$encoded"
|
|
464
491
|
;;
|
|
465
492
|
*)
|
|
466
493
|
echo "Unknown note action: $action" >&2
|
package/linux/routes/chat.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
const { spawn } = require('child_process')
|
|
20
|
+
const crypto = require('crypto')
|
|
20
21
|
const fs = require('fs')
|
|
21
22
|
const path = require('path')
|
|
22
23
|
const { verifyToken } = require('../../core/lib/auth')
|
|
@@ -57,10 +58,16 @@ async function chatRoutes(fastify) {
|
|
|
57
58
|
// so the user's chat log keeps just the [task:UUID] tag, not a noisy dump.
|
|
58
59
|
const prompt = await buildContextPrefix(message, context, session_id, workspaceId, referenced_tasks)
|
|
59
60
|
|
|
60
|
-
//
|
|
61
|
+
// Persist the user message BEFORE invoking the LLM so that crashes,
|
|
62
|
+
// timeouts, or unparseable CLI output can't lose it. For new sessions we
|
|
63
|
+
// mint a local pending ID; it gets rekeyed to the real Claude CLI session
|
|
64
|
+
// ID once that comes back on the SSE stream.
|
|
61
65
|
const currentSessionId = session_id || null
|
|
62
|
-
|
|
63
|
-
|
|
66
|
+
const pendingSessionId = currentSessionId || `pending-${crypto.randomUUID()}`
|
|
67
|
+
try {
|
|
68
|
+
await chatStore.addMessage(pendingSessionId, { role: 'user', content: message }, undefined, workspaceId)
|
|
69
|
+
} catch (err) {
|
|
70
|
+
console.error('[Chat] failed to persist user message:', err.message)
|
|
64
71
|
}
|
|
65
72
|
|
|
66
73
|
// Take over response handling from Fastify for SSE streaming
|
|
@@ -74,7 +81,7 @@ async function chatRoutes(fastify) {
|
|
|
74
81
|
reply.raw.flushHeaders()
|
|
75
82
|
|
|
76
83
|
try {
|
|
77
|
-
await streamLlmResponse(reply.raw, prompt, currentSessionId, workspaceId, message)
|
|
84
|
+
await streamLlmResponse(reply.raw, prompt, currentSessionId, workspaceId, message, pendingSessionId)
|
|
78
85
|
} catch (err) {
|
|
79
86
|
console.error('[Chat] stream error:', err.message)
|
|
80
87
|
const errorEvent = JSON.stringify({ type: 'error', error: err.message })
|
|
@@ -391,7 +398,7 @@ async function buildContextPrefix(message, context, sessionId, workspaceId, refe
|
|
|
391
398
|
'[ファイル出力ルール]',
|
|
392
399
|
'成果物の保存先は以下のルールに従うこと。`/home/minion/` 直下にファイルを保存しないこと。',
|
|
393
400
|
'',
|
|
394
|
-
'- **テキスト成果物**(レポート、調査結果、要約等)→ ノートに保存: `hq note create <project_id> --title "タイトル" --content "本文"`',
|
|
401
|
+
'- **テキスト成果物**(レポート、調査結果、要約等)→ ノートに保存: `hq note create <project_id> --title "タイトル" --content "本文"` (プロジェクト紐づけなしは `hq note create --workspace <ws_id> ...`)',
|
|
395
402
|
'- **バイナリファイル**(PDF、画像、ZIP等)→ `~/files/` に配置(ユーザーがHQからダウンロード可能)',
|
|
396
403
|
'- **一時ファイル** → `/tmp/` に配置(作業後に削除)',
|
|
397
404
|
'',
|
|
@@ -541,16 +548,16 @@ function getLlmBinary() {
|
|
|
541
548
|
* Tracks block types to correctly forward tool_use vs text events
|
|
542
549
|
* and counts turns for session management.
|
|
543
550
|
*/
|
|
544
|
-
async function streamLlmResponse(res, prompt, sessionId, workspaceId, originalMessage) {
|
|
551
|
+
async function streamLlmResponse(res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId) {
|
|
545
552
|
// Plugin system path: Primary is set → delegate to plugin
|
|
546
553
|
const primary = getActivePrimary()
|
|
547
554
|
if (primary) {
|
|
548
|
-
return streamViaPlugin(primary, res, prompt, sessionId, workspaceId, originalMessage)
|
|
555
|
+
return streamViaPlugin(primary, res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId)
|
|
549
556
|
}
|
|
550
|
-
return streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, originalMessage)
|
|
557
|
+
return streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId)
|
|
551
558
|
}
|
|
552
559
|
|
|
553
|
-
async function streamViaPlugin(plugin, res, prompt, sessionId, workspaceId, originalMessage) {
|
|
560
|
+
async function streamViaPlugin(plugin, res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId) {
|
|
554
561
|
const input = { prompt }
|
|
555
562
|
const activeRef = { current: null }
|
|
556
563
|
activeChatChild = { kill: () => activeRef.current?.kill?.('SIGTERM') }
|
|
@@ -576,6 +583,7 @@ async function streamViaPlugin(plugin, res, prompt, sessionId, workspaceId, orig
|
|
|
576
583
|
|
|
577
584
|
res.on('close', () => { activeRef.current?.kill?.('SIGTERM') })
|
|
578
585
|
|
|
586
|
+
let pluginError = null
|
|
579
587
|
try {
|
|
580
588
|
let output
|
|
581
589
|
if (plugin.capabilities.streaming && typeof plugin.stream === 'function') {
|
|
@@ -592,25 +600,42 @@ async function streamViaPlugin(plugin, res, prompt, sessionId, workspaceId, orig
|
|
|
592
600
|
}
|
|
593
601
|
}
|
|
594
602
|
resolvedSessionId = output?.metadata?.sessionId || resolvedSessionId
|
|
603
|
+
} catch (err) {
|
|
604
|
+
// Swallow here so we can persist any partial response first; rethrow below.
|
|
605
|
+
pluginError = err
|
|
595
606
|
} finally {
|
|
596
607
|
activeChatChild = null
|
|
597
608
|
}
|
|
598
609
|
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
610
|
+
// For new sessions, the user message was persisted under pendingSessionId
|
|
611
|
+
// before the plugin call. Rekey it to the real session ID now that we
|
|
612
|
+
// know it. If the plugin never reported a session ID, leave the message
|
|
613
|
+
// under the pending key so the history isn't lost.
|
|
614
|
+
const persistSessionId = resolvedSessionId || pendingSessionId
|
|
615
|
+
try {
|
|
616
|
+
if (!sessionId && resolvedSessionId && pendingSessionId && pendingSessionId !== resolvedSessionId) {
|
|
617
|
+
chatStore.rekeySession(pendingSessionId, resolvedSessionId)
|
|
602
618
|
}
|
|
603
|
-
if (fullResponse) {
|
|
604
|
-
await chatStore.addMessage(
|
|
619
|
+
if (fullResponse && persistSessionId) {
|
|
620
|
+
await chatStore.addMessage(persistSessionId, { role: 'assistant', content: fullResponse }, turnCount, workspaceId)
|
|
605
621
|
}
|
|
622
|
+
} catch (err) {
|
|
623
|
+
console.error('[Chat] failed to persist assistant message:', err.message)
|
|
606
624
|
}
|
|
607
625
|
|
|
608
|
-
|
|
609
|
-
|
|
626
|
+
if (pluginError) throw pluginError
|
|
627
|
+
|
|
628
|
+
let totalTurnCount = turnCount
|
|
629
|
+
try {
|
|
630
|
+
const session = await chatStore.load(workspaceId)
|
|
631
|
+
totalTurnCount = session?.turn_count || turnCount
|
|
632
|
+
} catch (err) {
|
|
633
|
+
console.error('[Chat] failed to load session for done event:', err.message)
|
|
634
|
+
}
|
|
610
635
|
res.write(`data: ${JSON.stringify({ type: 'done', session_id: resolvedSessionId, turn_count: totalTurnCount })}\n\n`)
|
|
611
636
|
}
|
|
612
637
|
|
|
613
|
-
function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, originalMessage) {
|
|
638
|
+
function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId) {
|
|
614
639
|
return new Promise((resolve, reject) => {
|
|
615
640
|
const binaryName = getLlmBinary()
|
|
616
641
|
if (!binaryName) {
|
|
@@ -786,16 +811,21 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
|
|
|
786
811
|
console.log(`[Chat] final stderr (tail 500): ${stderrBuffer.slice(-500)}`)
|
|
787
812
|
}
|
|
788
813
|
|
|
789
|
-
//
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
814
|
+
// For new sessions, the user message was already persisted under
|
|
815
|
+
// pendingSessionId before spawn. Rekey it to the real session ID when
|
|
816
|
+
// Claude CLI reported one; otherwise leave the message under the
|
|
817
|
+
// pending key so the history is never lost on crash.
|
|
818
|
+
const persistSessionId = resolvedSessionId || pendingSessionId
|
|
819
|
+
try {
|
|
820
|
+
if (!sessionId && resolvedSessionId && pendingSessionId && pendingSessionId !== resolvedSessionId) {
|
|
821
|
+
chatStore.rekeySession(pendingSessionId, resolvedSessionId)
|
|
794
822
|
}
|
|
795
|
-
//
|
|
796
|
-
if (fullResponse) {
|
|
797
|
-
await chatStore.addMessage(
|
|
823
|
+
// Persist any partial response we managed to collect, even on error
|
|
824
|
+
if (fullResponse && persistSessionId) {
|
|
825
|
+
await chatStore.addMessage(persistSessionId, { role: 'assistant', content: fullResponse }, turnCount, workspaceId)
|
|
798
826
|
}
|
|
827
|
+
} catch (err) {
|
|
828
|
+
console.error('[Chat] failed to persist assistant message:', err.message)
|
|
799
829
|
}
|
|
800
830
|
|
|
801
831
|
if (code !== 0) {
|
|
@@ -811,8 +841,13 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
|
|
|
811
841
|
}
|
|
812
842
|
|
|
813
843
|
// Load current turn count from session for the done event
|
|
814
|
-
|
|
815
|
-
|
|
844
|
+
let totalTurnCount = turnCount
|
|
845
|
+
try {
|
|
846
|
+
const session = await chatStore.load(workspaceId)
|
|
847
|
+
totalTurnCount = session?.turn_count || turnCount
|
|
848
|
+
} catch (err) {
|
|
849
|
+
console.error('[Chat] failed to load session for done event:', err.message)
|
|
850
|
+
}
|
|
816
851
|
|
|
817
852
|
const doneEvent = JSON.stringify({
|
|
818
853
|
type: 'done',
|
package/package.json
CHANGED
package/rules/core.md
CHANGED
|
@@ -445,28 +445,32 @@ display_name が同じユーザーが同一ワークスペースに複数いる
|
|
|
445
445
|
|
|
446
446
|
### ノートへの保存 (`hq note` コマンド)
|
|
447
447
|
|
|
448
|
-
|
|
448
|
+
テキストベースの成果物は原則ノートに保存する。ノートはワークスペース直下に保存され、任意で 1 つ以上のプロジェクトにリンクできる。HQダッシュボードで閲覧・編集・検索でき、バージョン管理もされる。
|
|
449
|
+
|
|
450
|
+
スコープの使い分け:
|
|
451
|
+
|
|
452
|
+
- **プロジェクトスコープ** — 作業がプロジェクトに紐づくとき (`hq note ... <project_id>`)。プロジェクトのノートタブから即発見できる
|
|
453
|
+
- **ワークスペーススコープ** — プロジェクトに紐づかない一般的なメモや、ワークスペース横断で検索したいとき (`hq note ... --workspace <workspace_id>`)。プロジェクト未指定でも作成・検索できる
|
|
449
454
|
|
|
450
455
|
```bash
|
|
451
|
-
#
|
|
456
|
+
# プロジェクトスコープ(作業コンテキストが特定プロジェクト)
|
|
452
457
|
hq note create <project_id> --title "レポートタイトル" --content "本文..."
|
|
453
|
-
|
|
454
|
-
# 長い内容はファイルから読み込む
|
|
455
458
|
hq note create <project_id> --title "調査結果" --file /tmp/report.md
|
|
456
|
-
|
|
457
|
-
# 既存ノートを更新
|
|
458
459
|
hq note update <project_id> <note_id> --content "更新された内容"
|
|
459
|
-
|
|
460
|
-
# ノート一覧を確認
|
|
461
460
|
hq note list <project_id>
|
|
462
|
-
|
|
463
|
-
# ノート内容を取得
|
|
464
461
|
hq note get <project_id> <note_id>
|
|
465
|
-
|
|
466
|
-
# ノートを検索
|
|
467
462
|
hq note search <project_id> "キーワード"
|
|
463
|
+
|
|
464
|
+
# ワークスペーススコープ(プロジェクト未紐づけのノート含む)
|
|
465
|
+
hq note create --workspace <workspace_id> --title "メモ" --content "..."
|
|
466
|
+
hq note update --workspace <workspace_id> <note_id> --content "更新内容"
|
|
467
|
+
hq note list --workspace <workspace_id>
|
|
468
|
+
hq note get --workspace <workspace_id> <note_id>
|
|
469
|
+
hq note search --workspace <workspace_id> "キーワード"
|
|
468
470
|
```
|
|
469
471
|
|
|
472
|
+
**削除はミニオンから実行できない**(事故防止のため人間操作限定)。不要なノートは `hq note update ... --status archived` (または PATCH の `status: "archived"`) でアーカイブすること。
|
|
473
|
+
|
|
470
474
|
### ノート内のユーザーメンション
|
|
471
475
|
|
|
472
476
|
人間ユーザーがノート編集UIで `Ctrl+I` を押すと、自分の発言を示すアバター付きチップが行頭に挿入される。マークダウン上は次の形式で永続化される:
|
package/win/routes/chat.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
const { spawn } = require('child_process')
|
|
16
|
+
const crypto = require('crypto')
|
|
16
17
|
const fs = require('fs')
|
|
17
18
|
const path = require('path')
|
|
18
19
|
const http = require('http')
|
|
@@ -130,8 +131,19 @@ async function chatRoutes(fastify) {
|
|
|
130
131
|
const prompt = await buildContextPrefix(message, context, session_id, workspaceId, referenced_tasks)
|
|
131
132
|
const currentSessionId = session_id || null
|
|
132
133
|
|
|
133
|
-
|
|
134
|
-
|
|
134
|
+
// Persist the user message BEFORE invoking the LLM so that crashes,
|
|
135
|
+
// timeouts, or unparseable CLI output can't lose it. New sessions get a
|
|
136
|
+
// local pending ID; it gets rekeyed to the real Claude CLI session ID
|
|
137
|
+
// once that comes back on the SSE stream.
|
|
138
|
+
// In WSL mode the upstream WSL session server keeps its own store, so we
|
|
139
|
+
// only persist locally for existing sessions (matching prior behavior).
|
|
140
|
+
const pendingSessionId = currentSessionId || `pending-${crypto.randomUUID()}`
|
|
141
|
+
if (!wsl_mode || currentSessionId) {
|
|
142
|
+
try {
|
|
143
|
+
await chatStore.addMessage(pendingSessionId, { role: 'user', content: message }, undefined, workspaceId)
|
|
144
|
+
} catch (err) {
|
|
145
|
+
console.error('[Chat] failed to persist user message:', err.message)
|
|
146
|
+
}
|
|
135
147
|
}
|
|
136
148
|
|
|
137
149
|
reply.hijack()
|
|
@@ -149,7 +161,7 @@ async function chatRoutes(fastify) {
|
|
|
149
161
|
await proxyWslChat(reply.raw, prompt, currentSessionId)
|
|
150
162
|
wslModeActive = false
|
|
151
163
|
} else {
|
|
152
|
-
await streamLlmResponse(reply.raw, prompt, currentSessionId, workspaceId, message)
|
|
164
|
+
await streamLlmResponse(reply.raw, prompt, currentSessionId, workspaceId, message, pendingSessionId)
|
|
153
165
|
}
|
|
154
166
|
} catch (err) {
|
|
155
167
|
wslModeActive = false
|
|
@@ -452,7 +464,7 @@ async function buildContextPrefix(message, context, sessionId, workspaceId, refe
|
|
|
452
464
|
'[ファイル出力ルール]',
|
|
453
465
|
'成果物の保存先は以下のルールに従うこと。`/home/minion/` 直下にファイルを保存しないこと。',
|
|
454
466
|
'',
|
|
455
|
-
'- **テキスト成果物**(レポート、調査結果、要約等)→ ノートに保存: `hq note create <project_id> --title "タイトル" --content "本文"`',
|
|
467
|
+
'- **テキスト成果物**(レポート、調査結果、要約等)→ ノートに保存: `hq note create <project_id> --title "タイトル" --content "本文"` (プロジェクト紐づけなしは `hq note create --workspace <ws_id> ...`)',
|
|
456
468
|
'- **バイナリファイル**(PDF、画像、ZIP等)→ `~/files/` に配置(ユーザーがHQからダウンロード可能)',
|
|
457
469
|
'- **一時ファイル** → `/tmp/` に配置(作業後に削除)',
|
|
458
470
|
'',
|
|
@@ -594,15 +606,15 @@ function getLlmBinary() {
|
|
|
594
606
|
* Tracks block types to correctly forward tool_use vs text events
|
|
595
607
|
* and counts turns for session management.
|
|
596
608
|
*/
|
|
597
|
-
async function streamLlmResponse(res, prompt, sessionId, workspaceId, originalMessage) {
|
|
609
|
+
async function streamLlmResponse(res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId) {
|
|
598
610
|
const primary = getActivePrimary()
|
|
599
611
|
if (primary) {
|
|
600
|
-
return streamViaPlugin(primary, res, prompt, sessionId, workspaceId, originalMessage)
|
|
612
|
+
return streamViaPlugin(primary, res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId)
|
|
601
613
|
}
|
|
602
|
-
return streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, originalMessage)
|
|
614
|
+
return streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId)
|
|
603
615
|
}
|
|
604
616
|
|
|
605
|
-
async function streamViaPlugin(plugin, res, prompt, sessionId, workspaceId, originalMessage) {
|
|
617
|
+
async function streamViaPlugin(plugin, res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId) {
|
|
606
618
|
const input = { prompt }
|
|
607
619
|
const activeRef = { current: null }
|
|
608
620
|
|
|
@@ -627,37 +639,57 @@ async function streamViaPlugin(plugin, res, prompt, sessionId, workspaceId, orig
|
|
|
627
639
|
|
|
628
640
|
res.on('close', () => { activeRef.current?.kill?.('SIGTERM') })
|
|
629
641
|
|
|
630
|
-
let
|
|
631
|
-
|
|
632
|
-
output
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
+
let pluginError = null
|
|
643
|
+
try {
|
|
644
|
+
let output
|
|
645
|
+
if (plugin.capabilities.streaming && typeof plugin.stream === 'function') {
|
|
646
|
+
output = await plugin.stream(input, emit, { resumeSessionId: sessionId, activeChildRef: activeRef })
|
|
647
|
+
} else {
|
|
648
|
+
output = await plugin.invoke(input)
|
|
649
|
+
if (output.text) {
|
|
650
|
+
fullResponse = output.text
|
|
651
|
+
res.write(`data: ${JSON.stringify({ type: 'text', content: output.text })}\n\n`)
|
|
652
|
+
turnCount = 1
|
|
653
|
+
}
|
|
654
|
+
if (output.error) {
|
|
655
|
+
res.write(`data: ${JSON.stringify({ type: 'error', error: output.error.message })}\n\n`)
|
|
656
|
+
}
|
|
642
657
|
}
|
|
658
|
+
resolvedSessionId = output?.metadata?.sessionId || resolvedSessionId
|
|
659
|
+
} catch (err) {
|
|
660
|
+
// Swallow here so we can persist any partial response first; rethrow below.
|
|
661
|
+
pluginError = err
|
|
643
662
|
}
|
|
644
|
-
resolvedSessionId = output?.metadata?.sessionId || resolvedSessionId
|
|
645
663
|
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
664
|
+
// For new sessions, the user message was persisted under pendingSessionId
|
|
665
|
+
// before the plugin call. Rekey it to the real session ID now that we
|
|
666
|
+
// know it. If the plugin never reported a session ID, leave the message
|
|
667
|
+
// under the pending key so the history isn't lost.
|
|
668
|
+
const persistSessionId = resolvedSessionId || pendingSessionId
|
|
669
|
+
try {
|
|
670
|
+
if (!sessionId && resolvedSessionId && pendingSessionId && pendingSessionId !== resolvedSessionId) {
|
|
671
|
+
chatStore.rekeySession(pendingSessionId, resolvedSessionId)
|
|
649
672
|
}
|
|
650
|
-
if (fullResponse) {
|
|
651
|
-
await chatStore.addMessage(
|
|
673
|
+
if (fullResponse && persistSessionId) {
|
|
674
|
+
await chatStore.addMessage(persistSessionId, { role: 'assistant', content: fullResponse }, turnCount, workspaceId)
|
|
652
675
|
}
|
|
676
|
+
} catch (err) {
|
|
677
|
+
console.error('[Chat] failed to persist assistant message:', err.message)
|
|
653
678
|
}
|
|
654
679
|
|
|
655
|
-
|
|
656
|
-
|
|
680
|
+
if (pluginError) throw pluginError
|
|
681
|
+
|
|
682
|
+
let totalTurnCount = turnCount
|
|
683
|
+
try {
|
|
684
|
+
const session = await chatStore.load(workspaceId)
|
|
685
|
+
totalTurnCount = session?.turn_count || turnCount
|
|
686
|
+
} catch (err) {
|
|
687
|
+
console.error('[Chat] failed to load session for done event:', err.message)
|
|
688
|
+
}
|
|
657
689
|
res.write(`data: ${JSON.stringify({ type: 'done', session_id: resolvedSessionId, turn_count: totalTurnCount })}\n\n`)
|
|
658
690
|
}
|
|
659
691
|
|
|
660
|
-
function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, originalMessage) {
|
|
692
|
+
function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId) {
|
|
661
693
|
return new Promise((resolve, reject) => {
|
|
662
694
|
const binaryName = getLlmBinary()
|
|
663
695
|
if (!binaryName) {
|
|
@@ -809,14 +841,22 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
|
|
|
809
841
|
console.log(`[Chat] final stderr (tail 500): ${stderrBuffer.slice(-500)}`)
|
|
810
842
|
}
|
|
811
843
|
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
844
|
+
// For new sessions, the user message was already persisted under
|
|
845
|
+
// pendingSessionId before spawn. Rekey it to the real session ID when
|
|
846
|
+
// Claude CLI reported one; otherwise leave the message under the
|
|
847
|
+
// pending key so the history is never lost on crash.
|
|
848
|
+
const persistSessionId = resolvedSessionId || pendingSessionId
|
|
849
|
+
try {
|
|
850
|
+
if (!sessionId && resolvedSessionId && pendingSessionId && pendingSessionId !== resolvedSessionId) {
|
|
851
|
+
chatStore.rekeySession(pendingSessionId, resolvedSessionId)
|
|
815
852
|
}
|
|
816
|
-
if (fullResponse) {
|
|
817
|
-
await chatStore.addMessage(
|
|
853
|
+
if (fullResponse && persistSessionId) {
|
|
854
|
+
await chatStore.addMessage(persistSessionId, { role: 'assistant', content: fullResponse }, turnCount, workspaceId)
|
|
818
855
|
}
|
|
856
|
+
} catch (err) {
|
|
857
|
+
console.error('[Chat] failed to persist assistant message:', err.message)
|
|
819
858
|
}
|
|
859
|
+
|
|
820
860
|
if (code !== 0) {
|
|
821
861
|
const errorMsg = stderrBuffer.trim() || `Claude CLI exited with code ${code}`
|
|
822
862
|
console.error(`[Chat] CLI failed (exit ${code}, partial=${!!fullResponse}): ${errorMsg}`)
|
|
@@ -828,8 +868,13 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
|
|
|
828
868
|
})}\n\n`)
|
|
829
869
|
}
|
|
830
870
|
|
|
831
|
-
|
|
832
|
-
|
|
871
|
+
let totalTurnCount = turnCount
|
|
872
|
+
try {
|
|
873
|
+
const session = await chatStore.load(workspaceId)
|
|
874
|
+
totalTurnCount = session?.turn_count || turnCount
|
|
875
|
+
} catch (err) {
|
|
876
|
+
console.error('[Chat] failed to load session for done event:', err.message)
|
|
877
|
+
}
|
|
833
878
|
|
|
834
879
|
res.write(`data: ${JSON.stringify({ type: 'done', session_id: resolvedSessionId, turn_count: totalTurnCount })}\n\n`)
|
|
835
880
|
resolve()
|