tycono-server 0.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/bin/cli.js +35 -0
  2. package/bin/server.ts +160 -0
  3. package/package.json +50 -0
  4. package/src/api/package.json +31 -0
  5. package/src/api/src/create-app.ts +90 -0
  6. package/src/api/src/create-server.ts +251 -0
  7. package/src/api/src/engine/agent-loop.ts +738 -0
  8. package/src/api/src/engine/authority-validator.ts +149 -0
  9. package/src/api/src/engine/context-assembler.ts +912 -0
  10. package/src/api/src/engine/index.ts +27 -0
  11. package/src/api/src/engine/knowledge-gate.ts +365 -0
  12. package/src/api/src/engine/llm-adapter.ts +304 -0
  13. package/src/api/src/engine/org-tree.ts +270 -0
  14. package/src/api/src/engine/role-lifecycle.ts +369 -0
  15. package/src/api/src/engine/runners/claude-cli.ts +796 -0
  16. package/src/api/src/engine/runners/direct-api.ts +66 -0
  17. package/src/api/src/engine/runners/index.ts +30 -0
  18. package/src/api/src/engine/runners/types.ts +95 -0
  19. package/src/api/src/engine/skill-template.ts +134 -0
  20. package/src/api/src/engine/tools/definitions.ts +201 -0
  21. package/src/api/src/engine/tools/executor.ts +611 -0
  22. package/src/api/src/routes/active-sessions.ts +134 -0
  23. package/src/api/src/routes/coins.ts +153 -0
  24. package/src/api/src/routes/company.ts +57 -0
  25. package/src/api/src/routes/cost.ts +141 -0
  26. package/src/api/src/routes/engine.ts +220 -0
  27. package/src/api/src/routes/execute.ts +1075 -0
  28. package/src/api/src/routes/git.ts +211 -0
  29. package/src/api/src/routes/knowledge.ts +378 -0
  30. package/src/api/src/routes/operations.ts +309 -0
  31. package/src/api/src/routes/preferences.ts +63 -0
  32. package/src/api/src/routes/presets.ts +123 -0
  33. package/src/api/src/routes/projects.ts +82 -0
  34. package/src/api/src/routes/quests.ts +41 -0
  35. package/src/api/src/routes/roles.ts +112 -0
  36. package/src/api/src/routes/save.ts +152 -0
  37. package/src/api/src/routes/sessions.ts +288 -0
  38. package/src/api/src/routes/setup.ts +437 -0
  39. package/src/api/src/routes/skills.ts +357 -0
  40. package/src/api/src/routes/speech.ts +959 -0
  41. package/src/api/src/routes/supervision.ts +136 -0
  42. package/src/api/src/routes/sync.ts +165 -0
  43. package/src/api/src/server.ts +59 -0
  44. package/src/api/src/services/activity-stream.ts +184 -0
  45. package/src/api/src/services/activity-tracker.ts +115 -0
  46. package/src/api/src/services/claude-md-manager.ts +94 -0
  47. package/src/api/src/services/company-config.ts +115 -0
  48. package/src/api/src/services/database.ts +77 -0
  49. package/src/api/src/services/digest-engine.ts +313 -0
  50. package/src/api/src/services/execution-manager.ts +1036 -0
  51. package/src/api/src/services/file-reader.ts +77 -0
  52. package/src/api/src/services/git-save.ts +614 -0
  53. package/src/api/src/services/job-manager.ts +16 -0
  54. package/src/api/src/services/knowledge-importer.ts +466 -0
  55. package/src/api/src/services/markdown-parser.ts +173 -0
  56. package/src/api/src/services/port-registry.ts +222 -0
  57. package/src/api/src/services/preferences.ts +150 -0
  58. package/src/api/src/services/preset-loader.ts +149 -0
  59. package/src/api/src/services/pricing.ts +34 -0
  60. package/src/api/src/services/scaffold.ts +546 -0
  61. package/src/api/src/services/session-store.ts +340 -0
  62. package/src/api/src/services/supervisor-heartbeat.ts +897 -0
  63. package/src/api/src/services/team-recommender.ts +382 -0
  64. package/src/api/src/services/token-ledger.ts +127 -0
  65. package/src/api/src/services/wave-messages.ts +194 -0
  66. package/src/api/src/services/wave-multiplexer.ts +356 -0
  67. package/src/api/src/services/wave-tracker.ts +359 -0
  68. package/src/api/src/utils/role-level.ts +31 -0
  69. package/src/core/scaffolder.ts +620 -0
  70. package/src/shared/types.ts +224 -0
  71. package/templates/CLAUDE.md.tmpl +239 -0
  72. package/templates/company.md.tmpl +17 -0
  73. package/templates/gitignore.tmpl +28 -0
  74. package/templates/roles.md.tmpl +8 -0
  75. package/templates/skills/_manifest.json +23 -0
  76. package/templates/skills/agent-browser/SKILL.md +159 -0
  77. package/templates/skills/agent-browser/meta.json +19 -0
  78. package/templates/skills/akb-linter/SKILL.md +125 -0
  79. package/templates/skills/akb-linter/meta.json +12 -0
  80. package/templates/skills/knowledge-gate/SKILL.md +120 -0
  81. package/templates/skills/knowledge-gate/meta.json +12 -0
  82. package/templates/teams/agency.json +58 -0
  83. package/templates/teams/research.json +58 -0
  84. package/templates/teams/startup.json +58 -0
