@team-semicolon/semo-cli 4.1.3 → 4.1.5

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.
@@ -50,6 +50,7 @@ const chalk_1 = __importDefault(require("chalk"));
50
50
  const ora_1 = __importDefault(require("ora"));
51
51
  const fs = __importStar(require("fs"));
52
52
  const path = __importStar(require("path"));
53
+ const child_process_1 = require("child_process");
53
54
  const database_1 = require("../database");
54
55
  function parseIdentityMd(content) {
55
56
  const nameMatch = content.match(/\*\*Name:\*\*\s*(.+)/);
@@ -343,11 +344,23 @@ function registerBotsCommands(program) {
343
344
  catch (err) {
344
345
  await client.query("ROLLBACK");
345
346
  spinner.fail(`sync 실패: ${err}`);
346
- process.exit(1);
347
- }
348
- finally {
349
347
  client.release();
350
348
  await (0, database_1.closeConnection)();
349
+ process.exit(1);
350
+ }
351
+ client.release();
352
+ await (0, database_1.closeConnection)();
353
+ // sessions sync 연동: DB 연결 반납 후 별도 프로세스로 실행
354
+ console.log(chalk_1.default.gray(" → sessions sync 실행 중..."));
355
+ try {
356
+ const semoCmd = process.argv[1];
357
+ (0, child_process_1.spawnSync)(process.execPath, [semoCmd, "sessions", "sync", "--all"], {
358
+ stdio: "inherit",
359
+ timeout: 60000,
360
+ });
361
+ }
362
+ catch {
363
+ console.log(chalk_1.default.yellow(" ⚠ sessions sync 호출 실패 (무시)"));
351
364
  }
352
365
  });
353
366
  // ── semo bots set-status ─────────────────────────────────────
@@ -1,16 +1,29 @@
1
1
  /**
2
2
  * semo sessions — 세션 추적
3
3
  *
4
- * Claude Code (SessionStart / Stop)에서 stdin으로 전달되는 JSON을 파싱해
5
- * semo.bot_sessions 테이블에 upsert합니다.
4
+ * sync: 봇의 OpenClaw 게이트웨이(HTTP API) 또는 로컬 sessions.json에서
5
+ * 세션 데이터를 읽어 semo.bot_sessions upsert합니다.
6
6
  *
7
- * Claude Code hook stdin 구조:
8
- * SessionStart: { session_id, transcript_path, cwd, hook_event_name }
9
- * Stop: { session_id, transcript_path, hook_event_name }
7
+ * push: Claude Code 훅(SessionStart / Stop)에서 stdin으로 전달되는 JSON을 파싱해
8
+ * semo.bot_sessions 테이블에 upsert합니다. (Claude Code 직접 실행 시에만 유효)
10
9
  *
11
- * 사용법 (settings.json hooks):
12
- * SessionStart semo sessions push --bot-id workclaw --event start
13
- * Stop → semo sessions push --bot-id workclaw --event stop
10
+ * OpenClaw 봇은 게이트웨이(WS/HTTP 서버)로 운영되어 Claude Code 훅이 트리거되지 않으므로
11
+ * semo sessions sync --all 주기적으로 동기화해야 합니다.
12
+ *
13
+ * 게이트웨이 설정 위치: ~/.openclaw-{botId}/openclaw.json
14
+ * gateway.port: 포트번호
15
+ * gateway.auth.token: Bearer 토큰
16
+ *
17
+ * HTTP API: POST http://127.0.0.1:{port}/tools/invoke
18
+ * { tool: "sessions_list", action: "json", args: {} }
19
+ * 응답: { ok: true, result: { content: [{ type: "text", text: "<JSON string>" }] } }
20
+ * inner JSON: { count: N, sessions: [{ key, kind, channel, displayName, updatedAt, totalTokens }] }
21
+ *
22
+ * Fallback: ~/.openclaw-{botId}/agents/main/sessions/sessions.json
23
+ * { "agent:main:slack:channel:xxx": { updatedAt: <unix ms>, ... }, ... }
14
24
  */
