ideaco 1.1.5

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.
Files changed (159) hide show
  1. package/.dockerignore +33 -0
  2. package/.nvmrc +1 -0
  3. package/ARCHITECTURE.md +394 -0
  4. package/Dockerfile +50 -0
  5. package/LICENSE +29 -0
  6. package/README.md +206 -0
  7. package/bin/i18n.js +46 -0
  8. package/bin/ideaco.js +494 -0
  9. package/deploy.sh +15 -0
  10. package/docker-compose.yml +30 -0
  11. package/electron/main.cjs +986 -0
  12. package/electron/preload.cjs +14 -0
  13. package/electron/web-backends.cjs +854 -0
  14. package/jsconfig.json +8 -0
  15. package/next.config.mjs +34 -0
  16. package/package.json +134 -0
  17. package/postcss.config.mjs +6 -0
  18. package/public/demo/dashboard.png +0 -0
  19. package/public/demo/employee.png +0 -0
  20. package/public/demo/messages.png +0 -0
  21. package/public/demo/office.png +0 -0
  22. package/public/demo/requirement.png +0 -0
  23. package/public/logo.jpeg +0 -0
  24. package/public/logo.png +0 -0
  25. package/scripts/prepare-electron.js +67 -0
  26. package/scripts/release.js +76 -0
  27. package/src/app/api/agents/[agentId]/chat/route.js +70 -0
  28. package/src/app/api/agents/[agentId]/conversations/route.js +35 -0
  29. package/src/app/api/agents/[agentId]/route.js +106 -0
  30. package/src/app/api/avatar/route.js +104 -0
  31. package/src/app/api/browse-dir/route.js +44 -0
  32. package/src/app/api/chat/route.js +265 -0
  33. package/src/app/api/company/factory-reset/route.js +43 -0
  34. package/src/app/api/company/route.js +82 -0
  35. package/src/app/api/departments/[deptId]/agents/[agentId]/dismiss/route.js +19 -0
  36. package/src/app/api/departments/route.js +92 -0
  37. package/src/app/api/group-chat-loop/events/route.js +70 -0
  38. package/src/app/api/group-chat-loop/route.js +94 -0
  39. package/src/app/api/mailbox/route.js +100 -0
  40. package/src/app/api/messages/route.js +14 -0
  41. package/src/app/api/providers/[id]/configure/route.js +21 -0
  42. package/src/app/api/providers/[id]/refresh-cookie/route.js +38 -0
  43. package/src/app/api/providers/[id]/test-cookie/route.js +28 -0
  44. package/src/app/api/providers/route.js +11 -0
  45. package/src/app/api/requirements/route.js +242 -0
  46. package/src/app/api/secretary/route.js +65 -0
  47. package/src/app/api/system/cli-backends/route.js +91 -0
  48. package/src/app/api/system/cron/route.js +110 -0
  49. package/src/app/api/system/knowledge/route.js +104 -0
  50. package/src/app/api/system/plugins/route.js +40 -0
  51. package/src/app/api/system/skills/route.js +46 -0
  52. package/src/app/api/system/status/route.js +46 -0
  53. package/src/app/api/talent-market/[profileId]/recall/route.js +22 -0
  54. package/src/app/api/talent-market/[profileId]/route.js +17 -0
  55. package/src/app/api/talent-market/route.js +26 -0
  56. package/src/app/api/teams/route.js +773 -0
  57. package/src/app/api/ws-files/[departmentId]/file/route.js +27 -0
  58. package/src/app/api/ws-files/[departmentId]/files/route.js +22 -0
  59. package/src/app/globals.css +130 -0
  60. package/src/app/layout.jsx +40 -0
  61. package/src/app/page.jsx +97 -0
  62. package/src/components/AgentChatModal.jsx +164 -0
  63. package/src/components/AgentDetailModal.jsx +425 -0
  64. package/src/components/AgentSpyModal.jsx +481 -0
  65. package/src/components/AvatarGrid.jsx +29 -0
  66. package/src/components/BossProfileModal.jsx +162 -0
  67. package/src/components/CachedAvatar.jsx +77 -0
  68. package/src/components/ChatPanel.jsx +219 -0
  69. package/src/components/ChatShared.jsx +255 -0
  70. package/src/components/DepartmentDetail.jsx +842 -0
  71. package/src/components/DepartmentView.jsx +367 -0
  72. package/src/components/FileReference.jsx +260 -0
  73. package/src/components/FilesView.jsx +465 -0
  74. package/src/components/GroupChatView.jsx +799 -0
  75. package/src/components/Mailbox.jsx +926 -0
  76. package/src/components/MessagesView.jsx +112 -0
  77. package/src/components/OnboardingGuide.jsx +209 -0
  78. package/src/components/OrgTree.jsx +151 -0
  79. package/src/components/Overview.jsx +391 -0
  80. package/src/components/PixelOffice.jsx +2281 -0
  81. package/src/components/ProviderGrid.jsx +551 -0
  82. package/src/components/ProvidersBoard.jsx +16 -0
  83. package/src/components/RequirementDetail.jsx +1279 -0
  84. package/src/components/RequirementsBoard.jsx +187 -0
  85. package/src/components/SecretarySettings.jsx +295 -0
  86. package/src/components/SetupWizard.jsx +388 -0
  87. package/src/components/Sidebar.jsx +169 -0
  88. package/src/components/SystemMonitor.jsx +808 -0
  89. package/src/components/TalentMarket.jsx +183 -0
  90. package/src/components/TeamDetail.jsx +697 -0
  91. package/src/core/agent/base-agent.js +104 -0
  92. package/src/core/agent/chat-store.js +602 -0
  93. package/src/core/agent/cli-agent/backends/claude-code/README.md +52 -0
  94. package/src/core/agent/cli-agent/backends/claude-code/config.js +27 -0
  95. package/src/core/agent/cli-agent/backends/codebuddy/README.md +236 -0
  96. package/src/core/agent/cli-agent/backends/codebuddy/config.js +27 -0
  97. package/src/core/agent/cli-agent/backends/codex/README.md +51 -0
  98. package/src/core/agent/cli-agent/backends/codex/config.js +27 -0
  99. package/src/core/agent/cli-agent/backends/index.js +27 -0
  100. package/src/core/agent/cli-agent/backends/registry.js +580 -0
  101. package/src/core/agent/cli-agent/index.js +154 -0
  102. package/src/core/agent/index.js +60 -0
  103. package/src/core/agent/llm-agent/client.js +320 -0
  104. package/src/core/agent/llm-agent/index.js +97 -0
  105. package/src/core/agent/message-bus.js +211 -0
  106. package/src/core/agent/session.js +608 -0
  107. package/src/core/agent/tools.js +596 -0
  108. package/src/core/agent/web-agent/backends/base-backend.js +180 -0
  109. package/src/core/agent/web-agent/backends/chatgpt/client.js +146 -0
  110. package/src/core/agent/web-agent/backends/chatgpt/config.js +148 -0
  111. package/src/core/agent/web-agent/backends/chatgpt/dom-scripts.js +303 -0
  112. package/src/core/agent/web-agent/backends/index.js +91 -0
  113. package/src/core/agent/web-agent/index.js +278 -0
  114. package/src/core/agent/web-agent/web-client.js +407 -0
  115. package/src/core/employee/base-employee.js +1088 -0
  116. package/src/core/employee/index.js +35 -0
  117. package/src/core/employee/knowledge.js +327 -0
  118. package/src/core/employee/lifecycle.js +990 -0
  119. package/src/core/employee/memory/index.js +642 -0
  120. package/src/core/employee/memory/store.js +143 -0
  121. package/src/core/employee/performance.js +224 -0
  122. package/src/core/employee/secretary.js +625 -0
  123. package/src/core/employee/skills.js +398 -0
  124. package/src/core/index.js +38 -0
  125. package/src/core/organization/company.js +2600 -0
  126. package/src/core/organization/department.js +737 -0
  127. package/src/core/organization/group-chat-loop.js +264 -0
  128. package/src/core/organization/index.js +8 -0
  129. package/src/core/organization/persistence.js +111 -0
  130. package/src/core/organization/team.js +267 -0
  131. package/src/core/organization/workforce/hr.js +377 -0
  132. package/src/core/organization/workforce/providers.js +468 -0
  133. package/src/core/organization/workforce/role-archetypes.js +805 -0
  134. package/src/core/organization/workforce/talent-market.js +205 -0
  135. package/src/core/prompts.js +532 -0
  136. package/src/core/requirement.js +1789 -0
  137. package/src/core/system/audit.js +483 -0
  138. package/src/core/system/cron.js +449 -0
  139. package/src/core/system/index.js +7 -0
  140. package/src/core/system/plugin.js +2183 -0
  141. package/src/core/utils/json-parse.js +188 -0
  142. package/src/core/workspace.js +239 -0
  143. package/src/lib/api-i18n.js +211 -0
  144. package/src/lib/avatar.js +268 -0
  145. package/src/lib/client-store.js +1025 -0
  146. package/src/lib/config-validator.js +483 -0
  147. package/src/lib/format-time.js +22 -0
  148. package/src/lib/hooks.js +414 -0
  149. package/src/lib/i18n.js +134 -0
  150. package/src/lib/paths.js +23 -0
  151. package/src/lib/store.js +72 -0
  152. package/src/locales/de.js +393 -0
  153. package/src/locales/en.js +1054 -0
  154. package/src/locales/es.js +393 -0
  155. package/src/locales/fr.js +393 -0
  156. package/src/locales/ja.js +501 -0
  157. package/src/locales/ko.js +513 -0
  158. package/src/locales/zh.js +828 -0
  159. package/tailwind.config.mjs +11 -0
