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,466 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* knowledge-importer.ts — AKB-aware document import service
|
|
3
|
+
*
|
|
4
|
+
* Scans directories for documents and creates AKB-formatted knowledge files.
|
|
5
|
+
* Processing priority: frontmatter → LLMProvider → claude -p CLI → simple fallback
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { execFile, execFileSync } from 'node:child_process';
|
|
10
|
+
import matter from 'gray-matter';
|
|
11
|
+
import type { LLMProvider } from '../engine/llm-adapter.js';
|
|
12
|
+
|
|
13
|
+
/* ─── Types ──────────────────────────────────── */
|
|
14
|
+
|
|
15
|
+
export interface ImportCallbacks {
|
|
16
|
+
onScanning: (scanPath: string, fileCount: number) => void;
|
|
17
|
+
onProcessing: (file: string, index: number, total: number) => void;
|
|
18
|
+
onCreated: (filePath: string, title: string, summary: string) => void;
|
|
19
|
+
onSkipped: (file: string, reason: string) => void;
|
|
20
|
+
onDone: (stats: { imported: number; created: number; skipped: number }) => void;
|
|
21
|
+
onError: (message: string) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface DocumentResult {
|
|
25
|
+
category: string;
|
|
26
|
+
title: string;
|
|
27
|
+
summary: string;
|
|
28
|
+
content: string;
|
|
29
|
+
akbType: 'hub' | 'node';
|
|
30
|
+
tags: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/* ─── Constants ──────────────────────────────── */
|
|
34
|
+
|
|
35
|
+
const SUPPORTED_EXTENSIONS = new Set(['.md', '.txt', '.json', '.yaml', '.yml', '.csv']);
|
|
36
|
+
const MAX_FILE_SIZE = 100_000; // 100KB
|
|
37
|
+
const MAX_CONTENT_FOR_LLM = 8_000; // chars to send to LLM
|
|
38
|
+
|
|
39
|
+
const CLASSIFY_PROMPT = `You are a knowledge organizer. Given a document, respond ONLY in JSON (no markdown fences):
|
|
40
|
+
{
|
|
41
|
+
"category": "market|tech|process|domain|competitor|financial|general",
|
|
42
|
+
"title": "Short Title (max 60 chars)",
|
|
43
|
+
"summary": "One-line TL;DR (max 120 chars)",
|
|
44
|
+
"content": "# Title\\n\\n> TL;DR summary\\n\\n---\\n\\n(reformatted content in markdown)",
|
|
45
|
+
"akb_type": "hub|node",
|
|
46
|
+
"tags": ["tag1", "tag2"]
|
|
47
|
+
}`;
|
|
48
|
+
|
|
49
|
+
/* ─── File Collection ────────────────────────── */
|
|
50
|
+
|
|
51
|
+
function collectFiles(dirPath: string): string[] {
|
|
52
|
+
const files: string[] = [];
|
|
53
|
+
|
|
54
|
+
function walk(dir: string) {
|
|
55
|
+
let entries: fs.Dirent[];
|
|
56
|
+
try {
|
|
57
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
58
|
+
} catch {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
63
|
+
const full = path.join(dir, entry.name);
|
|
64
|
+
if (entry.isDirectory()) {
|
|
65
|
+
walk(full);
|
|
66
|
+
} else if (entry.isFile()) {
|
|
67
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
68
|
+
if (SUPPORTED_EXTENSIONS.has(ext)) {
|
|
69
|
+
try {
|
|
70
|
+
const stat = fs.statSync(full);
|
|
71
|
+
if (stat.size <= MAX_FILE_SIZE && stat.size > 0) {
|
|
72
|
+
files.push(full);
|
|
73
|
+
}
|
|
74
|
+
} catch { /* skip */ }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
walk(dirPath);
|
|
81
|
+
return files;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/* ─── Frontmatter-based processing (highest priority) ─── */
|
|
85
|
+
|
|
86
|
+
function extractTldr(content: string): string {
|
|
87
|
+
const blockquote = content.match(/^>\s+(.+)/m);
|
|
88
|
+
if (blockquote) return blockquote[1].trim().slice(0, 120);
|
|
89
|
+
|
|
90
|
+
const tldrSection = content.match(/##\s+TL;DR\s*\n+([^\n#]+)/i);
|
|
91
|
+
if (tldrSection) return tldrSection[1].trim().slice(0, 120);
|
|
92
|
+
|
|
93
|
+
const firstLine = content.split('\n').find(
|
|
94
|
+
(l) => l.trim().length > 0 && !l.startsWith('#') && !l.startsWith('---') && !l.startsWith('|')
|
|
95
|
+
);
|
|
96
|
+
return firstLine?.trim().slice(0, 120) ?? '';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function extractFrontmatterCategory(filePath: string): DocumentResult | null {
|
|
100
|
+
let raw: string;
|
|
101
|
+
try {
|
|
102
|
+
raw = fs.readFileSync(filePath, 'utf-8');
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (raw.trim().length < 20) return null;
|
|
108
|
+
|
|
109
|
+
// Only parse if has frontmatter
|
|
110
|
+
if (!raw.startsWith('---')) return null;
|
|
111
|
+
|
|
112
|
+
const { data, content } = matter(raw);
|
|
113
|
+
|
|
114
|
+
// Need at least one AKB field
|
|
115
|
+
if (!data.title && !data.akb_type && !data.tags && !data.domain) return null;
|
|
116
|
+
|
|
117
|
+
const tags: string[] = Array.isArray(data.tags) ? data.tags : [];
|
|
118
|
+
|
|
119
|
+
// Determine category from tags or domain field
|
|
120
|
+
const domainTag = tags.find((t: string) => t.startsWith('domain/'));
|
|
121
|
+
const category: string = domainTag
|
|
122
|
+
? domainTag.replace('domain/', '')
|
|
123
|
+
: (data.domain as string) || 'general';
|
|
124
|
+
|
|
125
|
+
// Title from frontmatter > first heading
|
|
126
|
+
let title: string = (data.title as string) ?? '';
|
|
127
|
+
if (!title) {
|
|
128
|
+
const match = content.match(/^#\s+(.+)/m);
|
|
129
|
+
title = match ? match[1].trim() : path.basename(filePath, path.extname(filePath));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const akbType: 'hub' | 'node' = data.akb_type === 'hub' ? 'hub' : 'node';
|
|
133
|
+
const summary = (data.summary as string) || (data.tldr as string) || extractTldr(content);
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
category,
|
|
137
|
+
title,
|
|
138
|
+
summary,
|
|
139
|
+
content: raw, // preserve original content with frontmatter
|
|
140
|
+
akbType,
|
|
141
|
+
tags,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/* ─── LLM Processing via claude -p ───────────── */
|
|
146
|
+
|
|
147
|
+
async function processDocumentWithCli(filePath: string): Promise<DocumentResult | null> {
|
|
148
|
+
let content: string;
|
|
149
|
+
try {
|
|
150
|
+
content = fs.readFileSync(filePath, 'utf-8');
|
|
151
|
+
} catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (content.trim().length < 20) return null;
|
|
156
|
+
|
|
157
|
+
const truncated = content.length > MAX_CONTENT_FOR_LLM
|
|
158
|
+
? content.slice(0, MAX_CONTENT_FOR_LLM) + '\n\n[... truncated]'
|
|
159
|
+
: content;
|
|
160
|
+
|
|
161
|
+
const fileName = path.basename(filePath);
|
|
162
|
+
const userMessage = `File: ${fileName}\n\n${truncated}`;
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const env = { ...process.env };
|
|
166
|
+
delete env.CLAUDECODE;
|
|
167
|
+
|
|
168
|
+
const result = await new Promise<string>((resolve, reject) => {
|
|
169
|
+
execFile('claude', [
|
|
170
|
+
'-p',
|
|
171
|
+
'--system-prompt', CLASSIFY_PROMPT,
|
|
172
|
+
'--output-format', 'text',
|
|
173
|
+
'--model', 'claude-haiku-4-5-20251001',
|
|
174
|
+
'--max-turns', '1',
|
|
175
|
+
userMessage,
|
|
176
|
+
], {
|
|
177
|
+
timeout: 30_000,
|
|
178
|
+
env,
|
|
179
|
+
encoding: 'utf-8',
|
|
180
|
+
maxBuffer: 1024 * 1024,
|
|
181
|
+
}, (err, stdout) => {
|
|
182
|
+
if (err) reject(err);
|
|
183
|
+
else resolve(stdout);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const jsonMatch = result.match(/\{[\s\S]*\}/);
|
|
188
|
+
if (!jsonMatch) return null;
|
|
189
|
+
|
|
190
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
191
|
+
const akbType: 'hub' | 'node' = parsed.akb_type === 'hub' ? 'hub' : 'node';
|
|
192
|
+
const tags: string[] = Array.isArray(parsed.tags) ? parsed.tags : [];
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
category: parsed.category || 'general',
|
|
196
|
+
title: parsed.title || fileName.replace(/\.[^.]+$/, ''),
|
|
197
|
+
summary: parsed.summary || '',
|
|
198
|
+
content: parsed.content || content,
|
|
199
|
+
akbType,
|
|
200
|
+
tags,
|
|
201
|
+
};
|
|
202
|
+
} catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/* ─── LLM Processing via LLMProvider interface ── */
|
|
208
|
+
|
|
209
|
+
async function processDocumentWithLLM(filePath: string, llm: LLMProvider): Promise<DocumentResult | null> {
|
|
210
|
+
let content: string;
|
|
211
|
+
try {
|
|
212
|
+
content = fs.readFileSync(filePath, 'utf-8');
|
|
213
|
+
} catch {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (content.trim().length < 20) return null;
|
|
218
|
+
|
|
219
|
+
const truncated = content.length > MAX_CONTENT_FOR_LLM
|
|
220
|
+
? content.slice(0, MAX_CONTENT_FOR_LLM) + '\n\n[... truncated]'
|
|
221
|
+
: content;
|
|
222
|
+
|
|
223
|
+
const fileName = path.basename(filePath);
|
|
224
|
+
const userMessage = `File: ${fileName}\n\n${truncated}`;
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
const response = await llm.chat(
|
|
228
|
+
CLASSIFY_PROMPT,
|
|
229
|
+
[{ role: 'user', content: userMessage }],
|
|
230
|
+
undefined,
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
const textBlock = response.content.find((b) => b.type === 'text');
|
|
234
|
+
if (!textBlock || textBlock.type !== 'text') return null;
|
|
235
|
+
|
|
236
|
+
const jsonMatch = (textBlock as { type: 'text'; text: string }).text.match(/\{[\s\S]*\}/);
|
|
237
|
+
if (!jsonMatch) return null;
|
|
238
|
+
|
|
239
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
240
|
+
const akbType: 'hub' | 'node' = parsed.akb_type === 'hub' ? 'hub' : 'node';
|
|
241
|
+
const tags: string[] = Array.isArray(parsed.tags) ? parsed.tags : [];
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
category: parsed.category || 'general',
|
|
245
|
+
title: parsed.title || fileName.replace(/\.[^.]+$/, ''),
|
|
246
|
+
summary: parsed.summary || '',
|
|
247
|
+
content: parsed.content || content,
|
|
248
|
+
akbType,
|
|
249
|
+
tags,
|
|
250
|
+
};
|
|
251
|
+
} catch {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Fallback: import without LLM classification */
|
|
257
|
+
function processDocumentSimple(filePath: string): DocumentResult | null {
|
|
258
|
+
let content: string;
|
|
259
|
+
try {
|
|
260
|
+
content = fs.readFileSync(filePath, 'utf-8');
|
|
261
|
+
} catch {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (content.trim().length < 20) return null;
|
|
266
|
+
|
|
267
|
+
const fileName = path.basename(filePath, path.extname(filePath));
|
|
268
|
+
const title = fileName.replace(/[-_]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
269
|
+
const summary = extractTldr(content);
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
category: 'general',
|
|
273
|
+
title,
|
|
274
|
+
summary,
|
|
275
|
+
content,
|
|
276
|
+
akbType: 'node',
|
|
277
|
+
tags: [],
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/* ─── Build AKB frontmatter content ─────────── */
|
|
282
|
+
|
|
283
|
+
function buildAkbContent(result: DocumentResult, sourceFile: string): string {
|
|
284
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
285
|
+
|
|
286
|
+
// If content already has frontmatter, keep it as-is (just add source footer)
|
|
287
|
+
if (result.content.startsWith('---')) {
|
|
288
|
+
return result.content + `\n\n---\n\n*Source: ${path.basename(sourceFile)}*\n*Imported: ${date}*\n`;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Build AKB frontmatter
|
|
292
|
+
const fm = [
|
|
293
|
+
'---',
|
|
294
|
+
`title: "${result.title.replace(/"/g, "'")}"`,
|
|
295
|
+
`akb_type: ${result.akbType}`,
|
|
296
|
+
`status: active`,
|
|
297
|
+
`tags: [${result.tags.map(t => `"${t}"`).join(', ')}]`,
|
|
298
|
+
`domain: ${result.category}`,
|
|
299
|
+
'---',
|
|
300
|
+
'',
|
|
301
|
+
].join('\n');
|
|
302
|
+
|
|
303
|
+
const body = result.content.startsWith('#')
|
|
304
|
+
? result.content
|
|
305
|
+
: `# ${result.title}\n\n${result.summary ? `> ${result.summary}\n\n` : ''}${result.content}`;
|
|
306
|
+
|
|
307
|
+
return fm + body + `\n\n---\n\n*Source: ${path.basename(sourceFile)}*\n*Imported: ${date}*\n`;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/* ─── Check if claude CLI is available ───────── */
|
|
311
|
+
|
|
312
|
+
function isClaudeCliAvailable(): boolean {
|
|
313
|
+
try {
|
|
314
|
+
const env = { ...process.env };
|
|
315
|
+
delete env.CLAUDECODE;
|
|
316
|
+
execFileSync('claude', ['--version'], { timeout: 5000, env, encoding: 'utf-8' });
|
|
317
|
+
return true;
|
|
318
|
+
} catch {
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/* ─── Main Import Function ───────────────────── */
|
|
324
|
+
|
|
325
|
+
export async function importKnowledge(
|
|
326
|
+
paths: string[],
|
|
327
|
+
companyRoot: string,
|
|
328
|
+
callbacks: ImportCallbacks,
|
|
329
|
+
llm?: LLMProvider,
|
|
330
|
+
): Promise<void> {
|
|
331
|
+
const useCli = !llm && isClaudeCliAvailable();
|
|
332
|
+
const allFiles: string[] = [];
|
|
333
|
+
|
|
334
|
+
// Phase 1: Scan
|
|
335
|
+
for (const p of paths) {
|
|
336
|
+
const resolved = path.resolve(p);
|
|
337
|
+
if (!fs.existsSync(resolved)) {
|
|
338
|
+
callbacks.onError(`Path not found: ${p}`);
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const stat = fs.statSync(resolved);
|
|
343
|
+
if (stat.isDirectory()) {
|
|
344
|
+
const found = collectFiles(resolved);
|
|
345
|
+
allFiles.push(...found);
|
|
346
|
+
callbacks.onScanning(p, found.length);
|
|
347
|
+
} else if (stat.isFile()) {
|
|
348
|
+
allFiles.push(resolved);
|
|
349
|
+
callbacks.onScanning(p, 1);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (allFiles.length === 0) {
|
|
354
|
+
callbacks.onDone({ imported: 0, created: 0, skipped: 0 });
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Phase 2: Process each file
|
|
359
|
+
const knowledgeDir = path.join(companyRoot, 'knowledge');
|
|
360
|
+
fs.mkdirSync(knowledgeDir, { recursive: true });
|
|
361
|
+
|
|
362
|
+
// Load existing hub to skip duplicates
|
|
363
|
+
const hubPath = path.join(knowledgeDir, 'knowledge.md');
|
|
364
|
+
const existingHubContent = fs.existsSync(hubPath) ? fs.readFileSync(hubPath, 'utf-8') : '';
|
|
365
|
+
|
|
366
|
+
let created = 0;
|
|
367
|
+
let skipped = 0;
|
|
368
|
+
const hubEntries: { category: string; title: string; summary: string; filePath: string }[] = [];
|
|
369
|
+
|
|
370
|
+
for (let i = 0; i < allFiles.length; i++) {
|
|
371
|
+
const file = allFiles[i];
|
|
372
|
+
callbacks.onProcessing(path.basename(file), i + 1, allFiles.length);
|
|
373
|
+
|
|
374
|
+
// Processing priority: frontmatter → LLMProvider → CLI → simple fallback
|
|
375
|
+
let result: DocumentResult | null = extractFrontmatterCategory(file);
|
|
376
|
+
if (!result && llm) {
|
|
377
|
+
result = await processDocumentWithLLM(file, llm);
|
|
378
|
+
}
|
|
379
|
+
if (!result && useCli) {
|
|
380
|
+
result = await processDocumentWithCli(file);
|
|
381
|
+
}
|
|
382
|
+
if (!result) {
|
|
383
|
+
result = processDocumentSimple(file);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (!result) {
|
|
387
|
+
skipped++;
|
|
388
|
+
callbacks.onSkipped(path.basename(file), 'Too short or unreadable');
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Ensure category directory exists
|
|
393
|
+
const categoryDir = path.join(knowledgeDir, result.category);
|
|
394
|
+
fs.mkdirSync(categoryDir, { recursive: true });
|
|
395
|
+
|
|
396
|
+
// Generate safe filename
|
|
397
|
+
const baseName = path.basename(file, path.extname(file));
|
|
398
|
+
const safeName = baseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
|
399
|
+
const outPath = path.join(categoryDir, `${safeName}.md`);
|
|
400
|
+
const relativePath = `knowledge/${result.category}/${safeName}.md`;
|
|
401
|
+
|
|
402
|
+
// Skip if already linked in hub (duplicate prevention)
|
|
403
|
+
if (existingHubContent.includes(relativePath)) {
|
|
404
|
+
skipped++;
|
|
405
|
+
callbacks.onSkipped(path.basename(file), 'Already imported');
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const finalContent = buildAkbContent(result, file);
|
|
410
|
+
fs.writeFileSync(outPath, finalContent);
|
|
411
|
+
created++;
|
|
412
|
+
|
|
413
|
+
hubEntries.push({
|
|
414
|
+
category: result.category,
|
|
415
|
+
title: result.title,
|
|
416
|
+
summary: result.summary,
|
|
417
|
+
filePath: relativePath,
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
callbacks.onCreated(relativePath, result.title, result.summary);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Phase 3: Update knowledge.md hub
|
|
424
|
+
updateKnowledgeHub(companyRoot, hubEntries, existingHubContent);
|
|
425
|
+
|
|
426
|
+
callbacks.onDone({ imported: allFiles.length, created, skipped });
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/* ─── Hub Updater ────────────────────────────── */
|
|
430
|
+
|
|
431
|
+
function updateKnowledgeHub(
|
|
432
|
+
companyRoot: string,
|
|
433
|
+
entries: { category: string; title: string; summary: string; filePath: string }[],
|
|
434
|
+
existingContent: string,
|
|
435
|
+
) {
|
|
436
|
+
if (entries.length === 0) return;
|
|
437
|
+
|
|
438
|
+
const hubPath = path.join(companyRoot, 'knowledge', 'knowledge.md');
|
|
439
|
+
let content = existingContent || '# Knowledge Base\n\nDomain knowledge.\n';
|
|
440
|
+
|
|
441
|
+
// Remove previous "## Imported Knowledge" section to avoid duplication
|
|
442
|
+
const importedIdx = content.indexOf('\n## Imported Knowledge');
|
|
443
|
+
if (importedIdx !== -1) {
|
|
444
|
+
content = content.slice(0, importedIdx);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Group by category
|
|
448
|
+
const byCategory = new Map<string, typeof entries>();
|
|
449
|
+
for (const entry of entries) {
|
|
450
|
+
const list = byCategory.get(entry.category) || [];
|
|
451
|
+
list.push(entry);
|
|
452
|
+
byCategory.set(entry.category, list);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
content += '\n## Imported Knowledge\n\n';
|
|
456
|
+
|
|
457
|
+
for (const [category, items] of byCategory) {
|
|
458
|
+
content += `### ${category.charAt(0).toUpperCase() + category.slice(1)}\n\n`;
|
|
459
|
+
for (const item of items) {
|
|
460
|
+
content += `- [${item.title}](${item.filePath})${item.summary ? ` — ${item.summary}` : ''}\n`;
|
|
461
|
+
}
|
|
462
|
+
content += '\n';
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
fs.writeFileSync(hubPath, content);
|
|
466
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown 테이블을 JSON 배열로 변환하는 파서.
|
|
3
|
+
*
|
|
4
|
+
* roles.md, projects.md, tasks.md 등의 테이블 구조를 파싱한다.
|
|
5
|
+
* 헤더 행 + 구분선(---|---) + 데이터 행 패턴 처리.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Markdown 문자열에서 첫 번째 테이블을 찾아 객체 배열로 변환한다.
|
|
10
|
+
*/
|
|
11
|
+
export function parseMarkdownTable(content: string): Record<string, string>[] {
|
|
12
|
+
const lines = content.split('\n');
|
|
13
|
+
const result: Record<string, string>[] = [];
|
|
14
|
+
|
|
15
|
+
let headers: string[] | null = null;
|
|
16
|
+
let inTable = false;
|
|
17
|
+
|
|
18
|
+
for (const line of lines) {
|
|
19
|
+
const trimmed = line.trim();
|
|
20
|
+
if (!trimmed.startsWith('|')) {
|
|
21
|
+
if (inTable) break; // 테이블 끝
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const cells = parsePipeLine(trimmed);
|
|
26
|
+
|
|
27
|
+
// 구분선 (|---|---|---| 패턴) 건너뛰기
|
|
28
|
+
if (cells.every(c => /^[-: ]+$/.test(c))) {
|
|
29
|
+
inTable = true;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!headers) {
|
|
34
|
+
headers = cells.map(normalizeHeader);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!inTable) continue;
|
|
39
|
+
|
|
40
|
+
const row: Record<string, string> = {};
|
|
41
|
+
headers.forEach((header, i) => {
|
|
42
|
+
row[header] = cleanCellValue(cells[i] ?? '');
|
|
43
|
+
});
|
|
44
|
+
result.push(row);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Markdown 문자열에서 모든 테이블을 객체 배열로 변환한다.
|
|
52
|
+
*/
|
|
53
|
+
export function parseAllMarkdownTables(content: string): Record<string, string>[][] {
|
|
54
|
+
const lines = content.split('\n');
|
|
55
|
+
const tables: Record<string, string>[][] = [];
|
|
56
|
+
|
|
57
|
+
let headers: string[] | null = null;
|
|
58
|
+
let currentTable: Record<string, string>[] = [];
|
|
59
|
+
let inTable = false;
|
|
60
|
+
|
|
61
|
+
for (const line of lines) {
|
|
62
|
+
const trimmed = line.trim();
|
|
63
|
+
if (!trimmed.startsWith('|')) {
|
|
64
|
+
if (inTable) {
|
|
65
|
+
tables.push(currentTable);
|
|
66
|
+
currentTable = [];
|
|
67
|
+
headers = null;
|
|
68
|
+
inTable = false;
|
|
69
|
+
}
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const cells = parsePipeLine(trimmed);
|
|
74
|
+
|
|
75
|
+
if (cells.every(c => /^[-: ]+$/.test(c))) {
|
|
76
|
+
inTable = true;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!headers) {
|
|
81
|
+
headers = cells.map(normalizeHeader);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!inTable) continue;
|
|
86
|
+
|
|
87
|
+
const row: Record<string, string> = {};
|
|
88
|
+
headers.forEach((header, i) => {
|
|
89
|
+
row[header] = cleanCellValue(cells[i] ?? '');
|
|
90
|
+
});
|
|
91
|
+
currentTable.push(row);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (inTable && currentTable.length > 0) {
|
|
95
|
+
tables.push(currentTable);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return tables;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Markdown 본문에서 특정 ## 섹션의 내용을 추출한다.
|
|
103
|
+
*/
|
|
104
|
+
export function extractSection(content: string, sectionName: string): string | null {
|
|
105
|
+
const lines = content.split('\n');
|
|
106
|
+
let capturing = false;
|
|
107
|
+
const captured: string[] = [];
|
|
108
|
+
|
|
109
|
+
for (const line of lines) {
|
|
110
|
+
if (line.match(new RegExp(`^##\\s+${escapeRegex(sectionName)}`, 'i'))) {
|
|
111
|
+
capturing = true;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (capturing && /^##\s+/.test(line)) {
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
if (capturing) {
|
|
118
|
+
captured.push(line);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (captured.length === 0) return null;
|
|
123
|
+
return captured.join('\n').trim();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Markdown 리스트 아이템을 문자열 배열로 추출한다.
|
|
128
|
+
*/
|
|
129
|
+
export function extractListItems(content: string): string[] {
|
|
130
|
+
return content
|
|
131
|
+
.split('\n')
|
|
132
|
+
.filter(line => /^\s*[-*]\s+/.test(line))
|
|
133
|
+
.map(line => line.replace(/^\s*[-*]\s+/, '').trim());
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Markdown bold 패턴에서 key-value를 추출한다.
|
|
138
|
+
* 예: "**도메인**: AI SaaS" → { key: "도메인", value: "AI SaaS" }
|
|
139
|
+
*/
|
|
140
|
+
export function extractBoldKeyValues(content: string): Record<string, string> {
|
|
141
|
+
const result: Record<string, string> = {};
|
|
142
|
+
const regex = /\*\*(.+?)\*\*:\s*(.+)/g;
|
|
143
|
+
let match: RegExpExecArray | null;
|
|
144
|
+
while ((match = regex.exec(content)) !== null) {
|
|
145
|
+
result[match[1].trim()] = match[2].trim();
|
|
146
|
+
}
|
|
147
|
+
return result;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// --- Internal helpers ---
|
|
151
|
+
|
|
152
|
+
function parsePipeLine(line: string): string[] {
|
|
153
|
+
return line
|
|
154
|
+
.split('|')
|
|
155
|
+
.slice(1, -1) // 양끝 빈 문자열 제거
|
|
156
|
+
.map(cell => cell.trim());
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function normalizeHeader(header: string): string {
|
|
160
|
+
return header
|
|
161
|
+
.toLowerCase()
|
|
162
|
+
.replace(/\s+/g, '_')
|
|
163
|
+
.replace(/[^a-z0-9_가-힣]/g, '');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function cleanCellValue(value: string): string {
|
|
167
|
+
// Markdown 링크 [text](url) → text 추출
|
|
168
|
+
return value.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').trim();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function escapeRegex(str: string): string {
|
|
172
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
173
|
+
}
|