@team-semicolon/semo-cli 4.1.5 → 4.2.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.
@@ -2,11 +2,10 @@
2
2
  /**
3
3
  * semo get <resource> — 세션 중 실시간 DB 쿼리
4
4
  *
5
- * semo get projects [--active]
5
+ * semo get projects [--active] [--format table|json|md]
6
6
  * semo get bots [--status online|offline]
7
7
  * semo get kb [--domain <d>] [--key <k>] [--search <text>]
8
8
  * semo get ontology [--domain <d>]
9
- * semo get tasks [--project <p>] [--status <s>]
10
9
  * semo get sessions [--bot <n>]
11
10
  */
12
11
  var __importDefault = (this && this.__importDefault) || function (mod) {
@@ -52,69 +51,42 @@ function registerGetCommands(program) {
52
51
  // ── semo get projects ───────────────────────────────────────
53
52
  getCmd
54
53
  .command("projects")
55
- .description("프로젝트 목록 조회")
56
- .option("--active", "활성 프로젝트만")
54
+ .description("프로젝트 목록 조회 (KB 기반)")
55
+ .option("--active", "활성 프로젝트만 (metadata.status='active')")
57
56
  .option("--format <type>", "출력 형식 (table|json|md)", "table")
58
57
  .action(async (options) => {
59
58
  const spinner = (0, ora_1.default)("프로젝트 조회 중...").start();
60
59
  const connected = await (0, database_1.isDbConnected)();
61
60
  if (!connected) {
62
- // Fallback: show KB entries with domain=project
63
- try {
64
- const pool = (0, database_1.getPool)();
65
- const { shared } = await (0, kb_1.kbList)(pool, { domain: "project", limit: 100 });
66
- spinner.stop();
67
- if (options.format === "json") {
68
- console.log(JSON.stringify(shared, null, 2));
69
- }
70
- else {
71
- printTable(["key", "content"], shared.map(e => [e.key, e.content.substring(0, 80)]), "📁 프로젝트 (KB 기반)");
72
- }
73
- }
74
- catch {
75
- spinner.fail("DB 연결 실패");
76
- }
61
+ spinner.fail("DB 연결 실패");
77
62
  await (0, database_1.closeConnection)();
78
- return;
63
+ process.exit(1);
79
64
  }
80
65
  try {
81
66
  const pool = (0, database_1.getPool)();
82
- const client = await pool.connect();
83
- let query = `
84
- SELECT id, name, display_name, status, description, updated_at::text
85
- FROM semo.projects
86
- `;
87
- const params = [];
67
+ let entries = await (0, kb_1.kbList)(pool, { domain: "project", limit: 100 });
88
68
  if (options.active) {
89
- query += " WHERE status = 'active'";
90
- }
91
- query += " ORDER BY updated_at DESC";
92
- let rows = [];
93
- try {
94
- const result = await client.query(query, params);
95
- rows = result.rows;
96
- }
97
- catch {
98
- // semo.projects table may not exist — fallback to KB
99
- client.release();
100
- const { shared } = await (0, kb_1.kbList)(pool, { domain: "project", limit: 100 });
101
- spinner.stop();
102
- if (options.format === "json") {
103
- console.log(JSON.stringify(shared, null, 2));
104
- }
105
- else {
106
- printTable(["key", "content"], shared.map(e => [e.key, e.content.substring(0, 80)]), "📁 프로젝트 (KB 기반)");
107
- }
108
- await (0, database_1.closeConnection)();
109
- return;
69
+ entries = entries.filter(e => {
70
+ const meta = e.metadata;
71
+ return meta?.status === "active";
72
+ });
110
73
  }
111
- client.release();
112
74
  spinner.stop();
113
75
  if (options.format === "json") {
114
- console.log(JSON.stringify(rows, null, 2));
76
+ console.log(JSON.stringify(entries, null, 2));
77
+ }
78
+ else if (options.format === "md") {
79
+ for (const e of entries) {
80
+ console.log(`\n## ${e.key}\n`);
81
+ console.log(e.content);
82
+ }
115
83
  }
116
84
  else {
117
- printTable(["ID", "이름", "상태", "설명"], rows.map(r => [r.id, r.display_name || r.name, r.status || "-", (r.description || "").substring(0, 60)]), "📁 프로젝트");
85
+ printTable(["key", "content", "updated_at"], entries.map(e => [
86
+ e.key,
87
+ (e.content || "").substring(0, 60),
88
+ e.updated_at ? new Date(e.updated_at).toLocaleString("ko-KR") : "-",
89
+ ]), "📁 프로젝트 (KB)");
118
90
  }
119
91
  }
120
92
  catch (err) {
@@ -217,11 +189,11 @@ function registerGetCommands(program) {
217
189
  entries = result.rows;
218
190
  }
219
191
  else {
220
- const { shared } = await (0, kb_1.kbList)(pool, {
192
+ const kbEntries = await (0, kb_1.kbList)(pool, {
221
193
  domain: options.domain,
222
194
  limit,
223
195
  });
224
- entries = shared;
196
+ entries = kbEntries;
225
197
  }
226
198
  spinner.stop();
227
199
  if (options.format === "json") {
@@ -301,70 +273,6 @@ function registerGetCommands(program) {
301
273
  await (0, database_1.closeConnection)();
302
274
  }
303
275
  });
304
- // ── semo get tasks ──────────────────────────────────────────
305
- getCmd
306
- .command("tasks")
307
- .description("태스크 조회 (semo.tasks)")
308
- .option("--project <name>", "프로젝트 필터")
309
- .option("--status <s>", "상태 필터 (open|in_progress|done)")
310
- .option("--limit <n>", "최대 결과 수", "20")
311
- .option("--format <type>", "출력 형식 (table|json)", "table")
312
- .action(async (options) => {
313
- const spinner = (0, ora_1.default)("태스크 조회 중...").start();
314
- const connected = await (0, database_1.isDbConnected)();
315
- if (!connected) {
316
- spinner.fail("DB 연결 실패");
317
- await (0, database_1.closeConnection)();
318
- process.exit(1);
319
- }
320
- try {
321
- const pool = (0, database_1.getPool)();
322
- const client = await pool.connect();
323
- let query = "SELECT id, title, status, project_id, assignee_name, updated_at::text FROM semo.tasks";
324
- const params = [];
325
- const conditions = [];
326
- let idx = 1;
327
- if (options.project) {
328
- conditions.push(`project_id = $${idx++}`);
329
- params.push(options.project);
330
- }
331
- if (options.status) {
332
- conditions.push(`status = $${idx++}`);
333
- params.push(options.status);
334
- }
335
- if (conditions.length > 0) {
336
- query += " WHERE " + conditions.join(" AND ");
337
- }
338
- query += ` ORDER BY updated_at DESC LIMIT $${idx++}`;
339
- params.push(parseInt(options.limit));
340
- let rows = [];
341
- try {
342
- const result = await client.query(query, params);
343
- rows = result.rows;
344
- }
345
- catch {
346
- client.release();
347
- spinner.warn("semo.tasks 테이블이 없거나 접근 불가");
348
- await (0, database_1.closeConnection)();
349
- return;
350
- }
351
- client.release();
352
- spinner.stop();
353
- if (options.format === "json") {
354
- console.log(JSON.stringify(rows, null, 2));
355
- }
356
- else {
357
- printTable(["id", "title", "status", "assignee"], rows.map(r => [r.id, (r.title || "").substring(0, 50), r.status, r.assignee_name || "-"]), "📋 태스크");
358
- }
359
- }
360
- catch (err) {
361
- spinner.fail(`조회 실패: ${err}`);
362
- process.exit(1);
363
- }
364
- finally {
365
- await (0, database_1.closeConnection)();
366
- }
367
- });
368
276
  // ── semo get sessions ───────────────────────────────────────
369
277
  getCmd
370
278
  .command("sessions")
@@ -23,7 +23,8 @@
23
23
  * { "agent:main:slack:channel:xxx": { updatedAt: <unix ms>, ... }, ... }
24
24
  */
25
25
  import { Command } from "commander";
26
- export declare function syncBotSessions(botIds: string[], client: any): Promise<{
26
+ import { PoolClient } from "pg";
27
+ export declare function syncBotSessions(botIds: string[], client: PoolClient): Promise<{
27
28
  total: number;
28
29
  }>;
29
30
  export declare function registerSessionsCommands(program: Command): void;
@@ -69,6 +69,7 @@ const readline = __importStar(require("readline"));
69
69
  const os = __importStar(require("os"));
70
70
  const child_process_1 = require("child_process");
71
71
  const database_1 = require("../database");
72
+ // ─── OpenClaw config reader ─────────────────────────────────────────────────
72
73
  function readOpenClawConfig(botId) {
73
74
  const configPath = path.join(os.homedir(), `.openclaw-${botId}`, "openclaw.json");
74
75
  if (!fs.existsSync(configPath))
@@ -80,7 +81,7 @@ function readOpenClawConfig(botId) {
80
81
  return null;
81
82
  }
82
83
  }
83
- // HTTP API로 게이트웨이에서 세션 목록 조회
84
+ // ─── HTTP API로 게이트웨이에서 세션 목록 조회 ───────────────────────────────
84
85
  async function fetchSessionsFromGateway(botId) {
85
86
  const config = readOpenClawConfig(botId);
86
87
  if (!config?.gateway?.port || !config?.gateway?.auth?.token)
@@ -104,7 +105,7 @@ async function fetchSessionsFromGateway(botId) {
104
105
  if (!outer.ok)
105
106
  return null;
106
107
  // 이중 JSON 파싱: result.content[0].text가 JSON 문자열
107
- const textContent = outer.result?.content?.find(c => c.type === "text")?.text;
108
+ const textContent = outer.result?.content?.find((c) => c.type === "text")?.text;
108
109
  if (!textContent)
109
110
  return null;
110
111
  const inner = JSON.parse(textContent);
@@ -114,7 +115,7 @@ async function fetchSessionsFromGateway(botId) {
114
115
  return null;
115
116
  }
116
117
  }
117
- // Fallback: 로컬 sessions.json 파일 파싱
118
+ // ─── Fallback: 로컬 sessions.json 파일 파싱 ────────────────────────────────
118
119
  function readSessionsFromFile(botId) {
119
120
  const sessionsPath = path.join(os.homedir(), `.openclaw-${botId}`, "agents", "main", "sessions", "sessions.json");
120
121
  if (!fs.existsSync(sessionsPath))
@@ -130,11 +131,9 @@ function readSessionsFromFile(botId) {
130
131
  return null;
131
132
  }
132
133
  }
133
- // GatewaySession → DB 컬럼 매핑
134
+ // ─── GatewaySession → DB 컬럼 매핑 ─────────────────────────────────────────
134
135
  function mapSessionToDb(s) {
135
- // kind: "group" → isolated, 나머지 → main
136
136
  const kind = s.kind === "group" ? "isolated" : "main";
137
- // chat_type: key 패턴으로 파싱 (API의 channel 필드 우선)
138
137
  let chatType = s.channel ?? "direct";
139
138
  if (!s.channel) {
140
139
  if (s.key.includes(":slack:"))
@@ -142,14 +141,12 @@ function mapSessionToDb(s) {
142
141
  else if (s.key.includes(":cron:"))
143
142
  chatType = "cron";
144
143
  }
145
- // label: displayName 우선, 없으면 key에서 마지막 부분
146
144
  const label = s.displayName ?? s.key.split(":").slice(-2).join(":");
147
- // last_activity: unix ms → ISO string
148
145
  const lastActivity = s.updatedAt ? new Date(s.updatedAt).toISOString() : null;
149
146
  return { kind, chatType, label, lastActivity, totalTokens: s.totalTokens ?? null };
150
147
  }
148
+ // ─── stdin reader ───────────────────────────────────────────────────────────
151
149
  async function readStdin() {
152
- // stdin이 TTY면 hook에서 호출된 게 아님 → 빈 객체 반환
153
150
  if (process.stdin.isTTY)
154
151
  return {};
155
152
  return new Promise((resolve) => {
@@ -164,11 +161,10 @@ async function readStdin() {
164
161
  resolve({});
165
162
  }
166
163
  });
167
- // 500ms 타임아웃 — stdin이 오지 않으면 그냥 진행
168
164
  setTimeout(() => resolve({}), 500);
169
165
  });
170
166
  }
171
- // ─── 현재 git 브랜치 (label용) ───────────────────────────────────────────────
167
+ // ─── 현재 git 브랜치 (label용) ──────────────────────────────────────────────
172
168
  function getGitBranch(cwd) {
173
169
  try {
174
170
  const dir = cwd || process.cwd();
@@ -176,15 +172,13 @@ function getGitBranch(cwd) {
176
172
  cwd: dir,
177
173
  stdio: ["ignore", "pipe", "ignore"],
178
174
  timeout: 2000,
179
- })
180
- .toString()
181
- .trim();
175
+ }).toString().trim();
182
176
  }
183
177
  catch {
184
178
  return null;
185
179
  }
186
180
  }
187
- // ─── transcript.jsonl 메시지 수 카운트 ───────────────────────────────────────
181
+ // ─── transcript.jsonl 메시지 수 카운트 ──────────────────────────────────────
188
182
  async function countMessages(transcriptPath) {
189
183
  if (!transcriptPath || !fs.existsSync(transcriptPath))
190
184
  return 0;
@@ -199,20 +193,16 @@ async function countMessages(transcriptPath) {
199
193
  return;
200
194
  try {
201
195
  const obj = JSON.parse(line);
202
- // role이 있는 메시지(user/assistant)만 카운트
203
196
  if (obj.role === "user" || obj.role === "assistant")
204
197
  count++;
205
198
  }
206
- catch {
207
- // invalid line skip
208
- }
199
+ catch { /* invalid line skip */ }
209
200
  });
210
201
  rl.on("close", () => resolve(count));
211
202
  rl.on("error", () => resolve(0));
212
203
  });
213
204
  }
214
- // ─── 외부에서 호출 가능한 sync 헬퍼 ──────────────────────────────────────────
215
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
205
+ // ─── 외부에서 호출 가능한 sync 헬퍼 ────────────────────────────────────────
216
206
  async function syncBotSessions(botIds, client) {
217
207
  let totalUpserted = 0;
218
208
  for (const botId of botIds) {
@@ -245,10 +235,11 @@ async function syncBotSessions(botIds, client) {
245
235
  }
246
236
  catch { /* 개별 세션 실패 무시 */ }
247
237
  }
238
+ // session_count는 trg_session_count 트리거가 자동 관리
239
+ // last_active만 업데이트
248
240
  try {
249
241
  await client.query(`UPDATE semo.bot_status
250
- SET session_count = (SELECT COUNT(*) FROM semo.bot_sessions WHERE bot_id = $1),
251
- last_active = CASE
242
+ SET last_active = CASE
252
243
  WHEN $2::timestamptz IS NOT NULL
253
244
  AND (last_active IS NULL OR $2::timestamptz > last_active)
254
245
  THEN $2::timestamptz
@@ -262,12 +253,12 @@ async function syncBotSessions(botIds, client) {
262
253
  }
263
254
  return { total: totalUpserted };
264
255
  }
265
- // ─── Command registration ─────────────────────────────────────────────────────
256
+ // ─── Command registration ───────────────────────────────────────────────────
266
257
  function registerSessionsCommands(program) {
267
258
  const sessionsCmd = program
268
259
  .command("sessions")
269
260
  .description("세션 추적 (Claude Code 훅 연동)");
270
- // ── semo sessions push ───────────────────────────────────────────────────────
261
+ // ── semo sessions push ────────────────────────────────────────────────────
271
262
  sessionsCmd
272
263
  .command("push")
273
264
  .description("현재 세션을 semo.bot_sessions에 기록 (훅에서 호출)")
@@ -278,21 +269,19 @@ function registerSessionsCommands(program) {
278
269
  .action(async (options) => {
279
270
  const botId = options.botId;
280
271
  const event = options.event;
281
- // stdin에서 Claude Code hook JSON 읽기
282
272
  const hook = await readStdin();
283
- const sessionKey = hook.session_id ||
284
- process.env.CLAUDE_SESSION_ID ||
285
- `${botId}-${Date.now()}`;
273
+ const sessionKey = hook.session_id
274
+ || process.env.CLAUDE_SESSION_ID
275
+ || `${botId}-${Date.now()}`;
286
276
  const branch = getGitBranch(hook.cwd);
287
- const label = options.label ||
288
- branch ||
289
- path.basename(hook.cwd || process.cwd());
277
+ const label = options.label
278
+ || branch
279
+ || path.basename(hook.cwd || process.cwd());
290
280
  const messageCount = event === "stop" && hook.transcript_path
291
281
  ? await countMessages(hook.transcript_path)
292
282
  : undefined;
293
283
  const connected = await (0, database_1.isDbConnected)();
294
284
  if (!connected) {
295
- // 훅에서 호출 시 조용히 실패 (봇 세션에 영향 주지 않도록)
296
285
  await (0, database_1.closeConnection)();
297
286
  process.exit(0);
298
287
  }
@@ -307,13 +296,7 @@ function registerSessionsCommands(program) {
307
296
  label = EXCLUDED.label,
308
297
  last_activity = NOW(),
309
298
  synced_at = NOW()`, [botId, sessionKey, label, options.kind]);
310
- // bot_status.session_count 갱신
311
- await client.query(`UPDATE semo.bot_status
312
- SET session_count = (
313
- SELECT COUNT(*) FROM semo.bot_sessions WHERE bot_id = $1
314
- ),
315
- synced_at = NOW()
316
- WHERE bot_id = $1`, [botId]);
299
+ // session_count trg_session_count 트리거가 자동 관리
317
300
  }
318
301
  else if (event === "stop") {
319
302
  await client.query(`UPDATE semo.bot_sessions
@@ -323,7 +306,7 @@ function registerSessionsCommands(program) {
323
306
  WHERE bot_id = $2 AND session_key = $3`, [messageCount ?? null, botId, sessionKey]);
324
307
  }
325
308
  else {
326
- // heartbeat — 마지막 활동 시간 + 메시지 수 갱신
309
+ // heartbeat
327
310
  await client.query(`INSERT INTO semo.bot_sessions
328
311
  (bot_id, session_key, label, kind, chat_type, last_activity, message_count, synced_at)
329
312
  VALUES ($1, $2, $3, $4, 'claude-code', NOW(), COALESCE($5, 0), NOW())
@@ -331,19 +314,12 @@ function registerSessionsCommands(program) {
331
314
  last_activity = NOW(),
332
315
  message_count = COALESCE(EXCLUDED.message_count, semo.bot_sessions.message_count),
333
316
  synced_at = NOW()`, [botId, sessionKey, label, options.kind, messageCount ?? null]);
334
- // bot_status.session_count 갱신
335
- await client.query(`UPDATE semo.bot_status
336
- SET session_count = (
337
- SELECT COUNT(*) FROM semo.bot_sessions WHERE bot_id = $1
338
- ),
339
- synced_at = NOW()
340
- WHERE bot_id = $1`, [botId]);
317
+ // session_count trg_session_count 트리거가 자동 관리
341
318
  }
342
319
  client.release();
343
320
  console.log(chalk_1.default.green(`✔ sessions push [${event}] ${botId}/${sessionKey.slice(0, 8)}`));
344
321
  }
345
322
  catch (err) {
346
- // 훅에서 호출 시 조용히 실패
347
323
  console.error(chalk_1.default.red(`sessions push 실패: ${err}`));
348
324
  process.exit(0);
349
325
  }
@@ -351,7 +327,7 @@ function registerSessionsCommands(program) {
351
327
  await (0, database_1.closeConnection)();
352
328
  }
353
329
  });
