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,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;
|