@@ -0,0 +1,104 @@
1
+ /**
2
+ * BaseAgent — Pure communication engine.
3
+ *
4
+ * This layer ONLY handles: sending/receiving messages via LLM or CLI,
5
+ * provider management, and availability checks.
6
+ *
7
+ * All business logic (identity, memory, personality, tasks, performance,
8
+ * org structure, serialization) lives in the Employee layer above.
9
+ *
10
+ * Subclasses: LLMAgent, CLIAgent, WebAgent
11
+ */
12
+ export class BaseAgent {
13
+ constructor() {
14
+ if (new.target === BaseAgent) {
15
+ throw new Error('BaseAgent is abstract — use LLMAgent or CLIAgent');
16
+ }
17
+ }
18
+
19
+ // ======================== Abstract Methods ========================
20
+
21
+ /** @returns {string} 'llm' | 'cli' | 'web' */
22
+ get agentType() {
23
+ throw new Error('Subclass must implement get agentType()');
24
+ }
25
+
26
+ /** Whether this agent can execute tasks. @returns {boolean} */
27
+ isAvailable() {
28
+ throw new Error('Subclass must implement isAvailable()');
29
+ }
30
+
31
+ /** Whether this agent can do lightweight LLM-style chat. @returns {boolean} */
32
+ canChat() {
33
+ throw new Error('Subclass must implement canChat()');
34
+ }
35
+
36
+ /**
37
+ * Send a chat request.
38
+ * @param {Array<{role: string, content: string}>} messages
39
+ * @param {object} [options] - { temperature, maxTokens }
40
+ * @returns {Promise<{content: string, usage: object|null}>}
41
+ */
42
+ async chat(messages, options = {}) {
43
+ throw new Error('Subclass must implement chat()');
44
+ }
45
+
46
+ /**
47
+ * Send a chat request with tool calling support.
48
+ * @param {Array<{role: string, content: string}>} messages
49
+ * @param {object} toolExecutor - AgentToolKit instance
50
+ * @param {object} [options]
51
+ * @returns {Promise<{content: string, toolResults: Array, usage: object|null}>}
52
+ */
53
+ async chatWithTools(messages, toolExecutor, options = {}) {
54
+ throw new Error('Subclass must implement chatWithTools()');
55
+ }
56
+
57
+ /**
58
+ * Get display info about the agent's execution engine.
59
+ * @returns {{ name: string, provider: string, model: string, type: string }}
60
+ */
61
+ getDisplayInfo() {
62
+ throw new Error('Subclass must implement getDisplayInfo()');
63
+ }
64
+
65
+ /**
66
+ * Get the provider display info for frontend rendering.
67
+ * @returns {{ id: string, name: string, provider: string }}
68
+ */
69
+ getProviderDisplayInfo() {
70
+ throw new Error('Subclass must implement getProviderDisplayInfo()');
71
+ }
72
+
73
+ /**
74
+ * Get the fallback provider name (only meaningful for CLI agents).
75
+ * @returns {string|null}
76
+ */
77
+ getFallbackProviderName() {
78
+ return null;
79
+ }
80
+
81
+ /**
82
+ * Switch this agent's provider.
83
+ * @param {object} newProvider - Provider config from ProviderRegistry
84
+ */
85
+ switchProvider(newProvider) {
86
+ throw new Error('Subclass must implement switchProvider()');
87
+ }
88
+
89
+ /**
90
+ * Serialize agent-level (communication) fields.
91
+ * @returns {object}
92
+ */
93
+ serializeAgent() {
94
+ throw new Error('Subclass must implement serializeAgent()');
95
+ }
96
+
97
+ /**
98
+ * Get cost per token for this agent. Subclasses can override.
99
+ * @returns {number}
100
+ */
101
+ getCostPerToken() {
102
+ return 0.001;
103
+ }
104
+ }
@@ -0,0 +1,602 @@
1
+ /**
2
+ * Chat Store - Chat history file storage module
3
+ *
4
+ * Extracts chat history out of memory/company-state.json,
5
+ * storing it in a dedicated folder, partitioned by session chunks.
6
+ *
7
+ * Storage layout:
8
+ * data/chats/{sessionId}/
9
+ * ├── meta.json # Session metadata
10
+ * ├── chunk-0001.jsonl # Chat chunk file (max 50 messages per chunk)
11
+ * ├── chunk-0002.jsonl
12
+ * └── ...
13
+ *
14
+ * Features:
15
+ * 1. Chunk storage: one file per 50 messages to avoid single large files
16
+ * 2. Append writes: new messages are appended to the current chunk, no full rewrites
17
+ * 3. Fast recent message reads: only read the tail of the latest chunk
18
+ * 4. Keyword search: search historical messages for context
19
+ */
20
+ import fs from 'fs';
21
+ import path from 'path';
22
+ import { v4 as uuidv4 } from 'uuid';
23
+ import { CHATS_DIR } from '../../lib/paths.js';
24
+
25
+ // Max messages per chunk
26
+ const MESSAGES_PER_CHUNK = 50;
27
+
28
+ // Ensure the directory exists
29
+ if (!fs.existsSync(CHATS_DIR)) {
30
+ fs.mkdirSync(CHATS_DIR, { recursive: true });
31
+ }
32
+
33
+ /**
34
+ * Get the session directory path
35
+ */
36
+ function getSessionDir(sessionId) {
37
+ return path.join(CHATS_DIR, sessionId);
38
+ }
39
+
40
+ /**
41
+ * Ensure the session directory exists
42
+ */
43
+ function ensureSessionDir(sessionId) {
44
+ const dir = getSessionDir(sessionId);
45
+ if (!fs.existsSync(dir)) {
46
+ fs.mkdirSync(dir, { recursive: true });
47
+ }
48
+ return dir;
49
+ }
50
+
51
+ /**
52
+ * Generate a chunk file name
53
+ */
54
+ function chunkFileName(index) {
55
+ return `chunk-${String(index).padStart(4, '0')}.jsonl`;
56
+ }
57
+
58
+ /**
59
+ * Get all chunk files for a session (in order)
60
+ */
61
+ function listChunkFiles(sessionDir) {
62
+ if (!fs.existsSync(sessionDir)) return [];
63
+ return fs.readdirSync(sessionDir)
64
+ .filter(f => f.startsWith('chunk-') && f.endsWith('.jsonl'))
65
+ .sort();
66
+ }
67
+
68
+ /**
69
+ * Read all messages from a JSONL file
70
+ */
71
+ function readChunkFile(filePath) {
72
+ if (!fs.existsSync(filePath)) return [];
73
+ const content = fs.readFileSync(filePath, 'utf-8').trim();
74
+ if (!content) return [];
75
+ return content.split('\n').map(line => {
76
+ try {
77
+ return JSON.parse(line);
78
+ } catch {
79
+ return null;
80
+ }
81
+ }).filter(Boolean);
82
+ }
83
+
84
+ /**
85
+ * Count messages in a JSONL file
86
+ */
87
+ function countMessagesInChunk(filePath) {
88
+ if (!fs.existsSync(filePath)) return 0;
89
+ const content = fs.readFileSync(filePath, 'utf-8').trim();
90
+ if (!content) return 0;
91
+ return content.split('\n').length;
92
+ }
93
+
94
+ /**
95
+ * ChatStore - Chat history management
96
+ */
97
+ export class ChatStore {
98
+ constructor() {
99
+ // Cache: sessionId -> { currentChunkIndex, currentChunkCount }
100
+ this._cache = new Map();
101
+ }
102
+
103
+ /**
104
+ * Create a new session
105
+ * @param {string} sessionId - Session ID
106
+ * @param {object} meta - Session metadata { participants, type, title }
107
+ * @returns {string} sessionId
108
+ */
109
+ createSession(sessionId, meta = {}) {
110
+ const dir = ensureSessionDir(sessionId);
111
+ const metaPath = path.join(dir, 'meta.json');
112
+
113
+ if (!fs.existsSync(metaPath)) {
114
+ const metaData = {
115
+ sessionId,
116
+ title: meta.title || 'Conversation',
117
+ participants: meta.participants || [],
118
+ type: meta.type || 'boss-secretary',
119
+ createdAt: new Date().toISOString(),
120
+ lastActiveAt: new Date().toISOString(),
121
+ totalMessages: 0,
122
+ };
123
+ fs.writeFileSync(metaPath, JSON.stringify(metaData, null, 2), 'utf-8');
124
+ }
125
+
126
+ return sessionId;
127
+ }
128
+
129
+ /**
130
+ * Append a message to a session
131
+ * @param {string} sessionId - Session ID
132
+ * @param {object} message - Message object { role, content, action?, time? }
133
+ */
134
+ appendMessage(sessionId, message) {
135
+ const dir = ensureSessionDir(sessionId);
136
+
137
+ // Determine the current write chunk
138
+ const cacheEntry = this._getOrInitCache(sessionId, dir);
139
+ const chunkPath = path.join(dir, chunkFileName(cacheEntry.currentChunkIndex));
140
+
141
+ // Build the message record (preserving extended fields like fromAgentId, toAgentId, etc.)
142
+ const record = {
143
+ id: uuidv4(),
144
+ role: message.role,
145
+ content: message.content,
146
+ action: message.action || null,
147
+ time: message.time ? new Date(message.time).toISOString() : new Date().toISOString(),
148
+ };
149
+ // Preserve extended fields for agent-to-agent chat
150
+ if (message.fromAgentId) record.fromAgentId = message.fromAgentId;
151
+ if (message.fromAgentName) record.fromAgentName = message.fromAgentName;
152
+ if (message.toAgentId) record.toAgentId = message.toAgentId;
153
+ if (message.toAgentName) record.toAgentName = message.toAgentName;
154
+
155
+ // Append to the chunk file
156
+ fs.appendFileSync(chunkPath, JSON.stringify(record) + '\n', 'utf-8');
157
+ cacheEntry.currentChunkCount++;
158
+
159
+ // If the current chunk is full, advance to the next one
160
+ if (cacheEntry.currentChunkCount >= MESSAGES_PER_CHUNK) {
161
+ cacheEntry.currentChunkIndex++;
162
+ cacheEntry.currentChunkCount = 0;
163
+ }
164
+
165
+ // Update metadata
166
+ this._updateMeta(sessionId, dir);
167
+
168
+ return record;
169
+ }
170
+
171
+ /**
172
+ * Get the most recent N messages (used when an Agent wakes up to load context)
173
+ * @param {string} sessionId - Session ID
174
+ * @param {number} limit - Max messages to return
175
+ * @returns {Array} Message list (in ascending time order)
176
+ */
177
+ getRecentMessages(sessionId, limit = 10) {
178
+ const dir = getSessionDir(sessionId);
179
+ if (!fs.existsSync(dir)) return [];
180
+
181
+ const chunks = listChunkFiles(dir);
182
+ if (chunks.length === 0) return [];
183
+
184
+ const messages = [];
185
+ // Read backwards from the most recent chunk
186
+ for (let i = chunks.length - 1; i >= 0 && messages.length < limit; i--) {
187
+ const chunkPath = path.join(dir, chunks[i]);
188
+ const chunkMessages = readChunkFile(chunkPath);
189
+ // Take from the tail of the chunk
190
+ messages.unshift(...chunkMessages);
191
+ }
192
+
193
+ // Return only the most recent `limit` messages
194
+ return messages.slice(-limit);
195
+ }
196
+
197
+ /**
198
+ * Get the total message count for a session
199
+ * @param {string} sessionId
200
+ * @returns {number}
201
+ */
202
+ getMessageCount(sessionId) {
203
+ const dir = getSessionDir(sessionId);
204
+ if (!fs.existsSync(dir)) return 0;
205
+
206
+ const chunks = listChunkFiles(dir);
207
+ let total = 0;
208
+ for (const chunk of chunks) {
209
+ total += countMessagesInChunk(path.join(dir, chunk));
210
+ }
211
+ return total;
212
+ }
213
+
214
+ /**
215
+ * Keyword search over historical messages
216
+ * Searches all chunks for messages containing the keywords, for providing Agent context
217
+ *
218
+ * @param {string} sessionId - Session ID
219
+ * @param {string} query - Search keywords
220
+ * @param {number} limit - Max messages to return
221
+ * @returns {Array} Matching messages (sorted by relevance)
222
+ */
223
+ searchMessages(sessionId, query, limit = 10) {
224
+ const dir = getSessionDir(sessionId);
225
+ if (!fs.existsSync(dir)) return [];
226
+
227
+ const keywords = this._extractKeywords(query);
228
+ if (keywords.length === 0) return [];
229
+
230
+ const chunks = listChunkFiles(dir);
231
+ const scoredMessages = [];
232
+
233
+ for (const chunk of chunks) {
234
+ const chunkPath = path.join(dir, chunk);
235
+ const messages = readChunkFile(chunkPath);
236
+
237
+ for (const msg of messages) {
238
+ const score = this._calculateRelevance(msg, keywords);
239
+ if (score > 0) {
240
+ scoredMessages.push({ ...msg, _relevanceScore: score });
241
+ }
242
+ }
243
+ }
244
+
245
+ // Sort by relevance and take the top N
246
+ scoredMessages.sort((a, b) => b._relevanceScore - a._relevanceScore);
247
+ return scoredMessages.slice(0, limit);
248
+ }
249
+
250
+ /**
251
+ * Search and return matching messages with surrounding context (window context)
252
+ * @param {string} sessionId
253
+ * @param {string} query
254
+ * @param {number} limit - Number of match groups to return
255
+ * @param {number} windowSize - How many messages to include before/after each match
256
+ * @returns {Array} List of message contexts
257
+ */
258
+ searchWithContext(sessionId, query, limit = 5, windowSize = 2) {
259
+ const dir = getSessionDir(sessionId);
260
+ if (!fs.existsSync(dir)) return [];
261
+
262
+ const keywords = this._extractKeywords(query);
263
+ if (keywords.length === 0) return [];
264
+
265
+ // Read all messages (with global index)
266
+ const chunks = listChunkFiles(dir);
267
+ const allMessages = [];
268
+ for (const chunk of chunks) {
269
+ const chunkPath = path.join(dir, chunk);
270
+ allMessages.push(...readChunkFile(chunkPath));
271
+ }
272
+
273
+ // Find matching message indices and scores
274
+ const matches = [];
275
+ for (let i = 0; i < allMessages.length; i++) {
276
+ const score = this._calculateRelevance(allMessages[i], keywords);
277
+ if (score > 0) {
278
+ matches.push({ index: i, score });
279
+ }
280
+ }
281
+
282
+ // Sort by score
283
+ matches.sort((a, b) => b.score - a.score);
284
+
285
+ // Take the top N matches and return their window context
286
+ const contexts = [];
287
+ const usedIndices = new Set();
288
+ for (const match of matches.slice(0, limit)) {
289
+ const start = Math.max(0, match.index - windowSize);
290
+ const end = Math.min(allMessages.length - 1, match.index + windowSize);
291
+
292
+ // Deduplicate
293
+ if (usedIndices.has(match.index)) continue;
294
+
295
+ const contextMessages = [];
296
+ for (let i = start; i <= end; i++) {
297
+ usedIndices.add(i);
298
+ contextMessages.push(allMessages[i]);
299
+ }
300
+ contexts.push({
301
+ matchedMessage: allMessages[match.index],
302
+ score: match.score,
303
+ context: contextMessages,
304
+ });
305
+ }
306
+
307
+ return contexts;
308
+ }
309
+
310
+ /**
311
+ * List all sessions
312
+ * @returns {Array} Session list
313
+ */
314
+ listSessions() {
315
+ if (!fs.existsSync(CHATS_DIR)) return [];
316
+
317
+ const dirs = fs.readdirSync(CHATS_DIR).filter(d => {
318
+ const fullPath = path.join(CHATS_DIR, d);
319
+ return fs.statSync(fullPath).isDirectory();
320
+ });
321
+
322
+ return dirs.map(d => {
323
+ const metaPath = path.join(CHATS_DIR, d, 'meta.json');
324
+ if (!fs.existsSync(metaPath)) return null;
325
+ try {
326
+ return JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
327
+ } catch {
328
+ return null;
329
+ }
330
+ }).filter(Boolean);
331
+ }
332
+
333
+ /**
334
+ * Mark a session as read (updates bossLastReadAt)
335
+ * @param {string} sessionId
336
+ */
337
+ markSessionRead(sessionId) {
338
+ const dir = getSessionDir(sessionId);
339
+ const metaPath = path.join(dir, 'meta.json');
340
+ if (!fs.existsSync(metaPath)) return;
341
+ try {
342
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
343
+ meta.bossLastReadAt = new Date().toISOString();
344
+ fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), 'utf-8');
345
+ } catch (e) {
346
+ // ignore
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Get session metadata
352
+ * @param {string} sessionId
353
+ * @returns {object|null}
354
+ */
355
+ getSessionMeta(sessionId) {
356
+ const dir = getSessionDir(sessionId);
357
+ const metaPath = path.join(dir, 'meta.json');
358
+ if (!fs.existsSync(metaPath)) return null;
359
+ try {
360
+ return JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
361
+ } catch {
362
+ return null;
363
+ }
364
+ }
365
+
366
+ /**
367
+ * Delete a session
368
+ * @param {string} sessionId
369
+ */
370
+ deleteSession(sessionId) {
371
+ const dir = getSessionDir(sessionId);
372
+ if (fs.existsSync(dir)) {
373
+ fs.rmSync(dir, { recursive: true, force: true });
374
+ this._cache.delete(sessionId);
375
+ console.log(`🗑️ Deleted session record: ${sessionId}`);
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Migrate from a legacy chatHistory array to file storage
381
+ * @param {string} sessionId - Session ID
382
+ * @param {Array} chatHistory - Legacy chat history array
383
+ */
384
+ migrateFromArray(sessionId, chatHistory) {
385
+ if (!chatHistory || chatHistory.length === 0) return;
386
+
387
+ this.createSession(sessionId, {
388
+ title: 'Migrated session',
389
+ type: 'boss-secretary',
390
+ });
391
+
392
+ for (const msg of chatHistory) {
393
+ this.appendMessage(sessionId, {
394
+ role: msg.role,
395
+ content: msg.content,
396
+ action: msg.action || null,
397
+ time: msg.time,
398
+ });
399
+ }
400
+ console.log(`📦 Migrated ${chatHistory.length} chat messages to file storage`);
401
+ }
402
+
403
+ // ========================================================================
404
+ // Group Chat Storage (Requirement / Department / Sprint group chats)
405
+ // ========================================================================
406
+
407
+ /**
408
+ * Append a group chat message to file storage.
409
+ * The message is stored as-is (preserving from, type, visibility, etc.).
410
+ * @param {string} groupId - Group identifier, e.g. "req-{id}", "dept-{id}", "sprint-{id}"
411
+ * @param {object} message - Full group chat message object { id, from, content, type, visibility, time }
412
+ */
413
+ appendGroupMessage(groupId, message) {
414
+ const sessionId = `group-${groupId}`;
415
+ const dir = ensureSessionDir(sessionId);
416
+
417
+ const cacheEntry = this._getOrInitCache(sessionId, dir);
418
+ const chunkPath = path.join(dir, chunkFileName(cacheEntry.currentChunkIndex));
419
+
420
+ // Store the message as-is, just ensure time is ISO string
421
+ const record = {
422
+ ...message,
423
+ time: message.time ? new Date(message.time).toISOString() : new Date().toISOString(),
424
+ };
425
+
426
+ fs.appendFileSync(chunkPath, JSON.stringify(record) + '\n', 'utf-8');
427
+ cacheEntry.currentChunkCount++;
428
+
429
+ if (cacheEntry.currentChunkCount >= MESSAGES_PER_CHUNK) {
430
+ cacheEntry.currentChunkIndex++;
431
+ cacheEntry.currentChunkCount = 0;
432
+ }
433
+
434
+ // Skip meta update for group chats (high frequency, meta is not critical)
435
+ }
436
+
437
+ /**
438
+ * Load group chat messages from file storage.
439
+ * @param {string} groupId - Group identifier
440
+ * @param {number} [limit=200] - Max messages to return (from tail)
441
+ * @returns {Array} Message list (ascending time order)
442
+ */
443
+ getGroupMessages(groupId, limit = 200) {
444
+ const sessionId = `group-${groupId}`;
445
+ const dir = getSessionDir(sessionId);
446
+ if (!fs.existsSync(dir)) return [];
447
+
448
+ const chunks = listChunkFiles(dir);
449
+ if (chunks.length === 0) return [];
450
+
451
+ const messages = [];
452
+ for (let i = chunks.length - 1; i >= 0 && messages.length < limit; i--) {
453
+ const chunkPath = path.join(dir, chunks[i]);
454
+ const chunkMessages = readChunkFile(chunkPath);
455
+ messages.unshift(...chunkMessages);
456
+ }
457
+
458
+ return messages.slice(-limit);
459
+ }
460
+
461
+ /**
462
+ * Migrate an in-memory groupChat array to file storage (one-time migration).
463
+ * @param {string} groupId - Group identifier
464
+ * @param {Array} groupChat - Legacy in-memory groupChat array
465
+ */
466
+ migrateGroupChat(groupId, groupChat) {
467
+ if (!groupChat || groupChat.length === 0) return;
468
+ const sessionId = `group-${groupId}`;
469
+ // Skip if already migrated
470
+ if (this.getGroupMessages(groupId, 1).length > 0) return;
471
+
472
+ ensureSessionDir(sessionId);
473
+ for (const msg of groupChat) {
474
+ this.appendGroupMessage(groupId, msg);
475
+ }
476
+ console.log(`📦 Migrated ${groupChat.length} group chat messages for ${groupId} to file storage`);
477
+ }
478
+
479
+ /**
480
+ * Delete group chat file storage
481
+ * @param {string} groupId
482
+ */
483
+ deleteGroupChat(groupId) {
484
+ this.deleteSession(`group-${groupId}`);
485
+ }
486
+
487
+ // ========================================================================
488
+ // Internal Methods
489
+ // ========================================================================
490
+
491
+ /**
492
+ * Get or initialize a cache entry
493
+ */
494
+ _getOrInitCache(sessionId, dir) {
495
+ if (this._cache.has(sessionId)) {
496
+ return this._cache.get(sessionId);
497
+ }
498
+
499
+ const chunks = listChunkFiles(dir);
500
+ let currentChunkIndex = 1;
501
+ let currentChunkCount = 0;
502
+
503
+ if (chunks.length > 0) {
504
+ // Take the last chunk
505
+ const lastChunk = chunks[chunks.length - 1];
506
+ const match = lastChunk.match(/chunk-(\d+)\.jsonl/);
507
+ if (match) {
508
+ currentChunkIndex = parseInt(match[1], 10);
509
+ currentChunkCount = countMessagesInChunk(path.join(dir, lastChunk));
510
+ // If full, advance to the next
511
+ if (currentChunkCount >= MESSAGES_PER_CHUNK) {
512
+ currentChunkIndex++;
513
+ currentChunkCount = 0;
514
+ }
515
+ }
516
+ }
517
+
518
+ const entry = { currentChunkIndex, currentChunkCount };
519
+ this._cache.set(sessionId, entry);
520
+ return entry;
521
+ }
522
+
523
+ /**
524
+ * Update session metadata
525
+ */
526
+ _updateMeta(sessionId, dir) {
527
+ const metaPath = path.join(dir, 'meta.json');
528
+ try {
529
+ let meta = {};
530
+ if (fs.existsSync(metaPath)) {
531
+ meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
532
+ }
533
+ meta.lastActiveAt = new Date().toISOString();
534
+ meta.totalMessages = this.getMessageCount(sessionId);
535
+ fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), 'utf-8');
536
+ } catch (e) {
537
+ // Metadata update failure does not affect the main flow
538
+ }
539
+ }
540
+
541
+ /**
542
+ * Extract search keywords
543
+ * Language-agnostic: splits on whitespace, keeps the full query as an
544
+ * additional token so substring matches across word boundaries still work.
545
+ * Single-char tokens are dropped to reduce noise.
546
+ */
547
+ _extractKeywords(query) {
548
+ if (!query || !query.trim()) return [];
549
+
550
+ const text = query.toLowerCase().trim();
551
+ const tokens = text.split(/\s+/).filter(t => t.length > 1);
552
+
553
+ // Also keep the full query itself when it differs from a single token,
554
+ // so multi-word phrases can match as a whole.
555
+ if (tokens.length > 1) {
556
+ tokens.push(text);
557
+ }
558
+
559
+ return [...new Set(tokens)];
560
+ }
561
+
562
+ /**
563
+ * Calculate relevance score between a message and keywords
564
+ * Simple BM25-style scoring
565
+ */
566
+ _calculateRelevance(message, keywords) {
567
+ if (!message.content) return 0;
568
+
569
+ const content = message.content.toLowerCase();
570
+ let score = 0;
571
+
572
+ for (const keyword of keywords) {
573
+ // Exact match count
574
+ const regex = new RegExp(keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
575
+ const matches = content.match(regex);
576
+ if (matches) {
577
+ // TF (term frequency) — more occurrences = higher score, with decay
578
+ const tf = Math.log(1 + matches.length);
579
+ // Length weighting (longer keywords are more meaningful)
580
+ const lengthBonus = Math.min(keyword.length / 3, 2);
581
+ score += tf * lengthBonus;
582
+ }
583
+ }
584
+
585
+ // Role weighting: boss messages are more important (contain instructions/preferences)
586
+ if (message.role === 'boss') {
587
+ score *= 1.2;
588
+ }
589
+
590
+ // Time decay (optional: more recent messages get a slight boost)
591
+ if (message.time) {
592
+ const ageHours = (Date.now() - new Date(message.time).getTime()) / (1000 * 60 * 60);
593
+ const freshness = Math.max(0.5, 1 - ageHours / (24 * 30)); // Linear decay to 0.5 over 30 days
594
+ score *= freshness;
595
+ }
596
+
597
+ return score;
598
+ }
599
+ }
600
+
601
+ // Global singleton
602
+ export const chatStore = new ChatStore();