15
25
  import { Command } from "commander";
26
+ export declare function syncBotSessions(botIds: string[], client: any): Promise<{
27
+ total: number;
28
+ }>;
16
29
  export declare function registerSessionsCommands(program: Command): void;
@@ -2,16 +2,26 @@
2
2
  /**
3
3
  * semo sessions — 세션 추적
4
4
  *
5
- * Claude Code (SessionStart / Stop)에서 stdin으로 전달되는 JSON을 파싱해
6
- * semo.bot_sessions 테이블에 upsert합니다.
5
+ * sync: 봇의 OpenClaw 게이트웨이(HTTP API) 또는 로컬 sessions.json에서
6
+ * 세션 데이터를 읽어 semo.bot_sessions upsert합니다.
7
7
  *
8
- * Claude Code hook stdin 구조:
9
- * SessionStart: { session_id, transcript_path, cwd, hook_event_name }
10
- * Stop: { session_id, transcript_path, hook_event_name }
8
+ * push: Claude Code 훅(SessionStart / Stop)에서 stdin으로 전달되는 JSON을 파싱해
9
+ * semo.bot_sessions 테이블에 upsert합니다. (Claude Code 직접 실행 시에만 유효)
11
10
  *
12
- * 사용법 (settings.json hooks):
13
- * SessionStart semo sessions push --bot-id workclaw --event start
14
- * Stop → semo sessions push --bot-id workclaw --event stop
11
+ * OpenClaw 봇은 게이트웨이(WS/HTTP 서버)로 운영되어 Claude Code 훅이 트리거되지 않으므로
12
+ * semo sessions sync --all 주기적으로 동기화해야 합니다.
13
+ *
14
+ * 게이트웨이 설정 위치: ~/.openclaw-{botId}/openclaw.json
15
+ * gateway.port: 포트번호
16
+ * gateway.auth.token: Bearer 토큰
17
+ *
18
+ * HTTP API: POST http://127.0.0.1:{port}/tools/invoke
19
+ * { tool: "sessions_list", action: "json", args: {} }
20
+ * 응답: { ok: true, result: { content: [{ type: "text", text: "<JSON string>" }] } }
21
+ * inner JSON: { count: N, sessions: [{ key, kind, channel, displayName, updatedAt, totalTokens }] }
22
+ *
23
+ * Fallback: ~/.openclaw-{botId}/agents/main/sessions/sessions.json
24
+ * { "agent:main:slack:channel:xxx": { updatedAt: <unix ms>, ... }, ... }
15
25
  */
16
26
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
17
27
  if (k2 === undefined) k2 = k;
@@ -50,13 +60,94 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
50
60
  return (mod && mod.__esModule) ? mod : { "default": mod };
51
61
  };
52
62
  Object.defineProperty(exports, "__esModule", { value: true });
63
+ exports.syncBotSessions = syncBotSessions;
53
64
  exports.registerSessionsCommands = registerSessionsCommands;
54
65
  const chalk_1 = __importDefault(require("chalk"));
55
66
  const fs = __importStar(require("fs"));
56
67
  const path = __importStar(require("path"));
57
68
  const readline = __importStar(require("readline"));
69
+ const os = __importStar(require("os"));
58
70
  const child_process_1 = require("child_process");
59
71
  const database_1 = require("../database");
