engraphis 0.9.2__tar.gz → 0.9.4__tar.gz

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 (174) hide show
  1. {engraphis-0.9.2/engraphis.egg-info → engraphis-0.9.4}/PKG-INFO +6 -1
  2. {engraphis-0.9.2 → engraphis-0.9.4}/README.md +5 -0
  3. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/automation.py +93 -1
  4. engraphis-0.9.4/engraphis/backends/extractor.py +368 -0
  5. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/cloud_license.py +96 -22
  6. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/config.py +4 -2
  7. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/core/consolidate.py +156 -1
  8. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/dashboard_app.py +95 -0
  9. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/inspector/auth.py +47 -10
  10. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/inspector/license_cloud.py +264 -17
  11. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/inspector/webhooks.py +39 -0
  12. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/licensing.py +60 -28
  13. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/routes/v2_api.py +29 -12
  14. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/service.py +60 -12
  15. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/static/index.html +9 -9
  16. {engraphis-0.9.2 → engraphis-0.9.4/engraphis.egg-info}/PKG-INFO +6 -1
  17. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis.egg-info/SOURCES.txt +6 -0
  18. {engraphis-0.9.2 → engraphis-0.9.4}/pyproject.toml +1 -1
  19. {engraphis-0.9.2 → engraphis-0.9.4}/scripts/auto_maintain.py +6 -4
  20. engraphis-0.9.4/tests/test_chunking_eval.py +42 -0
  21. engraphis-0.9.4/tests/test_chunking_extractor.py +126 -0
  22. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_cloud_license.py +310 -51
  23. engraphis-0.9.4/tests/test_dashboard_dream_ui.py +26 -0
  24. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_dashboard_v2.py +108 -10
  25. engraphis-0.9.4/tests/test_dreaming_trigger.py +125 -0
  26. engraphis-0.9.4/tests/test_import_chunking.py +66 -0
  27. engraphis-0.9.4/tests/test_inference.py +91 -0
  28. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_online_only_enforcement.py +3 -2
  29. engraphis-0.9.2/engraphis/backends/extractor.py +0 -148
  30. {engraphis-0.9.2 → engraphis-0.9.4}/LICENSE +0 -0
  31. {engraphis-0.9.2 → engraphis-0.9.4}/NOTICE +0 -0
  32. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/__init__.py +0 -0
  33. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/analytics.py +0 -0
  34. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/app.py +0 -0
  35. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/autosync.py +0 -0
  36. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/backends/__init__.py +0 -0
  37. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/backends/codegraph.py +0 -0
  38. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/backends/embedder_api.py +0 -0
  39. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/backends/embedder_deterministic.py +0 -0
  40. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/backends/embedder_st.py +0 -0
  41. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/backends/encrypted_db.py +0 -0
  42. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/backends/graph_extractor.py +0 -0
  43. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/backends/reranker.py +0 -0
  44. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/backends/sync_folder.py +0 -0
  45. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/backends/sync_relay.py +0 -0
  46. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/backends/vector_numpy.py +0 -0
  47. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/backends/vector_sqlitevec.py +0 -0
  48. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/billing.py +0 -0
  49. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/core/__init__.py +0 -0
  50. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/core/engine.py +0 -0
  51. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/core/graphrank.py +0 -0
  52. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/core/grounded.py +0 -0
  53. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/core/ids.py +0 -0
  54. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/core/interfaces.py +0 -0
  55. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/core/recall.py +0 -0
  56. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/core/resolve.py +0 -0
  57. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/core/schema.py +0 -0
  58. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/core/scoring.py +0 -0
  59. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/core/store.py +0 -0
  60. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/core/sync.py +0 -0
  61. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/core/textutil.py +0 -0
  62. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/engines/__init__.py +0 -0
  63. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/engines/embedder.py +0 -0
  64. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/engines/ingest.py +0 -0
  65. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/engines/intelligence.py +0 -0
  66. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/engines/recall.py +0 -0
  67. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/engines/reweight.py +0 -0
  68. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/engines/thoughts.py +0 -0
  69. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/graphdata.py +0 -0
  70. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/inspector/__init__.py +0 -0
  71. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/inspector/app.py +0 -0
  72. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/inspector/cloud_mount.py +0 -0
  73. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/inspector/license_registry.py +0 -0
  74. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/inspector/sync_relay.py +0 -0
  75. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/llm/__init__.py +0 -0
  76. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/llm/client.py +0 -0
  77. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/logging_setup.py +0 -0
  78. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/mcp_server.py +0 -0
  79. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/models.py +0 -0
  80. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/redirector.py +0 -0
  81. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/routes/__init__.py +0 -0
  82. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/routes/memory.py +0 -0
  83. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/routes/v2_team.py +0 -0
  84. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/routes/vault.py +0 -0
  85. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/static/__init__.py +0 -0
  86. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/static/engraphis-icon.png +0 -0
  87. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/static/engraphis.ico +0 -0
  88. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/static/favicon-32.png +0 -0
  89. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/static/vendor/d3.min.js +0 -0
  90. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/static/vendor/force-graph.LICENSE +0 -0
  91. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/static/vendor/force-graph.min.js +0 -0
  92. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/static/vendor/marked.min.js +0 -0
  93. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/static/vendor/purify.min.js +0 -0
  94. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/stores/__init__.py +0 -0
  95. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/stores/graph.py +0 -0
  96. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/stores/ledger.py +0 -0
  97. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/stores/vaults.py +0 -0
  98. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/stores/vectors.py +0 -0
  99. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis.egg-info/dependency_links.txt +0 -0
  100. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis.egg-info/entry_points.txt +0 -0
  101. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis.egg-info/requires.txt +0 -0
  102. {engraphis-0.9.2 → engraphis-0.9.4}/engraphis.egg-info/top_level.txt +0 -0
  103. {engraphis-0.9.2 → engraphis-0.9.4}/scripts/__init__.py +0 -0
  104. {engraphis-0.9.2 → engraphis-0.9.4}/scripts/backfill_graph.py +0 -0
  105. {engraphis-0.9.2 → engraphis-0.9.4}/scripts/cli.py +0 -0
  106. {engraphis-0.9.2 → engraphis-0.9.4}/scripts/consolidate.py +0 -0
  107. {engraphis-0.9.2 → engraphis-0.9.4}/scripts/init.py +0 -0
  108. {engraphis-0.9.2 → engraphis-0.9.4}/scripts/inspector.py +0 -0
  109. {engraphis-0.9.2 → engraphis-0.9.4}/scripts/install_shortcuts.py +0 -0
  110. {engraphis-0.9.2 → engraphis-0.9.4}/scripts/license_admin.py +0 -0
  111. {engraphis-0.9.2 → engraphis-0.9.4}/scripts/mcp_server_http.py +0 -0
  112. {engraphis-0.9.2 → engraphis-0.9.4}/scripts/migrate_to_v2.py +0 -0
  113. {engraphis-0.9.2 → engraphis-0.9.4}/scripts/sdk_compat.py +0 -0
  114. {engraphis-0.9.2 → engraphis-0.9.4}/scripts/seed_from_obsidian.py +0 -0
  115. {engraphis-0.9.2 → engraphis-0.9.4}/scripts/smoke_cloud.py +0 -0
  116. {engraphis-0.9.2 → engraphis-0.9.4}/scripts/start_dashboard.py +0 -0
  117. {engraphis-0.9.2 → engraphis-0.9.4}/scripts/start_server.py +0 -0
  118. {engraphis-0.9.2 → engraphis-0.9.4}/scripts/sync.py +0 -0
  119. {engraphis-0.9.2 → engraphis-0.9.4}/scripts/test_routes.py +0 -0
  120. {engraphis-0.9.2 → engraphis-0.9.4}/scripts/update.py +0 -0
  121. {engraphis-0.9.2 → engraphis-0.9.4}/setup.cfg +0 -0
  122. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_analytics.py +0 -0
  123. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_app_auth.py +0 -0
  124. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_autosync.py +0 -0
  125. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_backends_factories.py +0 -0
  126. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_billing.py +0 -0
  127. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_cloud_endpoints_mounted.py +0 -0
  128. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_codegraph.py +0 -0
  129. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_config.py +0 -0
  130. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_consolidate.py +0 -0
  131. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_core_ids.py +0 -0
  132. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_core_store.py +0 -0
  133. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_encrypted_store.py +0 -0
  134. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_engine.py +0 -0
  135. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_eval_external.py +0 -0
  136. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_eval_harness.py +0 -0
  137. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_extractor.py +0 -0
  138. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_graph_extractor.py +0 -0
  139. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_graphdata.py +0 -0
  140. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_graphrank.py +0 -0
  141. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_grounded.py +0 -0
  142. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_ingest_entities.py +0 -0
  143. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_init.py +0 -0
  144. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_inspector.py +0 -0
  145. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_inspector_pro.py +0 -0
  146. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_licensing.py +0 -0
  147. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_logging_setup.py +0 -0
  148. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_mcp_server.py +0 -0
  149. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_merge.py +0 -0
  150. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_migration.py +0 -0
  151. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_password_reset.py +0 -0
  152. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_personal_folders.py +0 -0
  153. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_provenance_flags.py +0 -0
  154. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_ready.py +0 -0
  155. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_recall.py +0 -0
  156. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_resolve.py +0 -0
  157. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_scoring.py +0 -0
  158. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_service.py +0 -0
  159. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_service_graph.py +0 -0
  160. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_service_isolation.py +0 -0
  161. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_session_idempotent.py +0 -0
  162. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_sync.py +0 -0
  163. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_sync_cli.py +0 -0
  164. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_sync_dashboard.py +0 -0
  165. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_sync_relay.py +0 -0
  166. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_team_audit.py +0 -0
  167. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_v1_auto_migrate.py +0 -0
  168. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_v1_hardening.py +0 -0
  169. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_v1_licensing.py +0 -0
  170. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_vector_numpy.py +0 -0
  171. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_webhook_e2e.py +0 -0
  172. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_webhooks.py +0 -0
  173. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_workspace_isolation.py +0 -0
  174. {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_workspace_ops.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: engraphis
3
- Version: 0.9.2
3
+ Version: 0.9.4
4
4
  Summary: Local-first AI memory engine for agents — Ebbinghaus decay, interaction-aware recall, bi-temporal facts, hybrid retrieval, and an MCP server. You bring the LLM.
5
5
  Author: The Engraphis Authors
6
6
  License: Apache-2.0
@@ -197,6 +197,11 @@ pip install "engraphis[encryption]" # SQLCipher encryption-at-rest extra
197
197
  pip install engraphis # core library — numpy only, fully offline
198
198
  ```
199
199
 
200
+ > **Linux / macOS:** if `pip install` fails with `error: externally-managed-environment`,
201
+ > your system Python is marked read-only (PEP 668). Install into a virtual environment
202
+ > instead — `python3 -m venv venv && source venv/bin/activate && pip install "engraphis[server]"`
203
+ > — or use Docker (`docker compose up`). `pipx install "engraphis[server]"` also works.
204
+
200
205
  > First run downloads `all-MiniLM-L6-v2` (~80 MB). Without it, the engine falls back
201
206
  > to a deterministic offline embedder so it always runs.
202
207
 
@@ -129,6 +129,11 @@ pip install "engraphis[encryption]" # SQLCipher encryption-at-rest extra
129
129
  pip install engraphis # core library — numpy only, fully offline
130
130
  ```
131
131
 
132
+ > **Linux / macOS:** if `pip install` fails with `error: externally-managed-environment`,
133
+ > your system Python is marked read-only (PEP 668). Install into a virtual environment
134
+ > instead — `python3 -m venv venv && source venv/bin/activate && pip install "engraphis[server]"`
135
+ > — or use Docker (`docker compose up`). `pipx install "engraphis[server]"` also works.
136
+
132
137
  > First run downloads `all-MiniLM-L6-v2` (~80 MB). Without it, the engine falls back
133
138
  > to a deterministic offline embedder so it always runs.
134
139
 
@@ -29,6 +29,18 @@ DEFAULT_POLICY: dict = {
29
29
  "min_cluster": 3,
30
30
  "archive_below": 0.05,
31
31
  "workspaces": [], # empty = every workspace the caller can see
32
+ # "Dreaming" trigger: run a sweep *before* the cadence elapses when enough new
33
+ # episodic memories have piled up AND the store has gone quiet (the user paused).
34
+ # Purely additive to the cadence in ``due()`` — it can only cause more sweeps, never
35
+ # fewer — so existing cron behaviour is unchanged when left at defaults.
36
+ "dream": True,
37
+ "dream_min_new": 25, # new episodics since last run before an early sweep is worth it
38
+ "dream_idle_minutes": 15, # ...and this long since the most recent write ("went quiet")
39
+ # Cross-cluster inference (consolidate pass 4). OFF by default: it writes new
40
+ # (low-salience, untrusted, linked) memories, so a human opts in. When on, a sweep
41
+ # (manual *or* the dream loop) runs the inference pass too — following the sweep's
42
+ # own dry_run flag, so a dry-run preview proposes and a real run applies.
43
+ "infer": False,
32
44
  }
33
45
 
34
46
  _POLICY_KEYS = set(DEFAULT_POLICY)
@@ -78,6 +90,16 @@ def normalize_policy(raw: dict) -> dict:
78
90
  p["archive_below"] = 0.05
79
91
  wss = p.get("workspaces") or []
80
92
  p["workspaces"] = [str(w) for w in wss] if isinstance(wss, list) else []
93
+ p["dream"] = bool(p["dream"])
94
+ p["infer"] = bool(p["infer"])
95
+ try:
96
+ p["dream_min_new"] = max(1, int(p["dream_min_new"] or 25))
97
+ except (TypeError, ValueError):
98
+ p["dream_min_new"] = 25
99
+ try:
100
+ p["dream_idle_minutes"] = max(0, int(p["dream_idle_minutes"]))
101
+ except (TypeError, ValueError):
102
+ p["dream_idle_minutes"] = 15
81
103
  return p
82
104
 
83
105
 
@@ -131,6 +153,75 @@ def due(policy: dict, *, now: Optional[float] = None) -> bool:
131
153
  return True
132
154
 
133
155
 
156
+ def dream_signals(memories: Any, *, last_run: Optional[float],
157
+ now: float) -> tuple[int, Optional[float]]:
158
+ """From an iterable of memory records, compute ``(new_episodic, idle_seconds)``:
159
+ how many *episodic* memories were ingested since ``last_run`` (accumulation) and how
160
+ long since the most recent write of any kind (idle). ``idle_seconds`` is ``None`` for
161
+ an empty store. Pure and side-effect-free so it unit-tests without a database."""
162
+ newest = 0.0
163
+ new_episodic = 0
164
+ for m in memories:
165
+ ts = float(getattr(m, "ingested_at", 0) or 0)
166
+ if ts > newest:
167
+ newest = ts
168
+ mtype = getattr(getattr(m, "mtype", ""), "value", getattr(m, "mtype", ""))
169
+ if str(mtype) == "episodic" and (last_run is None or ts > float(last_run)):
170
+ new_episodic += 1
171
+ return new_episodic, (now - newest if newest else None)
172
+
173
+
174
+ def should_dream(policy: dict, memories: Any, *, now: Optional[float] = None) -> bool:
175
+ """True when accumulation + idle warrant a sweep *before* the cadence is due.
176
+
177
+ Requires the policy be enabled and dreaming on, then: at least ``dream_min_new`` new
178
+ episodic memories since ``last_run`` **and** the store idle for ``dream_idle_minutes``.
179
+ The idle guard means a sweep never fights a live editing burst; the accumulation guard
180
+ means it never runs on a store nothing has been added to."""
181
+ if not policy.get("enabled") or not policy.get("dream", True):
182
+ return False
183
+ now = time.time() if now is None else now
184
+ new_episodic, idle = dream_signals(
185
+ memories, last_run=policy.get("last_run"), now=now)
186
+ if new_episodic < int(policy.get("dream_min_new", 25)):
187
+ return False
188
+ if idle is None:
189
+ return False
190
+ return idle >= int(policy.get("dream_idle_minutes", 15)) * 60.0
191
+
192
+
193
+ def dream_due(service: Any, *, policy: Optional[dict] = None,
194
+ now: Optional[float] = None, scan_limit: int = 5000) -> bool:
195
+ """Should a scheduled sweep run now? ``due()`` (cadence) **or** ``should_dream()``
196
+ (accumulation+idle). Reads recent memories from the service's store — the only IO in
197
+ the trigger — and is purely additive to the cadence, so cron users are unaffected."""
198
+ pol = load_policy() if policy is None else policy
199
+ if due(pol, now=now):
200
+ return True
201
+ try:
202
+ from engraphis.core.interfaces import SearchFilter
203
+ targets = pol.get("workspaces") or []
204
+ if targets:
205
+ # Only accumulation/idle *within* the scoped workspaces can fire a sweep
206
+ # — a burst in an out-of-scope workspace must not trigger one. Names are
207
+ # resolved to ids with a read-only lookup (never create) so a stale policy
208
+ # entry can't mint an empty folder.
209
+ memories: list = []
210
+ conn = service.store.conn
211
+ for name in targets:
212
+ row = conn.execute(
213
+ "SELECT id FROM workspaces WHERE name=?", (str(name),)).fetchone()
214
+ if not row:
215
+ continue
216
+ memories.extend(service.store.list_memories(
217
+ SearchFilter(workspace_id=row["id"]), limit=scan_limit))
218
+ else:
219
+ memories = service.store.list_memories(SearchFilter(), limit=scan_limit)
220
+ except Exception: # noqa: BLE001 - a trigger must never crash the scheduled job
221
+ return False
222
+ return should_dream(pol, memories, now=now)
223
+
224
+
134
225
  def run_maintenance(service: Any, *, dry_run: bool = True,
135
226
  policy: Optional[dict] = None, record: bool = True,
136
227
  now: Optional[float] = None) -> dict:
@@ -156,7 +247,8 @@ def run_maintenance(service: Any, *, dry_run: bool = True,
156
247
  entry["consolidate"] = service.consolidate(
157
248
  workspace=ws, dry_run=dry_run,
158
249
  min_cluster=pol["min_cluster"],
159
- archive_below=pol["archive_below"])
250
+ archive_below=pol["archive_below"],
251
+ infer=bool(pol.get("infer", False)))
160
252
  except Exception as exc: # noqa: BLE001 - isolate a bad workspace
161
253
  entry["error"] = str(exc)
162
254
  runs.append(entry)
@@ -0,0 +1,368 @@
1
+ """Fact extractors — implementations of the ``core.interfaces.Extractor`` protocol.
2
+
3
+ Every SOTA memory system (mem0, A-Mem, Letta) auto-distills raw text into discrete
4
+ facts before storage; Engraphis makes that step *pluggable and optional* so the core
5
+ stays offline-capable (AGENTS.md §3.8):
6
+
7
+ * ``PassthroughExtractor`` — the default: the caller's text is stored exactly as given
8
+ (today's behaviour, zero dependencies, zero network).
9
+ * ``ChunkingExtractor`` — splits a document into retrieval-sized, structure-aware
10
+ chunks (one ``ExtractedFact`` each) *without* an LLM: headings start new chunks and
11
+ become the title, fenced code blocks stay intact, prose is packed to a token budget
12
+ with a small sentence overlap. Deterministic and offline (numpy/stdlib only) so it
13
+ runs under the offline gate; the answer to "one memory per file dilutes recall".
14
+ * ``LLMExtractor`` — distills a raw blob (a conversation turn, a log, a diff
15
+ summary) into discrete, self-contained facts with type/importance/keyword hints,
16
+ using any configured LLM. Fails soft: any error degrades to passthrough, never to a
17
+ lost write.
18
+
19
+ Selected via ``get_extractor()`` from ``ENGRAPHIS_EXTRACTOR`` (= ``none`` | ``chunk`` |
20
+ ``llm``) — a config change, not a refactor, matching every other backend swap here.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import os
26
+ import re
27
+ from typing import Any, Optional
28
+
29
+ from engraphis.core.interfaces import ExtractedFact, MemoryType
30
+ from engraphis.core.textutil import estimate_tokens, tokenize
31
+
32
+ MAX_FACTS = 12
33
+
34
+ # Structure-aware chunking defaults (offline, deterministic). Overridable via
35
+ # ENGRAPHIS_CHUNK_TOKENS / ENGRAPHIS_CHUNK_OVERLAP / ENGRAPHIS_CHUNK_MAX.
36
+ CHUNK_TARGET_TOKENS = 256 # target tokens per prose chunk
37
+ CHUNK_OVERLAP_TOKENS = 32 # sentence-level overlap carried between adjacent chunks
38
+ CHUNK_MAX = 200 # hard cap on chunks per document (amplification guard)
39
+
40
+ _HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*#*$")
41
+ _FENCE_RE = re.compile(r"^(```+|~~~+)")
42
+ _SENTENCE_RE = re.compile(r"(?<=[.!?])\s+")
43
+ _PARA_SPLIT_RE = re.compile(r"\n\s*\n")
44
+
45
+ # LLM output is untrusted input too (indirect prompt injection can steer it): strip the
46
+ # same control characters service.py strips from direct writes, so extracted facts can't
47
+ # smuggle hidden-instruction / terminal-escape payloads past the validation layer.
48
+ _CONTROL_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
49
+
50
+
51
+ def _defang(value: str, limit: int) -> str:
52
+ return _CONTROL_RE.sub("", value)[:limit].strip()
53
+
54
+ _EXTRACT_SYSTEM_PROMPT = (
55
+ "You distill raw text into discrete, self-contained memory facts for an AI agent's "
56
+ "long-term memory. Each fact must stand alone (no pronouns that depend on the "
57
+ "original text), be worth remembering beyond this moment, and be stated in one or "
58
+ "two sentences. Skip filler, pleasantries, and transient chatter.\n\n"
59
+ "Classify each fact:\n"
60
+ "- semantic: durable facts, preferences, conventions ('The API uses PASETO tokens')\n"
61
+ "- episodic: events and decisions with a when ('On 2026-06-30 PR #99 was merged')\n"
62
+ "- procedural: how-tos and playbooks ('To rebuild the index, run ...')\n"
63
+ "- working: transient state only relevant right now ('Currently blocked on CI')\n\n"
64
+ "Respond with JSON only, no markdown fences, no prose:\n"
65
+ '{"facts": [{"content": str, "title": str, "mtype": "semantic|episodic|procedural|'
66
+ 'working", "importance": <0..1>, "keywords": [str, ...]}]}'
67
+ )
68
+
69
+
70
+ class PassthroughExtractor:
71
+ """The offline default: one fact, the text as given."""
72
+
73
+ def extract(self, text: str, *, context: str = "") -> list[ExtractedFact]:
74
+ return [ExtractedFact(content=text)]
75
+
76
+
77
+ class LLMExtractor:
78
+ """LLM-backed fact distillation behind the ``Extractor`` protocol.
79
+
80
+ ``llm`` may be anything with a ``chat(messages, system=...) -> str`` method (the
81
+ engraphis v1 ``LLMClient``) or a ``complete(messages) -> str`` method (the
82
+ ``core.interfaces.LLM`` protocol). Untrusted input goes *into* the prompt; the
83
+ output is parsed defensively and every fact re-validated — a malformed or
84
+ adversarial response degrades to passthrough rather than corrupting the store.
85
+ """
86
+
87
+ def __init__(self, llm: Any, *, max_facts: int = MAX_FACTS) -> None:
88
+ self.llm = llm
89
+ self.max_facts = max_facts
90
+
91
+ def extract(self, text: str, *, context: str = "") -> list[ExtractedFact]:
92
+ prompt = f"Context: {context}\n\nText to distill:\n{text}" if context else text
93
+ try:
94
+ raw = self._ask(prompt)
95
+ facts = self._parse(raw)
96
+ except Exception:
97
+ facts = []
98
+ return facts or [ExtractedFact(content=text)]
99
+
100
+ # ── internals ────────────────────────────────────────────────────────────
101
+ def _ask(self, prompt: str) -> str:
102
+ messages = [{"role": "user", "content": prompt}]
103
+ if hasattr(self.llm, "chat"):
104
+ return self.llm.chat(messages, system=_EXTRACT_SYSTEM_PROMPT)
105
+ return self.llm.complete(
106
+ [{"role": "system", "content": _EXTRACT_SYSTEM_PROMPT}, *messages])
107
+
108
+ def _parse(self, raw: str) -> list[ExtractedFact]:
109
+ data = _loads_lenient(raw)
110
+ out: list[ExtractedFact] = []
111
+ for item in (data.get("facts") or [])[: self.max_facts]:
112
+ if not isinstance(item, dict):
113
+ continue
114
+ content = _defang(str(item.get("content") or ""), 100_000)
115
+ if not content:
116
+ continue
117
+ mtype: Optional[MemoryType] = None
118
+ try:
119
+ mtype = MemoryType(str(item.get("mtype", "")).strip().lower())
120
+ except ValueError:
121
+ mtype = None
122
+ try:
123
+ importance = max(0.0, min(1.0, float(item.get("importance", 0.0))))
124
+ except (TypeError, ValueError):
125
+ importance = 0.0
126
+ keywords = [_defang(str(k), 128) for k in (item.get("keywords") or [])[:16]
127
+ if isinstance(k, (str, int, float))]
128
+ out.append(ExtractedFact(content=content,
129
+ title=_defang(str(item.get("title") or ""), 1_000),
130
+ mtype=mtype, importance=importance,
131
+ keywords=[k for k in keywords if k]))
132
+ return out
133
+
134
+
135
+ class ChunkingExtractor:
136
+ """Deterministic, offline, structure-aware chunker (``Extractor`` protocol).
137
+
138
+ Splits a document into retrieval-sized ``ExtractedFact`` chunks that preserve
139
+ meaning rather than cutting at arbitrary character counts:
140
+
141
+ * Markdown headings (``#``..``######``) start a new chunk; the heading path
142
+ (``H1 > H2``) becomes the chunk title and is kept as context.
143
+ * Fenced code blocks (```` ``` ````/``~~~``) are emitted whole — never split
144
+ mid-fence.
145
+ * Prose is packed paragraph-by-paragraph up to ``target_tokens``, with a small
146
+ sentence-level overlap so a fact straddling a boundary survives in both chunks.
147
+
148
+ numpy/stdlib only — no model, no network — so it is safe inside the offline gate
149
+ and identical across runs (a requirement for deterministic-embedder eval).
150
+ """
151
+
152
+ def __init__(self, *, target_tokens: int = CHUNK_TARGET_TOKENS,
153
+ overlap_tokens: int = CHUNK_OVERLAP_TOKENS,
154
+ max_chunks: int = CHUNK_MAX) -> None:
155
+ self.target_tokens = max(16, int(target_tokens))
156
+ self.overlap_tokens = max(0, min(int(overlap_tokens), self.target_tokens // 2))
157
+ self.max_chunks = max(1, int(max_chunks))
158
+
159
+ def extract(self, text: str, *, context: str = "") -> list[ExtractedFact]:
160
+ text = text or ""
161
+ if not text.strip():
162
+ return []
163
+ facts: list[ExtractedFact] = []
164
+ for heading_path, content in self._chunks(text):
165
+ content = _defang(content, 100_000)
166
+ if not content:
167
+ continue
168
+ leaf = heading_path.split(" > ")[-1] if heading_path else ""
169
+ title = _defang(leaf or _first_line(content), 1_000)
170
+ facts.append(ExtractedFact(content=content, title=title[:200],
171
+ keywords=_keywords(content)))
172
+ if len(facts) >= self.max_chunks:
173
+ break
174
+ # Never lose the write: an all-whitespace/degenerate parse falls back to the
175
+ # whole text, exactly like PassthroughExtractor.
176
+ return facts or [ExtractedFact(content=_defang(text, 100_000))]
177
+
178
+ # ── internals ────────────────────────────────────────────────────────────
179
+ def _chunks(self, text: str) -> list[tuple[str, str]]:
180
+ out: list[tuple[str, str]] = []
181
+ for heading_path, kind, body in self._segment(text):
182
+ if len(out) >= self.max_chunks:
183
+ break
184
+ if kind == "code":
185
+ out.append((heading_path, body)) # atomic — never split
186
+ else:
187
+ for piece in self._pack(body):
188
+ out.append((heading_path, piece))
189
+ if len(out) >= self.max_chunks:
190
+ break
191
+ return out[: self.max_chunks]
192
+
193
+ def _segment(self, text: str) -> list[tuple[str, str, str]]:
194
+ """Split into ``(heading_path, kind, body)`` segments, preserving code fences
195
+ whole and tracking the active markdown heading stack."""
196
+ lines = text.split("\n")
197
+ heading_stack: list[tuple[int, str]] = []
198
+ segments: list[tuple[str, str, str]] = []
199
+ prose: list[str] = []
200
+ n = len(lines)
201
+
202
+ def path() -> str:
203
+ return " > ".join(t for _, t in heading_stack)
204
+
205
+ def flush() -> None:
206
+ if prose:
207
+ body = "\n".join(prose).strip()
208
+ if body:
209
+ segments.append((path(), "prose", body))
210
+ prose.clear()
211
+
212
+ i = 0
213
+ while i < n:
214
+ line = lines[i]
215
+ stripped = line.strip()
216
+ fence = _FENCE_RE.match(stripped)
217
+ heading = _HEADING_RE.match(line)
218
+ if fence:
219
+ flush()
220
+ marker = fence.group(1)[:3]
221
+ block = [line]
222
+ i += 1
223
+ while i < n:
224
+ block.append(lines[i])
225
+ closed = lines[i].strip().startswith(marker)
226
+ i += 1
227
+ if closed:
228
+ break
229
+ segments.append((path(), "code", "\n".join(block).strip()))
230
+ continue
231
+ if heading:
232
+ flush()
233
+ level = len(heading.group(1))
234
+ heading_stack[:] = [(lv, t) for lv, t in heading_stack if lv < level]
235
+ heading_stack.append((level, heading.group(2).strip()))
236
+ prose.append(line) # keep heading text in the body too, for lexical recall
237
+ i += 1
238
+ continue
239
+ prose.append(line)
240
+ i += 1
241
+ flush()
242
+ return segments
243
+
244
+ def _pack(self, body: str) -> list[str]:
245
+ """Greedily pack paragraphs to the token budget with sentence overlap."""
246
+ paras = [p.strip() for p in _PARA_SPLIT_RE.split(body) if p.strip()]
247
+ chunks: list[str] = []
248
+ cur: list[str] = []
249
+ cur_tokens = 0
250
+ for para in paras:
251
+ ptokens = estimate_tokens(para)
252
+ if cur and cur_tokens + ptokens > self.target_tokens:
253
+ joined = "\n\n".join(cur)
254
+ chunks.append(joined)
255
+ tail = self._overlap_tail(joined)
256
+ cur = [tail] if tail else []
257
+ cur_tokens = estimate_tokens(tail) if tail else 0
258
+ if ptokens > self.target_tokens:
259
+ for group in self._split_paragraph(para):
260
+ chunks.append(group)
261
+ cur, cur_tokens = [], 0
262
+ continue
263
+ cur.append(para)
264
+ cur_tokens += ptokens
265
+ if cur:
266
+ chunks.append("\n\n".join(cur))
267
+ return chunks
268
+
269
+ def _split_paragraph(self, para: str) -> list[str]:
270
+ """Split an oversized paragraph on sentence boundaries (never mid-sentence)."""
271
+ sentences = [s for s in _SENTENCE_RE.split(para.strip()) if s]
272
+ groups: list[str] = []
273
+ cur: list[str] = []
274
+ cur_tokens = 0
275
+ for sent in sentences:
276
+ stokens = estimate_tokens(sent)
277
+ if cur and cur_tokens + stokens > self.target_tokens:
278
+ joined = " ".join(cur)
279
+ groups.append(joined)
280
+ tail = self._overlap_tail(joined)
281
+ cur = [tail] if tail else []
282
+ cur_tokens = estimate_tokens(tail) if tail else 0
283
+ cur.append(sent)
284
+ cur_tokens += stokens
285
+ if cur:
286
+ groups.append(" ".join(cur))
287
+ return groups
288
+
289
+ def _overlap_tail(self, text: str) -> str:
290
+ """Trailing whole sentences of ``text`` up to ``overlap_tokens``."""
291
+ if self.overlap_tokens <= 0:
292
+ return ""
293
+ sentences = [s for s in _SENTENCE_RE.split(text.strip()) if s]
294
+ tail: list[str] = []
295
+ tokens = 0
296
+ for sent in reversed(sentences):
297
+ if tail and tokens + estimate_tokens(sent) > self.overlap_tokens:
298
+ break
299
+ tail.insert(0, sent)
300
+ tokens += estimate_tokens(sent)
301
+ if tokens >= self.overlap_tokens:
302
+ break
303
+ return " ".join(tail).strip()
304
+
305
+
306
+ def _first_line(text: str) -> str:
307
+ for line in text.splitlines():
308
+ candidate = line.strip().lstrip("#").strip()
309
+ if candidate:
310
+ return candidate[:200]
311
+ return "chunk"
312
+
313
+
314
+ def _keywords(text: str, k: int = 8) -> list[str]:
315
+ counts: dict[str, int] = {}
316
+ for token in tokenize(text):
317
+ counts[token] = counts.get(token, 0) + 1
318
+ return sorted(counts, key=lambda t: (-counts[t], t))[:k]
319
+
320
+
321
+ def _env_int(name: str, default: int) -> int:
322
+ try:
323
+ return int(os.environ.get(name, "") or default)
324
+ except (TypeError, ValueError):
325
+ return default
326
+
327
+
328
+ def _loads_lenient(raw: str) -> dict:
329
+ """Parse JSON that may arrive wrapped in markdown fences or prose."""
330
+ raw = (raw or "").strip()
331
+ try:
332
+ return json.loads(raw)
333
+ except (json.JSONDecodeError, RecursionError):
334
+ pass
335
+ m = re.search(r"\{.*\}", raw, flags=re.DOTALL)
336
+ if m:
337
+ try:
338
+ return json.loads(m.group(0))
339
+ except (json.JSONDecodeError, RecursionError):
340
+ pass
341
+ return {}
342
+
343
+
344
+ def get_extractor(kind: str = "none", llm: Any = None):
345
+ """Factory mirroring ``get_embedder``/``get_vector_index``: config in, backend out.
346
+
347
+ ``kind='chunk'`` returns the deterministic, offline ``ChunkingExtractor`` (knobs from
348
+ ``ENGRAPHIS_CHUNK_TOKENS``/``_OVERLAP``/``_MAX``). ``kind='llm'`` with no ``llm``
349
+ builds the v1 multi-provider ``LLMClient`` from settings (heavy import gated here,
350
+ never in ``core/``). Anything else — including an LLM kind with no usable client —
351
+ returns the offline passthrough.
352
+ """
353
+ kind = (kind or "none").lower()
354
+ if kind == "chunk":
355
+ return ChunkingExtractor(
356
+ target_tokens=_env_int("ENGRAPHIS_CHUNK_TOKENS", CHUNK_TARGET_TOKENS),
357
+ overlap_tokens=_env_int("ENGRAPHIS_CHUNK_OVERLAP", CHUNK_OVERLAP_TOKENS),
358
+ max_chunks=_env_int("ENGRAPHIS_CHUNK_MAX", CHUNK_MAX),
359
+ )
360
+ if kind != "llm":
361
+ return PassthroughExtractor()
362
+ if llm is None:
363
+ try:
364
+ from engraphis.llm.client import LLMClient
365
+ llm = LLMClient()
366
+ except Exception:
367
+ return PassthroughExtractor()
368
+ return LLMExtractor(llm)