superlocalmemory 3.8.0 → 3.8.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.
Files changed (118) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +32 -120
  3. package/package.json +9 -2
  4. package/plugin/.claude-plugin/plugin.json +1 -2
  5. package/plugin/CLAUDE.md +3 -3
  6. package/plugin/agents/slm-governance-advisor.md +1 -1
  7. package/plugin/agents/slm-loop-runner.md +1 -1
  8. package/plugin/agents/slm-memory-advisor.md +1 -1
  9. package/plugin/agents/slm-optimize-advisor.md +1 -1
  10. package/plugin/requirements.txt +1 -1
  11. package/plugin/skills/slm-cache/SKILL.md +1 -1
  12. package/plugin/skills/slm-compress/SKILL.md +1 -1
  13. package/plugin/skills/slm-governance/SKILL.md +1 -1
  14. package/plugin/skills/slm-graph/SKILL.md +1 -1
  15. package/plugin/skills/slm-loop/SKILL.md +1 -1
  16. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  17. package/plugin/skills/slm-profile/SKILL.md +1 -1
  18. package/plugin/skills/slm-recall/SKILL.md +1 -1
  19. package/plugin/skills/slm-remember/SKILL.md +1 -1
  20. package/plugin/skills/slm-scope/SKILL.md +1 -1
  21. package/plugin/skills/slm-session/SKILL.md +1 -1
  22. package/plugin/skills/slm-status/SKILL.md +1 -1
  23. package/plugin-src/rules/AGENTS.md +1 -1
  24. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  25. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  31. package/pyproject.toml +2 -1
  32. package/src/superlocalmemory/__init__.py +1 -1
  33. package/src/superlocalmemory/cli/commands.py +134 -7
  34. package/src/superlocalmemory/cli/daemon.py +7 -0
  35. package/src/superlocalmemory/cli/loop_cmd.py +2 -7
  36. package/src/superlocalmemory/cli/main.py +10 -4
  37. package/src/superlocalmemory/cli/version_banner.py +17 -3
  38. package/src/superlocalmemory/core/backend_orchestrator.py +18 -16
  39. package/src/superlocalmemory/core/embedding_worker.py +4 -5
  40. package/src/superlocalmemory/core/embeddings.py +119 -45
  41. package/src/superlocalmemory/core/engine.py +24 -21
  42. package/src/superlocalmemory/core/engine_ingestion.py +332 -45
  43. package/src/superlocalmemory/core/ingestion_command.py +134 -25
  44. package/src/superlocalmemory/core/injection.py +12 -7
  45. package/src/superlocalmemory/core/maintenance_scheduler.py +17 -7
  46. package/src/superlocalmemory/core/recall_pipeline.py +5 -3
  47. package/src/superlocalmemory/core/store_pipeline.py +153 -20
  48. package/src/superlocalmemory/hooks/hook_handlers.py +6 -1
  49. package/src/superlocalmemory/hooks/portable_kit.py +34 -2
  50. package/src/superlocalmemory/learning/model_rollback.py +3 -0
  51. package/src/superlocalmemory/learning/ranker_retrain_online.py +2 -0
  52. package/src/superlocalmemory/learning/reward.py +50 -0
  53. package/src/superlocalmemory/learning/source_quality.py +523 -1
  54. package/src/superlocalmemory/loops/ledger.py +25 -5
  55. package/src/superlocalmemory/mcp/server.py +11 -30
  56. package/src/superlocalmemory/mcp/tools_core.py +4 -3
  57. package/src/superlocalmemory/mcp/tools_learning.py +2 -2
  58. package/src/superlocalmemory/retrieval/engine.py +53 -21
  59. package/src/superlocalmemory/retrieval/reranker.py +3 -4
  60. package/src/superlocalmemory/server/config_file.py +90 -0
  61. package/src/superlocalmemory/server/origin.py +50 -0
  62. package/src/superlocalmemory/server/routes/backup.py +293 -70
  63. package/src/superlocalmemory/server/routes/behavioral.py +336 -59
  64. package/src/superlocalmemory/server/routes/brain.py +57 -16
  65. package/src/superlocalmemory/server/routes/config_api.py +84 -82
  66. package/src/superlocalmemory/server/routes/entity.py +100 -23
  67. package/src/superlocalmemory/server/routes/evolution.py +103 -100
  68. package/src/superlocalmemory/server/routes/learning.py +276 -105
  69. package/src/superlocalmemory/server/routes/learning_telemetry.py +153 -0
  70. package/src/superlocalmemory/server/routes/mesh.py +121 -32
  71. package/src/superlocalmemory/server/routes/ratelimit.py +33 -25
  72. package/src/superlocalmemory/server/routes/stats.py +93 -155
  73. package/src/superlocalmemory/server/routes/token.py +3 -13
  74. package/src/superlocalmemory/server/routes/v3_api.py +64 -20
  75. package/src/superlocalmemory/server/unified_daemon.py +467 -40
  76. package/src/superlocalmemory/storage/migration_runner.py +79 -1
  77. package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +5 -0
  78. package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +270 -0
  79. package/src/superlocalmemory/storage/migrations/M029_behavioral_history_indexes.py +137 -0
  80. package/src/superlocalmemory/storage/migrations/M030_entity_explorer_indexes.py +93 -0
  81. package/src/superlocalmemory/storage/migrations/__init__.py +4 -0
  82. package/src/superlocalmemory/storage/schema.py +49 -1
  83. package/src/superlocalmemory/storage/schema_v32.py +2 -0
  84. package/src/superlocalmemory/storage/schema_v347.py +4 -0
  85. package/src/superlocalmemory/ui/index.html +3 -6
  86. package/src/superlocalmemory/ui/js/core.js +52 -9
  87. package/src/superlocalmemory/ui/js/dashboard.js +169 -82
  88. package/src/superlocalmemory/ui/js/od-backup.js +156 -65
  89. package/src/superlocalmemory/ui/js/od-brain.js +88 -51
  90. package/src/superlocalmemory/ui/js/od-entities.js +22 -22
  91. package/src/superlocalmemory/ui/js/od-graph.js +11 -4
  92. package/src/superlocalmemory/ui/js/od-memories.js +47 -5
  93. package/src/superlocalmemory/ui/js/od-mesh.js +23 -9
  94. package/src/superlocalmemory/ui/js/od-settings.js +113 -59
  95. package/src/superlocalmemory/ui/js/od-shell.js +249 -33
  96. package/src/superlocalmemory/ui/js/od-skills.js +44 -17
  97. package/src/superlocalmemory/ui/js/settings.js +15 -1
  98. package/plugin-src/.mcp.json +0 -12
  99. package/plugin-src/agents/slm-governance-advisor.md +0 -80
  100. package/plugin-src/agents/slm-loop-runner.md +0 -71
  101. package/plugin-src/agents/slm-memory-advisor.md +0 -49
  102. package/plugin-src/agents/slm-optimize-advisor.md +0 -44
  103. package/plugin-src/commands/slm-loop.md +0 -31
  104. package/plugin-src/hooks/.gitkeep +0 -0
  105. package/plugin-src/hooks/hooks.json +0 -102
  106. package/plugin-src/manifest.json +0 -30
  107. package/plugin-src/requirements.txt +0 -1
  108. package/plugin-src/rules/CLAUDE.md.fragment +0 -44
  109. package/plugin-src/scripts/ensure-venv.bat +0 -122
  110. package/plugin-src/scripts/ensure-venv.sh +0 -105
  111. package/plugin-src/scripts/slm-launch +0 -62
  112. package/plugin-src/scripts/slm-launch.bat +0 -23
  113. package/plugin-src/settings.json +0 -25
  114. package/plugin-src/skills/slm-governance/SKILL.md +0 -248
  115. package/plugin-src/skills/slm-loop/SKILL.md +0 -99
  116. package/plugin-src/skills/slm-mesh/SKILL.md +0 -282
  117. package/plugin-src/skills/slm-profile/SKILL.md +0 -148
  118. package/plugin-src/skills/slm-scope/SKILL.md +0 -176