72
+ function readOpenClawConfig(botId) {
73
+ const configPath = path.join(os.homedir(), `.openclaw-${botId}`, "openclaw.json");
74
+ if (!fs.existsSync(configPath))
75
+ return null;
76
+ try {
77
+ return JSON.parse(fs.readFileSync(configPath, "utf-8"));
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ }
83
+ // HTTP API로 게이트웨이에서 세션 목록 조회
84
+ async function fetchSessionsFromGateway(botId) {
85
+ const config = readOpenClawConfig(botId);
86
+ if (!config?.gateway?.port || !config?.gateway?.auth?.token)
87
+ return null;
88
+ const { port, auth } = config.gateway;
89
+ const token = auth.token;
90
+ const url = `http://127.0.0.1:${port}/tools/invoke`;
91
+ try {
92
+ const res = await fetch(url, {
93
+ method: "POST",
94
+ headers: {
95
+ "Authorization": `Bearer ${token}`,
96
+ "Content-Type": "application/json",
97
+ },
98
+ body: JSON.stringify({ tool: "sessions_list", action: "json", args: {} }),
99
+ signal: AbortSignal.timeout(5000),
100
+ });
101
+ if (!res.ok)
102
+ return null;
103
+ const outer = await res.json();
104
+ if (!outer.ok)
105
+ return null;
106
+ // 이중 JSON 파싱: result.content[0].text가 JSON 문자열
107
+ const textContent = outer.result?.content?.find(c => c.type === "text")?.text;
108
+ if (!textContent)
109
+ return null;
110
+ const inner = JSON.parse(textContent);
111
+ return inner.sessions ?? null;
112
+ }
113
+ catch {
114
+ return null;
115
+ }
116
+ }
117
+ // Fallback: 로컬 sessions.json 파일 파싱
118
+ function readSessionsFromFile(botId) {
119
+ const sessionsPath = path.join(os.homedir(), `.openclaw-${botId}`, "agents", "main", "sessions", "sessions.json");
120
+ if (!fs.existsSync(sessionsPath))
121
+ return null;
122
+ try {
123
+ const raw = JSON.parse(fs.readFileSync(sessionsPath, "utf-8"));
124
+ return Object.entries(raw).map(([key, val]) => ({
125
+ key,
126
+ updatedAt: val.updatedAt,
127
+ }));
128
+ }
129
+ catch {
130
+ return null;
131
+ }
132
+ }
133
+ // GatewaySession → DB 컬럼 매핑
134
+ function mapSessionToDb(s) {
135
+ // kind: "group" → isolated, 나머지 → main
136
+ const kind = s.kind === "group" ? "isolated" : "main";
137
+ // chat_type: key 패턴으로 파싱 (API의 channel 필드 우선)
138
+ let chatType = s.channel ?? "direct";
139
+ if (!s.channel) {
140
+ if (s.key.includes(":slack:"))
141
+ chatType = "slack";
142
+ else if (s.key.includes(":cron:"))
143
+ chatType = "cron";
144
+ }
145
+ // label: displayName 우선, 없으면 key에서 마지막 부분
146
+ const label = s.displayName ?? s.key.split(":").slice(-2).join(":");
147
+ // last_activity: unix ms → ISO string
148
+ const lastActivity = s.updatedAt ? new Date(s.updatedAt).toISOString() : null;
149
+ return { kind, chatType, label, lastActivity, totalTokens: s.totalTokens ?? null };
150
+ }
60
151
  async function readStdin() {
61
152
  // stdin이 TTY면 hook에서 호출된 게 아님 → 빈 객체 반환
62
153
  if (process.stdin.isTTY)
@@ -120,6 +211,57 @@ async function countMessages(transcriptPath) {
120
211
  rl.on("error", () => resolve(0));
121
212
  });
122
213
  }
