conexus 5.2.0__tar.gz → 5.3.0__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 (245) hide show
  1. {conexus-5.2.0 → conexus-5.3.0}/PKG-INFO +1 -1
  2. {conexus-5.2.0 → conexus-5.3.0}/docs/rdr/README.md +5 -0
  3. {conexus-5.2.0 → conexus-5.3.0}/mcpb/pyproject.toml +2 -2
  4. {conexus-5.2.0 → conexus-5.3.0}/pyproject.toml +1 -1
  5. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/aspect_worker.py +155 -95
  6. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/commands/memory.py +42 -5
  7. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/daemon/t2_client.py +6 -0
  8. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/daemon/t2_daemon.py +1 -0
  9. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/db/t2/__init__.py +50 -0
  10. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/db/t2/memory_store.py +86 -0
  11. {conexus-5.2.0 → conexus-5.3.0}/.beads/README.md +0 -0
  12. {conexus-5.2.0 → conexus-5.3.0}/.gitignore +0 -0
  13. {conexus-5.2.0 → conexus-5.3.0}/LICENSE +0 -0
  14. {conexus-5.2.0 → conexus-5.3.0}/README.md +0 -0
  15. {conexus-5.2.0 → conexus-5.3.0}/conexus/README.md +0 -0
  16. {conexus-5.2.0 → conexus-5.3.0}/conexus/agents/_shared/README.md +0 -0
  17. {conexus-5.2.0 → conexus-5.3.0}/conexus/daemon/com.nexus.t2.plist +0 -0
  18. {conexus-5.2.0 → conexus-5.3.0}/conexus/daemon/com.nexus.t3.plist +0 -0
  19. {conexus-5.2.0 → conexus-5.3.0}/conexus/daemon/nexus-t2.service +0 -0
  20. {conexus-5.2.0 → conexus-5.3.0}/conexus/daemon/nexus-t3.service +0 -0
  21. {conexus-5.2.0 → conexus-5.3.0}/conexus/hooks/scripts/routing/README.md +0 -0
  22. {conexus-5.2.0 → conexus-5.3.0}/conexus/plans/builtin/abstract-themes.yml +0 -0
  23. {conexus-5.2.0 → conexus-5.3.0}/conexus/plans/builtin/analyze-default.yml +0 -0
  24. {conexus-5.2.0 → conexus-5.3.0}/conexus/plans/builtin/citation-traversal.yml +0 -0
  25. {conexus-5.2.0 → conexus-5.3.0}/conexus/plans/builtin/debug-default.yml +0 -0
  26. {conexus-5.2.0 → conexus-5.3.0}/conexus/plans/builtin/document-default.yml +0 -0
  27. {conexus-5.2.0 → conexus-5.3.0}/conexus/plans/builtin/find-by-author.yml +0 -0
  28. {conexus-5.2.0 → conexus-5.3.0}/conexus/plans/builtin/hybrid-factual-lookup.yml +0 -0
  29. {conexus-5.2.0 → conexus-5.3.0}/conexus/plans/builtin/plan-author-default.yml +0 -0
  30. {conexus-5.2.0 → conexus-5.3.0}/conexus/plans/builtin/plan-inspect-default.yml +0 -0
  31. {conexus-5.2.0 → conexus-5.3.0}/conexus/plans/builtin/plan-inspect-dimensions.yml +0 -0
  32. {conexus-5.2.0 → conexus-5.3.0}/conexus/plans/builtin/plan-promote-propose.yml +0 -0
  33. {conexus-5.2.0 → conexus-5.3.0}/conexus/plans/builtin/research-default.yml +0 -0
  34. {conexus-5.2.0 → conexus-5.3.0}/conexus/plans/builtin/review-default.yml +0 -0
  35. {conexus-5.2.0 → conexus-5.3.0}/conexus/plans/builtin/traverse-then-generate.yml +0 -0
  36. {conexus-5.2.0 → conexus-5.3.0}/conexus/plans/builtin/type-scoped-search.yml +0 -0
  37. {conexus-5.2.0 → conexus-5.3.0}/conexus/plans/dimensions.yml +0 -0
  38. {conexus-5.2.0 → conexus-5.3.0}/conexus/plans/purposes.yml +0 -0
  39. {conexus-5.2.0 → conexus-5.3.0}/data/calibration/rdr-109/README.md +0 -0
  40. {conexus-5.2.0 → conexus-5.3.0}/docs/README.md +0 -0
  41. {conexus-5.2.0 → conexus-5.3.0}/docs/migration/README.md +0 -0
  42. {conexus-5.2.0 → conexus-5.3.0}/dt/scripts/Index Current Group in nx.applescript +0 -0
  43. {conexus-5.2.0 → conexus-5.3.0}/dt/scripts/Index Selection in nx (Knowledge).applescript +0 -0
  44. {conexus-5.2.0 → conexus-5.3.0}/dt/scripts/Index Selection in nx.applescript +0 -0
  45. {conexus-5.2.0 → conexus-5.3.0}/mcpb/src/server.py +0 -0
  46. {conexus-5.2.0 → conexus-5.3.0}/scripts/cron/README.md +0 -0
  47. {conexus-5.2.0 → conexus-5.3.0}/scripts/launchd/README.md +0 -0
  48. {conexus-5.2.0 → conexus-5.3.0}/sn/README.md +0 -0
  49. {conexus-5.2.0 → conexus-5.3.0}/sn/hooks/scripts/routing/README.md +0 -0
  50. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/__init__.py +0 -0
  51. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/_git_hooks_meta.py +0 -0
  52. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/_locking.py +0 -0
  53. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/_mineru_pid.py +0 -0
  54. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/_session_end_launcher.py +0 -0
  55. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/aspect_extractor.py +0 -0
  56. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/aspect_promotion.py +0 -0
  57. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/aspect_readers.py +0 -0
  58. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/bib_enricher.py +0 -0
  59. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/bib_enricher_openalex.py +0 -0
  60. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/bib_extractor.py +0 -0
  61. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/catalog/AGENTS.md +0 -0
  62. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/catalog/__init__.py +0 -0
  63. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/catalog/auto_linker.py +0 -0
  64. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/catalog/catalog.py +0 -0
  65. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/catalog/catalog_backup.py +0 -0
  66. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/catalog/catalog_db.py +0 -0
  67. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/catalog/catalog_docs.py +0 -0
  68. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/catalog/catalog_git.py +0 -0
  69. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/catalog/catalog_links.py +0 -0
  70. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/catalog/catalog_spans.py +0 -0
  71. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/catalog/catalog_sync.py +0 -0
  72. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/catalog/catalog_writes.py +0 -0
  73. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/catalog/collection_name.py +0 -0
  74. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/catalog/consolidation.py +0 -0
  75. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/catalog/dedupe.py +0 -0
  76. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/catalog/event_log.py +0 -0
  77. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/catalog/events.py +0 -0
  78. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/catalog/link_generator.py +0 -0
  79. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/catalog/manifest_backfill.py +0 -0
  80. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/catalog/orphan_backfill.py +0 -0
  81. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/catalog/projector.py +0 -0
  82. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/catalog/store_hook.py +0 -0
  83. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/catalog/synthesizer.py +0 -0
  84. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/catalog/tumbler.py +0 -0
  85. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/catalog/types.py +0 -0
  86. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/checkpoint.py +0 -0
  87. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/chunker.py +0 -0
  88. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/classifier.py +0 -0
  89. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/cli.py +0 -0
  90. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/code_indexer.py +0 -0
  91. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/collection_audit.py +0 -0
  92. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/collection_health.py +0 -0
  93. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/collection_rename.py +0 -0
  94. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/commands/__init__.py +0 -0
  95. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/commands/_helpers.py +0 -0
  96. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/commands/_migration_prompt.py +0 -0
  97. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/commands/_provision.py +0 -0
  98. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/commands/aspects.py +0 -0
  99. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/commands/catalog.py +0 -0
  100. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/commands/collection.py +0 -0
  101. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/commands/command_context.py +0 -0
  102. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/commands/config_cmd.py +0 -0
  103. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/commands/console.py +0 -0
  104. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/commands/context_cmd.py +0 -0
  105. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/commands/daemon.py +0 -0
  106. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/commands/doc.py +0 -0
  107. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/commands/doctor.py +0 -0
  108. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/commands/dt.py +0 -0
  109. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/commands/enrich.py +0 -0
  110. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/commands/hook.py +0 -0
  111. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/commands/hooks.py +0 -0
  112. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/commands/index.py +0 -0
  113. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/commands/mineru.py +0 -0
  114. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/commands/plan.py +0 -0
  115. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/commands/rdr.py +0 -0
  116. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/commands/scratch.py +0 -0
  117. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/commands/search_cmd.py +0 -0
  118. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/commands/store.py +0 -0
  119. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/commands/t3.py +0 -0
  120. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/commands/taxonomy_cmd.py +0 -0
  121. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/commands/tier_status.py +0 -0
  122. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/commands/upgrade.py +0 -0
  123. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/config.py +0 -0
  124. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/console/__init__.py +0 -0
  125. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/console/app.py +0 -0
  126. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/console/config.py +0 -0
  127. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/console/routes/__init__.py +0 -0
  128. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/console/routes/activity.py +0 -0
  129. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/console/routes/campaigns.py +0 -0
  130. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/console/routes/health.py +0 -0
  131. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/console/routes/partials.py +0 -0
  132. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/console/static/alpine.min.js +0 -0
  133. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/console/static/console.css +0 -0
  134. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/console/static/htmx.min.js +0 -0
  135. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/console/static/pico.min.css +0 -0
  136. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/console/templates/activity/_detail.html +0 -0
  137. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/console/templates/activity/_stream.html +0 -0
  138. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/console/templates/activity/index.html +0 -0
  139. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/console/templates/base.html +0 -0
  140. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/console/templates/campaigns/detail.html +0 -0
  141. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/console/templates/campaigns/index.html +0 -0
  142. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/console/templates/health/_cards.html +0 -0
  143. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/console/templates/health/index.html +0 -0
  144. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/console/watchers.py +0 -0
  145. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/context.py +0 -0
  146. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/corpus.py +0 -0
  147. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/cross_encoder.py +0 -0
  148. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/daemon/T2_DAEMON_WIP.md +0 -0
  149. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/daemon/__init__.py +0 -0
  150. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/daemon/discovery.py +0 -0
  151. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/daemon/t3_client.py +0 -0
  152. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/daemon/t3_daemon.py +0 -0
  153. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/db/AGENTS.md +0 -0
  154. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/db/__init__.py +0 -0
  155. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/db/chroma_quotas.py +0 -0
  156. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/db/local_ef.py +0 -0
  157. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/db/migrations.py +0 -0
  158. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/db/t1.py +0 -0
  159. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/db/t2/_tuning.py +0 -0
  160. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/db/t2/aspect_extraction_queue.py +0 -0
  161. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/db/t2/catalog.py +0 -0
  162. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/db/t2/catalog_taxonomy.py +0 -0
  163. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/db/t2/chash_index.py +0 -0
  164. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/db/t2/document_aspects.py +0 -0
  165. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/db/t2/plan_library.py +0 -0
  166. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/db/t2/telemetry.py +0 -0
  167. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/db/t3.py +0 -0
  168. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/db/t3_reidentify.py +0 -0
  169. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/devonthink.py +0 -0
  170. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/doc/__init__.py +0 -0
  171. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/doc/_common.py +0 -0
  172. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/doc/citations.py +0 -0
  173. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/doc/ref_scanner.py +0 -0
  174. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/doc/render.py +0 -0
  175. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/doc/resolvers.py +0 -0
  176. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/doc/resolvers_corpus.py +0 -0
  177. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/doc/tokens.py +0 -0
  178. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/doc_indexer.py +0 -0
  179. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/doctor_search.py +0 -0
  180. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/dropped_writes.py +0 -0
  181. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/errors.py +0 -0
  182. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/exporter.py +0 -0
  183. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/filters.py +0 -0
  184. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/formatters.py +0 -0
  185. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/frecency.py +0 -0
  186. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/glossary.py +0 -0
  187. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/health.py +0 -0
  188. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/hook_registry.py +0 -0
  189. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/hooks.py +0 -0
  190. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/index_context.py +0 -0
  191. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/indexer.py +0 -0
  192. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/indexer_utils.py +0 -0
  193. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/languages.py +0 -0
  194. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/logging_setup.py +0 -0
  195. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/mcp/AGENTS.md +0 -0
  196. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/mcp/__init__.py +0 -0
  197. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/mcp/_first_run.py +0 -0
  198. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/mcp/_t1_state.py +0 -0
  199. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/mcp/catalog.py +0 -0
  200. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/mcp/core.py +0 -0
  201. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/mcp/plan_cache_registry.py +0 -0
  202. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/mcp_infra.py +0 -0
  203. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/mcp_server.py +0 -0
  204. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/md_chunker.py +0 -0
  205. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/merge_candidates.py +0 -0
  206. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/metadata_schema.py +0 -0
  207. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/name_canaries.py +0 -0
  208. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/operators/aspect_sql.py +0 -0
  209. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/operators/dispatch.py +0 -0
  210. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/pdf_chunker.py +0 -0
  211. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/pdf_extractor.py +0 -0
  212. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/phase_review_sentinel.py +0 -0
  213. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/pipeline_buffer.py +0 -0
  214. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/pipeline_stages.py +0 -0
  215. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/plans/__init__.py +0 -0
  216. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/plans/bundle.py +0 -0
  217. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/plans/loader.py +0 -0
  218. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/plans/match.py +0 -0
  219. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/plans/matcher.py +0 -0
  220. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/plans/promote.py +0 -0
  221. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/plans/purposes.py +0 -0
  222. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/plans/repair.py +0 -0
  223. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/plans/runner.py +0 -0
  224. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/plans/schema.py +0 -0
  225. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/plans/scope.py +0 -0
  226. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/plans/seed_loader.py +0 -0
  227. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/plans/session_cache.py +0 -0
  228. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/prose_indexer.py +0 -0
  229. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/registry.py +0 -0
  230. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/retry.py +0 -0
  231. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/ripgrep_cache.py +0 -0
  232. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/routing_stats.py +0 -0
  233. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/salience.py +0 -0
  234. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/scoring.py +0 -0
  235. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/search_clusterer.py +0 -0
  236. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/search_engine.py +0 -0
  237. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/session.py +0 -0
  238. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/stage_timers.py +0 -0
  239. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/storage_boundary_lint.py +0 -0
  240. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/taxonomy.py +0 -0
  241. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/taxonomy_backfill.py +0 -0
  242. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/ttl.py +0 -0
  243. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/types.py +0 -0
  244. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/util/__init__.py +0 -0
  245. {conexus-5.2.0 → conexus-5.3.0}/src/nexus/util/process_group.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conexus
