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,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* active-sessions.ts — Active session visibility API
|
|
3
|
+
*
|
|
4
|
+
* Exposes session + port + worktree state for both UI and AI agents.
|
|
5
|
+
*/
|
|
6
|
+
import { Router } from 'express';
|
|
7
|
+
import { portRegistry } from '../services/port-registry.js';
|
|
8
|
+
import { executionManager } from '../services/execution-manager.js';
|
|
9
|
+
import { getSession } from '../services/session-store.js';
|
|
10
|
+
|
|
11
|
+
export const activeSessionsRouter = Router();
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* GET /api/active-sessions
|
|
15
|
+
*/
|
|
16
|
+
activeSessionsRouter.get('/', (_req, res) => {
|
|
17
|
+
const sessions = portRegistry.getAll();
|
|
18
|
+
|
|
19
|
+
const enriched = sessions.map(s => {
|
|
20
|
+
const exec = executionManager.getActiveExecution(s.sessionId);
|
|
21
|
+
const session = getSession(s.sessionId);
|
|
22
|
+
return {
|
|
23
|
+
...s,
|
|
24
|
+
waveId: session?.waveId ?? null,
|
|
25
|
+
messageStatus: exec?.status ?? null,
|
|
26
|
+
roleName: exec?.roleId ?? s.roleId,
|
|
27
|
+
alive: s.pid ? isAlive(s.pid) : null,
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
res.json({
|
|
32
|
+
sessions: enriched,
|
|
33
|
+
summary: portRegistry.getSummary(),
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* GET /api/active-sessions/:id
|
|
39
|
+
*/
|
|
40
|
+
activeSessionsRouter.get('/:id', (req, res) => {
|
|
41
|
+
const session = portRegistry.get(req.params.id);
|
|
42
|
+
if (!session) {
|
|
43
|
+
res.status(404).json({ error: 'Session not found' });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const exec = executionManager.getActiveExecution(session.sessionId);
|
|
48
|
+
|
|
49
|
+
res.json({
|
|
50
|
+
...session,
|
|
51
|
+
messageStatus: exec?.status ?? null,
|
|
52
|
+
roleName: exec?.roleId ?? session.roleId,
|
|
53
|
+
alive: session.pid ? isAlive(session.pid) : null,
|
|
54
|
+
execution: exec ? { id: exec.id, status: exec.status, roleId: exec.roleId, task: exec.task } : null,
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* DELETE /api/active-sessions/:id
|
|
60
|
+
*/
|
|
61
|
+
activeSessionsRouter.delete('/:id', (req, res) => {
|
|
62
|
+
const sessionId = req.params.id;
|
|
63
|
+
const session = portRegistry.get(sessionId);
|
|
64
|
+
|
|
65
|
+
if (!session) {
|
|
66
|
+
res.status(404).json({ error: 'Session not found' });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
executionManager.abortSession(sessionId);
|
|
71
|
+
portRegistry.release(sessionId);
|
|
72
|
+
|
|
73
|
+
res.json({ ok: true, released: session.ports });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* POST /api/active-sessions/cleanup
|
|
78
|
+
*/
|
|
79
|
+
activeSessionsRouter.post('/cleanup', (_req, res) => {
|
|
80
|
+
const result = portRegistry.cleanup();
|
|
81
|
+
res.json({
|
|
82
|
+
cleaned: result.cleaned.length,
|
|
83
|
+
remaining: result.remaining.length,
|
|
84
|
+
sessions: result.cleaned.map(s => ({
|
|
85
|
+
sessionId: s.sessionId,
|
|
86
|
+
roleId: s.roleId,
|
|
87
|
+
ports: s.ports,
|
|
88
|
+
})),
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* POST /api/active-sessions/register
|
|
94
|
+
*/
|
|
95
|
+
activeSessionsRouter.post('/register', async (req, res) => {
|
|
96
|
+
const { sessionId, roleId, task, pid, worktreePath } = req.body;
|
|
97
|
+
|
|
98
|
+
if (!sessionId || !roleId) {
|
|
99
|
+
res.status(400).json({ error: 'sessionId and roleId are required' });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const existing = portRegistry.get(sessionId);
|
|
104
|
+
if (existing) {
|
|
105
|
+
res.json({ ok: true, ports: existing.ports, existing: true });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const ports = await portRegistry.allocate(
|
|
110
|
+
sessionId,
|
|
111
|
+
roleId,
|
|
112
|
+
task || 'Manual session',
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
if (pid || worktreePath) {
|
|
116
|
+
portRegistry.update(sessionId, {
|
|
117
|
+
pid: pid ?? undefined,
|
|
118
|
+
worktreePath: worktreePath ?? undefined,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
res.json({ ok: true, ports, existing: false });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
/* ─── Helpers ────────────────────────────── */
|
|
126
|
+
|
|
127
|
+
function isAlive(pid: number): boolean {
|
|
128
|
+
try {
|
|
129
|
+
process.kill(pid, 0);
|
|
130
|
+
return true;
|
|
131
|
+
} catch {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { Router, Request, Response, NextFunction } from 'express';
|
|
2
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { COMPANY_ROOT } from '../services/file-reader.js';
|
|
5
|
+
|
|
6
|
+
export const coinsRouter = Router();
|
|
7
|
+
|
|
8
|
+
/* ── Types ── */
|
|
9
|
+
|
|
10
|
+
interface CoinTransaction {
|
|
11
|
+
ts: string;
|
|
12
|
+
amount: number;
|
|
13
|
+
reason: string;
|
|
14
|
+
ref?: string; // questId, jobId, etc.
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface CoinsData {
|
|
18
|
+
balance: number;
|
|
19
|
+
totalEarned: number;
|
|
20
|
+
totalSpent: number;
|
|
21
|
+
transactions: CoinTransaction[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/* ── Persistence ── */
|
|
25
|
+
|
|
26
|
+
const COINS_FILE = () => join(COMPANY_ROOT, '.tycono', 'coins.json');
|
|
27
|
+
|
|
28
|
+
const DEFAULT_DATA: CoinsData = {
|
|
29
|
+
balance: 0,
|
|
30
|
+
totalEarned: 0,
|
|
31
|
+
totalSpent: 0,
|
|
32
|
+
transactions: [],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function readCoins(): CoinsData {
|
|
36
|
+
try {
|
|
37
|
+
if (existsSync(COINS_FILE())) {
|
|
38
|
+
return JSON.parse(readFileSync(COINS_FILE(), 'utf-8'));
|
|
39
|
+
}
|
|
40
|
+
} catch { /* use defaults */ }
|
|
41
|
+
return { ...DEFAULT_DATA, transactions: [] };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function writeCoins(data: CoinsData) {
|
|
45
|
+
mkdirSync(join(COMPANY_ROOT, '.tycono'), { recursive: true });
|
|
46
|
+
writeFileSync(COINS_FILE(), JSON.stringify(data, null, 2) + '\n');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/* ── Internal API (for server-side use) ── */
|
|
50
|
+
|
|
51
|
+
export function earnCoinsInternal(amount: number, reason: string, ref?: string): { balance: number; skipped: boolean } {
|
|
52
|
+
const data = readCoins();
|
|
53
|
+
// Idempotency
|
|
54
|
+
if (ref && data.transactions.some(t => t.ref === ref && t.amount > 0)) {
|
|
55
|
+
return { balance: data.balance, skipped: true };
|
|
56
|
+
}
|
|
57
|
+
const tx: CoinTransaction = { ts: new Date().toISOString(), amount, reason, ref };
|
|
58
|
+
data.balance += amount;
|
|
59
|
+
data.totalEarned += amount;
|
|
60
|
+
data.transactions.push(tx);
|
|
61
|
+
writeCoins(data);
|
|
62
|
+
return { balance: data.balance, skipped: false };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/* ── Routes ── */
|
|
66
|
+
|
|
67
|
+
// GET /api/coins — current balance + summary
|
|
68
|
+
coinsRouter.get('/', (_req: Request, res: Response, next: NextFunction) => {
|
|
69
|
+
try {
|
|
70
|
+
res.json(readCoins());
|
|
71
|
+
} catch (err) { next(err); }
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// POST /api/coins/earn — add coins
|
|
75
|
+
coinsRouter.post('/earn', (req: Request, res: Response, next: NextFunction) => {
|
|
76
|
+
try {
|
|
77
|
+
const { amount, reason, ref } = req.body;
|
|
78
|
+
if (typeof amount !== 'number' || amount <= 0) {
|
|
79
|
+
res.status(400).json({ error: 'amount must be a positive number' });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const data = readCoins();
|
|
83
|
+
// Idempotency: skip if same ref already earned (prevents double quest rewards)
|
|
84
|
+
if (ref && data.transactions.some(t => t.ref === ref && t.amount > 0)) {
|
|
85
|
+
res.json({ ok: true, balance: data.balance, skipped: true });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const tx: CoinTransaction = {
|
|
89
|
+
ts: new Date().toISOString(),
|
|
90
|
+
amount,
|
|
91
|
+
reason: reason || 'earn',
|
|
92
|
+
ref,
|
|
93
|
+
};
|
|
94
|
+
data.balance += amount;
|
|
95
|
+
data.totalEarned += amount;
|
|
96
|
+
data.transactions.push(tx);
|
|
97
|
+
writeCoins(data);
|
|
98
|
+
res.json({ ok: true, balance: data.balance, transaction: tx });
|
|
99
|
+
} catch (err) { next(err); }
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// POST /api/coins/spend — deduct coins
|
|
103
|
+
coinsRouter.post('/spend', (req: Request, res: Response, next: NextFunction) => {
|
|
104
|
+
try {
|
|
105
|
+
const { amount, reason, ref } = req.body;
|
|
106
|
+
if (typeof amount !== 'number' || amount <= 0) {
|
|
107
|
+
res.status(400).json({ error: 'amount must be a positive number' });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const data = readCoins();
|
|
111
|
+
if (data.balance < amount) {
|
|
112
|
+
res.status(400).json({ error: 'insufficient balance', balance: data.balance, required: amount });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const tx: CoinTransaction = {
|
|
116
|
+
ts: new Date().toISOString(),
|
|
117
|
+
amount: -amount,
|
|
118
|
+
reason: reason || 'spend',
|
|
119
|
+
ref,
|
|
120
|
+
};
|
|
121
|
+
data.balance -= amount;
|
|
122
|
+
data.totalSpent += amount;
|
|
123
|
+
data.transactions.push(tx);
|
|
124
|
+
writeCoins(data);
|
|
125
|
+
res.json({ ok: true, balance: data.balance, transaction: tx });
|
|
126
|
+
} catch (err) { next(err); }
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// POST /api/coins/migrate — initial coin grant for existing users
|
|
130
|
+
coinsRouter.post('/migrate', (req: Request, res: Response, next: NextFunction) => {
|
|
131
|
+
try {
|
|
132
|
+
const data = readCoins();
|
|
133
|
+
// Only migrate once
|
|
134
|
+
if (data.totalEarned > 0) {
|
|
135
|
+
res.json({ ok: true, skipped: true, balance: data.balance });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const { completedQuests = 0 } = req.body;
|
|
139
|
+
const grantAmount = completedQuests > 0 ? completedQuests * 2000 : 5000;
|
|
140
|
+
const reason = completedQuests > 0 ? `migration: ${completedQuests} quests × 2,000` : 'welcome bonus';
|
|
141
|
+
const tx: CoinTransaction = {
|
|
142
|
+
ts: new Date().toISOString(),
|
|
143
|
+
amount: grantAmount,
|
|
144
|
+
reason,
|
|
145
|
+
ref: 'migration',
|
|
146
|
+
};
|
|
147
|
+
data.balance = grantAmount;
|
|
148
|
+
data.totalEarned = grantAmount;
|
|
149
|
+
data.transactions.push(tx);
|
|
150
|
+
writeCoins(data);
|
|
151
|
+
res.json({ ok: true, balance: data.balance, granted: grantAmount, reason });
|
|
152
|
+
} catch (err) { next(err); }
|
|
153
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Router, Request, Response, NextFunction } from 'express';
|
|
2
|
+
import YAML from 'yaml';
|
|
3
|
+
import { readFile, fileExists } from '../services/file-reader.js';
|
|
4
|
+
import { parseMarkdownTable, extractBoldKeyValues } from '../services/markdown-parser.js';
|
|
5
|
+
|
|
6
|
+
export const companyRouter = Router();
|
|
7
|
+
|
|
8
|
+
// GET /api/company — 회사 기본 정보
|
|
9
|
+
companyRouter.get('/', (_req: Request, res: Response, next: NextFunction) => {
|
|
10
|
+
try {
|
|
11
|
+
const companyContent = readFile('knowledge/company.md');
|
|
12
|
+
const kv = extractBoldKeyValues(companyContent);
|
|
13
|
+
|
|
14
|
+
// blockquote에서 미션 추출
|
|
15
|
+
const missionMatch = companyContent.match(/^>\s*(.+)/m);
|
|
16
|
+
const mission = missionMatch ? missionMatch[1].trim() : '';
|
|
17
|
+
|
|
18
|
+
// Role 목록
|
|
19
|
+
const rolesContent = readFile('knowledge/roles/roles.md');
|
|
20
|
+
const roleRows = parseMarkdownTable(rolesContent);
|
|
21
|
+
const roles = roleRows
|
|
22
|
+
.filter(row => (row.id ?? '').toLowerCase() !== 'ceo')
|
|
23
|
+
.map(row => {
|
|
24
|
+
const id = row.id ?? '';
|
|
25
|
+
let name = row.role ?? row.name ?? '';
|
|
26
|
+
|
|
27
|
+
// role.yaml의 name이 있으면 우선 사용 (커스텀 이름 반영)
|
|
28
|
+
const yamlPath = `knowledge/roles/${id}/role.yaml`;
|
|
29
|
+
if (id && fileExists(yamlPath)) {
|
|
30
|
+
try {
|
|
31
|
+
const raw = YAML.parse(readFile(yamlPath)) as Record<string, unknown>;
|
|
32
|
+
if (raw.name) name = raw.name as string;
|
|
33
|
+
} catch { /* fallback to roles.md name */ }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
id,
|
|
38
|
+
name,
|
|
39
|
+
level: row.level ?? '',
|
|
40
|
+
reportsTo: row.reports_to ?? '',
|
|
41
|
+
status: row.상태 ?? row.status ?? '',
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const company = {
|
|
46
|
+
name: companyContent.split('\n').find(l => l.startsWith('# '))?.replace(/^#\s+/, '') ?? '',
|
|
47
|
+
domain: kv['도메인'] ?? kv['domain'] ?? '',
|
|
48
|
+
founded: kv['설립일'] ?? kv['founded'] ?? '',
|
|
49
|
+
mission,
|
|
50
|
+
roles,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
res.json(company);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
next(err);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
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
|
+
/* @deprecated D-014: use /api/cost/sessions/:sessionId */
|
|
65
|
+
|
|
66
|
+
costRouter.get('/jobs/:jobId', (req: Request, res: Response, next: NextFunction) => {
|
|
67
|
+
try {
|
|
68
|
+
const jobId = req.params.jobId as string;
|
|
69
|
+
const ledger = getTokenLedger(COMPANY_ROOT);
|
|
70
|
+
const summary = ledger.query({ jobId });
|
|
71
|
+
|
|
72
|
+
if (summary.entries.length === 0) {
|
|
73
|
+
res.status(404).json({ error: `No cost data found for job ${jobId}` });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let totalCostUsd = 0;
|
|
78
|
+
for (const entry of summary.entries) {
|
|
79
|
+
totalCostUsd += estimateCost(entry.inputTokens, entry.outputTokens, entry.model);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
res.json({
|
|
83
|
+
jobId,
|
|
84
|
+
totalInputTokens: summary.totalInput,
|
|
85
|
+
totalOutputTokens: summary.totalOutput,
|
|
86
|
+
totalCostUsd,
|
|
87
|
+
entries: summary.entries.map((e) => ({
|
|
88
|
+
ts: e.ts,
|
|
89
|
+
roleId: e.roleId,
|
|
90
|
+
model: e.model,
|
|
91
|
+
inputTokens: e.inputTokens,
|
|
92
|
+
outputTokens: e.outputTokens,
|
|
93
|
+
costUsd: estimateCost(e.inputTokens, e.outputTokens, e.model),
|
|
94
|
+
})),
|
|
95
|
+
});
|
|
96
|
+
} catch (err) {
|
|
97
|
+
next(err);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
/* ── D-014: GET /api/cost/sessions/:sessionId ───── */
|
|
102
|
+
|
|
103
|
+
costRouter.get('/sessions/:sessionId', (req: Request, res: Response, next: NextFunction) => {
|
|
104
|
+
try {
|
|
105
|
+
const sessionId = req.params.sessionId as string;
|
|
106
|
+
const ledger = getTokenLedger(COMPANY_ROOT);
|
|
107
|
+
|
|
108
|
+
// D-014: Try sessionId field first, fall back to jobId for legacy entries
|
|
109
|
+
let summary = ledger.query({ sessionId });
|
|
110
|
+
if (summary.entries.length === 0) {
|
|
111
|
+
summary = ledger.query({ jobId: sessionId });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (summary.entries.length === 0) {
|
|
115
|
+
res.status(404).json({ error: `No cost data found for session ${sessionId}` });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let totalCostUsd = 0;
|
|
120
|
+
for (const entry of summary.entries) {
|
|
121
|
+
totalCostUsd += estimateCost(entry.inputTokens, entry.outputTokens, entry.model);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
res.json({
|
|
125
|
+
sessionId,
|
|
126
|
+
totalInputTokens: summary.totalInput,
|
|
127
|
+
totalOutputTokens: summary.totalOutput,
|
|
128
|
+
totalCostUsd,
|
|
129
|
+
entries: summary.entries.map((e) => ({
|
|
130
|
+
ts: e.ts,
|
|
131
|
+
roleId: e.roleId,
|
|
132
|
+
model: e.model,
|
|
133
|
+
inputTokens: e.inputTokens,
|
|
134
|
+
outputTokens: e.outputTokens,
|
|
135
|
+
costUsd: estimateCost(e.inputTokens, e.outputTokens, e.model),
|
|
136
|
+
})),
|
|
137
|
+
});
|
|
138
|
+
} catch (err) {
|
|
139
|
+
next(err);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { Router, Request, Response, NextFunction } from 'express';
|
|
2
|
+
import { COMPANY_ROOT } from '../services/file-reader.js';
|
|
3
|
+
import {
|
|
4
|
+
buildOrgTree,
|
|
5
|
+
assembleContext,
|
|
6
|
+
validateDispatch,
|
|
7
|
+
RoleLifecycleManager,
|
|
8
|
+
formatOrgChart,
|
|
9
|
+
} from '../engine/index.js';
|
|
10
|
+
import { createRunner } from '../engine/runners/index.js';
|
|
11
|
+
|
|
12
|
+
export const engineRouter = Router();
|
|
13
|
+
|
|
14
|
+
/* ─── GET /api/engine/org — Org tree ─────────── */
|
|
15
|
+
|
|
16
|
+
engineRouter.get('/org', (_req: Request, res: Response, next: NextFunction) => {
|
|
17
|
+
try {
|
|
18
|
+
const tree = buildOrgTree(COMPANY_ROOT);
|
|
19
|
+
|
|
20
|
+
// Serialize Map to plain object
|
|
21
|
+
const nodes: Record<string, unknown> = {};
|
|
22
|
+
for (const [id, node] of tree.nodes) {
|
|
23
|
+
nodes[id] = {
|
|
24
|
+
id: node.id,
|
|
25
|
+
name: node.name,
|
|
26
|
+
level: node.level,
|
|
27
|
+
reportsTo: node.reportsTo,
|
|
28
|
+
children: node.children,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
res.json({
|
|
33
|
+
root: tree.root,
|
|
34
|
+
nodes,
|
|
35
|
+
chart: formatOrgChart(tree),
|
|
36
|
+
});
|
|
37
|
+
} catch (err) {
|
|
38
|
+
next(err);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/* ─── GET /api/engine/context/:roleId — Preview assembled context ── */
|
|
43
|
+
|
|
44
|
+
engineRouter.get('/context/:roleId', (req: Request, res: Response, next: NextFunction) => {
|
|
45
|
+
try {
|
|
46
|
+
const roleId = String(req.params.roleId ?? '');
|
|
47
|
+
const sourceRole = String(req.query.source ?? 'ceo');
|
|
48
|
+
const task = String(req.query.task ?? '(preview — no task specified)');
|
|
49
|
+
|
|
50
|
+
const tree = buildOrgTree(COMPANY_ROOT);
|
|
51
|
+
const context = assembleContext(COMPANY_ROOT, roleId as string, task, sourceRole, tree);
|
|
52
|
+
|
|
53
|
+
res.json({
|
|
54
|
+
targetRole: context.targetRole,
|
|
55
|
+
sourceRole: context.sourceRole,
|
|
56
|
+
metadata: context.metadata,
|
|
57
|
+
systemPromptLength: context.systemPrompt.length,
|
|
58
|
+
systemPromptPreview: context.systemPrompt.slice(0, 3000),
|
|
59
|
+
task: context.task,
|
|
60
|
+
});
|
|
61
|
+
} catch (err) {
|
|
62
|
+
next(err);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
/* ─── POST /api/engine/dispatch/validate — Check dispatch authority ── */
|
|
67
|
+
|
|
68
|
+
engineRouter.post('/dispatch/validate', (req: Request, res: Response, next: NextFunction) => {
|
|
69
|
+
try {
|
|
70
|
+
const { sourceRole, targetRole } = req.body;
|
|
71
|
+
|
|
72
|
+
if (!sourceRole || !targetRole) {
|
|
73
|
+
res.status(400).json({ error: 'sourceRole and targetRole are required' });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const tree = buildOrgTree(COMPANY_ROOT);
|
|
78
|
+
const result = validateDispatch(tree, sourceRole, targetRole);
|
|
79
|
+
|
|
80
|
+
res.json(result);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
next(err);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
/* ─── POST /api/engine/roles — Create a new role ── */
|
|
87
|
+
|
|
88
|
+
engineRouter.post('/roles', async (req: Request, res: Response, next: NextFunction) => {
|
|
89
|
+
try {
|
|
90
|
+
const def = req.body;
|
|
91
|
+
|
|
92
|
+
if (!def.id || !def.name || !def.reportsTo) {
|
|
93
|
+
res.status(400).json({ error: 'id, name, and reportsTo are required' });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const manager = new RoleLifecycleManager(COMPANY_ROOT);
|
|
98
|
+
await manager.createRole(def);
|
|
99
|
+
|
|
100
|
+
res.status(201).json({ ok: true, roleId: def.id });
|
|
101
|
+
} catch (err) {
|
|
102
|
+
next(err);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
/* ─── PATCH /api/engine/roles/:id — Update a role ── */
|
|
107
|
+
|
|
108
|
+
engineRouter.patch('/roles/:id', async (req: Request, res: Response, next: NextFunction) => {
|
|
109
|
+
try {
|
|
110
|
+
const id = String(req.params.id);
|
|
111
|
+
const changes = req.body;
|
|
112
|
+
|
|
113
|
+
if (!changes || Object.keys(changes).length === 0) {
|
|
114
|
+
res.status(400).json({ error: 'No changes provided' });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const manager = new RoleLifecycleManager(COMPANY_ROOT);
|
|
119
|
+
await manager.updateRole(id, changes);
|
|
120
|
+
|
|
121
|
+
res.json({ ok: true, roleId: id });
|
|
122
|
+
} catch (err) {
|
|
123
|
+
next(err);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
/* ─── DELETE /api/engine/roles/:id — Remove a role ── */
|
|
128
|
+
|
|
129
|
+
engineRouter.delete('/roles/:id', async (req: Request, res: Response, next: NextFunction) => {
|
|
130
|
+
try {
|
|
131
|
+
const id = String(req.params.id);
|
|
132
|
+
const manager = new RoleLifecycleManager(COMPANY_ROOT);
|
|
133
|
+
await manager.removeRole(id);
|
|
134
|
+
|
|
135
|
+
res.json({ ok: true, removed: id });
|
|
136
|
+
} catch (err) {
|
|
137
|
+
next(err);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
/* ─── GET /api/engine/roles/validate — Validate all roles ── */
|
|
142
|
+
|
|
143
|
+
engineRouter.get('/roles/validate', (_req: Request, res: Response, next: NextFunction) => {
|
|
144
|
+
try {
|
|
145
|
+
const manager = new RoleLifecycleManager(COMPANY_ROOT);
|
|
146
|
+
const results = manager.validateAll();
|
|
147
|
+
|
|
148
|
+
const output: Record<string, unknown> = {};
|
|
149
|
+
for (const [id, result] of results) {
|
|
150
|
+
output[id] = result;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
res.json(output);
|
|
154
|
+
} catch (err) {
|
|
155
|
+
next(err);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
/* ─── POST /api/engine/roles/:id/skill/regenerate — Regenerate SKILL.md ── */
|
|
160
|
+
|
|
161
|
+
engineRouter.post('/roles/:id/skill/regenerate', async (req: Request, res: Response, next: NextFunction) => {
|
|
162
|
+
try {
|
|
163
|
+
const id = String(req.params.id);
|
|
164
|
+
const manager = new RoleLifecycleManager(COMPANY_ROOT);
|
|
165
|
+
await manager.regenerateSkill(id);
|
|
166
|
+
|
|
167
|
+
res.json({ ok: true, roleId: id });
|
|
168
|
+
} catch (err) {
|
|
169
|
+
next(err);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
/* ─── POST /api/engine/ask/:roleId — Ask a role a question (read-only) ── */
|
|
174
|
+
|
|
175
|
+
engineRouter.post('/ask/:roleId', async (req: Request, res: Response, next: NextFunction) => {
|
|
176
|
+
try {
|
|
177
|
+
const roleId = String(req.params.roleId);
|
|
178
|
+
const { question, sourceRole } = req.body;
|
|
179
|
+
|
|
180
|
+
if (!question) {
|
|
181
|
+
res.status(400).json({ error: 'question is required' });
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const source = sourceRole || 'ceo';
|
|
186
|
+
const orgTree = buildOrgTree(COMPANY_ROOT);
|
|
187
|
+
|
|
188
|
+
if (!orgTree.nodes.has(roleId)) {
|
|
189
|
+
res.status(404).json({ error: `Role not found: ${roleId}` });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Ask is read-only: no authority check required (anyone can ask anyone)
|
|
194
|
+
const handle = createRunner().execute(
|
|
195
|
+
{
|
|
196
|
+
companyRoot: COMPANY_ROOT,
|
|
197
|
+
roleId,
|
|
198
|
+
task: `[Question from ${source}] ${question}`,
|
|
199
|
+
sourceRole: source,
|
|
200
|
+
orgTree,
|
|
201
|
+
readOnly: true,
|
|
202
|
+
maxTurns: 5,
|
|
203
|
+
sessionId: `ask-${Date.now()}`,
|
|
204
|
+
},
|
|
205
|
+
{},
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
const result = await handle.promise;
|
|
209
|
+
|
|
210
|
+
res.json({
|
|
211
|
+
roleId,
|
|
212
|
+
question,
|
|
213
|
+
answer: result.output,
|
|
214
|
+
turns: result.turns,
|
|
215
|
+
tokens: result.totalTokens,
|
|
216
|
+
});
|
|
217
|
+
} catch (err) {
|
|
218
|
+
next(err);
|
|
219
|
+
}
|
|
220
|
+
});
|