tycono 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/api/package.json +4 -5
- package/src/api/src/create-server.ts +57 -49
- package/src/api/src/engine/context-assembler.ts +46 -20
- package/src/api/src/engine/llm-adapter.ts +73 -0
- package/src/api/src/engine/runners/claude-cli.ts +8 -2
- package/src/api/src/routes/cost.ts +98 -0
- package/src/api/src/routes/execute.ts +16 -9
- package/src/api/src/routes/setup.ts +33 -4
- package/src/api/src/routes/speech.ts +206 -111
- package/src/api/src/services/company-config.ts +1 -0
- package/src/api/src/services/job-manager.ts +8 -0
- package/src/api/src/services/preferences.ts +13 -0
- package/src/web/dist/assets/index-DkB5qeA8.css +1 -0
- package/src/web/dist/assets/index-ZYrhS4zS.js +95 -0
- package/src/web/dist/assets/{preview-app-a8V0eihg.js → preview-app-OwehBXPG.js} +1 -1
- package/src/web/dist/index.html +2 -2
- package/templates/CLAUDE.md.tmpl +77 -0
- package/src/web/dist/assets/index-Ct9pM1_i.js +0 -90
- package/src/web/dist/assets/index-Dy8nGrfX.css +0 -1
package/package.json
CHANGED
package/src/api/package.json
CHANGED
|
@@ -6,11 +6,8 @@
|
|
|
6
6
|
"scripts": {
|
|
7
7
|
"dev": "tsx watch src/server.ts",
|
|
8
8
|
"start": "tsx src/server.ts",
|
|
9
|
-
"test": "vitest run",
|
|
10
|
-
"test:watch": "vitest"
|
|
11
|
-
"test:unit": "vitest run tests/unit/",
|
|
12
|
-
"test:integration": "vitest run tests/integration/",
|
|
13
|
-
"test:live": "LIVE=1 vitest run --config vitest.live.config.ts"
|
|
9
|
+
"test": "vitest run tests/smoke.test.ts",
|
|
10
|
+
"test:watch": "vitest tests/smoke.test.ts"
|
|
14
11
|
},
|
|
15
12
|
"dependencies": {
|
|
16
13
|
"@anthropic-ai/sdk": "^0.78.0",
|
|
@@ -25,6 +22,8 @@
|
|
|
25
22
|
"@types/cors": "^2.8.17",
|
|
26
23
|
"@types/express": "^5.0.0",
|
|
27
24
|
"@types/node": "^22.13.4",
|
|
25
|
+
"@types/supertest": "^7.2.0",
|
|
26
|
+
"supertest": "^7.2.2",
|
|
28
27
|
"tsx": "^4.19.3",
|
|
29
28
|
"typescript": "^5.7.3",
|
|
30
29
|
"vitest": "^4.0.18"
|
|
@@ -25,6 +25,7 @@ import { knowledgeRouter } from './routes/knowledge.js';
|
|
|
25
25
|
import { preferencesRouter } from './routes/preferences.js';
|
|
26
26
|
import { saveRouter } from './routes/save.js';
|
|
27
27
|
import { speechRouter } from './routes/speech.js';
|
|
28
|
+
import { costRouter } from './routes/cost.js';
|
|
28
29
|
import { importKnowledge } from './services/knowledge-importer.js';
|
|
29
30
|
import { AnthropicProvider, type LLMProvider } from './engine/llm-adapter.js';
|
|
30
31
|
import { readConfig } from './services/company-config.js';
|
|
@@ -100,6 +101,51 @@ function handleImportKnowledge(req: http.IncomingMessage, res: http.ServerRespon
|
|
|
100
101
|
export function createHttpServer(): http.Server {
|
|
101
102
|
cleanupStaleActivities();
|
|
102
103
|
|
|
104
|
+
const app = createExpressApp();
|
|
105
|
+
|
|
106
|
+
const server = http.createServer((req, res) => {
|
|
107
|
+
const url = req.url ?? '';
|
|
108
|
+
const method = req.method ?? '';
|
|
109
|
+
|
|
110
|
+
// SSE 엔드포인트: Express 우회하여 raw HTTP로 처리
|
|
111
|
+
if ((url.startsWith('/api/exec/') || url.startsWith('/api/jobs') || url === '/api/setup/import-knowledge') && method === 'POST') {
|
|
112
|
+
setExecCors(req, res);
|
|
113
|
+
if (url === '/api/setup/import-knowledge') {
|
|
114
|
+
handleImportKnowledge(req, res);
|
|
115
|
+
} else {
|
|
116
|
+
handleExecRequest(req, res);
|
|
117
|
+
}
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// CORS preflight for exec/jobs endpoints
|
|
122
|
+
if ((url.startsWith('/api/exec/') || url.startsWith('/api/jobs')) && method === 'OPTIONS') {
|
|
123
|
+
setExecCors(req, res);
|
|
124
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
125
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
126
|
+
res.writeHead(204);
|
|
127
|
+
res.end();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Non-SSE exec/jobs endpoints (GET, DELETE)
|
|
132
|
+
if ((url.startsWith('/api/exec/') || url.startsWith('/api/jobs')) && (method === 'GET' || method === 'DELETE')) {
|
|
133
|
+
setExecCors(req, res);
|
|
134
|
+
handleExecRequest(req, res);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 나머지는 Express 처리
|
|
139
|
+
(app as (req: http.IncomingMessage, res: http.ServerResponse) => void)(req, res);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
server.timeout = 0;
|
|
143
|
+
server.requestTimeout = 0;
|
|
144
|
+
|
|
145
|
+
return server;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function createExpressApp(): express.Application {
|
|
103
149
|
const app = express();
|
|
104
150
|
|
|
105
151
|
app.use(cors({ origin: corsOrigin }));
|
|
@@ -122,7 +168,7 @@ export function createHttpServer(): http.Server {
|
|
|
122
168
|
if (match) companyName = match[1].trim();
|
|
123
169
|
} catch { /* ignore */ }
|
|
124
170
|
}
|
|
125
|
-
res.json({ initialized, companyName, engine: config.engine || process.env.EXECUTION_ENGINE || 'none', companyRoot: COMPANY_ROOT });
|
|
171
|
+
res.json({ initialized, companyName, engine: config.engine || process.env.EXECUTION_ENGINE || 'none', companyRoot: COMPANY_ROOT, codeRoot: config.codeRoot || null, hasApiKey: !!process.env.ANTHROPIC_API_KEY });
|
|
126
172
|
});
|
|
127
173
|
|
|
128
174
|
app.use('/api/roles', rolesRouter);
|
|
@@ -135,6 +181,7 @@ export function createHttpServer(): http.Server {
|
|
|
135
181
|
app.use('/api/preferences', preferencesRouter);
|
|
136
182
|
app.use('/api/speech', speechRouter);
|
|
137
183
|
app.use('/api/save', saveRouter);
|
|
184
|
+
app.use('/api/cost', costRouter);
|
|
138
185
|
|
|
139
186
|
app.get('/api/health', (_req, res) => {
|
|
140
187
|
res.json({ status: 'ok', companyRoot: COMPANY_ROOT });
|
|
@@ -155,53 +202,14 @@ export function createHttpServer(): http.Server {
|
|
|
155
202
|
res.status(status).json({ error: err.message });
|
|
156
203
|
});
|
|
157
204
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
if (!origin) return;
|
|
161
|
-
if (isProd || /^http:\/\/localhost:\d+$/.test(origin)) {
|
|
162
|
-
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
163
|
-
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const server = http.createServer((req, res) => {
|
|
168
|
-
const url = req.url ?? '';
|
|
169
|
-
const method = req.method ?? '';
|
|
170
|
-
|
|
171
|
-
// SSE 엔드포인트: Express 우회하여 raw HTTP로 처리
|
|
172
|
-
if ((url.startsWith('/api/exec/') || url.startsWith('/api/jobs') || url === '/api/setup/import-knowledge') && method === 'POST') {
|
|
173
|
-
setExecCors(req, res);
|
|
174
|
-
if (url === '/api/setup/import-knowledge') {
|
|
175
|
-
handleImportKnowledge(req, res);
|
|
176
|
-
} else {
|
|
177
|
-
handleExecRequest(req, res);
|
|
178
|
-
}
|
|
179
|
-
return;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// CORS preflight for exec/jobs endpoints
|
|
183
|
-
if ((url.startsWith('/api/exec/') || url.startsWith('/api/jobs')) && method === 'OPTIONS') {
|
|
184
|
-
setExecCors(req, res);
|
|
185
|
-
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
186
|
-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
187
|
-
res.writeHead(204);
|
|
188
|
-
res.end();
|
|
189
|
-
return;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// Non-SSE exec/jobs endpoints (GET, DELETE)
|
|
193
|
-
if ((url.startsWith('/api/exec/') || url.startsWith('/api/jobs')) && (method === 'GET' || method === 'DELETE')) {
|
|
194
|
-
setExecCors(req, res);
|
|
195
|
-
handleExecRequest(req, res);
|
|
196
|
-
return;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// 나머지는 Express 처리
|
|
200
|
-
(app as (req: http.IncomingMessage, res: http.ServerResponse) => void)(req, res);
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
server.timeout = 0;
|
|
204
|
-
server.requestTimeout = 0;
|
|
205
|
+
return app;
|
|
206
|
+
}
|
|
205
207
|
|
|
206
|
-
|
|
208
|
+
function setExecCors(req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
209
|
+
const origin = req.headers.origin;
|
|
210
|
+
if (!origin) return;
|
|
211
|
+
if (isProd || /^http:\/\/localhost:\d+$/.test(origin)) {
|
|
212
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
213
|
+
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
214
|
+
}
|
|
207
215
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { readPreferences } from '../services/preferences.js';
|
|
4
|
+
import { readConfig } from '../services/company-config.js';
|
|
3
5
|
import {
|
|
4
6
|
type OrgTree,
|
|
5
7
|
type OrgNode,
|
|
@@ -97,7 +99,13 @@ export function assembleContext(
|
|
|
97
99
|
sections.push('# CEO Decisions (전사 공지)\n\n' + ceoDecisions);
|
|
98
100
|
}
|
|
99
101
|
|
|
100
|
-
// 9.
|
|
102
|
+
// 9. Code Root (코드 프로젝트 경로)
|
|
103
|
+
const config = readConfig(companyRoot);
|
|
104
|
+
if (config.codeRoot) {
|
|
105
|
+
sections.push(`# Code Project\n\nThe code repository is located at: \`${config.codeRoot}\`\nUse this path when working with source code (reading, writing, building, testing).`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 10. Task는 별도 필드로 분리
|
|
101
109
|
const subordinates = getSubordinates(orgTree, roleId);
|
|
102
110
|
|
|
103
111
|
// Dispatch 도구 안내 (하위 Role이 있는 경우)
|
|
@@ -105,6 +113,38 @@ export function assembleContext(
|
|
|
105
113
|
sections.push(buildDispatchSection(orgTree, roleId, subordinates, options?.teamStatus));
|
|
106
114
|
}
|
|
107
115
|
|
|
116
|
+
// Language preference
|
|
117
|
+
const prefs = readPreferences(companyRoot);
|
|
118
|
+
const lang = prefs.language ?? 'auto';
|
|
119
|
+
if (lang !== 'auto') {
|
|
120
|
+
const langNames: Record<string, string> = { en: 'English', ko: 'Korean', ja: 'Japanese' };
|
|
121
|
+
sections.push(`# Language\n\nAlways respond in **${langNames[lang] ?? lang}**. All output — reports, analysis, code comments, status updates — must be in ${langNames[lang] ?? lang}.`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Execution behavior rules (prevents infinite exploration loops in -p mode)
|
|
125
|
+
sections.push(`# Execution Rules (CRITICAL)
|
|
126
|
+
|
|
127
|
+
## Interpreting Tasks
|
|
128
|
+
- A [CEO Wave] is a directive from the CEO. Interpret it based on your role's expertise.
|
|
129
|
+
- If the directive is vague, focus on what YOUR ROLE can contribute. Don't try to cover everything.
|
|
130
|
+
- Break ambiguous directives into concrete actions within your authority scope.
|
|
131
|
+
- If you truly cannot determine what to do, state your interpretation and proceed with it.
|
|
132
|
+
|
|
133
|
+
## Efficiency
|
|
134
|
+
- Read ONLY files directly relevant to your task. Do NOT explore the codebase broadly.
|
|
135
|
+
- If a file doesn't exist at the expected path, try at most 2 alternatives, then move on.
|
|
136
|
+
- Do NOT use \`find\` or \`ls\` to scan entire directory trees. Use the Project Structure above.
|
|
137
|
+
- Never \`sleep\` or poll in loops. If something isn't ready, report it and move on.
|
|
138
|
+
|
|
139
|
+
## When Stuck
|
|
140
|
+
- If you cannot find what you need after 3 search attempts, STOP searching immediately.
|
|
141
|
+
- Do NOT retry the same failing command or approach.
|
|
142
|
+
- Summarize what you found, what you couldn't find, and deliver your best answer with what you have.
|
|
143
|
+
|
|
144
|
+
## Output
|
|
145
|
+
- Always produce a concrete deliverable: code change, report, analysis, or clear status update.
|
|
146
|
+
- End with a brief summary of what you did and any unresolved items.`);
|
|
147
|
+
|
|
108
148
|
const systemPrompt = sections.join('\n\n---\n\n');
|
|
109
149
|
|
|
110
150
|
return {
|
|
@@ -127,25 +167,11 @@ function loadCompanyRules(companyRoot: string): string | null {
|
|
|
127
167
|
const claudeMdPath = path.join(companyRoot, 'CLAUDE.md');
|
|
128
168
|
if (!fs.existsSync(claudeMdPath)) return null;
|
|
129
169
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
// Extract AKB rules section
|
|
137
|
-
const akbMatch = content.match(/### AKB 관리 의무[\s\S]*?(?=\n---|\n## [^#])/);
|
|
138
|
-
if (akbMatch) {
|
|
139
|
-
sections.push(akbMatch[0].trim());
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// Extract Git rules
|
|
143
|
-
const gitMatch = content.match(/### Git 규칙[\s\S]*?(?=\n###|\n---|\n## [^#])/);
|
|
144
|
-
if (gitMatch) {
|
|
145
|
-
sections.push(gitMatch[0].trim());
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
return sections.length > 0 ? sections.join('\n\n') : content.slice(0, 2000);
|
|
170
|
+
// Give the full CLAUDE.md — it contains the routing table, folder structure,
|
|
171
|
+
// Hub-first principle, and other navigation info that roles need to work effectively.
|
|
172
|
+
// Previously we extracted only AKB + Git rules, but that stripped out the most
|
|
173
|
+
// practically useful parts (routing table, folder structure, skill principle).
|
|
174
|
+
return fs.readFileSync(claudeMdPath, 'utf-8');
|
|
149
175
|
}
|
|
150
176
|
|
|
151
177
|
function buildOrgContextSection(orgTree: OrgTree, node: OrgNode): string {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
1
2
|
import Anthropic from '@anthropic-ai/sdk';
|
|
2
3
|
|
|
3
4
|
/* ─── Types ──────────────────────────────────── */
|
|
@@ -211,6 +212,78 @@ export class AnthropicProvider implements LLMProvider {
|
|
|
211
212
|
}
|
|
212
213
|
}
|
|
213
214
|
|
|
215
|
+
/* ─── Claude CLI Provider ───────────────────── */
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Claude CLI (`claude -p`)를 LLMProvider로 사용.
|
|
219
|
+
* Claude Max 구독 기반 — API 키 불필요.
|
|
220
|
+
* Chat pipeline (speech) 등 간단한 텍스트 생성에 사용.
|
|
221
|
+
*/
|
|
222
|
+
export class ClaudeCliProvider implements LLMProvider {
|
|
223
|
+
private model: string;
|
|
224
|
+
|
|
225
|
+
constructor(options?: { model?: string }) {
|
|
226
|
+
this.model = options?.model || 'claude-haiku-4-5-20251001';
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async chat(
|
|
230
|
+
systemPrompt: string,
|
|
231
|
+
messages: LLMMessage[],
|
|
232
|
+
_tools?: ToolDefinition[],
|
|
233
|
+
signal?: AbortSignal,
|
|
234
|
+
): Promise<LLMResponse> {
|
|
235
|
+
// Build user message from messages array
|
|
236
|
+
const userText = messages
|
|
237
|
+
.filter(m => m.role === 'user')
|
|
238
|
+
.map(m => typeof m.content === 'string' ? m.content : m.content.filter(c => c.type === 'text').map(c => (c as { type: 'text'; text: string }).text).join(''))
|
|
239
|
+
.join('\n');
|
|
240
|
+
|
|
241
|
+
return new Promise((resolve, reject) => {
|
|
242
|
+
const args = [
|
|
243
|
+
'-p',
|
|
244
|
+
'--system-prompt', systemPrompt,
|
|
245
|
+
'--model', this.model,
|
|
246
|
+
'--max-turns', '1',
|
|
247
|
+
'--output-format', 'text',
|
|
248
|
+
userText,
|
|
249
|
+
];
|
|
250
|
+
|
|
251
|
+
const cleanEnv = { ...process.env };
|
|
252
|
+
delete cleanEnv.CLAUDECODE;
|
|
253
|
+
|
|
254
|
+
const proc = spawn('claude', args, {
|
|
255
|
+
env: cleanEnv,
|
|
256
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
let stdout = '';
|
|
260
|
+
let stderr = '';
|
|
261
|
+
|
|
262
|
+
proc.stdout.on('data', (data: Buffer) => { stdout += data.toString(); });
|
|
263
|
+
proc.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
|
|
264
|
+
|
|
265
|
+
if (signal) {
|
|
266
|
+
signal.addEventListener('abort', () => proc.kill('SIGTERM'), { once: true });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
proc.on('close', (code) => {
|
|
270
|
+
const text = stdout.trim();
|
|
271
|
+
if (code !== 0 && !text) {
|
|
272
|
+
reject(new Error(`claude-cli exited with code ${code}: ${stderr}`));
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
resolve({
|
|
276
|
+
content: [{ type: 'text', text }],
|
|
277
|
+
stopReason: 'end_turn',
|
|
278
|
+
usage: { inputTokens: 0, outputTokens: 0 },
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
proc.on('error', reject);
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
214
287
|
/* ─── Backwards Compatibility ────────────────── */
|
|
215
288
|
|
|
216
289
|
/** @deprecated Use AnthropicProvider instead */
|
|
@@ -4,6 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import { assembleContext } from '../context-assembler.js';
|
|
6
6
|
import { getSubordinates } from '../org-tree.js';
|
|
7
|
+
import { readConfig } from '../../services/company-config.js';
|
|
7
8
|
import type { ExecutionRunner, RunnerConfig, RunnerCallbacks, RunnerHandle, RunnerResult } from './types.js';
|
|
8
9
|
|
|
9
10
|
/* ─── Dispatch Bridge Script (Python3) ────── */
|
|
@@ -169,6 +170,7 @@ export class ClaudeCliRunner implements ExecutionRunner {
|
|
|
169
170
|
});
|
|
170
171
|
|
|
171
172
|
// 6. CLI args 구성
|
|
173
|
+
const maxTurns = config.maxTurns ?? 25;
|
|
172
174
|
const args = [
|
|
173
175
|
'-p',
|
|
174
176
|
'--system-prompt', fs.readFileSync(promptFile, 'utf-8'),
|
|
@@ -176,6 +178,7 @@ export class ClaudeCliRunner implements ExecutionRunner {
|
|
|
176
178
|
'--verbose',
|
|
177
179
|
'--dangerously-skip-permissions',
|
|
178
180
|
'--model', config.model ?? 'claude-sonnet-4-5',
|
|
181
|
+
'--max-turns', String(maxTurns),
|
|
179
182
|
'--mcp-config', mcpConfig,
|
|
180
183
|
'--strict-mcp-config',
|
|
181
184
|
taskPrompt,
|
|
@@ -197,10 +200,13 @@ export class ClaudeCliRunner implements ExecutionRunner {
|
|
|
197
200
|
cleanEnv.DISPATCH_CMD = dispatchScript;
|
|
198
201
|
|
|
199
202
|
const modelName = config.model ?? 'claude-sonnet-4-5';
|
|
200
|
-
|
|
203
|
+
// Use codeRoot as cwd if configured, otherwise fall back to companyRoot
|
|
204
|
+
const companyConfig = readConfig(companyRoot);
|
|
205
|
+
const cwd = companyConfig.codeRoot || companyRoot;
|
|
206
|
+
console.log(`[Runner] Spawning claude -p: role=${roleId}, model=${modelName}, maxTurns=${maxTurns}, jobId=${config.jobId ?? 'none'}, cwd=${cwd}, subordinates=[${subordinates.join(',')}]`);
|
|
201
207
|
|
|
202
208
|
const proc = spawn('claude', args, {
|
|
203
|
-
cwd
|
|
209
|
+
cwd,
|
|
204
210
|
env: cleanEnv,
|
|
205
211
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
206
212
|
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { Router, Request, Response, NextFunction } from 'express';
|
|
2
|
+
import { COMPANY_ROOT } from '../services/file-reader.js';
|
|
3
|
+
import { getTokenLedger } from '../services/token-ledger.js';
|
|
4
|
+
import { estimateCost } from '../services/pricing.js';
|
|
5
|
+
|
|
6
|
+
export const costRouter = Router();
|
|
7
|
+
|
|
8
|
+
/* ── W-T601: GET /api/cost/summary ───────── */
|
|
9
|
+
|
|
10
|
+
costRouter.get('/summary', (req: Request, res: Response, next: NextFunction) => {
|
|
11
|
+
try {
|
|
12
|
+
const from = req.query.from as string | undefined;
|
|
13
|
+
const to = req.query.to as string | undefined;
|
|
14
|
+
|
|
15
|
+
const ledger = getTokenLedger(COMPANY_ROOT);
|
|
16
|
+
const summary = ledger.query({ from, to });
|
|
17
|
+
|
|
18
|
+
// Role-by-role aggregation
|
|
19
|
+
const byRole: Record<string, { inputTokens: number; outputTokens: number; costUsd: number }> = {};
|
|
20
|
+
// Model-by-model aggregation
|
|
21
|
+
const byModel: Record<string, { inputTokens: number; outputTokens: number; costUsd: number }> = {};
|
|
22
|
+
|
|
23
|
+
for (const entry of summary.entries) {
|
|
24
|
+
// By role
|
|
25
|
+
if (!byRole[entry.roleId]) {
|
|
26
|
+
byRole[entry.roleId] = { inputTokens: 0, outputTokens: 0, costUsd: 0 };
|
|
27
|
+
}
|
|
28
|
+
byRole[entry.roleId].inputTokens += entry.inputTokens;
|
|
29
|
+
byRole[entry.roleId].outputTokens += entry.outputTokens;
|
|
30
|
+
byRole[entry.roleId].costUsd += estimateCost(entry.inputTokens, entry.outputTokens, entry.model);
|
|
31
|
+
|
|
32
|
+
// By model
|
|
33
|
+
if (!byModel[entry.model]) {
|
|
34
|
+
byModel[entry.model] = { inputTokens: 0, outputTokens: 0, costUsd: 0 };
|
|
35
|
+
}
|
|
36
|
+
byModel[entry.model].inputTokens += entry.inputTokens;
|
|
37
|
+
byModel[entry.model].outputTokens += entry.outputTokens;
|
|
38
|
+
byModel[entry.model].costUsd += estimateCost(entry.inputTokens, entry.outputTokens, entry.model);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const totalCostUsd = estimateCost(summary.totalInput, summary.totalOutput, '');
|
|
42
|
+
|
|
43
|
+
// Compute total cost from individual entries (more accurate with mixed models)
|
|
44
|
+
let totalCostFromEntries = 0;
|
|
45
|
+
for (const entry of summary.entries) {
|
|
46
|
+
totalCostFromEntries += estimateCost(entry.inputTokens, entry.outputTokens, entry.model);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
res.json({
|
|
50
|
+
from: from ?? null,
|
|
51
|
+
to: to ?? null,
|
|
52
|
+
totalInputTokens: summary.totalInput,
|
|
53
|
+
totalOutputTokens: summary.totalOutput,
|
|
54
|
+
totalCostUsd: totalCostFromEntries,
|
|
55
|
+
byRole,
|
|
56
|
+
byModel,
|
|
57
|
+
});
|
|
58
|
+
} catch (err) {
|
|
59
|
+
next(err);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/* ── W-T602: GET /api/cost/jobs/:jobId ───── */
|
|
64
|
+
|
|
65
|
+
costRouter.get('/jobs/:jobId', (req: Request, res: Response, next: NextFunction) => {
|
|
66
|
+
try {
|
|
67
|
+
const jobId = req.params.jobId as string;
|
|
68
|
+
const ledger = getTokenLedger(COMPANY_ROOT);
|
|
69
|
+
const summary = ledger.query({ jobId });
|
|
70
|
+
|
|
71
|
+
if (summary.entries.length === 0) {
|
|
72
|
+
res.status(404).json({ error: `No cost data found for job ${jobId}` });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let totalCostUsd = 0;
|
|
77
|
+
for (const entry of summary.entries) {
|
|
78
|
+
totalCostUsd += estimateCost(entry.inputTokens, entry.outputTokens, entry.model);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
res.json({
|
|
82
|
+
jobId,
|
|
83
|
+
totalInputTokens: summary.totalInput,
|
|
84
|
+
totalOutputTokens: summary.totalOutput,
|
|
85
|
+
totalCostUsd,
|
|
86
|
+
entries: summary.entries.map((e) => ({
|
|
87
|
+
ts: e.ts,
|
|
88
|
+
roleId: e.roleId,
|
|
89
|
+
model: e.model,
|
|
90
|
+
inputTokens: e.inputTokens,
|
|
91
|
+
outputTokens: e.outputTokens,
|
|
92
|
+
costUsd: estimateCost(e.inputTokens, e.outputTokens, e.model),
|
|
93
|
+
})),
|
|
94
|
+
});
|
|
95
|
+
} catch (err) {
|
|
96
|
+
next(err);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
@@ -477,20 +477,27 @@ function handleWave(body: Record<string, unknown>, req: IncomingMessage, res: Se
|
|
|
477
477
|
function handleStatus(res: ServerResponse): void {
|
|
478
478
|
const statuses: Record<string, string> = {};
|
|
479
479
|
|
|
480
|
-
|
|
481
|
-
statuses[roleId] = status;
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
// Merge with file-backed activity tracker
|
|
480
|
+
// 1. File-backed activity tracker (baseline)
|
|
485
481
|
const fileActivities = getAllActivities();
|
|
486
482
|
for (const activity of fileActivities) {
|
|
487
|
-
|
|
488
|
-
statuses[activity.roleId] = activity.status;
|
|
489
|
-
}
|
|
483
|
+
statuses[activity.roleId] = activity.status;
|
|
490
484
|
}
|
|
491
485
|
|
|
492
|
-
//
|
|
486
|
+
// 2. JobManager running jobs are the source of truth for "working"
|
|
493
487
|
const runningJobs = jobManager.listJobs({ status: 'running' });
|
|
488
|
+
const runningRoles = new Set(runningJobs.map(j => j.roleId));
|
|
489
|
+
|
|
490
|
+
// 3. Any role marked "working" in file/memory but NOT in JobManager → done
|
|
491
|
+
for (const roleId of Object.keys(statuses)) {
|
|
492
|
+
if (statuses[roleId] === 'working' && !runningRoles.has(roleId)) {
|
|
493
|
+
statuses[roleId] = 'done';
|
|
494
|
+
// Also fix stale roleStatus map
|
|
495
|
+
roleStatus.set(roleId, 'idle');
|
|
496
|
+
completeActivity(roleId);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// 4. Running jobs override everything
|
|
494
501
|
for (const job of runningJobs) {
|
|
495
502
|
statuses[job.roleId] = 'working';
|
|
496
503
|
}
|
|
@@ -14,7 +14,7 @@ import type { ScaffoldConfig } from '../services/scaffold.js';
|
|
|
14
14
|
import { importKnowledge } from '../services/knowledge-importer.js';
|
|
15
15
|
import { AnthropicProvider, type LLMProvider } from '../engine/llm-adapter.js';
|
|
16
16
|
import { jobManager } from '../services/job-manager.js';
|
|
17
|
-
import { applyConfig, readConfig } from '../services/company-config.js';
|
|
17
|
+
import { applyConfig, readConfig, writeConfig } from '../services/company-config.js';
|
|
18
18
|
|
|
19
19
|
export const setupRouter = Router();
|
|
20
20
|
|
|
@@ -81,7 +81,7 @@ setupRouter.post('/validate-path', (req, res) => {
|
|
|
81
81
|
* POST /api/setup/scaffold
|
|
82
82
|
*/
|
|
83
83
|
setupRouter.post('/scaffold', (req, res) => {
|
|
84
|
-
const { companyName, description, apiKey, team, existingProjectPath, knowledgePaths } = req.body;
|
|
84
|
+
const { companyName, description, apiKey, team, existingProjectPath, knowledgePaths, codeRoot } = req.body;
|
|
85
85
|
|
|
86
86
|
if (!companyName || typeof companyName !== 'string') {
|
|
87
87
|
res.status(400).json({ error: 'companyName is required' });
|
|
@@ -105,7 +105,11 @@ setupRouter.post('/scaffold', (req, res) => {
|
|
|
105
105
|
|
|
106
106
|
process.env.COMPANY_ROOT = projectRoot;
|
|
107
107
|
// Load config.json written by scaffold and apply to process.env
|
|
108
|
-
applyConfig(projectRoot);
|
|
108
|
+
const scaffoldConfig = applyConfig(projectRoot);
|
|
109
|
+
// Save codeRoot if provided
|
|
110
|
+
if (codeRoot && typeof codeRoot === 'string') {
|
|
111
|
+
writeConfig(projectRoot, { ...scaffoldConfig, codeRoot });
|
|
112
|
+
}
|
|
109
113
|
jobManager.refreshRunner();
|
|
110
114
|
|
|
111
115
|
res.json({ ok: true, companyName, projectRoot, created });
|
|
@@ -197,7 +201,7 @@ setupRouter.post('/connect-akb', (req, res) => {
|
|
|
197
201
|
applyConfig(resolved);
|
|
198
202
|
jobManager.refreshRunner();
|
|
199
203
|
|
|
200
|
-
res.json({ ok: true, companyName, companyRoot: resolved, engine: config.engine });
|
|
204
|
+
res.json({ ok: true, companyName, companyRoot: resolved, engine: config.engine, codeRoot: config.codeRoot || null });
|
|
201
205
|
});
|
|
202
206
|
|
|
203
207
|
/**
|
|
@@ -250,6 +254,31 @@ setupRouter.post('/import-knowledge', (req, res) => {
|
|
|
250
254
|
});
|
|
251
255
|
});
|
|
252
256
|
|
|
257
|
+
/**
|
|
258
|
+
* POST /api/setup/code-root
|
|
259
|
+
* Set or update the codeRoot config field.
|
|
260
|
+
*/
|
|
261
|
+
setupRouter.post('/code-root', (req, res) => {
|
|
262
|
+
const { codeRoot: newCodeRoot } = req.body;
|
|
263
|
+
const companyRoot = process.env.COMPANY_ROOT || process.cwd();
|
|
264
|
+
|
|
265
|
+
if (!newCodeRoot || typeof newCodeRoot !== 'string') {
|
|
266
|
+
res.status(400).json({ ok: false, error: 'codeRoot path is required' });
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const resolved = path.resolve(newCodeRoot);
|
|
271
|
+
if (!fs.existsSync(resolved)) {
|
|
272
|
+
res.status(400).json({ ok: false, error: 'Path does not exist' });
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const config = readConfig(companyRoot);
|
|
277
|
+
writeConfig(companyRoot, { ...config, codeRoot: resolved });
|
|
278
|
+
|
|
279
|
+
res.json({ ok: true, codeRoot: resolved });
|
|
280
|
+
});
|
|
281
|
+
|
|
253
282
|
/**
|
|
254
283
|
* GET /api/setup/teams
|
|
255
284
|
*/
|