@@ -16,8 +16,6 @@ import os
16
16
  import uuid
17
17
  from typing import TYPE_CHECKING
18
18
 
19
- logger = logging.getLogger(__name__)
20
-
21
19
  from superlocalmemory.core.ingestion_command import (
22
20
  IngestionCommand,
23
21
  IngestionOperation,
@@ -31,7 +29,11 @@ if TYPE_CHECKING:
31
29
  from superlocalmemory.storage.models import AtomicFact
32
30
 
33
31
 
32
+ logger = logging.getLogger(__name__)
33
+
34
+
34
35
  _PREBUILT_FACT_KEY = "_slm_prebuilt_fact_v1"
36
+ _DERIVATION_VERSION = "v3.7-ingestion-1"
35
37
 
36
38
 
37
39
  def _pii_redaction_enabled(engine: "MemoryEngine") -> bool:
@@ -141,7 +143,14 @@ def canonical_store(
141
143
  require_complete: bool = True,
142
144
  return_receipt: bool = False,
143
145
  ) -> list[str] | IngestionOperation:
144
- """Synchronously submit and completely materialize one canonical write."""
146
+ """Submit canonical evidence, optionally waiting for enrichment completion.
147
+
148
+ ``require_complete=False`` is the interactive/CQRS path: it commits a
149
+ profile-scoped memory and FTS-queryable fact through M018, then returns the
150
+ durable receipt without invoking any LLM, embedding, or graph work. The
151
+ daemon materializer owns that expensive, retryable enrichment. Explicit
152
+ complete callers retain the historical synchronous contract.
153
+ """
145
154
  import time
146
155
 
147
156
  from superlocalmemory.core.ingestion_command import IngestionRequest, IngestionState
@@ -185,21 +194,16 @@ def canonical_store(
185
194
  speaker=speaker,
186
195
  role=role,
187
196
  ))