3
- Version: 5.2.0
3
+ Version: 5.3.0
4
4
  Summary: Self-hosted semantic search and knowledge management for LLM-driven development
5
5
  Project-URL: Homepage, https://github.com/Hellblazer/nexus
6
6
  Project-URL: Repository, https://github.com/Hellblazer/nexus
@@ -146,6 +146,11 @@ An RDR (Research-Design-Review) is a short document that records a technical dec
146
146
  | [RDR-128](rdr-128-t2-single-writer-enforcement.md) | T2 Single-Writer Enforcement: One Owner for memory.db, or an Enforced Lock Discipline | Architecture | Closed 2026-05-25 (implemented, shipped 5.1.0) | 2026-05-25 |
147
147
  | [RDR-129](rdr-129-t2-daemon-serving-path-cross-store-contention.md) | T2 Daemon Write-Path Hardening: Guaranteed-Single-Daemon Enforcement and Contention-Free Internal Serialization | Architecture | Accepted | 2026-05-25 |
148
148
  | [RDR-130](rdr-130-command-preambles-via-nx-cli.md) | Command Preambles via the nx CLI: Thin Commands, Tested Logic, No Inlined Bash | Architecture | Accepted 2026-05-26 | 2026-05-26 |
149
+ | [RDR-131](rdr-131-t2-session-rollup-summaries.md) | T2 Session Rollup Summaries (MemTree-Lite): Recency-Windowed Memory Consolidation for Compact Context Injection | Architecture | Draft | 2026-05-27 |
150
+ | [RDR-132](rdr-132-scope-routed-t1-t2-promotion.md) | Scope-Routed T1 to T2 Promotion: Entity / Session / Project Scopes for Targeted Memory Retrieval | Architecture | Draft | 2026-05-27 |
151
+ | [RDR-133](rdr-133-entity-cluster-cross-tier-aggregation.md) | Entity-Cluster Cross-Tier Aggregation: A First-Class Entity Handle Unifying T2 Memory, T3 Catalog, and T3 Chunks | Architecture | Draft | 2026-05-27 |
152
+ | [RDR-134](rdr-134-taxonomy-aware-recall-in-nx-answer.md) | RDR-070 Phase 5: Taxonomy-Aware Recall in nx_answer — Teach the Composed-Retrieval Path to Read the Taxonomy It Already Has | Architecture | Draft | 2026-05-27 |
153
+ | [RDR-135](rdr-135-windowed-aspect-extraction.md) | Windowed Aspect Extraction with Cross-Window Merge: Stop Whole-Paper Single-Shot Extraction from Degrading on Long Inputs | Architecture | Draft | 2026-05-27 |
149
154
 
