@team-semicolon/semo-cli 4.1.5 → 4.3.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/dist/kb.d.ts CHANGED
@@ -27,86 +27,78 @@ export interface KBEntry {
27
27
  created_at?: string;
28
28
  updated_at?: string;
29
29
  }
30
- export interface BotKBEntry extends KBEntry {
31
- bot_id: string;
32
- synced_at?: string;
33
- }
34
30
  export interface OntologyDomain {
35
31
  domain: string;
36
32
  schema: Record<string, unknown>;
37
33
  description: string | null;
38
34
  version: number;
35
+ service?: string | null;
36
+ entity_type?: string | null;
37
+ parent?: string | null;
38
+ tags?: string[];
39
39
  updated_at?: string;
40
40
  }
41
+ export interface OntologyType {
42
+ type_key: string;
43
+ schema: Record<string, unknown>;
44
+ description: string | null;
45
+ version: number;
46
+ }
41
47
  export interface KBStatusInfo {
42
48
  shared: {
43
49
  total: number;
44
50
  domains: Record<string, number>;
45
51
  lastUpdated: string | null;
46
52
  };
47
- bot: {
48
- total: number;
49
- domains: Record<string, number>;
50
- lastUpdated: string | null;
51
- lastSynced: string | null;
52
- };
53
53
  }
54
- export interface KBDiffResult {
55
- added: KBEntry[];
56
- removed: KBEntry[];
57
- modified: Array<{
58
- local: KBEntry;
59
- remote: KBEntry;
60
- }>;
61
- unchanged: number;
54
+ export interface KBDigestEntry {
55
+ domain: string;
56
+ key: string;
57
+ content: string;
58
+ version: number;
59
+ change_type: 'new' | 'updated';
60
+ updated_at: string;
61
+ }
62
+ export interface KBDigestResult {
63
+ changes: KBDigestEntry[];
64
+ since: string;
65
+ generatedAt: string;
62
66
  }
63
67
  export interface SyncState {
64
- botId: string;
65
68
  lastPull: string | null;
66
69
  lastPush: string | null;
67
70
  sharedCount: number;
68
- botCount: number;
69
71
  }
70
72
  /**
71
- * Get shared KB entries from semo.knowledge_base
73
+ * Pull KB entries from semo.knowledge_base to local .kb/
72
74
  */
73
- export declare function kbPull(pool: Pool, botId: string, domain?: string, cwd?: string): Promise<{
74
- shared: KBEntry[];
75
- bot: BotKBEntry[];
76
- }>;
75
+ export declare function kbPull(pool: Pool, domain?: string, cwd?: string): Promise<KBEntry[]>;
77
76
  /**
78
- * Push local KB entries to database
77
+ * Push local KB entries to database (knowledge_base only)
79
78
  */
80
- export declare function kbPush(pool: Pool, botId: string, entries: KBEntry[], target?: "shared" | "bot", cwd?: string): Promise<{
79
+ export declare function kbPush(pool: Pool, entries: KBEntry[], createdBy?: string, cwd?: string): Promise<{
81
80
  upserted: number;
82
81
  errors: string[];
83
82
  }>;
84
83
  /**
85
- * Get KB status for a bot
84
+ * Get KB status
86
85
  */
87
- export declare function kbStatus(pool: Pool, botId: string): Promise<KBStatusInfo>;
86
+ export declare function kbStatus(pool: Pool): Promise<KBStatusInfo>;
88
87
  /**
89
88
  * List KB entries with optional filters
90
89
  */
91
90
  export declare function kbList(pool: Pool, options: {
92
91
  domain?: string;
93
- botId?: string;
92
+ service?: string;
94
93
  limit?: number;
95
94
  offset?: number;
96
- }): Promise<{
97
- shared: KBEntry[];
98
- bot: BotKBEntry[];
99
- }>;
100
- /**
101
- * Diff local KB files against database
102
- */
103
- export declare function kbDiff(pool: Pool, botId: string, cwd: string): Promise<KBDiffResult>;
95
+ }): Promise<KBEntry[]>;
104
96
  /**
105
97
  * Search KB — hybrid: vector similarity (if embedding available) + text fallback
106
98
  */
107
99
  export declare function kbSearch(pool: Pool, query: string, options: {
108
100
  domain?: string;
109
- botId?: string;
101
+ service?: string;
110
102
  limit?: number;
111
103
  mode?: "semantic" | "text" | "hybrid";
112
104
  }): Promise<KBEntry[]>;
