@geekbeer/minion 3.11.5 → 3.13.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.js CHANGED
@@ -195,6 +195,7 @@ function initSchema(db) {
195
195
  -- ==================== chat_sessions ====================
196
196
  CREATE TABLE IF NOT EXISTS chat_sessions (
197
197
  session_id TEXT PRIMARY KEY,
198
+ workspace_id TEXT,
198
199
  turn_count INTEGER NOT NULL DEFAULT 0,
199
200
  created_at INTEGER NOT NULL,
200
201
  updated_at INTEGER NOT NULL
@@ -390,57 +391,87 @@ function migrateSchema(db) {
390
391
  }
391
392
 
392
393
  if (currentVersion < 2) {
393
- console.log('[DB] Migration 2: Adding emails FTS5...')
394
-
395
- db.exec(`
396
- CREATE VIRTUAL TABLE IF NOT EXISTS emails_fts USING fts5(
397
- subject,
398
- body_text,
399
- content=emails,
400
- content_rowid=rowid,
401
- tokenize='trigram'
402
- );
403
-
404
- CREATE TRIGGER IF NOT EXISTS emails_ai AFTER INSERT ON emails BEGIN
405
- INSERT INTO emails_fts(rowid, subject, body_text)
406
- VALUES (new.rowid, new.subject, new.body_text);
407
- END;
394
+ try {
395
+ console.log('[DB] Migration 2: Adding emails FTS5...')
396
+
397
+ db.exec(`
398
+ CREATE VIRTUAL TABLE IF NOT EXISTS emails_fts USING fts5(
399
+ subject,
400
+ body_text,
401
+ content=emails,
402
+ content_rowid=rowid,
403
+ tokenize='trigram'
404
+ );
405
+
406
+ CREATE TRIGGER IF NOT EXISTS emails_ai AFTER INSERT ON emails BEGIN
407
+ INSERT INTO emails_fts(rowid, subject, body_text)
408
+ VALUES (new.rowid, new.subject, new.body_text);
409
+ END;
410
+
411
+ CREATE TRIGGER IF NOT EXISTS emails_ad AFTER DELETE ON emails BEGIN
412
+ INSERT INTO emails_fts(emails_fts, rowid, subject, body_text)
413
+ VALUES ('delete', old.rowid, old.subject, old.body_text);
414
+ END;
415
+
416
+ CREATE TRIGGER IF NOT EXISTS emails_au AFTER UPDATE ON emails BEGIN
417
+ INSERT INTO emails_fts(emails_fts, rowid, subject, body_text)
418
+ VALUES ('delete', old.rowid, old.subject, old.body_text);
419
+ INSERT INTO emails_fts(rowid, subject, body_text)
420
+ VALUES (new.rowid, new.subject, new.body_text);
421
+ END;
422
+
423
+ INSERT INTO emails_fts(emails_fts) VALUES ('rebuild');
424
+
425
+ INSERT INTO schema_version (version) VALUES (2);
426
+ `)
427
+
428
+ console.log('[DB] Migration 2 complete: emails FTS5 added')
429
+ } catch (err) {
430
+ console.warn(`[DB] Migration 2 skipped (emails table may not exist yet): ${err.message}`)
431
+ // Mark as applied so it doesn't block subsequent migrations.
432
+ // emails FTS will be created by initSchema on next fresh DB.
433
+ try { db.exec("INSERT OR IGNORE INTO schema_version (version) VALUES (2)") } catch {}
434
+ }
435
+ }
408
436
 
409
- CREATE TRIGGER IF NOT EXISTS emails_ad AFTER DELETE ON emails BEGIN
410
- INSERT INTO emails_fts(emails_fts, rowid, subject, body_text)
411
- VALUES ('delete', old.rowid, old.subject, old.body_text);
412
- END;
437
+ if (currentVersion < 3) {
438
+ try {
439
+ console.log('[DB] Migration 3: Adding project_id to memories...')
413
440
 
414
- CREATE TRIGGER IF NOT EXISTS emails_au AFTER UPDATE ON emails BEGIN
415
- INSERT INTO emails_fts(emails_fts, rowid, subject, body_text)
416
- VALUES ('delete', old.rowid, old.subject, old.body_text);
417
- INSERT INTO emails_fts(rowid, subject, body_text)
418
- VALUES (new.rowid, new.subject, new.body_text);
419
- END;
441
+ db.exec(`
442
+ ALTER TABLE memories ADD COLUMN project_id TEXT DEFAULT NULL;
420
443
 
421
- INSERT INTO emails_fts(emails_fts) VALUES ('rebuild');
444
+ CREATE INDEX IF NOT EXISTS idx_memories_project_id ON memories(project_id);
422
445
 
423
- INSERT INTO schema_version (version) VALUES (2);
424
- `)
446
+ INSERT INTO schema_version (version) VALUES (3);
447
+ `)
425
448
 
426
- console.log('[DB] Migration 2 complete: emails FTS5 added')
449
+ console.log('[DB] Migration 3 complete: memories.project_id added')
450
+ } catch (err) {
451
+ // Column may already exist (duplicate column error)
452
+ console.warn(`[DB] Migration 3 skipped: ${err.message}`)
453
+ try { db.exec("INSERT OR IGNORE INTO schema_version (version) VALUES (3)") } catch {}
454
+ }
427
455
  }
428
456
 
429
- if (currentVersion < 3) {
430
- console.log('[DB] Migration 3: Adding project_id to memories...')
457
+ if (currentVersion < 4) {
458
+ try {
459
+ console.log('[DB] Migration 4: Adding workspace_id to chat_sessions...')
431
460
 
432
- db.exec(`
433
- -- Add nullable project_id column to memories table.
434
- -- NULL = universal memory (cross-project knowledge).
435
- -- Set = project-scoped memory.
436
- ALTER TABLE memories ADD COLUMN project_id TEXT DEFAULT NULL;
461
+ db.exec(`
462
+ ALTER TABLE chat_sessions ADD COLUMN workspace_id TEXT DEFAULT NULL;
437
463
 
438
- CREATE INDEX idx_memories_project_id ON memories(project_id);
464
+ CREATE INDEX IF NOT EXISTS idx_chat_sessions_workspace
465
+ ON chat_sessions(workspace_id, updated_at DESC);
439
466
 
440
- INSERT INTO schema_version (version) VALUES (3);
441
- `)
467
+ INSERT INTO schema_version (version) VALUES (4);
468
+ `)
442
469
 
443
- console.log('[DB] Migration 3 complete: memories.project_id added')
470
+ console.log('[DB] Migration 4 complete: chat_sessions.workspace_id added')
471
+ } catch (err) {
472
+ console.warn(`[DB] Migration 4 skipped: ${err.message}`)
473
+ try { db.exec("INSERT OR IGNORE INTO schema_version (version) VALUES (4)") } catch {}
474
+ }
444
475
  }
445
476
  }
446
477
 
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Chat Session Store (SQLite)
3
3
  * Persists chat sessions and messages.
4
- * Supports multiple sessions: one active + archived past sessions.
4
+ * Supports multiple sessions scoped by workspace_id.
5
5
  * Claude CLI manages conversation context via --resume.
6
6
  */
7
7
 
@@ -10,14 +10,22 @@ const { getDb } = require('../db')
10
10
  const MAX_MESSAGES = 100
11
11
 
12
12
  /**
13
- * Load the active (most recent) chat session
13
+ * Load the active (most recent) chat session for a workspace.
14
+ * @param {string|null} workspaceId - Workspace ID to scope sessions
14
15
  * @returns {object|null} Session object or null if none exists
15
16
  */
16
- function load() {
17
+ function load(workspaceId) {
17
18
  const db = getDb()
18
- const session = db.prepare(
19
- 'SELECT * FROM chat_sessions ORDER BY updated_at DESC LIMIT 1'
20
- ).get()
19
+ let session
20
+ if (workspaceId) {
21
+ session = db.prepare(
22
+ 'SELECT * FROM chat_sessions WHERE workspace_id = ? ORDER BY updated_at DESC LIMIT 1'
23
+ ).get(workspaceId)
24
+ } else {
25
+ session = db.prepare(
26
+ 'SELECT * FROM chat_sessions WHERE workspace_id IS NULL ORDER BY updated_at DESC LIMIT 1'
27
+ ).get()
28
+ }
21
29
  if (!session) return null
22
30
 
23
31
  const messages = db.prepare(
@@ -26,6 +34,7 @@ function load() {
26
34
 
27
35
  return {
28
36
  session_id: session.session_id,
37
+ workspace_id: session.workspace_id,
29
38
  messages,
30
39
  turn_count: session.turn_count,
31
40
  created_at: session.created_at,
@@ -49,6 +58,7 @@ function loadById(sessionId) {
49
58
 
50
59
  return {
51
60
  session_id: session.session_id,
61
+ workspace_id: session.workspace_id,
52
62
  messages,
53
63
  turn_count: session.turn_count,
54
64
  created_at: session.created_at,
@@ -57,15 +67,32 @@ function loadById(sessionId) {
57
67
  }
58
68
 
59
69
  /**
60
- * List all sessions (metadata only, no messages)
70
+ * List sessions, optionally filtered by workspace_id.
61
71
  * @param {number} limit
62
- * @returns {Array<{ session_id, turn_count, message_count, created_at, updated_at }>}
72
+ * @param {string|null} workspaceId - If provided, filter by workspace
73
+ * @returns {Array<{ session_id, workspace_id, turn_count, message_count, created_at, updated_at }>}
63
74
  */
64
- function listSessions(limit = 50) {
75
+ function listSessions(limit = 50, workspaceId) {
65
76
  const db = getDb()
77
+ if (workspaceId) {
78
+ return db.prepare(`
79
+ SELECT
80
+ s.session_id,
81
+ s.workspace_id,
82
+ s.turn_count,
83
+ s.created_at,
84
+ s.updated_at,
85
+ (SELECT COUNT(*) FROM chat_messages WHERE session_id = s.session_id) as message_count
86
+ FROM chat_sessions s
87
+ WHERE s.workspace_id = ?
88
+ ORDER BY s.updated_at DESC
89
+ LIMIT ?
90
+ `).all(workspaceId, limit)
91
+ }
66
92
  return db.prepare(`
67
93
  SELECT
68
94
  s.session_id,
95
+ s.workspace_id,
69
96
  s.turn_count,
70
97
  s.created_at,
71
98
  s.updated_at,
@@ -88,8 +115,8 @@ function save(session) {
88
115
 
89
116
  // Insert session
90
117
  db.prepare(
91
- 'INSERT INTO chat_sessions (session_id, turn_count, created_at, updated_at) VALUES (?, ?, ?, ?)'
92
- ).run(session.session_id, session.turn_count || 0, session.created_at, session.updated_at)
118
+ 'INSERT INTO chat_sessions (session_id, workspace_id, turn_count, created_at, updated_at) VALUES (?, ?, ?, ?, ?)'
119
+ ).run(session.session_id, session.workspace_id || null, session.turn_count || 0, session.created_at, session.updated_at)
93
120
 
94
121
  // Insert messages
95
122
  const insertMsg = db.prepare(
@@ -103,14 +130,15 @@ function save(session) {
103
130
  }
104
131
 
105
132
  /**
106
- * Add a message to the active session.
107
- * Creates a new session if none exists or session_id changed.
133
+ * Add a message to a session.
134
+ * Creates a new session if none exists for the given session_id.
108
135
  * Past sessions are preserved (not deleted).
109
136
  * @param {string} sessionId - Claude CLI session ID
110
137
  * @param {{ role: string, content: string }} msg - Message to add
111
138
  * @param {number} [turnCount] - Optional turn count to add
139
+ * @param {string|null} [workspaceId] - Workspace ID for new sessions
112
140
  */
113
- function addMessage(sessionId, msg, turnCount) {
141
+ function addMessage(sessionId, msg, turnCount, workspaceId) {
114
142
  const db = getDb()
115
143
  const existing = db.prepare('SELECT * FROM chat_sessions WHERE session_id = ?').get(sessionId)
116
144
 
@@ -118,8 +146,8 @@ function addMessage(sessionId, msg, turnCount) {
118
146
  if (!existing) {
119
147
  const now = Date.now()
120
148
  db.prepare(
121
- 'INSERT INTO chat_sessions (session_id, turn_count, created_at, updated_at) VALUES (?, ?, ?, ?)'
122
- ).run(sessionId, 0, now, now)
149
+ 'INSERT INTO chat_sessions (session_id, workspace_id, turn_count, created_at, updated_at) VALUES (?, ?, ?, ?, ?)'
150
+ ).run(sessionId, workspaceId || null, 0, now, now)
123
151
  }
124
152
 
125
153
  // Insert message
@@ -149,13 +177,21 @@ function addMessage(sessionId, msg, turnCount) {
149
177
  }
150
178
 
151
179
  /**
152
- * Clear the active session (remove from DB).
153
- * This deletes only the most recent session.
154
- * For reset flows, use clearActive() which preserves the session in history.
180
+ * Clear the active session for a workspace.
181
+ * @param {string|null} workspaceId - Workspace to clear session for
155
182
  */
156
- function clear() {
183
+ function clear(workspaceId) {
157
184
  const db = getDb()
158
- const active = db.prepare('SELECT session_id FROM chat_sessions ORDER BY updated_at DESC LIMIT 1').get()
185
+ let active
186
+ if (workspaceId) {
187
+ active = db.prepare(
188
+ 'SELECT session_id FROM chat_sessions WHERE workspace_id = ? ORDER BY updated_at DESC LIMIT 1'
189
+ ).get(workspaceId)
190
+ } else {
191
+ active = db.prepare(
192
+ 'SELECT session_id FROM chat_sessions WHERE workspace_id IS NULL ORDER BY updated_at DESC LIMIT 1'
193
+ ).get()
194
+ }
159
195
  if (active) {
160
196
  db.prepare('DELETE FROM chat_sessions WHERE session_id = ?').run(active.session_id)
161
197
  }
@@ -41,20 +41,22 @@ async function chatRoutes(fastify) {
41
41
  return { success: false, error: 'Unauthorized' }
42
42
  }
43
43
 
44
- const { message, session_id, context } = request.body || {}
44
+ const { message, session_id, context, workspace_id } = request.body || {}
45
45
 
46
46
  if (!message || typeof message !== 'string') {
47
47
  reply.code(400)
48
48
  return { success: false, error: 'message is required' }
49
49
  }
50
50
 
51
+ const workspaceId = workspace_id || null
52
+
51
53
  // Build prompt — add memory context on new sessions + page context
52
54
  const prompt = await buildContextPrefix(message, context, session_id)
53
55
 
54
56
  // Store user message
55
57
  const currentSessionId = session_id || null
56
58
  if (currentSessionId) {
57
- await chatStore.addMessage(currentSessionId, { role: 'user', content: message })
59
+ await chatStore.addMessage(currentSessionId, { role: 'user', content: message }, undefined, workspaceId)
58
60
  }
59
61
 
60
62
  // Take over response handling from Fastify for SSE streaming
@@ -68,7 +70,7 @@ async function chatRoutes(fastify) {
68
70
  reply.raw.flushHeaders()
69
71
 
70
72
  try {
71
- await streamLlmResponse(reply.raw, prompt, currentSessionId)
73
+ await streamLlmResponse(reply.raw, prompt, currentSessionId, workspaceId, message)
72
74
  } catch (err) {
73
75
  console.error('[Chat] stream error:', err.message)
74
76
  const errorEvent = JSON.stringify({ type: 'error', error: err.message })
@@ -78,14 +80,15 @@ async function chatRoutes(fastify) {
78
80
  reply.raw.end()
79
81
  })
80
82
 
81
- // GET /api/chat/session - Get active chat session
83
+ // GET /api/chat/session - Get active chat session for a workspace
82
84
  fastify.get('/api/chat/session', async (request, reply) => {
83
85
  if (!verifyToken(request)) {
84
86
  reply.code(401)
85
87
  return { success: false, error: 'Unauthorized' }
86
88
  }
87
89
 
88
- const session = chatStore.load()
90
+ const workspaceId = request.query?.workspace_id || null
91
+ const session = chatStore.load(workspaceId)
89
92
  if (!session) {
90
93
  return { success: true, session: null }
91
94
  }
@@ -94,6 +97,7 @@ async function chatRoutes(fastify) {
94
97
  success: true,
95
98
  session: {
96
99
  session_id: session.session_id,
100
+ workspace_id: session.workspace_id,
97
101
  messages: session.messages,
98
102
  turn_count: session.turn_count,
99
103
  created_at: session.created_at,
@@ -102,7 +106,7 @@ async function chatRoutes(fastify) {
102
106
  }
103
107
  })
104
108
 
105
- // GET /api/chat/sessions - List all sessions (metadata only)
109
+ // GET /api/chat/sessions - List sessions, optionally filtered by workspace
106
110
  fastify.get('/api/chat/sessions', async (request, reply) => {
107
111
  if (!verifyToken(request)) {
108
112
  reply.code(401)
@@ -110,7 +114,8 @@ async function chatRoutes(fastify) {
110
114
  }
111
115
 
112
116
  const limit = parseInt(request.query?.limit) || 50
113
- const sessions = chatStore.listSessions(limit)
117
+ const workspaceId = request.query?.workspace_id || undefined
118
+ const sessions = chatStore.listSessions(limit, workspaceId)
114
119
  return { success: true, sessions }
115
120
  })
116
121
 
@@ -130,14 +135,15 @@ async function chatRoutes(fastify) {
130
135
  return { success: true, session }
131
136
  })
132
137
 
133
- // POST /api/chat/clear - Clear the active session
138
+ // POST /api/chat/clear - Clear the active session for a workspace
134
139
  fastify.post('/api/chat/clear', async (request, reply) => {
135
140
  if (!verifyToken(request)) {
136
141
  reply.code(401)
137
142
  return { success: false, error: 'Unauthorized' }
138
143
  }
139
144
 
140
- await chatStore.clear()
145
+ const workspaceId = request.body?.workspace_id || null
146
+ await chatStore.clear(workspaceId)
141
147
  return { success: true }
142
148
  })
143
149
 
@@ -168,42 +174,57 @@ async function chatRoutes(fastify) {
168
174
  return { success: true }
169
175
  })
170
176
 
171
- // POST /api/chat/reset - Summarize conversation and start fresh session
177
+ // POST /api/chat/reset - Carry over relevant messages and start fresh session
172
178
  fastify.post('/api/chat/reset', async (request, reply) => {
173
179
  if (!verifyToken(request)) {
174
180
  reply.code(401)
175
181
  return { success: false, error: 'Unauthorized' }
176
182
  }
177
183
 
178
- const session = await chatStore.load()
184
+ const workspaceId = request.body?.workspace_id || null
185
+ const session = await chatStore.load(workspaceId)
179
186
  if (!session || session.messages.length === 0) {
180
- await chatStore.clear()
181
- return { success: true, summary: null }
187
+ await chatStore.clear(workspaceId)
188
+ return { success: true, carry_over: null }
182
189
  }
183
190
 
184
- // Build summarization prompt from recent messages
191
+ // Take recent messages for topic analysis
185
192
  const recentMessages = session.messages.slice(-20)
186
- const conversationText = recentMessages
187
- .map(m => `${m.role === 'user' ? 'ユーザー' : 'アシスタント'}: ${m.content.substring(0, 500)}`)
188
- .join('\n\n')
189
-
190
- const summarizePrompt = `以下の会話を要約してください。重要なコンテキスト、決定事項、進行中のタスクを含めてください。200文字以内で。\n\n${conversationText}`
193
+ let carryOverMessages = recentMessages
191
194
 
192
- let summary = null
195
+ // Ask LLM to find where the current topic begins
193
196
  try {
194
- summary = await runQuickLlmCall(summarizePrompt)
195
- } catch (err) {
196
- console.error('[Chat] summarization failed:', err.message)
197
- // Fallback: use last few messages as summary
198
- const fallback = recentMessages.slice(-4)
199
- .map(m => `${m.role === 'user' ? 'ユーザー' : 'アシスタント'}: ${m.content.substring(0, 200)}`)
197
+ const indexed = recentMessages
198
+ .map((m, i) => `[${i}] ${m.role === 'user' ? 'ユーザー' : 'アシスタント'}: ${m.content.substring(0, 300)}`)
200
199
  .join('\n')
201
- summary = fallback
200
+
201
+ const analyzePrompt = `以下の会話履歴を分析し、現在進行中のトピックが始まったインデックス番号を特定してください。
202
+ 会話の流れが明確に変わったポイント(新しい質問・別の作業への切り替え等)がある場合、最後の切り替え以降のインデックスを返してください。
203
+ 全体が同じトピックの場合は 0 を返してください。
204
+
205
+ 数値のみを返してください(例: 5)。
206
+
207
+ ${indexed}`
208
+
209
+ const result = await runQuickLlmCall(analyzePrompt)
210
+ const breakIndex = parseInt(result.trim(), 10)
211
+ if (!isNaN(breakIndex) && breakIndex >= 0 && breakIndex < recentMessages.length) {
212
+ carryOverMessages = recentMessages.slice(breakIndex)
213
+ console.log(`[Chat] topic break at index ${breakIndex}, carrying over ${carryOverMessages.length}/${recentMessages.length} messages`)
214
+ }
215
+ } catch (err) {
216
+ console.error('[Chat] topic analysis failed, carrying over last 10:', err.message)
217
+ carryOverMessages = recentMessages.slice(-10)
202
218
  }
203
219
 
204
- await chatStore.clear()
205
- console.log(`[Chat] session reset with summary (${summary?.length || 0} chars)`)
206
- return { success: true, summary }
220
+ // Format carry-over as conversation text
221
+ const carryOver = carryOverMessages
222
+ .map(m => `${m.role === 'user' ? 'ユーザー' : 'アシスタント'}: ${m.content}`)
223
+ .join('\n\n')
224
+
225
+ await chatStore.clear(workspaceId)
226
+ console.log(`[Chat] session reset, carrying over ${carryOverMessages.length} messages (${carryOver.length} chars)`)
227
+ return { success: true, carry_over: carryOver }
207
228
  })
208
229
 
209
230
  // POST /api/chat/end-of-day - Generate daily log + extract memories
@@ -341,7 +362,7 @@ function getLlmBinary() {
341
362
  * Tracks block types to correctly forward tool_use vs text events
342
363
  * and counts turns for session management.
343
364
  */
344
- function streamLlmResponse(res, prompt, sessionId) {
365
+ function streamLlmResponse(res, prompt, sessionId, workspaceId, originalMessage) {
345
366
  return new Promise((resolve, reject) => {
346
367
  const binaryName = getLlmBinary()
347
368
  if (!binaryName) {
@@ -516,11 +537,11 @@ function streamLlmResponse(res, prompt, sessionId) {
516
537
  if (resolvedSessionId) {
517
538
  // If this was a new session, also store the user message now
518
539
  if (!sessionId) {
519
- await chatStore.addMessage(resolvedSessionId, { role: 'user', content: prompt })
540
+ await chatStore.addMessage(resolvedSessionId, { role: 'user', content: originalMessage || prompt }, undefined, workspaceId)
520
541
  }
521
542
  // Store assistant response with turn count
522
543
  if (fullResponse) {
523
- await chatStore.addMessage(resolvedSessionId, { role: 'assistant', content: fullResponse }, turnCount)
544
+ await chatStore.addMessage(resolvedSessionId, { role: 'assistant', content: fullResponse }, turnCount, workspaceId)
524
545
  }
525
546
  }
526
547
 
@@ -533,7 +554,7 @@ function streamLlmResponse(res, prompt, sessionId) {
533
554
  }
534
555
 
535
556
  // Load current turn count from session for the done event
536
- const session = await chatStore.load()
557
+ const session = await chatStore.load(workspaceId)
537
558
  const totalTurnCount = session?.turn_count || turnCount
538
559
 
539
560
  const doneEvent = JSON.stringify({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekbeer/minion",
3
- "version": "3.11.5",
3
+ "version": "3.13.0",
4
4
  "description": "AI Agent runtime for Minion - manages status and skill deployment on VPS",
5
5
  "main": "linux/server.js",
6
6
  "bin": {
@@ -116,17 +116,18 @@ async function chatRoutes(fastify) {
116
116
  return { success: false, error: 'Unauthorized' }
117
117
  }
118
118
 
119
- const { message, session_id, context, wsl_mode } = request.body || {}
119
+ const { message, session_id, context, wsl_mode, workspace_id } = request.body || {}
120
120
  if (!message || typeof message !== 'string') {
121
121
  reply.code(400)
122
122
  return { success: false, error: 'message is required' }
123
123
  }
124
124
 
125
+ const workspaceId = workspace_id || null
125
126
  const prompt = await buildContextPrefix(message, context, session_id)
126
127
  const currentSessionId = session_id || null
127
128
 
128
129
  if (currentSessionId) {
129
- await chatStore.addMessage(currentSessionId, { role: 'user', content: message })
130
+ await chatStore.addMessage(currentSessionId, { role: 'user', content: message }, undefined, workspaceId)
130
131
  }
131
132
 
132
133
  reply.hijack()
@@ -144,7 +145,7 @@ async function chatRoutes(fastify) {
144
145
  await proxyWslChat(reply.raw, prompt, currentSessionId)
145
146
  wslModeActive = false
146
147
  } else {
147
- await streamLlmResponse(reply.raw, prompt, currentSessionId)
148
+ await streamLlmResponse(reply.raw, prompt, currentSessionId, workspaceId, message)
148
149
  }
149
150
  } catch (err) {
150
151
  wslModeActive = false
@@ -160,12 +161,14 @@ async function chatRoutes(fastify) {
160
161
  reply.code(401)
161
162
  return { success: false, error: 'Unauthorized' }
162
163
  }
163
- const session = chatStore.load()
164
+ const workspaceId = request.query?.workspace_id || null
165
+ const session = chatStore.load(workspaceId)
164
166
  if (!session) return { success: true, session: null }
165
167
  return {
166
168
  success: true,
167
169
  session: {
168
170
  session_id: session.session_id,
171
+ workspace_id: session.workspace_id,
169
172
  messages: session.messages,
170
173
  turn_count: session.turn_count,
171
174
  created_at: session.created_at,
@@ -174,7 +177,7 @@ async function chatRoutes(fastify) {
174
177
  }
175
178
  })
176
179
 
177
- // GET /api/chat/sessions - List all sessions (metadata only)
180
+ // GET /api/chat/sessions - List sessions, optionally filtered by workspace
178
181
  fastify.get('/api/chat/sessions', async (request, reply) => {
179
182
  if (!verifyToken(request)) {
180
183
  reply.code(401)
@@ -182,7 +185,8 @@ async function chatRoutes(fastify) {
182
185
  }
183
186
 
184
187
  const limit = parseInt(request.query?.limit) || 50
185
- const sessions = chatStore.listSessions(limit)
188
+ const workspaceId = request.query?.workspace_id || undefined
189
+ const sessions = chatStore.listSessions(limit, workspaceId)
186
190
  return { success: true, sessions }
187
191
  })
188
192
 
@@ -207,7 +211,8 @@ async function chatRoutes(fastify) {
207
211
  reply.code(401)
208
212
  return { success: false, error: 'Unauthorized' }
209
213
  }
210
- await chatStore.clear()
214
+ const workspaceId = request.body?.workspace_id || null
215
+ await chatStore.clear(workspaceId)
211
216
  return { success: true }
212
217
  })
213
218
 
@@ -239,40 +244,57 @@ async function chatRoutes(fastify) {
239
244
  return { success: true }
240
245
  })
241
246
 
242
- // POST /api/chat/reset - Summarize conversation and start fresh session
247
+ // POST /api/chat/reset - Carry over relevant messages and start fresh session
243
248
  fastify.post('/api/chat/reset', async (request, reply) => {
244
249
  if (!verifyToken(request)) {
245
250
  reply.code(401)
246
251
  return { success: false, error: 'Unauthorized' }
247
252
  }
248
253
 
249
- const session = await chatStore.load()
254
+ const workspaceId = request.body?.workspace_id || null
255
+ const session = await chatStore.load(workspaceId)
250
256
  if (!session || session.messages.length === 0) {
251
- await chatStore.clear()
252
- return { success: true, summary: null }
257
+ await chatStore.clear(workspaceId)
258
+ return { success: true, carry_over: null }
253
259
  }
254
260
 
261
+ // Take recent messages for topic analysis
255
262
  const recentMessages = session.messages.slice(-20)
256
- const conversationText = recentMessages
257
- .map(m => `${m.role === 'user' ? 'ユーザー' : 'アシスタント'}: ${m.content.substring(0, 500)}`)
258
- .join('\n\n')
259
-
260
- const summarizePrompt = `以下の会話を要約してください。重要なコンテキスト、決定事項、進行中のタスクを含めてください。200文字以内で。\n\n${conversationText}`
263
+ let carryOverMessages = recentMessages
261
264
 
262
- let summary = null
265
+ // Ask LLM to find where the current topic begins
263
266
  try {
264
- summary = await runQuickLlmCall(summarizePrompt)
265
- } catch (err) {
266
- console.error('[Chat] summarization failed:', err.message)
267
- const fallback = recentMessages.slice(-4)
268
- .map(m => `${m.role === 'user' ? 'ユーザー' : 'アシスタント'}: ${m.content.substring(0, 200)}`)
267
+ const indexed = recentMessages
268
+ .map((m, i) => `[${i}] ${m.role === 'user' ? 'ユーザー' : 'アシスタント'}: ${m.content.substring(0, 300)}`)
269
269
  .join('\n')
270
- summary = fallback
270
+
271
+ const analyzePrompt = `以下の会話履歴を分析し、現在進行中のトピックが始まったインデックス番号を特定してください。
272
+ 会話の流れが明確に変わったポイント(新しい質問・別の作業への切り替え等)がある場合、最後の切り替え以降のインデックスを返してください。
273
+ 全体が同じトピックの場合は 0 を返してください。
274
+
275
+ 数値のみを返してください(例: 5)。
276
+
277
+ ${indexed}`
278
+
279
+ const result = await runQuickLlmCall(analyzePrompt)
280
+ const breakIndex = parseInt(result.trim(), 10)
281
+ if (!isNaN(breakIndex) && breakIndex >= 0 && breakIndex < recentMessages.length) {
282
+ carryOverMessages = recentMessages.slice(breakIndex)
283
+ console.log(`[Chat] topic break at index ${breakIndex}, carrying over ${carryOverMessages.length}/${recentMessages.length} messages`)
284
+ }
285
+ } catch (err) {
286
+ console.error('[Chat] topic analysis failed, carrying over last 10:', err.message)
287
+ carryOverMessages = recentMessages.slice(-10)
271
288
  }
272
289
 
273
- await chatStore.clear()
274
- console.log(`[Chat] session reset with summary (${summary?.length || 0} chars)`)
275
- return { success: true, summary }
290
+ // Format carry-over as conversation text
291
+ const carryOver = carryOverMessages
292
+ .map(m => `${m.role === 'user' ? 'ユーザー' : 'アシスタント'}: ${m.content}`)
293
+ .join('\n\n')
294
+
295
+ await chatStore.clear(workspaceId)
296
+ console.log(`[Chat] session reset, carrying over ${carryOverMessages.length} messages (${carryOver.length} chars)`)
297
+ return { success: true, carry_over: carryOver }
276
298
  })
277
299
 
278
300
  // POST /api/chat/end-of-day - Generate daily log + extract memories
@@ -394,7 +416,7 @@ function getLlmBinary() {
394
416
  * Tracks block types to correctly forward tool_use vs text events
395
417
  * and counts turns for session management.
396
418
  */
397
- function streamLlmResponse(res, prompt, sessionId) {
419
+ function streamLlmResponse(res, prompt, sessionId, workspaceId, originalMessage) {
398
420
  return new Promise((resolve, reject) => {
399
421
  const binaryName = getLlmBinary()
400
422
  if (!binaryName) {
@@ -542,10 +564,10 @@ function streamLlmResponse(res, prompt, sessionId) {
542
564
  activeChatChild = null
543
565
  if (resolvedSessionId) {
544
566
  if (!sessionId) {
545
- await chatStore.addMessage(resolvedSessionId, { role: 'user', content: prompt })
567
+ await chatStore.addMessage(resolvedSessionId, { role: 'user', content: originalMessage || prompt }, undefined, workspaceId)
546
568
  }
547
569
  if (fullResponse) {
548
- await chatStore.addMessage(resolvedSessionId, { role: 'assistant', content: fullResponse }, turnCount)
570
+ await chatStore.addMessage(resolvedSessionId, { role: 'assistant', content: fullResponse }, turnCount, workspaceId)
549
571
  }
550
572
  }
551
573
  if (code !== 0 && !fullResponse) {
@@ -553,7 +575,7 @@ function streamLlmResponse(res, prompt, sessionId) {
553
575
  res.write(`data: ${JSON.stringify({ type: 'error', error: errorMsg })}\n\n`)
554
576
  }
555
577
 
556
- const session = await chatStore.load()
578
+ const session = await chatStore.load(workspaceId)
557
579
  const totalTurnCount = session?.turn_count || turnCount
558
580
 
559
581
  res.write(`data: ${JSON.stringify({ type: 'done', session_id: resolvedSessionId, turn_count: totalTurnCount })}\n\n`)