214
+ // ─── 외부에서 호출 가능한 sync 헬퍼 ──────────────────────────────────────────
215
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
216
+ async function syncBotSessions(botIds, client) {
217
+ let totalUpserted = 0;
218
+ for (const botId of botIds) {
219
+ let sessions = await fetchSessionsFromGateway(botId);
220
+ if (!sessions)
221
+ sessions = readSessionsFromFile(botId);
222
+ if (!sessions || sessions.length === 0)
223
+ continue;
224
+ let upserted = 0;
225
+ let latestActivity = null;
226
+ for (const s of sessions) {
227
+ try {
228
+ const { kind, chatType, label, lastActivity, totalTokens } = mapSessionToDb(s);
229
+ await client.query(`INSERT INTO semo.bot_sessions
230
+ (bot_id, session_key, label, kind, chat_type, last_activity, message_count, synced_at)
231
+ VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
232
+ ON CONFLICT (bot_id, session_key) DO UPDATE SET
233
+ label = EXCLUDED.label,
234
+ kind = EXCLUDED.kind,
235
+ chat_type = EXCLUDED.chat_type,
236
+ last_activity = COALESCE(EXCLUDED.last_activity, semo.bot_sessions.last_activity),
237
+ message_count = COALESCE(EXCLUDED.message_count, semo.bot_sessions.message_count),
238
+ synced_at = NOW()`, [botId, s.key, label, kind, chatType, lastActivity, totalTokens]);
239
+ upserted++;
240
+ if (lastActivity) {
241
+ const d = new Date(lastActivity);
242
+ if (!latestActivity || d > latestActivity)
243
+ latestActivity = d;
244
+ }
245
+ }
246
+ catch { /* 개별 세션 실패 무시 */ }
247
+ }
248
+ try {
249
+ 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
252
+ WHEN $2::timestamptz IS NOT NULL
253
+ AND (last_active IS NULL OR $2::timestamptz > last_active)
254
+ THEN $2::timestamptz
255
+ ELSE last_active
256
+ END,
257
+ synced_at = NOW()
258
+ WHERE bot_id = $1`, [botId, latestActivity?.toISOString() ?? null]);
259
+ }
260
+ catch { /* bot_status 없으면 무시 */ }
261
+ totalUpserted += upserted;
262
+ }
263
+ return { total: totalUpserted };
264
+ }
123
265
  // ─── Command registration ─────────────────────────────────────────────────────
124
266
  function registerSessionsCommands(program) {
125
267
  const sessionsCmd = program
@@ -209,6 +351,117 @@ function registerSessionsCommands(program) {
209
351
  await (0, database_1.closeConnection)();
210
352
  }
211
353
  });
354
+ // ── semo sessions sync ──────────────────────────────────────────────────────
355
+ sessionsCmd
356
+ .command("sync")
357
+ .description("OpenClaw 게이트웨이에서 세션 읽어 DB upsert")
358
+ .option("--bot-id <id>", "특정 봇만 동기화")
359
+ .option("--all", "semo.bot_status의 모든 봇 동기화")
360
+ .action(async (options) => {
361
+ const connected = await (0, database_1.isDbConnected)();
362
+ if (!connected) {
363
+ console.log(chalk_1.default.red("❌ DB 연결 실패"));
364
+ await (0, database_1.closeConnection)();
365
+ process.exit(1);
366
+ }
367
+ const pool = (0, database_1.getPool)();
368
+ const client = await pool.connect();
369
+ // 대상 봇 목록 결정
370
+ let botIds = [];
371
+ if (options.botId) {
372
+ botIds = [options.botId];
373
+ }
374
+ else if (options.all) {
375
+ try {
376
+ const r = await client.query("SELECT bot_id FROM semo.bot_status ORDER BY bot_id");
377
+ botIds = r.rows.map((row) => row.bot_id);
378
+ }
379
+ catch {
380
+ // bot_status 없으면 openclaw 디렉토리에서 자동 감지
381
+ const home = os.homedir();
382
+ botIds = fs.readdirSync(home)
383
+ .filter(d => d.startsWith(".openclaw-"))
384
+ .map(d => d.replace(".openclaw-", ""))
385
+ .filter(id => id.length > 0);
386
+ }
387
+ }
388
+ else {
389
+ console.log(chalk_1.default.yellow(" --bot-id <id> 또는 --all 옵션 필요"));
390
+ client.release();
391
+ await (0, database_1.closeConnection)();
392
+ process.exit(1);
393
+ }
394
+ if (botIds.length === 0) {
395
+ console.log(chalk_1.default.yellow(" 동기화할 봇 없음"));
396
+ client.release();
397
+ await (0, database_1.closeConnection)();
398
+ return;
399
+ }
400
+ console.log(chalk_1.default.cyan(`\n🔄 sessions sync — ${botIds.length}개 봇\n`));
401
+ let totalUpserted = 0;
402
+ for (const botId of botIds) {
403
+ process.stdout.write(chalk_1.default.gray(` ${botId.padEnd(14)}`));
404
+ // 1) HTTP API 시도
405
+ let sessions = await fetchSessionsFromGateway(botId);
406
+ let source = "gateway";
407
+ // 2) Fallback: 로컬 파일
408
+ if (!sessions) {
409
+ sessions = readSessionsFromFile(botId);
410
+ source = "file";
411
+ }
412
+ if (!sessions || sessions.length === 0) {
413
+ console.log(chalk_1.default.yellow("세션 없음 (게이트웨이 오프라인, 파일 없음)"));
414
+ continue;
415
+ }
416
+ // DB upsert
417
+ let upserted = 0;
418
+ let latestActivity = null;
419
+ for (const s of sessions) {
420
+ try {
421
+ const { kind, chatType, label, lastActivity, totalTokens } = mapSessionToDb(s);
422
+ await client.query(`INSERT INTO semo.bot_sessions
423
+ (bot_id, session_key, label, kind, chat_type, last_activity, message_count, synced_at)
424
+ VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
425
+ ON CONFLICT (bot_id, session_key) DO UPDATE SET
426
+ label = EXCLUDED.label,
427
+ kind = EXCLUDED.kind,
428
+ chat_type = EXCLUDED.chat_type,
429
+ last_activity = COALESCE(EXCLUDED.last_activity, semo.bot_sessions.last_activity),
430
+ message_count = COALESCE(EXCLUDED.message_count, semo.bot_sessions.message_count),
431
+ synced_at = NOW()`, [botId, s.key, label, kind, chatType, lastActivity, totalTokens]);
432
+ upserted++;
433
+ if (lastActivity) {
434
+ const d = new Date(lastActivity);
435
+ if (!latestActivity || d > latestActivity)
436
+ latestActivity = d;
437
+ }
438
+ }
439
+ catch {
440
+ // 개별 세션 실패는 무시
441
+ }
442
+ }
443
+ // bot_status session_count + last_active 갱신
444
+ try {
445
+ 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
448
+ WHEN $2::timestamptz IS NOT NULL
449
+ AND (last_active IS NULL OR $2::timestamptz > last_active)
450
+ THEN $2::timestamptz
451
+ ELSE last_active
452
+ END,
453
+ synced_at = NOW()
454
+ WHERE bot_id = $1`, [botId, latestActivity?.toISOString() ?? null]);
455
+ }
456
+ catch { /* bot_status 없으면 무시 */ }
457
+ totalUpserted += upserted;
458
+ console.log(chalk_1.default.green(`✔ ${upserted}개`) +
459
+ chalk_1.default.gray(` (${source}, total ${sessions.length})`));
460
+ }
461
+ client.release();
462
+ console.log(chalk_1.default.green(`\n✅ sessions sync 완료 — 총 ${totalUpserted}건 upsert\n`));
463
+ await (0, database_1.closeConnection)();
464
+ });
212
465
  // ── semo sessions list ───────────────────────────────────────────────────────
213
466
  sessionsCmd
214
467
  .command("list")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-semicolon/semo-cli",
3
- "version": "4.1.3",
3
+ "version": "4.1.5",
4
4
  "description": "SEMO CLI - AI Agent Orchestration Framework Installer",
5
5
  "main": "dist/index.js",
6
6
  "bin": {