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,211 @@
1
+ import { Router, Request, Response, NextFunction } from 'express';
2
+ import { execSync } from 'node:child_process';
3
+ import { COMPANY_ROOT } from '../services/file-reader.js';
4
+ import { resolveCodeRoot } from '../services/company-config.js';
5
+
6
+ export const gitRouter = Router();
7
+
8
+ type RepoType = 'akb' | 'code';
9
+
10
+ interface WorktreeInfo {
11
+ path: string;
12
+ branch: string;
13
+ commitHash: string;
14
+ isMain: boolean;
15
+ }
16
+
17
+ interface LastCommit {
18
+ hash: string;
19
+ message: string;
20
+ date: string;
21
+ }
22
+
23
+ /**
24
+ * Resolve repository root based on repo type
25
+ */
26
+ function resolveRepoRoot(repo: RepoType = 'akb'): string {
27
+ if (repo === 'akb') {
28
+ return COMPANY_ROOT;
29
+ }
30
+ return resolveCodeRoot(COMPANY_ROOT);
31
+ }
32
+
33
+ function git(cmd: string, cwd: string): string {
34
+ return execSync(`git ${cmd}`, { cwd, encoding: 'utf-8', timeout: 5000 }).trim();
35
+ }
36
+
37
+ // GET /api/git/status?repo=akb|code
38
+ gitRouter.get('/status', (req: Request, res: Response, next: NextFunction) => {
39
+ try {
40
+ const repoParam = (req.query.repo as string) ?? 'akb';
41
+ const repo: RepoType = repoParam === 'code' ? 'code' : 'akb';
42
+
43
+ let repoRoot: string;
44
+ try {
45
+ repoRoot = resolveRepoRoot(repo);
46
+ } catch (err) {
47
+ res.status(400).json({
48
+ error: repo === 'code'
49
+ ? 'Code root not configured or inaccessible'
50
+ : 'Company root not found'
51
+ });
52
+ return;
53
+ }
54
+
55
+ // Current branch
56
+ let currentBranch: string;
57
+ try {
58
+ currentBranch = git('rev-parse --abbrev-ref HEAD', repoRoot);
59
+ } catch {
60
+ res.status(500).json({ error: 'Not a git repository or git is not available' });
61
+ return;
62
+ }
63
+
64
+ // Worktrees
65
+ let worktrees: WorktreeInfo[] = [];
66
+ try {
67
+ const raw = git('worktree list --porcelain', repoRoot);
68
+ const blocks = raw.split('\n\n').filter(Boolean);
69
+ for (const block of blocks) {
70
+ const lines = block.split('\n');
71
+ const wtPath = lines.find(l => l.startsWith('worktree '))?.replace('worktree ', '') ?? '';
72
+ const commitHash = lines.find(l => l.startsWith('HEAD '))?.replace('HEAD ', '') ?? '';
73
+ const branchLine = lines.find(l => l.startsWith('branch '));
74
+ const branch = branchLine ? branchLine.replace('branch refs/heads/', '') : '(detached)';
75
+ const isMain = lines.some(l => l === 'worktree ' + repoRoot) ||
76
+ (!branchLine && lines.some(l => l === 'bare'));
77
+ worktrees.push({
78
+ path: wtPath,
79
+ branch,
80
+ commitHash,
81
+ isMain: wtPath === repoRoot,
82
+ });
83
+ }
84
+ } catch {
85
+ worktrees = [];
86
+ }
87
+
88
+ // Stale (unmerged) branches
89
+ let staleBranches: string[] = [];
90
+ try {
91
+ const raw = git('branch --no-merged develop', repoRoot);
92
+ staleBranches = raw
93
+ .split('\n')
94
+ .map(b => b.trim().replace(/^\*\s*/, ''))
95
+ .filter(Boolean);
96
+ } catch {
97
+ staleBranches = [];
98
+ }
99
+
100
+ // Unsaved changes count
101
+ let unsavedChanges = 0;
102
+ try {
103
+ const raw = git('status --porcelain', repoRoot);
104
+ unsavedChanges = raw ? raw.split('\n').filter(Boolean).length : 0;
105
+ } catch {
106
+ unsavedChanges = 0;
107
+ }
108
+
109
+ // Last commit
110
+ let lastCommit: LastCommit | null = null;
111
+ try {
112
+ const raw = git('log -1 --format=%H%n%s%n%aI', repoRoot);
113
+ const [hash, message, date] = raw.split('\n');
114
+ if (hash) {
115
+ lastCommit = { hash, message: message ?? '', date: date ?? '' };
116
+ }
117
+ } catch {
118
+ lastCommit = null;
119
+ }
120
+
121
+ res.json({
122
+ currentBranch,
123
+ worktrees,
124
+ staleBranches,
125
+ unsavedChanges,
126
+ lastCommit,
127
+ });
128
+ } catch (err) {
129
+ next(err);
130
+ }
131
+ });
132
+
133
+ // DELETE /api/git/worktree/:path — Remove a worktree
134
+ gitRouter.delete('/worktree/{*path}', (req: Request, res: Response, next: NextFunction) => {
135
+ try {
136
+ const rawPath = (req.params as Record<string, unknown>).path;
137
+ const worktreePath = Array.isArray(rawPath) ? rawPath.join('/') : String(rawPath ?? '');
138
+ if (!worktreePath) {
139
+ res.status(400).json({ error: 'Worktree path is required' });
140
+ return;
141
+ }
142
+
143
+ try {
144
+ const repoRoot = resolveRepoRoot('code');
145
+ git(`worktree remove ${JSON.stringify(worktreePath)}`, repoRoot);
146
+ } catch {
147
+ // Try force remove if normal remove fails
148
+ const repoRoot = resolveRepoRoot('code');
149
+ git(`worktree remove --force ${JSON.stringify(worktreePath)}`, repoRoot);
150
+ }
151
+
152
+ res.json({ success: true, removed: worktreePath });
153
+ } catch (err) {
154
+ const message = err instanceof Error ? err.message : 'Failed to remove worktree';
155
+ res.status(500).json({ error: message });
156
+ }
157
+ });
158
+
159
+ // DELETE /api/git/branch/:name — Delete a branch (local + remote)
160
+ gitRouter.delete('/branch/{*name}', (req: Request, res: Response, next: NextFunction) => {
161
+ try {
162
+ const rawName = (req.params as Record<string, unknown>).name;
163
+ const branchName = Array.isArray(rawName) ? rawName.join('/') : String(rawName ?? '');
164
+ if (!branchName) {
165
+ res.status(400).json({ error: 'Branch name is required' });
166
+ return;
167
+ }
168
+
169
+ // Prevent deleting main/develop
170
+ if (branchName === 'main' || branchName === 'develop') {
171
+ res.status(403).json({ error: `Cannot delete protected branch: ${branchName}` });
172
+ return;
173
+ }
174
+
175
+ const errors: string[] = [];
176
+
177
+ // Delete local branch
178
+ try {
179
+ const repoRoot = resolveRepoRoot('code');
180
+ git(`branch -d ${JSON.stringify(branchName)}`, repoRoot);
181
+ } catch (err) {
182
+ const msg = err instanceof Error ? err.message : 'Unknown error';
183
+ // If branch is not fully merged, report but continue to try remote
184
+ if (msg.includes('not fully merged')) {
185
+ errors.push(`Local branch not fully merged. Use force delete if intended.`);
186
+ } else if (!msg.includes('not found')) {
187
+ errors.push(`Local: ${msg}`);
188
+ }
189
+ }
190
+
191
+ // Delete remote branch
192
+ try {
193
+ const repoRoot = resolveRepoRoot('code');
194
+ git(`push origin --delete ${JSON.stringify(branchName)}`, repoRoot);
195
+ } catch (err) {
196
+ const msg = err instanceof Error ? err.message : 'Unknown error';
197
+ if (!msg.includes('remote ref does not exist')) {
198
+ errors.push(`Remote: ${msg}`);
199
+ }
200
+ }
201
+
202
+ if (errors.length > 0) {
203
+ res.status(207).json({ success: false, branch: branchName, errors });
204
+ } else {
205
+ res.json({ success: true, deleted: branchName });
206
+ }
207
+ } catch (err) {
208
+ const message = err instanceof Error ? err.message : 'Failed to delete branch';
209
+ res.status(500).json({ error: message });
210
+ }
211
+ });
@@ -0,0 +1,378 @@
1
+ /**
2
+ * knowledge.ts — Knowledge Base API routes
3
+ *
4
+ * GET /api/knowledge — 전체 문서 목록 (frontmatter, TL;DR, cross-links)
5
+ * GET /api/knowledge/* — 단일 문서 (full content, wildcard nested path)
6
+ */
7
+ import { Router, Request, Response, NextFunction } from 'express';
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import matter from 'gray-matter';
11
+ import { glob } from 'glob';
12
+ import { COMPANY_ROOT } from '../services/file-reader.js';
13
+ import { detectDecay, searchRelatedDocs, extractKeywords } from '../engine/knowledge-gate.js';
14
+
15
+ export const knowledgeRouter = Router();
16
+
17
+ function knowledgeDir(): string { return path.join(COMPANY_ROOT, 'knowledge'); }
18
+ function companyRoot(): string { return COMPANY_ROOT; }
19
+
20
+ /* ─── Helpers ─────────────────────────────────────── */
21
+
22
+ function extractTldr(content: string): string {
23
+ // Try > blockquote on the first few lines
24
+ const blockquoteMatch = content.match(/^>\s+(.+)/m);
25
+ if (blockquoteMatch) return blockquoteMatch[1].trim().slice(0, 160);
26
+
27
+ // Try ## TL;DR section
28
+ const tldrSection = content.match(/##\s+TL;DR\s*\n+([^\n#]+)/i);
29
+ if (tldrSection) return tldrSection[1].trim().slice(0, 160);
30
+
31
+ // Fallback: first non-heading, non-empty line
32
+ const firstLine = content
33
+ .split('\n')
34
+ .find((l) => l.trim().length > 0 && !l.startsWith('#') && !l.startsWith('---') && !l.startsWith('|'));
35
+ return firstLine?.trim().slice(0, 160) ?? '';
36
+ }
37
+
38
+ function extractLinks(content: string): { text: string; href: string }[] {
39
+ const matches = [...content.matchAll(/\[([^\]]+)\]\(([^)]+\.md[^)]*)\)/g)];
40
+ return matches.map((m) => ({ text: m[1], href: m[2] })).slice(0, 20);
41
+ }
42
+
43
+ function inferCategory(filePath: string, tags: string[]): string {
44
+ // dir name as category
45
+ const parts = filePath.split('/');
46
+ if (parts.length > 1) return parts[0];
47
+
48
+ // domain/ tag
49
+ const domainTag = tags.find((t) => t.startsWith('domain/'));
50
+ if (domainTag) return domainTag.replace('domain/', '');
51
+
52
+ // keyword fallback from remaining tags
53
+ const knownCategories = ['tech', 'market', 'strategy', 'financial', 'process', 'competitor'];
54
+ for (const tag of tags) {
55
+ if (knownCategories.includes(tag)) return tag;
56
+ }
57
+
58
+ return 'general';
59
+ }
60
+
61
+ /* ─── List endpoint ───────────────────────────────── */
62
+
63
+ knowledgeRouter.get('/', (_req: Request, res: Response, next: NextFunction) => {
64
+ try {
65
+ if (!fs.existsSync(companyRoot())) {
66
+ res.json([]);
67
+ return;
68
+ }
69
+
70
+ const files = glob.sync('**/*.{md,html}', {
71
+ cwd: companyRoot(),
72
+ ignore: [
73
+ 'node_modules/**', '.claude/**', '.obsidian/**', '.tycono/**', '.git/**',
74
+ '**/node_modules/**',
75
+ ],
76
+ })
77
+ .filter((f) => {
78
+ const base = path.basename(f);
79
+ // Exclude hub files (folder-name.md pattern) and CLAUDE.md
80
+ if (base === 'CLAUDE.md') return false;
81
+ return true;
82
+ })
83
+ .sort();
84
+
85
+ const docs = files.map((f) => {
86
+ const absPath = path.join(companyRoot(), f);
87
+ let raw = '';
88
+ try { raw = fs.readFileSync(absPath, 'utf-8'); } catch { return null; }
89
+
90
+ const isHtml = f.endsWith('.html');
91
+ const format: 'md' | 'html' = isHtml ? 'html' : 'md';
92
+
93
+ if (isHtml) {
94
+ // HTML files: extract <title>, no frontmatter
95
+ const titleMatch = raw.match(/<title>([^<]+)<\/title>/i);
96
+ const title = titleMatch ? titleMatch[1].trim() : path.basename(f, '.html');
97
+ const category = inferCategory(f, []);
98
+ return {
99
+ id: f.replace(/\\/g, '/'),
100
+ title,
101
+ akb_type: 'node' as const,
102
+ status: 'active' as const,
103
+ tags: [] as string[],
104
+ category,
105
+ tldr: '',
106
+ links: [] as { text: string; href: string }[],
107
+ format,
108
+ };
109
+ }
110
+
111
+ const { data, content } = matter(raw);
112
+
113
+ const tags: string[] = Array.isArray(data.tags) ? data.tags : [];
114
+ const akbType: 'hub' | 'node' = data.akb_type === 'hub' ? 'hub' : 'node';
115
+ const status: 'active' | 'draft' | 'deprecated' =
116
+ data.status === 'draft' ? 'draft' : data.status === 'deprecated' ? 'deprecated' : 'active';
117
+
118
+ // Title: frontmatter > first # heading > filename
119
+ let title: string = (data.title as string) ?? '';
120
+ if (!title) {
121
+ const headingMatch = content.match(/^#\s+(.+)/m);
122
+ title = headingMatch ? headingMatch[1].trim() : path.basename(f, '.md');
123
+ }
124
+
125
+ const category = inferCategory(f, tags);
126
+ const tldr = extractTldr(content);
127
+ const links = extractLinks(content);
128
+
129
+ return {
130
+ id: f.replace(/\\/g, '/'),
131
+ title,
132
+ akb_type: akbType,
133
+ status,
134
+ tags,
135
+ category,
136
+ tldr,
137
+ links,
138
+ format,
139
+ };
140
+ }).filter(Boolean);
141
+
142
+ res.json(docs);
143
+ } catch (err) {
144
+ next(err);
145
+ }
146
+ });
147
+
148
+ /* ─── Knowledge Health endpoint ──────────────────── */
149
+
150
+ knowledgeRouter.get('/health', (_req: Request, res: Response, next: NextFunction) => {
151
+ try {
152
+ const report = detectDecay(companyRoot());
153
+ res.json(report);
154
+ } catch (err) {
155
+ next(err);
156
+ }
157
+ });
158
+
159
+ /* ─── Related docs search endpoint ──────────────── */
160
+
161
+ knowledgeRouter.get('/related', (req: Request, res: Response, next: NextFunction) => {
162
+ try {
163
+ const query = String(req.query.q ?? '');
164
+ if (!query) {
165
+ res.status(400).json({ error: 'q parameter required' });
166
+ return;
167
+ }
168
+
169
+ const keywords = extractKeywords(query);
170
+ const docs = searchRelatedDocs(companyRoot(), keywords);
171
+ res.json({ keywords, docs });
172
+ } catch (err) {
173
+ next(err);
174
+ }
175
+ });
176
+
177
+ /* ─── Single document endpoint ────────────────────── */
178
+
179
+ /* ─── Create document endpoint ───────────────────── */
180
+
181
+ knowledgeRouter.post('/', (req: Request, res: Response, next: NextFunction) => {
182
+ try {
183
+ const { filename, title, category, content } = req.body as {
184
+ filename?: string;
185
+ title?: string;
186
+ category?: string;
187
+ content?: string;
188
+ };
189
+
190
+ if (!filename || !title) {
191
+ res.status(400).json({ error: 'filename and title required' });
192
+ return;
193
+ }
194
+
195
+ // Sanitize filename
196
+ const safeName = filename.replace(/[^a-zA-Z0-9가-힣_\-. ]/g, '').replace(/\s+/g, '-');
197
+ const fullName = safeName.endsWith('.md') ? safeName : `${safeName}.md`;
198
+ const absPath = path.join(knowledgeDir(), fullName);
199
+
200
+ if (!absPath.startsWith(knowledgeDir())) {
201
+ res.status(403).json({ error: 'Forbidden' });
202
+ return;
203
+ }
204
+
205
+ if (fs.existsSync(absPath)) {
206
+ res.status(409).json({ error: 'Document already exists' });
207
+ return;
208
+ }
209
+
210
+ // Build file with frontmatter
211
+ const frontmatter = {
212
+ title,
213
+ status: 'draft',
214
+ akb_type: 'node',
215
+ tags: category ? [category] : [],
216
+ };
217
+
218
+ const fileContent = matter.stringify(content || `# ${title}\n`, frontmatter);
219
+ fs.mkdirSync(path.dirname(absPath), { recursive: true });
220
+ fs.writeFileSync(absPath, fileContent);
221
+
222
+ res.status(201).json({ id: fullName, title });
223
+ } catch (err) {
224
+ next(err);
225
+ }
226
+ });
227
+
228
+ /* ─── Update document endpoint ───────────────────── */
229
+
230
+ knowledgeRouter.put('/{*path}', (req: Request, res: Response, next: NextFunction) => {
231
+ try {
232
+ const rawPath = (req.params as Record<string, unknown>).path;
233
+ const docId = Array.isArray(rawPath) ? rawPath.join('/') : String(rawPath ?? '');
234
+ if (!docId) {
235
+ res.status(400).json({ error: 'Document ID required' });
236
+ return;
237
+ }
238
+
239
+ const absPath = path.resolve(companyRoot(), docId);
240
+ if (!absPath.startsWith(companyRoot() + path.sep) && absPath !== companyRoot()) {
241
+ res.status(403).json({ error: 'Forbidden' });
242
+ return;
243
+ }
244
+
245
+ if (!fs.existsSync(absPath)) {
246
+ res.status(404).json({ error: `Document not found: ${docId}` });
247
+ return;
248
+ }
249
+
250
+ const { content } = req.body as { content?: string };
251
+ if (content === undefined) {
252
+ res.status(400).json({ error: 'content required' });
253
+ return;
254
+ }
255
+
256
+ // Read existing frontmatter
257
+ const raw = fs.readFileSync(absPath, 'utf-8');
258
+ const { data } = matter(raw);
259
+
260
+ // Preserve frontmatter, update content
261
+ const updated = matter.stringify(content, data);
262
+ fs.writeFileSync(absPath, updated);
263
+
264
+ res.json({ id: docId, status: 'updated' });
265
+ } catch (err) {
266
+ next(err);
267
+ }
268
+ });
269
+
270
+ /* ─── Delete document endpoint ───────────────────── */
271
+
272
+ knowledgeRouter.delete('/{*path}', (req: Request, res: Response, next: NextFunction) => {
273
+ try {
274
+ const rawPath = (req.params as Record<string, unknown>).path;
275
+ const docId = Array.isArray(rawPath) ? rawPath.join('/') : String(rawPath ?? '');
276
+ if (!docId) {
277
+ res.status(400).json({ error: 'Document ID required' });
278
+ return;
279
+ }
280
+
281
+ const absPath = path.resolve(companyRoot(), docId);
282
+ if (!absPath.startsWith(companyRoot() + path.sep) && absPath !== companyRoot()) {
283
+ res.status(403).json({ error: 'Forbidden' });
284
+ return;
285
+ }
286
+
287
+ if (!fs.existsSync(absPath)) {
288
+ res.status(404).json({ error: `Document not found: ${docId}` });
289
+ return;
290
+ }
291
+
292
+ fs.unlinkSync(absPath);
293
+ res.json({ id: docId, status: 'deleted' });
294
+ } catch (err) {
295
+ next(err);
296
+ }
297
+ });
298
+
299
+ /* ─── Single document endpoint ────────────────────── */
300
+
301
+ knowledgeRouter.get('/{*path}', (req: Request, res: Response, next: NextFunction) => {
302
+ try {
303
+ const rawPath = (req.params as Record<string, unknown>).path;
304
+ const docId = Array.isArray(rawPath) ? rawPath.join('/') : String(rawPath ?? '');
305
+ if (!docId) {
306
+ res.status(400).json({ error: 'Document ID required' });
307
+ return;
308
+ }
309
+
310
+ const absPath = path.resolve(companyRoot(), docId);
311
+
312
+ // Security: ensure path stays within companyRoot
313
+ if (!absPath.startsWith(companyRoot() + path.sep) && absPath !== companyRoot()) {
314
+ res.status(403).json({ error: 'Forbidden' });
315
+ return;
316
+ }
317
+
318
+ if (!fs.existsSync(absPath)) {
319
+ res.status(404).json({ error: `Document not found: ${docId}` });
320
+ return;
321
+ }
322
+
323
+ const raw = fs.readFileSync(absPath, 'utf-8');
324
+ const isHtml = docId.endsWith('.html');
325
+ const format: 'md' | 'html' = isHtml ? 'html' : 'md';
326
+
327
+ if (isHtml) {
328
+ const titleMatch = raw.match(/<title>([^<]+)<\/title>/i);
329
+ const title = titleMatch ? titleMatch[1].trim() : path.basename(docId, '.html');
330
+ const category = inferCategory(docId, []);
331
+ res.json({
332
+ id: docId,
333
+ title,
334
+ akb_type: 'node',
335
+ status: 'active',
336
+ tags: [],
337
+ category,
338
+ tldr: '',
339
+ links: [],
340
+ content: raw,
341
+ format,
342
+ });
343
+ return;
344
+ }
345
+
346
+ const { data, content } = matter(raw);
347
+
348
+ const tags: string[] = Array.isArray(data.tags) ? data.tags : [];
349
+ const akbType: 'hub' | 'node' = data.akb_type === 'hub' ? 'hub' : 'node';
350
+ const status: 'active' | 'draft' | 'deprecated' =
351
+ data.status === 'draft' ? 'draft' : data.status === 'deprecated' ? 'deprecated' : 'active';
352
+
353
+ let title: string = (data.title as string) ?? '';
354
+ if (!title) {
355
+ const headingMatch = content.match(/^#\s+(.+)/m);
356
+ title = headingMatch ? headingMatch[1].trim() : path.basename(docId, '.md');
357
+ }
358
+
359
+ const category = inferCategory(docId, tags);
360
+ const tldr = extractTldr(content);
361
+ const links = extractLinks(content);
362
+
363
+ res.json({
364
+ id: docId,
365
+ title,
366
+ akb_type: akbType,
367
+ status,
368
+ tags,
369
+ category,
370
+ tldr,
371
+ links,
372
+ content,
373
+ format,
374
+ });
375
+ } catch (err) {
376
+ next(err);
377
+ }
378
+ });