tycono 0.1.29 → 0.1.31

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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 the-company-inc contributors
3
+ Copyright (c) 2026 Tycono contributors
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,6 +1,27 @@
1
- # tycono
1
+ <p align="center">
2
+ <img src=".github/assets/hero-office.png" alt="Tycono — AI Office" width="640" />
3
+ </p>
2
4
 
3
- > Build an AI company. Watch them work.
5
+ <h1 align="center">tycono</h1>
6
+
7
+ <p align="center">
8
+ <strong>Build an AI company. Watch them work.</strong>
9
+ </p>
10
+
11
+ <p align="center">
12
+ <a href="https://www.npmjs.com/package/tycono"><img src="https://img.shields.io/npm/v/tycono.svg" alt="npm version" /></a>
13
+ <a href="https://github.com/seongsu-kang/tycono/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/tycono.svg" alt="license" /></a>
14
+ <a href="https://www.npmjs.com/package/tycono"><img src="https://img.shields.io/node/v/tycono.svg" alt="node version" /></a>
15
+ </p>
16
+
17
+ <p align="center">
18
+ <a href="https://tycono.ai">Website</a> &middot;
19
+ <a href="#quick-start">Quick Start</a> &middot;
20
+ <a href="#how-it-works">How It Works</a> &middot;
21
+ <a href="CONTRIBUTING.md">Contributing</a>
22
+ </p>
23
+
24
+ ---
4
25
 
5
26
  **tycono** is an open-source platform that lets you create and run an AI-powered organization. Define roles (CTO, PM, Engineer...), assign them AI agents, and watch them collaborate through a real-time dashboard.
6
27
 
@@ -13,15 +34,29 @@ npx tycono
13
34
 
14
35
  That's it. A setup wizard guides you through creating your company, then your browser opens to a live dashboard showing your AI team at work.
15
36
 
37
+ ## Why Tycono?
38
+
39
+ | | Single AI Agent | Tycono |
40
+ |---|---|---|
41
+ | **Structure** | One agent, one context | Multiple roles with org hierarchy |
42
+ | **Knowledge** | Loses context between sessions | Persistent, file-based knowledge system (AKB) |
43
+ | **Authority** | Can do anything | Scoped — each role has boundaries |
44
+ | **Delegation** | Manual prompt chaining | Automatic dispatch through org chart |
45
+ | **Visibility** | Terminal output | Real-time isometric office dashboard |
46
+
16
47
  ## What You Get
17
48
 
18
49
  - **Role-based AI agents** — Each role has its own persona, authority scope, and knowledge boundaries
19
50
  - **Org hierarchy** — Roles report to each other. CTO dispatches to Engineers. PM coordinates with Design.
20
- - **Real-time dashboard** — Watch your AI team work in an isometric office view
51
+ - **Real-time dashboard** — Watch your AI team work in an isometric pixel-art office
21
52
  - **Knowledge management** — Automatic document routing, cross-linking, and Hub-based organization
22
53
  - **Local-first** — Everything runs on your machine. Your data stays yours.
23
54
  - **BYOK** — Bring your own Anthropic API key. No middleman.
24
55
 
56
+ <p align="center">
57
+ <img src=".github/assets/sidepanel-chat.png" alt="Tycono Dashboard" width="640" />
58
+ </p>
59
+
25
60
  ## Requirements
26
61
 
27
62
  - Node.js >= 18
@@ -29,14 +64,14 @@ That's it. A setup wizard guides you through creating your company, then your br
29
64
 
30
65
  ## Team Templates
31
66
 
32
- During setup, pick a template:
67
+ During setup, pick a template or build your own:
33
68
 
34
- | Template | Roles |
35
- |----------|-------|
36
- | **Startup** | CTO + PM + Engineer |
37
- | **Research** | Lead Researcher + Analyst + Writer |
38
- | **Agency** | Creative Director + Designer + Developer |
39
- | **Custom** | Start with no roles, build your own |
69
+ | Template | Roles | Best For |
70
+ |----------|-------|----------|
71
+ | **Startup** | CTO + PM + Engineer | Product development |
72
+ | **Research** | Lead Researcher + Analyst + Writer | Analysis & reports |
73
+ | **Agency** | Creative Director + Designer + Developer | Client projects |
74
+ | **Custom** | Start empty, hire as you go | Full control |
40
75
 
41
76
  ## How It Works
42
77
 
@@ -54,25 +89,26 @@ Every role has:
54
89
  - `profile.md` — Public-facing description
