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.
- package/bin/cli.js +35 -0
- package/bin/server.ts +160 -0
- package/package.json +50 -0
- package/src/api/package.json +31 -0
- package/src/api/src/create-app.ts +90 -0
- package/src/api/src/create-server.ts +251 -0
- package/src/api/src/engine/agent-loop.ts +738 -0
- package/src/api/src/engine/authority-validator.ts +149 -0
- package/src/api/src/engine/context-assembler.ts +912 -0
- package/src/api/src/engine/index.ts +27 -0
- package/src/api/src/engine/knowledge-gate.ts +365 -0
- package/src/api/src/engine/llm-adapter.ts +304 -0
- package/src/api/src/engine/org-tree.ts +270 -0
- package/src/api/src/engine/role-lifecycle.ts +369 -0
- package/src/api/src/engine/runners/claude-cli.ts +796 -0
- package/src/api/src/engine/runners/direct-api.ts +66 -0
- package/src/api/src/engine/runners/index.ts +30 -0
- package/src/api/src/engine/runners/types.ts +95 -0
- package/src/api/src/engine/skill-template.ts +134 -0
- package/src/api/src/engine/tools/definitions.ts +201 -0
- package/src/api/src/engine/tools/executor.ts +611 -0
- package/src/api/src/routes/active-sessions.ts +134 -0
- package/src/api/src/routes/coins.ts +153 -0
- package/src/api/src/routes/company.ts +57 -0
- package/src/api/src/routes/cost.ts +141 -0
- package/src/api/src/routes/engine.ts +220 -0
- package/src/api/src/routes/execute.ts +1075 -0
- package/src/api/src/routes/git.ts +211 -0
- package/src/api/src/routes/knowledge.ts +378 -0
- package/src/api/src/routes/operations.ts +309 -0
- package/src/api/src/routes/preferences.ts +63 -0
- package/src/api/src/routes/presets.ts +123 -0
- package/src/api/src/routes/projects.ts +82 -0
- package/src/api/src/routes/quests.ts +41 -0
- package/src/api/src/routes/roles.ts +112 -0
- package/src/api/src/routes/save.ts +152 -0
- package/src/api/src/routes/sessions.ts +288 -0
- package/src/api/src/routes/setup.ts +437 -0
- package/src/api/src/routes/skills.ts +357 -0
- package/src/api/src/routes/speech.ts +959 -0
- package/src/api/src/routes/supervision.ts +136 -0
- package/src/api/src/routes/sync.ts +165 -0
- package/src/api/src/server.ts +59 -0
- package/src/api/src/services/activity-stream.ts +184 -0
- package/src/api/src/services/activity-tracker.ts +115 -0
- package/src/api/src/services/claude-md-manager.ts +94 -0
- package/src/api/src/services/company-config.ts +115 -0
- package/src/api/src/services/database.ts +77 -0
- package/src/api/src/services/digest-engine.ts +313 -0
- package/src/api/src/services/execution-manager.ts +1036 -0
- package/src/api/src/services/file-reader.ts +77 -0
- package/src/api/src/services/git-save.ts +614 -0
- package/src/api/src/services/job-manager.ts +16 -0
- package/src/api/src/services/knowledge-importer.ts +466 -0
- package/src/api/src/services/markdown-parser.ts +173 -0
- package/src/api/src/services/port-registry.ts +222 -0
- package/src/api/src/services/preferences.ts +150 -0
- package/src/api/src/services/preset-loader.ts +149 -0
- package/src/api/src/services/pricing.ts +34 -0
- package/src/api/src/services/scaffold.ts +546 -0
- package/src/api/src/services/session-store.ts +340 -0
- package/src/api/src/services/supervisor-heartbeat.ts +897 -0
- package/src/api/src/services/team-recommender.ts +382 -0
- package/src/api/src/services/token-ledger.ts +127 -0
- package/src/api/src/services/wave-messages.ts +194 -0
- package/src/api/src/services/wave-multiplexer.ts +356 -0
- package/src/api/src/services/wave-tracker.ts +359 -0
- package/src/api/src/utils/role-level.ts +31 -0
- package/src/core/scaffolder.ts +620 -0
- package/src/shared/types.ts +224 -0
- package/templates/CLAUDE.md.tmpl +239 -0
- package/templates/company.md.tmpl +17 -0
- package/templates/gitignore.tmpl +28 -0
- package/templates/roles.md.tmpl +8 -0
- package/templates/skills/_manifest.json +23 -0
- package/templates/skills/agent-browser/SKILL.md +159 -0
- package/templates/skills/agent-browser/meta.json +19 -0
- package/templates/skills/akb-linter/SKILL.md +125 -0
- package/templates/skills/akb-linter/meta.json +12 -0
- package/templates/skills/knowledge-gate/SKILL.md +120 -0
- package/templates/skills/knowledge-gate/meta.json +12 -0
- package/templates/teams/agency.json +58 -0
- package/templates/teams/research.json +58 -0
- 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
|
+
});
|