@@ -118,6 +110,10 @@ export declare function ontoList(pool: Pool): Promise<OntologyDomain[]>;
118
110
  * Show ontology detail for a domain
119
111
  */
120
112
  export declare function ontoShow(pool: Pool, domain: string): Promise<OntologyDomain | null>;
113
+ /**
114
+ * List all ontology types (structural templates)
115
+ */
116
+ export declare function ontoListTypes(pool: Pool): Promise<OntologyType[]>;
121
117
  /**
122
118
  * Validate KB entries against ontology schema (basic JSON Schema validation)
123
119
  */
@@ -128,6 +124,11 @@ export declare function ontoValidate(pool: Pool, domain: string, entries?: KBEnt
128
124
  errors: string[];
129
125
  }>;
130
126
  }>;
127
+ /**
128
+ * Generate KB change digest based on a since timestamp.
129
+ * Returns all KB changes since the given ISO timestamp.
130
+ */
131
+ export declare function kbDigest(pool: Pool, since: string, domain?: string): Promise<KBDigestResult>;
131
132
  /**
132
133
  * Write ontology schemas to local cache
133
134
  */
package/dist/kb.js CHANGED
@@ -48,11 +48,12 @@ exports.kbPull = kbPull;
48
48
  exports.kbPush = kbPush;
49
49
  exports.kbStatus = kbStatus;
50
50
  exports.kbList = kbList;
51
- exports.kbDiff = kbDiff;
52
51
  exports.kbSearch = kbSearch;
53
52
  exports.ontoList = ontoList;
54
53
  exports.ontoShow = ontoShow;
54
+ exports.ontoListTypes = ontoListTypes;
55
55
  exports.ontoValidate = ontoValidate;
56
+ exports.kbDigest = kbDigest;
56
57
  exports.ontoPullToLocal = ontoPullToLocal;
57
58
  const fs = __importStar(require("fs"));
58
59
  const path = __importStar(require("path"));
@@ -153,7 +154,7 @@ function readSyncState(cwd) {
153
154
  // corrupted file
154
155
  }
155
156
  }
156
- return { botId: "", lastPull: null, lastPush: null, sharedCount: 0, botCount: 0 };
157
+ return { lastPull: null, lastPush: null, sharedCount: 0 };
157
158
  }