55
90
  - `journal/` — Work history
56
91
 
57
- ## Project Structure
92
+ ## Your Company Structure
58
93
 
59
94
  ```
60
95
  your-company/
61
- ├── CLAUDE.md ← AI entry point
96
+ ├── CLAUDE.md ← AI entry point (auto-managed)
62
97
  ├── company/ ← Mission, vision, values
63
98
  ├── roles/ ← AI role definitions
64
99
  ├── projects/ ← Product specs and tasks
65
100
  ├── architecture/ ← Technical decisions
66
- ├── operations/ ← Standups, decisions
67
- └── knowledge/ ← Domain knowledge
101
+ ├── operations/ ← Standups, decisions, waves
102
+ ├── knowledge/ ← Domain knowledge
103
+ └── .tycono/ ← Config and preferences
68
104
  ```
69
105
 
70
106
  ## CLI Usage
71
107
 
72
108
  ```bash
73
- tycono # Start server + open dashboard (setup wizard if first run)
74
- tycono --help # Show help
75
- tycono --version # Show version
109
+ npx tycono # Start server + open dashboard
110
+ npx tycono --help # Show help
111
+ npx tycono --version # Show version
76
112
  ```
77
113
 
78
114
  ## Environment Variables
@@ -103,10 +139,17 @@ npm run typecheck
103
139
 
104
140
  See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
105
141
 
142
+ ## Get Help
143
+
144
+ - [GitHub Issues](https://github.com/seongsu-kang/tycono/issues) — Bug reports and feature requests
145
+ - [GitHub Discussions](https://github.com/seongsu-kang/tycono/discussions) — Questions and ideas
146
+
106
147
  ## License
107
148
 
108
149
  [MIT](LICENSE)
109
150
 
110
151
  ---
111
152
 
112
- *Built with tycono. An AI company that builds itself.*
153
+ <p align="center">
154
+ <sub>Built with tycono. An AI company that builds itself.</sub>
155
+ </p>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tycono",
3
- "version": "0.1.29",
3
+ "version": "0.1.31",
4
4
  "description": "Build an AI company. Watch them work.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -45,16 +45,20 @@
45
45
  },
46
46
  "keywords": [
47
47
  "ai",
48
+ "tycono",
48
49
  "company",
49
50
  "organization",
50
51
  "roles",
51
52
  "claude",
52
- "agent"
53
+ "agent",
54
+ "multi-agent",
55
+ "agentic",
56
+ "local-first"
53
57
  ],
54
58
  "license": "MIT",
55
59
  "repository": {
56
60
  "type": "git",
57
- "url": "git+https://github.com/seongsu-kang/the-company.git"
61
+ "url": "git+https://github.com/seongsu-kang/tycono.git"
58
62
  },
59
- "homepage": "https://github.com/seongsu-kang/the-company#readme"
63
+ "homepage": "https://github.com/seongsu-kang/tycono#readme"
60
64
  }
@@ -163,8 +163,10 @@ export class ClaudeCliRunner implements ExecutionRunner {
163
163
  mcpServers: {
164
164
  playwright: {
165
165
  type: 'stdio',
166
- command: '/Users/nodias/.local/bin/playwright-mcp.sh',
167
- args: ['--output-dir', runnerOutputDir],
166
+ command: process.env.PLAYWRIGHT_MCP_PATH || 'npx',
167
+ args: process.env.PLAYWRIGHT_MCP_PATH
168
+ ? ['--output-dir', runnerOutputDir]
169
+ : ['@anthropic-ai/mcp-playwright', '--output-dir', runnerOutputDir],
168
170
  },
169
171
  },
170
172
  });
@@ -83,6 +83,13 @@ function handleJobsRequest(url: string, method: string, req: IncomingMessage, re
83
83
  return;
84
84
  }
85
85
 
86
+ // Match /api/jobs/:id/reply
87
+ const replyMatch = path.match(/^\/api\/jobs\/([^/]+)\/reply$/);
88
+ if (replyMatch && method === 'POST') {
89
+ readBody(req).then((body) => handleReplyToJob(replyMatch[1], body, res));
90
+ return;
91
+ }
92
+
86
93
  // Match /api/jobs/:id/history
87
94
  const historyMatch = path.match(/^\/api\/jobs\/([^/]+)\/history$/);
