@team-semicolon/semo-cli 4.12.0 → 4.15.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.
@@ -0,0 +1,283 @@
1
+ "use strict";
2
+ /**
3
+ * semo service — 서비스 관리
4
+ *
5
+ * semo service migrate --domain {name} — 특정 서비스 이식
6
+ * semo service migrate --all — 미등록 전체 이식
7
+ * semo service migrate --all --dry-run — 미리보기
8
+ * semo service list — 등록/미등록 서비스 목록
9
+ */
10
+ var __importDefault = (this && this.__importDefault) || function (mod) {
11
+ return (mod && mod.__esModule) ? mod : { "default": mod };
12
+ };
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.registerServiceCommands = registerServiceCommands;
15
+ const chalk_1 = __importDefault(require("chalk"));
16
+ const ora_1 = __importDefault(require("ora"));
17
+ const database_1 = require("../database");
18
+ const service_migrate_1 = require("../service-migrate");
19
+ function registerServiceCommands(program) {
20
+ const service = program
21
+ .command("service")
22
+ .description("서비스 관리 — 이식, 목록 조회, 진단, 업데이트");
23
+ // ── semo service migrate ──
24
+ service
25
+ .command("migrate")
26
+ .description("기존 서비스를 services 테이블에 이식")
27
+ .option("--domain <name>", "특정 서비스 도메인")
28
+ .option("--all", "미등록 서비스 전체 이식")
29
+ .option("--dry-run", "미리보기 (DB 변경 없음)")
30
+ .action(async (options) => {
31
+ if (!options.domain && !options.all) {
32
+ console.log(chalk_1.default.yellow("--domain <name> 또는 --all 옵션이 필요합니다."));
33
+ await (0, database_1.closeConnection)();
34
+ return;
35
+ }
36
+ const pool = (0, database_1.getPool)();
37
+ const spinner = (0, ora_1.default)("서비스 이식 준비 중...").start();
38
+ try {
39
+ // 1. 미등록 서비스 조회
40
+ const unregistered = await (0, service_migrate_1.getUnregisteredServices)(pool);
41
+ if (unregistered.length === 0) {
42
+ spinner.succeed("모든 서비스가 이미 등록되어 있습니다.");
43
+ await (0, database_1.closeConnection)();
44
+ return;
45
+ }
46
+ // 2. 대상 필터링
47
+ let targets = unregistered;
48
+ if (options.domain) {
49
+ targets = unregistered.filter((s) => s.domain === options.domain);
50
+ if (targets.length === 0) {
51
+ // 도메인이 있는데 미등록 목록에 없으면 → 이미 등록됨 또는 미존재
52
+ const registered = await (0, service_migrate_1.getRegisteredServices)(pool);
53
+ if (registered.includes(options.domain)) {
54
+ spinner.info(`'${options.domain}'은(는) 이미 services에 등록되어 있습니다.`);
55
+ }
56
+ else {
57
+ spinner.fail(`'${options.domain}'은(는) 온톨로지에 service 타입으로 등록되지 않았습니다.`);
58
+ }
59
+ await (0, database_1.closeConnection)();
60
+ return;
61
+ }
62
+ }
63
+ spinner.text = `${targets.length}개 서비스 감사(audit) 중...`;
64
+ // 3. 각 서비스 audit
65
+ const results = [];
66
+ for (const svc of targets) {
67
+ const audit = await (0, service_migrate_1.auditServiceKBEntries)(pool, svc.domain, svc.description, svc.created_at);
68
+ if (options.dryRun) {
69
+ results.push({ domain: svc.domain, action: "skipped", audit });
70
+ continue;
71
+ }
72
+ // 4. INSERT
73
+ try {
74
+ const row = (0, service_migrate_1.buildServiceProjectRow)(audit);
75
+ // KB 엔트리 수 조회
76
+ const countResult = await pool.query("SELECT COUNT(*)::int as cnt FROM semo.knowledge_base WHERE domain = $1", [svc.domain]);
77
+ const kbEntryCount = countResult.rows[0]?.cnt ?? 0;
78
+ const projectId = await (0, service_migrate_1.insertServiceProject)(pool, row);
79
+ // 5. pm-summary projection 쓰기
80
+ await (0, service_migrate_1.writePmSummaryToKB)(pool, svc.domain, projectId, row, kbEntryCount);
81
+ results.push({
82
+ domain: svc.domain,
83
+ action: "created",
84
+ projectId,
85
+ audit,
86
+ });
87
+ }
88
+ catch (err) {
89
+ results.push({
90
+ domain: svc.domain,
91
+ action: "error",
92
+ audit,
93
+ error: err.message,
94
+ });
95
+ }
96
+ }
97
+ spinner.stop();
98
+ // 6. 결과 리포트
99
+ if (options.dryRun) {
100
+ (0, service_migrate_1.printDryRunReport)(results);
101
+ }
102
+ else {
103
+ (0, service_migrate_1.printAuditReport)(results);
104
+ }
105
+ }
106
+ catch (err) {
107
+ spinner.fail(`이식 실패: ${err.message}`);
108
+ }
109
+ finally {
110
+ await (0, database_1.closeConnection)();
111
+ }
112
+ });
113
+ // ── semo service list ──
114
+ service
115
+ .command("list")
116
+ .description("서비스 목록 (등록/미등록 상태 포함)")
117
+ .action(async () => {
118
+ const pool = (0, database_1.getPool)();
119
+ try {
120
+ const registered = await (0, service_migrate_1.getRegisteredServices)(pool);
121
+ const unregistered = await (0, service_migrate_1.getUnregisteredServices)(pool);
122
+ console.log(chalk_1.default.cyan.bold("\n📋 서비스 목록\n"));
123
+ if (registered.length > 0) {
124
+ console.log(chalk_1.default.green(` ✓ 등록됨 (${registered.length}개):`));
125
+ for (const d of registered.sort()) {
126
+ console.log(chalk_1.default.gray(` ${d}`));
127
+ }
128
+ }
129
+ if (unregistered.length > 0) {
130
+ console.log(chalk_1.default.yellow(`\n ○ 미등록 (${unregistered.length}개):`));
131
+ for (const s of unregistered) {
132
+ const desc = s.description ? chalk_1.default.gray(` — ${s.description.substring(0, 60)}`) : "";
133
+ console.log(` ${s.domain}${desc}`);
134
+ }
135
+ }
136
+ console.log();
137
+ }
138
+ finally {
139
+ await (0, database_1.closeConnection)();
140
+ }
141
+ });
142
+ // ── semo service diagnose ──
143
+ service
144
+ .command("diagnose")
145
+ .description("서비스 KB ↔ services 교차 진단")
146
+ .requiredOption("--domain <name>", "진단할 서비스 도메인")
147
+ .action(async (options) => {
148
+ const pool = (0, database_1.getPool)();
149
+ const spinner = (0, ora_1.default)("서비스 진단 중...").start();
150
+ try {
151
+ const result = await (0, service_migrate_1.diagnoseServiceStatus)(pool, options.domain);
152
+ spinner.stop();
153
+ console.log(chalk_1.default.cyan.bold(`\n🔍 서비스 진단: ${result.domain}\n`));
154
+ // KB 상태
155
+ if (result.kbEntryCount > 0) {
156
+ console.log(chalk_1.default.bold(" ┌─ KB ─────────────────────────────┐"));
157
+ console.log(` │ 도메인: ${result.domain} (service)`);
158
+ console.log(` │ KB 엔트리: ${result.kbEntryCount}개`);
159
+ console.log(` │ 키: ${result.kbKeys.join(", ")}`);
160
+ if (result.missingRequired.length > 0) {
161
+ console.log(chalk_1.default.yellow(` │ 필수 키 누락: ${result.missingRequired.join(", ")}`));
162
+ }
163
+ console.log(chalk_1.default.bold(" └──────────────────────────────────┘\n"));
164
+ }
165
+ else {
166
+ console.log(chalk_1.default.yellow(" KB 도메인 없음\n"));
167
+ }
168
+ // services 상태
169
+ const sp = result.serviceProject;
170
+ if (sp) {
171
+ console.log(chalk_1.default.bold(" ┌─ services ───────────────────────┐"));
172
+ console.log(` │ service_id: ${sp.service_id}`);
173
+ console.log(` │ project_name: ${sp.project_name}`);
174
+ console.log(` │ owner_name: ${sp.owner_name}`);
175
+ console.log(` │ lifecycle: ${sp.lifecycle}`);
176
+ console.log(` │ current_phase: ${sp.current_phase}`);
177
+ console.log(` │ status: ${sp.status}`);
178
+ if (sp.launched_at) {
179
+ console.log(` │ launched_at: ${sp.launched_at}`);
180
+ }
181
+ console.log(chalk_1.default.bold(" └─────────────────────────────────┘\n"));
182
+ }
183
+ else {
184
+ console.log(chalk_1.default.yellow(" services 미등록\n"));
185
+ }
186
+ // 교차 검증
187
+ if (result.mismatches.length > 0) {
188
+ console.log(chalk_1.default.bold(" ┌─ 교차 검증 ──────────────────────┐"));
189
+ for (const m of result.mismatches) {
190
+ console.log(chalk_1.default.yellow(` │ ⚠️ ${m.field}: KB=${m.kbValue}, SP=${m.spValue}`));
191
+ console.log(chalk_1.default.gray(` │ 기대값: ${m.expected}`));
192
+ }
193
+ console.log(chalk_1.default.bold(" └─────────────────────────────────┘\n"));
194
+ }
195
+ // Verdict
196
+ const verdictMap = {
197
+ healthy: chalk_1.default.green("✅ HEALTHY — 정합성 양호"),
198
+ mismatch: chalk_1.default.yellow(`⚠️ MISMATCH (${result.mismatches.length}건)`),
199
+ "kb-only": chalk_1.default.yellow("⚠️ KB-ONLY — services 미등록"),
200
+ "sp-only": chalk_1.default.red("❌ SP-ONLY — KB 도메인 없음 (비정상)"),
201
+ missing: chalk_1.default.red("❌ MISSING — KB, services 모두 없음"),
202
+ };
203
+ console.log(` 결과: ${verdictMap[result.verdict] ?? result.verdict}\n`);
204
+ }
205
+ catch (err) {
206
+ spinner.fail(`진단 실패: ${err.message}`);
207
+ }
208
+ finally {
209
+ await (0, database_1.closeConnection)();
210
+ }
211
+ });
212
+ // ── semo service update ──
213
+ service
214
+ .command("update")
215
+ .description("services 레코드 업데이트")
216
+ .requiredOption("--domain <name>", "대상 서비스 도메인")
217
+ .option("--status <status>", "서비스 상태 (active|paused|completed)")
218
+ .option("--lifecycle <lifecycle>", "라이프사이클 (build|ops|sunset)")
219
+ .option("--phase <number>", "현재 phase (0-9)", parseInt)
220
+ .option("--name <projectName>", "프로젝트명")
221
+ .option("--owner <ownerName>", "오너명")
222
+ .action(async (options) => {
223
+ const pool = (0, database_1.getPool)();
224
+ const spinner = (0, ora_1.default)("서비스 업데이트 중...").start();
225
+ try {
226
+ // Validate enum values
227
+ const validStatuses = ["active", "paused", "completed"];
228
+ if (options.status && !validStatuses.includes(options.status)) {
229
+ spinner.fail(`잘못된 status: '${options.status}' (허용: ${validStatuses.join(", ")})`);
230
+ await (0, database_1.closeConnection)();
231
+ return;
232
+ }
233
+ const validLifecycles = ["build", "ops", "sunset"];
234
+ if (options.lifecycle && !validLifecycles.includes(options.lifecycle)) {
235
+ spinner.fail(`잘못된 lifecycle: '${options.lifecycle}' (허용: ${validLifecycles.join(", ")})`);
236
+ await (0, database_1.closeConnection)();
237
+ return;
238
+ }
239
+ if (options.phase !== undefined && (options.phase < 0 || options.phase > 9)) {
240
+ spinner.fail(`잘못된 phase: ${options.phase} (허용: 0-9)`);
241
+ await (0, database_1.closeConnection)();
242
+ return;
243
+ }
244
+ const updates = {};
245
+ if (options.status)
246
+ updates.status = options.status;
247
+ if (options.lifecycle)
248
+ updates.lifecycle = options.lifecycle;
249
+ if (options.phase !== undefined)
250
+ updates.current_phase = options.phase;
251
+ if (options.name)
252
+ updates.project_name = options.name;
253
+ if (options.owner)
254
+ updates.owner_name = options.owner;
255
+ if (Object.keys(updates).length === 0) {
256
+ spinner.info("업데이트할 항목이 없습니다. --status, --lifecycle, --phase 등을 지정하세요.");
257
+ await (0, database_1.closeConnection)();
258
+ return;
259
+ }
260
+ const result = await (0, service_migrate_1.updateServiceProject)(pool, options.domain, updates);
261
+ if (!result) {
262
+ spinner.fail(`'${options.domain}'은(는) services에 등록되지 않았습니다.`);
263
+ await (0, database_1.closeConnection)();
264
+ return;
265
+ }
266
+ spinner.succeed(`'${options.domain}' 업데이트 완료`);
267
+ console.log(chalk_1.default.gray(` project_name: ${result.project_name}\n` +
268
+ ` owner_name: ${result.owner_name}\n` +
269
+ ` lifecycle: ${result.lifecycle}\n` +
270
+ ` current_phase: ${result.current_phase}\n` +
271
+ ` status: ${result.status}\n` +
272
+ ` launched_at: ${result.launched_at ?? "(없음)"}\n` +
273
+ ` updated_at: ${result.updated_at}`));
274
+ console.log();
275
+ }
276
+ catch (err) {
277
+ spinner.fail(`업데이트 실패: ${err.message}`);
278
+ }
279
+ finally {
280
+ await (0, database_1.closeConnection)();
281
+ }
282
+ });
283
+ }
@@ -492,4 +492,160 @@ function registerSessionsCommands(program) {
492
492
  await (0, database_1.closeConnection)();
493
493
  }
