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,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
|
+
});
|