197
+ if not require_complete:
198
+ record_operation(
199
+ "remember",
200
+ client=trusted_actor_id,
201
+ duration_ms=(time.monotonic() - started) * 1000.0,
202
+ )
203
+ return receipt if return_receipt else list(receipt.fact_ids)
204
+
188
205
  result = command.materialize(receipt.operation_id)
189
206
  if result.state is not IngestionState.COMPLETE:
190
- if not require_complete and receipt.fact_ids:
191
- import logging
192
- logging.getLogger(__name__).warning(
193
- "Canonical operation %s remains %s and will retry: %s",
194
- result.operation_id,
195
- result.state.value,
196
- result.last_error,
197
- )
198
- record_operation(
199
- "remember", client=trusted_actor_id,
200
- duration_ms=(time.monotonic() - started) * 1000.0,
201
- )
202
- return list(receipt.fact_ids)
203
207
  raise RuntimeError(result.last_error or "canonical materialization failed")
204
208
  except Exception as exc:
205
209
  record_operation(
@@ -345,29 +349,138 @@ def build_engine_ingestion_command(engine: MemoryEngine) -> IngestionCommand:
345
349
  index_external=False,
346
350
  )
347
351
 
348
- def materialize(operation: IngestionOperation) -> MaterializationResult:
352
+ def resume_checkpoint(operation: IngestionOperation) -> MaterializationResult:
353
+ """Repair only stages whose writes have an idempotent natural key."""
354
+ state = dict(operation.derivation_state)
355
+ if not state.get("pipeline", False):
356
+ return MaterializationResult(
357
+ operation.final_fact_ids, state, operation.last_error,
358
+ )
349
359
  facts = engine._db.get_facts_by_ids(
350
- list(operation.queryable_fact_ids),
351
- operation.profile_id,
360
+ list(operation.final_fact_ids), operation.profile_id,
361
+ )
362
+ if len(facts) != len(operation.final_fact_ids):
363
+ state["relational"] = False
364
+ return MaterializationResult(
365
+ operation.final_fact_ids,
366
+ state,
367
+ "checkpointed relational facts are missing",
368
+ )
369
+ if state.get("provenance") is False and engine._provenance is not None:
370
+ provenance_complete = True
371
+ for fact in facts:
372
+ existing = engine._db.execute(
373
+ "SELECT 1 FROM provenance WHERE fact_id=? AND profile_id=? "
374
+ "AND source_type=? AND source_id=? AND created_by=? LIMIT 1",
375
+ (
376
+ fact.fact_id,
377
+ operation.profile_id,
378
+ operation.source_type,
379
+ operation.operation_id,
380
+ operation.trusted_actor_id,
381
+ ),
382
+ )
383
+ if existing:
384
+ continue
385
+ try:
386
+ engine._provenance.record(
387
+ fact_id=fact.fact_id,
388
+ profile_id=operation.profile_id,
389
+ source_type=operation.source_type,
390
+ source_id=operation.operation_id,
391
+ created_by=operation.trusted_actor_id,
392
+ )
393
+ except Exception:
394
+ provenance_complete = False
395
+ state["provenance"] = provenance_complete
396
+ if state.get("post_hooks") is False:
397
+ try:
398
+ engine._hooks.run_post("store", {
399
+ "operation": "store",
400
+ "agent_id": operation.trusted_actor_id,
401
+ "profile_id": operation.profile_id,
402
+ "content_preview": operation.raw_content[:100],
403
+ "ingestion_operation_id": operation.operation_id,
404
+ "fact_ids": list(operation.final_fact_ids),
405
+ "fact_count": len(operation.final_fact_ids),
406
+ })
407
+ except Exception as exc:
408
+ return MaterializationResult(
409
+ operation.final_fact_ids, state, str(exc),
410
+ )
411
+ state["post_hooks"] = True
412
+ incomplete = [name for name, complete in state.items() if not complete]
413
+ return MaterializationResult(
414
+ operation.final_fact_ids,
415
+ state,
416
+ "" if not incomplete else operation.last_error,
352
417
  )