158
159
  function writeSyncState(cwd, state) {
159
160
  const kbDir = ensureKBDir(cwd);
@@ -178,88 +179,75 @@ function readKBFile(cwd, filename) {
178
179
  // Database Operations
179
180
  // ============================================================
180
181
  /**
181
- * Get shared KB entries from semo.knowledge_base
182
+ * Pull KB entries from semo.knowledge_base to local .kb/
182
183
  */
183
- async function kbPull(pool, botId, domain, cwd) {
184
+ async function kbPull(pool, domain, cwd) {
184
185
  const client = await pool.connect();
185
186
  try {
186
- // Shared KB
187
- let sharedQuery = `
187
+ let query = `
188
188
  SELECT domain, key, content, metadata, created_by, version,
189
189
  created_at::text, updated_at::text
190
190
  FROM semo.knowledge_base
191
191
  `;
192
- const sharedParams = [];
192
+ const params = [];
193
193
  if (domain) {
194
- sharedQuery += " WHERE domain = $1";
195
- sharedParams.push(domain);
194
+ query += " WHERE domain = $1";
195
+ params.push(domain);
196
196
  }
197
- sharedQuery += " ORDER BY domain, key";
198
- const sharedResult = await client.query(sharedQuery, sharedParams);
199
- const shared = sharedResult.rows;
200
- // Bot-specific KB
201
- let botQuery = `
202
- SELECT bot_id, domain, key, content, metadata, version,
203
- synced_at::text, created_at::text, updated_at::text
204
- FROM semo.bot_knowledge
205
- WHERE bot_id = $1
206
- `;
207
- const botParams = [botId];
208
- if (domain) {
209
- botQuery += " AND domain = $2";
210
- botParams.push(domain);
211
- }
212
- botQuery += " ORDER BY domain, key";
213
- const botResult = await client.query(botQuery, botParams);
214
- const bot = botResult.rows;
215
- // Write to local files if cwd provided
197
+ query += " ORDER BY domain, key";
198
+ const result = await client.query(query, params);
199
+ const entries = result.rows;
216
200
  if (cwd) {
217
- writeKBFile(cwd, "team.json", shared);
218
- writeKBFile(cwd, "bot.json", bot);
201
+ writeKBFile(cwd, "team.json", entries);
219
202
  const state = readSyncState(cwd);
220
- state.botId = botId;
221
203
  state.lastPull = new Date().toISOString();
222
- state.sharedCount = shared.length;
223
- state.botCount = bot.length;
204
+ state.sharedCount = entries.length;
224
205
  writeSyncState(cwd, state);
225
206
  }
226
- return { shared, bot };
207
+ return entries;
227
208
  }
228
209
  finally {
229
210
  client.release();
230
211
  }
231
212
  }
232
213
  /**
233
- * Push local KB entries to database
214
+ * Push local KB entries to database (knowledge_base only)
234
215
  */
235
- async function kbPush(pool, botId, entries, target = "bot", cwd) {
216
+ async function kbPush(pool, entries, createdBy, cwd) {
236
217
  const client = await pool.connect();
237
218
  let upserted = 0;
238
219
  const errors = [];
239
220
  try {
240
- await client.query("BEGIN");
221
+ // Domain validation: check all domains against ontology before transaction
222
+ const ontologyResult = await client.query("SELECT domain FROM semo.ontology");
223
+ const knownDomains = new Set(ontologyResult.rows.map((r) => r.domain));
224
+ const invalidEntries = [];
225
+ const validEntries = [];
241
226
  for (const entry of entries) {
227
+ if (knownDomains.has(entry.domain)) {
228
+ validEntries.push(entry);
229
+ }
230
+ else {
231
+ invalidEntries.push(`${entry.domain}/${entry.key}: 미등록 도메인 '${entry.domain}'`);
232
+ }
233
+ }
234
+ if (invalidEntries.length > 0) {
235
+ errors.push(...invalidEntries);
236
+ }
237
+ await client.query("BEGIN");
238
+ const texts = validEntries.map(e => `${e.key}: ${e.content}`);
239
+ const embeddings = await generateEmbeddings(texts);
240
+ for (let i = 0; i < validEntries.length; i++) {
241
+ const entry = validEntries[i];
242
242
  try {
243
- // Generate embedding for content
244
- const embedding = await generateEmbedding(`${entry.key}: ${entry.content}`);
243
+ const embedding = embeddings[i];
245
244
  const embeddingStr = embedding ? `[${embedding.join(",")}]` : null;
246
- if (target === "shared") {
247
- await client.query(`INSERT INTO semo.knowledge_base (domain, key, content, metadata, created_by, embedding)
248
- VALUES ($1, $2, $3, $4, $5, $6::vector)
249
- ON CONFLICT (domain, key) DO UPDATE SET
250
- content = EXCLUDED.content,
251
- metadata = EXCLUDED.metadata,
252
- embedding = EXCLUDED.embedding`, [entry.domain, entry.key, entry.content, JSON.stringify(entry.metadata || {}), entry.created_by || botId, embeddingStr]);
253
- }
254
- else {
255
- await client.query(`INSERT INTO semo.bot_knowledge (bot_id, domain, key, content, metadata, synced_at, embedding)
256
- VALUES ($1, $2, $3, $4, $5, NOW(), $6::vector)
257
- ON CONFLICT (bot_id, domain, key) DO UPDATE SET
258
- content = EXCLUDED.content,
259
- metadata = EXCLUDED.metadata,
260
- synced_at = NOW(),
261
- embedding = EXCLUDED.embedding`, [botId, entry.domain, entry.key, entry.content, JSON.stringify(entry.metadata || {}), embeddingStr]);
262
- }
245
+ await client.query(`INSERT INTO semo.knowledge_base (domain, key, content, metadata, created_by, embedding)
246
+ VALUES ($1, $2, $3, $4, $5, $6::vector)
247
+ ON CONFLICT (domain, key) DO UPDATE SET
248
+ content = EXCLUDED.content,
249
+ metadata = EXCLUDED.metadata,
250
+ embedding = EXCLUDED.embedding`, [entry.domain, entry.key, entry.content, JSON.stringify(entry.metadata || {}), entry.created_by || createdBy || "unknown", embeddingStr]);
263
251
  upserted++;
264
252
  }
265
253
  catch (err) {
@@ -269,7 +257,6 @@ async function kbPush(pool, botId, entries, target = "bot", cwd) {
269
257
  await client.query("COMMIT");
270
258
  if (cwd) {
271
259
  const state = readSyncState(cwd);
272
- state.botId = botId;
273
260
  state.lastPush = new Date().toISOString();
274
261
  writeSyncState(cwd, state);
275
262
  }
@@ -284,12 +271,11 @@ async function kbPush(pool, botId, entries, target = "bot", cwd) {
284
271
  return { upserted, errors };
285
272
  }
286
273
  /**
287
- * Get KB status for a bot
274
+ * Get KB status
288
275
  */
289
- async function kbStatus(pool, botId) {
276
+ async function kbStatus(pool) {
290
277
  const client = await pool.connect();
291
278
  try {
292
- // Shared KB stats
293
279
  const sharedStats = await client.query(`
294
280
  SELECT domain, COUNT(*)::int as count
295
281
  FROM semo.knowledge_base
@@ -301,31 +287,12 @@ async function kbStatus(pool, botId) {
301
287
  for (const row of sharedStats.rows) {
302
288
  sharedDomains[row.domain] = row.count;
303
289
  }
304
- // Bot KB stats
305
- const botStats = await client.query(`
306
- SELECT domain, COUNT(*)::int as count
307
- FROM semo.bot_knowledge WHERE bot_id = $1
308
- GROUP BY domain ORDER BY domain
309
- `, [botId]);
310
- const botTotal = await client.query(`SELECT COUNT(*)::int as total FROM semo.bot_knowledge WHERE bot_id = $1`, [botId]);
311
- const botLastUpdated = await client.query(`SELECT MAX(updated_at)::text as last FROM semo.bot_knowledge WHERE bot_id = $1`, [botId]);
312
- const botLastSynced = await client.query(`SELECT MAX(synced_at)::text as last FROM semo.bot_knowledge WHERE bot_id = $1`, [botId]);
313
- const botDomains = {};
314
- for (const row of botStats.rows) {
315
- botDomains[row.domain] = row.count;
316
- }
317
290
  return {
318
291
  shared: {
319
292
  total: sharedTotal.rows[0]?.total || 0,
320
293
  domains: sharedDomains,
321
294
  lastUpdated: sharedLastUpdated.rows[0]?.last || null,
322
295
  },
323
- bot: {
324
- total: botTotal.rows[0]?.total || 0,
325
- domains: botDomains,
326
- lastUpdated: botLastUpdated.rows[0]?.last || null,
327
- lastSynced: botLastSynced.rows[0]?.last || null,
328
- },
329
296
  };
330
297
  }
331
298
  finally {
@@ -340,79 +307,30 @@ async function kbList(pool, options) {
340
307
  const limit = options.limit || 50;
341
308
  const offset = options.offset || 0;
342
309
  try {
343
- // Shared
344
- let sharedQuery = "SELECT domain, key, content, metadata, created_by, version, updated_at::text FROM semo.knowledge_base";
345
- const sharedParams = [];
310
+ let query = "SELECT domain, key, content, metadata, created_by, version, updated_at::text FROM semo.knowledge_base";
311
+ const params = [];
346
312
  let paramIdx = 1;
347
313
  if (options.domain) {
348
- sharedQuery += ` WHERE domain = $${paramIdx++}`;
349
- sharedParams.push(options.domain);
314
+ query += ` WHERE domain = $${paramIdx++}`;
315
+ params.push(options.domain);
350
316
  }
351
- sharedQuery += ` ORDER BY domain, key LIMIT $${paramIdx++} OFFSET $${paramIdx++}`;
352
- sharedParams.push(limit, offset);
353
- const sharedResult = await client.query(sharedQuery, sharedParams);
354
- // Bot
355
- let bot = [];
356
- if (options.botId) {
357
- let botQuery = "SELECT bot_id, domain, key, content, metadata, version, synced_at::text, updated_at::text FROM semo.bot_knowledge WHERE bot_id = $1";
358
- const botParams = [options.botId];
359
- let bParamIdx = 2;
360
- if (options.domain) {
361
- botQuery += ` AND domain = $${bParamIdx++}`;
362
- botParams.push(options.domain);
317
+ else if (options.service) {
318
+ // Resolve service to domain list: service name itself + dot-notation domains
319
+ const serviceDomains = await resolveServiceDomainsLocal(client, options.service);
320
+ if (serviceDomains.length > 0) {
321
+ query += ` WHERE domain = ANY($${paramIdx++})`;
322
+ params.push(serviceDomains);
363
323
  }
364
- botQuery += ` ORDER BY domain, key LIMIT $${bParamIdx++} OFFSET $${bParamIdx++}`;
365
- botParams.push(limit, offset);
366
- const botResult = await client.query(botQuery, botParams);
367
- bot = botResult.rows;
368
324
  }
369
- return { shared: sharedResult.rows, bot };
325
+ query += ` ORDER BY domain, key LIMIT $${paramIdx++} OFFSET $${paramIdx++}`;
326
+ params.push(limit, offset);
327
+ const result = await client.query(query, params);
328
+ return result.rows;
370
329
  }
371
330
  finally {
372
331
  client.release();
373
332
  }
374
333
  }
375
- /**
376
- * Diff local KB files against database
377
- */
378
- async function kbDiff(pool, botId, cwd) {
379
- const localShared = readKBFile(cwd, "team.json");
380
- const localBot = readKBFile(cwd, "bot.json");
381
- const localAll = [...localShared, ...localBot];
382
- const { shared: remoteShared, bot: remoteBot } = await kbPull(pool, botId);
383
- const remoteAll = [...remoteShared, ...remoteBot];
384
- const localMap = new Map(localAll.map(e => [`${e.domain}/${e.key}`, e]));
385
- const remoteMap = new Map(remoteAll.map(e => [`${e.domain}/${e.key}`, e]));
386
- const added = [];
387
- const removed = [];
388
- const modified = [];
389
- let unchanged = 0;
390
- // Remote entries not in local → added
391
- for (const [k, v] of remoteMap) {
392
- if (!localMap.has(k)) {
393
- added.push(v);
394
- }
395
- }
396
- // Local entries not in remote → removed
397
- for (const [k, v] of localMap) {
398
- if (!remoteMap.has(k)) {
399
- removed.push(v);
400
- }
401
- }
402
- // Both exist → check content
403
- for (const [k, local] of localMap) {
404
- const remote = remoteMap.get(k);
405
- if (remote) {
406
- if (local.content !== remote.content || JSON.stringify(local.metadata) !== JSON.stringify(remote.metadata)) {
407
- modified.push({ local, remote });
408
- }
409
- else {
410
- unchanged++;
411
- }
412
- }
413
- }
414
- return { added, removed, modified, unchanged };
415
- }
416
334
  /**
417
335
  * Search KB — hybrid: vector similarity (if embedding available) + text fallback
418
336
  */
@@ -420,6 +338,11 @@ async function kbSearch(pool, query, options) {
420
338
  const client = await pool.connect();
421
339
  const limit = options.limit || 10;
422
340
  const mode = options.mode || "hybrid";
341
+ // Resolve service → domain list for filtering
342
+ let serviceDomains = null;
343
+ if (options.service && !options.domain) {
344
+ serviceDomains = await resolveServiceDomainsLocal(client, options.service);
345
+ }
423
346
  try {
424
347
  let results = [];
425
348
  // Try semantic search first (if mode allows and embedding API available)
@@ -440,31 +363,14 @@ async function kbSearch(pool, query, options) {
440
363
  sql += ` AND domain = $${paramIdx++}`;
441
364
  params.push(options.domain);
442
365
  }
366
+ else if (serviceDomains && serviceDomains.length > 0) {
367
+ sql += ` AND domain = ANY($${paramIdx++})`;
368
+ params.push(serviceDomains);
369
+ }
443
370
  sql += ` ORDER BY embedding <=> $1::vector LIMIT $${paramIdx++}`;
444
371
  params.push(limit);
445
372
  const sharedResult = await client.query(sql, params);
446
373
  results = sharedResult.rows;
447
- // Also search bot_knowledge if botId specified
448
- if (options.botId) {
449
- let botSql = `
450
- SELECT bot_id, domain, key, content, metadata, version, updated_at::text,
451
- 1 - (embedding <=> $1::vector) as score
452
- FROM semo.bot_knowledge
453
- WHERE bot_id = $2 AND embedding IS NOT NULL
454
- `;
455
- const botParams = [embeddingStr, options.botId];
456
- let bIdx = 3;
457
- if (options.domain) {
458
- botSql += ` AND domain = $${bIdx++}`;
459
- botParams.push(options.domain);
460
- }
461
- botSql += ` ORDER BY embedding <=> $1::vector LIMIT $${bIdx++}`;
462
- botParams.push(limit);
463
- const botResult = await client.query(botSql, botParams);
464
- results = [...results, ...botResult.rows]
465
- .sort((a, b) => (b.score || 0) - (a.score || 0))
466
- .slice(0, limit);
467
- }
468
374
  // If we got results from semantic search and mode is not hybrid, return
469
375
  if (results.length > 0 && mode === "semantic") {
470
376
  return results;
@@ -472,31 +378,62 @@ async function kbSearch(pool, query, options) {
472
378
  }
473
379
  }
474
380
  // Text search (fallback or hybrid supplement)
381
+ // Split query into tokens and match ANY token via ILIKE (Korean-friendly)
475
382
  if (mode !== "semantic" || results.length === 0) {
383
+ const tokens = query.split(/\s+/).filter(t => t.length >= 2);
384
+ const textParams = [];
385
+ let tIdx = 1;
386
+ // Build per-token ILIKE conditions + count matching tokens for scoring
387
+ const tokenConditions = tokens.map(token => {
388
+ textParams.push(`%${token}%`);
389
+ return `(CASE WHEN content ILIKE $${tIdx} OR key ILIKE $${tIdx++} THEN 1 ELSE 0 END)`;
390
+ });
391
+ // Score = 0.7 base + 0.15 * (matched_tokens / total_tokens), capped at 0.95
392
+ const matchCountExpr = tokenConditions.length > 0
393
+ ? tokenConditions.join(" + ")
394
+ : "0";
395
+ const scoreExpr = `LEAST(0.95, 0.7 + 0.15 * (${matchCountExpr})::float / ${Math.max(tokens.length, 1)})`;
396
+ // WHERE: any token matches
397
+ const whereTokens = tokens.map((_, i) => `(content ILIKE $${i + 1} OR key ILIKE $${i + 1})`);
398
+ const whereClause = whereTokens.length > 0
399
+ ? whereTokens.join(" OR ")
400
+ : "FALSE";
476
401
  let textSql = `
477
402
  SELECT domain, key, content, metadata, created_by, version, updated_at::text,
478
- 0.0 as score
403
+ ${scoreExpr} as score
479
404
  FROM semo.knowledge_base
480
- WHERE content ILIKE $1 OR key ILIKE $1
405
+ WHERE ${whereClause}
481
406
  `;
482
- const textParams = [`%${query}%`];
483
- let tIdx = 2;
484
407
  if (options.domain) {
485
408
  textSql += ` AND domain = $${tIdx++}`;
486
409
  textParams.push(options.domain);
487
410
  }
488
- textSql += ` ORDER BY updated_at DESC LIMIT $${tIdx++}`;
411
+ else if (serviceDomains && serviceDomains.length > 0) {
412
+ textSql += ` AND domain = ANY($${tIdx++})`;
413
+ textParams.push(serviceDomains);
414
+ }
415
+ textSql += ` ORDER BY score DESC, updated_at DESC LIMIT $${tIdx++}`;
489
416
  textParams.push(limit);
490
417
  const textResult = await client.query(textSql, textParams);
491
- // Merge: deduplicate by domain/key, prefer semantic results
492
- const seen = new Set(results.map((r) => `${r.domain}/${r.key}`));
418
+ // Merge: text matches get priority score (0.85) for exact keyword hits
419
+ // Deduplicate by domain/key; if already in semantic results, boost its score
420
+ const resultMap = new Map();
421
+ for (const r of results) {
422
+ resultMap.set(`${r.domain}/${r.key}`, r);
423
+ }
493
424
  for (const row of textResult.rows) {
494
425
  const k = `${row.domain}/${row.key}`;
495
- if (!seen.has(k)) {
496
- results.push(row);
497
- seen.add(k);
426
+ const existing = resultMap.get(k);
427
+ if (existing) {
428
+ // Boost: semantic match + text match = highest relevance
429
+ existing.score = Math.max(Number(existing.score), 0.85);
430
+ }
431
+ else {
432
+ resultMap.set(k, row);
498
433
  }
499
434
  }
435
+ // Sort by score descending
436
+ results = Array.from(resultMap.values()).sort((a, b) => Number(b.score) - Number(a.score));
500
437
  }
501
438
  return results.slice(0, limit);
502
439
  }
@@ -532,8 +469,10 @@ async function ontoList(pool) {
532
469
  const client = await pool.connect();
533
470
  try {
534
471
  const result = await client.query(`
535
- SELECT domain, schema, description, version, updated_at::text
536
- FROM semo.ontology ORDER BY domain
472
+ SELECT domain, schema, description, version,
473
+ service, entity_type, parent, tags,
474
+ updated_at::text
475
+ FROM semo.ontology ORDER BY service NULLS FIRST, domain
537
476
  `);
538
477
  return result.rows;
539
478
  }
@@ -547,13 +486,52 @@ async function ontoList(pool) {
547
486
  async function ontoShow(pool, domain) {
548
487
  const client = await pool.connect();
549
488
  try {
550
- const result = await client.query(`SELECT domain, schema, description, version, updated_at::text FROM semo.ontology WHERE domain = $1`, [domain]);
489
+ const result = await client.query(`SELECT domain, schema, description, version,
490
+ service, entity_type, parent, tags,
491
+ updated_at::text
492
+ FROM semo.ontology WHERE domain = $1`, [domain]);
551
493
  return result.rows[0] || null;
552
494
  }
553
495
  finally {
554
496
  client.release();
555
497
  }
556
498
  }
499
+ /**
500
+ * List all ontology types (structural templates)
501
+ */
502
+ async function ontoListTypes(pool) {
503
+ const client = await pool.connect();
504
+ try {
505
+ const result = await client.query(`
506
+ SELECT type_key, schema, description, version
507
+ FROM semo.ontology_types ORDER BY type_key
508
+ `);
509
+ return result.rows;
510
+ }
511
+ catch {
512
+ return []; // Table may not exist yet (pre-016 migration)
513
+ }
514
+ finally {
515
+ client.release();
516
+ }
517
+ }
518
+ /**
519
+ * Resolve a service name to its associated domain list.
520
+ * Uses ontology.service column + dot-notation domain detection.
521
+ */
522
+ async function resolveServiceDomainsLocal(client, service) {
523
+ try {
524
+ const result = await client.query(`SELECT domain FROM semo.ontology WHERE service = $1
525
+ UNION
526
+ SELECT domain FROM semo.ontology WHERE domain LIKE $2
527
+ UNION
528
+ SELECT domain FROM semo.ontology WHERE domain = $1`, [service, `${service}.%`]);
529
+ return result.rows.map((r) => r.domain);
530
+ }
531
+ catch {
532
+ return [];
533
+ }
534
+ }
557
535
  /**
558
536
  * Validate KB entries against ontology schema (basic JSON Schema validation)
559
537
  */
@@ -613,6 +591,37 @@ async function ontoValidate(pool, domain, entries) {
613
591
  }
614
592
  return { valid, invalid };
615
593
  }
594
+ // ============================================================
595
+ // KB Digest
596
+ // ============================================================
597
+ /**
598
+ * Generate KB change digest based on a since timestamp.
599
+ * Returns all KB changes since the given ISO timestamp.
600
+ */
601
+ async function kbDigest(pool, since, domain) {
602
+ const client = await pool.connect();
603
+ const generatedAt = new Date().toISOString();
604
+ try {
605
+ let sql = `
606
+ SELECT domain, key, content, version, updated_at::text,
607
+ CASE WHEN created_at > $1 THEN 'new' ELSE 'updated' END as change_type
608
+ FROM semo.knowledge_base
609
+ WHERE updated_at > $1 OR created_at > $1
610
+ `;
611
+ const params = [since];
612
+ let paramIdx = 2;
613
+ if (domain) {
614
+ sql += ` AND domain = $${paramIdx++}`;
615
+ params.push(domain);
616
+ }
617
+ sql += ` ORDER BY updated_at DESC LIMIT 100`;
618
+ const result = await client.query(sql, params);
619
+ return { changes: result.rows, since, generatedAt };
620
+ }
621
+ finally {
622
+ client.release();
623
+ }
624
+ }
616
625
  /**
617
626
  * Write ontology schemas to local cache
618
627
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-semicolon/semo-cli",
3
- "version": "4.1.5",
3
+ "version": "4.3.0",
4
4
  "description": "SEMO CLI - AI Agent Orchestration Framework Installer",
5
5
  "main": "dist/index.js",
6
6
  "bin": {