tycono-server 0.1.0-beta.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.
Files changed (84) hide show
  1. package/bin/cli.js +35 -0
  2. package/bin/server.ts +160 -0
  3. package/package.json +50 -0
  4. package/src/api/package.json +31 -0
  5. package/src/api/src/create-app.ts +90 -0
  6. package/src/api/src/create-server.ts +251 -0
  7. package/src/api/src/engine/agent-loop.ts +738 -0
  8. package/src/api/src/engine/authority-validator.ts +149 -0
  9. package/src/api/src/engine/context-assembler.ts +912 -0
  10. package/src/api/src/engine/index.ts +27 -0
  11. package/src/api/src/engine/knowledge-gate.ts +365 -0
  12. package/src/api/src/engine/llm-adapter.ts +304 -0
  13. package/src/api/src/engine/org-tree.ts +270 -0
  14. package/src/api/src/engine/role-lifecycle.ts +369 -0
  15. package/src/api/src/engine/runners/claude-cli.ts +796 -0
  16. package/src/api/src/engine/runners/direct-api.ts +66 -0
  17. package/src/api/src/engine/runners/index.ts +30 -0
  18. package/src/api/src/engine/runners/types.ts +95 -0
  19. package/src/api/src/engine/skill-template.ts +134 -0
  20. package/src/api/src/engine/tools/definitions.ts +201 -0
  21. package/src/api/src/engine/tools/executor.ts +611 -0
  22. package/src/api/src/routes/active-sessions.ts +134 -0
  23. package/src/api/src/routes/coins.ts +153 -0
  24. package/src/api/src/routes/company.ts +57 -0
  25. package/src/api/src/routes/cost.ts +141 -0
  26. package/src/api/src/routes/engine.ts +220 -0
  27. package/src/api/src/routes/execute.ts +1075 -0
  28. package/src/api/src/routes/git.ts +211 -0
  29. package/src/api/src/routes/knowledge.ts +378 -0
  30. package/src/api/src/routes/operations.ts +309 -0
  31. package/src/api/src/routes/preferences.ts +63 -0
  32. package/src/api/src/routes/presets.ts +123 -0
  33. package/src/api/src/routes/projects.ts +82 -0
  34. package/src/api/src/routes/quests.ts +41 -0
  35. package/src/api/src/routes/roles.ts +112 -0
  36. package/src/api/src/routes/save.ts +152 -0
  37. package/src/api/src/routes/sessions.ts +288 -0
  38. package/src/api/src/routes/setup.ts +437 -0
  39. package/src/api/src/routes/skills.ts +357 -0
  40. package/src/api/src/routes/speech.ts +959 -0
  41. package/src/api/src/routes/supervision.ts +136 -0
  42. package/src/api/src/routes/sync.ts +165 -0
  43. package/src/api/src/server.ts +59 -0
  44. package/src/api/src/services/activity-stream.ts +184 -0
  45. package/src/api/src/services/activity-tracker.ts +115 -0
  46. package/src/api/src/services/claude-md-manager.ts +94 -0
  47. package/src/api/src/services/company-config.ts +115 -0
  48. package/src/api/src/services/database.ts +77 -0
  49. package/src/api/src/services/digest-engine.ts +313 -0
  50. package/src/api/src/services/execution-manager.ts +1036 -0
  51. package/src/api/src/services/file-reader.ts +77 -0
  52. package/src/api/src/services/git-save.ts +614 -0
  53. package/src/api/src/services/job-manager.ts +16 -0
  54. package/src/api/src/services/knowledge-importer.ts +466 -0
  55. package/src/api/src/services/markdown-parser.ts +173 -0
  56. package/src/api/src/services/port-registry.ts +222 -0
  57. package/src/api/src/services/preferences.ts +150 -0
  58. package/src/api/src/services/preset-loader.ts +149 -0
  59. package/src/api/src/services/pricing.ts +34 -0
  60. package/src/api/src/services/scaffold.ts +546 -0
  61. package/src/api/src/services/session-store.ts +340 -0
  62. package/src/api/src/services/supervisor-heartbeat.ts +897 -0
  63. package/src/api/src/services/team-recommender.ts +382 -0
  64. package/src/api/src/services/token-ledger.ts +127 -0
  65. package/src/api/src/services/wave-messages.ts +194 -0
  66. package/src/api/src/services/wave-multiplexer.ts +356 -0
  67. package/src/api/src/services/wave-tracker.ts +359 -0
  68. package/src/api/src/utils/role-level.ts +31 -0
  69. package/src/core/scaffolder.ts +620 -0
  70. package/src/shared/types.ts +224 -0
  71. package/templates/CLAUDE.md.tmpl +239 -0
  72. package/templates/company.md.tmpl +17 -0
  73. package/templates/gitignore.tmpl +28 -0
  74. package/templates/roles.md.tmpl +8 -0
  75. package/templates/skills/_manifest.json +23 -0
  76. package/templates/skills/agent-browser/SKILL.md +159 -0
  77. package/templates/skills/agent-browser/meta.json +19 -0
  78. package/templates/skills/akb-linter/SKILL.md +125 -0
  79. package/templates/skills/akb-linter/meta.json +12 -0
  80. package/templates/skills/knowledge-gate/SKILL.md +120 -0
  81. package/templates/skills/knowledge-gate/meta.json +12 -0
  82. package/templates/teams/agency.json +58 -0
  83. package/templates/teams/research.json +58 -0
  84. package/templates/teams/startup.json +58 -0