150
155
  > **Scrapped 2026-05-19 (RDR-110-119 arc).** Bundled the storage-substrate split with new abstractions (tuplespace, ORB, host-trust, surfaces-as-tuples, UI fabric); scope discipline failed across nine RDRs and 67 stranded beads. Files preserved as tombstones per the "never delete RDR files" rule. Postmortem: [docs/postmortem/2026-05-16-rdr110-113-remediation-chain.md](../postmortem/2026-05-16-rdr110-113-remediation-chain.md). Active substrate work continues as [RDR-120](rdr-120-storage-substrate-split.md) with an explicit moratorium on co-shipped consumers. Numbers RDR-114 through RDR-117 are unused on `main` (drafted on feature branches that never merged).
151
156
 
@@ -1,10 +1,10 @@
1
1
  [project]
2
2
  name = "conexus-mcpb"
3
- version = "5.2.0"
3
+ version = "5.3.0"
4
4
  description = "Conexus packaged as Claude Desktop .mcpb (Desktop Extension)"
5
5
  requires-python = ">=3.12"
6
6
  dependencies = [
7
- "conexus>=5.2.0",
7
+ "conexus>=5.3.0",
8
8
  ]
9
9
 
10
10
  [tool.uv]
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "conexus"
7
- version = "5.2.0"
7
+ version = "5.3.0"
8
8
  description = "Self-hosted semantic search and knowledge management for LLM-driven development"
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  requires-python = ">=3.12,<3.14"
@@ -158,7 +158,7 @@ class AspectExtractionWorker:
158
158
  self,
