@team-semicolon/semo-cli 4.1.3 → 4.1.4
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/dist/commands/bots.js +16 -3
- package/dist/commands/sessions.d.ts +21 -8
- package/dist/commands/sessions.js +261 -8
- package/package.json +1 -1
package/dist/commands/bots.js
CHANGED
|
@@ -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
|
-
*
|
|
5
|
-
* semo.bot_sessions
|
|
4
|
+
* sync: 각 봇의 OpenClaw 게이트웨이(HTTP API) 또는 로컬 sessions.json에서
|
|
5
|
+
* 세션 데이터를 읽어 semo.bot_sessions에 upsert합니다.
|
|
6
6
|
*
|
|
7
|
-
* Claude Code
|
|
8
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
*
|
|
6
|
-
* semo.bot_sessions
|
|
5
|
+
* sync: 각 봇의 OpenClaw 게이트웨이(HTTP API) 또는 로컬 sessions.json에서
|
|
6
|
+
* 세션 데이터를 읽어 semo.bot_sessions에 upsert합니다.
|
|
7
7
|
*
|
|
8
|
-
* Claude Code
|
|
9
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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")
|