@@ -0,0 +1,27 @@
1
+ // Context Engine — public API
2
+ export { buildOrgTree, canDispatchTo, canConsult, getSubordinates, getDescendants, getChainOfCommand, formatOrgChart, refreshOrgTree } from './org-tree.js';
3
+ export type { OrgTree, OrgNode, Authority, KnowledgeAccess } from './org-tree.js';
4
+
5
+ export { assembleContext } from './context-assembler.js';
6
+ export type { AssembledContext } from './context-assembler.js';
7
+
8
+ export { validateDispatch, validateConsult, validateWrite, validateRead } from './authority-validator.js';
9
+ export type { AuthResult } from './authority-validator.js';
10
+
11
+ export { RoleLifecycleManager } from './role-lifecycle.js';
12
+ export type { RoleDefinition, RoleValidationResult } from './role-lifecycle.js';
13
+
14
+ export { generateSkillMd } from './skill-template.js';
15
+
16
+ export { AnthropicProvider, LLMAdapter } from './llm-adapter.js';
17
+ export type { LLMProvider, ToolDefinition, ToolCall, ToolResult, LLMResponse, LLMMessage } from './llm-adapter.js';
18
+
19
+ export { runAgentLoop } from './agent-loop.js';
20
+ export type { AgentConfig, AgentResult } from './agent-loop.js';
21
+
22
+ export { getToolsForRole } from './tools/definitions.js';
23
+ export { executeTool } from './tools/executor.js';
24
+
25
+ // Runner abstraction
26
+ export { createRunner, ClaudeCliRunner, DirectApiRunner } from './runners/index.js';
27
+ export type { ExecutionRunner, RunnerConfig, RunnerCallbacks, RunnerHandle, RunnerResult } from './runners/index.js';
@@ -0,0 +1,365 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { glob } from 'glob';
4
+
5
+ /* ─── Types ──────────────────────────────────── */
6
+
7
+ export interface RelatedDoc {
8
+ path: string;
9
+ matches: number;
10
+ preview: string;
11
+ }
12
+
13
+ export interface KnowledgeDebtItem {
14
+ type: 'missing-crosslink' | 'missing-hub' | 'stale-doc' | 'orphan-doc' | 'broken-link';
15
+ file: string;
16
+ message: string;
17
+ }
18
+
19
+ export interface PostKnowledgingResult {
20
+ pass: boolean;
21
+ debt: KnowledgeDebtItem[];
22
+ newDocs: string[];
23
+ modifiedDocs: string[];
24
+ }
25
+
26
+ export interface DecayReport {
27
+ health: number;
28
+ orphanDocs: string[];
29
+ staleDocs: string[];
30
+ brokenLinks: Array<{ file: string; link: string }>;
31
+ suggestions: string[];
32
+ totalDocs: number;
33
+ linkedDocs: number;
34
+ }
35
+
36
+ /* ─── Pre-Knowledging: Keyword Extraction ────── */
37
+
38
+ /** Extract meaningful keywords from task directive for knowledge search */
39
+ export function extractKeywords(text: string): string[] {
40
+ // Remove common stop words and short words
41
+ const stopWords = new Set([
42
+ // English
43
+ 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
44
+ 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',
45
+ 'should', 'may', 'might', 'shall', 'can', 'need', 'must', 'to', 'of',
46
+ 'in', 'for', 'on', 'with', 'at', 'by', 'from', 'as', 'into', 'through',
47
+ 'and', 'but', 'or', 'not', 'no', 'if', 'then', 'else', 'when', 'up',
48
+ 'all', 'each', 'every', 'both', 'few', 'more', 'most', 'other', 'some',
49
+ 'such', 'than', 'too', 'very', 'just', 'about', 'above', 'after',
50
+ 'this', 'that', 'these', 'those', 'it', 'its', 'my', 'your', 'our',
51
+ 'what', 'which', 'who', 'how', 'use', 'make', 'get', 'set',
52
+ // Korean common particles/verbs
53
+ '해', '하고', '하는', '해줘', '해라', '하세요', '합니다', '된', '되는',
54
+ '이', '그', '저', '것', '거', '을', '를', '에', '에서', '으로', '로',
55
+ '와', '과', '는', '은', '가', '의', '도', '만', '좀', '더',
56
+ // Task-specific
57
+ 'ceo', 'wave', 'continuation', 'previous', 'context', 'response',
58
+ 'read', 'write', 'file', 'update', 'check', 'implement',
59
+ ]);
60
+
61
+ // Strip markdown, brackets, special chars
62
+ const cleaned = text
63
+ .replace(/\[.*?\]/g, ' ')
64
+ .replace(/[#*`_\->\[\](){}|]/g, ' ')
65
+ .replace(/https?:\/\/\S+/g, ' ')
66
+ .replace(/[^\w\sㄱ-힣]/g, ' ');
67
+
68
+ const words = cleaned
69
+ .split(/\s+/)
70
+ .map(w => w.toLowerCase().trim())
71
+ .filter(w => w.length >= 3 && !stopWords.has(w));
72
+
73
+ // Deduplicate and take top keywords by frequency
74
+ const freq = new Map<string, number>();
75
+ for (const w of words) {
76
+ freq.set(w, (freq.get(w) ?? 0) + 1);
77
+ }
78
+
79
+ return [...freq.entries()]
80
+ .sort((a, b) => b[1] - a[1])
81
+ .slice(0, 8)
82
+ .map(([word]) => word);
83
+ }
84
+
85
+ /* ─── Pre-Knowledging: Related Doc Search ────── */
86
+
87
+ /** Search knowledge/ and architecture/ for docs related to given keywords */
88
+ export function searchRelatedDocs(companyRoot: string, keywords: string[]): RelatedDoc[] {
89
+ if (keywords.length === 0) return [];
90
+
91
+ const searchDirs = ['knowledge', 'knowledge/architecture', 'knowledge/projects'];
92
+ const results: RelatedDoc[] = [];
93
+
94
+ for (const dir of searchDirs) {
95
+ const dirPath = path.join(companyRoot, dir);
96
+ if (!fs.existsSync(dirPath)) continue;
97
+
98
+ const files = glob.sync('**/*.md', {
99
+ cwd: dirPath,
100
+ ignore: ['**/journal/**'],
101
+ });
102
+
103
+ for (const file of files) {
104
+ const filePath = path.join(dirPath, file);
105
+ try {
106
+ const content = fs.readFileSync(filePath, 'utf-8');
107
+ const lowerContent = content.toLowerCase();
108
+
109
+ let matches = 0;
110
+ for (const kw of keywords) {
111
+ // Count occurrences (case insensitive)
112
+ const regex = new RegExp(kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
113
+ const found = lowerContent.match(regex);
114
+ if (found) matches += found.length;
115
+ }
116
+
117
+ if (matches >= 2) {
118
+ // Extract title from first heading
119
+ const titleMatch = content.match(/^#\s+(.+)/m);
120
+ const title = titleMatch ? titleMatch[1].trim() : file;
121
+ const relativePath = path.join(dir, file);
122
+
123
+ results.push({
124
+ path: relativePath,
125
+ matches,
126
+ preview: title,
127
+ });
128
+ }
129
+ } catch {
130
+ // Skip unreadable files
131
+ }
132
+ }
133
+ }
134
+
135
+ // Sort by match count descending, take top 5
136
+ return results
137
+ .sort((a, b) => b.matches - a.matches)
138
+ .slice(0, 5);
139
+ }
140
+
141
+ /* ─── Knowledge Gate: Auto-search on new .md ─── */
142
+
143
+ /** Build an enhanced AKB warning with auto-search results for a new .md file */
144
+ export function buildKnowledgeGateWarning(
145
+ companyRoot: string,
146
+ filePath: string,
147
+ content: string,
148
+ ): string {
149
+ // Extract keywords from file name + first 5 lines
150
+ const fileName = path.basename(filePath, '.md').replace(/[-_]/g, ' ');
151
+ const firstLines = content.split('\n').slice(0, 5).join(' ');
152
+ const keywords = extractKeywords(`${fileName} ${firstLines}`);
153
+
154
+ const related = searchRelatedDocs(companyRoot, keywords);
155
+
156
+ let warning = '\n\n[AKB Knowledge Gate] 새 .md 파일입니다.\n';
157
+
158
+ if (related.length > 0) {
159
+ warning += '\n📚 관련 문서 발견:\n';
160
+ for (const doc of related) {
161
+ warning += ` - ${doc.path} — "${doc.preview}" (${doc.matches} matches)\n`;
162
+ }
163
+ warning += '\n→ 70%+ 중복이면 기존 문서에 추가하세요.\n';
164
+ warning += '→ 새 문서라면 반드시:\n';
165
+ } else {
166
+ warning += '\n관련 문서를 찾지 못했습니다. 새 문서 생성이 적절합니다.\n';
167
+ warning += '반드시:\n';
168
+ }
169
+
170
+ warning += ' (1) 관련 문서 섹션에 cross-link를 추가하세요\n';
171
+ warning += ' (2) 해당 폴더의 Hub 파일에 등록하세요\n';
172
+
173
+ return warning;
174
+ }
175
+
176
+ /* ─── Post-Knowledging: Verification ─────────── */
177
+
178
+ /** Check if a .md file has a cross-link section with at least 1 link */
179
+ export function hasCrossLinks(content: string): boolean {
180
+ // Look for "관련 문서" or "Related" section with markdown links
181
+ const crossLinkSection = content.match(/##\s*(관련 문서|Related|References|See Also)/i);
182
+ if (!crossLinkSection) return false;
183
+
184
+ // Check for at least one markdown link after the section header
185
+ const sectionStart = content.indexOf(crossLinkSection[0]);
186
+ const afterSection = content.slice(sectionStart);
187
+ return /\[.+?\]\(.+?\)/.test(afterSection);
188
+ }
189
+
190
+ /** Check if a file is registered in its folder's Hub document */
191
+ export function isRegisteredInHub(companyRoot: string, filePath: string): boolean {
192
+ const dir = path.dirname(filePath);
193
+ const dirName = path.basename(dir);
194
+ const hubPath = path.join(companyRoot, dir, `${dirName}.md`);
195
+
196
+ if (!fs.existsSync(hubPath)) return true; // No hub = no enforcement
197
+
198
+ const hubContent = fs.readFileSync(hubPath, 'utf-8');
199
+ const fileName = path.basename(filePath);
200
+
201
+ // Check if the file is mentioned in the hub (by filename or relative path)
202
+ return hubContent.includes(fileName) || hubContent.includes(`./${fileName}`);
203
+ }
204
+
205
+ /** Run Post-Knowledging checks on changed files */
206
+ export function postKnowledgingCheck(
207
+ companyRoot: string,
208
+ changedFiles: string[],
209
+ ): PostKnowledgingResult {
210
+ const debt: KnowledgeDebtItem[] = [];
211
+ const newDocs: string[] = [];
212
+ const modifiedDocs: string[] = [];
213
+
214
+ for (const file of changedFiles) {
215
+ // Only check .md files (skip journals)
216
+ if (!file.endsWith('.md') || file.includes('journal/')) continue;
217
+
218
+ const absolute = path.resolve(companyRoot, file);
219
+ if (!fs.existsSync(absolute)) continue;
220
+
221
+ const content = fs.readFileSync(absolute, 'utf-8');
222
+
223
+ // Categorize
224
+ // We can't tell new vs modified from just file list, so check if it's a knowledge/architecture doc
225
+ if (file.startsWith('knowledge/') || file.startsWith('knowledge/architecture/') || file.startsWith('knowledge/projects/')) {
226
+ modifiedDocs.push(file);
227
+ }
228
+
229
+ // Check cross-links
230
+ if (!hasCrossLinks(content)) {
231
+ debt.push({
232
+ type: 'missing-crosslink',
233
+ file,
234
+ message: `"${file}" has no cross-link section (## 관련 문서)`,
235
+ });
236
+ }
237
+
238
+ // Check Hub registration
239
+ if (!isRegisteredInHub(companyRoot, file)) {
240
+ debt.push({
241
+ type: 'missing-hub',
242
+ file,
243
+ message: `"${file}" is not registered in its Hub document`,
244
+ });
245
+ }
246
+ }
247
+
248
+ return {
249
+ pass: debt.length === 0,
250
+ debt,
251
+ newDocs,
252
+ modifiedDocs,
253
+ };
254
+ }
255
+
256
+ /* ─── Decay Detection ────────────────────────── */
257
+
258
+ /** Scan for orphan docs (not registered in Hub) and broken links */
259
+ export function detectDecay(companyRoot: string): DecayReport {
260
+ const searchDirs = ['knowledge', 'knowledge/architecture'];
261
+ const orphanDocs: string[] = [];
262
+ const staleDocs: string[] = [];
263
+ const brokenLinks: Array<{ file: string; link: string }> = [];
264
+ let totalDocs = 0;
265
+ let linkedDocs = 0;
266
+
267
+ for (const dir of searchDirs) {
268
+ const dirPath = path.join(companyRoot, dir);
269
+ if (!fs.existsSync(dirPath)) continue;
270
+
271
+ const hubName = `${dir}.md`;
272
+ const hubPath = path.join(dirPath, hubName);
273
+ const hubContent = fs.existsSync(hubPath) ? fs.readFileSync(hubPath, 'utf-8') : '';
274
+
275
+ const files = glob.sync('*.md', { cwd: dirPath });
276
+
277
+ for (const file of files) {
278
+ if (file === hubName) continue; // Skip hub itself
279
+ totalDocs++;
280
+
281
+ // Check if registered in hub
282
+ if (hubContent && !hubContent.includes(file) && !hubContent.includes(`./${file}`)) {
283
+ orphanDocs.push(path.join(dir, file));
284
+ } else {
285
+ linkedDocs++;
286
+ }
287
+
288
+ // Check for broken links and stale status in the file
289
+ const filePath = path.join(dirPath, file);
290
+ try {
291
+ const content = fs.readFileSync(filePath, 'utf-8');
292
+
293
+ // Check for deprecated/stale status in frontmatter
294
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
295
+ if (frontmatterMatch) {
296
+ const frontmatter = frontmatterMatch[1];
297
+ if (/status:\s*(deprecated|stale)/i.test(frontmatter)) {
298
+ staleDocs.push(path.join(dir, file));
299
+ }
300
+ }
301
+
302
+ const linkRegex = /\[.*?\]\(\.\/(.*?\.md)\)/g;
303
+ let match;
304
+ while ((match = linkRegex.exec(content)) !== null) {
305
+ const linkedFile = match[1];
306
+ const linkedPath = path.join(dirPath, linkedFile);
307
+ if (!fs.existsSync(linkedPath)) {
308
+ // Also check if it's a relative path from parent
309
+ const parentLinkedPath = path.join(companyRoot, dir, linkedFile);
310
+ if (!fs.existsSync(parentLinkedPath)) {
311
+ brokenLinks.push({
312
+ file: path.join(dir, file),
313
+ link: linkedFile,
314
+ });
315
+ }
316
+ }
317
+ }
318
+
319
+ // Also check ../relative links
320
+ const parentLinkRegex = /\[.*?\]\(\.\.\/(.*?\.md)\)/g;
321
+ while ((match = parentLinkRegex.exec(content)) !== null) {
322
+ const linkedFile = match[1];
323
+ const linkedPath = path.join(companyRoot, linkedFile);
324
+ if (!fs.existsSync(linkedPath)) {
325
+ brokenLinks.push({
326
+ file: path.join(dir, file),
327
+ link: `../${linkedFile}`,
328
+ });
329
+ }
330
+ }
331
+ } catch {
332
+ // Skip unreadable
333
+ }
334
+ }
335
+ }
336
+
337
+ const health = totalDocs > 0
338
+ ? Math.round(((totalDocs - orphanDocs.length - staleDocs.length - brokenLinks.length) / totalDocs) * 100)
339
+ : 100;
340
+
341
+ // Build suggestions
342
+ const suggestions: string[] = [];
343
+ if (orphanDocs.length > 0) {
344
+ suggestions.push(`${orphanDocs.length}개의 고아 문서를 Hub에 등록하세요`);
345
+ }
346
+ if (staleDocs.length > 0) {
347
+ suggestions.push(`${staleDocs.length}개의 오래된 문서를 업데이트하거나 삭제하세요`);
348
+ }
349
+ if (brokenLinks.length > 0) {
350
+ suggestions.push(`${brokenLinks.length}개의 깨진 링크를 수정하세요`);
351
+ }
352
+ if (orphanDocs.length === 0 && staleDocs.length === 0 && brokenLinks.length === 0) {
353
+ suggestions.push('모든 문서가 건강합니다! 🎉');
354
+ }
355
+
356
+ return {
357
+ health: Math.max(0, Math.min(100, health)),
358
+ orphanDocs,
359
+ staleDocs,
360
+ brokenLinks,
361
+ suggestions,
362
+ totalDocs,
363
+ linkedDocs,
364
+ };
365
+ }
@@ -0,0 +1,304 @@
1
+ import { spawn } from 'node:child_process';
2
+ import Anthropic from '@anthropic-ai/sdk';
3
+
4
+ /* ─── Types ──────────────────────────────────── */
5
+
6
+ export interface ToolDefinition {
7
+ name: string;
8
+ description: string;
9
+ input_schema: Record<string, unknown>;
10
+ }
11
+
12
+ export interface ToolCall {
13
+ id: string;
14
+ name: string;
15
+ input: Record<string, unknown>;
16
+ }
17
+
18
+ export interface ToolResult {
19
+ tool_use_id: string;
20
+ content: string;
21
+ is_error?: boolean;
22
+ }
23
+
24
+ export type MessageContent =
25
+ | { type: 'text'; text: string }
26
+ | { type: 'tool_use'; id: string; name: string; input: Record<string, unknown> }
27
+ | { type: 'image'; source: { type: 'base64'; media_type: string; data: string } };
28
+
29
+ export interface LLMResponse {
30
+ content: MessageContent[];
31
+ stopReason: string;
32
+ usage: { inputTokens: number; outputTokens: number };
33
+ }
34
+
35
+ export interface LLMMessage {
36
+ role: 'user' | 'assistant';
37
+ content: string | MessageContent[];
38
+ }
39
+
40
+ export interface StreamCallbacks {
41
+ onText?: (text: string) => void;
42
+ onToolUse?: (toolCall: ToolCall) => void;
43
+ onDone?: (response: LLMResponse) => void;
44
+ }
45
+
46
+ /* ─── LLM Provider Interface ────────────────── */
47
+
48
+ /**
49
+ * LLM 프로바이더 추상화 인터페이스.
50
+ *
51
+ * 구현체:
52
+ * - AnthropicProvider: @anthropic-ai/sdk 기반 (기본)
53
+ * - (향후) OpenAIProvider, OllamaProvider, MockProvider
54
+ */
55
+ export interface ChatOptions {
56
+ maxTokens?: number;
57
+ }
58
+
59
+ export interface LLMProvider {
60
+ chat(
61
+ systemPrompt: string,
62
+ messages: LLMMessage[],
63
+ tools?: ToolDefinition[],
64
+ signal?: AbortSignal,
65
+ options?: ChatOptions,
66
+ ): Promise<LLMResponse>;
67
+
68
+ chatStream?(
69
+ systemPrompt: string,
70
+ messages: LLMMessage[],
71
+ tools: ToolDefinition[] | undefined,
72
+ callbacks: StreamCallbacks,
73
+ ): Promise<LLMResponse>;
74
+ }
75
+
76
+ /* ─── Anthropic Provider ─────────────────────── */
77
+
78
+ export class AnthropicProvider implements LLMProvider {
79
+ private client: Anthropic;
80
+ private model: string;
81
+
82
+ constructor(options?: { apiKey?: string; model?: string }) {
83
+ this.client = new Anthropic({
84
+ apiKey: options?.apiKey || process.env.ANTHROPIC_API_KEY,
85
+ });
86
+ this.model = options?.model || process.env.LLM_MODEL || 'claude-sonnet-4-20250514';
87
+ }
88
+
89
+ /**
90
+ * Send a message and get a complete response (non-streaming)
91
+ */
92
+ async chat(
93
+ systemPrompt: string,
94
+ messages: LLMMessage[],
95
+ tools?: ToolDefinition[],
96
+ signal?: AbortSignal,
97
+ options?: ChatOptions,
98
+ ): Promise<LLMResponse> {
99
+ const params: Anthropic.MessageCreateParamsNonStreaming = {
100
+ model: this.model,
101
+ max_tokens: options?.maxTokens ?? 8192,
102
+ system: systemPrompt,
103
+ messages: messages.map((m) => ({
104
+ role: m.role,
105
+ content: m.content as Anthropic.MessageCreateParams['messages'][0]['content'],
106
+ })),
107
+ };
108
+
109
+ if (tools && tools.length > 0) {
110
+ params.tools = tools.map((t) => ({
111
+ name: t.name,
112
+ description: t.description,
113
+ input_schema: t.input_schema as Anthropic.Tool['input_schema'],
114
+ }));
115
+ }
116
+
117
+ const response = await this.client.messages.create(params, { signal });
118
+
119
+ return {
120
+ content: this.mapContent(response.content),
121
+ stopReason: response.stop_reason ?? 'end_turn',
122
+ usage: {
123
+ inputTokens: response.usage.input_tokens,
124
+ outputTokens: response.usage.output_tokens,
125
+ },
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Send a message with streaming (for SSE)
131
+ */
132
+ async chatStream(
133
+ systemPrompt: string,
134
+ messages: LLMMessage[],
135
+ tools: ToolDefinition[] | undefined,
136
+ callbacks: StreamCallbacks,
137
+ ): Promise<LLMResponse> {
138
+ const params: Anthropic.MessageCreateParamsStreaming = {
139
+ model: this.model,
140
+ max_tokens: 8192,
141
+ stream: true,
142
+ system: systemPrompt,
143
+ messages: messages.map((m) => ({
144
+ role: m.role,
145
+ content: m.content as Anthropic.MessageCreateParams['messages'][0]['content'],
146
+ })),
147
+ };
148
+
149
+ if (tools && tools.length > 0) {
150
+ params.tools = tools.map((t) => ({
151
+ name: t.name,
152
+ description: t.description,
153
+ input_schema: t.input_schema as Anthropic.Tool['input_schema'],
154
+ }));
155
+ }
156
+
157
+ const stream = this.client.messages.stream(params);
158
+ const contentBlocks: MessageContent[] = [];
159
+ let currentToolInput = '';
160
+ let currentToolId = '';
161
+ let currentToolName = '';
162
+
163
+ stream.on('text', (text) => {
164
+ callbacks.onText?.(text);
165
+ });
166
+
167
+ stream.on('contentBlock', (block) => {
168
+ if (block.type === 'text') {
169
+ contentBlocks.push({ type: 'text', text: block.text });
170
+ } else if (block.type === 'tool_use') {
171
+ const toolCall: ToolCall = {
172
+ id: block.id,
173
+ name: block.name,
174
+ input: block.input as Record<string, unknown>,
175
+ };
176
+ contentBlocks.push({
177
+ type: 'tool_use',
178
+ id: block.id,
179
+ name: block.name,
180
+ input: block.input as Record<string, unknown>,
181
+ });
182
+ callbacks.onToolUse?.(toolCall);
183
+ }
184
+ });
185
+
186
+ const finalMessage = await stream.finalMessage();
187
+
188
+ const response: LLMResponse = {
189
+ content: this.mapContent(finalMessage.content),
190
+ stopReason: finalMessage.stop_reason ?? 'end_turn',
191
+ usage: {
192
+ inputTokens: finalMessage.usage.input_tokens,
193
+ outputTokens: finalMessage.usage.output_tokens,
194
+ },
195
+ };
196
+
197
+ callbacks.onDone?.(response);
198
+ return response;
199
+ }
200
+
201
+ /* ─── Private helpers ──────────────────────── */
202
+
203
+ private mapContent(blocks: Anthropic.ContentBlock[]): MessageContent[] {
204
+ const result: MessageContent[] = [];
205
+ for (const block of blocks) {
206
+ if (block.type === 'text') {
207
+ result.push({ type: 'text', text: block.text });
208
+ } else if (block.type === 'tool_use') {
209
+ result.push({
210
+ type: 'tool_use',
211
+ id: block.id,
212
+ name: block.name,
213
+ input: block.input as Record<string, unknown>,
214
+ });
215
+ }
216
+ // Skip thinking, redacted_thinking, and other block types
217
+ }
218
+ return result;
219
+ }
220
+ }
221
+
222
+ /* ─── Claude CLI Provider ───────────────────── */
223
+
224
+ /**
225
+ * Claude CLI (`claude -p`)를 LLMProvider로 사용.
226
+ * Claude Max 구독 기반 — API 키 불필요.
227
+ * Chat pipeline (speech) 등 간단한 텍스트 생성에 사용.
228
+ */
229
+ export class ClaudeCliProvider implements LLMProvider {
230
+ private model: string;
231
+
232
+ constructor(options?: { model?: string }) {
233
+ this.model = options?.model || 'claude-haiku-4-5-20251001';
234
+ }
235
+
236
+ async chat(
237
+ systemPrompt: string,
238
+ messages: LLMMessage[],
239
+ tools?: ToolDefinition[],
240
+ signal?: AbortSignal,
241
+ ): Promise<LLMResponse> {
242
+ // Build user message from messages array
243
+ const userText = messages
244
+ .filter(m => m.role === 'user')
245
+ .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(''))
246
+ .join('\n');
247
+
248
+ // When tools are requested, enable claude's built-in Read/Grep/Glob
249
+ const useTools = tools && tools.length > 0;
250
+
251
+ return new Promise((resolve, reject) => {
252
+ const args = [
253
+ '-p',
254
+ '--system-prompt', systemPrompt,
255
+ '--model', this.model,
256
+ '--max-turns', useTools ? '50' : '1',
257
+ '--output-format', 'text',
258
+ ...(useTools ? [
259
+ '--tools', 'Read,Grep,Glob',
260
+ '--dangerously-skip-permissions',
261
+ ] : []),
262
+ userText,
263
+ ];
264
+
265
+ const cleanEnv = { ...process.env };
266
+ delete cleanEnv.CLAUDECODE;
267
+
268
+ const proc = spawn('claude', args, {
269
+ env: cleanEnv,
270
+ stdio: ['ignore', 'pipe', 'pipe'],
271
+ });
272
+
273
+ let stdout = '';
274
+ let stderr = '';
275
+
276
+ proc.stdout.on('data', (data: Buffer) => { stdout += data.toString(); });
277
+ proc.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
278
+
279
+ if (signal) {
280
+ signal.addEventListener('abort', () => proc.kill('SIGTERM'), { once: true });
281
+ }
282
+
283
+ proc.on('close', (code) => {
284
+ const text = stdout.trim();
285
+ if (code !== 0 && !text) {
286
+ reject(new Error(`claude-cli exited with code ${code}: ${stderr}`));
287
+ return;
288
+ }
289
+ resolve({
290
+ content: [{ type: 'text', text }],
291
+ stopReason: 'end_turn',
292
+ usage: { inputTokens: 0, outputTokens: 0 },
293
+ });
294
+ });
295
+
296
+ proc.on('error', reject);
297
+ });
298
+ }
299
+ }
300
+
301
+ /* ─── Backwards Compatibility ────────────────── */
302
+
303
+ /** @deprecated Use AnthropicProvider instead */
304
+ export const LLMAdapter = AnthropicProvider;