159
159
  *,
160
160
  poll_interval: float = 2.0,
161
- stale_timeout_seconds: int = 300,
161
+ stale_timeout_seconds: int = 60,
162
162
  batch_size: int = _DEFAULT_BATCH_SIZE,
163
163
  ) -> None:
164
164
  self._poll_interval = poll_interval
@@ -272,10 +272,12 @@ class AspectExtractionWorker:
272
272
  # contending on its single WAL writer lock. This is the
273
273
  # every-2s contention behind the recurring
274
274
  # `aspect_worker_claim_failed` / `database is locked`
275
- # incidents (memory: daemon-restart-not-worker-fix). The
276
- # work-bounded persist below stays direct (t2_ctx) because
277
- # document_aspects.upsert takes an AspectRecord the daemon
278
- # wire protocol cannot round-trip.
275
+ # incidents (memory: daemon-restart-not-worker-fix).
276
+ # nexus-zir76: the persist path (_process_row /
277
+ # _process_batch) now routes too, via the daemon-side
278
+ # `complete_aspect` method (AspectRecord travels as an
279
+ # asdict() field dict). The worker no longer opens
280
+ # memory.db on ANY path.
279
281
  def _poll(t2):
280
282
  if do_reclaim:
281
283
  t2.aspect_queue.reclaim_stale(
@@ -316,8 +318,10 @@ class AspectExtractionWorker:
316
318
  P5.1 / nexus-8g79.34 — the batch path now mirrors the
317
319
  single-doc extractor's read contract).
318
320
  """
321
+ import dataclasses
322
+
319
323
  from nexus.aspect_extractor import select_config
320
- from nexus.mcp_infra import t2_ctx
324
+ from nexus.mcp_infra import t2_index_write
321
325
 
322
326
  # extract_aspects_batch requires every input to share a single
323
327
  # ExtractorConfig. claim_batch grabs FIFO across collections, so
@@ -360,13 +364,14 @@ class AspectExtractionWorker:
360
364
  row_count=len(rows),
361
365
  exc_info=True,
362
366
  )
367
+ # nexus-zir76: route through the daemon, never direct memory.db.
368
+ def _fail_all(db): # noqa: ANN001
369
+ for row in rows:
370
+ db.aspect_queue.mark_failed(
371
+ row.collection, row.source_path, error=str(exc),
372
+ )
363
373
  try:
364
- with t2_ctx() as t2:
365
- for row in rows:
366
- t2.aspect_queue.mark_failed(
367
- row.collection, row.source_path,
368
- error=str(exc),
369
- )
374
+ t2_index_write(_fail_all)
370
375
  except Exception:
371
376
  _log.warning(
372
377
  "aspect_worker_batch_mark_failed_persist_failed",
@@ -374,45 +379,78 @@ class AspectExtractionWorker:
374
379
  )
375
380
  return
376
381
 
377
- try:
378
- with t2_ctx() as t2:
379
- for row, record in zip(rows, records):
380
- if record is None:
381
- # Unsupported collection drop silently.
382
- t2.aspect_queue.mark_done(
383
- row.collection, row.source_path,
384
- )
385
- continue
386
- # nexus-8g79.34: ExtractFail per-row — typed read-
387
- # failure sentinel from URI-based reading. Mark
388
- # queue done so we don't retry an unreadable
389
- # source on every drain; operators re-enqueue
390
- # manually after fixing source identity (mirrors
391
- # _process_row's handling).
392
- if isinstance(record, ExtractFail):
393
- _log.info(
394
- "aspect_worker_batch_extract_skip",
395
- uri=record.uri,
396
- reason=record.reason,
397
- detail=record.detail,
398
- )
399
- t2.aspect_queue.mark_done(
400
- row.collection, row.source_path,
401
- )
402
- continue
403
- t2.document_aspects.upsert(record)
404
- t2.aspect_queue.mark_done(
382
+ # nexus-zir76: one routed write_fn does the whole batch's persist;
383
+ # each row clears via the daemon (``complete_aspect`` /
384
+ # ``mark_done``) instead of a direct memory.db transaction.
385
+ def _persist_all(db): # noqa: ANN001
386
+ for row, record in zip(rows, records):
387
+ if record is None:
388
+ # Unsupported collection — drop silently.
389
+ db.aspect_queue.mark_done(
390
+ row.collection, row.source_path,
391
+ )
392
+ continue
393
+ # nexus-8g79.34: ExtractFail per-row typed read-failure
394
+ # sentinel from URI-based reading. Mark queue done so we
395
+ # don't retry an unreadable source on every drain;
396
+ # operators re-enqueue manually after fixing source
397
+ # identity (mirrors _process_row's handling).
398
+ if isinstance(record, ExtractFail):
399
+ _log.info(
400
+ "aspect_worker_batch_extract_skip",
401
+ uri=record.uri,
402
+ reason=record.reason,
403
+ detail=record.detail,
404
+ )
405
+ db.aspect_queue.mark_done(
405
406
  row.collection, row.source_path,
406
407
  )
408
+ continue
409
+ db.complete_aspect(dataclasses.asdict(record))
410
+ try:
411
+ t2_index_write(_persist_all)
407
412
  except Exception:
408
413
  _log.warning(
409
414
  "aspect_worker_batch_persist_failed",
410
415
  exc_info=True,
411
416
  )
412
417
 
418
+ def _mark_failed_routed(self, row, error: str) -> None:
419
+ """Route a queue ``mark_failed`` through the daemon (nexus-zir76).
420
+
421
+ The failure path must not open ``memory.db`` directly either: a
422
+ direct ``mark_failed`` losing the WAL writer race is exactly what
423
+ orphaned rows ``in_progress`` until the reclaim backstop. If even
424
+ the routed write raises (daemon down AND the direct fallback
425
+ contended), ``reclaim_stale`` recovers the row; we log and move on
426
+ without killing the worker thread.
427
+ """
428
+ from nexus.mcp_infra import t2_index_write
429
+ try:
430
+ t2_index_write(
431
+ lambda db: db.aspect_queue.mark_failed(
432
+ row.collection, row.source_path, error=error,
433
+ )
434
+ )
435
+ except Exception:
436
+ _log.warning(
437
+ "aspect_worker_mark_failed_persist_failed",
438
+ collection=row.collection,
439
+ source_path=row.source_path,
440
+ exc_info=True,
441
+ )
442
+
413
443
  def _process_row(self, row) -> None:
414
- """Run extraction on one queue row and dispatch on the result."""
415
- from nexus.mcp_infra import t2_ctx
444
+ """Run extraction on one queue row and dispatch on the result.
445
+
446
+ nexus-zir76: every persist routes through ``t2_index_write`` (the
447
+ daemon when reachable, a direct fallback when not) so the worker
448
+ never opens ``memory.db`` directly and cannot contend with the
449
+ daemon for the single WAL writer lock.
450
+ """
451
+ import dataclasses
452
+
453
+ from nexus.mcp_infra import t2_index_write
416
454
  try:
417
455
  # Content was captured at enqueue time when in scope (MCP
418
456
  # store_put). For CLI rows where content was not in scope
@@ -451,52 +489,46 @@ class AspectExtractionWorker:
451
489
  source_path=row.source_path,
452
490
  exc_info=True,
453
491
  )
454
- try:
455
- with t2_ctx() as t2:
456
- t2.aspect_queue.mark_failed(
457
- row.collection, row.source_path,
458
- error=str(exc),
459
- )
460
- except Exception:
461
- _log.warning(
462
- "aspect_worker_mark_failed_persist_failed",
463
- exc_info=True,
464
- )
492
+ self._mark_failed_routed(row, str(exc))
465
493
  return
466
494
 
467
495
  try:
468
- with t2_ctx() as t2:
469
- if record is None:
470
- # Unsupported collection — drop silently.
471
- t2.aspect_queue.mark_done(
496
+ if record is None:
497
+ # Unsupported collection — drop silently.
498
+ t2_index_write(
499
+ lambda db: db.aspect_queue.mark_done(
472
500
  row.collection, row.source_path,
473
501
  )
474
- return
475
- # RDR-096 P1.2: ExtractFail is the typed read-failure
476
- # sentinel. No row written; mark queue done so we
477
- # don't retry the unreadable source on every drain.
478
- # Operators can re-enqueue manually after fixing the
479
- # source identity.
480
- if isinstance(record, ExtractFail):
481
- _log.info(
482
- "aspect_worker_extract_skip",
483
- uri=record.uri,
484
- reason=record.reason,
485
- detail=record.detail,
486
- )
487
- t2.aspect_queue.mark_done(
502
+ )
503
+ return
504
+ # RDR-096 P1.2: ExtractFail is the typed read-failure
505
+ # sentinel. No row written; mark queue done so we
506
+ # don't retry the unreadable source on every drain.
507
+ # Operators can re-enqueue manually after fixing the
508
+ # source identity.
509
+ if isinstance(record, ExtractFail):
510
+ _log.info(
511
+ "aspect_worker_extract_skip",
512
+ uri=record.uri,
513
+ reason=record.reason,
514
+ detail=record.detail,
515
+ )
516
+ t2_index_write(
517
+ lambda db: db.aspect_queue.mark_done(
488
518
  row.collection, row.source_path,
489
519
  )
490
- return
491
- # AspectRecord — either a populated record or a null-
492
- # fields record from a subprocess-side failure (the
493
- # extractor already retried up to 3 attempts
494
- # internally for those paths). Persist and remove
495
- # from the queue.
496
- t2.document_aspects.upsert(record)
497
- t2.aspect_queue.mark_done(
498
- row.collection, row.source_path,
499
520
  )
521
+ return
522
+ # AspectRecord — either a populated record or a null-fields
523
+ # record from a subprocess-side failure (the extractor
524
+ # already retried up to 3 attempts internally for those
525
+ # paths). nexus-zir76: persist + clear the queue row in one
526
+ # daemon-routed call (``complete_aspect``) so the worker
527
+ # never writes memory.db directly. asdict() because the wire
528
+ # protocol decodes a dataclass arg to its field dict.
529
+ t2_index_write(
530
+ lambda db: db.complete_aspect(dataclasses.asdict(record))
531
+ )
500
532
  except Exception as exc:
501
533
  _log.warning(
502
534
  "aspect_worker_persist_failed",
@@ -504,17 +536,7 @@ class AspectExtractionWorker:
504
536
  source_path=row.source_path,
505
537
  exc_info=True,
506
538
  )
507
- try:
508
- with t2_ctx() as t2:
509
- t2.aspect_queue.mark_failed(
510
- row.collection, row.source_path,
511
- error=str(exc),
512
- )
513
- except Exception:
514
- _log.warning(
515
- "aspect_worker_mark_failed_secondary_persist_failed",
516
- exc_info=True,
517
- )
539
+ self._mark_failed_routed(row, str(exc))
518
540
 
519
541
 
520
542
  # ── Module-level singleton ──────────────────────────────────────────────────
@@ -545,18 +567,56 @@ def _worker_lock_path(locks_dir: Path | None = None) -> Path:
545
567
  return base / f"aspect_worker.{os.getpid()}"
546
568
 
547
569
 
570
+ def _sweep_dead_worker_locks(locks_dir: Path) -> None:
571
+ """Remove ``aspect_worker.<pid>`` lock files whose PID is dead.
572
+
573
+ nexus-zir76: ``_remove_worker_lock`` only runs on a clean
574
+ ``stop_worker``; a ``-9`` or a crash leaks the file. Over many
575
+ sessions these accumulate unbounded (85 found in the wild on
576
+ 2026-05-27). Sweeping dead-PID locks at worker startup bounds the
577
+ pileup. Live locks (including this process's own) are left intact;
578
+ non-PID-shaped files are ignored. Best-effort — never raises.
579
+ """
580
+ import os
581
+
582
+ if not locks_dir.exists():
583
+ return
584
+ own_pid = os.getpid()
585
+ for lock_file in locks_dir.glob("aspect_worker.*"):
586
+ try:
587
+ pid = int(lock_file.name.rsplit(".", 1)[-1])
588
+ except ValueError:
589
+ continue # not a PID-suffixed lock file
590
+ if pid == own_pid:
591
+ continue
592
+ try:
593
+ os.kill(pid, 0)
594
+ except ProcessLookupError:
595
+ try:
596
+ lock_file.unlink(missing_ok=True)
597
+ _log.info("aspect_worker_stale_lock_swept", pid=pid)
598
+ except Exception:
599
+ pass
600
+ except PermissionError:
601
+ # Alive under another user — leave it.
602
+ continue
603
+
604
+
548
605
  def _write_worker_lock(locks_dir: Path | None = None) -> None:
549
606
  """Write a process-scoped lock file advertising this worker.
550
607
 
551
- Non-fatal if the locks directory cannot be created or the file cannot
552
- be written the lock is advisory; a missing file merely means
553
- ``drain_worker`` from another process will not detect this worker.
608
+ Sweeps dead-PID lock files first (nexus-zir76) so leaked locks from
609
+ crashed/killed predecessors do not accumulate. Non-fatal if the locks
610
+ directory cannot be created or the file cannot be written — the lock
611
+ is advisory; a missing file merely means ``drain_worker`` from another
612
+ process will not detect this worker.
554
613
  """
555
614
  import os
556
615
 
557
616
  try:
558
617
  lock = _worker_lock_path(locks_dir)
559
618
  lock.parent.mkdir(parents=True, exist_ok=True)
619
+ _sweep_dead_worker_locks(lock.parent)
560
620
  lock.write_text(str(os.getpid()))
561
621
  except Exception:
562
622
  _log.warning("aspect_worker_lock_write_failed", exc_info=True)
@@ -576,7 +636,7 @@ def _remove_worker_lock(locks_dir: Path | None = None) -> None:
576
636
  def ensure_worker_started(
577
637
  *,
578
638
  poll_interval: float = 2.0,
579
- stale_timeout_seconds: int = 300,
639
+ stale_timeout_seconds: int = 60,
580
640
  _locks_dir: Path | None = None,
581
641
  ) -> AspectExtractionWorker:
582
642
  """Lazy-start the singleton worker. Returns the worker.
@@ -24,11 +24,39 @@ def memory() -> None:
24
24
  @click.option("--title", "-t", required=True, help="Entry title/filename")
25
25
  @click.option("--tags", default="", help="Comma-separated tags")
26
26
  @click.option("--ttl", default="30d", show_default=True, help="TTL: Nd, Nw, or permanent")
27
- def put_cmd(content: str, project: str, title: str, tags: str, ttl: str) -> None:
27
+ @click.option(
28
+ "--merge",
29
+ is_flag=True,
30
+ default=False,
31
+ help="Canonical-fact merge: fold into an existing high-overlap entry "
32
+ "(non-destructive) instead of inserting a near-duplicate.",
33
+ )
34
+ @click.option(
35
+ "--merge-threshold",
36
+ type=float,
37
+ default=0.5,
38
+ show_default=True,
39
+ help="Word-set Jaccard threshold for --merge.",
40
+ )
41
+ def put_cmd(
42
+ content: str,
43
+ project: str,
44
+ title: str,
45
+ tags: str,
46
+ ttl: str,
47
+ merge: bool,
48
+ merge_threshold: float,
49
+ ) -> None:
28
50
  """Write content to the T2 memory bank.
29
51
 
30
52
  Use '-' as CONTENT to read from stdin.
31
53
 
54
+ With ``--merge`` (bead nexus-lhxz4), the new content is folded into the
55
+ most word-set-overlapping existing entry in the project when overlap is
56
+ at or above ``--merge-threshold`` (non-destructive: both texts are kept),
57
+ instead of inserting a near-duplicate. Without it, the default upsert
58
+ keyed on (project, title) is unchanged.
59
+
32
60
  RDR-120 P6 follow-up (nexus-w6txl): routes through the T2 daemon
33
61
  so host CLI + Cowork-bridged MCP + dev-container CLI all share
34
62
  the same arbitrated state. Requires the T2 daemon running; start
@@ -41,10 +69,19 @@ def put_cmd(content: str, project: str, title: str, tags: str, ttl: str) -> None
41
69
  except ValueError as exc:
42
70
  raise click.ClickException(str(exc)) from exc
43
71
  with t2_handle() as db:
44
- row_id = db.memory.put(
45
- project=project, title=title, content=content, tags=tags, ttl=ttl_days,
46
- )
47
- click.echo(f"Stored: {project}/{title} (id={row_id})")
72
+ if merge:
73
+ row_id, action = db.memory.put_or_merge(
74
+ project=project, title=title, content=content, tags=tags,
75
+ ttl=ttl_days, min_similarity=merge_threshold,
76
+ )
77
+ else:
78
+ row_id = db.memory.put(
79
+ project=project, title=title, content=content, tags=tags,
80
+ ttl=ttl_days,
81
+ )
82
+ action = "inserted"
83
+ verb = "Merged into" if action == "merged" else "Stored"
84
+ click.echo(f"{verb}: {project}/{title} (id={row_id})")
48
85
 
49
86
 
50
87
  @memory.command("get")
@@ -285,6 +285,12 @@ class T2Client:
285
285
  def rename_collection_cascade(self, *args: Any, **kwargs: Any) -> Any:
286
286
  return self.database.rename_collection_cascade(*args, **kwargs)
287
287
 
288
+ def complete_aspect(self, *args: Any, **kwargs: Any) -> Any:
289
+ # nexus-zir76: aspect-worker persist (document_aspects.upsert +
290
+ # aspect_queue.mark_done) folded into one daemon-routable call so
291
+ # the worker stays off the direct memory.db write path.
292
+ return self.database.complete_aspect(*args, **kwargs)
293
+
288
294
  def put(self, *args: Any, **kwargs: Any) -> Any:
289
295
  # T2Database.put is a thin facade over memory.put; mirror it so a
290
296
  # write_fn (or a helper handed the writer, e.g. T1Database.promote)
@@ -433,6 +433,7 @@ _T2_STORE_ATTRS: tuple[str, ...] = (
433
433
  _T2_DATABASE_METHODS: tuple[str, ...] = (
434
434
  "rename_collection_cascade",
435
435
  "expire",
436
+ "complete_aspect",
436
437
  "hello",
437
438
  )
438
439
 
@@ -684,6 +684,28 @@ class T2Database:
684
684
  session=session,
685
685
  )
686
686
 
687
+ def put_or_merge(
688
+ self,
689
+ project: str,
690
+ title: str,
691
+ content: str,
692
+ tags: str = "",
693
+ ttl: int | None = 30,
694
+ agent: str | None = None,
695
+ session: str | None = None,
696
+ min_similarity: float = 0.5,
697
+ ) -> tuple[int, str]:
698
+ return self.memory.put_or_merge(
699
+ project=project,
700
+ title=title,
701
+ content=content,
702
+ tags=tags,
703
+ ttl=ttl,
704
+ agent=agent,
705
+ session=session,
706
+ min_similarity=min_similarity,
707
+ )
708
+
687
709
  def get(
688
710
  self,
689
711
  project: str | None = None,
@@ -961,3 +983,31 @@ class T2Database:
961
983
  **extra,
962
984
  )
963
985
  return len(expired_ids)
986
+
987
+ def complete_aspect(self, record_fields: dict[str, Any]) -> bool:
988
+ """Persist an extracted aspect and clear its queue row in one call.
989
+
990
+ nexus-zir76 (RDR-128 follow-up): the aspect worker previously
991
+ upserted ``document_aspects`` and called ``aspect_queue.mark_done``
992
+ via two DIRECT ``memory.db`` writes, competing with the daemon for
993
+ the single WAL writer lock. When the direct ``mark_done`` (or the
994
+ failure path's ``mark_failed``) lost that race, the row was
995
+ orphaned ``in_progress`` until the ``reclaim_stale`` backstop.
996
+ Folding both writes into one daemon-routable method keeps the
997
+ worker off the direct write path and closes that window.
998
+
999
+ *record_fields* is ``dataclasses.asdict(AspectRecord)`` — a plain
1000
+ JSON-shaped dict, because the daemon wire protocol decodes a
1001
+ dataclass argument to its field dict (it does not reconstruct the
1002
+ object). The ``AspectRecord`` is rebuilt here, server-side.
1003
+
1004
+ Returns the ``document_aspects.upsert`` result. ``mark_done`` is
1005
+ idempotent, so a reclaim-driven re-extraction after a crash
1006
+ between the two writes simply re-upserts — no duplicate, no stuck
1007
+ row.
1008
+ """
1009
+ from nexus.db.t2.document_aspects import AspectRecord
1010
+ record = AspectRecord(**record_fields)
1011
+ upserted = self.document_aspects.upsert(record)
1012
+ self.aspect_queue.mark_done(record.collection, record.source_path)
1013
+ return upserted
@@ -890,6 +890,92 @@ class MemoryStore:
890
890
  delete_ids,
891
891
  )
892
892
 
893
+ def _content_words(self, text: str) -> set[str]:
894
+ """Lowercased word set for Jaccard overlap: drop short tokens and
895
+ stopwords. Shared by write-time merge (``put_or_merge``); mirrors the
896
+ word-set logic inside ``find_overlapping_memories``."""
897
+ return {
898
+ w.lower()
899
+ for w in text.split()
900
+ if len(w) > 2 and w.lower() not in self._STOPWORDS
901
+ }
902
+
903
+ def put_or_merge(
904
+ self,
905
+ project: str,
906
+ title: str,
907
+ content: str,
908
+ tags: str = "",
909
+ ttl: int | None = 30,
910
+ agent: str | None = None,
911
+ session: str | None = None,
912
+ min_similarity: float = 0.5,
913
+ ) -> tuple[int, str]:
914
+ """Opt-in canonical-fact merge at write time (bead nexus-lhxz4).
915
+
916
+ MemForest-inspired (T3 research-memforest-nexus-leverage-2026-05-27,
917
+ idea #1): instead of accumulating near-duplicate entries that later
918
+ need a reactive ``memory_consolidate`` pass, fold the new content into
919
+ the most-overlapping existing entry at write time.
920
+
921
+ Returns ``(row_id, action)`` where ``action`` is ``"inserted"`` or
922
+ ``"merged"``.
923
+
924
+ Merge is **non-destructive**: when ``content``'s word set overlaps an
925
+ existing *different-title* entry in ``project`` at Jaccard
926
+ ``>= min_similarity``, the new content is appended to that entry under
927
+ a provenance separator (both texts preserved), its timestamp is
928
+ refreshed, and ``(existing_id, "merged")`` is returned — the requested
929
+ ``title`` is not created. Otherwise a normal upsert runs and
930
+ ``(row_id, "inserted")`` is returned.
931
+
932
+ Exact ``(project, title)`` collisions always take the normal upsert
933
+ path (identity update), never the cross-title merge. Empty content
934
+ (no word set) always inserts.
935
+
936
+ Selection is best-overlap-wins across the project's entries. There is
937
+ a benign TOCTOU window between the scan and the merge UPDATE (an entry
938
+ could change concurrently); accepted because the merge is opt-in and
939
+ non-destructive, mirroring ``merge_memories``' race posture.
940
+ """
941
+ new_words = self._content_words(content)
942
+ if new_words:
943
+ best_id: int | None = None
944
+ best_jaccard = 0.0
945
+ best_content = ""
946
+ for entry in self.get_all(project):
947
+ if entry.get("title") == title:
948
+ continue # identity upsert, not a cross-title merge
949
+ existing_words = self._content_words(entry.get("content", ""))
950
+ if not existing_words:
951
+ continue
952
+ jaccard = len(new_words & existing_words) / len(
953
+ new_words | existing_words
954
+ )
955
+ if jaccard > best_jaccard:
956
+ best_jaccard = jaccard
957
+ best_id = entry["id"]
958
+ best_content = entry.get("content", "")
959
+ if best_id is not None and best_jaccard >= min_similarity:
960
+ timestamp = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
961
+ merged = (
962
+ f"{best_content}\n\n"
963
+ f"<!-- merged from {title!r} @ {timestamp} "
964
+ f"(jaccard={best_jaccard:.2f}) -->\n{content}"
965
+ )
966
+ with self._lock, self.conn:
967
+ self.conn.execute(
968
+ "UPDATE memory SET content = ?, timestamp = ? "
969
+ "WHERE id = ?",
970
+ (merged, timestamp, best_id),
971
+ )
972
+ return best_id, "merged"
973
+ row_id = self.put(
974
+ project, title, content,
975
+ tags=tags, ttl=ttl, agent=agent, session=session,
976
+ )
977
+ return row_id, "inserted"
978
+
893
979
  def flag_stale_memories(
894
980
  self,
895
981
  project: str,
File without changes
File without changes
File without changes
File without changes
File without changes