88
95
  if (historyMatch && method === 'GET') {
@@ -213,8 +220,8 @@ function handleJobStream(jobId: string, fromSeq: number, req: IncomingMessage, r
213
220
  sendSSE(res, 'activity', event);
214
221
  }
215
222
 
216
- // If the job is not running (or doesn't exist in memory), send end and close
217
- if (!job || job.status !== 'running') {
223
+ // If the job is finished (not running/awaiting), send end and close
224
+ if (!job || (job.status !== 'running' && job.status !== 'awaiting_input')) {
218
225
  sendSSE(res, 'stream:end', { reason: job ? job.status : 'not-found' });
219
226
  res.end();
220
227
  return;
@@ -225,12 +232,13 @@ function handleJobStream(jobId: string, fromSeq: number, req: IncomingMessage, r
225
232
  if (event.seq >= fromSeq) {
226
233
  sendSSE(res, 'activity', event);
227
234
  }
228
- // Auto-close SSE when job ends
235
+ // Auto-close SSE when job ends (done or error, NOT awaiting_input)
229
236
  if (event.type === 'job:done' || event.type === 'job:error') {
230
237
  sendSSE(res, 'stream:end', { reason: event.type === 'job:done' ? 'done' : 'error' });
231
238
  res.end();
232
239
  job.stream.unsubscribe(subscriber);
233
240
  }
241
+ // awaiting_input keeps SSE open (sends event but doesn't close)
234
242
  };
235
243
 
236
244
  job.stream.subscribe(subscriber);
@@ -263,6 +271,24 @@ function handleAbortJob(jobId: string, res: ServerResponse): void {
263
271
  jsonResponse(res, 200, { ok: true });
264
272
  }
265
273
 
274
+ /* ─── POST /api/jobs/:id/reply ──────────── */
275
+
276
+ function handleReplyToJob(jobId: string, body: Record<string, unknown>, res: ServerResponse): void {
277
+ const message = body.message as string;
278
+ if (!message) {
279
+ jsonResponse(res, 400, { error: 'message is required' });
280
+ return;
281
+ }
282
+
283
+ const newJob = jobManager.replyToJob(jobId, message);
284
+ if (!newJob) {
285
+ jsonResponse(res, 400, { error: 'Job not found or not awaiting input' });
286
+ return;
287
+ }
288
+
289
+ jsonResponse(res, 200, { jobId: newJob.id, roleId: newJob.roleId });
290
+ }
291
+
266
292
  /* ═══════════════════════════════════════════════
267
293
  Legacy /api/exec/* — kept for backward compat
268
294
  Now internally delegates to JobManager where possible
@@ -206,8 +206,8 @@ knowledgeRouter.put('/{*path}', (req: Request, res: Response, next: NextFunction
206
206
  return;
207
207
  }
208
208
 
209
- const absPath = path.join(companyRoot(), docId);
210
- if (!absPath.startsWith(companyRoot())) {
209
+ const absPath = path.resolve(companyRoot(), docId);
210
+ if (!absPath.startsWith(companyRoot() + path.sep) && absPath !== companyRoot()) {
211
211
  res.status(403).json({ error: 'Forbidden' });
212
212
  return;
213
213
  }
@@ -248,8 +248,8 @@ knowledgeRouter.delete('/{*path}', (req: Request, res: Response, next: NextFunct
248
248
  return;
249
249
  }
250
250
 
251
- const absPath = path.join(companyRoot(), docId);
252
- if (!absPath.startsWith(companyRoot())) {
251
+ const absPath = path.resolve(companyRoot(), docId);
252
+ if (!absPath.startsWith(companyRoot() + path.sep) && absPath !== companyRoot()) {
253
253
  res.status(403).json({ error: 'Forbidden' });
254
254
  return;
255
255
  }
@@ -3,25 +3,187 @@
3
3
  *
4
4
  * POST /api/speech/chat — History-aware channel conversation.
5
5
  * AI reads channel history and responds in character.
6
- * Uses Haiku for cost efficiency (~$0.0006/call).
6
+ * Uses Haiku with AKB tool-use for grounded, context-aware chat.
7
7
  */
8
8
  import { Router, Request, Response, NextFunction } from 'express';
9
9
  import fs from 'node:fs';
10
10
  import path from 'node:path';
11
+ import { glob } from 'glob';
11
12
  import { COMPANY_ROOT, readFile, fileExists, listFiles } from '../services/file-reader.js';
12
13
  import { buildOrgTree } from '../engine/index.js';
13
14
  import { parseMarkdownTable, extractBoldKeyValues } from '../services/markdown-parser.js';
14
- import { AnthropicProvider, ClaudeCliProvider, type LLMProvider } from '../engine/llm-adapter.js';
15
+ import {
16
+ AnthropicProvider, ClaudeCliProvider,
17
+ type LLMProvider, type ToolDefinition, type LLMMessage, type LLMResponse, type MessageContent,
18
+ } from '../engine/llm-adapter.js';
15
19
  import { TokenLedger } from '../services/token-ledger.js';
16
20
  import { readConfig } from '../services/company-config.js';
17
21
  import { calcLevel } from '../utils/role-level.js';
18
22
 
19
23
  export const speechRouter = Router();
20
24
 
25
+ /* ══════════════════════════════════════════════════
26
+ * AKB Tools — Let chat roles explore company knowledge
27
+ * ══════════════════════════════════════════════════ */
28
+
29
+ const MAX_TOOL_ROUNDS = 2;
30
+ const MAX_FILE_CHARS = 1500; // truncate large files
31
+
32
+ const AKB_TOOLS: ToolDefinition[] = [
33
+ {
34
+ name: 'search_akb',
35
+ 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.',
36
+ input_schema: {
37
+ type: 'object' as const,
38
+ properties: {
39
+ query: { type: 'string', description: 'Search keywords (e.g. "landing deploy", "refactoring decision", "Store Import")' },
40
+ path: { type: 'string', description: 'Optional subdirectory to search in (e.g. "operations/decisions", "projects", "knowledge"). Defaults to entire AKB.' },
41
+ },
42
+ required: ['query'],
43
+ },
44
+ },
45
+ {
46
+ name: 'read_file',
47
+ description: 'Read a specific file from the AKB. Use after search_akb to read full content of interesting files.',
48
+ input_schema: {
49
+ type: 'object' as const,
50
+ properties: {
51
+ path: { type: 'string', description: 'File path relative to AKB root (e.g. "operations/decisions/008-repo-structure.md", "projects/projects.md")' },
52
+ },
53
+ required: ['path'],
54
+ },
55
+ },
56
+ {
57
+ name: 'list_files',
58
+ description: 'List files in a directory. Useful to discover what exists (e.g. "operations/waves/", "roles/engineer/journal/").',
59
+ input_schema: {
60
+ type: 'object' as const,
61
+ properties: {
62
+ path: { type: 'string', description: 'Directory path relative to AKB root (e.g. "operations/standups", "roles/pm/journal")' },
63
+ pattern: { type: 'string', description: 'Glob pattern (default: "*.md")' },
64
+ },
65
+ required: ['path'],
66
+ },
67
+ },
68
+ ];
69
+
70
+ function executeAkbTool(name: string, input: Record<string, unknown>): string {
71
+ try {
72
+ switch (name) {
73
+ case 'search_akb': {
74
+ const query = String(input.query || '');
75
+ const searchPath = input.path ? String(input.path) : '';
76
+ const searchDir = path.resolve(COMPANY_ROOT, searchPath);
77
+
78
+ if (!fs.existsSync(searchDir)) return `Directory not found: ${searchPath || '/'}`;
79
+
80
+ // Find all .md files, then grep for query keywords
81
+ const mdFiles = glob.sync('**/*.md', { cwd: searchDir, nodir: true }).slice(0, 100);
82
+ const keywords = query.toLowerCase().split(/\s+/).filter(Boolean);
83
+ const results: string[] = [];
84
+
85
+ for (const file of mdFiles) {
86
+ const fullPath = path.join(searchDir, file);
87
+ const content = fs.readFileSync(fullPath, 'utf-8');
88
+ const lower = content.toLowerCase();
89
+ const matchCount = keywords.filter(k => lower.includes(k)).length;
90
+ if (matchCount >= Math.max(1, Math.ceil(keywords.length * 0.5))) {
91
+ // Extract a relevant snippet (first matching line + context)
92
+ const lines = content.split('\n');
93
+ let snippet = '';
94
+ for (let i = 0; i < lines.length; i++) {
95
+ const ll = lines[i].toLowerCase();
96
+ if (keywords.some(k => ll.includes(k))) {
97
+ snippet = lines.slice(Math.max(0, i - 1), i + 3).join('\n').slice(0, 200);
98
+ break;
99
+ }
100
+ }
101
+ const relPath = searchPath ? `${searchPath}/${file}` : file;
102
+ results.push(`📄 ${relPath} (${matchCount}/${keywords.length} keywords)\n${snippet}`);
103
+ }
104
+ if (results.length >= 8) break;
105
+ }
106
+
107
+ return results.length > 0
108
+ ? results.join('\n\n')
109
+ : `No results for "${query}" in ${searchPath || 'AKB'}`;
110
+ }
111
+
112
+ case 'read_file': {
113
+ const filePath = String(input.path || '');
114
+ const absolute = path.resolve(COMPANY_ROOT, filePath);
115
+ if (!fs.existsSync(absolute)) return `File not found: ${filePath}`;
116
+ const content = fs.readFileSync(absolute, 'utf-8');
117
+ return content.length > MAX_FILE_CHARS
118
+ ? content.slice(0, MAX_FILE_CHARS) + `\n\n... (truncated, ${content.length} chars total)`
119
+ : content;
120
+ }
121
+
122
+ case 'list_files': {
123
+ const dirPath = String(input.path || '');
124
+ const pat = String(input.pattern || '*.md');
125
+ const absolute = path.resolve(COMPANY_ROOT, dirPath);
126
+ if (!fs.existsSync(absolute)) return `Directory not found: ${dirPath}`;
127
+ const files = glob.sync(pat, { cwd: absolute, nodir: true }).sort();
128
+ return files.length > 0
129
+ ? files.map(f => `- ${dirPath}/${f}`).join('\n')
130
+ : `No files matching "${pat}" in ${dirPath}`;
131
+ }
132
+
133
+ default:
134
+ return `Unknown tool: ${name}`;
135
+ }
136
+ } catch (err) {
137
+ return `Error: ${err instanceof Error ? err.message : String(err)}`;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Run mini agent loop: LLM call → tool use → LLM call → ... → final text.
143
+ * Max MAX_TOOL_ROUNDS rounds of tool use, then force a text response.
144
+ */
145
+ async function chatWithTools(
146
+ provider: LLMProvider,
147
+ systemPrompt: string,
148
+ initialMessages: LLMMessage[],
149
+ useTools: boolean,
150
+ ): Promise<{ text: string; totalUsage: { inputTokens: number; outputTokens: number } }> {
151
+ const messages: LLMMessage[] = [...initialMessages];
152
+ const totalUsage = { inputTokens: 0, outputTokens: 0 };
153
+ const tools = useTools ? AKB_TOOLS : undefined;
154
+
155
+ for (let round = 0; round <= MAX_TOOL_ROUNDS; round++) {
156
+ const response = await provider.chat(systemPrompt, messages, tools);
157
+ totalUsage.inputTokens += response.usage.inputTokens;
158
+ totalUsage.outputTokens += response.usage.outputTokens;
159
+
160
+ // Check if there are tool calls
161
+ const toolCalls = response.content.filter(c => c.type === 'tool_use');
162
+ const textParts = response.content.filter(c => c.type === 'text').map(c => (c as { type: 'text'; text: string }).text);
163
+
164
+ if (toolCalls.length === 0 || round === MAX_TOOL_ROUNDS) {
165
+ // No tool calls or max rounds reached — return text
166
+ return { text: textParts.join('').trim(), totalUsage };
167
+ }
168
+
169
+ // Execute tool calls and build tool results
170
+ messages.push({ role: 'assistant', content: response.content });
171
+
172
+ const toolResults: MessageContent[] = toolCalls.map(tc => {
173
+ const call = tc as { type: 'tool_use'; id: string; name: string; input: Record<string, unknown> };
174
+ const result = executeAkbTool(call.name, call.input);
175
+ return { type: 'tool_result' as any, tool_use_id: call.id, content: result } as any;
176
+ });
177
+
178
+ messages.push({ role: 'user', content: toolResults });
179
+ }
180
+
181
+ return { text: '', totalUsage };
182
+ }
183
+
21
184
  /**
22
185
  * Build a compact company context for chat system prompts.
23
- * Includes: company info, org overview, projects, knowledge highlights.
24
- * Kept brief to minimize Haiku token cost.
186
+ * Provides a seed overview the agent can dig deeper via AKB tools.
25
187
  */
26
188
  function buildCompanyContext(): string {
27
189
  const parts: string[] = [];
@@ -316,10 +478,27 @@ ${relContext}
316
478
 
317
479
  ${roleStyle}
318
480
 
481
+ AKB EXPLORATION (IMPORTANT):
482
+ You have tools to search and read the company knowledge base (AKB). Use them to ground your conversation in REAL company context.
483
+ Before responding, consider: "Is there something in our AKB that relates to this conversation?"
484
+ - search_akb: Search for keywords across the AKB (decisions, projects, journals, waves, standups)
485
+ - read_file: Read a specific file for details
486
+ - list_files: Discover what files exist in a directory
487
+
488
+ Useful paths to explore:
489
+ - operations/decisions/ — CEO decisions (what was decided and why)
490
+ - operations/waves/ — Work dispatches (what CEO asked teams to do)
491
+ - operations/standups/ — Daily standups (what everyone reported)
492
+ - projects/ — Active projects and their tasks
493
+ - roles/${roleId}/journal/ — Your own work journal
494
+ - knowledge/ — Domain knowledge
495
+
496
+ You don't need to search every time. But when the conversation touches on company work, decisions, or direction, DO search to find real facts rather than making up generic discussion.
497
+
319
498
  CONVERSATION RULES:
320
499
  1. Stay deeply in character — your expertise, vocabulary, and concerns should be DISTINCT from other roles.
321
500
  2. Keep it to 1-3 sentences. No walls of text.
322
- 3. Be SPECIFIC. Reference actual projects, files, tools, metrics, or decisions — never vague platitudes.
501
+ 3. Be SPECIFIC. Reference actual projects, files, tools, metrics, or decisions from the AKB — never vague platitudes.
323
502
  4. Do NOT just agree with everyone. Real teams have different perspectives:
324
503
  - If you genuinely disagree, say so (respectfully but firmly)
325
504
  - If someone oversimplifies your domain, push back with specifics
@@ -340,39 +519,40 @@ ANTI-PATTERNS (never do these):
340
519
  - Using the same emoji pattern as the previous speaker
341
520
  - Restating the consensus without adding anything new
342
521
  - Meta-commentary about the conversation itself ("wow we actually agreed")
343
- - Generic statements that any role could say — speak from YOUR expertise`;
522
+ - Generic statements that any role could say — speak from YOUR expertise
523
+ - Talking about vague "refactoring" or "metrics" without referencing actual company work`;
344
524
 
345
525
  const provider = getLLM();
346
- const response = await provider.chat(
526
+
527
+ // Use tool-based agent loop (AnthropicProvider supports tools; ClaudeCliProvider falls back to no-tools)
528
+ const useTools = provider instanceof AnthropicProvider;
529
+ const { text: raw, totalUsage } = await chatWithTools(
530
+ provider,
347
531
  systemPrompt,
348
532
  [{ role: 'user', content: historyText }],
533
+ useTools,
349
534
  );
350
535
 
351
- const raw = response.content
352
- .filter(c => c.type === 'text')
353
- .map(c => (c as { type: 'text'; text: string }).text)
354
- .join('')
355
- .trim()
356
- .replace(/^["']|["']$/g, '');
536
+ const cleaned = raw.replace(/^["']|["']$/g, '');
357
537
 
358
538
  // Filter out CLI noise and [SILENT]
359
- const message = (raw === '[SILENT]' || raw.startsWith('Error: Reached max turns')) ? '' : raw;
539
+ const message = (cleaned === '[SILENT]' || cleaned.startsWith('Error: Reached max turns') || !cleaned) ? '' : cleaned;
360
540
 
361
541
  // Record usage in token ledger (category: chat)
362
- if (response.usage) {
542
+ if (totalUsage) {
363
543
  getLedger().record({
364
544
  ts: new Date().toISOString(),
365
545
  jobId: `chat-${channelId}`,
366
546
  roleId,
367
547
  model: process.env.SPEECH_MODEL || 'claude-haiku-4-5-20251001',
368
- inputTokens: response.usage.inputTokens ?? 0,
369
- outputTokens: response.usage.outputTokens ?? 0,
548
+ inputTokens: totalUsage.inputTokens ?? 0,
549
+ outputTokens: totalUsage.outputTokens ?? 0,
370
550
  });
371
551
  }
372
552
 
373
553
  res.json({
374
554
  message,
375
- tokens: response.usage,
555
+ tokens: totalUsage,
376
556
  });
377
557
  } catch (err) {
378
558
  next(err);
@@ -6,6 +6,7 @@ import { COMPANY_ROOT } from './file-reader.js';
6
6
 
7
7
  export type ActivityEventType =
8
8
  | 'job:start' | 'job:done' | 'job:error'
9
+ | 'job:awaiting_input' | 'job:reply'
9
10
  | 'text' | 'thinking'
10
11
  | 'tool:start' | 'tool:result'
11
12
  | 'dispatch:start' | 'dispatch:done'