get-claudia 1.29.2 → 1.30.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.
@@ -138,6 +138,21 @@ class Database:
138
138
  else:
139
139
  conn.commit()
140
140
 
141
+ @contextmanager
142
+ def transaction(self) -> Generator[sqlite3.Connection, None, None]:
143
+ """Explicit multi-step transaction. Commits on success, rolls back on error.
144
+
145
+ Use this when multiple operations must succeed or fail atomically.
146
+ Unlike connection(), the caller uses conn.execute() directly.
147
+ """
148
+ conn = self._get_connection()
149
+ try:
150
+ yield conn
151
+ conn.commit()
152
+ except Exception:
153
+ conn.rollback()
154
+ raise
155
+
141
156
  @contextmanager
142
157
  def cursor(self) -> Generator[sqlite3.Cursor, None, None]:
143
158
  """Context manager for database cursor"""
@@ -724,6 +739,30 @@ class Database:
724
739
  conn.commit()
725
740
  logger.info("Applied migration 14: dispatch_tier for native agent teams")
726
741
 
742
+ if current_version < 15:
743
+ # Migration 15: Add origin_type to relationships for organic trust model
744
+ try:
745
+ conn.execute(
746
+ "ALTER TABLE relationships ADD COLUMN origin_type TEXT DEFAULT 'extracted'"
747
+ )
748
+ except sqlite3.OperationalError as e:
749
+ if "duplicate column" not in str(e).lower():
750
+ logger.warning(f"Migration 15 statement failed: {e}")
751
+
752
+ # Grandfather existing relationships: all get 'extracted' (the safe default)
753
+ try:
754
+ conn.execute(
755
+ "UPDATE relationships SET origin_type = 'extracted' WHERE origin_type IS NULL"
756
+ )
757
+ except sqlite3.OperationalError as e:
758
+ logger.warning(f"Migration 15 grandfather failed: {e}")
759
+
760
+ conn.execute(
761
+ "INSERT OR IGNORE INTO schema_migrations (version, description) VALUES (15, 'Add origin_type to relationships for organic trust model')"
762
+ )
763
+ conn.commit()
764
+ logger.info("Applied migration 15: relationship origin_type for organic trust model")
765
+
727
766
  # FTS5 setup: ensure memories_fts exists regardless of migration path.
728
767
  # The FTS5 virtual table + triggers contain internal semicolons that the
729
768
  # schema.sql line-based parser can't handle, so we always check here.
@@ -852,6 +891,11 @@ class Database:
852
891
  logger.warning("Migration 14 incomplete: agent_dispatches missing dispatch_tier column")
853
892
  return 13
854
893
 
894
+ # Migration 15 added origin_type to relationships
895
+ if "origin_type" not in rel_cols:
896
+ logger.warning("Migration 15 incomplete: relationships missing origin_type column")
897
+ return 14
898
+
855
899
  return None # All good
856
900
 
857
901
  def _store_workspace_path(self, conn: sqlite3.Connection) -> None:
@@ -57,6 +57,7 @@ from ..services.remember import (
57
57
  get_remember_service,
58
58
  get_unsummarized_turns,
59
59
  invalidate_memory,
60
+ invalidate_relationship,
60
61
  merge_entities,
61
62
  relate_entities,
62
63
  store_reflection,
@@ -240,6 +241,16 @@ async def list_tools() -> ListToolsResult:
240
241
  "description": "If true, invalidate existing relationship of same type between same entities and create new one",
241
242
  "default": False,
242
243
  },
244
+ "origin_type": {
245
+ "type": "string",
246
+ "description": "How this was learned: user_stated, extracted, inferred, corrected",
247
+ "default": "extracted",
248
+ },
249
+ "direction": {
250
+ "type": "string",
251
+ "description": "Relationship direction: forward, backward, or bidirectional",
252
+ "default": "bidirectional",
253
+ },
243
254
  },
244
255
  "required": ["source", "target", "relationship"],
245
256
  },
@@ -639,6 +650,23 @@ async def list_tools() -> ListToolsResult:
639
650
  "type": "number",
640
651
  "description": "Relationship strength 0.0-1.0 (for 'relate' op)",
641
652
  },
653
+ "origin_type": {
654
+ "type": "string",
655
+ "description": "How this was learned: user_stated, extracted, inferred (for 'relate' op)",
656
+ },
657
+ "supersedes": {
658
+ "type": "boolean",
659
+ "description": "Invalidate existing relationship of same type (for 'relate' op)",
660
+ "default": False,
661
+ },
662
+ "valid_at": {
663
+ "type": "string",
664
+ "description": "When this relationship became true (for 'relate' op)",
665
+ },
666
+ "direction": {
667
+ "type": "string",
668
+ "description": "Relationship direction (for 'relate' op)",
669
+ },
642
670
  },
643
671
  "required": ["op"],
644
672
  },
@@ -1060,6 +1088,37 @@ async def list_tools() -> ListToolsResult:
1060
1088
  "required": ["memory_id"],
1061
1089
  },
1062
1090
  ),
1091
+ Tool(
1092
+ name="memory.invalidate_relationship",
1093
+ description=(
1094
+ "Mark a relationship as incorrect or ended without creating a replacement. "
1095
+ "Use when the user says a relationship is wrong, or when someone leaves a "
1096
+ "company, ends a partnership, etc. The relationship is preserved for history "
1097
+ "but excluded from active queries."
1098
+ ),
1099
+ inputSchema={
1100
+ "type": "object",
1101
+ "properties": {
1102
+ "source": {
1103
+ "type": "string",
1104
+ "description": "Source entity name",
1105
+ },
1106
+ "target": {
1107
+ "type": "string",
1108
+ "description": "Target entity name",
1109
+ },
1110
+ "relationship": {
1111
+ "type": "string",
1112
+ "description": "Relationship type to invalidate (works_with, manages, etc.)",
1113
+ },
1114
+ "reason": {
1115
+ "type": "string",
1116
+ "description": "Why this relationship is being invalidated",
1117
+ },
1118
+ },
1119
+ "required": ["source", "target", "relationship"],
1120
+ },
1121
+ ),
1063
1122
  Tool(
1064
1123
  name="memory.audit_history",
1065
1124
  description=(
@@ -1281,6 +1340,8 @@ async def call_tool(name: str, arguments: Dict[str, Any]) -> CallToolResult:
1281
1340
  strength=arguments.get("strength", 1.0),
1282
1341
  valid_at=arguments.get("valid_at"),
1283
1342
  supersedes=arguments.get("supersedes", False),
1343
+ origin_type=arguments.get("origin_type", "extracted"),
1344
+ direction=arguments.get("direction", "bidirectional"),
1284
1345
  )
1285
1346
  return CallToolResult(
1286
1347
  content=[
@@ -1604,6 +1665,10 @@ async def call_tool(name: str, arguments: Dict[str, Any]) -> CallToolResult:
1604
1665
  target=op["target"],
1605
1666
  relationship=op["relationship"],
1606
1667
  strength=op.get("strength", 1.0),
1668
+ supersedes=op.get("supersedes", False),
1669
+ valid_at=op.get("valid_at"),
1670
+ direction=op.get("direction", "bidirectional"),
1671
+ origin_type=op.get("origin_type", "extracted"),
1607
1672
  )
1608
1673
  op_result["success"] = True
1609
1674
  op_result["relationship_id"] = relationship_id
@@ -1873,6 +1938,22 @@ async def call_tool(name: str, arguments: Dict[str, Any]) -> CallToolResult:
1873
1938
  ]
1874
1939
  )