494
494
  });
495
+ // ── semo sessions digest ──────────────────────────────────────────────────
496
+ sessionsCmd
497
+ .command("digest")
498
+ .description("세션 transcript에서 미기록 의사결정 추출")
499
+ .option("--transcript <path>", "transcript JSONL 파일 경로")
500
+ .option("--session-dir <dir>", "세션 디렉토리 (최신 transcript 자동 선택)")
501
+ .option("--hours <n>", "최근 N시간 내 transcript만 (session-dir 사용 시)", "24")
502
+ .option("--format <type>", "출력 형식 (table|json)", "table")
503
+ .option("--output <path>", "결과를 파일로 출력")
504
+ .action(async (options) => {
505
+ // transcript 파일 찾기
506
+ let transcripts = [];
507
+ if (options.transcript) {
508
+ if (!fs.existsSync(options.transcript)) {
509
+ console.error(chalk_1.default.red(`❌ 파일 없음: ${options.transcript}`));
510
+ process.exit(1);
511
+ }
512
+ transcripts = [options.transcript];
513
+ }
514
+ else if (options.sessionDir) {
515
+ const dir = options.sessionDir;
516
+ if (!fs.existsSync(dir)) {
517
+ console.error(chalk_1.default.red(`❌ 디렉토리 없음: ${dir}`));
518
+ process.exit(1);
519
+ }
520
+ const hours = parseInt(options.hours) || 24;
521
+ const cutoff = Date.now() - hours * 60 * 60 * 1000;
522
+ const files = fs.readdirSync(dir)
523
+ .filter(f => f.endsWith(".jsonl"))
524
+ .map(f => ({ name: f, path: path.join(dir, f), mtime: fs.statSync(path.join(dir, f)).mtimeMs }))
525
+ .filter(f => f.mtime > cutoff)
526
+ .sort((a, b) => b.mtime - a.mtime);
527
+ transcripts = files.map(f => f.path);
528
+ }
529
+ else {
530
+ // 기본: 현재 semo 프로젝트의 세션 디렉토리
531
+ const semoSessionDir = path.join(os.homedir(), ".claude", "projects", "-Users-reus-Desktop-Sources-semicolon-projects-semo");
532
+ if (fs.existsSync(semoSessionDir)) {
533
+ const hours = parseInt(options.hours) || 24;
534
+ const cutoff = Date.now() - hours * 60 * 60 * 1000;
535
+ const files = fs.readdirSync(semoSessionDir)
536
+ .filter(f => f.endsWith(".jsonl"))
537
+ .map(f => ({ name: f, path: path.join(semoSessionDir, f), mtime: fs.statSync(path.join(semoSessionDir, f)).mtimeMs }))
538
+ .filter(f => f.mtime > cutoff)
539
+ .sort((a, b) => b.mtime - a.mtime);
540
+ transcripts = files.map(f => f.path);
541
+ }
542
+ }
543
+ if (transcripts.length === 0) {
544
+ console.log(chalk_1.default.yellow("⚠ 분석할 transcript가 없습니다."));
545
+ process.exit(0);
546
+ }
547
+ // 의사결정 키워드 패턴
548
+ const decisionRe = /(?:도입했|폐기했|전환했|적용했|배포했|마이그레이션|변경했|합의했|결정했|도입합니다|폐기합니다|전환합니다|적용합니다|배포합니다|도입 완료|폐기 완료|전환 완료|적용 완료|배포 완료|표준화했|통합했|분리했|추가했|제거했|Phase \d+ 완료|설정을? 변경|규칙을? 변경|프로세스를? 변경|NON-NEGOTIABLE|신규 생성|전체 배포)/g;
549
+ const kbRecordRe = /KB 기록:|semo kb upsert|KB upsert 완료|답변근거: KB/;
550
+ const candidates = [];
551
+ for (const tPath of transcripts) {
552
+ const lines = fs.readFileSync(tPath, "utf-8").split("\n").filter(l => l.trim());
553
+ let lastKbUpsertLine = -1;
554
+ for (let i = 0; i < lines.length; i++) {
555
+ try {
556
+ const entry = JSON.parse(lines[i]);
557
+ const msg = entry.message ?? entry;
558
+ // KB upsert tool call 추적
559
+ if (msg.role === "assistant") {
560
+ const content = msg.content ?? [];
561
+ for (const block of content) {
562
+ if (block?.type === "tool_use") {
563
+ const inp = JSON.stringify(block.input ?? {});
564
+ if (inp.includes("kb upsert") || inp.includes("kb_upsert")) {
565
+ lastKbUpsertLine = i;
566
+ }
567
+ }
568
+ }
569
+ }
570
+ // tool result에서 upsert 완료 추적
571
+ if (msg.role === "tool") {
572
+ const content = Array.isArray(msg.content)
573
+ ? msg.content.map((c) => typeof c === "string" ? c : c?.text ?? "").join(" ")
574
+ : String(msg.content ?? "");
575
+ if (content.includes("upsert 완료")) {
576
+ lastKbUpsertLine = i;
577
+ }
578
+ }
579
+ // assistant 텍스트에서 의사결정 키워드 탐지
580
+ if (msg.role === "assistant") {
581
+ const content = msg.content ?? [];
582
+ for (const block of content) {
583
+ if (block?.type !== "text")
584
+ continue;
585
+ let text = block.text ?? "";
586
+ // 코드 블록 제거
587
+ text = text.replace(/```[\s\S]*?```/g, "").replace(/`[^`\n]+`/g, "");
588
+ const matches = text.match(decisionRe);
589
+ if (matches && matches.length > 0) {
590
+ const hasKbInText = kbRecordRe.test(text);
591
+ // 같은 턴(±5줄 이내)에 KB upsert가 있었는지
592
+ const hasKbNearby = Math.abs(i - lastKbUpsertLine) <= 10;
593
+ candidates.push({
594
+ file: path.basename(tPath),
595
+ lineNum: i,
596
+ text: text.slice(0, 200).replace(/\n/g, " "),
597
+ keywords: [...new Set(matches)].slice(0, 3),
598
+ hasKbRecord: hasKbInText || hasKbNearby,
599
+ timestamp: entry.timestamp ? new Date(entry.timestamp).toLocaleString("ko-KR") : undefined,
600
+ });
601
+ }
602
+ }
603
+ }
604
+ }
605
+ catch { /* skip */ }
606
+ }
607
+ }
608
+ // 미기록 건만 필터
609
+ const unrecorded = candidates.filter(c => !c.hasKbRecord);
610
+ const recorded = candidates.filter(c => c.hasKbRecord);
611
+ if (options.format === "json") {
612
+ const result = { total: candidates.length, recorded: recorded.length, unrecorded: unrecorded.length, items: unrecorded };
613
+ const out = JSON.stringify(result, null, 2);
614
+ if (options.output) {
615
+ fs.writeFileSync(options.output, out);
616
+ console.log(chalk_1.default.green(`✔ 결과 저장: ${options.output}`));
617
+ }
618
+ else {
619
+ console.log(out);
620
+ }
621
+ }
622
+ else {
623
+ console.log(chalk_1.default.cyan.bold(`\n📋 세션 의사결정 다이제스트\n`));
624
+ console.log(chalk_1.default.gray(` transcript: ${transcripts.length}개 | 총 감지: ${candidates.length}건 | 기록됨: ${recorded.length}건 | 미기록: ${unrecorded.length}건\n`));
625
+ if (unrecorded.length === 0) {
626
+ console.log(chalk_1.default.green(" ✅ 미기록 의사결정 없음\n"));
627
+ }
628
+ else {
629
+ console.log(chalk_1.default.yellow.bold(" ⚠ 미기록 의사결정:\n"));
630
+ for (const c of unrecorded) {
631
+ console.log(chalk_1.default.yellow(` [${c.file}:${c.lineNum}]`) + chalk_1.default.gray(c.timestamp ? ` ${c.timestamp}` : ""));
632
+ console.log(chalk_1.default.white(` 키워드: ${c.keywords.join(", ")}`));
633
+ console.log(chalk_1.default.gray(` "${c.text.slice(0, 120)}..."\n`));
634
+ }
635
+ }
636
+ if (options.output) {
637
+ const lines = [`# 세션 의사결정 다이제스트`, ``, `총 감지: ${candidates.length}건 | 기록됨: ${recorded.length}건 | 미기록: ${unrecorded.length}건`, ``];
638
+ if (unrecorded.length > 0) {
639
+ lines.push(`## 미기록 의사결정`, ``);
640
+ for (const c of unrecorded) {
641
+ lines.push(`- **[${c.file}:${c.lineNum}]** ${c.keywords.join(", ")}`);
642
+ lines.push(` > ${c.text.slice(0, 150)}...`);
643
+ lines.push(``);
644
+ }
645
+ }
646
+ fs.writeFileSync(options.output, lines.join("\n"));
647
+ console.log(chalk_1.default.green(`✔ 결과 저장: ${options.output}`));
648
+ }
649
+ }
650
+ });
495
651
  }
@@ -8,12 +8,13 @@
8
8
  * 스킬 이름은 flat (예: 'kb-manager'), metadata.bot_ids 배열로 봇 매핑.
9
9
  * 동일 스킬명이 여러 봇에 존재하면 bot_ids를 머지.
10
10
  */
11
- import { Pool, PoolClient } from "pg";
11
+ import { Pool, PoolClient } from 'pg';
12
12
  export interface ScannedSkill {
13
13
  name: string;
14
14
  prompt: string;
15
15
  package: string;
16
16
  botId: string;
17
+ referenceFiles?: Record<string, string>;
17
18
  }
18
19
  export interface SkillSyncResult {
19
20
  botSpecific: number;
@@ -49,16 +49,16 @@ exports.syncSkillsToDB = syncSkillsToDB;
49
49
  const fs = __importStar(require("fs"));
50
50
  const path = __importStar(require("path"));
51
51
  function scanSkills(arg) {
52
- if (typeof arg === "string") {
52
+ if (typeof arg === 'string') {
53
53
  return scanSkillsLegacy(arg);
54
54
  }
55
55
  return scanSkillsV2(arg);
56
56
  }
57
57
  function scanSkillsV2(botIds) {
58
58
  const skills = [];
59
- const home = process.env.HOME || "/Users/reus";
59
+ const home = process.env.HOME || '/Users/reus';
60
60
  for (const botId of botIds) {
61
- const skillsDir = path.join(home, `.openclaw-${botId}`, "workspace", "skills");
61
+ const skillsDir = path.join(home, `.openclaw-${botId}`, 'workspace', 'skills');
62
62
  if (!fs.existsSync(skillsDir))
63
63
  continue;
64
64
  let skillEntries;
@@ -71,22 +71,49 @@ function scanSkillsV2(botIds) {
71
71
  for (const skillEntry of skillEntries) {
72
72
  if (!skillEntry.isDirectory())
73
73
  continue;
74
- if (skillEntry.name.endsWith(".skill"))
74
+ if (skillEntry.name.endsWith('.skill'))
75
75
  continue;
76
- if (skillEntry.name.startsWith("_") || skillEntry.name.startsWith("."))
76
+ if (skillEntry.name.startsWith('_') || skillEntry.name.startsWith('.'))
77
77
  continue;
78
- const skillMdPath = path.join(skillsDir, skillEntry.name, "SKILL.md");
78
+ const skillMdPath = path.join(skillsDir, skillEntry.name, 'SKILL.md');
79
79
  if (!fs.existsSync(skillMdPath))
80
80
  continue;
81
81
  try {
82
+ // Scan references/ subdirectory
83
+ let referenceFiles;
84
+ const refsDir = path.join(skillsDir, skillEntry.name, 'references');
85
+ if (fs.existsSync(refsDir)) {
86
+ try {
87
+ const refs = {};
88
+ for (const refFile of fs.readdirSync(refsDir)) {
89
+ const refPath = path.join(refsDir, refFile);
90
+ try {
91
+ if (!fs.statSync(refPath).isFile())
92
+ continue;
93
+ refs[refFile] = fs.readFileSync(refPath, 'utf-8');
94
+ }
95
+ catch {
96
+ /* skip unreadable */
97
+ }
98
+ }
99
+ if (Object.keys(refs).length > 0)
100
+ referenceFiles = refs;
101
+ }
102
+ catch {
103
+ /* skip unreadable refs dir */
104
+ }
105
+ }
82
106
  skills.push({
83
107
  name: skillEntry.name,
84
- prompt: fs.readFileSync(skillMdPath, "utf-8"),
85
- package: "openclaw",
108
+ prompt: fs.readFileSync(skillMdPath, 'utf-8'),
109
+ package: 'openclaw',
86
110
  botId,
111
+ referenceFiles,
87
112
  });
88
113
  }
89
- catch { /* skip unreadable */ }
114
+ catch {
115
+ /* skip unreadable */
116
+ }
90
117
  }
91
118
  }
92
119
  return skills;
@@ -94,34 +121,61 @@ function scanSkillsV2(botIds) {
94
121
  /** Legacy: semo-system/bot-workspaces 에서 스캔 (하위호환) */
95
122
  function scanSkillsLegacy(semoSystemDir) {
96
123
  const skills = [];
97
- const workspacesDir = path.join(semoSystemDir, "bot-workspaces");
124
+ const workspacesDir = path.join(semoSystemDir, 'bot-workspaces');
98
125
  if (!fs.existsSync(workspacesDir))
99
126
  return skills;
100
127
  const botEntries = fs.readdirSync(workspacesDir, { withFileTypes: true });
101
128
  for (const botEntry of botEntries) {
102
129
  if (!botEntry.isDirectory())
103
130
  continue;
104
- const skillsDir = path.join(workspacesDir, botEntry.name, "skills");
131
+ const skillsDir = path.join(workspacesDir, botEntry.name, 'skills');
105
132
  if (!fs.existsSync(skillsDir))
106
133
  continue;
107
134
  const skillEntries = fs.readdirSync(skillsDir, { withFileTypes: true });
108
135
  for (const skillEntry of skillEntries) {
109
136
  if (!skillEntry.isDirectory())
110
137
  continue;
111
- if (skillEntry.name.endsWith(".skill"))
138
+ if (skillEntry.name.endsWith('.skill'))
112
139
  continue;
113
- const skillMdPath = path.join(skillsDir, skillEntry.name, "SKILL.md");
140
+ const skillMdPath = path.join(skillsDir, skillEntry.name, 'SKILL.md');
114
141
  if (!fs.existsSync(skillMdPath))
115
142
  continue;
116
143
  try {
144
+ // Scan references/ subdirectory
145
+ let referenceFiles;
146
+ const refsDir = path.join(skillsDir, skillEntry.name, 'references');
147
+ if (fs.existsSync(refsDir)) {
148
+ try {
149
+ const refs = {};
150
+ for (const refFile of fs.readdirSync(refsDir)) {
151
+ const refPath = path.join(refsDir, refFile);
152
+ try {
153
+ if (!fs.statSync(refPath).isFile())
154
+ continue;
155
+ refs[refFile] = fs.readFileSync(refPath, 'utf-8');
156
+ }
157
+ catch {
158
+ /* skip unreadable */
159
+ }
160
+ }
161
+ if (Object.keys(refs).length > 0)
162
+ referenceFiles = refs;
163
+ }
164
+ catch {
165
+ /* skip unreadable refs dir */
166
+ }
167
+ }
117
168
  skills.push({
118
169
  name: skillEntry.name,
119
- prompt: fs.readFileSync(skillMdPath, "utf-8"),
120
- package: "openclaw",
170
+ prompt: fs.readFileSync(skillMdPath, 'utf-8'),
171
+ package: 'openclaw',
121
172
  botId: botEntry.name,
173
+ referenceFiles,
122
174
  });
123
175
  }
124
- catch { /* skip unreadable */ }
176
+ catch {
177
+ /* skip unreadable */
178
+ }
125
179
  }
126
180
  }
127
181
  return skills;
@@ -131,28 +185,32 @@ function scanSkillsLegacy(semoSystemDir) {
131
185
  */
132
186
  async function getBotIds(pool) {
133
187
  try {
134
- const result = await pool.query("SELECT bot_id FROM semo.bot_status ORDER BY bot_id");
188
+ const result = await pool.query('SELECT bot_id FROM semo.bot_status ORDER BY bot_id');
135
189
  if (result.rows.length > 0) {
136
190
  return result.rows.map((r) => r.bot_id);
137
191
  }
138
192
  }
139
- catch { /* fallback to local scan */ }
193
+ catch {
194
+ /* fallback to local scan */
195
+ }
140
196
  // Fallback: scan ~/.openclaw-*/workspace/ directories
141
- const home = process.env.HOME || "/Users/reus";
197
+ const home = process.env.HOME || '/Users/reus';
142
198
  const botIds = [];
143
199
  try {
144
200
  const entries = fs.readdirSync(home);
145
201
  for (const entry of entries) {
146
202
  const match = entry.match(/^\.openclaw-(.+)$/);
147
203
  if (match) {
148
- const wsDir = path.join(home, entry, "workspace");
204
+ const wsDir = path.join(home, entry, 'workspace');
149
205
  if (fs.existsSync(wsDir)) {
150
206
  botIds.push(match[1]);
151
207
  }
152
208
  }
153
209
  }
154
210
  }
155
- catch { /* skip */ }
211
+ catch {
212
+ /* skip */
213
+ }
156
214
  return botIds.sort();
157
215
  }
158
216
  /**
@@ -163,13 +221,20 @@ async function syncSkillsToDB(client, pool) {
163
221
  const botIds = await getBotIds(pool);
164
222
  const skills = scanSkills(botIds);
165
223
  for (const skill of skills) {
224
+ const metadata = { bot_ids: [skill.botId] };
225
+ if (skill.referenceFiles)
226
+ metadata.reference_files = skill.referenceFiles;
166
227
  await client.query(`INSERT INTO semo.skill_definitions (name, prompt, package, metadata, is_active, office_id)
167
228
  VALUES ($1, $2, $3, $4, true, NULL)
168
229
  ON CONFLICT (name, office_id) DO UPDATE SET
169
230
  prompt = EXCLUDED.prompt,
170
231
  package = EXCLUDED.package,
171
232
  metadata = jsonb_set(
172
- skill_definitions.metadata,
233
+ CASE
234
+ WHEN EXCLUDED.metadata ? 'reference_files'
235
+ THEN jsonb_set(skill_definitions.metadata, '{reference_files}', EXCLUDED.metadata->'reference_files')
236
+ ELSE skill_definitions.metadata
237
+ END,
173
238
  '{bot_ids}',
174
239
  (SELECT jsonb_agg(DISTINCT v)
175
240
  FROM jsonb_array_elements(
@@ -177,7 +242,7 @@ async function syncSkillsToDB(client, pool) {
177
242
  COALESCE(EXCLUDED.metadata->'bot_ids', '[]'::jsonb)
178
243
  ) AS v)
179
244
  ),
180
- updated_at = NOW()`, [skill.name, skill.prompt, skill.package, JSON.stringify({ bot_ids: [skill.botId] })]);
245
+ updated_at = NOW()`, [skill.name, skill.prompt, skill.package, JSON.stringify(metadata)]);
181
246
  }
182
247
  return {
183
248
  botSpecific: skills.length,