rag-memory-epf-mcp 3.6.0 → 4.0.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/index.js CHANGED
@@ -21,6 +21,10 @@ import modularity from 'graphology-metrics/graph/modularity.js';
21
21
  import { getAllMCPTools, validateToolArgs, getSystemInfo } from './src/tools/tool-registry.js';
22
22
  // Import migration system
23
23
  import { MigrationManager } from './src/migrations/migration-manager.js';
24
+ import { backupBeforeMigration } from './src/backup/preflight.js';
25
+ import { rebuildProjection, deleteStaleKgChunks } from './src/observations/projection.js';
26
+ import { addRevision, correctRevision, transitionStatus, linkSources, nextProjectionOrder } from './src/observations/lifecycle.js';
27
+ import { getObservationHistory } from './src/observations/history.js';
24
28
  // Import chunk text algorithm (extracted for publish-time invariant testing)
25
29
  import { chunkText as splitTextIntoChunks } from './src/chunkText.js';
26
30
  import { migrations } from './src/migrations/migrations.js';
@@ -129,6 +133,9 @@ export class RAGKnowledgeGraphManager {
129
133
  // v3.6 (spec §3): initialize = DB + migrations + profile only. The embedding
130
134
  // model is NEVER awaited here — main() connects the MCP server first and the
131
135
  // gate loads in the background (lazy) or is awaited explicitly (eager).
136
+ // __testForceFkOff: 음성 대조군 전용. FK 게이트가 실제로 부팅을 막는지 시험한다.
137
+ // 이름에 __test 를 박아 둔 이유는 이것이 프로덕션 설정 표면이 아니라는 것을
138
+ // 호출부에서 읽히게 하기 위해서다.
132
139
  async initialize(opts = {}) {
133
140
  console.error('🚀 Initializing RAG Knowledge Graph MCP Server...');
134
141
  this.db = new Database(DB_FILE_PATH);
@@ -140,6 +147,26 @@ export class RAGKnowledgeGraphManager {
140
147
  this.db.pragma('temp_store = MEMORY');
141
148
  this.db.pragma('mmap_size = 268435456');
142
149
  this.db.pragma('foreign_keys = ON');
150
+ // spec §5.2: 관찰 lifecycle 의 무결성은 전부 FK CASCADE 를 전제한다 — root 를 지우면
151
+ // revision 이, revision 을 지우면 source 가 따라가야 history 가 고아로 남지 않는다.
152
+ // FK 가 꺼진 채 돌면 그 계약이 조용히 무효가 되고, 그게 최악이다. 스키마를 건드리기
153
+ // 전에(= runMigrations 앞에서) 멈춘다.
154
+ // 트랜잭션 내부에서는 이 pragma 가 no-op 이므로(실측 before=1·during=1·after=1)
155
+ // 부팅 시점 확인이 유일한 방어 지점이다.
156
+ //
157
+ // 음성 대조군 주입은 **인자로만** 받는다. 환경변수로 두면 프로덕션 경로에
158
+ // "부팅을 막는 스위치"가 상시 존재하게 되고, 오설정 한 줄로 서버가 안 뜬다
159
+ // (advisor beta 자기의심 2 = "더 나쁘다"). 테스트는 manager 를 직접 만들므로
160
+ // 인자 주입으로 충분하다.
161
+ if (opts.__testForceFkOff)
162
+ this.db.pragma('foreign_keys = OFF');
163
+ {
164
+ const fk = this.db.pragma('foreign_keys', { simple: true });
165
+ if (Number(fk) !== 1) {
166
+ throw new Error(`foreign_keys is ${fk}, expected 1. The observation lifecycle relies on FK CASCADE ` +
167
+ `for history integrity; refusing to run migrations without it.`);
168
+ }
169
+ }
143
170
  this.encoding = get_encoding("cl100k_base");
144
171
  await this.runMigrations();
145
172
  this.currentProfileId = this.ensureCurrentProfile();
@@ -322,18 +349,44 @@ export class RAGKnowledgeGraphManager {
322
349
  // where another tool call can retrieve the pre-mutation vector, and a crash
323
350
  // between mutation and re-embed leaves a clean missing state (backfill
324
351
  // target), never a stale-searchable one.
352
+ // spec §4.5 단계 1: 관찰 변경 · projection 재합성 · entity vector 무효화 ·
353
+ // stale KG chunk 제거를 한 트랜잭션으로 묶는다. 하나만 되면 검색이 낡은
354
+ // 사실을 계속 반환한다.
355
+ // mutate 가 명시적으로 false 를 반환하면 "아무것도 바꾸지 않았다"는 뜻이고
356
+ // projection 재합성·벡터 무효화·KG 정리를 건너뛴다. 이 경로가 없으면
357
+ // 무변경 upsert 나 dedup-only add 가 **정상 벡터를 지우고 재임베딩도 안 해서**
358
+ // 검색 품질만 깎는다(advisor 구현리뷰 r1 발견 1, 실행 재현).
359
+ // 반환값 = 실제로 변경이 있었는가.
325
360
  mutateEntityAndInvalidate(entityId, mutate) {
361
+ let changed = false;
326
362
  const tx = this.db.transaction(() => {
327
- mutate();
328
- const meta = this.db.prepare(`SELECT rowid FROM entity_embedding_metadata WHERE entity_id = ?`)
329
- .get(entityId);
330
- if (meta) {
331
- this.db.exec(`DELETE FROM entity_embeddings WHERE rowid = ${Number(meta.rowid)}`);
332
- this.db.prepare(`DELETE FROM entity_embedding_metadata WHERE entity_id = ?`).run(entityId);
333
- }
363
+ changed = mutate() !== false;
364
+ if (!changed)
365
+ return;
366
+ rebuildProjection(this.db, entityId);
367
+ this.invalidateDerivedForEntity(entityId);
334
368
  });
335
369
  tx();
336
- this.coordinator?.invalidateCoverage();
370
+ if (changed)
371
+ this.coordinator?.invalidateCoverage();
372
+ return changed;
373
+ }
374
+ // 관찰이 바뀐 entity 의 파생 상태를 무효화한다: entity vector + stale KG chunk.
375
+ // **트랜잭션을 열지 않는다** — 호출자가 이미 하나의 단위 안에 있다고 가정한다.
376
+ //
377
+ // importGraph 가 이 단계를 건너뛰고 있었다: projection 만 재합성하고 파생 상태를
378
+ // 그대로 둬서, 이미 존재하는 entity 를 import 로 덮으면 옛 벡터·옛 KG chunk 가
379
+ // 계속 검색에 나왔다(advisor beta 발견 2, hybridSearch 로 실측 재현).
380
+ // 그래서 "모든 관찰 변경은 mutateEntityAndInvalidate 를 통한다"는 규칙에
381
+ // 예외가 하나 있었고, 그 예외가 정확히 그 규칙이 막으려던 결함을 만들었다.
382
+ invalidateDerivedForEntity(entityId) {
383
+ const meta = this.db.prepare(`SELECT rowid FROM entity_embedding_metadata WHERE entity_id = ?`)
384
+ .get(entityId);
385
+ if (meta) {
386
+ this.db.exec(`DELETE FROM entity_embeddings WHERE rowid = ${Number(meta.rowid)}`);
387
+ this.db.prepare(`DELETE FROM entity_embedding_metadata WHERE entity_id = ?`).run(entityId);
388
+ }
389
+ deleteStaleKgChunks(this.db, entityId);
337
390
  }
338
391
  // §6a-1 invariant: when an entity's embedding input changed but re-embedding
339
392
  // is unavailable, its old vector must not stay searchable.
@@ -434,6 +487,11 @@ export class RAGKnowledgeGraphManager {
434
487
  });
435
488
  // Get pending migrations before running them
436
489
  const pendingBefore = migrationManager.getPendingMigrations();
490
+ // spec §5.1: 대기 중 마이그레이션이 있으면 먼저 일관 스냅샷을 남긴다.
491
+ // 실패는 throw = fail-closed (백업 없이 스키마를 바꾸지 않는다).
492
+ // await: 백업은 Online Backup API 를 쓰므로 비동기다. 여기서 await 를 빠뜨리면
493
+ // 백업이 끝나기 전에 마이그레이션이 시작한다 = 백업 없이 스키마를 바꾸는 것이다.
494
+ await backupBeforeMigration(this.db, DB_FILE_PATH, pendingBefore.map(m => m.version), migrationManager.getCurrentVersion());
437
495
  // Run pending migrations
438
496
  const result = await migrationManager.runMigrations();
439
497
  console.error(`🔧 Database schema ready (version ${result.currentVersion}, ${result.applied} migrations applied)`);
@@ -469,45 +527,73 @@ export class RAGKnowledgeGraphManager {
469
527
  if (!this.db)
470
528
  throw new Error('Database not initialized');
471
529
  const result = [];
530
+ // v13: 관찰은 lifecycle 테이블이 정본이고 entities.observations 는 projection 이다.
531
+ // entity 행은 빈 배열로 만들고 rebuildProjection 이 채운다.
472
532
  const insertStmt = this.db.prepare(`
473
533
  INSERT OR IGNORE INTO entities (id, name, entityType, observations, metadata)
474
- VALUES (?, ?, ?, ?, ?)
534
+ VALUES (?, ?, ?, '[]', ?)
475
535
  `);
536
+ const stripDate = (s2) => s2.replace(/^\[\d{4}-\d{2}-\d{2}\]\s*/, '');
476
537
  for (const entity of entities) {
477
538
  const entityId = `entity_${entity.name.toLowerCase().replace(/[^\p{L}\p{N}]/gu, '_')}`;
478
- const timestamped = (entity.observations || []).map(o => this._timestampObservation(o));
479
- // Try insert first
480
- const insertResult = insertStmt.run(entityId, entity.name, entity.entityType, JSON.stringify(timestamped), '{}');
481
- if (insertResult.changes > 0) {
482
- // New entity created. CRUD success is independent of model readiness
483
- // (spec §5): not-ready -> row stays vectorless (queued for backfill).
484
- console.error(`🔮 Generating embedding for new entity: ${entity.name}`);
539
+ const ts = new Date().toISOString();
540
+ const ids = [];
541
+ let created = false;
542
+ let addedCount = 0;
543
+ let typeUpdated = false;
544
+ // entity INSERT 도 같은 트랜잭션 안이다. 밖에 두면 lifecycle INSERT 가
545
+ // 실패할 때 entity 행만 남는 split state 가 생긴다
546
+ // (advisor 구현리뷰 r1 발견 2, 실행 재현).
547
+ const changed = this.mutateEntityAndInvalidate(entityId, () => {
548
+ created = insertStmt.run(entityId, entity.name, entity.entityType, '{}').changes > 0;
549
+ if (!created && entity.entityType && entity.entityType !== 'CONCEPT') {
550
+ const cur = this.db.prepare(`SELECT entityType FROM entities WHERE id = ?`)
551
+ .get(entityId);
552
+ if (cur && cur.entityType !== entity.entityType) {
553
+ this.db.prepare(`UPDATE entities SET entityType = ? WHERE id = ?`)
554
+ .run(entity.entityType, entityId);
555
+ typeUpdated = true;
556
+ }
557
+ }
558
+ const activeRows = this.db.prepare(`SELECT observation_id, content FROM entity_observations
559
+ WHERE entity_id = ? AND status = 'active'`).all(entityId);
560
+ const activeByBare = new Map(activeRows.map(r => [stripDate(r.content), r.observation_id]));
561
+ let sourcesAdded = 0;
562
+ for (const raw of (entity.observations || [])) {
563
+ const content = this._timestampObservation(raw);
564
+ const bare = stripDate(content);
565
+ const dupId = activeByBare.get(bare);
566
+ if (dupId) {
567
+ // 같은 사실이 다른 출처에서 다시 왔다 = evidence 추가, 새 revision 아님.
568
+ if (entity.sources?.length)
569
+ sourcesAdded += linkSources(this.db, dupId, entity.sources, ts);
570
+ ids.push(null);
571
+ continue;
572
+ }
573
+ const id = addRevision(this.db, {
574
+ entityId, content, status: entity.status ?? 'active', sources: entity.sources, ts
575
+ });
576
+ activeByBare.set(bare, id);
577
+ ids.push(id);
578
+ addedCount++;
579
+ }
580
+ // 아무것도 안 바뀌었으면 projection·벡터·KG 를 건드리지 않는다.
581
+ return created || typeUpdated || addedCount > 0 || sourcesAdded > 0;
582
+ });
583
+ const projected = JSON.parse(this.db.prepare(`SELECT observations FROM entities WHERE id = ?`)
584
+ .get(entityId).observations);
585
+ // 재임베딩은 무효화가 실제로 일어났을 때만. 조건이 갈리면
586
+ // "벡터를 지우고 다시 만들지 않는" 창이 생긴다.
587
+ if (changed) {
588
+ console.error(created
589
+ ? `🔮 Generating embedding for new entity: ${entity.name}`
590
+ : `♻️ Upserted entity: ${entity.name} (+${addedCount} obs${typeUpdated ? ', type→' + entity.entityType : ''})`);
485
591
  const embedding_status = await this.tryEmbedEntity(entityId, 'bulk');
486
- result.push({ ...entity, observations: timestamped, embedding_status });
592
+ result.push({ ...entity, observations: projected, created,
593
+ observation_ids: ids, embedding_status });
487
594
  }
488
595
  else {
489
- // Entity already exists — upsert: merge observations and update entityType
490
- const existing = this.db.prepare(`SELECT observations, entityType FROM entities WHERE id = ?`)
491
- .get(entityId);
492
- if (existing) {
493
- const currentObs = JSON.parse(existing.observations);
494
- // Strip date prefix for dedup comparison
495
- const stripDate = (s) => s.replace(/^\[\d{4}-\d{2}-\d{2}\]\s*/, '');
496
- const currentBare = new Set(currentObs.map(stripDate));
497
- const newObs = timestamped.filter(o => !currentBare.has(stripDate(o)));
498
- const needsTypeUpdate = entity.entityType && entity.entityType !== 'CONCEPT' && entity.entityType !== existing.entityType;
499
- if (newObs.length > 0 || needsTypeUpdate) {
500
- const mergedObs = [...currentObs, ...newObs];
501
- const updatedType = needsTypeUpdate ? entity.entityType : existing.entityType;
502
- this.mutateEntityAndInvalidate(entityId, () => {
503
- this.db.prepare(`UPDATE entities SET observations = ?, entityType = ? WHERE id = ?`)
504
- .run(JSON.stringify(mergedObs), updatedType, entityId);
505
- });
506
- console.error(`♻️ Upserted entity: ${entity.name} (+${newObs.length} obs${needsTypeUpdate ? ', type→' + updatedType : ''})`);
507
- const embedding_status = await this.tryEmbedEntity(entityId, 'bulk');
508
- result.push({ ...entity, observations: mergedObs, embedding_status });
509
- }
510
- }
596
+ result.push({ ...entity, observations: projected, created, observation_ids: ids });
511
597
  }
512
598
  }
513
599
  return result;
@@ -551,35 +637,144 @@ export class RAGKnowledgeGraphManager {
551
637
  const results = [];
552
638
  for (const obs of observations) {
553
639
  const entityId = `entity_${obs.entityName.toLowerCase().replace(/[^\p{L}\p{N}]/gu, '_')}`;
554
- // Get current observations
555
- const entity = this.db.prepare(`
556
- SELECT observations FROM entities WHERE id = ?
557
- `).get(entityId);
558
- if (!entity) {
640
+ const entity = this.db.prepare(`SELECT id FROM entities WHERE id = ?`).get(entityId);
641
+ if (!entity)
559
642
  throw new Error(`Entity with name ${obs.entityName} not found`);
560
- }
561
- const currentObservations = JSON.parse(entity.observations);
562
- const stripDate = (s) => s.replace(/^\[\d{4}-\d{2}-\d{2}\]\s*/, '');
563
- const currentBare = new Set(currentObservations.map(stripDate));
564
- const timestamped = obs.contents.map(c => this._timestampObservation(c));
565
- const newObservations = timestamped.filter(c => !currentBare.has(stripDate(c)));
566
- if (newObservations.length > 0) {
567
- const updatedObservations = [...currentObservations, ...newObservations];
568
- this.mutateEntityAndInvalidate(entityId, () => {
569
- this.db.prepare(`
570
- UPDATE entities SET observations = ? WHERE id = ?
571
- `).run(JSON.stringify(updatedObservations), entityId);
572
- });
573
- // Regenerate embedding for the updated entity (queued when not ready)
643
+ // dedup 기준은 v3.6 과 같다: 날짜 prefix 를 뗀 본문이 active 에 이미 있으면
644
+ // 새 revision 을 만들지 않는다. 다만 v13 에서는 같은 사실이 다른 출처에서 다시
645
+ // 온 것이므로 그 revision 에 source link 를 더한다(spec §8.3 T13).
646
+ const stripDate = (s2) => s2.replace(/^\[\d{4}-\d{2}-\d{2}\]\s*/, '');
647
+ const activeRows = this.db.prepare(`SELECT observation_id, content FROM entity_observations
648
+ WHERE entity_id = ? AND status = 'active'`).all(entityId);
649
+ const activeByBare = new Map(activeRows.map(r => [stripDate(r.content), r.observation_id]));
650
+ const ts = new Date().toISOString();
651
+ const ids = [];
652
+ const added = [];
653
+ let sourcesAdded = 0;
654
+ const changed = this.mutateEntityAndInvalidate(entityId, () => {
655
+ for (const raw of obs.contents) {
656
+ const content = this._timestampObservation(raw);
657
+ const bare = stripDate(content);
658
+ const dupId = activeByBare.get(bare);
659
+ if (dupId) {
660
+ if (obs.sources?.length)
661
+ sourcesAdded += linkSources(this.db, dupId, obs.sources, ts);
662
+ ids.push(null);
663
+ continue;
664
+ }
665
+ const id = addRevision(this.db, {
666
+ entityId, content, status: obs.status ?? 'active', sources: obs.sources, ts
667
+ });
668
+ activeByBare.set(bare, id);
669
+ ids.push(id);
670
+ added.push(content);
671
+ }
672
+ // 아무것도 안 바뀌었으면 projection·벡터·KG 를 건드리지 않는다.
673
+ // 이 반환이 없으면 빈 contents 나 dedup-only add 가 정상 벡터를
674
+ // 지우고 재임베딩도 안 한다(advisor 구현리뷰 r1 발견 1).
675
+ return added.length > 0 || sourcesAdded > 0;
676
+ });
677
+ let embedding_status;
678
+ if (changed) {
574
679
  console.error(`🔮 Regenerating embedding for updated entity: ${obs.entityName}`);
575
- const embedding_status = await this.tryEmbedEntity(entityId, 'bulk');
576
- results.push({ entityName: obs.entityName, addedObservations: newObservations, embedding_status });
577
- continue;
680
+ embedding_status = await this.tryEmbedEntity(entityId, 'bulk');
578
681
  }
579
- results.push({ entityName: obs.entityName, addedObservations: newObservations });
682
+ results.push({ entityName: obs.entityName, observation_ids: ids,
683
+ addedObservations: added, embedding_status });
580
684
  }
581
685
  return results;
582
686
  }
687
+ async correctObservation(observationId, content, changeKind = 'correction', reason) {
688
+ if (!this.db)
689
+ throw new Error('Database not initialized');
690
+ const row = this.db.prepare(`SELECT entity_id FROM entity_observations WHERE observation_id = ?`)
691
+ .get(observationId);
692
+ if (!row)
693
+ throw new Error(`observation ${observationId} not found`);
694
+ let newId = '';
695
+ const ts = new Date().toISOString();
696
+ this.mutateEntityAndInvalidate(row.entity_id, () => {
697
+ newId = correctRevision(this.db, {
698
+ observationId, content: this._timestampObservation(content),
699
+ changeKind, reason: reason ?? null, ts
700
+ });
701
+ });
702
+ await this.tryEmbedEntity(row.entity_id, 'bulk');
703
+ return newId;
704
+ }
705
+ async _transition(observationId, event, reason) {
706
+ if (!this.db)
707
+ throw new Error('Database not initialized');
708
+ const row = this.db.prepare(`SELECT entity_id FROM entity_observations WHERE observation_id = ?`)
709
+ .get(observationId);
710
+ if (!row)
711
+ throw new Error(`observation ${observationId} not found`);
712
+ const ts = new Date().toISOString();
713
+ this.mutateEntityAndInvalidate(row.entity_id, () => {
714
+ transitionStatus(this.db, { observationId, event, reason: reason ?? null, ts });
715
+ });
716
+ await this.tryEmbedEntity(row.entity_id, 'bulk');
717
+ }
718
+ async retractObservation(observationId, reason) {
719
+ return this._transition(observationId, 'retract', reason);
720
+ }
721
+ async restoreObservation(observationId, reason) {
722
+ return this._transition(observationId, 'restore', reason);
723
+ }
724
+ async approveObservation(observationId, reason) {
725
+ return this._transition(observationId, 'approve', reason);
726
+ }
727
+ async declineObservation(observationId, reason) {
728
+ return this._transition(observationId, 'decline', reason);
729
+ }
730
+ // DESTRUCTIVE. Physically removes revisions (and their sources via CASCADE).
731
+ //
732
+ // Chain contract (advisor 구현리뷰 r1 발견 4): a revision chain is
733
+ // rev1 <- rev2 <- ... and purging a middle revision would either fail on the
734
+ // supersedes_id FK or leave a chain pointing at a deleted row, plus events
735
+ // whose from_id/to_id dangle. So purge is defined as **suffix purge from the
736
+ // target to the newest revision of that root**, newest-first:
737
+ // - purging the newest revision removes exactly it
738
+ // - purging rev2 of a 3-revision chain removes rev3 then rev2
739
+ // - purging rev1 removes the whole chain
740
+ // Events for purged revisions are removed too, so no event dangles.
741
+ // The root row is always kept: its projection_order stays reserved, because
742
+ // reusing an order would make a later restore/approve fail on the
743
+ // active-order index.
744
+ async purgeObservation(observationId, confirm) {
745
+ if (!this.db)
746
+ throw new Error('Database not initialized');
747
+ if (confirm !== 'PURGE') {
748
+ throw new Error(`purgeObservation refused: pass confirm='PURGE' to physically delete a revision. ` +
749
+ `This destroys history — retractObservation() is almost always what you want.`);
750
+ }
751
+ const row = this.db.prepare(`SELECT entity_id, root_id, revision_no FROM entity_observations WHERE observation_id = ?`)
752
+ .get(observationId);
753
+ if (!row)
754
+ return { purged: 0 };
755
+ let purged = 0;
756
+ this.mutateEntityAndInvalidate(row.entity_id, () => {
757
+ // newest-first so each DELETE has no successor referencing it
758
+ const victims = this.db.prepare(`SELECT observation_id FROM entity_observations
759
+ WHERE root_id = ? AND revision_no >= ?
760
+ ORDER BY revision_no DESC`).all(row.root_id, row.revision_no);
761
+ for (const v of victims) {
762
+ this.db.prepare(`DELETE FROM observation_events WHERE from_id = ? OR to_id = ?`)
763
+ .run(v.observation_id, v.observation_id);
764
+ purged += this.db.prepare(`DELETE FROM entity_observations WHERE observation_id = ?`)
765
+ .run(v.observation_id).changes;
766
+ }
767
+ return purged > 0;
768
+ });
769
+ await this.tryEmbedEntity(row.entity_id, 'bulk');
770
+ return { purged };
771
+ }
772
+ // spec §6.2: 과거 판본은 여기서만 나온다. 일반 검색은 active 만 반환한다.
773
+ async getObservationHistory(sel) {
774
+ if (!this.db)
775
+ throw new Error('Database not initialized');
776
+ return getObservationHistory(this.db, sel);
777
+ }
583
778
  async deleteEntities(entityNames) {
584
779
  if (!this.db)
585
780
  throw new Error('Database not initialized');
@@ -647,34 +842,85 @@ export class RAGKnowledgeGraphManager {
647
842
  // Pre-3.6 this method silently left STALE entity vectors behind (the input
648
843
  // text changed but the vector was never regenerated) — fixed via
649
844
  // tryEmbedEntity, which also covers the not-ready dirty contract.
845
+ // DEPRECATED shim (v13, one version only). Content-addressed deletion cannot
846
+ // express "which revision" — use retractObservation(observation_id) instead.
847
+ // Semantics: exact-string match against ACTIVE revisions -> soft retract.
848
+ //
849
+ // The whole call is one transaction and ambiguity is judged before any
850
+ // mutation: if any item matches 2+ active revisions the call aborts with 0
851
+ // mutations (spec §6.3). That is a deliberate change from v3.6, which deleted
852
+ // every duplicate and carried on — a machine cannot pick which revision was meant.
853
+ //
854
+ // Duplicate ids across items are collapsed. Without that, listing the same
855
+ // (entity, content) twice retracted it once and then failed on an illegal
856
+ // transition, returning an error *after* committing part of the batch
857
+ // (advisor 구현리뷰 r1 발견 3, 실행 재현). Embedding runs after the commit.
650
858
  async deleteObservations(deletions) {
651
859
  if (!this.db)
652
860
  throw new Error('Database not initialized');
861
+ const plan = [];
862
+ const ambiguous = [];
863
+ const claimed = new Set(); // 항목 간 중복 id 흡수
864
+ for (const d of deletions) {
865
+ const entityId = `entity_${d.entityName.toLowerCase().replace(/[^\p{L}\p{N}]/gu, '_')}`;
866
+ const ids = [];
867
+ for (const content of d.observations) {
868
+ const rows = this.db.prepare(`SELECT observation_id FROM entity_observations
869
+ WHERE entity_id = ? AND status = 'active' AND content = ?`).all(entityId, content);
870
+ if (rows.length > 1) {
871
+ ambiguous.push({ entityName: d.entityName, content, matches: rows.length });
872
+ continue;
873
+ }
874
+ if (rows.length === 1 && !claimed.has(rows[0].observation_id)) {
875
+ claimed.add(rows[0].observation_id);
876
+ ids.push(rows[0].observation_id);
877
+ }
878
+ // rows.length === 0 -> no-op (v3.6 behaviour, spec §6.3-3)
879
+ }
880
+ plan.push({ entityName: d.entityName, entityId, ids });
881
+ }
882
+ if (ambiguous.length > 0) {
883
+ throw new Error(`AMBIGUOUS_OBSERVATION_MATCH: ${ambiguous.length} item(s) matched multiple active ` +
884
+ `revisions; 0 mutations were applied. Use retractObservation(observation_id) instead. ` +
885
+ `Conflicts: ${JSON.stringify(ambiguous)}`);
886
+ }
887
+ // pass 2 — mutate everything in ONE transaction so a failure anywhere
888
+ // leaves zero mutations. Per-plan transactions plus an awaited embedding
889
+ // in between made a partial commit observable.
890
+ const touched = plan.filter(p => p.ids.length > 0);
891
+ const ts = new Date().toISOString();
892
+ if (touched.length > 0) {
893
+ const tx = this.db.transaction(() => {
894
+ for (const p of touched) {
895
+ for (const id of p.ids) {
896
+ transitionStatus(this.db, { observationId: id, event: 'retract',
897
+ reason: 'deleteObservations (deprecated shim)', ts });
898
+ }
899
+ rebuildProjection(this.db, p.entityId);
900
+ const meta = this.db.prepare(`SELECT rowid FROM entity_embedding_metadata WHERE entity_id = ?`)
901
+ .get(p.entityId);
902
+ if (meta) {
903
+ this.db.exec(`DELETE FROM entity_embeddings WHERE rowid = ${Number(meta.rowid)}`);
904
+ this.db.prepare(`DELETE FROM entity_embedding_metadata WHERE entity_id = ?`)
905
+ .run(p.entityId);
906
+ }
907
+ deleteStaleKgChunks(this.db, p.entityId);
908
+ }
909
+ });
910
+ tx();
911
+ this.coordinator?.invalidateCoverage();
912
+ }
913
+ // pass 3 — embedding after the commit
653
914
  const results = [];
654
915
  let total = 0;
655
- for (const deletion of deletions) {
656
- const entityId = `entity_${deletion.entityName.toLowerCase().replace(/[^\p{L}\p{N}]/gu, '_')}`;
657
- const entity = this.db.prepare(`
658
- SELECT observations FROM entities WHERE id = ?
659
- `).get(entityId);
660
- if (!entity) {
661
- results.push({ entityName: deletion.entityName, deleted: 0, embedding_status: 'n/a' });
662
- continue;
663
- }
664
- const currentObservations = JSON.parse(entity.observations);
665
- const filteredObservations = currentObservations.filter((obs) => !deletion.observations.includes(obs));
666
- const deleted = currentObservations.length - filteredObservations.length;
667
- if (deleted === 0) {
668
- results.push({ entityName: deletion.entityName, deleted: 0, embedding_status: 'n/a' });
916
+ for (const p of plan) {
917
+ if (p.ids.length === 0) {
918
+ results.push({ entityName: p.entityName, deleted: 0, embedding_status: 'n/a' });
669
919
  continue;
670
920
  }
671
- this.mutateEntityAndInvalidate(entityId, () => {
672
- this.db.prepare(`UPDATE entities SET observations = ? WHERE id = ?`)
673
- .run(JSON.stringify(filteredObservations), entityId);
674
- });
675
- const embedding_status = await this.tryEmbedEntity(entityId, 'bulk');
676
- total += deleted;
677
- results.push({ entityName: deletion.entityName, deleted, embedding_status });
921
+ const embedding_status = await this.tryEmbedEntity(p.entityId, 'bulk');
922
+ results.push({ entityName: p.entityName, deleted: p.ids.length, embedding_status });
923
+ total += p.ids.length;
678
924
  }
679
925
  return { results, total_deleted: total };
680
926
  }
@@ -2192,10 +2438,20 @@ export class RAGKnowledgeGraphManager {
2192
2438
  created_at: row.created_at
2193
2439
  }));
2194
2440
  console.error(`✅ Export completed: ${entities.length} entities, ${relations.length} relations, ${documents.length} documents`);
2441
+ // spec §6.4: lifecycle 정본을 함께 내보낸다. 이게 없으면 export->import 뒤
2442
+ // 관찰의 신원·출처·이력이 사라지고 projection 만 남는다.
2443
+ const observation_roots = this.db.prepare(`SELECT * FROM observation_roots ORDER BY entity_id, projection_order`).all();
2444
+ const entity_observations = this.db.prepare(`SELECT * FROM entity_observations ORDER BY root_id, revision_no`).all();
2445
+ const observation_sources = this.db.prepare(`SELECT * FROM observation_sources ORDER BY observation_id, source_kind, source_ref`).all();
2446
+ const observation_events = this.db.prepare(`SELECT * FROM observation_events ORDER BY root_id, recorded_at, event_id`).all();
2195
2447
  return {
2196
2448
  entities,
2197
2449
  relations,
2198
2450
  documents,
2451
+ observation_roots,
2452
+ entity_observations,
2453
+ observation_sources,
2454
+ observation_events,
2199
2455
  metadata: {
2200
2456
  exportedAt: new Date().toISOString(),
2201
2457
  version: PKG_VERSION,
@@ -2211,66 +2467,245 @@ export class RAGKnowledgeGraphManager {
2211
2467
  console.error(`📥 Importing knowledge graph (merge: ${options.merge !== false})...`);
2212
2468
  const imported = { entities: 0, relations: 0, documents: 0 };
2213
2469
  const skipped = { entities: 0, relations: 0, documents: 0 };
2214
- // If merge=false, clear existing data first
2215
- if (options.merge === false) {
2216
- this.db.exec(`DELETE FROM relationships`);
2217
- this.db.exec(`DELETE FROM entities`);
2218
- this.db.exec(`DELETE FROM documents`);
2219
- console.error('🗑️ Cleared existing data for full import');
2220
- }
2221
- // Import entities using INSERT OR IGNORE
2222
- if (data.entities && Array.isArray(data.entities)) {
2223
- const stmt = this.db.prepare(`
2470
+ // merge 로 배열 위치가 재배정된 관찰. 조용히 순서를 바꾸면 호출자가 알 수 없으므로
2471
+ // 응답으로 내보낸다(advisor beta r3 발견 3).
2472
+ const remapReport = [];
2473
+ // spec §6.4: abort 는 0 mutation 이다. lifecycle 만 트랜잭션으로 감싸면
2474
+ // 충돌로 throw 할 때 그 앞에서 넣은 entity·relation·document 가 살아남는다
2475
+ // (T17b 가 ghost entity 로 실증). import 전체가 한 단위여야 한다.
2476
+ // 내부 transaction() 호출은 better-sqlite3 에서 savepoint 로 중첩된다.
2477
+ const importAll = this.db.transaction(() => {
2478
+ // If merge=false, clear existing data first
2479
+ if (options.merge === false) {
2480
+ this.db.exec(`DELETE FROM relationships`);
2481
+ // entities 삭제가 FK CASCADE 로 lifecycle 4테이블을 지우지만, 순서를 계약으로
2482
+ // 두어 FK 가 꺼진 환경에서도 잔존 행이 남지 않게 한다.
2483
+ this.db.exec(`DELETE FROM observation_events`);
2484
+ this.db.exec(`DELETE FROM observation_sources`);
2485
+ this.db.exec(`DELETE FROM entity_observations`);
2486
+ this.db.exec(`DELETE FROM observation_roots`);
2487
+ this.db.exec(`DELETE FROM entities`);
2488
+ this.db.exec(`DELETE FROM documents`);
2489
+ // entities 를 지워도 파생 데이터는 따라오지 않는다: chunk_metadata 에는
2490
+ // entities 로 가는 FK 가 없고 entity_embedding_metadata.entity_id 는 UNIQUE 일
2491
+ // 뿐이다. 그래서 replace-import 뒤에 **사라진 entity 의 벡터와 KG chunk 가
2492
+ // 검색에 남았다**(advisor beta 발견 2). document chunk 는 documents 의
2493
+ // CASCADE 로 이미 정리되므로 여기서는 entity·relationship chunk 만 지운다.
2494
+ const orphanChunks = this.db.prepare(`SELECT rowid FROM chunk_metadata WHERE chunk_type IN ('entity','relationship')`)
2495
+ .all();
2496
+ for (const c of orphanChunks) {
2497
+ this.db.exec(`DELETE FROM chunks WHERE rowid = ${Number(c.rowid)}`);
2498
+ this.db.prepare(`DELETE FROM chunk_metadata WHERE rowid = ?`).run(c.rowid);
2499
+ }
2500
+ this.db.exec(`DELETE FROM entity_embeddings WHERE rowid IN (SELECT rowid FROM entity_embedding_metadata)`);
2501
+ this.db.exec(`DELETE FROM entity_embedding_metadata`);
2502
+ console.error('🗑️ Cleared existing data for full import');
2503
+ }
2504
+ // Import entities using INSERT OR IGNORE
2505
+ if (data.entities && Array.isArray(data.entities)) {
2506
+ const stmt = this.db.prepare(`
2224
2507
  INSERT OR IGNORE INTO entities (id, name, entityType, observations, metadata, created_at)
2225
2508
  VALUES (?, ?, ?, ?, ?, ?)
2226
2509
  `);
2227
- for (const entity of data.entities) {
2228
- const result = stmt.run(entity.id, entity.name, entity.entityType || 'CONCEPT', JSON.stringify(entity.observations || []), JSON.stringify(entity.metadata || {}), entity.created_at || new Date().toISOString());
2229
- if (result.changes > 0) {
2230
- imported.entities++;
2231
- }
2232
- else {
2233
- skipped.entities++;
2510
+ for (const entity of data.entities) {
2511
+ const result = stmt.run(entity.id, entity.name, entity.entityType || 'CONCEPT',
2512
+ // v13: observations 는 projection 이다. lifecycle 행을 넣은 뒤
2513
+ // rebuildProjection 이 채운다 — 여기서 배열을 심으면 정본과 갈라진다.
2514
+ '[]', JSON.stringify(entity.metadata || {}), entity.created_at || new Date().toISOString());
2515
+ if (result.changes > 0) {
2516
+ imported.entities++;
2517
+ }
2518
+ else {
2519
+ skipped.entities++;
2520
+ }
2234
2521
  }
2235
2522
  }
2236
- }
2237
- // Import relations using INSERT OR IGNORE
2238
- if (data.relations && Array.isArray(data.relations)) {
2239
- const stmt = this.db.prepare(`
2523
+ // Import relations using INSERT OR IGNORE
2524
+ if (data.relations && Array.isArray(data.relations)) {
2525
+ const stmt = this.db.prepare(`
2240
2526
  INSERT OR IGNORE INTO relationships (id, source_entity, target_entity, relationType, confidence, metadata, created_at)
2241
2527
  VALUES (?, ?, ?, ?, ?, ?, ?)
2242
2528
  `);
2243
- for (const relation of data.relations) {
2244
- const result = stmt.run(relation.id, relation.source_entity, relation.target_entity, relation.relationType, relation.confidence ?? 1.0, JSON.stringify(relation.metadata || {}), relation.created_at || new Date().toISOString());
2245
- if (result.changes > 0) {
2246
- imported.relations++;
2247
- }
2248
- else {
2249
- skipped.relations++;
2529
+ for (const relation of data.relations) {
2530
+ const result = stmt.run(relation.id, relation.source_entity, relation.target_entity, relation.relationType, relation.confidence ?? 1.0, JSON.stringify(relation.metadata || {}), relation.created_at || new Date().toISOString());
2531
+ if (result.changes > 0) {
2532
+ imported.relations++;
2533
+ }
2534
+ else {
2535
+ skipped.relations++;
2536
+ }
2250
2537
  }
2251
2538
  }
2252
- }
2253
- // Import documents using INSERT OR REPLACE
2254
- if (data.documents && Array.isArray(data.documents)) {
2255
- const stmt = this.db.prepare(`
2539
+ // Import documents using INSERT OR REPLACE
2540
+ if (data.documents && Array.isArray(data.documents)) {
2541
+ const stmt = this.db.prepare(`
2256
2542
  INSERT OR REPLACE INTO documents (id, content, metadata, created_at)
2257
2543
  VALUES (?, ?, ?, ?)
2258
2544
  `);
2259
- for (const doc of data.documents) {
2260
- const result = stmt.run(doc.id, doc.content, JSON.stringify(doc.metadata || {}), doc.created_at || new Date().toISOString());
2261
- if (result.changes > 0) {
2262
- imported.documents++;
2263
- }
2264
- else {
2265
- skipped.documents++;
2545
+ for (const doc of data.documents) {
2546
+ const result = stmt.run(doc.id, doc.content, JSON.stringify(doc.metadata || {}), doc.created_at || new Date().toISOString());
2547
+ if (result.changes > 0) {
2548
+ imported.documents++;
2549
+ }
2550
+ else {
2551
+ skipped.documents++;
2552
+ }
2266
2553
  }
2267
2554
  }
2268
- }
2555
+ // ---- spec §6.4: lifecycle import ----
2556
+ // 순서가 계약이다: entities -> roots -> revisions(root별 revision_no ↑)
2557
+ // -> sources/events. §4.1 트리거가 root 선행과 체인 연속성을 요구하므로
2558
+ // importer 는 입력 순서와 무관하게 재정렬한다 (역순 export 를 그대로
2559
+ // 스트리밍하면 'immediately preceding revision' 으로 죽는다).
2560
+ const sameRow = (a, b, cols) => cols.every(c => (a[c] ?? null) === (b[c] ?? null));
2561
+ const hasLifecycle = Array.isArray(data.observation_roots);
2562
+ if (hasLifecycle) {
2563
+ const tx = this.db.transaction(() => {
2564
+ // 새 root 가 이미 점유된 (entity_id, projection_order) 슬롯을 요구할 수 있다:
2565
+ // 두 DB 가 같은 entity 이름을 갖고 서로 다른 관찰을 배열 0번에 두면 그렇다.
2566
+ // 이건 §6.4 의 "같은 키 다른 값" 충돌이 아니라 **슬롯 충돌**이고, 규칙이 없어서
2567
+ // raw UNIQUE 오류로 터졌다(내 MCP 왕복 테스트가 잡았다). merge 의 뜻은
2568
+ // "더한다"이므로 들어오는 root 에 다음 빈 순번을 준다 — 남의 관찰을 덮지 않고,
2569
+ // 배열 끝에 붙는다. remap 은 그 root 의 revision 들에도 그대로 적용해야 한다
2570
+ // (trg_obs_matches_root 가 둘의 일치를 요구한다).
2571
+ // 입력 순서에 결과가 의존하면 같은 dump 를 두 번 넣었을 때 배열 순서가 달라진다.
2572
+ // (entity_id, projection_order, root_id) 로 정렬해 결정론을 만든다.
2573
+ const incomingRoots = [...(data.observation_roots ?? [])].sort((a, b) => String(a.entity_id).localeCompare(String(b.entity_id)) ||
2574
+ (a.projection_order - b.projection_order) ||
2575
+ String(a.root_id).localeCompare(String(b.root_id)));
2576
+ const remappedOrder = new Map();
2577
+ for (const r of incomingRoots) {
2578
+ const cur = this.db.prepare(`SELECT * FROM observation_roots WHERE root_id = ?`)
2579
+ .get(r.root_id);
2580
+ if (cur) {
2581
+ // projection_order 는 **target-local** 속성이다: merge 는 배열 위치를
2582
+ // 이 DB 기준으로 재배정하므로, 이미 remap 된 root 를 같은 dump 로 다시
2583
+ // 넣으면 dump 의 옛 순번과 다를 수밖에 없다. 그걸 충돌로 보면 동일
2584
+ // 재수입이 실패한다(advisor beta r3 발견 3, 실행 재현).
2585
+ if (!sameRow(cur, r, ['entity_id', 'created_at']))
2586
+ throw new Error(`import conflict: observation_roots ${r.root_id} differs from the existing row`);
2587
+ remappedOrder.set(r.root_id, cur.projection_order);
2588
+ continue;
2589
+ }
2590
+ let order = r.projection_order;
2591
+ const taken = this.db.prepare(`SELECT root_id FROM observation_roots WHERE entity_id = ? AND projection_order = ?`)
2592
+ .get(r.entity_id, order);
2593
+ if (taken) {
2594
+ order = nextProjectionOrder(this.db, r.entity_id);
2595
+ remappedOrder.set(r.root_id, order);
2596
+ remapReport.push({ root_id: r.root_id, entity_id: r.entity_id,
2597
+ from: r.projection_order, to: order });
2598
+ console.error(` ├─ ↪️ import: ${r.entity_id} position ${r.projection_order} is held by ` +
2599
+ `${taken.root_id}; appending imported observation at ${order}`);
2600
+ }
2601
+ this.db.prepare(`INSERT INTO observation_roots
2602
+ (root_id, entity_id, projection_order, created_at) VALUES (?, ?, ?, ?)`)
2603
+ .run(r.root_id, r.entity_id, order, r.created_at);
2604
+ }
2605
+ // projection_order 는 root 와 같은 이유로 비교 대상이 아니다(target-local).
2606
+ const revCols = ['root_id', 'entity_id', 'revision_no', 'content',
2607
+ 'status', 'supersedes_id', 'recorded_at', 'superseded_at'];
2608
+ const revs = [...(data.entity_observations ?? [])]
2609
+ .sort((a, b) => a.root_id === b.root_id
2610
+ ? a.revision_no - b.revision_no
2611
+ : String(a.root_id).localeCompare(String(b.root_id)));
2612
+ for (const v of revs) {
2613
+ const cur = this.db.prepare(`SELECT * FROM entity_observations WHERE observation_id = ?`)
2614
+ .get(v.observation_id);
2615
+ if (cur) {
2616
+ if (!sameRow(cur, v, revCols))
2617
+ throw new Error(`import conflict: entity_observations ${v.observation_id} differs from the existing row`);
2618
+ continue;
2619
+ }
2620
+ const order = remappedOrder.has(v.root_id)
2621
+ ? remappedOrder.get(v.root_id) : v.projection_order;
2622
+ this.db.prepare(`INSERT INTO entity_observations
2623
+ (observation_id, root_id, entity_id, revision_no, projection_order,
2624
+ content, status, supersedes_id, recorded_at, superseded_at)
2625
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
2626
+ .run(v.observation_id, v.root_id, v.entity_id, v.revision_no, order, v.content, v.status, v.supersedes_id ?? null, v.recorded_at, v.superseded_at ?? null);
2627
+ }
2628
+ for (const so of (data.observation_sources ?? [])) {
2629
+ const cur = this.db.prepare(`SELECT * FROM observation_sources
2630
+ WHERE observation_id=? AND source_kind=? AND source_ref=?`)
2631
+ .get(so.observation_id, so.source_kind, so.source_ref);
2632
+ if (cur) {
2633
+ if (!sameRow(cur, so, ['source_hash', 'recorded_at']))
2634
+ throw new Error(`import conflict: observation_sources ` +
2635
+ `${so.observation_id}/${so.source_kind}/${so.source_ref} differs from the existing row`);
2636
+ continue;
2637
+ }
2638
+ this.db.prepare(`INSERT INTO observation_sources
2639
+ (observation_id, source_kind, source_ref, source_hash, recorded_at) VALUES (?, ?, ?, ?, ?)`)
2640
+ .run(so.observation_id, so.source_kind, so.source_ref, so.source_hash ?? null, so.recorded_at);
2641
+ }
2642
+ const evCols = ['root_id', 'from_id', 'to_id', 'event', 'change_kind', 'reason', 'actor', 'batch_id', 'recorded_at'];
2643
+ for (const e of (data.observation_events ?? [])) {
2644
+ const cur = this.db.prepare(`SELECT * FROM observation_events WHERE event_id = ?`)
2645
+ .get(e.event_id);
2646
+ if (cur) {
2647
+ if (!sameRow(cur, e, evCols))
2648
+ throw new Error(`import conflict: observation_events ${e.event_id} differs from the existing row`);
2649
+ continue;
2650
+ }
2651
+ this.db.prepare(`INSERT INTO observation_events
2652
+ (event_id, root_id, from_id, to_id, event, change_kind, reason, actor, batch_id, recorded_at)
2653
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
2654
+ .run(e.event_id, e.root_id, e.from_id ?? null, e.to_id ?? null, e.event, e.change_kind ?? null, e.reason ?? null, e.actor ?? null, e.batch_id ?? null, e.recorded_at);
2655
+ }
2656
+ });
2657
+ tx();
2658
+ }
2659
+ else {
2660
+ // 구(舊) 형식 export: lifecycle 필드가 없으므로 entities.observations 를
2661
+ // 신규 root 로 승격한다. legacy import 필수 필드값 = spec §6.4.
2662
+ const ts = new Date().toISOString();
2663
+ const tx = this.db.transaction(() => {
2664
+ for (const ent of (data.entities ?? [])) {
2665
+ const entityId = ent.id ??
2666
+ `entity_${String(ent.name).toLowerCase().replace(/[^\p{L}\p{N}]/gu, '_')}`;
2667
+ for (const content of (ent.observations ?? [])) {
2668
+ addRevision(this.db, {
2669
+ entityId, content, status: 'active',
2670
+ sources: [{ source_kind: 'import', source_ref: 'legacy-export', source_hash: null }],
2671
+ actor: 'import', ts, event: 'import'
2672
+ });
2673
+ }
2674
+ }
2675
+ });
2676
+ tx();
2677
+ }
2678
+ // projection 재합성 + 파생 상태 무효화.
2679
+ // 무효화가 없으면 이미 있던 entity 를 덮어쓴 뒤에도 옛 벡터·옛 KG chunk 가
2680
+ // 검색에 남는다. import 는 관찰을 바꾸는 writer 이므로 다른 writer 와 같은
2681
+ // 계약을 져야 한다(advisor beta 발견 2).
2682
+ //
2683
+ // 대상은 `data.entities` 가 아니라 **영향받은 entity 전부**다. lifecycle import 는
2684
+ // observation_roots 만 있어도 활성화되므로, entities 없이 lifecycle 배열만 보내면
2685
+ // revision 은 들어가는데 projection 이 갱신되지 않아 새 사실이 reader 에 안 보이고
2686
+ // 옛 벡터가 남는다(advisor beta r3 발견 2, 실행 재현).
2687
+ const affected = new Set();
2688
+ for (const ent of (data.entities ?? [])) {
2689
+ affected.add(ent.id ??
2690
+ `entity_${String(ent.name).toLowerCase().replace(/[^\p{L}\p{N}]/gu, '_')}`);
2691
+ }
2692
+ for (const r of (data.observation_roots ?? []))
2693
+ if (r.entity_id)
2694
+ affected.add(r.entity_id);
2695
+ for (const v of (data.entity_observations ?? []))
2696
+ if (v.entity_id)
2697
+ affected.add(v.entity_id);
2698
+ for (const entityId of affected) {
2699
+ rebuildProjection(this.db, entityId);
2700
+ this.invalidateDerivedForEntity(entityId);
2701
+ }
2702
+ });
2703
+ importAll();
2269
2704
  console.error(`✅ Import completed: ${imported.entities} entities, ${imported.relations} relations, ${imported.documents} documents imported`);
2270
2705
  // Indirect missing-row producer (spec §5): imported rows may lack vectors.
2271
2706
  this.coordinator?.invalidateCoverage();
2272
2707
  this.coordinator?.kick();
2273
- return { imported, skipped };
2708
+ return { imported, skipped, observation_order_remap: remapReport };
2274
2709
  }
2275
2710
  async hybridSearch(query, limit = 5, useGraph = true) {
2276
2711
  if (!this.db)
@@ -3125,7 +3560,32 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3125
3560
  case "createRelations":
3126
3561
  return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.createRelations(validatedArgs.relations), null, 2) }] };
3127
3562
  case "addObservations":
3563
+ // v13: status·sources 를 그대로 넘긴다. 여기서 떨어뜨리면 스키마가 받아도
3564
+ // 엔진에 도달하지 않아 provenance 가 조용히 사라진다.
3128
3565
  return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.addObservations(validatedArgs.observations), null, 2) }] };
3566
+ // v13 observation lifecycle (spec §6.1 / §6.2)
3567
+ case "correctObservation":
3568
+ return { content: [{ type: "text", text: JSON.stringify({ observation_id: await ragKgManager.correctObservation(validatedArgs.observation_id, validatedArgs.content, validatedArgs.change_kind ?? 'correction', validatedArgs.reason) }, null, 2) }] };
3569
+ case "retractObservation":
3570
+ await ragKgManager.retractObservation(validatedArgs.observation_id, validatedArgs.reason);
3571
+ return { content: [{ type: "text", text: JSON.stringify({ observation_id: validatedArgs.observation_id, status: 'retracted' }, null, 2) }] };
3572
+ case "restoreObservation":
3573
+ await ragKgManager.restoreObservation(validatedArgs.observation_id, validatedArgs.reason);
3574
+ return { content: [{ type: "text", text: JSON.stringify({ observation_id: validatedArgs.observation_id, status: 'active' }, null, 2) }] };
3575
+ case "approveObservation":
3576
+ await ragKgManager.approveObservation(validatedArgs.observation_id, validatedArgs.reason);
3577
+ return { content: [{ type: "text", text: JSON.stringify({ observation_id: validatedArgs.observation_id, status: 'active' }, null, 2) }] };
3578
+ case "declineObservation":
3579
+ await ragKgManager.declineObservation(validatedArgs.observation_id, validatedArgs.reason);
3580
+ return { content: [{ type: "text", text: JSON.stringify({ observation_id: validatedArgs.observation_id, status: 'retracted' }, null, 2) }] };
3581
+ case "purgeObservation":
3582
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.purgeObservation(validatedArgs.observation_id, validatedArgs.confirm), null, 2) }] };
3583
+ case "getObservationHistory":
3584
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.getObservationHistory({
3585
+ entity_name: validatedArgs.entity_name,
3586
+ observation_id: validatedArgs.observation_id,
3587
+ root_id: validatedArgs.root_id,
3588
+ }), null, 2) }] };
3129
3589
  case "deleteEntities":
3130
3590
  await ragKgManager.deleteEntities(validatedArgs.entityNames);
3131
3591
  return { content: [{ type: "text", text: "Entities deleted successfully" }] };