@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.
- package/README.md +3 -4
- package/dist/commands/audit.d.ts +27 -0
- package/dist/commands/audit.js +338 -0
- package/dist/commands/bots.js +524 -24
- package/dist/commands/context.d.ts +14 -3
- package/dist/commands/context.js +192 -113
- package/dist/commands/db.d.ts +9 -0
- package/dist/commands/db.js +189 -0
- package/dist/commands/get.d.ts +1 -2
- package/dist/commands/get.js +24 -116
- package/dist/commands/sessions.d.ts +2 -1
- package/dist/commands/sessions.js +31 -62
- package/dist/commands/skill-sync.d.ts +28 -0
- package/dist/commands/skill-sync.js +111 -0
- package/dist/commands/skill-sync.test.d.ts +16 -0
- package/dist/commands/skill-sync.test.js +186 -0
- package/dist/database.d.ts +41 -3
- package/dist/database.js +128 -554
- package/dist/env-parser.d.ts +5 -0
- package/dist/env-parser.js +27 -0
- package/dist/global-cache.d.ts +12 -0
- package/dist/global-cache.js +184 -0
- package/dist/index.js +352 -817
- package/dist/kb.d.ts +24 -39
- package/dist/kb.js +121 -175
- package/package.json +1 -1
package/dist/commands/get.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
63
|
+
process.exit(1);
|
|
79
64
|
}
|
|
80
65
|
try {
|
|
81
66
|
const pool = (0, database_1.getPool)();
|
|
82
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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(
|
|
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(["
|
|
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
|
|
192
|
+
const kbEntries = await (0, kb_1.kbList)(pool, {
|
|
221
193
|
domain: options.domain,
|
|
222
194
|
limit,
|
|
223
195
|
});
|
|
224
|
-
entries =
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
412
|
+
// session_count는 trg_session_count 트리거가 자동 관리
|
|
413
|
+
// last_active만 업데이트
|
|
444
414
|
try {
|
|
445
415
|
await client.query(`UPDATE semo.bot_status
|
|
446
|
-
SET
|
|
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 {};
|