353
- if len(facts) != len(operation.queryable_fact_ids):
354
- raise ValueError("queryable fact profile mismatch or missing fact")
355
- memory_ids = {fact.memory_id for fact in facts}
356
- if len(memory_ids) != 1:
357
- raise ValueError("queryable facts do not share one source memory")
358
- memory_id = next(iter(memory_ids))
359
418
 
419
+ def resume_partial_relational(
420
+ operation: IngestionOperation,
421
+ memory_id: str,
422
+ ) -> MaterializationResult:
423
+ """Finish idempotent relational effects from facts committed before a crash.
424
+
425
+ Extraction and consolidation mutate evidence and access counters, so a
426
+ relational-start checkpoint is an at-most-once boundary for those
427
+ stages. The facts already committed to this operation's dedicated
428
+ memory can safely be promoted again: they use stable fact IDs, graph
429
+ edge logical keys, temporal event IDs, and fact/entity association
430
+ keys. Deliberately omit the other best-effort enrichers here; they
431
+ have no operation-scoped idempotency contract.
432
+ """
360
433
  from superlocalmemory.core.store_pipeline import run_store
361
434
 
362
- is_prebuilt = isinstance(operation.metadata.get(_PREBUILT_FACT_KEY), dict)
435
+ # Consolidation may replace a submitted projection with an existing
436
+ # canonical fact from another memory. The operation checkpoint is the
437
+ # authoritative recovery set in that case; falling back to the source
438
+ # memory is only for a fault before the checkpoint captured final IDs.
439
+ if operation.final_fact_ids:
440
+ facts = engine._db.get_facts_by_ids(
441
+ list(operation.final_fact_ids), operation.profile_id,
442
+ )
443
+ if len(facts) != len(operation.final_fact_ids):
444
+ return MaterializationResult(
445
+ operation.final_fact_ids,
446
+ dict(operation.derivation_state),
447
+ "checkpointed relational facts are missing",
448
+ )
449
+ else:
450
+ facts = engine._db.get_facts_by_memory_id(
451
+ memory_id, operation.profile_id,
452
+ )
453
+ fact_ids = tuple(fact.fact_id for fact in facts)
454
+ if not fact_ids:
455
+ return MaterializationResult(
456
+ (),
457
+ dict(operation.derivation_state),
458
+ "no committed facts available for relational recovery",
459
+ )
363
460
 
