@team-semicolon/semo-cli 4.13.0 → 4.15.1

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.js CHANGED
@@ -81,7 +81,7 @@ function combineKey(key, subKey) {
81
81
  // ============================================================
82
82
  // Embedding
83
83
  // ============================================================
84
- const EMBEDDING_MODEL = "text-embedding-3-small";
84
+ const EMBEDDING_MODEL = 'text-embedding-3-small';
85
85
  const EMBEDDING_DIMENSIONS = 1024; // DB vector(1024) 유지 — OpenAI dimensions 파라미터로 축소
86
86
  /**
87
87
  * Generate embedding vector for text using OpenAI Embeddings API
@@ -92,11 +92,11 @@ async function generateEmbedding(text) {
92
92
  if (!apiKey)
93
93
  return null;
94
94
  try {
95
- const response = await fetch("https://api.openai.com/v1/embeddings", {
96
- method: "POST",
95
+ const response = await fetch('https://api.openai.com/v1/embeddings', {
96
+ method: 'POST',
97
97
  headers: {
98
- "Authorization": `Bearer ${apiKey}`,
99
- "Content-Type": "application/json",
98
+ Authorization: `Bearer ${apiKey}`,
99
+ 'Content-Type': 'application/json',
100
100
  },
101
101
  body: JSON.stringify({
102
102
  model: EMBEDDING_MODEL,
@@ -109,7 +109,7 @@ async function generateEmbedding(text) {
109
109
  console.error(`Embedding API error: ${response.status} ${err}`);
110
110
  return null;
111
111
  }
112
- const data = await response.json();
112
+ const data = (await response.json());
113
113
  return data.data?.[0]?.embedding || null;
114
114
  }
115
115
  catch (err) {
@@ -125,21 +125,21 @@ async function generateEmbeddings(texts) {
125
125
  if (!apiKey)
126
126
  return texts.map(() => null);
127
127
  try {
128
- const response = await fetch("https://api.openai.com/v1/embeddings", {
129
- method: "POST",
128
+ const response = await fetch('https://api.openai.com/v1/embeddings', {
129
+ method: 'POST',
130
130
  headers: {
131
- "Authorization": `Bearer ${apiKey}`,
132
- "Content-Type": "application/json",
131
+ Authorization: `Bearer ${apiKey}`,
132
+ 'Content-Type': 'application/json',
133
133
  },
134
134
  body: JSON.stringify({
135
135
  model: EMBEDDING_MODEL,
136
- input: texts.map(t => t.substring(0, 8000)),
136
+ input: texts.map((t) => t.substring(0, 8000)),
137
137
  dimensions: EMBEDDING_DIMENSIONS,
138
138
  }),
139
139
  });
140
140
  if (!response.ok)
141
141
  return texts.map(() => null);
142
- const data = await response.json();
142
+ const data = (await response.json());
143
143
  return data.data?.map((d) => d.embedding) || texts.map(() => null);
144
144
  }
145
145
  catch {
@@ -149,8 +149,8 @@ async function generateEmbeddings(texts) {
149
149
  // ============================================================
150
150
  // KB Directory Management
151
151
  // ============================================================
152
- const KB_DIR = ".kb";
153
- const SYNC_STATE_FILE = ".sync-state.json";
152
+ const KB_DIR = '.kb';
153
+ const SYNC_STATE_FILE = '.sync-state.json';
154
154
  function getKBDir(cwd) {
155
155
  return path.join(cwd, KB_DIR);
156
156
  }
@@ -159,7 +159,7 @@ function ensureKBDir(cwd) {
159
159
  if (!fs.existsSync(kbDir)) {
160
160
  fs.mkdirSync(kbDir, { recursive: true });
161
161
  }
162
- const ontoDir = path.join(kbDir, "ontology");
162
+ const ontoDir = path.join(kbDir, 'ontology');
163
163
  if (!fs.existsSync(ontoDir)) {
164
164
  fs.mkdirSync(ontoDir, { recursive: true });
165
165
  }
@@ -169,7 +169,7 @@ function readSyncState(cwd) {
169
169
  const statePath = path.join(getKBDir(cwd), SYNC_STATE_FILE);
170
170
  if (fs.existsSync(statePath)) {
171
171
  try {
172
- return JSON.parse(fs.readFileSync(statePath, "utf-8"));
172
+ return JSON.parse(fs.readFileSync(statePath, 'utf-8'));
173
173
  }
174
174
  catch {
175
175
  // corrupted file
@@ -190,7 +190,7 @@ function readKBFile(cwd, filename) {
190
190
  if (!fs.existsSync(filePath))
191
191
  return [];
192
192
  try {
193
- return JSON.parse(fs.readFileSync(filePath, "utf-8"));
193
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
194
194
  }
195
195
  catch {
196
196
  return [];
@@ -212,14 +212,14 @@ async function kbPull(pool, domain, cwd) {
212
212
  `;
213
213
  const params = [];
214
214
  if (domain) {
215
- query += " WHERE domain = $1";
215
+ query += ' WHERE domain = $1';
216
216
  params.push(domain);
217
217
  }
218
- query += " ORDER BY domain, key";
218
+ query += ' ORDER BY domain, key';
219
219
  const result = await client.query(query, params);
220
220
  const entries = result.rows;
221
221
  if (cwd) {
222
- writeKBFile(cwd, "team.json", entries);
222
+ writeKBFile(cwd, 'team.json', entries);
223
223
  const state = readSyncState(cwd);
224
224
  state.lastPull = new Date().toISOString();
225
225
  state.sharedCount = entries.length;
@@ -240,7 +240,7 @@ async function kbPush(pool, entries, createdBy, cwd) {
240
240
  const errors = [];
241
241
  try {
242
242
  // Domain validation: check all domains against ontology before transaction
243
- const ontologyResult = await client.query("SELECT domain FROM semo.ontology");
243
+ const ontologyResult = await client.query('SELECT domain FROM semo.ontology');
244
244
  const knownDomains = new Set(ontologyResult.rows.map((r) => r.domain));
245
245
  const invalidEntries = [];
246
246
  const validEntries = [];
@@ -255,28 +255,36 @@ async function kbPush(pool, entries, createdBy, cwd) {
255
255
  if (invalidEntries.length > 0) {
256
256
  errors.push(...invalidEntries);
257
257
  }
258
- await client.query("BEGIN");
259
- const texts = validEntries.map(e => `${e.key}: ${e.content}`);
258
+ await client.query('BEGIN');
259
+ const texts = validEntries.map((e) => `${e.key}: ${e.content}`);
260
260
  const embeddings = await generateEmbeddings(texts);
261
261
  for (let i = 0; i < validEntries.length; i++) {
262
262
  const entry = validEntries[i];
263
263
  try {
264
264
  const embedding = embeddings[i];
265
- const embeddingStr = embedding ? `[${embedding.join(",")}]` : null;
265
+ const embeddingStr = embedding ? `[${embedding.join(',')}]` : null;
266
266
  const { key: flatKey, subKey } = splitKey(entry.key);
267
267
  await client.query(`INSERT INTO semo.knowledge_base (domain, key, sub_key, content, metadata, created_by, embedding)
268
268
  VALUES ($1, $2, $3, $4, $5, $6, $7::vector)
269
269
  ON CONFLICT (domain, key, sub_key) DO UPDATE SET
270
270
  content = EXCLUDED.content,
271
271
  metadata = EXCLUDED.metadata,
272
- embedding = EXCLUDED.embedding`, [entry.domain, flatKey, subKey, entry.content, JSON.stringify(entry.metadata || {}), entry.created_by || createdBy || "unknown", embeddingStr]);
272
+ embedding = EXCLUDED.embedding`, [
273
+ entry.domain,
274
+ flatKey,
275
+ subKey,
276
+ entry.content,
277
+ JSON.stringify(entry.metadata || {}),
278
+ entry.created_by || createdBy || 'unknown',
279
+ embeddingStr,
280
+ ]);
273
281
  upserted++;
274
282
  }
275
283
  catch (err) {
276
284
  errors.push(`${entry.domain}/${entry.key}: ${err}`);
277
285
  }
278
286
  }
279
- await client.query("COMMIT");
287
+ await client.query('COMMIT');
280
288
  if (cwd) {
281
289
  const state = readSyncState(cwd);
282
290
  state.lastPush = new Date().toISOString();
@@ -284,7 +292,7 @@ async function kbPush(pool, entries, createdBy, cwd) {
284
292
  }
285
293
  }
286
294
  catch (err) {
287
- await client.query("ROLLBACK");
295
+ await client.query('ROLLBACK');
288
296
  errors.push(`Transaction failed: ${err}`);
289
297
  }
290
298
  finally {
@@ -329,7 +337,7 @@ async function kbList(pool, options) {
329
337
  const limit = options.limit || 50;
330
338
  const offset = options.offset || 0;
331
339
  try {
332
- let query = "SELECT domain, key, sub_key, content, metadata, created_by, version, updated_at::text FROM semo.knowledge_base";
340
+ let query = 'SELECT domain, key, sub_key, content, metadata, created_by, version, updated_at::text FROM semo.knowledge_base';
333
341
  const params = [];
334
342
  let paramIdx = 1;
335
343
  if (options.domain) {
@@ -359,7 +367,7 @@ async function kbList(pool, options) {
359
367
  async function kbSearch(pool, query, options) {
360
368
  const client = await pool.connect();
361
369
  const limit = options.limit || 10;
362
- const mode = options.mode || "hybrid";
370
+ const mode = options.mode || 'hybrid';
363
371
  // Resolve service → domain list for filtering
364
372
  let serviceDomains = null;
365
373
  if (options.service && !options.domain) {
@@ -368,10 +376,10 @@ async function kbSearch(pool, query, options) {
368
376
  try {
369
377
  let results = [];
370
378
  // Try semantic search first (if mode allows and embedding API available)
371
- if (mode !== "text") {
379
+ if (mode !== 'text') {
372
380
  const queryEmbedding = await generateEmbedding(query);
373
381
  if (queryEmbedding) {
374
- const embeddingStr = `[${queryEmbedding.join(",")}]`;
382
+ const embeddingStr = `[${queryEmbedding.join(',')}]`;
375
383
  // Vector search on shared KB
376
384
  let sql = `
377
385
  SELECT domain, key, sub_key, content, metadata, created_by, version, updated_at::text,
@@ -394,7 +402,7 @@ async function kbSearch(pool, query, options) {
394
402
  const sharedResult = await client.query(sql, params);
395
403
  results = sharedResult.rows;
396
404
  // If we got results from semantic search and mode is not hybrid, return
397
- if (results.length > 0 && mode === "semantic") {
405
+ if (results.length > 0 && mode === 'semantic') {
398
406
  return results;
399
407
  }
400
408
  }
@@ -403,8 +411,8 @@ async function kbSearch(pool, query, options) {
403
411
  // Split query into tokens and match ANY token via ILIKE (Korean-friendly)
404
412
  // For Korean tokens of 4+ chars with no spaces, add 2-char sub-tokens
405
413
  // e.g. "노조관리" → ["노조관리", "노조", "관리"]
406
- if (mode !== "semantic" || results.length === 0) {
407
- const rawTokens = query.split(/\s+/).filter(t => t.length >= 2);
414
+ if (mode !== 'semantic' || results.length === 0) {
415
+ const rawTokens = query.split(/\s+/).filter((t) => t.length >= 2);
408
416
  const tokens = [];
409
417
  const KOREAN_RE = /[\uAC00-\uD7AF]/;
410
418
  for (const t of rawTokens) {
@@ -420,20 +428,16 @@ async function kbSearch(pool, query, options) {
420
428
  const textParams = [];
421
429
  let tIdx = 1;
422
430
  // Build per-token ILIKE conditions + count matching tokens for scoring
423
- const tokenConditions = tokens.map(token => {
431
+ const tokenConditions = tokens.map((token) => {
424
432
  textParams.push(`%${token}%`);
425
433
  return `(CASE WHEN content ILIKE $${tIdx} OR key ILIKE $${tIdx} OR sub_key ILIKE $${tIdx++} THEN 1 ELSE 0 END)`;
426
434
  });
427
435
  // Score = 0.7 base + 0.15 * (matched_tokens / total_tokens), capped at 0.95
428
- const matchCountExpr = tokenConditions.length > 0
429
- ? tokenConditions.join(" + ")
430
- : "0";
436
+ const matchCountExpr = tokenConditions.length > 0 ? tokenConditions.join(' + ') : '0';
431
437
  const scoreExpr = `LEAST(0.95, 0.7 + 0.15 * (${matchCountExpr})::float / ${Math.max(tokens.length, 1)})`;
432
438
  // WHERE: any token matches
433
439
  const whereTokens = tokens.map((_, i) => `(content ILIKE $${i + 1} OR key ILIKE $${i + 1} OR sub_key ILIKE $${i + 1})`);
434
- const whereClause = whereTokens.length > 0
435
- ? whereTokens.join(" OR ")
436
- : "FALSE";
440
+ const whereClause = whereTokens.length > 0 ? whereTokens.join(' OR ') : 'FALSE';
437
441
  let textSql = `
438
442
  SELECT domain, key, sub_key, content, metadata, created_by, version, updated_at::text,
439
443
  ${scoreExpr} as score
@@ -574,7 +578,7 @@ async function resolveServiceDomainsLocal(client, service) {
574
578
  async function ontoValidate(pool, domain, entries) {
575
579
  const onto = await ontoShow(pool, domain);
576
580
  if (!onto) {
577
- return { valid: 0, invalid: [{ key: "*", errors: [`Ontology domain '${domain}' not found`] }] };
581
+ return { valid: 0, invalid: [{ key: '*', errors: [`Ontology domain '${domain}' not found`] }] };
578
582
  }
579
583
  // If no entries provided, fetch from DB
580
584
  if (!entries) {
@@ -595,7 +599,7 @@ async function ontoValidate(pool, domain, entries) {
595
599
  const errors = [];
596
600
  // Check required fields
597
601
  for (const field of required) {
598
- if (!entry[field] && entry[field] !== "") {
602
+ if (!entry[field] && entry[field] !== '') {
599
603
  errors.push(`Missing required field: ${field}`);
600
604
  }
601
605
  }
@@ -607,12 +611,12 @@ async function ontoValidate(pool, domain, entries) {
607
611
  const val = entry.metadata[propKey];
608
612
  if (val !== undefined) {
609
613
  if (def.enum && !def.enum.includes(val)) {
610
- errors.push(`metadata.${propKey}: '${val}' not in allowed values [${def.enum.join(", ")}]`);
614
+ errors.push(`metadata.${propKey}: '${val}' not in allowed values [${def.enum.join(', ')}]`);
611
615
  }
612
- if (def.type === "string" && typeof val !== "string") {
616
+ if (def.type === 'string' && typeof val !== 'string') {
613
617
  errors.push(`metadata.${propKey}: expected string, got ${typeof val}`);
614
618
  }
615
- if (def.type === "array" && !Array.isArray(val)) {
619
+ if (def.type === 'array' && !Array.isArray(val)) {
616
620
  errors.push(`metadata.${propKey}: expected array, got ${typeof val}`);
617
621
  }
618
622
  }
@@ -737,13 +741,15 @@ async function kbUpsert(pool, entry) {
737
741
  // Domain validation
738
742
  const client = await pool.connect();
739
743
  try {
740
- const ontoCheck = await client.query("SELECT domain FROM semo.ontology WHERE domain = $1", [entry.domain]);
744
+ const ontoCheck = await client.query('SELECT domain FROM semo.ontology WHERE domain = $1', [
745
+ entry.domain,
746
+ ]);
741
747
  if (ontoCheck.rows.length === 0) {
742
- const known = await client.query("SELECT domain FROM semo.ontology ORDER BY domain");
748
+ const known = await client.query('SELECT domain FROM semo.ontology ORDER BY domain');
743
749
  const knownDomains = known.rows.map((r) => r.domain);
744
750
  return {
745
751
  success: false,
746
- error: `도메인 '${entry.domain}'은(는) 온톨로지에 등록되지 않았습니다. 등록된 도메인: [${knownDomains.join(", ")}]`,
752
+ error: `도메인 '${entry.domain}'은(는) 온톨로지에 등록되지 않았습니다. 등록된 도메인: [${knownDomains.join(', ')}]`,
747
753
  };
748
754
  }
749
755
  }
@@ -753,14 +759,14 @@ async function kbUpsert(pool, entry) {
753
759
  // Naming convention check: kebab-case only
754
760
  const warnings = [];
755
761
  if (/_/.test(key)) {
756
- const suggested = key.replace(/_/g, "-");
762
+ const suggested = key.replace(/_/g, '-');
757
763
  return {
758
764
  success: false,
759
765
  error: `키 '${key}'에 snake_case가 포함되어 있습니다. kebab-case를 사용하세요: '${suggested}'`,
760
766
  };
761
767
  }
762
768
  if (/_/.test(subKey)) {
763
- const suggested = subKey.replace(/_/g, "-");
769
+ const suggested = subKey.replace(/_/g, '-');
764
770
  return {
765
771
  success: false,
766
772
  error: `sub_key '${subKey}'에 snake_case가 포함되어 있습니다. kebab-case를 사용하세요: '${suggested}'`,
@@ -770,35 +776,41 @@ async function kbUpsert(pool, entry) {
770
776
  {
771
777
  const schemaClient = await pool.connect();
772
778
  try {
773
- const typeResult = await schemaClient.query("SELECT entity_type FROM semo.ontology WHERE domain = $1 AND entity_type IS NOT NULL", [entry.domain]);
779
+ const typeResult = await schemaClient.query('SELECT entity_type FROM semo.ontology WHERE domain = $1 AND entity_type IS NOT NULL', [entry.domain]);
774
780
  if (typeResult.rows.length > 0) {
775
781
  const entityType = typeResult.rows[0].entity_type;
776
782
  const schemaResult = await schemaClient.query("SELECT scheme_key, COALESCE(key_type, 'singleton') as key_type, COALESCE(source, 'manual') as source FROM semo.kb_type_schema WHERE type_key = $1", [entityType]);
777
783
  const schemas = schemaResult.rows;
778
784
  if (schemas.length > 0) {
779
- const match = schemas.find(s => s.scheme_key === key);
785
+ const match = schemas.find((s) => s.scheme_key === key);
780
786
  if (!match) {
781
- const allowedKeys = schemas.map(s => s.key_type === "singleton" ? s.scheme_key : `${s.scheme_key}/{sub_key}`);
787
+ const allowedKeys = schemas.map((s) => s.key_type === 'singleton' ? s.scheme_key : `${s.scheme_key}/{sub_key}`);
782
788
  return {
783
789
  success: false,
784
- error: `키 '${key}'은(는) '${entityType}' 타입의 스키마에 허용되지 않습니다. 허용 키: [${allowedKeys.join(", ")}]`,
790
+ error: `키 '${key}'은(는) '${entityType}' 타입의 스키마에 허용되지 않습니다. 허용 키: [${allowedKeys.join(', ')}]`,
785
791
  };
786
792
  }
787
793
  // Projection key 차단: pm-pipeline만 쓰기 허용
788
- if (match.source === "projection") {
789
- const createdBy = entry.created_by ?? "";
790
- if (!createdBy.startsWith("pm-") && !createdBy.startsWith("gfp-")) {
794
+ if (match.source === 'projection') {
795
+ const createdBy = entry.created_by ?? '';
796
+ if (!createdBy.startsWith('pm-') && !createdBy.startsWith('gfp-')) {
791
797
  return {
792
798
  success: false,
793
799
  error: `키 '${key}'은(는) projection 키입니다 (PM 파이프라인에서 자동 동기화). 직접 쓰기가 차단됩니다.`,
794
800
  };
795
801
  }
796
802
  }
797
- if (match.key_type === "singleton" && subKey !== "") {
798
- return { success: false, error: `키 '${key}'은(는) singleton이므로 sub_key가 비어야 합니다.` };
803
+ if (match.key_type === 'singleton' && subKey !== '') {
804
+ return {
805
+ success: false,
806
+ error: `키 '${key}'은(는) singleton이므로 sub_key가 비어야 합니다.`,
807
+ };
799
808
  }
800
- if (match.key_type === "collection" && subKey === "") {
801
- return { success: false, error: `키 '${key}'은(는) collection이므로 sub_key가 필요합니다.` };
809
+ if (match.key_type === 'collection' && subKey === '') {
810
+ return {
811
+ success: false,
812
+ error: `키 '${key}'은(는) collection이므로 sub_key가 필요합니다.`,
813
+ };
802
814
  }
803
815
  }
804
816
  }
@@ -811,20 +823,67 @@ async function kbUpsert(pool, entry) {
811
823
  schemaClient.release();
812
824
  }
813
825
  }
826
+ // Slug hygiene validation for decision/incident
827
+ if (key === 'decision' || key === 'incident') {
828
+ const slug = subKey.replace(/^\d{4}-\d{2}-\d{2}\//, ''); // strip date prefix
829
+ if (/^test/i.test(slug) || /test$/i.test(slug) || slug === 'test') {
830
+ return {
831
+ success: false,
832
+ error: `${key}의 sub_key에 'test'가 포함되어 있습니다. 테스트 데이터는 KB에 저장할 수 없습니다.`,
833
+ };
834
+ }
835
+ if (slug.length < 3) {
836
+ return {
837
+ success: false,
838
+ error: `${key}의 sub_key '${slug}'가 너무 짧습니다 (최소 3자). 구체적인 slug를 사용하세요 (예: 'github-seat-reduction').`,
839
+ };
840
+ }
841
+ const genericSlugs = ['결정사항', '검토된-대안', '변경사항', '결정', '메모', '임시'];
842
+ if (genericSlugs.includes(slug)) {
843
+ return {
844
+ success: false,
845
+ error: `${key}의 sub_key '${slug}'가 너무 일반적입니다. 구체적인 slug를 사용하세요 (예: 'github-seat-reduction').`,
846
+ };
847
+ }
848
+ }
849
+ // Metadata required fields validation by key type
850
+ {
851
+ const meta = (entry.metadata || {});
852
+ const requiredMetaByKey = {
853
+ decision: {
854
+ fields: ['decided_at', 'decided_by'],
855
+ label: '의사결정',
856
+ },
857
+ incident: {
858
+ fields: ['occurred_at', 'severity', 'status'],
859
+ label: '인시던트',
860
+ },
861
+ };
862
+ const rule = requiredMetaByKey[key];
863
+ if (rule) {
864
+ const missing = rule.fields.filter((f) => !meta[f]);
865
+ if (missing.length > 0) {
866
+ return {
867
+ success: false,
868
+ error: `${rule.label} 엔트리(key='${key}')는 metadata에 [${missing.join(', ')}] 필드가 필수입니다. --metadata '{"${missing[0]}":"..."}' 형태로 전달하세요.`,
869
+ };
870
+ }
871
+ }
872
+ }
814
873
  // Generate embedding (mandatory)
815
874
  const fullKey = combineKey(key, subKey);
816
875
  const text = `${fullKey}: ${entry.content}`;
817
876
  const embedding = await generateEmbedding(text);
818
877
  if (!embedding) {
819
878
  const reason = process.env.OPENAI_API_KEY
820
- ? "임베딩 생성 API 호출 실패"
821
- : "OPENAI_API_KEY가 설정되지 않음";
879
+ ? '임베딩 생성 API 호출 실패'
880
+ : 'OPENAI_API_KEY가 설정되지 않음';
822
881
  return {
823
882
  success: false,
824
883
  error: `임베딩 생성 실패 — ${reason}. 임베딩 없이 저장하면 벡터 검색에서 누락되므로 저장이 거부됩니다.`,
825
884
  };
826
885
  }
827
- const embeddingStr = `[${embedding.join(",")}]`;
886
+ const embeddingStr = `[${embedding.join(',')}]`;
828
887
  const writeClient = await pool.connect();
829
888
  try {
830
889
  await writeClient.query(`INSERT INTO semo.knowledge_base (domain, key, sub_key, content, metadata, created_by, embedding)
@@ -838,10 +897,41 @@ async function kbUpsert(pool, entry) {
838
897
  subKey,
839
898
  entry.content,
840
899
  JSON.stringify(entry.metadata || {}),
841
- entry.created_by || "semo-cli",
900
+ entry.created_by || 'semo-cli',
842
901
  embeddingStr,
843
902
  ]);
844
- return { success: true };
903
+ // KB→DB 동기화: Dashboard API에 위임 (파서 단일화)
904
+ if ((key === 'kpi' || key === 'action-item') && subKey) {
905
+ try {
906
+ const baseUrl = process.env.DASHBOARD_URL ||
907
+ process.env.NEXT_PUBLIC_BASE_URL ||
908
+ 'https://semo.semi-colon.space';
909
+ const syncRes = await fetch(`${baseUrl}/api/kb-sync`, {
910
+ method: 'POST',
911
+ headers: { 'Content-Type': 'application/json' },
912
+ body: JSON.stringify({
913
+ domain: entry.domain,
914
+ key,
915
+ sub_key: subKey,
916
+ content: entry.content,
917
+ }),
918
+ });
919
+ if (syncRes.ok) {
920
+ const syncData = await syncRes.json();
921
+ if (syncData.synced > 0) {
922
+ console.error(` ↳ DB sync: ${syncData.synced} records`);
923
+ }
924
+ }
925
+ else {
926
+ console.error(`[kb] ⚠️ DB sync failed: ${syncRes.status}`);
927
+ }
928
+ }
929
+ catch (wtErr) {
930
+ // sync 실패는 KB 성공에 영향 없음 — 로깅만
931
+ console.error(`[kb] ⚠️ DB sync 실패 (KB 저장은 정상): ${wtErr}`);
932
+ }
933
+ }
934
+ return { success: true, warnings: warnings.length > 0 ? warnings : undefined };
845
935
  }
846
936
  catch (err) {
847
937
  return { success: false, error: String(err) };
@@ -961,17 +1051,19 @@ async function ontoRegister(pool, opts) {
961
1051
  const client = await pool.connect();
962
1052
  try {
963
1053
  // 1. Validate entity_type
964
- const typeCheck = await client.query("SELECT type_key FROM semo.ontology_types WHERE type_key = $1", [opts.entity_type]);
1054
+ const typeCheck = await client.query('SELECT type_key FROM semo.ontology_types WHERE type_key = $1', [opts.entity_type]);
965
1055
  if (typeCheck.rows.length === 0) {
966
- const known = await client.query("SELECT type_key FROM semo.ontology_types ORDER BY type_key");
1056
+ const known = await client.query('SELECT type_key FROM semo.ontology_types ORDER BY type_key');
967
1057
  const knownTypes = known.rows.map((r) => r.type_key);
968
1058
  return {
969
1059
  success: false,
970
- error: `타입 '${opts.entity_type}'은(는) 존재하지 않습니다. 사용 가능한 타입: [${knownTypes.join(", ")}]`,
1060
+ error: `타입 '${opts.entity_type}'은(는) 존재하지 않습니다. 사용 가능한 타입: [${knownTypes.join(', ')}]`,
971
1061
  };
972
1062
  }
973
1063
  // 2. Check domain doesn't already exist
974
- const existCheck = await client.query("SELECT domain FROM semo.ontology WHERE domain = $1", [opts.domain]);
1064
+ const existCheck = await client.query('SELECT domain FROM semo.ontology WHERE domain = $1', [
1065
+ opts.domain,
1066
+ ]);
975
1067
  if (existCheck.rows.length > 0) {
976
1068
  return { success: false, error: `도메인 '${opts.domain}'은(는) 이미 등록되어 있습니다.` };
977
1069
  }
@@ -981,7 +1073,7 @@ async function ontoRegister(pool, opts) {
981
1073
  opts.domain,
982
1074
  opts.entity_type,
983
1075
  opts.description || null,
984
- opts.service || "_global",
1076
+ opts.service || '_global',
985
1077
  opts.tags || [opts.entity_type],
986
1078
  JSON.stringify({}),
987
1079
  ]);
@@ -994,19 +1086,17 @@ async function ontoRegister(pool, opts) {
994
1086
  WHERE type_key = $1 AND required = true
995
1087
  ORDER BY sort_order`, [opts.entity_type]);
996
1088
  for (const s of schemaResult.rows) {
997
- if (s.key_type === "collection")
1089
+ if (s.key_type === 'collection')
998
1090
  continue; // collection은 sub_key가 필요하므로 스킵
999
- const placeholder = s.value_hint
1000
- ? `(미입력 — hint: ${s.value_hint})`
1001
- : `(미입력)`;
1091
+ const placeholder = s.value_hint ? `(미입력 — hint: ${s.value_hint})` : `(미입력)`;
1002
1092
  const text = `${s.scheme_key}: ${placeholder}`;
1003
1093
  const embedding = await generateEmbedding(text);
1004
- const embeddingStr = embedding ? `[${embedding.join(",")}]` : null;
1094
+ const embeddingStr = embedding ? `[${embedding.join(',')}]` : null;
1005
1095
  try {
1006
1096
  await client.query(`INSERT INTO semo.knowledge_base (domain, key, sub_key, content, metadata, created_by, embedding)
1007
1097
  VALUES ($1, $2, '', $3, '{}', 'semo-cli:onto-register', $4::vector)
1008
1098
  ON CONFLICT (domain, key, sub_key) DO NOTHING`, [opts.domain, s.scheme_key, placeholder, embeddingStr]);
1009
- createdEntries.push({ key: s.scheme_key, sub_key: "" });
1099
+ createdEntries.push({ key: s.scheme_key, sub_key: '' });
1010
1100
  }
1011
1101
  catch {
1012
1102
  // 개별 entry 실패는 무시 — 도메인 등록 자체는 성공
@@ -1031,12 +1121,12 @@ async function ontoUnregister(pool, domain, force) {
1031
1121
  const client = await pool.connect();
1032
1122
  try {
1033
1123
  // 1. Check domain exists
1034
- const domainCheck = await client.query("SELECT domain, entity_type, service FROM semo.ontology WHERE domain = $1", [domain]);
1124
+ const domainCheck = await client.query('SELECT domain, entity_type, service FROM semo.ontology WHERE domain = $1', [domain]);
1035
1125
  if (domainCheck.rows.length === 0) {
1036
1126
  return { success: false, error: `도메인 '${domain}'은(는) 존재하지 않습니다.` };
1037
1127
  }
1038
1128
  // 2. Count KB entries
1039
- const countResult = await client.query("SELECT COUNT(*)::int AS cnt FROM semo.knowledge_base WHERE domain = $1", [domain]);
1129
+ const countResult = await client.query('SELECT COUNT(*)::int AS cnt FROM semo.knowledge_base WHERE domain = $1', [domain]);
1040
1130
  const entryCount = countResult.rows[0].cnt;
1041
1131
  // 3. If entries exist and no force → error
1042
1132
  if (entryCount > 0 && !force) {
@@ -1046,19 +1136,21 @@ async function ontoUnregister(pool, domain, force) {
1046
1136
  };
1047
1137
  }
1048
1138
  // 4. Transaction: delete KB entries (if any) → delete ontology
1049
- await client.query("BEGIN");
1139
+ await client.query('BEGIN');
1050
1140
  try {
1051
1141
  let deletedEntries = 0;
1052
1142
  if (entryCount > 0) {
1053
- const delResult = await client.query("DELETE FROM semo.knowledge_base WHERE domain = $1", [domain]);
1143
+ const delResult = await client.query('DELETE FROM semo.knowledge_base WHERE domain = $1', [
1144
+ domain,
1145
+ ]);
1054
1146
  deletedEntries = delResult.rowCount ?? 0;
1055
1147
  }
1056
- await client.query("DELETE FROM semo.ontology WHERE domain = $1", [domain]);
1057
- await client.query("COMMIT");
1148
+ await client.query('DELETE FROM semo.ontology WHERE domain = $1', [domain]);
1149
+ await client.query('COMMIT');
1058
1150
  return { success: true, deleted_entries: deletedEntries };
1059
1151
  }
1060
1152
  catch (err) {
1061
- await client.query("ROLLBACK");
1153
+ await client.query('ROLLBACK');
1062
1154
  return { success: false, error: String(err) };
1063
1155
  }
1064
1156
  }
@@ -1074,32 +1166,38 @@ async function ontoAddKey(pool, opts) {
1074
1166
  try {
1075
1167
  // Naming convention: kebab-case only
1076
1168
  if (/_/.test(opts.scheme_key)) {
1077
- const suggested = opts.scheme_key.replace(/_/g, "-");
1078
- return { success: false, error: `키 '${opts.scheme_key}'에 snake_case가 포함되어 있습니다. kebab-case를 사용하세요: '${suggested}'` };
1169
+ const suggested = opts.scheme_key.replace(/_/g, '-');
1170
+ return {
1171
+ success: false,
1172
+ error: `키 '${opts.scheme_key}'에 snake_case가 포함되어 있습니다. kebab-case를 사용하세요: '${suggested}'`,
1173
+ };
1079
1174
  }
1080
1175
  // Check type exists
1081
- const typeCheck = await client.query("SELECT DISTINCT type_key FROM semo.kb_type_schema WHERE type_key = $1", [opts.type_key]);
1176
+ const typeCheck = await client.query('SELECT DISTINCT type_key FROM semo.kb_type_schema WHERE type_key = $1', [opts.type_key]);
1082
1177
  if (typeCheck.rows.length === 0) {
1083
1178
  // Check if this type exists in ontology at all
1084
- const ontoCheck = await client.query("SELECT DISTINCT entity_type FROM semo.ontology WHERE entity_type = $1", [opts.type_key]);
1179
+ const ontoCheck = await client.query('SELECT DISTINCT entity_type FROM semo.ontology WHERE entity_type = $1', [opts.type_key]);
1085
1180
  if (ontoCheck.rows.length === 0) {
1086
1181
  return { success: false, error: `타입 '${opts.type_key}'이(가) 존재하지 않습니다.` };
1087
1182
  }
1088
1183
  }
1089
1184
  // Check duplicate
1090
- const dupCheck = await client.query("SELECT id FROM semo.kb_type_schema WHERE type_key = $1 AND scheme_key = $2", [opts.type_key, opts.scheme_key]);
1185
+ const dupCheck = await client.query('SELECT id FROM semo.kb_type_schema WHERE type_key = $1 AND scheme_key = $2', [opts.type_key, opts.scheme_key]);
1091
1186
  if (dupCheck.rows.length > 0) {
1092
- return { success: false, error: `키 '${opts.scheme_key}'은(는) '${opts.type_key}' 타입에 이미 존재합니다.` };
1187
+ return {
1188
+ success: false,
1189
+ error: `키 '${opts.scheme_key}'은(는) '${opts.type_key}' 타입에 이미 존재합니다.`,
1190
+ };
1093
1191
  }
1094
1192
  // Get max sort_order
1095
- const maxOrder = await client.query("SELECT COALESCE(MAX(sort_order), 0) + 10 as next_order FROM semo.kb_type_schema WHERE type_key = $1", [opts.type_key]);
1193
+ const maxOrder = await client.query('SELECT COALESCE(MAX(sort_order), 0) + 10 as next_order FROM semo.kb_type_schema WHERE type_key = $1', [opts.type_key]);
1096
1194
  const sortOrder = maxOrder.rows[0].next_order;
1097
1195
  await client.query(`INSERT INTO semo.kb_type_schema (type_key, scheme_key, scheme_description, key_type, required, value_hint, sort_order)
1098
1196
  VALUES ($1, $2, $3, $4, $5, $6, $7)`, [
1099
1197
  opts.type_key,
1100
1198
  opts.scheme_key,
1101
1199
  opts.description || opts.scheme_key,
1102
- opts.key_type || "singleton",
1200
+ opts.key_type || 'singleton',
1103
1201
  opts.required || false,
1104
1202
  opts.value_hint || null,
1105
1203
  sortOrder,
@@ -1119,21 +1217,20 @@ async function ontoAddKey(pool, opts) {
1119
1217
  async function ontoCreateType(pool, opts) {
1120
1218
  // kebab-case 검증
1121
1219
  if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(opts.type_key)) {
1122
- return { success: false, error: `타입 키 '${opts.type_key}'이(가) 유효하지 않습니다. kebab-case 소문자만 사용 가능 (예: my-type)` };
1220
+ return {
1221
+ success: false,
1222
+ error: `타입 키 '${opts.type_key}'이(가) 유효하지 않습니다. kebab-case 소문자만 사용 가능 (예: my-type)`,
1223
+ };
1123
1224
  }
1124
1225
  const client = await pool.connect();
1125
1226
  try {
1126
1227
  // 중복 확인
1127
- const dupCheck = await client.query("SELECT type_key FROM semo.ontology_types WHERE type_key = $1", [opts.type_key]);
1228
+ const dupCheck = await client.query('SELECT type_key FROM semo.ontology_types WHERE type_key = $1', [opts.type_key]);
1128
1229
  if (dupCheck.rows.length > 0) {
1129
1230
  return { success: false, error: `타입 '${opts.type_key}'은(는) 이미 존재합니다.` };
1130
1231
  }
1131
1232
  await client.query(`INSERT INTO semo.ontology_types (type_key, schema, description)
1132
- VALUES ($1, $2, $3)`, [
1133
- opts.type_key,
1134
- JSON.stringify(opts.schema || {}),
1135
- opts.description || opts.type_key,
1136
- ]);
1233
+ VALUES ($1, $2, $3)`, [opts.type_key, JSON.stringify(opts.schema || {}), opts.description || opts.type_key]);
1137
1234
  return { success: true };
1138
1235
  }
1139
1236
  catch (err) {
@@ -1149,9 +1246,12 @@ async function ontoCreateType(pool, opts) {
1149
1246
  async function ontoRemoveKey(pool, typeKey, schemeKey) {
1150
1247
  const client = await pool.connect();
1151
1248
  try {
1152
- const result = await client.query("DELETE FROM semo.kb_type_schema WHERE type_key = $1 AND scheme_key = $2 RETURNING id", [typeKey, schemeKey]);
1249
+ const result = await client.query('DELETE FROM semo.kb_type_schema WHERE type_key = $1 AND scheme_key = $2 RETURNING id', [typeKey, schemeKey]);
1153
1250
  if (result.rows.length === 0) {
1154
- return { success: false, error: `키 '${schemeKey}'은(는) '${typeKey}' 타입에 존재하지 않습니다.` };
1251
+ return {
1252
+ success: false,
1253
+ error: `키 '${schemeKey}'은(는) '${typeKey}' 타입에 존재하지 않습니다.`,
1254
+ };
1155
1255
  }
1156
1256
  return { success: true };
1157
1257
  }
@@ -1165,7 +1265,7 @@ async function ontoRemoveKey(pool, typeKey, schemeKey) {
1165
1265
  async function ontoPullToLocal(pool, cwd) {
1166
1266
  const domains = await ontoList(pool);
1167
1267
  const kbDir = ensureKBDir(cwd);
1168
- const ontoDir = path.join(kbDir, "ontology");
1268
+ const ontoDir = path.join(kbDir, 'ontology');
1169
1269
  for (const d of domains) {
1170
1270
  fs.writeFileSync(path.join(ontoDir, `${d.domain}.json`), JSON.stringify(d, null, 2));
1171
1271
  }