1875
1940
 
1941
+ elif name == "memory.invalidate_relationship":
1942
+ result = invalidate_relationship(
1943
+ source=arguments["source"],
1944
+ target=arguments["target"],
1945
+ relationship=arguments["relationship"],
1946
+ reason=arguments.get("reason"),
1947
+ )
1948
+ return CallToolResult(
1949
+ content=[
1950
+ TextContent(
1951
+ type="text",
1952
+ text=json.dumps(result),
1953
+ )
1954
+ ]
1955
+ )
1956
+
1876
1957
  elif name == "memory.audit_history":
1877
1958
  # Get audit history for entity or memory
1878
1959
  entity_id = arguments.get("entity_id")
@@ -89,6 +89,7 @@ CREATE TABLE IF NOT EXISTS relationships (
89
89
  target_entity_id INTEGER NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
90
90
  relationship_type TEXT NOT NULL, -- works_with, manages, client_of, etc.
91
91
  strength REAL DEFAULT 1.0, -- Relationship strength (decays/grows)
92
+ origin_type TEXT DEFAULT 'extracted', -- user_stated, extracted, inferred, corrected
92
93
  direction TEXT DEFAULT 'bidirectional' CHECK (direction IN ('forward', 'backward', 'bidirectional')),
93
94
  valid_at TEXT, -- When this relationship became true in the real world
94
95
  invalid_at TEXT, -- When this relationship was superseded (NULL = current)
@@ -408,6 +409,9 @@ VALUES (13, 'Add origin_type to memories, agent_dispatches table for Trust North
408
409
  INSERT OR IGNORE INTO schema_migrations (version, description)
409
410
  VALUES (14, 'Add dispatch_tier to agent_dispatches for native agent team support');
410
411
 
412
+ INSERT OR IGNORE INTO schema_migrations (version, description)
413
+ VALUES (15, 'Add origin_type to relationships for organic trust model');
414
+
411
415
  -- ============================================================================
412
416
  -- AGENT DISPATCHES: Track delegated tasks to sub-agents
413
417
  -- ============================================================================
@@ -112,15 +112,37 @@ def validate_entity(
112
112
  return result
113
113
 
114
114
 
115
- def validate_relationship(strength: float = 1.0) -> ValidationResult:
115
+ # Origin-aware strength ceilings: relationships can't exceed their evidence level
116
+ ORIGIN_STRENGTH_CEILING = {
117
+ "user_stated": 1.0, # User said it directly -- full trust
118
+ "extracted": 0.8, # Evidence from a document -- high but not absolute
119
+ "inferred": 0.5, # Co-occurrence guess -- must earn trust through repetition
120
+ "corrected": 1.0, # User corrected it -- full trust
121
+ }
122
+
123
+ # Origin-scaled reinforcement increments (Hebbian: stronger signals potentiate more)
124
+ REINFORCEMENT_BY_ORIGIN = {
125
+ "user_stated": 0.2,
126
+ "extracted": 0.1,
127
+ "inferred": 0.05,
128
+ "corrected": 0.2,
129
+ }
130
+
131
+
132
+ def validate_relationship(
133
+ strength: float = 1.0,
134
+ origin_type: str = "extracted",
135
+ ) -> ValidationResult:
116
136
  """
117
137
  Validate a relationship before storage.
118
138
 
119
139
  Checks:
120
140
  - Strength clamped to [0, 1]
141
+ - Strength capped by origin authority ceiling
121
142
  """
122
143
  result = ValidationResult()
123
144
 
145
+ # Clamp to [0, 1]
124
146
  if strength < 0:
125
147
  result.warnings.append(f"Relationship strength {strength} clamped to 0.0")
126
148
  result.adjustments["strength"] = 0.0
@@ -128,4 +150,13 @@ def validate_relationship(strength: float = 1.0) -> ValidationResult:
128
150
  result.warnings.append(f"Relationship strength {strength} clamped to 1.0")
129
151
  result.adjustments["strength"] = 1.0
130
152
 
153
+ # Cap by origin authority
154
+ ceiling = ORIGIN_STRENGTH_CEILING.get(origin_type, 0.5)
155
+ effective = result.adjustments.get("strength", strength)
156
+ if effective > ceiling:
157
+ result.warnings.append(
158
+ f"Strength {effective} exceeds ceiling {ceiling} for origin '{origin_type}', capped"
159
+ )
160
+ result.adjustments["strength"] = ceiling
161
+
131
162
  return result
@@ -696,10 +696,12 @@ class RecallService:
696
696
 
697
697
  relationships = []
698
698
  for row in rel_rows:
699
+ row_keys = row.keys()
699
700
  rel_dict = {
700
701
  "type": row["relationship_type"],
701
702
  "direction": row["direction"],
702
703
  "strength": row["strength"],
704
+ "origin_type": row["origin_type"] if "origin_type" in row_keys else "extracted",
703
705
  "other_entity": (
704
706
  row["target_name"]
705
707
  if row["source_entity_id"] == entity["id"]
@@ -713,7 +715,6 @@ class RecallService:
713
715
  }
714
716
  # Include temporal fields when showing historical data
715
717
  if include_historical:
716
- row_keys = row.keys()
717
718
  rel_dict["valid_at"] = row["valid_at"] if "valid_at" in row_keys else None
718
719
  rel_dict["invalid_at"] = row["invalid_at"] if "invalid_at" in row_keys else None
719
720
  relationships.append(rel_dict)
@@ -376,6 +376,7 @@ class RememberService:
376
376
  metadata: Optional[Dict] = None,
377
377
  valid_at: Optional[str] = None,
378
378
  supersedes: bool = False,
379
+ origin_type: str = "extracted",
379
380
  ) -> Optional[int]:
380
381
  """
381
382
  Create or strengthen a relationship between entities.
@@ -390,12 +391,15 @@ class RememberService:
390
391
  valid_at: When this relationship became true (ISO string, defaults to now)
391
392
  supersedes: If True, invalidate existing relationship of same type
392
393
  between same entities before creating a new one
394
+ origin_type: How this was learned: user_stated, extracted, inferred, corrected
393
395
 
394
396
  Returns:
395
397
  Relationship ID or None
396
398
  """
397
- # Run deterministic guards
398
- guard_result = validate_relationship(strength)
399
+ from .guards import ORIGIN_STRENGTH_CEILING, REINFORCEMENT_BY_ORIGIN
400
+
401
+ # Run deterministic guards (origin-aware)
402
+ guard_result = validate_relationship(strength, origin_type=origin_type)
399
403
  if guard_result.warnings:
400
404
  for w in guard_result.warnings:
401
405
  logger.warning(f"Relationship guard: {w}")
@@ -412,44 +416,56 @@ class RememberService:
412
416
  effective_valid_at = valid_at or now
413
417
 
414
418
  if supersedes:
415
- # Invalidate any existing relationship of same type FROM this source entity
419
+ # Invalidate existing relationship of same type between same entities (atomic)
416
420
  existing_to_supersede = self.db.get_one(
417
421
  "relationships",
418
- where="source_entity_id = ? AND relationship_type = ? AND invalid_at IS NULL",
419
- where_params=(source_id, relationship_type),
422
+ where="source_entity_id = ? AND target_entity_id = ? AND relationship_type = ? AND invalid_at IS NULL",
423
+ where_params=(source_id, target_id, relationship_type),
420
424
  )
421
425
  if existing_to_supersede:
422
- # Invalidate the old relationship (mark when it ended)
423
- self.db.update(
424
- "relationships",
425
- {
426
- "invalid_at": now,
427
- "updated_at": now,
428
- },
429
- "id = ?",
430
- (existing_to_supersede["id"],),
431
- )
432
- # Rename the type to free the UNIQUE constraint slot
433
- old_meta = json.loads(existing_to_supersede["metadata"] or "{}")
434
- old_meta["superseded_by_at"] = now
435
- self.db.update(
436
- "relationships",
437
- {
438
- "relationship_type": f"{relationship_type}__superseded_{existing_to_supersede['id']}",
439
- "metadata": json.dumps(old_meta),
426
+ with self.db.transaction() as conn:
427
+ # Invalidate the old relationship (mark when it ended)
428
+ conn.execute(
429
+ "UPDATE relationships SET invalid_at = ?, updated_at = ? WHERE id = ?",
430
+ (now, now, existing_to_supersede["id"]),
431
+ )
432
+ # Rename the type to free the UNIQUE constraint slot
433
+ old_meta = json.loads(existing_to_supersede["metadata"] or "{}")
434
+ old_meta["superseded_by_at"] = now
435
+ conn.execute(
436
+ "UPDATE relationships SET relationship_type = ?, metadata = ? WHERE id = ?",
437
+ (
438
+ f"{relationship_type}__superseded_{existing_to_supersede['id']}",
439
+ json.dumps(old_meta),
440
+ existing_to_supersede["id"],
441
+ ),
442
+ )
443
+
444
+ # Audit log for supersede
445
+ _audit_log(
446
+ "relationship_supersede",
447
+ details={
448
+ "old_id": existing_to_supersede["id"],
449
+ "source": source_name,
450
+ "target": target_name,
451
+ "type": relationship_type,
440
452
  },
441
- "id = ?",
442
- (existing_to_supersede["id"],),
443
453
  )
444
454
 
455
+ # Supersede always sets origin_type to 'corrected' (user is correcting the record)
456
+ supersede_origin = "corrected"
457
+ ceiling = ORIGIN_STRENGTH_CEILING.get(supersede_origin, 0.5)
458
+ capped_strength = min(strength, ceiling)
459
+
445
460
  # Create new relationship
446
- return self.db.insert(
461
+ new_id = self.db.insert(
447
462
  "relationships",
448
463
  {
449
464
  "source_entity_id": source_id,
450
465
  "target_entity_id": target_id,
451
466
  "relationship_type": relationship_type,
452
- "strength": strength,
467
+ "strength": capped_strength,
468
+ "origin_type": supersede_origin,
453
469
  "direction": direction,
454
470
  "valid_at": effective_valid_at,
455
471
  "created_at": now,
@@ -458,6 +474,21 @@ class RememberService:
458
474
  },
459
475
  )
460
476
 
477
+ # Audit log for create
478
+ _audit_log(
479
+ "relationship_create",
480
+ details={
481
+ "id": new_id,
482
+ "source": source_name,
483
+ "target": target_name,
484
+ "type": relationship_type,
485
+ "origin_type": supersede_origin,
486
+ "strength": capped_strength,
487
+ },
488
+ )
489
+
490
+ return new_id
491
+
461
492
  # Check for existing current relationship (non-supersede path)
462
493
  existing = self.db.get_one(
463
494
  "relationships",
@@ -466,11 +497,23 @@ class RememberService:
466
497
  )
467
498
 
468
499
  if existing:
469
- # Default behavior: strengthen existing relationship
470
- new_strength = min(1.0, existing["strength"] + 0.1)
500
+ # Determine ceiling: if new origin is higher-authority, upgrade
501
+ existing_origin = existing["origin_type"] if "origin_type" in existing.keys() else "extracted"
502
+ effective_origin = existing_origin
503
+
504
+ # Origin upgrade: user_stated/corrected outrank extracted, which outranks inferred
505
+ origin_rank = {"inferred": 0, "extracted": 1, "user_stated": 2, "corrected": 2}
506
+ if origin_rank.get(origin_type, 0) > origin_rank.get(existing_origin, 0):
507
+ effective_origin = origin_type
508
+
509
+ ceiling = ORIGIN_STRENGTH_CEILING.get(effective_origin, 0.5)
510
+ increment = REINFORCEMENT_BY_ORIGIN.get(origin_type, 0.1)
511
+ new_strength = min(ceiling, existing["strength"] + increment)
512
+
471
513
  update_data = {
472
514
  "strength": new_strength,
473
515
  "updated_at": now,
516
+ "origin_type": effective_origin,
474
517
  }
475
518
  # Ensure valid_at is set on existing relationships
476
519
  row_keys = existing.keys()
@@ -485,13 +528,14 @@ class RememberService:
485
528
  return existing["id"]
486
529
  else:
487
530
  # Create new relationship
488
- return self.db.insert(
531
+ new_id = self.db.insert(
489
532
  "relationships",
490
533
  {
491
534
  "source_entity_id": source_id,
492
535
  "target_entity_id": target_id,
493
536
  "relationship_type": relationship_type,
494
537
  "strength": strength,
538
+ "origin_type": origin_type,
495
539
  "direction": direction,
496
540
  "valid_at": effective_valid_at,
497
541
  "created_at": now,
@@ -500,6 +544,105 @@ class RememberService:
500
544
  },
501
545
  )
502
546
 
547
+ # Audit log
548
+ _audit_log(
549
+ "relationship_create",
550
+ details={
551
+ "id": new_id,
552
+ "source": source_name,
553
+ "target": target_name,
554
+ "type": relationship_type,
555
+ "origin_type": origin_type,
556
+ "strength": strength,
557
+ },
558
+ )
559
+
560
+ return new_id
561
+
562
+ def invalidate_relationship(
563
+ self,
564
+ source_name: str,
565
+ target_name: str,
566
+ relationship_type: str,
567
+ reason: Optional[str] = None,
568
+ ) -> Optional[Dict[str, Any]]:
569
+ """
570
+ Invalidate a relationship without creating a replacement.
571
+
572
+ Finds the active relationship by source + target + type, marks it with
573
+ invalid_at, and renames the type to free the UNIQUE constraint. Atomic.
574
+
575
+ Args:
576
+ source_name: Source entity name
577
+ target_name: Target entity name
578
+ relationship_type: Type of relationship to invalidate
579
+ reason: Why this relationship is being invalidated
580
+
581
+ Returns:
582
+ Dict with invalidated relationship info, or None if not found
583
+ """
584
+ source_id = self._find_or_create_entity(source_name)
585
+ target_id = self._find_or_create_entity(target_name)
586
+
587
+ if not source_id or not target_id:
588
+ return None
589
+
590
+ existing = self.db.get_one(
591
+ "relationships",
592
+ where="source_entity_id = ? AND target_entity_id = ? AND relationship_type = ? AND invalid_at IS NULL",
593
+ where_params=(source_id, target_id, relationship_type),
594
+ )
595
+
596
+ if not existing:
597
+ return None
598
+
599
+ now = datetime.utcnow().isoformat()
600
+
601
+ with self.db.transaction() as conn:
602
+ # Invalidate and rename type atomically
603
+ old_meta = json.loads(existing["metadata"] or "{}")
604
+ old_meta["invalidated_reason"] = reason
605
+ old_meta["invalidated_at"] = now
606
+
607
+ conn.execute(
608
+ "UPDATE relationships SET invalid_at = ?, updated_at = ?, "
609
+ "relationship_type = ?, metadata = ? WHERE id = ?",
610
+ (
611
+ now,
612
+ now,
613
+ f"{relationship_type}__invalidated_{existing['id']}",
614
+ json.dumps(old_meta),
615
+ existing["id"],
616
+ ),
617
+ )
618
+
619
+ # Audit log
620
+ _audit_log(
621
+ "relationship_invalidate",
622
+ details={
623
+ "id": existing["id"],
624
+ "source": source_name,
625
+ "target": target_name,
626
+ "type": relationship_type,
627
+ "reason": reason,
628
+ },
629
+ )
630
+
631
+ logger.info(
632
+ f"Invalidated relationship {existing['id']}: "
633
+ f"{source_name} -> {relationship_type} -> {target_name}"
634
+ + (f" ({reason})" if reason else "")
635
+ )
636
+
637
+ return {
638
+ "relationship_id": existing["id"],
639
+ "source": source_name,
640
+ "target": target_name,
641
+ "relationship_type": relationship_type,
642
+ "invalidated_at": now,
643
+ "reason": reason,
644
+ }
645
+
503
646
  def merge_entities(
504
647
  self,
505
648
  source_id: int,
@@ -1576,3 +1719,10 @@ def correct_memory(memory_id: int, correction: str, reason: Optional[str] = None
1576
1719
  def invalidate_memory(memory_id: int, reason: Optional[str] = None) -> Dict[str, Any]:
1577
1720
  """Mark a memory as no longer true"""
1578
1721
  return get_remember_service().invalidate_memory(memory_id, reason)
1722
+
1723
+
1724
+ def invalidate_relationship(
1725
+ source: str, target: str, relationship: str, reason: Optional[str] = None
1726
+ ) -> Optional[Dict[str, Any]]:
1727
+ """Invalidate a relationship without creating a replacement"""
1728
+ return get_remember_service().invalidate_relationship(source, target, relationship, reason)
@@ -346,3 +346,122 @@ class TestBatchWithParallelEmbeddings:
346
346
 
347
347
  mem2 = db.get_one("memories", where="content = ?", where_params=("Meeting scheduled for Friday",))
348
348
  assert mem2 is not None
349
+
350
+
351
+ class TestBatchRelateForwarding:
352
+ """Test that batch relate operations forward origin_type and other parameters."""
353
+
354
+ def test_batch_relate_forwards_origin_type(self, db):
355
+ """origin_type is stored in DB when passed through batch relate."""
356
+ svc = _get_remember_service(db)
357
+
358
+ # Create entities first
359
+ svc.remember_entity(
360
+ name="Ford Perry",
361
+ entity_type="person",
362
+ _precomputed_embedding=_fake_embedding("Ford Perry"),
363
+ )
364
+ svc.remember_entity(
365
+ name="Beemok Capital",
366
+ entity_type="organization",
367
+ _precomputed_embedding=_fake_embedding("Beemok Capital"),
368
+ )
369
+
370
+ # Relate with inferred origin
371
+ rel_id = svc.relate_entities(
372
+ source_name="Ford Perry",
373
+ target_name="Beemok Capital",
374
+ relationship_type="works_at",
375
+ origin_type="inferred",
376
+ )
377
+ assert rel_id is not None
378
+
379
+ row = db.get_one("relationships", where="id = ?", where_params=(rel_id,))
380
+ assert row["origin_type"] == "inferred"
381
+ # Inferred ceiling is 0.5, so strength should be capped
382
+ assert row["strength"] <= 0.5
383
+
384
+ def test_batch_relate_forwards_supersedes(self, db):
385
+ """Batch supersede works: old relationship invalidated, new one created."""
386
+ svc = _get_remember_service(db)
387
+
388
+ svc.remember_entity(
389
+ name="Ford Perry",
390
+ entity_type="person",
391
+ _precomputed_embedding=_fake_embedding("Ford Perry"),
392
+ )
393
+ svc.remember_entity(
394
+ name="Acme Corp",
395
+ entity_type="organization",
396
+ _precomputed_embedding=_fake_embedding("Acme Corp"),
397
+ )
398
+
399
+ # Create initial relationship
400
+ rel1 = svc.relate_entities(
401
+ source_name="Ford Perry",
402
+ target_name="Acme Corp",
403
+ relationship_type="works_at",
404
+ origin_type="extracted",
405
+ )
406
+
407
+ # Supersede it
408
+ rel2 = svc.relate_entities(
409
+ source_name="Ford Perry",
410
+ target_name="Acme Corp",
411
+ relationship_type="works_at",
412
+ supersedes=True,
413
+ )
414
+
415
+ assert rel2 != rel1
416
+
417
+ # Old should be invalidated
418
+ old_row = db.get_one("relationships", where="id = ?", where_params=(rel1,))
419
+ assert old_row["invalid_at"] is not None
420
+
421
+ # New should be active with origin_type='corrected'
422
+ new_row = db.get_one("relationships", where="id = ?", where_params=(rel2,))
423
+ assert new_row["invalid_at"] is None
424
+ assert new_row["origin_type"] == "corrected"
425
+
426
+ def test_origin_upgrade_lifts_ceiling(self, db):
427
+ """Re-encountering with higher-authority origin upgrades origin_type and lifts ceiling."""
428
+ svc = _get_remember_service(db)
429
+
430
+ svc.remember_entity(
431
+ name="Ford Perry",
432
+ entity_type="person",
433
+ _precomputed_embedding=_fake_embedding("Ford Perry"),
434
+ )
435
+ svc.remember_entity(
436
+ name="Beemok Capital",
437
+ entity_type="organization",
438
+ _precomputed_embedding=_fake_embedding("Beemok Capital"),
439
+ )
440
+
441
+ # Create with inferred origin (ceiling 0.5)
442
+ rel_id = svc.relate_entities(
443
+ source_name="Ford Perry",
444
+ target_name="Beemok Capital",
445
+ relationship_type="works_at",
446
+ origin_type="inferred",
447
+ )
448
+
449
+ row1 = db.get_one("relationships", where="id = ?", where_params=(rel_id,))
450
+ assert row1["origin_type"] == "inferred"
451
+ assert row1["strength"] <= 0.5
452
+
453
+ # Re-encounter with user_stated (should upgrade origin and lift ceiling)
454
+ rel_id2 = svc.relate_entities(
455
+ source_name="Ford Perry",
456
+ target_name="Beemok Capital",
457
+ relationship_type="works_at",
458
+ origin_type="user_stated",
459
+ )
460
+
461
+ # Same relationship ID (strengthened, not replaced)
462
+ assert rel_id2 == rel_id
463
+
464
+ row2 = db.get_one("relationships", where="id = ?", where_params=(rel_id,))
465
+ assert row2["origin_type"] == "user_stated"
466
+ # With user_stated ceiling of 1.0 and +0.2 increment, strength should increase
467
+ assert row2["strength"] > row1["strength"]
@@ -18,7 +18,7 @@ def _setup_db():
18
18
 
19
19
 
20
20
  def _create_entities(db):
21
- """Create Sarah, Acme, and Beta entities."""
21
+ """Create Sarah, Acme, Beta, and Casey entities."""
22
22
  sarah_id = db.insert("entities", {
23
23
  "name": "Sarah Chen",
24
24
  "type": "person",
@@ -37,7 +37,13 @@ def _create_entities(db):
37
37
  "canonical_name": "beta corp",
38
38
  "importance": 1.0,
39
39
  })
40
- return sarah_id, acme_id, beta_id
40
+ casey_id = db.insert("entities", {
41
+ "name": "Casey Potenzone",
42
+ "type": "person",
43
+ "canonical_name": "casey potenzone",
44
+ "importance": 1.0,
45
+ })
46
+ return sarah_id, acme_id, beta_id, casey_id
41
47
 
42
48
 
43
49
  class TestBitemporalRelationships:
@@ -46,7 +52,7 @@ class TestBitemporalRelationships:
46
52
  def test_new_relationship_gets_valid_at(self):
47
53
  """New relationship gets valid_at set automatically."""
48
54
  db, tmpdir = _setup_db()
49
- sarah_id, acme_id, _ = _create_entities(db)
55
+ sarah_id, acme_id, _, _ = _create_entities(db)
50
56
 
51
57
  import claudia_memory.database as db_mod
52
58
  import claudia_memory.services.remember as rem_mod
@@ -70,9 +76,14 @@ class TestBitemporalRelationships:
70
76
  rem_mod._service = old_svc
71
77
 
72
78
  def test_supersedes_invalidates_old_creates_new(self):
73
- """supersedes=True invalidates old relationship and creates new one."""
79
+ """supersedes=True invalidates old relationship of same triple and creates new one.
80
+
81
+ Note: supersede now correctly matches source + target + type (not just source + type).
82
+ To replace Sarah->Acme with Sarah->Beta, use invalidate+create, not supersede.
83
+ Supersede is for correcting the SAME relationship (same source, target, type).
84
+ """
74
85
  db, tmpdir = _setup_db()
75
- sarah_id, acme_id, beta_id = _create_entities(db)
86
+ sarah_id, acme_id, beta_id, _ = _create_entities(db)
76
87
 
77
88
  import claudia_memory.database as db_mod
78
89
  import claudia_memory.services.remember as rem_mod
@@ -89,9 +100,9 @@ class TestBitemporalRelationships:
89
100
  rel1_id = svc.relate_entities("Sarah Chen", "Acme Corp", "works_at")
90
101
  assert rel1_id is not None
91
102
 
92
- # Supersede with new relationship
103
+ # Supersede same triple (correction to same relationship)
93
104
  rel2_id = svc.relate_entities(
94
- "Sarah Chen", "Beta Corp", "works_at",
105
+ "Sarah Chen", "Acme Corp", "works_at",
95
106
  supersedes=True,
96
107
  )
97
108
  assert rel2_id is not None
@@ -102,11 +113,12 @@ class TestBitemporalRelationships:
102
113
  assert old_row["invalid_at"] is not None
103
114
  assert "__superseded_" in old_row["relationship_type"]
104
115
 
105
- # New relationship should be current
116
+ # New relationship should be current with origin_type='corrected'
106
117
  new_row = db.get_one("relationships", where="id = ?", where_params=(rel2_id,))
107
118
  assert new_row["invalid_at"] is None
108
119
  assert new_row["valid_at"] is not None
109
120
  assert new_row["relationship_type"] == "works_at"
121
+ assert new_row["origin_type"] == "corrected"
110
122
  finally:
111
123
  db_mod._db = old_db
112
124
  rem_mod._service = old_svc
@@ -114,7 +126,7 @@ class TestBitemporalRelationships:
114
126
  def test_default_recall_shows_current_only(self):
115
127
  """Default recall_about only shows current (non-invalidated) relationships."""
116
128
  db, tmpdir = _setup_db()
117
- sarah_id, acme_id, beta_id = _create_entities(db)
129
+ sarah_id, acme_id, beta_id, _ = _create_entities(db)
118
130
 
119
131
  import claudia_memory.database as db_mod
120
132
  import claudia_memory.services.remember as rem_mod
@@ -132,9 +144,11 @@ class TestBitemporalRelationships:
132
144
  rem_svc = RememberService()
133
145
  rec_svc = RecallService()
134
146
 
135
- # Create and supersede
147
+ # Create Acme relationship, then invalidate and create Beta
136
148
  rem_svc.relate_entities("Sarah Chen", "Acme Corp", "works_at")
137
- rem_svc.relate_entities("Sarah Chen", "Beta Corp", "works_at", supersedes=True)
149
+ rem_svc.invalidate_relationship("Sarah Chen", "Acme Corp", "works_at",
150
+ reason="Left company")
151
+ rem_svc.relate_entities("Sarah Chen", "Beta Corp", "works_at")
138
152
 
139
153
  # Default: only current
140
154
  result = rec_svc.recall_about("Sarah Chen")
@@ -152,7 +166,7 @@ class TestBitemporalRelationships:
152
166
  def test_include_historical_shows_all(self):
153
167
  """include_historical=True shows all relationships including invalidated."""
154
168
  db, tmpdir = _setup_db()
155
- sarah_id, acme_id, beta_id = _create_entities(db)
169
+ sarah_id, acme_id, beta_id, _ = _create_entities(db)
156
170
 
157
171
  import claudia_memory.database as db_mod
158
172
  import claudia_memory.services.remember as rem_mod
@@ -170,11 +184,13 @@ class TestBitemporalRelationships:
170
184
  rem_svc = RememberService()
171
185
  rec_svc = RecallService()
172
186
 
173
- # Create and supersede
187
+ # Create Acme relationship, then invalidate and create Beta
174
188
  rem_svc.relate_entities("Sarah Chen", "Acme Corp", "works_at")
175
- rem_svc.relate_entities("Sarah Chen", "Beta Corp", "works_at", supersedes=True)
189
+ rem_svc.invalidate_relationship("Sarah Chen", "Acme Corp", "works_at",
190
+ reason="Left company")
191
+ rem_svc.relate_entities("Sarah Chen", "Beta Corp", "works_at")
176
192
 
177
- # Historical: shows both
193
+ # Historical: shows both (Acme invalidated + Beta current)
178
194
  result = rec_svc.recall_about("Sarah Chen", include_historical=True)
179
195
  assert len(result["relationships"]) >= 2
180
196
 
@@ -190,7 +206,7 @@ class TestBitemporalRelationships:
190
206
  def test_migration_grandfathers_existing(self):
191
207
  """Migration sets valid_at = created_at for existing relationships."""
192
208
  db, tmpdir = _setup_db()
193
- sarah_id, acme_id, _ = _create_entities(db)
209
+ sarah_id, acme_id, _, _ = _create_entities(db)
194
210
 
195
211
  # Insert a relationship without valid_at (simulating pre-migration)
196
212
  db.execute(
@@ -251,3 +267,173 @@ class TestBitemporalRelationships:
251
267
  finally:
252
268
  db_mod._db = old_db
253
269
  rem_mod._service = old_svc
270
+
271
+ def test_supersede_targets_correct_relationship(self):
272
+ """Superseding A->X only affects X, not A->Y of the same type.
273
+
274
+ This was a real bug: the old supersede query only matched source + type,
275
+ not target. When Sarah had works_with->Acme AND works_with->Beta,
276
+ superseding works_with->Acme could invalidate works_with->Beta instead.
277
+ """
278
+ db, tmpdir = _setup_db()
279
+ sarah_id, acme_id, beta_id, _ = _create_entities(db)
280
+
281
+ import claudia_memory.database as db_mod
282
+ import claudia_memory.services.remember as rem_mod
283
+ old_db = db_mod._db
284
+ old_svc = rem_mod._service
285
+
286
+ try:
287
+ db_mod._db = db
288
+ rem_mod._service = None
289
+ from claudia_memory.services.remember import RememberService
290
+ svc = RememberService()
291
+
292
+ # Create two relationships of the same type to different targets
293
+ rel_acme = svc.relate_entities("Sarah Chen", "Acme Corp", "works_at")
294
+ rel_beta = svc.relate_entities("Sarah Chen", "Beta Corp", "works_at")
295
+ assert rel_acme is not None
296
+ assert rel_beta is not None
297
+ assert rel_acme != rel_beta
298
+
299
+ # Supersede the Acme relationship specifically
300
+ rel_acme_new = svc.relate_entities(
301
+ "Sarah Chen", "Acme Corp", "works_at",
302
+ supersedes=True,
303
+ )
304
+
305
+ # The Beta relationship should be UNTOUCHED
306
+ beta_row = db.get_one("relationships", where="id = ?", where_params=(rel_beta,))
307
+ assert beta_row["invalid_at"] is None
308
+ assert beta_row["relationship_type"] == "works_at"
309
+
310
+ # The old Acme relationship should be invalidated
311
+ acme_old = db.get_one("relationships", where="id = ?", where_params=(rel_acme,))
312
+ assert acme_old["invalid_at"] is not None
313
+ finally:
314
+ db_mod._db = old_db
315
+ rem_mod._service = old_svc
316
+
317
+ def test_supersede_same_triple_correction(self):
318
+ """Superseding A->X with a new A->X (same triple) works as a correction."""
319
+ db, tmpdir = _setup_db()
320
+ _create_entities(db)
321
+
322
+ import claudia_memory.database as db_mod
323
+ import claudia_memory.services.remember as rem_mod
324
+ old_db = db_mod._db
325
+ old_svc = rem_mod._service
326
+
327
+ try:
328
+ db_mod._db = db
329
+ rem_mod._service = None
330
+ from claudia_memory.services.remember import RememberService
331
+ svc = RememberService()
332
+
333
+ # Create initial relationship
334
+ rel1 = svc.relate_entities("Sarah Chen", "Acme Corp", "works_at",
335
+ strength=0.5, origin_type="inferred")
336
+
337
+ # Supersede with a correction (same source, target, type)
338
+ rel2 = svc.relate_entities("Sarah Chen", "Acme Corp", "works_at",
339
+ supersedes=True, origin_type="user_stated")
340
+
341
+ assert rel2 is not None
342
+ assert rel2 != rel1
343
+
344
+ # Old should be invalidated
345
+ old_row = db.get_one("relationships", where="id = ?", where_params=(rel1,))
346
+ assert old_row["invalid_at"] is not None
347
+
348
+ # New should have origin_type='corrected' (supersede always sets this)
349
+ new_row = db.get_one("relationships", where="id = ?", where_params=(rel2,))
350
+ assert new_row["origin_type"] == "corrected"
351
+ assert new_row["invalid_at"] is None
352
+ finally:
353
+ db_mod._db = old_db
354
+ rem_mod._service = old_svc
355
+
356
+ def test_invalidate_relationship(self):
357
+ """Invalidation sets invalid_at without creating a replacement."""
358
+ db, tmpdir = _setup_db()
359
+ _create_entities(db)
360
+
361
+ import claudia_memory.database as db_mod
362
+ import claudia_memory.services.remember as rem_mod
363
+ old_db = db_mod._db
364
+ old_svc = rem_mod._service
365
+
366
+ try:
367
+ db_mod._db = db
368
+ rem_mod._service = None
369
+ from claudia_memory.services.remember import RememberService
370
+ svc = RememberService()
371
+
372
+ # Create a relationship
373
+ rel_id = svc.relate_entities("Sarah Chen", "Acme Corp", "works_at")
374
+ assert rel_id is not None
375
+
376
+ # Invalidate it
377
+ result = svc.invalidate_relationship(
378
+ "Sarah Chen", "Acme Corp", "works_at",
379
+ reason="She left the company",
380
+ )
381
+ assert result is not None
382
+ assert result["relationship_id"] == rel_id
383
+ assert result["reason"] == "She left the company"
384
+
385
+ # The row should be invalidated with renamed type
386
+ row = db.get_one("relationships", where="id = ?", where_params=(rel_id,))
387
+ assert row["invalid_at"] is not None
388
+ assert "__invalidated_" in row["relationship_type"]
389
+
390
+ # No new relationship should have been created
391
+ active = db.execute(
392
+ "SELECT COUNT(*) as cnt FROM relationships WHERE invalid_at IS NULL",
393
+ fetch=True,
394
+ )
395
+ assert active[0]["cnt"] == 0
396
+ finally:
397
+ db_mod._db = old_db
398
+ rem_mod._service = old_svc
399
+
400
+ def test_invalidate_logs_audit(self):
401
+ """Invalidation logs to the audit trail."""
402
+ db, tmpdir = _setup_db()
403
+ _create_entities(db)
404
+
405
+ import claudia_memory.database as db_mod
406
+ import claudia_memory.services.remember as rem_mod
407
+ import claudia_memory.services.audit as audit_mod
408
+ old_db = db_mod._db
409
+ old_svc = rem_mod._service
410
+ old_audit_svc = audit_mod._service
411
+
412
+ try:
413
+ db_mod._db = db
414
+ rem_mod._service = None
415
+ audit_mod._service = None # Reset audit singleton to use test DB
416
+ from claudia_memory.services.remember import RememberService
417
+ svc = RememberService()
418
+
419
+ rel_id = svc.relate_entities("Sarah Chen", "Acme Corp", "works_at")
420
+ svc.invalidate_relationship(
421
+ "Sarah Chen", "Acme Corp", "works_at",
422
+ reason="Incorrect data",
423
+ )
424
+
425
+ # Check audit log for invalidation entry
426
+ audit_rows = db.execute(
427
+ "SELECT * FROM audit_log WHERE operation = 'relationship_invalidate'",
428
+ fetch=True,
429
+ ) or []
430
+ assert len(audit_rows) >= 1
431
+ # The audit should reference the correct relationship
432
+ import json
433
+ details = json.loads(audit_rows[0]["details"])
434
+ assert details["id"] == rel_id
435
+ assert details["reason"] == "Incorrect data"
436
+ finally:
437
+ db_mod._db = old_db
438
+ rem_mod._service = old_svc
439
+ audit_mod._service = old_audit_svc
@@ -68,8 +68,9 @@ def test_validate_entity_empty_name():
68
68
 
69
69
  def test_validate_relationship_strength_clamped():
70
70
  """Strength outside [0, 1] gets clamped"""
71
- result = validate_relationship(1.5)
71
+ # Use user_stated (ceiling=1.0) to isolate the clamping logic from origin ceilings
72
+ result = validate_relationship(1.5, origin_type="user_stated")
72
73
  assert result.adjustments["strength"] == 1.0
73
74
 
74
- result2 = validate_relationship(-0.2)
75
+ result2 = validate_relationship(-0.2, origin_type="user_stated")
75
76
  assert result2.adjustments["strength"] == 0.0
@@ -0,0 +1,79 @@
1
+ """Tests for origin-aware relationship strength guards."""
2
+
3
+ import pytest
4
+
5
+ from claudia_memory.services.guards import (
6
+ ORIGIN_STRENGTH_CEILING,
7
+ REINFORCEMENT_BY_ORIGIN,
8
+ validate_relationship,
9
+ )
10
+
11
+
12
+ class TestOriginStrengthCeilings:
13
+ """Test that relationship strength is capped by origin authority."""
14
+
15
+ def test_inferred_capped_at_half(self):
16
+ """Inferred relationships are capped at 0.5 strength."""
17
+ result = validate_relationship(strength=1.0, origin_type="inferred")
18
+ assert "strength" in result.adjustments
19
+ assert result.adjustments["strength"] == 0.5
20
+ assert any("ceiling" in w for w in result.warnings)
21
+
22
+ def test_user_stated_uncapped(self):
23
+ """User-stated relationships allow full 1.0 strength."""
24
+ result = validate_relationship(strength=1.0, origin_type="user_stated")
25
+ # No cap needed -- user_stated ceiling is 1.0
26
+ assert result.adjustments.get("strength", 1.0) == 1.0
27
+ assert not any("ceiling" in w for w in result.warnings)
28
+
29
+ def test_extracted_capped_at_0_8(self):
30
+ """Extracted relationships are capped at 0.8 strength."""
31
+ result = validate_relationship(strength=1.0, origin_type="extracted")
32
+ assert "strength" in result.adjustments
33
+ assert result.adjustments["strength"] == 0.8
34
+ assert any("ceiling" in w for w in result.warnings)
35
+
36
+ def test_corrected_uncapped(self):
37
+ """Corrected relationships allow full 1.0 strength (same as user_stated)."""
38
+ result = validate_relationship(strength=1.0, origin_type="corrected")
39
+ assert result.adjustments.get("strength", 1.0) == 1.0
40
+ assert not any("ceiling" in w for w in result.warnings)
41
+
42
+ def test_unknown_origin_defaults_to_0_5(self):
43
+ """Unknown origin types default to 0.5 ceiling."""
44
+ result = validate_relationship(strength=0.9, origin_type="mystery")
45
+ assert result.adjustments["strength"] == 0.5
46
+
47
+ def test_strength_below_ceiling_untouched(self):
48
+ """Strength already below ceiling is not adjusted."""
49
+ result = validate_relationship(strength=0.3, origin_type="inferred")
50
+ # 0.3 < 0.5 ceiling, so no adjustment for ceiling
51
+ assert "strength" not in result.adjustments or result.adjustments.get("strength") == 0.3
52
+
53
+
54
+ class TestReinforcementScaling:
55
+ """Test that reinforcement increments scale by origin."""
56
+
57
+ def test_inferred_increment_is_0_05(self):
58
+ assert REINFORCEMENT_BY_ORIGIN["inferred"] == 0.05
59
+
60
+ def test_extracted_increment_is_0_1(self):
61
+ assert REINFORCEMENT_BY_ORIGIN["extracted"] == 0.1
62
+
63
+ def test_user_stated_increment_is_0_2(self):
64
+ assert REINFORCEMENT_BY_ORIGIN["user_stated"] == 0.2
65
+
66
+ def test_corrected_increment_is_0_2(self):
67
+ assert REINFORCEMENT_BY_ORIGIN["corrected"] == 0.2
68
+
69
+
70
+ class TestCeilingValues:
71
+ """Verify the ceiling constants are correct."""
72
+
73
+ def test_ceiling_values(self):
74
+ assert ORIGIN_STRENGTH_CEILING == {
75
+ "user_stated": 1.0,
76
+ "extracted": 0.8,
77
+ "inferred": 0.5,
78
+ "corrected": 1.0,
79
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "get-claudia",
3
- "version": "1.29.2",
3
+ "version": "1.30.0",
4
4
  "description": "An AI assistant who learns how you work.",
5
5
  "keywords": [
6
6
  "claudia",
@@ -60,9 +60,10 @@ For each file, extract:
60
60
 
61
61
  ### 3. Extract Relationships
62
62
 
63
- Identify explicit and implicit relationships:
63
+ Identify explicit and implicit relationships. For each relationship, set `origin_type` honestly based on how you know it. The system automatically caps strength based on origin, so always use `strength: 1.0` and let the guards enforce the ceiling.
64
64
 
65
- **Explicit Relationships (High Confidence: 0.9)**
65
+ **Extracted Relationships** (origin_type: "extracted", ceiling: 0.8)
66
+ Explicitly stated in the file:
66
67
  - "works with [Name]" -> `works_with`
67
68
  - "client of [Name]" -> `client_of`
68
69
  - "reports to [Name]" -> `reports_to`
@@ -71,11 +72,10 @@ Identify explicit and implicit relationships:
71
72
  - "partner at [Org]" -> `partner_at`
72
73
  - "advisor to [Name/Org]" -> `advisor_to`
73
74
 
74
- **Co-mention Relationships (Medium Confidence: 0.6)**
75
+ **Inferred Relationships** (origin_type: "inferred", ceiling: 0.5)
76
+ Co-mentioned or contextually implied:
75
77
  - Two people mentioned in the same file -> `mentioned_with`
76
78
  - People in the same project file -> `collaborates_on`
77
-
78
- **Inferred Relationships (Low Confidence: 0.3)**
79
79
  - Same city + same industry -> `likely_connected`
80
80
  - Same organization -> `colleagues`
81
81
  - Same community group -> `community_connection`
@@ -96,18 +96,20 @@ Use `memory.batch` for efficiency:
96
96
  memory.batch operations=[
97
97
  {op: "entity", name: "Sarah Chen", type: "person", description: "CEO at Acme Corp"},
98
98
  {op: "entity", name: "Acme Corp", type: "organization"},
99
- {op: "relate", source: "Sarah Chen", target: "Acme Corp", relationship: "works_at", strength: 0.9},
100
- {op: "relate", source: "Sarah Chen", target: "Tom Miller", relationship: "works_with", strength: 0.6},
99
+ {op: "relate", source: "Sarah Chen", target: "Acme Corp", relationship: "works_at", strength: 1.0, origin_type: "extracted"},
100
+ {op: "relate", source: "Sarah Chen", target: "Tom Miller", relationship: "works_with", strength: 1.0, origin_type: "inferred"},
101
101
  ...
102
102
  ]
103
103
  ```
104
104
 
105
- For relationship strength:
106
- - High confidence (explicit): 0.9
107
- - Medium confidence (co-mention): 0.6
108
- - Low confidence (inferred): 0.3
105
+ For relationship `origin_type`:
106
+ - Explicitly stated in the file ("Sarah is CEO of Acme"): `origin_type: "extracted"`
107
+ - Co-mentioned or contextually implied: `origin_type: "inferred"`
108
+ - User told you directly: `origin_type: "user_stated"`
109
+
110
+ The system automatically caps strength based on origin. You don't need to manually calibrate. Just be honest about how you know, and always use `strength: 1.0`.
109
111
 
110
- When updating existing relationships, take the maximum strength.
112
+ When re-encountering existing relationships, the system strengthens them incrementally (scaled by origin). Repeated evidence builds trust organically.
111
113
 
112
114
  ### 6. Report Results
113
115
 
@@ -129,23 +131,23 @@ Output format:
129
131
 
130
132
  ### New Relationships ([count])
131
133
 
132
- | Source | Relationship | Target | Confidence |
133
- |--------|--------------|--------|------------|
134
- | Sarah Chen | works_at | Acme Corp | high (0.9) |
135
- | Sarah Chen | collaborates_on | Website Redesign | high (0.9) |
136
- | Sarah Chen | mentioned_with | Tom Miller | medium (0.6) |
134
+ | Source | Relationship | Target | Origin |
135
+ |--------|--------------|--------|--------|
136
+ | Sarah Chen | works_at | Acme Corp | extracted |
137
+ | Sarah Chen | collaborates_on | Website Redesign | extracted |
138
+ | Sarah Chen | mentioned_with | Tom Miller | inferred |
137
139
 
138
140
  ### Inferred Connections ([count])
139
141
 
140
- | Entity A | Entity B | Reason | Confidence |
141
- |----------|----------|--------|------------|
142
- | Sarah Chen | Jane Doe | Same city (Palm Beach) + industry (real estate) | low (0.3) |
142
+ | Entity A | Entity B | Reason | Origin |
143
+ |----------|----------|--------|--------|
144
+ | Sarah Chen | Jane Doe | Same city (Palm Beach) + industry (real estate) | inferred |
143
145
 
144
146
  ### Updated Relationships ([count])
145
147
 
146
148
  | Relationship | Change |
147
149
  |--------------|--------|
148
- | Sarah Chen -> client_of -> Beta Inc | strength: 0.6 -> 0.9 |
150
+ | Sarah Chen -> client_of -> Beta Inc | strengthened (re-encountered, extracted) |
149
151
 
150
152
  ### Summary
151
153