364
- class _QueryableProjectionExtractor:
461
+ class _CommittedFactsExtractor:
365
462
  @staticmethod
366
463
  def extract_facts(**_kwargs):
367
464
  return []
368
465
 
369
466
  pipeline_state: dict[str, bool] = {}
370
- fact_ids = run_store(
467
+ progress: dict[str, object] = {}
468
+
469
+ def checkpoint_materialization(
470
+ _phase: str,
471
+ checkpoint_fact_ids: tuple[str, ...],
472
+ state: dict[str, bool],
473
+ ) -> None:
474
+ repository.checkpoint_enriching(
475
+ operation.operation_id,
476
+ final_fact_ids=checkpoint_fact_ids,
477
+ derivation_version=_DERIVATION_VERSION,
478
+ derivation_state=state,
479
+ lease_owner=operation.lease_owner,
480
+ lease_seconds=900.0,
481
+ )
482
+
483
+ recovered_ids = run_store(
371
484
  operation.raw_content,
372
485
  operation.profile_id,
373
486
  session_id=operation.session_id,
@@ -380,42 +493,211 @@ def build_engine_ingestion_command(engine: MemoryEngine) -> IngestionCommand:
380
493
  config=engine._config,
381
494
  db=engine._db,
382
495
  embedder=engine._embedder,
383
- fact_extractor=(
384
- _QueryableProjectionExtractor() if is_prebuilt else engine._fact_extractor
385
- ),
496
+ fact_extractor=_CommittedFactsExtractor(),
386
497
  entity_resolver=engine._entity_resolver,
387
498
  temporal_parser=engine._temporal_parser,
388
- type_router=None if is_prebuilt else engine._type_router,
499
+ type_router=None,
389
500
  graph_builder=engine._graph_builder,
390
- consolidator=None if is_prebuilt else engine._consolidator,
391
- observation_builder=engine._observation_builder,
392
- scene_builder=engine._scene_builder,
501
+ consolidator=None,
502
+ observation_builder=None,
503
+ scene_builder=None,
393
504
  entropy_gate=engine._entropy_gate,
394
- # External indexes are projected only after the relational unit of
395
- # work commits; sqlite-vec uses a separate connection.
396
505
  ann_index=None,
397
- sheaf_checker=engine._sheaf_checker,
506
+ sheaf_checker=None,
398
507
  retrieval_engine=None,
399
508
  provenance=engine._provenance,
400
509
  hooks=engine._hooks,
401
510
  vector_store=None,
402
- context_generator=engine._context_generator,
511
+ context_generator=None,
403
512
  temporal_validator=engine._temporal_validator,
404
- auto_linker=engine._auto_linker,
405
- consolidation_engine=engine._consolidation_engine,
513
+ auto_linker=None,
514
+ consolidation_engine=None,
406
515
  existing_memory_id=memory_id,
407
- queryable_fact_ids=operation.queryable_fact_ids,
516
+ queryable_fact_ids=fact_ids,
408
517
  trusted_actor_id=operation.trusted_actor_id,
409
518
  pre_authorized=True,
410
519
  ingestion_source_type=operation.source_type,
411
520
  ingestion_operation_id=operation.operation_id,
412
521
  derivation_report=pipeline_state,
522
+ precompleted_derivation_stages=frozenset({
523
+ "extraction", "consolidation",
524
+ }),
525
+ materialization_progress=progress,
526
+ materialization_checkpoint=checkpoint_materialization,
527
+ )
528
+ state = {
529
+ "pipeline_started": True,
530
+ "relational_started": True,
531
+ "pipeline": True,
532
+ "post_hooks": True,
533
+ "relational": True,
534
+ **pipeline_state,
535
+ }
536
+ return MaterializationResult(tuple(recovered_ids), state)
537
+
538
+ def materialize(operation: IngestionOperation) -> MaterializationResult:
539
+ # A checkpointed relational result is an at-most-once boundary. The
540
+ # extraction/consolidation pipeline mutates evidence/access counters,
541
+ # so replaying it is not a valid repair strategy. Idempotent external
542
+ # projections are resumed by IngestionCommand after every relational
543
+ # stage is complete; otherwise the durable failure remains inspectable.
544
+ if operation.final_fact_ids and operation.derivation_state.get("pipeline", False):
545
+ return resume_checkpoint(operation)
546
+
547
+ facts = engine._db.get_facts_by_ids(
548
+ list(operation.queryable_fact_ids),
549
+ operation.profile_id,
550
+ )
551
+ if len(facts) != len(operation.queryable_fact_ids):
552
+ raise ValueError("queryable fact profile mismatch or missing fact")
553
+ memory_ids = {fact.memory_id for fact in facts}
554
+ if len(memory_ids) != 1:
555
+ raise ValueError("queryable facts do not share one source memory")
556
+ memory_id = next(iter(memory_ids))
557
+
558
+ from superlocalmemory.core.store_pipeline import run_store
559
+
560
+ is_prebuilt = isinstance(operation.metadata.get(_PREBUILT_FACT_KEY), dict)
561
+ if (
562
+ operation.derivation_state.get("pipeline_started", False)
563
+ and not operation.derivation_state.get("pipeline", False)
564
+ and operation.derivation_state.get("relational_started", False)
565
+ ):
566
+ return resume_partial_relational(operation, memory_id)
567
+ repository.checkpoint_enriching(
568
+ operation.operation_id,
569
+ final_fact_ids=(),
570
+ derivation_version=_DERIVATION_VERSION,
571
+ derivation_state={
572
+ "pipeline_started": True,
573
+ "pipeline": False,
574
+ },
575
+ lease_owner=operation.lease_owner,
576
+ lease_seconds=900.0,
413
577
  )
578
+
579
+ class _QueryableProjectionExtractor:
580
+ @staticmethod
581
+ def extract_facts(**_kwargs):
582
+ return []
583
+
584
+ pipeline_state: dict[str, bool] = {}
585
+ progress: dict[str, object] = {}
586
+
587
+ def checkpoint_materialization(
588
+ _phase: str,
589
+ fact_ids: tuple[str, ...],
590
+ state: dict[str, bool],
591
+ ) -> None:
592
+ repository.checkpoint_enriching(
593
+ operation.operation_id,
594
+ final_fact_ids=fact_ids,
595
+ derivation_version=_DERIVATION_VERSION,
596
+ derivation_state=state,
597
+ lease_owner=operation.lease_owner,
598
+ lease_seconds=900.0,
599
+ )
600
+
601
+ try:
602
+ fact_ids = run_store(
603
+ operation.raw_content,
604
+ operation.profile_id,
605
+ session_id=operation.session_id,
606
+ session_date=operation.session_date or None,
607
+ speaker=operation.speaker,
608
+ role=operation.role,
609
+ metadata=dict(operation.metadata),
610
+ scope=operation.scope,
611
+ shared_with=list(operation.shared_with) or None,
612
+ config=engine._config,
613
+ db=engine._db,
614
+ embedder=engine._embedder,
615
+ fact_extractor=(
616
+ _QueryableProjectionExtractor()
617
+ if is_prebuilt else engine._fact_extractor
618
+ ),
619
+ entity_resolver=engine._entity_resolver,
620
+ temporal_parser=engine._temporal_parser,
621
+ type_router=None if is_prebuilt else engine._type_router,
622
+ graph_builder=engine._graph_builder,
623
+ consolidator=None if is_prebuilt else engine._consolidator,
624
+ observation_builder=engine._observation_builder,
625
+ scene_builder=engine._scene_builder,
626
+ entropy_gate=engine._entropy_gate,
627
+ # External indexes are projected only after the relational unit
628
+ # of work commits; sqlite-vec uses a separate connection.
629
+ ann_index=None,
630
+ sheaf_checker=engine._sheaf_checker,
631
+ retrieval_engine=None,
632
+ provenance=engine._provenance,
633
+ hooks=engine._hooks,
634
+ vector_store=None,
635
+ context_generator=engine._context_generator,
636
+ temporal_validator=engine._temporal_validator,
637
+ auto_linker=engine._auto_linker,
638
+ consolidation_engine=engine._consolidation_engine,
639
+ existing_memory_id=memory_id,
640
+ queryable_fact_ids=operation.queryable_fact_ids,
641
+ trusted_actor_id=operation.trusted_actor_id,
642
+ pre_authorized=True,
643
+ ingestion_source_type=operation.source_type,
644
+ ingestion_operation_id=operation.operation_id,
645
+ derivation_report=pipeline_state,
646
+ precompleted_derivation_stages=(
647
+ frozenset({"extraction", "consolidation"})
648
+ if is_prebuilt else frozenset()
649
+ ),
650
+ materialization_progress=progress,
651
+ materialization_checkpoint=checkpoint_materialization,
652
+ )
653
+ except Exception as exc:
654
+ # ``run_store`` checkpoints the completed relational pipeline
655
+ # immediately before post-hooks run. Prefer that durable ledger
656
+ # over rebuilding state from local variables: a one-time hook
657
+ # failure must not make committed extraction/consolidation look
658
+ # incomplete and trigger a destructive pipeline replay on retry.
659
+ checkpoint = repository.get(operation.operation_id)
660
+ partial_ids = tuple(checkpoint.final_fact_ids)
661
+ failed_state = dict(checkpoint.derivation_state)
662
+ if not partial_ids:
663
+ partial_ids = tuple(progress.get("fact_ids") or ())
664
+ if not partial_ids:
665
+ partial_ids = tuple(
666
+ fact.fact_id
667
+ for fact in engine._db.get_facts_by_memory_id(
668
+ memory_id, operation.profile_id
669
+ )
670
+ )
671
+ if not failed_state:
672
+ relational_complete = bool(
673
+ progress.get("relational_complete", False)
674
+ )
675
+ failed_state = {
676
+ **{
677
+ name: bool(pipeline_state.get(name, False))
678
+ for name in (
679
+ "extraction",
680
+ "canonicalization",
681
+ "consolidation",
682
+ "graph",
683
+ "temporal",
684
+ "provenance",
685
+ )
686
+ },
687
+ "pipeline_started": True,
688
+ "relational_started": bool(
689
+ progress.get("relational_started", False)
690
+ ),
691
+ "pipeline": relational_complete,
692
+ "post_hooks": False,
693
+ }
694
+ # Reaching this exception handler means hooks did not complete.
695
+ # Keep this false even if a future hook implementation mutates the
696
+ # in-memory progress map before raising.
697
+ failed_state["post_hooks"] = False
698
+ return MaterializationResult(partial_ids, failed_state, str(exc))
414
699
  if not fact_ids:
415
700
  return MaterializationResult((), {"relational": False})
416
- if is_prebuilt:
417
- pipeline_state["extraction"] = True
418
- pipeline_state["consolidation"] = True
419
701
 
420
702
  placeholders = ",".join("?" for _ in fact_ids)
421
703
  relational_count = engine._db.execute(
@@ -462,6 +744,10 @@ def build_engine_ingestion_command(engine: MemoryEngine) -> IngestionCommand:
462
744
  )
463
745
 
464
746
  derivation_state = {
747
+ "pipeline_started": True,
748
+ "relational_started": True,
749
+ "pipeline": True,
750
+ "post_hooks": True,
465
751
  "relational": int(dict(relational_count[0])["count"]) == len(fact_ids),
466
752
  "fts": int(dict(fts_count[0])["count"]) == len(fact_ids),
467
753
  "extraction": pipeline_state.get("extraction", False),
@@ -531,6 +817,7 @@ def build_engine_ingestion_command(engine: MemoryEngine) -> IngestionCommand:
531
817
  write_queryable=write_queryable,
532
818
  materialize=materialize,
533
819
  project=project,
820
+ derivation_version=_DERIVATION_VERSION,
534
821
  )
535
822
 
536
823