354
- // ── semo sessions sync ──────────────────────────────────────────────────────
330
+ // ── semo sessions sync ────────────────────────────────────────────────────
355
331
  sessionsCmd
356
332
  .command("sync")
357
333
  .description("OpenClaw 게이트웨이에서 세션 읽어 DB upsert")
@@ -366,7 +342,6 @@ function registerSessionsCommands(program) {
366
342
  }
367
343
  const pool = (0, database_1.getPool)();
368
344
  const client = await pool.connect();
369
- // 대상 봇 목록 결정
370
345
  let botIds = [];
371
346
  if (options.botId) {
372
347
  botIds = [options.botId];
@@ -377,7 +352,6 @@ function registerSessionsCommands(program) {
377
352
  botIds = r.rows.map((row) => row.bot_id);
378
353
  }
379
354
  catch {
380
- // bot_status 없으면 openclaw 디렉토리에서 자동 감지
381
355
  const home = os.homedir();
382
356
  botIds = fs.readdirSync(home)
383
357
  .filter(d => d.startsWith(".openclaw-"))
@@ -401,10 +375,8 @@ function registerSessionsCommands(program) {
401
375
  let totalUpserted = 0;
402
376
  for (const botId of botIds) {
403
377
  process.stdout.write(chalk_1.default.gray(` ${botId.padEnd(14)}`));
404
- // 1) HTTP API 시도
405
378
  let sessions = await fetchSessionsFromGateway(botId);
406
379
  let source = "gateway";
407
- // 2) Fallback: 로컬 파일
408
380
  if (!sessions) {
409
381
  sessions = readSessionsFromFile(botId);
410
382
  source = "file";
@@ -413,7 +385,6 @@ function registerSessionsCommands(program) {
413
385
  console.log(chalk_1.default.yellow("세션 없음 (게이트웨이 오프라인, 파일 없음)"));
414
386
  continue;
415
387
  }
416
- // DB upsert
417
388
  let upserted = 0;
418
389
  let latestActivity = null;
419
390
  for (const s of sessions) {
@@ -436,15 +407,13 @@ function registerSessionsCommands(program) {
436
407
  latestActivity = d;
437
408
  }
438
409
  }
439
- catch {
440
- // 개별 세션 실패는 무시
441
- }
410
+ catch { /* 개별 세션 실패 무시 */ }
442
411
  }
443
- // bot_status session_count + last_active 갱신
412
+ // session_count trg_session_count 트리거가 자동 관리
413
+ // last_active만 업데이트
444
414
  try {
445
415
  await client.query(`UPDATE semo.bot_status
446
- SET session_count = (SELECT COUNT(*) FROM semo.bot_sessions WHERE bot_id = $1),
447
- last_active = CASE
416
+ SET last_active = CASE
448
417
  WHEN $2::timestamptz IS NOT NULL
449
418
  AND (last_active IS NULL OR $2::timestamptz > last_active)
450
419
  THEN $2::timestamptz
@@ -462,7 +431,7 @@ function registerSessionsCommands(program) {
462
431
  console.log(chalk_1.default.green(`\n✅ sessions sync 완료 — 총 ${totalUpserted}건 upsert\n`));
463
432
  await (0, database_1.closeConnection)();
464
433
  });
465
- // ── semo sessions list ───────────────────────────────────────────────────────
434
+ // ── semo sessions list ────────────────────────────────────────────────────
466
435
  sessionsCmd
467
436
  .command("list")
468
437
  .description("bot_sessions 테이블 조회")
@@ -0,0 +1,28 @@
1
+ /**
2
+ * skill-sync — 봇 전용 스킬 파일 스캔 + DB 동기화
3
+ *
4
+ * semo bots sync (piggyback) 및 semo context sync (세션 훅) 양쪽에서 호출.
5
+ * 스킬 이름은 flat (예: 'kb-manager'), metadata.bot_ids 배열로 봇 매핑.
6
+ * 동일 스킬명이 여러 봇에 존재하면 bot_ids를 머지.
7
+ */
8
+ import { PoolClient } from "pg";
9
+ interface ScannedSkill {
10
+ name: string;
11
+ prompt: string;
12
+ package: string;
13
+ botId: string;
14
+ }
15
+ export interface SkillSyncResult {
16
+ botSpecific: number;
17
+ total: number;
18
+ }
19
+ /**
20
+ * semo-system/bot-workspaces 에서 봇 전용 스킬 파일 스캔
21
+ */
22
+ export declare function scanSkills(semoSystemDir: string): ScannedSkill[];
23
+ /**
24
+ * 스캔된 스킬을 skill_definitions에 upsert
25
+ * flat name + metadata.bot_ids 배열 사용, 동일 스킬명은 bot_ids 머지
26
+ */
27
+ export declare function syncSkillsToDB(client: PoolClient, semoSystemDir: string): Promise<SkillSyncResult>;
28
+ export {};
@@ -0,0 +1,111 @@
1
+ "use strict";
2
+ /**
3
+ * skill-sync — 봇 전용 스킬 파일 스캔 + DB 동기화
4
+ *
5
+ * semo bots sync (piggyback) 및 semo context sync (세션 훅) 양쪽에서 호출.
6
+ * 스킬 이름은 flat (예: 'kb-manager'), metadata.bot_ids 배열로 봇 매핑.
7
+ * 동일 스킬명이 여러 봇에 존재하면 bot_ids를 머지.
8
+ */
9
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ var desc = Object.getOwnPropertyDescriptor(m, k);
12
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
13
+ desc = { enumerable: true, get: function() { return m[k]; } };
14
+ }
15
+ Object.defineProperty(o, k2, desc);
16
+ }) : (function(o, m, k, k2) {
17
+ if (k2 === undefined) k2 = k;
18
+ o[k2] = m[k];
19
+ }));
20
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
21
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
22
+ }) : function(o, v) {
23
+ o["default"] = v;
24
+ });
25
+ var __importStar = (this && this.__importStar) || (function () {
26
+ var ownKeys = function(o) {
27
+ ownKeys = Object.getOwnPropertyNames || function (o) {
28
+ var ar = [];
29
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
30
+ return ar;
31
+ };
32
+ return ownKeys(o);
33
+ };
34
+ return function (mod) {
35
+ if (mod && mod.__esModule) return mod;
36
+ var result = {};
37
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
38
+ __setModuleDefault(result, mod);
39
+ return result;
40
+ };
41
+ })();
42
+ Object.defineProperty(exports, "__esModule", { value: true });
43
+ exports.scanSkills = scanSkills;
44
+ exports.syncSkillsToDB = syncSkillsToDB;
45
+ const fs = __importStar(require("fs"));
46
+ const path = __importStar(require("path"));
47
+ /**
48
+ * semo-system/bot-workspaces 에서 봇 전용 스킬 파일 스캔
49
+ */
50
+ function scanSkills(semoSystemDir) {
51
+ const skills = [];
52
+ const workspacesDir = path.join(semoSystemDir, "bot-workspaces");
53
+ if (!fs.existsSync(workspacesDir))
54
+ return skills;
55
+ const botEntries = fs.readdirSync(workspacesDir, { withFileTypes: true });
56
+ for (const botEntry of botEntries) {
57
+ if (!botEntry.isDirectory())
58
+ continue;
59
+ const skillsDir = path.join(workspacesDir, botEntry.name, "skills");
60
+ if (!fs.existsSync(skillsDir))
61
+ continue;
62
+ const skillEntries = fs.readdirSync(skillsDir, { withFileTypes: true });
63
+ for (const skillEntry of skillEntries) {
64
+ if (!skillEntry.isDirectory())
65
+ continue;
66
+ if (skillEntry.name.endsWith(".skill"))
67
+ continue;
68
+ const skillMdPath = path.join(skillsDir, skillEntry.name, "SKILL.md");
69
+ if (!fs.existsSync(skillMdPath))
70
+ continue;
71
+ try {
72
+ skills.push({
73
+ name: skillEntry.name,
74
+ prompt: fs.readFileSync(skillMdPath, "utf-8"),
75
+ package: "openclaw",
76
+ botId: botEntry.name,
77
+ });
78
+ }
79
+ catch { /* skip unreadable */ }
80
+ }
81
+ }
82
+ return skills;
83
+ }
84
+ /**
85
+ * 스캔된 스킬을 skill_definitions에 upsert
86
+ * flat name + metadata.bot_ids 배열 사용, 동일 스킬명은 bot_ids 머지
87
+ */
88
+ async function syncSkillsToDB(client, semoSystemDir) {
89
+ const skills = scanSkills(semoSystemDir);
90
+ for (const skill of skills) {
91
+ await client.query(`INSERT INTO skill_definitions (name, prompt, package, metadata, is_active, office_id)
92
+ VALUES ($1, $2, $3, $4, true, NULL)
93
+ ON CONFLICT (name, office_id) DO UPDATE SET
94
+ prompt = EXCLUDED.prompt,
95
+ package = EXCLUDED.package,
96
+ metadata = jsonb_set(
97
+ skill_definitions.metadata,
98
+ '{bot_ids}',
99
+ (SELECT jsonb_agg(DISTINCT v)
100
+ FROM jsonb_array_elements(
101
+ COALESCE(skill_definitions.metadata->'bot_ids', '[]'::jsonb) ||
102
+ COALESCE(EXCLUDED.metadata->'bot_ids', '[]'::jsonb)
103
+ ) AS v)
104
+ ),
105
+ updated_at = NOW()`, [skill.name, skill.prompt, skill.package, JSON.stringify({ bot_ids: [skill.botId] })]);
106
+ }
107
+ return {
108
+ botSpecific: skills.length,
109
+ total: skills.length,
110
+ };
111
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * skill-sync 테스트 — scanSkills 함수의 파일 스캔 로직 검증
3
+ *
4
+ * 실행: npx ts-node packages/cli/src/commands/skill-sync.test.ts
5
+ *
6
+ * 테스트 케이스:
7
+ * 1. 빈 디렉토리 → 0개
8
+ * 2. 봇 전용 스킬 → flat name으로 반환
9
+ * 3. 복수 봇의 전용 스킬 — botId 정확성
10
+ * 4. .skill 확장자 디렉토리 → 스킵
11
+ * 5. SKILL.md 없는 디렉토리 → 스킵
12
+ * 6. 스킬 변경 감지 — 파일 수정 후 재스캔
13
+ * 7. 스킬 추가 감지 — 새 디렉토리 추가 후 재스캔
14
+ * 8. 스킬 삭제 감지 — SKILL.md 삭제 후 재스캔
15
+ */
16
+ export {};