@@ -0,0 +1,959 @@
1
+ /**
2
+ * speech.ts — Chat Pipeline LLM endpoint
3
+ *
4
+ * POST /api/speech/chat — History-aware channel conversation.
5
+ * AI reads channel history and responds in character.
6
+ * Uses Haiku with AKB tool-use for grounded, context-aware chat.
7
+ */
8
+ import { Router, Request, Response, NextFunction } from 'express';
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { glob } from 'glob';
12
+ import { COMPANY_ROOT, readFile, fileExists, listFiles } from '../services/file-reader.js';
13
+ import { buildOrgTree } from '../engine/index.js';
14
+ import { parseMarkdownTable, extractBoldKeyValues } from '../services/markdown-parser.js';
15
+ import {
16
+ AnthropicProvider, ClaudeCliProvider,
17
+ type LLMProvider, type ToolDefinition, type LLMMessage, type LLMResponse, type MessageContent, type ChatOptions,
18
+ } from '../engine/llm-adapter.js';
19
+ import { TokenLedger } from '../services/token-ledger.js';
20
+ import { readConfig } from '../services/company-config.js';
21
+ import { readPreferences } from '../services/preferences.js';
22
+ import { calcLevel } from '../utils/role-level.js';
23
+
24
+ export const speechRouter = Router();
25
+
26
+ /* ══════════════════════════════════════════════════
27
+ * Post-processing — OpenClaw-inspired filtering layer
28
+ * ══════════════════════════════════════════════════ */
29
+
30
+ const MIN_DUPLICATE_TEXT_LENGTH = 10;
31
+
32
+ /** Exact match: entire message is [SILENT] (with optional whitespace) */
33
+ function isSilentReply(text: string): boolean {
34
+ return /^\s*\[SILENT\]\s*$/i.test(text);
35
+ }
36
+
37
+ /** Strip trailing [SILENT] from mixed content */
38
+ function stripSilentToken(text: string): string {
39
+ return text.replace(/(?:^|\s+)\[SILENT\]\s*$/i, '').trim();
40
+ }
41
+
42
+ /** Normalize for duplicate comparison (OpenClaw pattern) */
43
+ function normalizeForComparison(text: string): string {
44
+ return text
45
+ .trim()
46
+ .toLowerCase()
47
+ .replace(/\p{Emoji_Presentation}|\p{Extended_Pictographic}/gu, '')
48
+ .replace(/\s+/g, ' ')
49
+ .trim();
50
+ }
51
+
52
+ /** Check if message is a duplicate of any history message (substring match) */
53
+ function isDuplicateMessage(text: string, historyTexts: string[]): boolean {
54
+ const normalized = normalizeForComparison(text);
55
+ if (!normalized || normalized.length < MIN_DUPLICATE_TEXT_LENGTH) return false;
56
+
57
+ return historyTexts.some(sent => {
58
+ const normSent = normalizeForComparison(sent);
59
+ if (!normSent || normSent.length < MIN_DUPLICATE_TEXT_LENGTH) return false;
60
+ return normalized.includes(normSent) || normSent.includes(normalized);
61
+ });
62
+ }
63
+
64
+ /** Post-process LLM chat output: sanitize, detect silence, check duplicates */
65
+ function postProcessChatMessage(
66
+ raw: string,
67
+ historyTexts: string[],
68
+ ): string {
69
+ // 1. Clean quotes
70
+ let text = raw.replace(/^["']|["']$/g, '').trim();
71
+
72
+ // 2. Strip CLI noise
73
+ if (text.startsWith('Error: Reached max turns') || !text) return '';
74
+
75
+ // 3. Exact [SILENT] → suppress
76
+ if (isSilentReply(text)) return '';
77
+
78
+ // 4. Trailing [SILENT] → strip it
79
+ text = stripSilentToken(text);
80
+ if (!text) return '';
81
+
82
+ // 5. Duplicate detection (substring match against recent history)
83
+ if (isDuplicateMessage(text, historyTexts)) return '';
84
+
85
+ return text;
86
+ }
87
+
88
+ /* ══════════════════════════════════════════════════
89
+ * AKB Tools — Let chat roles explore company knowledge
90
+ * ══════════════════════════════════════════════════ */
91
+
92
+ const MAX_TOOL_ROUNDS = 50;
93
+ const MAX_FILE_CHARS = 1500; // truncate large files
94
+
95
+ const AKB_TOOLS: ToolDefinition[] = [
96
+ {
97
+ name: 'search_akb',
98
+ description: 'Search the company knowledge base (AKB) for keywords. Returns matching file paths and snippets. Use to find decisions, journals, projects, waves, standups, or any company knowledge.',
99
+ input_schema: {
100
+ type: 'object' as const,
101
+ properties: {
102
+ query: { type: 'string', description: 'Search keywords (e.g. "landing deploy", "refactoring decision", "Store Import")' },
103
+ path: { type: 'string', description: 'Optional subdirectory to search in (e.g. "knowledge/decisions", "knowledge/projects", "knowledge"). Defaults to entire AKB.' },
104
+ },
105
+ required: ['query'],
106
+ },
107
+ },
108
+ {
109
+ name: 'read_file',
110
+ description: 'Read a specific file from the AKB. Use after search_akb to read full content of interesting files.',
111
+ input_schema: {
112
+ type: 'object' as const,
113
+ properties: {
114
+ path: { type: 'string', description: 'File path relative to AKB root (e.g. "knowledge/decisions/008-repo-structure.md", "knowledge/projects/projects.md")' },
115
+ },
116
+ required: ['path'],
117
+ },
118
+ },
119
+ {
120
+ name: 'list_files',
121
+ description: 'List files in a directory. Useful to discover what exists (e.g. ".tycono/waves/", "knowledge/roles/engineer/journal/").',
122
+ input_schema: {
123
+ type: 'object' as const,
124
+ properties: {
125
+ path: { type: 'string', description: 'Directory path relative to AKB root (e.g. ".tycono/standup", "knowledge/roles/pm/journal")' },
126
+ pattern: { type: 'string', description: 'Glob pattern (default: "*.md")' },
127
+ },
128
+ required: ['path'],
129
+ },
130
+ },
131
+ ];
132
+
133
+ function executeAkbTool(name: string, input: Record<string, unknown>): string {
134
+ try {
135
+ switch (name) {
136
+ case 'search_akb': {
137
+ const query = String(input.query || '');
138
+ const searchPath = input.path ? String(input.path) : '';
139
+ const searchDir = path.resolve(COMPANY_ROOT, searchPath);
140
+
141
+ if (!fs.existsSync(searchDir)) return `Directory not found: ${searchPath || '/'}`;
142
+
143
+ // Find all .md files, then grep for query keywords
144
+ const mdFiles = glob.sync('**/*.md', { cwd: searchDir, nodir: true }).slice(0, 100);
145
+ const keywords = query.toLowerCase().split(/\s+/).filter(Boolean);
146
+ const results: string[] = [];
147
+
148
+ for (const file of mdFiles) {
149
+ const fullPath = path.join(searchDir, file);
150
+ const content = fs.readFileSync(fullPath, 'utf-8');
151
+ const lower = content.toLowerCase();
152
+ const matchCount = keywords.filter(k => lower.includes(k)).length;
153
+ if (matchCount >= Math.max(1, Math.ceil(keywords.length * 0.5))) {
154
+ // Extract a relevant snippet (first matching line + context)
155
+ const lines = content.split('\n');
156
+ let snippet = '';
157
+ for (let i = 0; i < lines.length; i++) {
158
+ const ll = lines[i].toLowerCase();
159
+ if (keywords.some(k => ll.includes(k))) {
160
+ snippet = lines.slice(Math.max(0, i - 1), i + 3).join('\n').slice(0, 200);
161
+ break;
162
+ }
163
+ }
164
+ const relPath = searchPath ? `${searchPath}/${file}` : file;
165
+ results.push(`📄 ${relPath} (${matchCount}/${keywords.length} keywords)\n${snippet}`);
166
+ }
167
+ if (results.length >= 8) break;
168
+ }
169
+
170
+ return results.length > 0
171
+ ? results.join('\n\n')
172
+ : `No results for "${query}" in ${searchPath || 'AKB'}`;
173
+ }
174
+
175
+ case 'read_file': {
176
+ const filePath = String(input.path || '');
177
+ const absolute = path.resolve(COMPANY_ROOT, filePath);
178
+ if (!fs.existsSync(absolute)) return `File not found: ${filePath}`;
179
+ const content = fs.readFileSync(absolute, 'utf-8');
180
+ return content.length > MAX_FILE_CHARS
181
+ ? content.slice(0, MAX_FILE_CHARS) + `\n\n... (truncated, ${content.length} chars total)`
182
+ : content;
183
+ }
184
+
185
+ case 'list_files': {
186
+ const dirPath = String(input.path || '');
187
+ const pat = String(input.pattern || '*.md');
188
+ const absolute = path.resolve(COMPANY_ROOT, dirPath);
189
+ if (!fs.existsSync(absolute)) return `Directory not found: ${dirPath}`;
190
+ const files = glob.sync(pat, { cwd: absolute, nodir: true }).sort();
191
+ return files.length > 0
192
+ ? files.map(f => `- ${dirPath}/${f}`).join('\n')
193
+ : `No files matching "${pat}" in ${dirPath}`;
194
+ }
195
+
196
+ default:
197
+ return `Unknown tool: ${name}`;
198
+ }
199
+ } catch (err) {
200
+ return `Error: ${err instanceof Error ? err.message : String(err)}`;
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Run mini agent loop: LLM call → tool use → LLM call → ... → final text.
206
+ * Max MAX_TOOL_ROUNDS rounds of tool use, then force a text response.
207
+ */
208
+ async function chatWithTools(
209
+ provider: LLMProvider,
210
+ systemPrompt: string,
211
+ initialMessages: LLMMessage[],
212
+ useTools: boolean,
213
+ maxTokens?: number,
214
+ ): Promise<{ text: string; totalUsage: { inputTokens: number; outputTokens: number } }> {
215
+ const messages: LLMMessage[] = [...initialMessages];
216
+ const totalUsage = { inputTokens: 0, outputTokens: 0 };
217
+ const tools = useTools ? AKB_TOOLS : undefined;
218
+
219
+ for (let round = 0; round <= MAX_TOOL_ROUNDS; round++) {
220
+ // During tool exploration use higher limit; cap only final text response
221
+ const isToolPhase = tools && round < MAX_TOOL_ROUNDS;
222
+ const opts: ChatOptions | undefined = isToolPhase ? { maxTokens: 1024 } : maxTokens ? { maxTokens } : undefined;
223
+ const response = await provider.chat(systemPrompt, messages, tools, undefined, opts);
224
+ totalUsage.inputTokens += response.usage.inputTokens;
225
+ totalUsage.outputTokens += response.usage.outputTokens;
226
+
227
+ // Check if there are tool calls
228
+ const toolCalls = response.content.filter(c => c.type === 'tool_use');
229
+ const textParts = response.content.filter(c => c.type === 'text').map(c => (c as { type: 'text'; text: string }).text);
230
+
231
+ if (toolCalls.length === 0 || round === MAX_TOOL_ROUNDS) {
232
+ // No tool calls or max rounds reached — return text
233
+ return { text: textParts.join('').trim(), totalUsage };
234
+ }
235
+
236
+ // Execute tool calls and build tool results
237
+ messages.push({ role: 'assistant', content: response.content });
238
+
239
+ const toolResults: MessageContent[] = toolCalls.map(tc => {
240
+ const call = tc as { type: 'tool_use'; id: string; name: string; input: Record<string, unknown> };
241
+ const result = executeAkbTool(call.name, call.input);
242
+ return { type: 'tool_result' as any, tool_use_id: call.id, content: result } as any;
243
+ });
244
+
245
+ messages.push({ role: 'user', content: toolResults });
246
+ }
247
+
248
+ return { text: '', totalUsage };
249
+ }
250
+
251
+ /**
252
+ * Build a compact company context for chat system prompts.
253
+ * Provides a seed overview — the agent can dig deeper via AKB tools.
254
+ */
255
+ function buildCompanyContext(): string {
256
+ const parts: string[] = [];
257
+
258
+ // 1. Company info (name, mission)
259
+ try {
260
+ const companyContent = readFile('knowledge/company.md');
261
+ const companyName = companyContent.split('\n').find(l => l.startsWith('# '))?.replace(/^#\s+/, '') ?? '';
262
+ const missionMatch = companyContent.match(/^>\s*(.+)/m);
263
+ const mission = missionMatch ? missionMatch[1].trim() : '';
264
+ const kv = extractBoldKeyValues(companyContent);
265
+ const domain = kv['도메인'] ?? kv['domain'] ?? '';
266
+ if (companyName) {
267
+ parts.push(`Company: ${companyName}${domain ? ` (${domain})` : ''}${mission ? `\nMission: ${mission}` : ''}`);
268
+ }
269
+ } catch { /* no company.md */ }
270
+
271
+ // 2. Org overview (who reports to whom)
272
+ try {
273
+ const tree = buildOrgTree(COMPANY_ROOT);
274
+ const orgLines: string[] = [];
275
+ for (const [, node] of tree.nodes) {
276
+ if (node.id === 'ceo') continue;
277
+ orgLines.push(`- ${node.name} (${node.id}, ${node.level}) reports to ${node.reportsTo}`);
278
+ }
279
+ if (orgLines.length > 0) {
280
+ parts.push(`Organization:\n${orgLines.join('\n')}`);
281
+ }
282
+ } catch { /* no org */ }
283
+
284
+ // 3. Active projects + current phase from tasks.md
285
+ try {
286
+ const projectsContent = readFile('knowledge/projects/projects.md');
287
+ const rows = parseMarkdownTable(projectsContent);
288
+ const activeProjects = rows
289
+ .filter(r => (r.status ?? r.상태 ?? '').toLowerCase() !== 'archived')
290
+ .map(r => {
291
+ const name = r.name ?? r.project ?? r.프로젝트 ?? '';
292
+ const status = r.status ?? r.상태 ?? '';
293
+ const folder = r.folder ?? r.path ?? r.경로 ?? '';
294
+ // Try to read tasks.md for current phase info
295
+ let phaseInfo = '';
296
+ if (folder) {
297
+ try {
298
+ const tasksPath = `${folder.replace(/^\//, '')}/tasks.md`;
299
+ const tasksContent = readFile(tasksPath);
300
+ // Extract current phase (look for "Current" or latest non-done phase)
301
+ const phaseMatch = tasksContent.match(/##\s+(Phase\s+\S+[^\n]*)/gi);
302
+ if (phaseMatch) phaseInfo = ` — ${phaseMatch[0].replace(/^##\s+/, '').slice(0, 60)}`;
303
+ } catch { /* no tasks.md */ }
304
+ }
305
+ return `- ${name} (${status}${phaseInfo})`;
306
+ })
307
+ .slice(0, 5);
308
+ if (activeProjects.length > 0) {
309
+ parts.push(`Active Projects:\n${activeProjects.join('\n')}`);
310
+ }
311
+ } catch { /* no projects */ }
312
+
313
+ // 3b. Tech stack reality check (prevent hallucination about wrong tech)
314
+ try {
315
+ const config = readConfig(COMPANY_ROOT);
316
+ if (config.codeRoot) {
317
+ const pkgPath = path.join(config.codeRoot, 'package.json');
318
+ if (fs.existsSync(pkgPath)) {
319
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
320
+ const name = pkg.name ?? '';
321
+ const version = pkg.version ?? '';
322
+ parts.push(`Tech Stack: ${name}@${version} — TypeScript + React + Node.js (Express). NO Python in codebase. NO ongoing language migration.`);
323
+ }
324
+ }
325
+ } catch { /* no package.json */ }
326
+
327
+ // 4. Knowledge highlights (hub TL;DRs, max 3)
328
+ try {
329
+ const knowledgeHub = readFile('knowledge/knowledge.md');
330
+ const tldr = knowledgeHub.match(/## TL;DR[\s\S]*?(?=\n## [^#])/);
331
+ if (tldr) {
332
+ parts.push(`Knowledge Base:\n${tldr[0].replace('## TL;DR', '').trim().slice(0, 300)}`);
333
+ }
334
+ } catch { /* no knowledge */ }
335
+
336
+ // 5. Recent CEO decisions (max 5)
337
+ try {
338
+ const decisionsDir = path.join(COMPANY_ROOT, 'knowledge', 'decisions');
339
+ if (fs.existsSync(decisionsDir)) {
340
+ const files = fs.readdirSync(decisionsDir)
341
+ .filter(f => f.endsWith('.md') && f !== 'decisions.md')
342
+ .sort()
343
+ .slice(-5);
344
+ const decisions: string[] = [];
345
+ for (const file of files) {
346
+ const content = fs.readFileSync(path.join(decisionsDir, file), 'utf-8');
347
+ const statusMatch = content.match(/>\s*Status:\s*(.+)/i);
348
+ if (!statusMatch || !statusMatch[1].toLowerCase().includes('approved')) continue;
349
+ const titleMatch = content.match(/^#\s+(.+)/m);
350
+ if (titleMatch) decisions.push(`- ${titleMatch[1].trim()}`);
351
+ }
352
+ if (decisions.length > 0) {
353
+ parts.push(`Recent CEO Decisions:\n${decisions.join('\n')}`);
354
+ }
355
+ }
356
+ } catch { /* no decisions */ }
357
+
358
+ return parts.length > 0
359
+ ? `\n\nCOMPANY CONTEXT (use this to inform your conversations):\n${parts.join('\n\n')}`
360
+ : '';
361
+ }
362
+
363
+ /**
364
+ * Build role-specific AKB context by pre-fetching relevant knowledge server-side.
365
+ * This is the PRIMARY source of grounding for chat — must be rich enough that
366
+ * agents don't need to use tools (Haiku won't proactively search).
367
+ */
368
+ function buildRoleContext(roleId: string): string {
369
+ const parts: string[] = [];
370
+
371
+ // 0. Role profile — gives the agent its identity and work context
372
+ try {
373
+ const profilePath = path.join(COMPANY_ROOT, 'knowledge', 'roles', roleId, 'profile.md');
374
+ if (fs.existsSync(profilePath)) {
375
+ const content = fs.readFileSync(profilePath, 'utf-8').trim();
376
+ if (content.length > 20) {
377
+ parts.push(`[Your Profile]\n${content.slice(0, 600)}`);
378
+ }
379
+ }
380
+ } catch { /* no profile */ }
381
+
382
+ // 1. Role's journal — latest entry only, compact summary (not full header dump)
383
+ try {
384
+ const journalDir = path.join(COMPANY_ROOT, 'knowledge', 'roles', roleId, 'journal');
385
+ if (fs.existsSync(journalDir)) {
386
+ const files = fs.readdirSync(journalDir)
387
+ .filter(f => f.endsWith('.md'))
388
+ .sort()
389
+ .slice(-1); // Only latest entry
390
+ for (const file of files) {
391
+ const content = fs.readFileSync(path.join(journalDir, file), 'utf-8');
392
+ const title = content.match(/^#\s+(.+)/m)?.[1] ?? file;
393
+ // Take first 300 chars of actual content (skip title line)
394
+ const body = content.split('\n').slice(1).join('\n').trim().slice(0, 300);
395
+ parts.push(`[Your Recent Work: ${file}] ${title}\n${body}`);
396
+ }
397
+ }
398
+ } catch { /* no journal */ }
399
+
400
+ // 2. Current tasks assigned to this role (from all project tasks.md files)
401
+ try {
402
+ const projectsDir = path.join(COMPANY_ROOT, 'knowledge', 'projects');
403
+ if (fs.existsSync(projectsDir)) {
404
+ const taskFiles = glob.sync('**/tasks.md', { cwd: projectsDir, absolute: false });
405
+ const roleTasks: string[] = [];
406
+ for (const tf of taskFiles.slice(0, 3)) {
407
+ const content = fs.readFileSync(path.join(projectsDir, tf), 'utf-8');
408
+ const rows = parseMarkdownTable(content);
409
+ const myTasks = rows.filter(r => {
410
+ const role = (r.role ?? r.Role ?? '').toLowerCase();
411
+ return role.includes(roleId);
412
+ });
413
+ for (const t of myTasks.slice(0, 5)) {
414
+ const id = t.id ?? t.ID ?? '';
415
+ const task = t.task ?? t.Task ?? t.title ?? '';
416
+ const status = t.status ?? t.Status ?? '';
417
+ if (task) roleTasks.push(`- ${id}: ${task} [${status}]`);
418
+ }
419
+ }
420
+ if (roleTasks.length > 0) {
421
+ parts.push(`[Your Assigned Tasks]\n${roleTasks.join('\n')}`);
422
+ }
423
+ }
424
+ } catch { /* no tasks */ }
425
+
426
+ // 3. Recent waves — only from last 7 days (stale waves cause repetitive references)
427
+ try {
428
+ const wavesDir = path.join(COMPANY_ROOT, '.tycono', 'waves');
429
+ if (fs.existsSync(wavesDir)) {
430
+ const tree = buildOrgTree(COMPANY_ROOT);
431
+ const node = tree.nodes.get(roleId);
432
+ const roleName = node?.name?.toLowerCase() ?? '';
433
+ const roleLevel = node?.level?.toLowerCase() ?? '';
434
+
435
+ // Parse date from filename: "20260310-1200.md" or "wave-2026-03-10-xxx.md" or "2026-03-10-xxx.md"
436
+ const now = Date.now();
437
+ const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000;
438
+
439
+ function parseDateFromFilename(f: string): number | null {
440
+ // Format: 20260310-xxxx.md
441
+ let m = f.match(/^(\d{4})(\d{2})(\d{2})/);
442
+ if (m) return new Date(`${m[1]}-${m[2]}-${m[3]}`).getTime();
443
+ // Format: wave-2026-03-10-xxx.md or 2026-03-10-xxx.md
444
+ m = f.match(/(\d{4})-(\d{2})-(\d{2})/);
445
+ if (m) return new Date(`${m[1]}-${m[2]}-${m[3]}`).getTime();
446
+ return null;
447
+ }
448
+
449
+ const waveFiles = fs.readdirSync(wavesDir)
450
+ .filter(f => f.endsWith('.md'))
451
+ .filter(f => {
452
+ const fileDate = parseDateFromFilename(f);
453
+ if (!fileDate) return false;
454
+ return (now - fileDate) < SEVEN_DAYS;
455
+ })
456
+ .sort();
457
+ const relevant: string[] = [];
458
+ for (const file of waveFiles.reverse()) {
459
+ if (relevant.length >= 2) break;
460
+ const content = fs.readFileSync(path.join(wavesDir, file), 'utf-8');
461
+ const lower = content.toLowerCase();
462
+ if (lower.includes(roleId) || lower.includes('all roles') || lower.includes('전체')
463
+ || (roleName && lower.includes(roleName))
464
+ || (roleLevel && lower.includes(roleLevel))) {
465
+ const title = content.match(/^#\s+(.+)/m)?.[1] ?? file;
466
+ const snippet = content.split('\n').slice(1, 8).join('\n').trim();
467
+ relevant.push(`[CEO Wave: ${file}] ${title}\n${snippet.slice(0, 400)}`);
468
+ }
469
+ }
470
+ if (relevant.length > 0) parts.push(...relevant);
471
+ }
472
+ } catch { /* no waves */ }
473
+
474
+ // 4. Recent standup (latest, this role's section)
475
+ try {
476
+ const standupDir = path.join(COMPANY_ROOT, '.tycono', 'standup');
477
+ if (fs.existsSync(standupDir)) {
478
+ const files = fs.readdirSync(standupDir).filter(f => f.endsWith('.md')).sort().slice(-1);
479
+ for (const file of files) {
480
+ const content = fs.readFileSync(path.join(standupDir, file), 'utf-8');
481
+ const rolePattern = new RegExp(`(## .*${roleId}.*|### .*${roleId}.*)([\\s\\S]*?)(?=\\n## |\\n### |$)`, 'i');
482
+ const match = content.match(rolePattern);
483
+ if (match) {
484
+ parts.push(`[Standup: ${file}] Your report:\n${match[0].slice(0, 300)}`);
485
+ }
486
+ }
487
+ }
488
+ } catch { /* no standups */ }
489
+
490
+ // 5. Recent decisions (last 3)
491
+ try {
492
+ const decisionsDir = path.join(COMPANY_ROOT, 'knowledge', 'decisions');
493
+ if (fs.existsSync(decisionsDir)) {
494
+ const files = fs.readdirSync(decisionsDir)
495
+ .filter(f => f.endsWith('.md') && f !== 'decisions.md')
496
+ .sort()
497
+ .slice(-3);
498
+ const decisions: string[] = [];
499
+ for (const file of files) {
500
+ const content = fs.readFileSync(path.join(decisionsDir, file), 'utf-8');
501
+ const title = content.match(/^#\s+(.+)/m)?.[1] ?? file;
502
+ decisions.push(`- ${title}`);
503
+ }
504
+ if (decisions.length > 0) {
505
+ parts.push(`[Recent Decisions]\n${decisions.join('\n')}`);
506
+ }
507
+ }
508
+ } catch { /* no decisions */ }
509
+
510
+ // 6. Architecture highlights (always include — not just fallback)
511
+ try {
512
+ const techDebtPath = path.join(COMPANY_ROOT, 'architecture', 'tech-debt.md');
513
+ if (fs.existsSync(techDebtPath)) {
514
+ const tdContent = fs.readFileSync(techDebtPath, 'utf-8');
515
+ const rows = parseMarkdownTable(tdContent);
516
+ const active = rows
517
+ .filter(r => !(r.status ?? '').toLowerCase().includes('fixed') && !(r.status ?? '').toLowerCase().includes('done'))
518
+ .slice(0, 3)
519
+ .map(r => `- ${r.id ?? ''}: ${r.title ?? r.issue ?? ''} (${r.status ?? ''})`)
520
+ .filter(s => s.length > 10);
521
+ if (active.length > 0) {
522
+ parts.push(`[Active Tech Issues]\n${active.join('\n')}`);
523
+ }
524
+ }
525
+ } catch { /* no tech-debt */ }
526
+
527
+ return parts.length > 0
528
+ ? `\n\nYOUR KNOWLEDGE (real AKB context — you MUST reference this in conversation):\n${parts.join('\n\n')}`
529
+ : '';
530
+ }
531
+
532
+ /**
533
+ * SOUL Pattern — Few-shot example dialogues per role.
534
+ * 2-3 example exchanges teach the model tone + length naturally.
535
+ * (See knowledge/soul-pattern-chat-quality.md)
536
+ */
537
+ function getRoleChatStyle(roleId: string, level: string, persona?: string): string {
538
+ // SOUL-006: Persona Priority + Fallback (Plan C from persona-system-design.md)
539
+ // If persona has personality/tone keywords → persona drives the tone
540
+ // If persona is only work instructions → hardcoded few-shot as fallback
541
+ const hasPersonalityContent = persona && persona.length > 50 &&
542
+ /humor|sarcastic|cheerful|serious|calm|energetic|blunt|warm|cold|cynical|optimistic|dry|witty|chill|confident|anxious|grumpy|friendly|formal|casual|direct|shy|bold|quirky/i.test(persona);
543
+
544
+ if (hasPersonalityContent) {
545
+ return `YOUR VOICE (from your persona — this defines how you talk):
546
+ ${persona}
547
+
548
+ Example response format (match this LENGTH only — your TONE comes from the persona above):
549
+ [Other]: something happened at work
550
+ [You]: (1-2 sentences in YOUR voice from persona above)
551
+
552
+ [Other]: unrelated topic to your expertise
553
+ [You]: [SILENT]`;
554
+ }
555
+
556
+ const souls: Record<string, string> = {
557
+ engineer: `YOUR VOICE — Engineer (code, architecture, DX, tech debt)
558
+
559
+ Example conversations (match this exact tone and length):
560
+ [Other]: CEO just greenlit 3 new features for next sprint
561
+ [You]: we haven't closed the 12 bugs from last sprint but sure let's add more
562
+
563
+ [Other]: Should we refactor the context assembler before adding new features?
564
+ [You]: it works fine rn. refactoring now is procrastination with extra steps
565
+
566
+ [Other]: The leaderboard page looks great!
567
+ [You]: [SILENT]`,
568
+
569
+ pm: `YOUR VOICE — Product Manager (scope, priorities, roadmap, user impact)
570
+
571
+ Example conversations (match this exact tone and length):
572
+ [Other]: Can we also add dark mode while we're at it?
573
+ [You]: that's a P2. we ship the coin system first or nothing ships
574
+
575
+ [Other]: CTO wants to refactor the entire auth layer before launch
576
+ [You]: cool so what are we dropping from the sprint then
577
+
578
+ [Other]: Quest board is getting good user feedback
579
+ [You]: [SILENT]`,
580
+
581
+ designer: `YOUR VOICE — Designer (UX, visual consistency, user flows, design debt)
582
+
583
+ Example conversations (match this exact tone and length):
584
+ [Other]: The furniture shop UI is done, it works!
585
+ [You]: "works" and "good" are different things. the grid alignment is off and the hover states are inconsistent
586
+
587
+ [Other]: We're shipping the save modal without the scope selector
588
+ [You]: so we're just not designing the most confusing part. love that for us
589
+
590
+ [Other]: API response times improved by 30%
591
+ [You]: [SILENT]`,
592
+
593
+ qa: `YOUR VOICE — QA Engineer (test coverage, edge cases, regression risk, bugs)
594
+
595
+ Example conversations (match this exact tone and length):
596
+ [Other]: We shipped the coin system, all manual tests passed
597
+ [You]: "manual tests passed" means "i clicked around and it didn't explode." what about edge cases
598
+
599
+ [Other]: No bugs reported this week!
600
+ [You]: that means nobody's testing, not that there's no bugs
601
+
602
+ [Other]: Designer wants to tweak the button colors
603
+ [You]: [SILENT]`,
604
+
605
+ cto: `YOUR VOICE — CTO (architecture, tech strategy, eng culture, technical bets)
606
+
607
+ Example conversations (match this exact tone and length):
608
+ [Other]: Why are we using file-based state instead of a real database?
609
+ [You]: at our scale a DB is overhead we don't need. revisit when we have concurrent users
610
+
611
+ [Other]: Engineer says the dispatch bridge needs a rewrite
612
+ [You]: it needs better error handling not a rewrite. let's not burn a sprint on aesthetics
613
+
614
+ [Other]: The landing page copy got updated
615
+ [You]: [SILENT]`,
616
+
617
+ cbo: `YOUR VOICE — CBO (market, revenue, competitors, growth, go-to-market)
618
+
619
+ Example conversations (match this exact tone and length):
620
+ [Other]: We added 5 new special furniture items to the shop
621
+ [You]: who's paying for this? show me the conversion funnel not the feature list
622
+
623
+ [Other]: OpenClaw just raised their Series A
624
+ [You]: their moat is thin. they have tooling, we have organizational intelligence. different game
625
+
626
+ [Other]: Test coverage went up to 80%
627
+ [You]: [SILENT]`,
628
+
629
+ 'data-analyst': `YOUR VOICE — Data Analyst (metrics, data quality, measurement, insights)
630
+
631
+ Example conversations (match this exact tone and length):
632
+ [Other]: We shipped 5 features this sprint!
633
+ [You]: shipped is not adopted. show me the usage numbers
634
+
635
+ [Other]: Revenue is up 20% this month
636
+ [You]: what's the baseline? 20% of what. context matters
637
+
638
+ [Other]: Designer updated the color palette
639
+ [You]: [SILENT]`,
640
+ };
641
+
642
+ const defaultSoul = level === 'c-level'
643
+ ? `YOUR VOICE — Senior Leader
644
+
645
+ Example conversations (match this exact tone and length):
646
+ [Other]: The sprint is overloaded again
647
+ [You]: then we cut scope. what's the lowest-impact item?
648
+
649
+ [Other]: New competitor launched yesterday
650
+ [You]: [SILENT]`
651
+ : `YOUR VOICE — Team Member
652
+
653
+ Example conversations (match this exact tone and length):
654
+ [Other]: CEO wants this done by Friday
655
+ [You]: that's ambitious. which corners are we allowed to cut?
656
+
657
+ [Other]: Company all-hands is tomorrow
658
+ [You]: [SILENT]`;
659
+
660
+ const baseSoul = souls[roleId] ?? defaultSoul;
661
+ // Append persona as additional context when it exists but isn't personality-driven
662
+ if (persona && persona.length > 10) {
663
+ return `${baseSoul}\n\nYour persona for additional context: ${persona}`;
664
+ }
665
+ return baseSoul;
666
+ }
667
+
668
+ // Lazy-init token ledger for cost tracking
669
+ let ledger: TokenLedger | null = null;
670
+ function getLedger(): TokenLedger {
671
+ if (!ledger) { ledger = new TokenLedger(COMPANY_ROOT); }
672
+ return ledger;
673
+ }
674
+
675
+ // Lazy-init LLM provider based on engine config
676
+ let llm: LLMProvider | null = null;
677
+ function getLLM(): LLMProvider {
678
+ if (!llm) {
679
+ const config = readConfig(COMPANY_ROOT);
680
+ const model = process.env.SPEECH_MODEL || 'claude-haiku-4-5-20251001';
681
+ if (config.engine === 'claude-cli' && !process.env.ANTHROPIC_API_KEY) {
682
+ llm = new ClaudeCliProvider({ model });
683
+ } else {
684
+ llm = new AnthropicProvider({ model });
685
+ }
686
+ }
687
+ return llm;
688
+ }
689
+
690
+ /**
691
+ * POST /api/speech/chat
692
+ *
693
+ * Body: {
694
+ * channelId: string,
695
+ * roleId: string,
696
+ * history: Array<{ roleId: string, text: string, ts: number }>,
697
+ * members: Array<{ id: string, name: string, level: string }>,
698
+ * relationships: Array<{ partnerId: string, familiarity: number }>,
699
+ * workContext?: { currentTask: string | null, taskProgress: string | null }
700
+ * }
701
+ * Returns: { message: string, tokens: { input: number, output: number } }
702
+ */
703
+ speechRouter.post('/chat', async (req: Request, res: Response, next: NextFunction) => {
704
+ try {
705
+ const { channelId, channelTopic, roleId, history, members, relationships, workContext } = req.body as {
706
+ channelId: string;
707
+ channelTopic?: string;
708
+ roleId: string;
709
+ history: Array<{ roleId: string; text: string; ts: number }>;
710
+ members: Array<{ id: string; name: string; level: string }>;
711
+ relationships: Array<{ partnerId: string; familiarity: number }>;
712
+ workContext?: { currentTask: string | null; taskProgress: string | null };
713
+ };
714
+
715
+ // ── Compute role levels from token ledger ──
716
+ const tokenLedger = getLedger();
717
+ const allEntries = tokenLedger.query();
718
+
719
+ // Aggregate total tokens (input + output) per role
720
+ const tokensByRole: Record<string, number> = {};
721
+ for (const entry of allEntries.entries) {
722
+ tokensByRole[entry.roleId] = (tokensByRole[entry.roleId] ?? 0) + entry.inputTokens + entry.outputTokens;
723
+ }
724
+
725
+ const roleLevel = calcLevel(tokensByRole[roleId] ?? 0);
726
+
727
+ // Team stats
728
+ const roleIds = Object.keys(tokensByRole);
729
+ const levels = roleIds.map(id => ({ id, level: calcLevel(tokensByRole[id]) }));
730
+ const avgLevel = levels.length > 0
731
+ ? Math.round(levels.reduce((sum, r) => sum + r.level, 0) / levels.length)
732
+ : 1;
733
+ const topEntry = levels.reduce((best, r) => r.level > best.level ? r : best, { id: roleId, level: roleLevel });
734
+ const totalTokens = allEntries.totalInput + allEntries.totalOutput;
735
+
736
+ const teamStats = { avgLevel, topRole: topEntry.id, totalTokens };
737
+
738
+ if (!roleId || !channelId) {
739
+ res.status(400).json({ error: 'roleId and channelId are required' });
740
+ return;
741
+ }
742
+
743
+ const config = readConfig(COMPANY_ROOT);
744
+ if (config.engine !== 'claude-cli' && !process.env.ANTHROPIC_API_KEY) {
745
+ res.status(503).json({ error: 'Chat requires ANTHROPIC_API_KEY or claude-cli engine', message: '' });
746
+ return;
747
+ }
748
+
749
+ // Build org tree to get persona
750
+ const tree = buildOrgTree(COMPANY_ROOT);
751
+ const node = tree.nodes.get(roleId);
752
+ if (!node) {
753
+ res.status(404).json({ error: `Role not found: ${roleId}` });
754
+ return;
755
+ }
756
+
757
+ const persona = node.persona || `${node.name} (${node.level})`;
758
+
759
+ // Build member context
760
+ const memberList = members
761
+ .map(m => `${m.name} (${m.level})`)
762
+ .join(', ');
763
+
764
+ // Build relationship context
765
+ const relContext = relationships.length > 0
766
+ ? `\nYour relationships:\n${relationships.map(r => {
767
+ const memberName = members.find(m => m.id === r.partnerId)?.name ?? r.partnerId;
768
+ const level = r.familiarity >= 80 ? 'best friends'
769
+ : r.familiarity >= 50 ? 'close colleagues'
770
+ : r.familiarity >= 20 ? 'coworkers'
771
+ : 'barely acquainted';
772
+ return `- ${memberName}: ${level} (${r.familiarity}/100)`;
773
+ }).join('\n')}`
774
+ : '';
775
+
776
+ // Build work context
777
+ const workCtx = workContext?.currentTask
778
+ ? `\nYou are currently working on: "${workContext.currentTask}"${workContext.taskProgress ? ` (${workContext.taskProgress})` : ''}`
779
+ : '\nYou are currently idle (no active task).';
780
+
781
+ // Build level context
782
+ const levelCtx = `\nYour current level is Lv.${roleLevel}. Team average is Lv.${avgLevel}. ${topEntry.id} is the highest-leveled team member.`;
783
+
784
+ // Build multi-turn messages from history (OpenClaw pattern)
785
+ // This role's messages → assistant, others → user (with sender attribution)
786
+ // LLM naturally maintains voice consistency with its own "previous" messages
787
+ const chatMessages: LLMMessage[] = [];
788
+
789
+ if (history.length > 0) {
790
+ // Group consecutive messages from same "side" (self vs others)
791
+ let pendingOthers: string[] = [];
792
+
793
+ const flushOthers = () => {
794
+ if (pendingOthers.length > 0) {
795
+ chatMessages.push({ role: 'user', content: pendingOthers.join('\n') });
796
+ pendingOthers = [];
797
+ }
798
+ };
799
+
800
+ for (const h of history) {
801
+ const name = members.find(m => m.id === h.roleId)?.name ?? h.roleId;
802
+ if (h.roleId === roleId) {
803
+ // This agent's previous message → assistant role
804
+ flushOthers();
805
+ // Anthropic requires alternating roles — merge consecutive assistant messages
806
+ const last = chatMessages[chatMessages.length - 1];
807
+ if (last?.role === 'assistant') {
808
+ last.content = `${last.content}\n${h.text}`;
809
+ } else {
810
+ chatMessages.push({ role: 'assistant', content: h.text });
811
+ }
812
+ } else {
813
+ // Other agent's message → accumulate as user role
814
+ pendingOthers.push(`${name}: ${h.text}`);
815
+ }
816
+ }
817
+ flushOthers();
818
+
819
+ // Final instruction — append to last user message if exists, otherwise add new
820
+ const lastMsg = chatMessages[chatMessages.length - 1];
821
+ if (lastMsg?.role === 'user') {
822
+ lastMsg.content = `${lastMsg.content}\n\n---\nRespond as ${node.name}. New angle or [SILENT].`;
823
+ } else {
824
+ chatMessages.push({ role: 'user', content: `Respond as ${node.name}. New angle or [SILENT].` });
825
+ }
826
+ } else {
827
+ chatMessages.push({ role: 'user', content: 'Start the conversation. 1-2 sentences.' });
828
+ }
829
+
830
+ // Ensure messages start with user role (Anthropic API requirement)
831
+ if (chatMessages.length > 0 && chatMessages[0].role === 'assistant') {
832
+ chatMessages.unshift({ role: 'user', content: '(conversation context)' });
833
+ }
834
+
835
+ // Build channel topic context
836
+ const topicCtx = channelTopic
837
+ ? `\nChannel topic: "${channelTopic}"\nYour messages should relate to this topic.`
838
+ : '';
839
+
840
+ // Build company context (cached per request — lightweight)
841
+ const companyCtx = buildCompanyContext();
842
+
843
+ // Build role-specific AKB context (pre-fetched, works with any engine)
844
+ const roleCtx = buildRoleContext(roleId);
845
+
846
+ // Role-specific communication style (SOUL-006: persona-priority)
847
+ const roleStyle = getRoleChatStyle(roleId, node.level, node.persona);
848
+
849
+ // Language preference
850
+ const prefs = readPreferences(COMPANY_ROOT);
851
+ const chatLang = prefs.language && prefs.language !== 'auto' ? prefs.language : 'en';
852
+ const chatLangNames: Record<string, string> = { en: 'English', ko: 'Korean (한국어)', ja: 'Japanese (日本語)' };
853
+ const chatLangName = chatLangNames[chatLang] ?? chatLang;
854
+
855
+ const systemPrompt = `You are ${node.name}, ${node.level}. ${persona.slice(0, 800)}
856
+ ${workCtx}${levelCtx}
857
+ Channel: #${channelId}${topicCtx} | Members: ${memberList}${relContext}
858
+
859
+ ${roleStyle}
860
+
861
+ CONTEXT (from company AKB — reference by name):
862
+ ${companyCtx}
863
+ ${roleCtx}
864
+
865
+ You have search_akb, read_file, list_files tools. AKB root: ${COMPANY_ROOT}/
866
+ Optionally explore 1-2 for fresh context: .tycono/waves/, knowledge/decisions/, knowledge/roles/${roleId}/journal/
867
+
868
+ RULES:
869
+ 1. Match the tone and length from the example conversations above. 1-3 sentences MAX.
870
+ 2. Reference actual projects, tasks, decisions by name.
871
+ 3. NEVER invent technologies or projects not in AKB.
872
+ 4. Nothing new to add? respond exactly: [SILENT]
873
+ 5. Do NOT repeat others' points. New angle or silent.
874
+ 6. No quotes around response.
875
+ 7. NEVER start with "Honestly" or "Yeah".
876
+ 8. You MUST respond in ${chatLangName}. All messages must be in ${chatLangName}.`;
877
+
878
+ // ── Chat debug logging ──
879
+ const chatDebug = process.env.CHAT_DEBUG === '1';
880
+ if (chatDebug) {
881
+ console.log('\n' + '═'.repeat(80));
882
+ console.log(`[CHAT] Role: ${roleId} (${node.name}) | Channel: #${channelId}`);
883
+ console.log('─'.repeat(80));
884
+ console.log('[SYSTEM PROMPT]');
885
+ console.log(systemPrompt);
886
+ console.log('─'.repeat(80));
887
+ console.log('[MESSAGES]');
888
+ for (const m of chatMessages) {
889
+ const text = typeof m.content === 'string' ? m.content : JSON.stringify(m.content);
890
+ console.log(` [${m.role}] ${text.slice(0, 500)}`);
891
+ }
892
+ console.log('─'.repeat(80));
893
+ }
894
+
895
+ const provider = getLLM();
896
+
897
+ // ClaudeCliProvider now supports tools via built-in Read/Grep/Glob
898
+ // For ClaudeCliProvider: tools are handled internally by claude CLI (no custom tool loop needed)
899
+ // For AnthropicProvider: use custom AKB tool loop via chatWithTools()
900
+ const isAnthropicProvider = provider instanceof AnthropicProvider;
901
+
902
+ let raw: string;
903
+ let totalUsage: { inputTokens: number; outputTokens: number };
904
+
905
+ // SOUL-001: max_tokens safety net (not primary length control — few-shot handles that)
906
+ const CHAT_MAX_TOKENS = 300;
907
+
908
+ if (isAnthropicProvider) {
909
+ // Anthropic SDK: custom AKB tool loop with multi-turn history
910
+ const result = await chatWithTools(provider, systemPrompt, chatMessages, true, CHAT_MAX_TOKENS);
911
+ raw = result.text;
912
+ totalUsage = result.totalUsage;
913
+ } else {
914
+ // ClaudeCliProvider: flatten to single message (CLI doesn't support multi-turn)
915
+ const flatHistory = history.map(h => {
916
+ const name = members.find(m => m.id === h.roleId)?.name ?? h.roleId;
917
+ return `${name}: ${h.text}`;
918
+ }).join('\n');
919
+ const cliPrompt = history.length > 0
920
+ ? `CHAT LOG:\n${flatHistory}\n\n---\nRespond as ${node.name}. New angle or [SILENT].`
921
+ : 'Start the conversation. 1-2 sentences.';
922
+ const result = await provider.chat(systemPrompt, [{ role: 'user', content: cliPrompt }], AKB_TOOLS);
923
+ raw = result.content.filter(c => c.type === 'text').map(c => (c as { type: 'text'; text: string }).text).join('');
924
+ totalUsage = result.usage;
925
+ }
926
+
927
+ // Post-process: sanitize, [SILENT] detection, duplicate filtering
928
+ const historyTexts = history.map(h => h.text);
929
+
930
+ if (chatDebug) {
931
+ console.log(`[RAW RESPONSE] ${raw}`);
932
+ }
933
+ const message = postProcessChatMessage(raw, historyTexts);
934
+
935
+ if (chatDebug) {
936
+ console.log(`[FINAL] ${message || '(empty — filtered out)'}`);
937
+ console.log('═'.repeat(80) + '\n');
938
+ }
939
+
940
+ // Record usage in token ledger (category: chat)
941
+ if (totalUsage) {
942
+ getLedger().record({
943
+ ts: new Date().toISOString(),
944
+ sessionId: `chat-${channelId}`,
945
+ roleId,
946
+ model: process.env.SPEECH_MODEL || 'claude-haiku-4-5-20251001',
947
+ inputTokens: totalUsage.inputTokens ?? 0,
948
+ outputTokens: totalUsage.outputTokens ?? 0,
949
+ });
950
+ }
951
+
952
+ res.json({
953
+ message,
954
+ tokens: totalUsage,
955
+ });
956
+ } catch (err) {
957
+ next(err);
958
+ }
959
+ });