apsimo 1.3.0__py3-none-any.whl

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 (614) hide show
  1. apsimo/__init__.py +38 -0
  2. apsimo/__main__.py +6 -0
  3. apsimo/agent/__init__.py +6 -0
  4. apsimo/agent/client.py +276 -0
  5. apsimo/agent/models.py +46 -0
  6. apsimo/agents/__init__.py +20 -0
  7. apsimo/agents/models.py +264 -0
  8. apsimo/agents/store.py +861 -0
  9. apsimo/agents/websocket.py +522 -0
  10. apsimo/api/__init__.py +1 -0
  11. apsimo/api/auth_telemetry.py +287 -0
  12. apsimo/api/authority.py +1203 -0
  13. apsimo/api/contact_grants.py +347 -0
  14. apsimo/api/middleware.py +483 -0
  15. apsimo/api/routers/__init__.py +1 -0
  16. apsimo/api/routers/commitment_work.py +265 -0
  17. apsimo/api/routers/context_gate.py +123 -0
  18. apsimo/api/routers/executions.py +140 -0
  19. apsimo/api/routers/followup_plans.py +147 -0
  20. apsimo/api/routers/governed_actions.py +162 -0
  21. apsimo/api/routers/host.py +14473 -0
  22. apsimo/api/routers/initiative_work.py +115 -0
  23. apsimo/api/routers/mining.py +104 -0
  24. apsimo/api/routers/observations.py +110 -0
  25. apsimo/api/routers/social_state.py +225 -0
  26. apsimo/api/routers/task_queue.py +2715 -0
  27. apsimo/api/routers/temporal_followups.py +251 -0
  28. apsimo/api/routers/transport.py +110 -0
  29. apsimo/api/routers/transport_ingress_api.py +240 -0
  30. apsimo/api/schemas/__init__.py +1 -0
  31. apsimo/api/schemas/host.py +1949 -0
  32. apsimo/autonomy/cli.py +110 -0
  33. apsimo/autonomy/condition_worker.py +437 -0
  34. apsimo/autonomy/config.py +424 -0
  35. apsimo/autonomy/loop.py +4316 -0
  36. apsimo/autonomy/registry.py +339 -0
  37. apsimo/autonomy/scheduler.py +1822 -0
  38. apsimo/autonomy/synthesis.py +449 -0
  39. apsimo/backup.py +962 -0
  40. apsimo/beliefs/__init__.py +23 -0
  41. apsimo/beliefs/contradictions.py +109 -0
  42. apsimo/beliefs/decay.py +61 -0
  43. apsimo/beliefs/engine.py +479 -0
  44. apsimo/beliefs/models.py +67 -0
  45. apsimo/beliefs/promotion.py +41 -0
  46. apsimo/beliefs/resolve.py +58 -0
  47. apsimo/beliefs/source_claims.py +690 -0
  48. apsimo/beliefs/source_projection.py +883 -0
  49. apsimo/beliefs/source_time.py +208 -0
  50. apsimo/beliefs/store.py +133 -0
  51. apsimo/briefings/aggregators.py +824 -0
  52. apsimo/briefings/composer.py +420 -0
  53. apsimo/briefings/config.py +55 -0
  54. apsimo/briefings/delivery.py +439 -0
  55. apsimo/briefings/engagement.py +97 -0
  56. apsimo/briefings/engine.py +274 -0
  57. apsimo/briefings/enhancer.py +99 -0
  58. apsimo/briefings/models.py +183 -0
  59. apsimo/briefings/scheduler.py +382 -0
  60. apsimo/briefings/store.py +435 -0
  61. apsimo/chain/__init__.py +48 -0
  62. apsimo/chain/block.py +100 -0
  63. apsimo/chain/cli.py +704 -0
  64. apsimo/chain/genesis.py +443 -0
  65. apsimo/chain/identity.py +416 -0
  66. apsimo/chain/keys.py +1025 -0
  67. apsimo/chain/local_keys.py +187 -0
  68. apsimo/chain/manager.py +290 -0
  69. apsimo/chain/node.py +163 -0
  70. apsimo/chain/plugin_transactions.py +371 -0
  71. apsimo/chain/protocol.py +220 -0
  72. apsimo/chain/state_machine.py +676 -0
  73. apsimo/chain/storage.py +503 -0
  74. apsimo/chain/transactions.py +250 -0
  75. apsimo/chain/validation.py +397 -0
  76. apsimo/channels/__init__.py +1 -0
  77. apsimo/channels/manifest.py +31 -0
  78. apsimo/channels/migrations/001_channels_schema.sql +12 -0
  79. apsimo/channels/phone_gateways.py +42 -0
  80. apsimo/channels/presence.py +188 -0
  81. apsimo/channels/router.py +235 -0
  82. apsimo/channels/store.py +231 -0
  83. apsimo/cli.py +2688 -0
  84. apsimo/cognition/__init__.py +11 -0
  85. apsimo/cognition/charter.py +398 -0
  86. apsimo/cognition/drive_governance.py +3530 -0
  87. apsimo/cognition/evidence_pipeline.py +1627 -0
  88. apsimo/cognition/external_events.py +932 -0
  89. apsimo/cognition/goal_spine.py +3488 -0
  90. apsimo/cognition/introspection.py +214 -0
  91. apsimo/cognition/prompt.py +150 -0
  92. apsimo/cognition/runtime.py +108 -0
  93. apsimo/cognition/trigger.py +154 -0
  94. apsimo/commitments/__init__.py +18 -0
  95. apsimo/commitments/local_work.py +355 -0
  96. apsimo/commitments/store.py +1052 -0
  97. apsimo/commitments/work.py +91 -0
  98. apsimo/compat.py +53 -0
  99. apsimo/compression/__init__.py +467 -0
  100. apsimo/connectors/__init__.py +21 -0
  101. apsimo/connectors/base.py +152 -0
  102. apsimo/connectors/caldav_calendar.py +125 -0
  103. apsimo/connectors/fs_documents.py +85 -0
  104. apsimo/connectors/imap_email.py +138 -0
  105. apsimo/connectors/manager.py +218 -0
  106. apsimo/connectors/webhook_pull.py +88 -0
  107. apsimo/contacts/__init__.py +33 -0
  108. apsimo/contacts/comms.py +357 -0
  109. apsimo/contacts/config.py +79 -0
  110. apsimo/contacts/exporters/__init__.py +1 -0
  111. apsimo/contacts/exporters/vcard.py +71 -0
  112. apsimo/contacts/identity_links.py +251 -0
  113. apsimo/contacts/importer.py +280 -0
  114. apsimo/contacts/importers/__init__.py +1 -0
  115. apsimo/contacts/importers/batch.py +43 -0
  116. apsimo/contacts/importers/macos_contacts.py +101 -0
  117. apsimo/contacts/migrations/001_contacts_schema.sql +141 -0
  118. apsimo/contacts/migrations/002_trust_scopes.sql +36 -0
  119. apsimo/contacts/migrations/003_open_gateway_enum.sql +32 -0
  120. apsimo/contacts/migrations/004_contact_provision_operations.sql +18 -0
  121. apsimo/contacts/migrations/005_identity_links.sql +27 -0
  122. apsimo/contacts/models.py +308 -0
  123. apsimo/contacts/scoring.py +16 -0
  124. apsimo/contacts/store.py +1623 -0
  125. apsimo/contacts/transport_ingress.py +252 -0
  126. apsimo/contacts/world_bridge.py +314 -0
  127. apsimo/contextgate/__init__.py +69 -0
  128. apsimo/contextgate/chunker.py +169 -0
  129. apsimo/contextgate/estimate.py +54 -0
  130. apsimo/contextgate/gate.py +313 -0
  131. apsimo/contextgate/retrieve.py +115 -0
  132. apsimo/delivery/__init__.py +16 -0
  133. apsimo/delivery/bridge.py +1260 -0
  134. apsimo/delivery/channels.py +526 -0
  135. apsimo/delivery/classification.py +50 -0
  136. apsimo/delivery/rate_limiter.py +268 -0
  137. apsimo/delivery/reachout_policy.py +206 -0
  138. apsimo/directed/__init__.py +22 -0
  139. apsimo/directed/audit.py +167 -0
  140. apsimo/directed/intake.py +95 -0
  141. apsimo/directed/models.py +191 -0
  142. apsimo/directed/service.py +509 -0
  143. apsimo/directives/__init__.py +25 -0
  144. apsimo/directives/evidence.py +87 -0
  145. apsimo/directives/extractor.py +188 -0
  146. apsimo/directives/guard.py +364 -0
  147. apsimo/directives/models.py +206 -0
  148. apsimo/directives/service.py +372 -0
  149. apsimo/directives/store.py +167 -0
  150. apsimo/doctor.py +2173 -0
  151. apsimo/environment.py +43 -0
  152. apsimo/events/__init__.py +33 -0
  153. apsimo/events/broadcaster.py +98 -0
  154. apsimo/events/bus.py +217 -0
  155. apsimo/events/journal.py +863 -0
  156. apsimo/events/stream.py +131 -0
  157. apsimo/events/types.py +150 -0
  158. apsimo/execution_results.py +357 -0
  159. apsimo/feedback/__init__.py +5 -0
  160. apsimo/feedback/store.py +76 -0
  161. apsimo/feeds/__init__.py +19 -0
  162. apsimo/feeds/cli.py +84 -0
  163. apsimo/feeds/engine.py +437 -0
  164. apsimo/feeds/example-feed.yaml +77 -0
  165. apsimo/feeds/hermes_cron.py +126 -0
  166. apsimo/feeds/manager.py +235 -0
  167. apsimo/feeds/spec.py +250 -0
  168. apsimo/feeds/template.py +202 -0
  169. apsimo/gate/__init__.py +18 -0
  170. apsimo/gate/audit.py +61 -0
  171. apsimo/gate/communication_policy.py +166 -0
  172. apsimo/gate/config.py +72 -0
  173. apsimo/gate/context_provenance.py +170 -0
  174. apsimo/gate/env_risk.py +226 -0
  175. apsimo/gate/guard_audit.py +353 -0
  176. apsimo/gate/layers/__init__.py +1 -0
  177. apsimo/gate/layers/base.py +15 -0
  178. apsimo/gate/layers/l1_recipient.py +66 -0
  179. apsimo/gate/layers/l2_pii.py +134 -0
  180. apsimo/gate/layers/l3_cross_context.py +50 -0
  181. apsimo/gate/layers/l4_trust_tier.py +78 -0
  182. apsimo/gate/layers/l5_injection.py +199 -0
  183. apsimo/gate/layers/l6_review.py +86 -0
  184. apsimo/gate/layers/l7_delay.py +100 -0
  185. apsimo/gate/layers/tom2_epistemic.py +185 -0
  186. apsimo/gate/models.py +64 -0
  187. apsimo/gate/pending_dispatch.py +5 -0
  188. apsimo/gate/pipeline.py +206 -0
  189. apsimo/gate/rejection.py +259 -0
  190. apsimo/gate/response_guard.py +700 -0
  191. apsimo/gate/rulesets/injection_v1.yaml +51 -0
  192. apsimo/gate/surface_policy.py +189 -0
  193. apsimo/gate/taint.py +226 -0
  194. apsimo/genesis.json +9 -0
  195. apsimo/goals/__init__.py +100 -0
  196. apsimo/goals/config.py +38 -0
  197. apsimo/goals/decomposer.py +421 -0
  198. apsimo/goals/engine.py +617 -0
  199. apsimo/goals/inference.py +354 -0
  200. apsimo/goals/models.py +302 -0
  201. apsimo/goals/priority.py +270 -0
  202. apsimo/goals/queue_bridge.py +149 -0
  203. apsimo/goals/replan.py +450 -0
  204. apsimo/goals/schema.sql +89 -0
  205. apsimo/goals/store.py +692 -0
  206. apsimo/governed_actions.py +1708 -0
  207. apsimo/harness_integration/__init__.py +45 -0
  208. apsimo/harness_integration/context.py +41 -0
  209. apsimo/harness_integration/skills.py +231 -0
  210. apsimo/identity/__init__.py +26 -0
  211. apsimo/identity/participants.py +181 -0
  212. apsimo/identity/resolver.py +329 -0
  213. apsimo/identity_bootstrap/__init__.py +5 -0
  214. apsimo/identity_bootstrap/builder.py +208 -0
  215. apsimo/identity_bootstrap/corpus.py +443 -0
  216. apsimo/identity_bootstrap/models.py +54 -0
  217. apsimo/identity_bootstrap/runner.py +353 -0
  218. apsimo/identity_bootstrap/seeders/__init__.py +25 -0
  219. apsimo/identity_bootstrap/seeders/briefings.py +109 -0
  220. apsimo/identity_bootstrap/seeders/chain.py +57 -0
  221. apsimo/identity_bootstrap/seeders/goals.py +128 -0
  222. apsimo/identity_bootstrap/seeders/memory.py +191 -0
  223. apsimo/identity_bootstrap/seeders/neo4j_cognition.py +79 -0
  224. apsimo/identity_bootstrap/seeders/relationship.py +152 -0
  225. apsimo/identity_bootstrap/seeders/sessions.py +67 -0
  226. apsimo/identity_bootstrap/seeders/skills.py +92 -0
  227. apsimo/identity_bootstrap/seeders/task_queue.py +72 -0
  228. apsimo/identity_bootstrap/seeders/world_model.py +143 -0
  229. apsimo/identity_bootstrap/self_query.py +92 -0
  230. apsimo/identity_bootstrap/self_reflection.py +155 -0
  231. apsimo/identity_bootstrap/skill.py +37 -0
  232. apsimo/identity_bootstrap/verifier.py +436 -0
  233. apsimo/initiatives/__init__.py +20 -0
  234. apsimo/initiatives/action_registry.py +454 -0
  235. apsimo/initiatives/approval_authority.py +2105 -0
  236. apsimo/initiatives/approval_policy.py +123 -0
  237. apsimo/initiatives/assignment.py +263 -0
  238. apsimo/initiatives/backup_evidence.py +100 -0
  239. apsimo/initiatives/context_freshness.py +103 -0
  240. apsimo/initiatives/models.py +318 -0
  241. apsimo/initiatives/native_work.py +270 -0
  242. apsimo/initiatives/standing_approvals.py +232 -0
  243. apsimo/initiatives/store.py +1081 -0
  244. apsimo/initiatives/temporal_followup.py +410 -0
  245. apsimo/intelligence/__init__.py +1 -0
  246. apsimo/intelligence/cognition/__init__.py +24 -0
  247. apsimo/intelligence/cognition/gap_detector.py +148 -0
  248. apsimo/intelligence/cognition/metalearner.py +547 -0
  249. apsimo/intelligence/cognition/metrics_collector.py +217 -0
  250. apsimo/intelligence/cognition/performance_index.py +299 -0
  251. apsimo/intelligence/cognition/registry.py +192 -0
  252. apsimo/intelligence/cognition/strategy_adjuster.py +222 -0
  253. apsimo/intelligence/cognition/types.py +16 -0
  254. apsimo/intelligence/components/__init__.py +66 -0
  255. apsimo/intelligence/components/anomaly_detector.py +413 -0
  256. apsimo/intelligence/components/initiative_engine.py +2643 -0
  257. apsimo/intelligence/components/preference_learner.py +521 -0
  258. apsimo/intelligence/components/research_orchestrator.py +358 -0
  259. apsimo/intelligence/components/self_directed_thinker.py +221 -0
  260. apsimo/intelligence/components/self_reflector.py +252 -0
  261. apsimo/intelligence/components/session_continuity.py +154 -0
  262. apsimo/intelligence/components/task_planner.py +320 -0
  263. apsimo/intelligence/components/tool_learner.py +217 -0
  264. apsimo/intelligence/graph/__init__.py +79 -0
  265. apsimo/intelligence/graph/client.py +2483 -0
  266. apsimo/intelligence/graph/consolidator.py +405 -0
  267. apsimo/intelligence/graph/distiller.py +312 -0
  268. apsimo/intelligence/graph/migrations.py +129 -0
  269. apsimo/intelligence/graph/queries.py +248 -0
  270. apsimo/intelligence/graph/recall.py +281 -0
  271. apsimo/intelligence/graph/reconciler.py +144 -0
  272. apsimo/intelligence/graph/schema.py +337 -0
  273. apsimo/intelligence/graph/selection.py +252 -0
  274. apsimo/intelligence/learning/__init__.py +17 -0
  275. apsimo/intelligence/learning/continuous_learner.py +245 -0
  276. apsimo/intelligence/learning/feedback_store.py +321 -0
  277. apsimo/intelligence/mind_model/__init__.py +1 -0
  278. apsimo/intelligence/mind_model/graph_baseline.py +136 -0
  279. apsimo/intelligence/mind_model/signal_collector.py +361 -0
  280. apsimo/intelligence/relationships/__init__.py +11 -0
  281. apsimo/intelligence/relationships/profiler.py +389 -0
  282. apsimo/intelligence/relationships/scorer.py +560 -0
  283. apsimo/intelligence/relationships/signal_floor.py +66 -0
  284. apsimo/intelligence/relationships/trust_tiers.py +300 -0
  285. apsimo/intelligence/synthesis/__init__.py +40 -0
  286. apsimo/intelligence/synthesis/connection_discoverer.py +379 -0
  287. apsimo/intelligence/synthesis/cross_domain_analyzer.py +287 -0
  288. apsimo/intelligence/synthesis/insight_deliverer.py +171 -0
  289. apsimo/intelligence/synthesis/insight_store.py +79 -0
  290. apsimo/intelligence/synthesis/insight_validator.py +183 -0
  291. apsimo/intelligence/synthesis/novelty_scorer.py +267 -0
  292. apsimo/intelligence/turn_middleware/__init__.py +15 -0
  293. apsimo/intelligence/turn_middleware/memory_sync.py +119 -0
  294. apsimo/mcp/__init__.py +41 -0
  295. apsimo/mcp/__main__.py +6 -0
  296. apsimo/mcp/config.py +287 -0
  297. apsimo/mcp/server.py +501 -0
  298. apsimo/migrations.py +187 -0
  299. apsimo/mining/__init__.py +27 -0
  300. apsimo/mining/corpus.py +239 -0
  301. apsimo/mining/escalations.py +289 -0
  302. apsimo/mining/models.py +169 -0
  303. apsimo/mining/store.py +210 -0
  304. apsimo/models/__init__.py +30 -0
  305. apsimo/models/memory.py +80 -0
  306. apsimo/models/mesh.py +72 -0
  307. apsimo/models/person.py +104 -0
  308. apsimo/models/signal.py +108 -0
  309. apsimo/observations/__init__.py +15 -0
  310. apsimo/observations/store.py +277 -0
  311. apsimo/patterns/__init__.py +6 -0
  312. apsimo/patterns/extract.py +187 -0
  313. apsimo/patterns/store.py +227 -0
  314. apsimo/persona/__init__.py +1 -0
  315. apsimo/persona/engine.py +611 -0
  316. apsimo/persona/manifest.py +140 -0
  317. apsimo/projects/__init__.py +28 -0
  318. apsimo/projects/engine.py +1681 -0
  319. apsimo/projects/event_outbox.py +188 -0
  320. apsimo/projects/models.py +216 -0
  321. apsimo/projects/planner.py +181 -0
  322. apsimo/projects/store.py +1446 -0
  323. apsimo/proposals/__init__.py +12 -0
  324. apsimo/proposals/engine.py +114 -0
  325. apsimo/proposals/models.py +207 -0
  326. apsimo/qualification/__init__.py +1 -0
  327. apsimo/qualification/cases.py +75 -0
  328. apsimo/qualification/cli.py +51 -0
  329. apsimo/qualification/memory_cases.py +209 -0
  330. apsimo/qualification/records.py +92 -0
  331. apsimo/qualification/report.py +87 -0
  332. apsimo/qualification/runner.py +311 -0
  333. apsimo/qualification/structured_cases.py +131 -0
  334. apsimo/reasoning/__init__.py +13 -0
  335. apsimo/reasoning/executor.py +506 -0
  336. apsimo/reasoning/loop.py +373 -0
  337. apsimo/reasoning/native_tools/__init__.py +16 -0
  338. apsimo/reasoning/native_tools/calculate.py +141 -0
  339. apsimo/reasoning/native_tools/file_ops.py +150 -0
  340. apsimo/reasoning/native_tools/web_search.py +49 -0
  341. apsimo/reasoning/tool_policy.py +182 -0
  342. apsimo/redact/__init__.py +176 -0
  343. apsimo/repos/__init__.py +5 -0
  344. apsimo/repos/mirrors.py +204 -0
  345. apsimo/research/__init__.py +41 -0
  346. apsimo/research/artifact.py +482 -0
  347. apsimo/research/gatherer.py +387 -0
  348. apsimo/research/pipeline.py +513 -0
  349. apsimo/research/search/__init__.py +7 -0
  350. apsimo/research/search/base.py +41 -0
  351. apsimo/research/search/brave.py +59 -0
  352. apsimo/research/search/cache.py +51 -0
  353. apsimo/research/search/duckduckgo.py +103 -0
  354. apsimo/research/search/orchestrator.py +119 -0
  355. apsimo/research/search/serpapi.py +59 -0
  356. apsimo/research/search/tavily.py +59 -0
  357. apsimo/research/synthesizer.py +309 -0
  358. apsimo/router/__init__.py +30 -0
  359. apsimo/router/complexity_scorer.py +148 -0
  360. apsimo/router/endpoints.py +153 -0
  361. apsimo/router/fallback.py +58 -0
  362. apsimo/router/functions.py +243 -0
  363. apsimo/router/native_policy.py +52 -0
  364. apsimo/router/router.py +762 -0
  365. apsimo/router/self_learning.py +174 -0
  366. apsimo/router/tiers.py +677 -0
  367. apsimo/sandbox/__init__.py +21 -0
  368. apsimo/sandbox/backend.py +195 -0
  369. apsimo/sandbox/manager.py +173 -0
  370. apsimo/scope_bounds.py +7 -0
  371. apsimo/secrets/__init__.py +6 -0
  372. apsimo/secrets/backends/__init__.py +8 -0
  373. apsimo/secrets/backends/base.py +42 -0
  374. apsimo/secrets/backends/env.py +110 -0
  375. apsimo/secrets/backends/keyring.py +72 -0
  376. apsimo/secrets/backends/onepassword.py +232 -0
  377. apsimo/secrets/cli.py +191 -0
  378. apsimo/secrets/manager.py +160 -0
  379. apsimo/secrets/migration.py +101 -0
  380. apsimo/secrets/types.py +98 -0
  381. apsimo/seed.py +41 -0
  382. apsimo/self_model/__init__.py +37 -0
  383. apsimo/self_model/appraisals.py +673 -0
  384. apsimo/self_model/benchmark.py +1314 -0
  385. apsimo/self_model/brief.py +40 -0
  386. apsimo/self_model/event_concerns.py +1128 -0
  387. apsimo/self_model/execution_forecasts.py +353 -0
  388. apsimo/self_model/expectations.py +1595 -0
  389. apsimo/self_model/experiments.py +1150 -0
  390. apsimo/self_model/journal.py +148 -0
  391. apsimo/self_model/judgments.py +705 -0
  392. apsimo/self_model/native_outcomes.py +55 -0
  393. apsimo/self_model/params.py +220 -0
  394. apsimo/self_model/perspective.py +246 -0
  395. apsimo/self_model/reconcile.py +183 -0
  396. apsimo/self_model/reply_forecasts.py +381 -0
  397. apsimo/self_model/runtime_forecasts.py +296 -0
  398. apsimo/self_model/runtime_models.py +67 -0
  399. apsimo/self_model/settlement.py +207 -0
  400. apsimo/self_model/situation.py +1731 -0
  401. apsimo/self_model/store.py +883 -0
  402. apsimo/self_model/supervised.py +137 -0
  403. apsimo/self_model/thinker.py +99 -0
  404. apsimo/self_model/trust.py +388 -0
  405. apsimo/self_model/workspace.py +2388 -0
  406. apsimo/server.py +4197 -0
  407. apsimo/services/__init__.py +1 -0
  408. apsimo/services/agent_bridge.py +474 -0
  409. apsimo/services/initiative_executor.py +914 -0
  410. apsimo/services/instance.py +297 -0
  411. apsimo/sessions/__init__.py +22 -0
  412. apsimo/sessions/config.py +13 -0
  413. apsimo/sessions/context_loader.py +88 -0
  414. apsimo/sessions/federation_session.py +75 -0
  415. apsimo/sessions/isolated_session.py +98 -0
  416. apsimo/sessions/reports.py +84 -0
  417. apsimo/sessions/store.py +148 -0
  418. apsimo/setup.py +2818 -0
  419. apsimo/setup_hermes.py +879 -0
  420. apsimo/setup_local_work.py +218 -0
  421. apsimo/setup_native_goals.py +134 -0
  422. apsimo/setup_native_reviews.py +115 -0
  423. apsimo/skills/__init__.py +10 -0
  424. apsimo/skills/base.py +108 -0
  425. apsimo/skills/budget.py +28 -0
  426. apsimo/skills/executor.py +493 -0
  427. apsimo/skills/executors/__init__.py +1 -0
  428. apsimo/skills/executors/behavioral_correction.py +75 -0
  429. apsimo/skills/executors/capability_gap.py +38 -0
  430. apsimo/skills/executors/data_quality.py +163 -0
  431. apsimo/skills/executors/knowledge_acquisition.py +41 -0
  432. apsimo/skills/executors/operational_hygiene.py +185 -0
  433. apsimo/skills/executors/subsystem_health.py +169 -0
  434. apsimo/skills/hermes_export.py +431 -0
  435. apsimo/skills/index.py +123 -0
  436. apsimo/skills/learning/__init__.py +21 -0
  437. apsimo/skills/learning/novelty_detector.py +206 -0
  438. apsimo/skills/learning/pattern_extractor.py +199 -0
  439. apsimo/skills/learning/triggers.py +159 -0
  440. apsimo/skills/loader.py +246 -0
  441. apsimo/skills/migrations/002_progressive_loading.sql +6 -0
  442. apsimo/skills/migrations/backfill_triggers.py +20 -0
  443. apsimo/skills/models.py +202 -0
  444. apsimo/skills/packager.py +128 -0
  445. apsimo/skills/protocols.py +70 -0
  446. apsimo/skills/registry.py +191 -0
  447. apsimo/skills/runtime.py +58 -0
  448. apsimo/skills/sandbox_runner.py +229 -0
  449. apsimo/skills/scheduler.py +129 -0
  450. apsimo/skills/schema.py +79 -0
  451. apsimo/skills/security/__init__.py +12 -0
  452. apsimo/skills/security/guards.py +53 -0
  453. apsimo/skills/security/scanner.py +223 -0
  454. apsimo/skills_memory/__init__.py +26 -0
  455. apsimo/skills_memory/distill.py +159 -0
  456. apsimo/skills_memory/models.py +85 -0
  457. apsimo/skills_memory/retrieve.py +62 -0
  458. apsimo/skills_memory/store.py +172 -0
  459. apsimo/surprise/__init__.py +6 -0
  460. apsimo/surprise/accumulation.py +57 -0
  461. apsimo/surprise/scorer.py +102 -0
  462. apsimo/surprise/store.py +203 -0
  463. apsimo/task_queue/__init__.py +69 -0
  464. apsimo/task_queue/action_receipts.py +148 -0
  465. apsimo/task_queue/approval_relay_canary.py +108 -0
  466. apsimo/task_queue/config.py +85 -0
  467. apsimo/task_queue/contract.py +361 -0
  468. apsimo/task_queue/events.py +130 -0
  469. apsimo/task_queue/governor.py +1031 -0
  470. apsimo/task_queue/handlers/__init__.py +16 -0
  471. apsimo/task_queue/handlers/base.py +37 -0
  472. apsimo/task_queue/handlers/inference.py +640 -0
  473. apsimo/task_queue/handlers/monitoring.py +116 -0
  474. apsimo/task_queue/handlers/registry.py +75 -0
  475. apsimo/task_queue/handlers/subtask_handler.py +173 -0
  476. apsimo/task_queue/handlers/system_maintenance.py +147 -0
  477. apsimo/task_queue/mesh_integration.py +111 -0
  478. apsimo/task_queue/models.py +317 -0
  479. apsimo/task_queue/queue_manager.py +8286 -0
  480. apsimo/task_queue/routing.py +287 -0
  481. apsimo/task_queue/scheduler.py +252 -0
  482. apsimo/task_queue/schema.sql +197 -0
  483. apsimo/task_queue/work_control.py +342 -0
  484. apsimo/task_queue/worker.py +993 -0
  485. apsimo/telemetry.py +145 -0
  486. apsimo/tom/__init__.py +6 -0
  487. apsimo/tom/affect.py +387 -0
  488. apsimo/tom/approvals.py +171 -0
  489. apsimo/tom/arcs.py +896 -0
  490. apsimo/tom/asymmetry.py +131 -0
  491. apsimo/tom/eligibility.py +248 -0
  492. apsimo/tom/engagement.py +214 -0
  493. apsimo/tom/exposure.py +214 -0
  494. apsimo/tom/extractor.py +306 -0
  495. apsimo/tom/fact_adapters.py +144 -0
  496. apsimo/tom/facts.py +326 -0
  497. apsimo/tom/integration.py +592 -0
  498. apsimo/tom/leveled.py +118 -0
  499. apsimo/tom/levels.py +247 -0
  500. apsimo/tom/recipient_audit.py +995 -0
  501. apsimo/tom/recipient_simulator.py +593 -0
  502. apsimo/tom/source_lineage.py +93 -0
  503. apsimo/tom/tom2.py +277 -0
  504. apsimo/tom/visibility.py +559 -0
  505. apsimo/tom/visibility_store.py +414 -0
  506. apsimo/tools/__init__.py +0 -0
  507. apsimo/tools/definitions.py +740 -0
  508. apsimo/tools/handlers.py +943 -0
  509. apsimo/toolsmith/__init__.py +26 -0
  510. apsimo/toolsmith/authority.py +166 -0
  511. apsimo/toolsmith/engine.py +559 -0
  512. apsimo/toolsmith/integrity.py +100 -0
  513. apsimo/toolsmith/miner.py +145 -0
  514. apsimo/toolsmith/policy.py +110 -0
  515. apsimo/toolsmith/registry.py +635 -0
  516. apsimo/turns/__init__.py +17 -0
  517. apsimo/turns/audio.py +134 -0
  518. apsimo/turns/documents.py +235 -0
  519. apsimo/turns/executions.py +486 -0
  520. apsimo/turns/hermes_history.py +245 -0
  521. apsimo/turns/hermes_kanban.py +268 -0
  522. apsimo/turns/hermes_work.py +96 -0
  523. apsimo/turns/idempotency.py +752 -0
  524. apsimo/turns/local_work.py +115 -0
  525. apsimo/turns/media.py +581 -0
  526. apsimo/turns/reported_workers.py +196 -0
  527. apsimo/turns/source_annotations.py +283 -0
  528. apsimo/turns/source_attribution.py +154 -0
  529. apsimo/turns/source_read.py +351 -0
  530. apsimo/turns/source_vectors.py +263 -0
  531. apsimo/turns/video.py +210 -0
  532. apsimo/util/autonomy_preset.py +220 -0
  533. apsimo/util/instance.py +92 -0
  534. apsimo/util/model_output.py +25 -0
  535. apsimo/util/quiet_hours.py +27 -0
  536. apsimo/util/session_safety.py +37 -0
  537. apsimo/util/temporal.py +343 -0
  538. apsimo/vector/__init__.py +75 -0
  539. apsimo/vector/backfill.py +171 -0
  540. apsimo/vector/caption.py +114 -0
  541. apsimo/vector/collections.py +51 -0
  542. apsimo/vector/config.py +102 -0
  543. apsimo/vector/embedder.py +670 -0
  544. apsimo/vector/image_preprocess.py +406 -0
  545. apsimo/vector/image_store.py +296 -0
  546. apsimo/vector/indexes.py +162 -0
  547. apsimo/vector/migrate.py +334 -0
  548. apsimo/vector/multimodal_provider.py +417 -0
  549. apsimo/vector/multimodal_types.py +87 -0
  550. apsimo/vector/openai_provider.py +119 -0
  551. apsimo/vector/query.py +49 -0
  552. apsimo/vector/reranker.py +565 -0
  553. apsimo/vector/safety_image.py +159 -0
  554. apsimo/vector/scanner.py +197 -0
  555. apsimo/vector/setup.py +289 -0
  556. apsimo/vector/store.py +533 -0
  557. apsimo/vector/tiers.py +263 -0
  558. apsimo/work_orders.py +925 -0
  559. apsimo/workers/__init__.py +21 -0
  560. apsimo/workers/agent_bridge.py +640 -0
  561. apsimo/workers/colony_worker.py +382 -0
  562. apsimo/workers/queue_worker.py +441 -0
  563. apsimo/workers/skills_sync.py +152 -0
  564. apsimo/world_model/__init__.py +71 -0
  565. apsimo/world_model/causal_maintenance.py +131 -0
  566. apsimo/world_model/causal_policy.py +43 -0
  567. apsimo/world_model/causal_query.py +125 -0
  568. apsimo/world_model/confidence.py +54 -0
  569. apsimo/world_model/config.py +64 -0
  570. apsimo/world_model/constants.py +97 -0
  571. apsimo/world_model/entities.py +145 -0
  572. apsimo/world_model/expectation_resolvers.py +177 -0
  573. apsimo/world_model/extraction/__init__.py +7 -0
  574. apsimo/world_model/extraction/base.py +62 -0
  575. apsimo/world_model/extraction/conversation_extractor.py +262 -0
  576. apsimo/world_model/extraction/detector.py +74 -0
  577. apsimo/world_model/extraction/document_extractor.py +78 -0
  578. apsimo/world_model/extraction/formats/__init__.py +24 -0
  579. apsimo/world_model/extraction/formats/csv_fmt.py +68 -0
  580. apsimo/world_model/extraction/formats/html_fmt.py +72 -0
  581. apsimo/world_model/extraction/formats/json_fmt.py +68 -0
  582. apsimo/world_model/extraction/formats/pdf.py +43 -0
  583. apsimo/world_model/extraction/formats/text.py +27 -0
  584. apsimo/world_model/extraction/llm_extractor.py +164 -0
  585. apsimo/world_model/extraction/pipeline.py +73 -0
  586. apsimo/world_model/integrations/__init__.py +5 -0
  587. apsimo/world_model/integrations/mind_model_bridge.py +115 -0
  588. apsimo/world_model/integrations/social_intel_bridge.py +120 -0
  589. apsimo/world_model/jobs/__init__.py +4 -0
  590. apsimo/world_model/jobs/extraction_job.py +168 -0
  591. apsimo/world_model/llm_extract.py +572 -0
  592. apsimo/world_model/neo4j/__init__.py +5 -0
  593. apsimo/world_model/neo4j/backend.py +654 -0
  594. apsimo/world_model/observations.py +155 -0
  595. apsimo/world_model/populator.py +307 -0
  596. apsimo/world_model/postgres/__init__.py +1 -0
  597. apsimo/world_model/postgres/backend.py +683 -0
  598. apsimo/world_model/relationships.py +25 -0
  599. apsimo/world_model/resolution/__init__.py +13 -0
  600. apsimo/world_model/resolution/entity_resolver.py +232 -0
  601. apsimo/world_model/resolution/merge_audit.py +16 -0
  602. apsimo/world_model/resolution/merge_workflow.py +117 -0
  603. apsimo/world_model/source_reports.py +121 -0
  604. apsimo/world_model/sqlite/__init__.py +4 -0
  605. apsimo/world_model/sqlite/backend.py +855 -0
  606. apsimo/world_model/sqlite/schema.sql +132 -0
  607. apsimo/world_model/store.py +545 -0
  608. apsimo-1.3.0.dist-info/METADATA +78 -0
  609. apsimo-1.3.0.dist-info/RECORD +614 -0
  610. apsimo-1.3.0.dist-info/WHEEL +5 -0
  611. apsimo-1.3.0.dist-info/entry_points.txt +11 -0
  612. apsimo-1.3.0.dist-info/licenses/LICENSE +21 -0
  613. apsimo-1.3.0.dist-info/top_level.txt +2 -0
  614. colony_sidecar/__init__.py +4 -0
@@ -0,0 +1,883 @@
1
+ """Scoped assertion projections owned by the canonical source transaction."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ from contextlib import closing
6
+ import hashlib
7
+ import json
8
+ import logging
9
+ import time
10
+ import uuid
11
+
12
+ from .source_claims import (EXTRACTION_VERSION, admission_metadata, extract_claims,
13
+ extraction_diagnostics, projection_timeout_seconds, norm_value)
14
+ from .source_time import MemoryTimeQuery, filter_unstructured
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def initialize(conn):
20
+ conn.execute('''CREATE TABLE IF NOT EXISTS source_claim_jobs (
21
+ turn_id TEXT PRIMARY KEY, status TEXT NOT NULL DEFAULT 'pending',
22
+ timezone TEXT NOT NULL DEFAULT 'UTC', attempts INTEGER NOT NULL DEFAULT 0,
23
+ next_attempt REAL NOT NULL DEFAULT 0, lease_until REAL NOT NULL DEFAULT 0,
24
+ error TEXT, model TEXT, extraction_version TEXT, lease_token TEXT NOT NULL DEFAULT '')''')
25
+ if 'diagnostics_json' not in {row[1] for row in conn.execute('PRAGMA table_info(source_claim_jobs)')}:
26
+ conn.execute('ALTER TABLE source_claim_jobs ADD COLUMN diagnostics_json TEXT')
27
+ conn.execute('''CREATE TABLE IF NOT EXISTS source_claims (
28
+ id TEXT PRIMARY KEY, turn_id TEXT NOT NULL, message_hash TEXT NOT NULL,
29
+ subject_key TEXT NOT NULL, predicate TEXT NOT NULL, value_key TEXT NOT NULL, data_json TEXT NOT NULL,
30
+ valid_from TEXT, valid_to TEXT, superseded_by TEXT, retracted_by TEXT)''')
31
+ conn.execute('CREATE INDEX IF NOT EXISTS source_claim_turn ON source_claims(turn_id)')
32
+ conn.execute('CREATE INDEX IF NOT EXISTS source_claim_key ON source_claims(subject_key,predicate)')
33
+ conn.execute('CREATE INDEX IF NOT EXISTS source_claim_message ON source_claims(message_hash)')
34
+ conn.execute('CREATE INDEX IF NOT EXISTS source_contact_scope ON turn_sources(contact_id,scope,session_id)')
35
+
36
+
37
+ def enqueue(conn, turn_id, messages, *, scope, timezone_name=None):
38
+ # Checkpoint history has no per-message verified speaker attribution/time.
39
+ if scope == "person" and any(m.get("role") == "user" for m in messages):
40
+ conn.execute('INSERT OR IGNORE INTO source_claim_jobs(turn_id,timezone) VALUES (?,?)',
41
+ (turn_id, timezone_name or "UTC"))
42
+
43
+
44
+ def erase_removed(conn, turn_id, session_id, retained):
45
+ from apsimo.turns.idempotency import source_message_hash
46
+ hashes = {source_message_hash(session_id, message) for message in retained}
47
+ rows = conn.execute('SELECT id,message_hash FROM source_claims WHERE turn_id=?', (turn_id,)).fetchall()
48
+ for row in rows:
49
+ if row["message_hash"] not in hashes:
50
+ # Inherited identities point directly to their original grounded
51
+ # claim, so erasure needs one hop and never removes raw corrections.
52
+ conn.execute("DELETE FROM source_claims WHERE json_extract(data_json,'$.subject_basis_claim_id')=?", (row['id'],))
53
+ conn.execute('DELETE FROM source_claims WHERE id=?', (row["id"],))
54
+ if not retained:
55
+ conn.execute('DELETE FROM source_claim_jobs WHERE turn_id=?', (turn_id,))
56
+ # Supersession/retraction links on surviving claims retain IDs, not erased
57
+ # values. Deleting a correction must never silently revive its old value.
58
+
59
+
60
+ def _subject_basis_source_sql(identifier_sql, contact_sql):
61
+ """Shared root lifecycle eligibility; full grounding stays in subject_basis.
62
+
63
+ Both arguments are internal SQL fragments, never user-supplied values.
64
+ """
65
+ return f'''FROM source_claims b
66
+ JOIN turn_sources bs ON bs.turn_id=b.turn_id JOIN source_claim_jobs bj ON bj.turn_id=b.turn_id
67
+ WHERE b.id={identifier_sql} AND bs.contact_id={contact_sql} AND bs.scope='person' AND bj.status='complete'
68
+ AND NOT EXISTS (SELECT 1 FROM source_attribution_invalidations i WHERE i.source_id=bs.turn_id)
69
+ AND NOT EXISTS (SELECT 1 FROM source_projection_erasures e WHERE e.turn_id=bs.turn_id)
70
+ AND NOT EXISTS (SELECT 1 FROM source_annotations a,json_each(a.target_message_hashes_json) h
71
+ WHERE a.target_source_id=bs.turn_id AND h.value=b.message_hash)'''
72
+
73
+
74
+ def subject_basis(conn, claim, *, contact_id):
75
+ """Return grounded identity and explicitly qualified earlier episode context.
76
+
77
+ A root's value may be retracted or superseded while its literal subject
78
+ remains grounded. Annotation, erasure or changed attribution revokes it.
79
+ Only one fully grounded ancestor is allowed, not a recursive claim chain.
80
+ An immediate episode correction can qualify the earlier report without
81
+ withdrawing every unchanged detail. Further corrections require the existing
82
+ history reader: the root alone does not represent intervening revisions.
83
+ """
84
+ identifier = claim.get('subject_basis_claim_id')
85
+ if not identifier:
86
+ return None
87
+ from apsimo.turns.idempotency import source_message_hash, canonical_turn_digest
88
+ from apsimo.turns.audio import claim_message
89
+ row = conn.execute('SELECT b.*,bs.session_id,bs.messages_json,bs.occurred_at,bs.ingested_at '
90
+ + _subject_basis_source_sql('?', '?'),
91
+ (identifier, contact_id)).fetchone()
92
+ if row is None:
93
+ return None
94
+ data = json.loads(row['data_json'])
95
+ messages = json.loads(row['messages_json'])
96
+ message = next((claim_message(m) for m in messages
97
+ if source_message_hash(row['session_id'], m) == row['message_hash']), None)
98
+ if (data.get('subject_basis_claim_id') or message is None or message.get('role') != 'user'
99
+ or data.get('subject') != claim.get('subject') or row['subject_key'] != claim['subject_key']
100
+ or row['predicate'] != claim['predicate'] or data['evidence'] not in message.get('content', '')
101
+ or admission_metadata(data) is None
102
+ or ('source_admission' in data and ('_audio_segments' in message
103
+ or data['evidence'] != message.get('content')))):
104
+ return None
105
+ # Reuse the original literal-grounding rule, without inferring an alias.
106
+ from .source_claims import literal_subject
107
+ episode = data.get('representation') == claim.get('representation') == 'episode'
108
+ if episode:
109
+ if row['subject_key'] != 'episode:' + hashlib.sha256(data['evidence'].encode()).hexdigest():
110
+ return None
111
+ elif not literal_subject(data['subject'], data['evidence']):
112
+ return None
113
+ result = {'claim_id': row['id'], 'turn_id': row['turn_id'], 'message_hash': row['message_hash'],
114
+ 'source_version': canonical_turn_digest(messages),
115
+ 'disposition': 'subject_identity_only',
116
+ 'value_use': 'not_evidence_for_current_value',
117
+ **{k: data[k] for k in ('evidence_basis', 'epistemic_state', 'source_modality') if k in data},
118
+ 'subject': data['subject'], 'predicate': row['predicate'], 'evidence': data['evidence']}
119
+ if episode:
120
+ immediate = claim.get('prior_claim_id') == identifier
121
+ result.update(
122
+ disposition='prior_episode_report' if immediate else 'episode_history_incomplete',
123
+ value_use=(
124
+ 'Read with the current correction. Corrected or withdrawn details are not current. '
125
+ 'Unchanged details remain attributed earlier context, not newly verified facts. '
126
+ 'A withdrawal of the whole report withdraws all its details.' if immediate else
127
+ 'Intermediate corrections are absent here. Do not infer current details from this '
128
+ 'original report. Open assertion history; missing or withdrawn revisions cannot '
129
+ 'be reconstructed from the original.'),
130
+ role='user', reported_at=row['occurred_at'], recorded_at=row['ingested_at'],
131
+ event_at=data.get('event_at'), event_time=data.get('event_time', {
132
+ 'status': 'legacy_precision_unknown' if data.get('event_at') else 'unknown'}))
133
+ return result
134
+
135
+
136
+ class SourceClaimProjection:
137
+ def __init__(self, ledger):
138
+ self.ledger = ledger
139
+
140
+ def _rows(self, conn, contact_id, session_id, *, turn_ids=None, message_hashes=None, ids=None,
141
+ key=None, time_query=None, distinct_values=False, limit=256):
142
+ from apsimo.turns.idempotency import source_message_hash
143
+ where = ["s.contact_id=?", "(s.scope='person' OR s.session_id=?)",
144
+ "NOT EXISTS (SELECT 1 FROM source_attribution_invalidations i WHERE i.source_id=s.turn_id)"]
145
+ args = [contact_id, session_id]
146
+ if ids is not None:
147
+ if not ids:
148
+ return []
149
+ where.append("c.id IN (" + ",".join("?" for _ in ids) + ")")
150
+ args.extend(ids)
151
+ if turn_ids is not None or message_hashes is not None:
152
+ alternatives = []
153
+ for column, values in (("c.turn_id", turn_ids), ("c.message_hash", message_hashes)):
154
+ if values:
155
+ alternatives.append(column + " IN (" + ",".join("?" for _ in values) + ")")
156
+ args.extend(values)
157
+ if not alternatives:
158
+ return []
159
+ where.append("(" + " OR ".join(alternatives) + ")")
160
+ if key:
161
+ where += ["c.subject_key=?", "c.predicate=?"]
162
+ args.extend(key)
163
+ if time_query:
164
+ where.append("c.retracted_by IS NULL")
165
+ if time_query.mode == "unresolved_time":
166
+ pass # Return labelled evidence, never certify a requested time.
167
+ elif time_query.mode == "observed_range":
168
+ # A source calendar day is an interval, not a midnight event.
169
+ # Source and query timezones can give that day different UTC
170
+ # boundaries. Retain overlap without claiming an exact instant.
171
+ where.append("""((json_extract(c.data_json,'$.event_time.status')='resolved'
172
+ AND json_extract(c.data_json,'$.event_time.precision')='calendar_day'
173
+ AND json_extract(c.data_json,'$.event_time.start')<?
174
+ AND json_extract(c.data_json,'$.event_time.end_exclusive')>?)
175
+ OR (coalesce(json_extract(c.data_json,'$.event_time.precision'),'')!='calendar_day'
176
+ AND json_extract(c.data_json,'$.event_at')>=?
177
+ AND json_extract(c.data_json,'$.event_at')<?))""")
178
+ args.extend((time_query.end, time_query.start, time_query.start, time_query.end))
179
+ elif time_query.mode == "valid_range":
180
+ where += ["c.valid_from IS NOT NULL", "c.valid_from<?", "(c.valid_to IS NULL OR c.valid_to>?)"]
181
+ args.extend((time_query.end, time_query.start))
182
+ else:
183
+ where += ["(c.valid_from IS NULL OR c.valid_from<=?)", "(c.valid_to IS NULL OR c.valid_to>?)"]
184
+ args.extend((time_query.start, time_query.start))
185
+ columns = "c.*,s.contact_id,s.session_id,s.scope,s.messages_json,s.occurred_at,s.ingested_at"
186
+ if distinct_values:
187
+ # A revoked inherited claim cannot win value deduplication over
188
+ # an independent witness. Filter lifecycle eligibility inside the
189
+ # scoped query, preserving its value limit and the grounding
190
+ # checks on returned rows below.
191
+ where.append("(json_extract(c.data_json,'$.subject_basis_claim_id') IS NULL OR EXISTS (SELECT 1 "
192
+ + _subject_basis_source_sql("json_extract(c.data_json,'$.subject_basis_claim_id')", 's.contact_id') + '))')
193
+ columns += ",row_number() OVER (PARTITION BY c.value_key ORDER BY s.ingested_at DESC,c.id) AS value_rank"
194
+ query = ("SELECT " + columns + " FROM source_claims c JOIN turn_sources s ON s.turn_id=c.turn_id WHERE "
195
+ + " AND ".join(where))
196
+ if distinct_values:
197
+ query = "SELECT * FROM (" + query + ") WHERE value_rank=1"
198
+ query += " ORDER BY ingested_at DESC LIMIT ?"
199
+ rows = conn.execute(query, [*args, limit]).fetchall()
200
+ result, membership = [], {}
201
+ for row in rows:
202
+ turn = row["turn_id"]
203
+ if turn not in membership:
204
+ membership[turn] = {source_message_hash(row["session_id"], message)
205
+ for message in json.loads(row["messages_json"])}
206
+ if row["message_hash"] not in membership[turn]:
207
+ continue
208
+ data = json.loads(row["data_json"])
209
+ if data.get('subject_basis_claim_id'):
210
+ basis = subject_basis(conn, data, contact_id=contact_id)
211
+ if basis is None:
212
+ continue
213
+ data['subject_basis'] = basis
214
+ data.update({key: row[key] for key in ("id", "turn_id", "message_hash", "subject_key", "predicate",
215
+ "valid_from", "valid_to", "superseded_by", "retracted_by")})
216
+ data.update(observed_at=row["occurred_at"], recorded_at=row["ingested_at"])
217
+ result.append(data)
218
+ return result
219
+
220
+ def prior(self, source, message, limit=16):
221
+ words = set(norm_value(message.get("content", "")).split())
222
+
223
+ def relevance(row):
224
+ # A partial correction can omit every topic word in the original
225
+ # report. Its retained, scoped basis still identifies that episode.
226
+ basis = row.get('subject_basis', {}) if row.get('representation') == 'episode' else {}
227
+ evidence = row['evidence'] + ' ' + basis.get('evidence', '')
228
+ return len(words & set(norm_value(evidence).split())), row['recorded_at']
229
+
230
+ hits = self.ledger.search_sources(message.get("content", ""), contact_id=source["contact_id"],
231
+ session_id=source["session_id"], limit=10)
232
+ turn_ids = [hit["turn_id"] for hit in hits if hit["turn_id"] != source["turn_id"]]
233
+ with closing(self.ledger._connect()) as conn:
234
+ rows = self._rows(conn, source["contact_id"], source["session_id"], turn_ids=turn_ids or None)
235
+ # Relevance can find a retracted report without finding its latest
236
+ # count-only correction. Offer the existing current episode, never
237
+ # the stale handle. The normal row reader rechecks source scope,
238
+ # attribution and retained basis; no erased history is reconstructed.
239
+ keys = list(dict.fromkeys((row['subject_key'], row['predicate'])
240
+ for row in sorted(rows, key=relevance, reverse=True)
241
+ if row.get('representation') == 'episode'))[:limit]
242
+ for key in keys:
243
+ rows.extend(self._rows(conn, source['contact_id'], source['session_id'],
244
+ key=key, limit=limit))
245
+ rows = list({row['id']: row for row in rows
246
+ if not row['superseded_by'] and not row['retracted_by']}.values())
247
+ rows.sort(key=relevance, reverse=True)
248
+ return rows[:limit]
249
+
250
+ def commit(self, source, message, claims, *, model, lease_token=None):
251
+ from apsimo.turns.idempotency import source_message_hash, canonical_turn_digest
252
+ from apsimo.turns.audio import claim_message, claim_basis
253
+ message_hash = source_message_hash(source["session_id"], message)
254
+ with closing(self.ledger._connect()) as conn, conn:
255
+ conn.execute('BEGIN IMMEDIATE')
256
+ if lease_token is not None and not conn.execute(
257
+ "SELECT 1 FROM source_claim_jobs WHERE turn_id=? AND status='running' AND lease_token=?",
258
+ (source["turn_id"], lease_token)).fetchone():
259
+ return 0
260
+ current = conn.execute('''SELECT * FROM turn_sources s WHERE turn_id=? AND NOT EXISTS
261
+ (SELECT 1 FROM source_attribution_invalidations i WHERE i.source_id=s.turn_id)''',
262
+ (source["turn_id"],)).fetchone()
263
+ if current is None:
264
+ return 0
265
+ current_messages = json.loads(current['messages_json'])
266
+ original = next((m for m in current_messages
267
+ if source_message_hash(current['session_id'], m) == message_hash), None)
268
+ current_view = claim_message(original) if original is not None else None
269
+ if (current_view is None or current_view.get('content') != message.get('content')
270
+ or current_view.get('_audio_segments') != message.get('_audio_segments')):
271
+ return 0
272
+ ids = list({claim["prior_claim_id"] for claim in claims if claim.get("prior_claim_id")})
273
+ prior = {row["id"]: row for row in self._rows(conn, current["contact_id"], current["session_id"], ids=ids)}
274
+ written = 0
275
+ for raw in claims:
276
+ claim = dict(raw)
277
+ # Validation occurs against exact current message bytes again,
278
+ # after model execution and after any concurrent source erase.
279
+ if current_view["content"][claim["span_start"]:claim["span_end"]] != claim["evidence"]:
280
+ continue
281
+ if 'source_admission' in claim and (admission_metadata(claim) is None
282
+ or '_audio_segments' in current_view or claim['evidence'] != current_view['content']):
283
+ continue
284
+ if '_audio_segments' in current_view:
285
+ basis = claim_basis(current_view, claim['span_start'], claim['span_end'])
286
+ review = claim.get('admission_review', {})
287
+ if (basis is None or basis != claim.get('evidence_basis')
288
+ or review.get('version') != 'source-claim-review-v1'
289
+ or review.get('basis') != 'model_judgment_unverified'):
290
+ continue
291
+ if not conn.execute('''SELECT 1 FROM source_media_links l JOIN source_media m USING(asset_hash)
292
+ WHERE l.turn_id=? AND l.message_hash=? AND l.asset_hash=?
293
+ AND m.mime_type='audio/wav' AND m.status='complete' ''',
294
+ (source['turn_id'], message_hash, basis['segment']['asset_id'][7:])).fetchone():
295
+ continue
296
+ claim.update(epistemic_state='derived_unverified', source_modality='audio_transcript',
297
+ evidence_basis={**basis, 'source_message_hash': message_hash,
298
+ 'source_version_at_formation': canonical_turn_digest(current_messages)})
299
+ basis = [source["turn_id"], message_hash, claim["subject_key"], claim["predicate"], claim["value"], claim["evidence"]]
300
+ if claim.get('subject_basis_claim_id'):
301
+ basis.append(claim['subject_basis_claim_id'])
302
+ cid = "claim:" + hashlib.sha256(json.dumps(basis, ensure_ascii=False).encode()).hexdigest()
303
+ if conn.execute('SELECT 1 FROM source_claims WHERE id=?', (cid,)).fetchone():
304
+ continue
305
+ old = prior.get(claim.get("prior_claim_id"))
306
+ if claim.get('subject_basis_claim_id'):
307
+ # Re-read current status even when another candidate in
308
+ # this same batch already corrected the supplied prior.
309
+ live = self._rows(conn, current['contact_id'], current['session_id'],
310
+ ids=[claim.get('prior_claim_id')])
311
+ old = live[0] if live else None
312
+ if (old is None or old['subject'] != claim['subject']
313
+ or old['subject_key'] != claim['subject_key'] or old['predicate'] != claim['predicate']
314
+ or old['superseded_by'] or old['retracted_by']
315
+ or claim['operation'] not in {'correct', 'change'}
316
+ or admission_metadata(claim) is None
317
+ or claim['subject_basis_claim_id'] != (old.get('subject_basis_claim_id') or old['id'])
318
+ or subject_basis(conn, claim, contact_id=current['contact_id']) is None):
319
+ continue # A missing dependency cannot become assert.
320
+ if old and (old["subject_key"] != claim["subject_key"] or old["predicate"] != claim["predicate"]
321
+ or old["superseded_by"] or old["retracted_by"]):
322
+ old = None
323
+ if not old:
324
+ claim["operation"] = "assert"
325
+ claim.update(model=model, extraction_version=EXTRACTION_VERSION, role="user")
326
+ conn.execute('''INSERT INTO source_claims
327
+ (id,turn_id,message_hash,subject_key,predicate,value_key,data_json,valid_from,valid_to)
328
+ VALUES (?,?,?,?,?,?,?,?,?)''', (cid, source["turn_id"], message_hash,
329
+ claim["subject_key"], claim["predicate"], norm_value(claim["value"]), json.dumps(claim, ensure_ascii=False),
330
+ claim["valid_from"], claim["valid_to"]))
331
+ if old and norm_value(old["value"]) != norm_value(claim["value"]):
332
+ if claim["operation"] == "correct":
333
+ conn.execute('UPDATE source_claims SET retracted_by=? WHERE id=?', (cid, old["id"]))
334
+ elif claim["operation"] == "change" and claim["valid_from"]:
335
+ if not old["valid_from"] or old["valid_from"] < claim["valid_from"]:
336
+ conn.execute('UPDATE source_claims SET valid_to=?,superseded_by=? WHERE id=?',
337
+ (claim["valid_from"], cid, old["id"]))
338
+ written += 1
339
+ return written
340
+
341
+ def claim_job(self):
342
+ now = time.time()
343
+ with closing(self.ledger._connect()) as conn, conn:
344
+ conn.execute('BEGIN IMMEDIATE')
345
+ job = conn.execute('''SELECT j.*,s.contact_id,s.session_id,s.scope,s.messages_json,s.occurred_at,s.ingested_at
346
+ FROM source_claim_jobs j JOIN turn_sources s ON s.turn_id=j.turn_id
347
+ WHERE (j.status='pending' AND j.next_attempt<=?) OR (j.status='running' AND j.lease_until<=?)
348
+ ORDER BY s.ingested_at LIMIT 1''', (now, now)).fetchone()
349
+ if job is None:
350
+ return None
351
+ token = uuid.uuid4().hex
352
+ conn.execute("UPDATE source_claim_jobs SET status='running',attempts=attempts+1,lease_until=?,lease_token=? WHERE turn_id=?",
353
+ (now + 60, token, job["turn_id"]))
354
+ return dict(job, lease_token=token)
355
+
356
+ def finish_job(self, job, *, model=None, error=None, diagnostics=None):
357
+ # claim_job returns the pre-increment count. Older workers can leave
358
+ # this additive column untouched; never attribute their later attempt
359
+ # to measurements produced by this worker.
360
+ encoded = json.dumps(dict(diagnostics, attempt=job['attempts'] + 1), sort_keys=True) if diagnostics is not None else None
361
+ with closing(self.ledger._connect()) as conn, conn:
362
+ if error:
363
+ delay = min(900, 15 * 2 ** min(job["attempts"], 6))
364
+ conn.execute("UPDATE source_claim_jobs SET status='pending',error=?,next_attempt=?,lease_until=0,diagnostics_json=? WHERE turn_id=? AND lease_token=?",
365
+ (error, time.time() + delay, encoded, job["turn_id"], job["lease_token"]))
366
+ else:
367
+ conn.execute("UPDATE source_claim_jobs SET status='complete',error=NULL,model=?,extraction_version=?,lease_until=0,diagnostics_json=? WHERE turn_id=? AND lease_token=?",
368
+ (model, EXTRACTION_VERSION, encoded, job["turn_id"], job["lease_token"]))
369
+
370
+ def renew_job(self, job, request_timeout):
371
+ """Extend this lease for bounded extraction, review and their commit."""
372
+ with closing(self.ledger._connect()) as conn, conn:
373
+ updated = conn.execute('''UPDATE source_claim_jobs SET lease_until=?
374
+ WHERE turn_id=? AND status='running' AND lease_token=?''',
375
+ (time.time() + request_timeout + 30, job['turn_id'], job['lease_token']))
376
+ return updated.rowcount == 1
377
+
378
+ async def process_one(self, router):
379
+ job = self.claim_job()
380
+ if job is None:
381
+ return False
382
+ model = None
383
+ diagnostics = extraction_diagnostics()
384
+ try:
385
+ for message in json.loads(job["messages_json"]):
386
+ # Retained ASR has a deterministic derived view. Other media
387
+ # remains in its existing caption/source recall path.
388
+ from apsimo.turns.audio import claim_message
389
+ message = claim_message(message)
390
+ if message is None:
391
+ continue
392
+ content = message.get('content')
393
+ if message.get("role") != "user" or not isinstance(content, str) or not content.strip():
394
+ continue
395
+ request_timeout = projection_timeout_seconds(router)
396
+ if not self.renew_job(job, request_timeout):
397
+ return True # Forgotten or reclaimed; do not start a stale model call.
398
+ claims, model = await extract_claims(router, job, message, self.prior(job, message),
399
+ timezone_name=job["timezone"], request_timeout=request_timeout,
400
+ diagnostics=diagnostics)
401
+ if model == "local_extraction_role_unavailable":
402
+ self.finish_job(job, error=model, diagnostics=diagnostics)
403
+ return True
404
+ self.commit(job, message, claims, model=model, lease_token=job["lease_token"])
405
+ self.finish_job(job, model=model, diagnostics=diagnostics)
406
+ except asyncio.CancelledError:
407
+ raise
408
+ except Exception as exc:
409
+ self.finish_job(job, error=type(exc).__name__, diagnostics=diagnostics)
410
+ logger.warning("source claim projection deferred (%s)", type(exc).__name__)
411
+ return True
412
+
413
+ def status(self, contact_id):
414
+ with closing(self.ledger._connect()) as conn:
415
+ rows = [dict(row) for row in conn.execute('''SELECT j.turn_id,j.status,j.attempts,j.error,j.model,j.extraction_version,j.diagnostics_json,
416
+ (SELECT count(*) FROM source_claims c WHERE c.turn_id=j.turn_id) AS claim_count
417
+ FROM source_claim_jobs j JOIN turn_sources s ON s.turn_id=j.turn_id WHERE s.contact_id=?
418
+ ORDER BY s.ingested_at DESC LIMIT 20''', (contact_id,))]
419
+ for row in rows:
420
+ encoded = row.pop('diagnostics_json')
421
+ data = json.loads(encoded) if encoded is not None else None
422
+ row['diagnostics'] = data if data is not None and data['attempt'] == row['attempts'] else None
423
+ return rows
424
+
425
+ def preferences(self, contact_id, session_id='', *, now=None, limit=20):
426
+ """Read admitted speaker preferences without authoring another profile.
427
+
428
+ Keep the full quotation, including a recurring activity or condition.
429
+ A correction makes an interpretation unsuitable as automatic guidance;
430
+ ordinary source recall still presents the original and its annotation.
431
+ """
432
+ from datetime import datetime, timezone
433
+ from apsimo.turns.idempotency import canonical_turn_digest
434
+ stamp = datetime.fromtimestamp(time.time() if now is None else now, timezone.utc).isoformat()
435
+ with closing(self.ledger._connect()) as conn:
436
+ ids = [r[0] for r in conn.execute('''SELECT c.id FROM source_claims c
437
+ JOIN turn_sources s ON s.turn_id=c.turn_id
438
+ WHERE s.contact_id=? AND (s.scope='person' OR s.session_id=?)
439
+ AND c.subject_key='speaker' AND c.superseded_by IS NULL AND c.retracted_by IS NULL
440
+ AND json_extract(c.data_json,'$.memory_quality.memory_kind')='preference'
441
+ AND json_extract(c.data_json,'$.admission_review.version')='source-claim-review-v1'
442
+ AND json_extract(c.data_json,'$.admission_review.basis')='model_judgment_unverified'
443
+ AND (c.valid_from IS NULL OR c.valid_from<=?) AND (c.valid_to IS NULL OR c.valid_to>?)
444
+ AND NOT EXISTS (SELECT 1 FROM source_projection_erasures e WHERE e.turn_id=s.turn_id)
445
+ AND NOT EXISTS (SELECT 1 FROM source_attribution_invalidations i WHERE i.source_id=s.turn_id)
446
+ ORDER BY s.ingested_at DESC,c.id LIMIT ?''',
447
+ (contact_id, session_id, stamp, stamp, max(1, min(limit, 100))))]
448
+ result = []
449
+ for claim in self._rows(conn, contact_id, session_id, ids=ids, limit=len(ids) or 1):
450
+ annotations = conn.execute('SELECT target_message_hashes_json FROM source_annotations WHERE target_source_id=?',
451
+ (claim['turn_id'],)).fetchall()
452
+ if any(claim['message_hash'] in json.loads(r[0]) for r in annotations):
453
+ continue
454
+ source = conn.execute('SELECT session_id,messages_json FROM turn_sources WHERE turn_id=?', (claim['turn_id'],)).fetchone()
455
+ refs = [{
456
+ 'source_id': claim['turn_id'], 'source_contact_id': contact_id,
457
+ 'source_version': canonical_turn_digest(json.loads(source['messages_json'])),
458
+ 'message_hash': claim['message_hash']}]
459
+ if claim.get('subject_basis'):
460
+ basis = claim['subject_basis']
461
+ refs.append({'source_id': basis['turn_id'], 'source_contact_id': contact_id,
462
+ 'source_version': basis['source_version'], 'message_hash': basis['message_hash']})
463
+ result.append({**claim, 'session_id': source['session_id'], 'sources': refs})
464
+ return result
465
+
466
+ def _current_work_reply(self, conn, source, message, *, contact_id, session_id):
467
+ """Resolve one exact status request through existing canonical lineage.
468
+
469
+ A native answer is often stored separately from its supplied user input.
470
+ Unknown, corrected, changed, multiple-input and cross-scope provenance
471
+ remains recallable. This transient hint never changes stored history.
472
+ """
473
+ from apsimo.intelligence.graph.selection import current_work_query
474
+ from apsimo.turns.idempotency import canonical_turn_digest, source_message_hash
475
+ if message.get('role') != 'assistant' or source['scope'] != 'person':
476
+ return False
477
+ refs = message.get('_supplied_inputs')
478
+ if refs is not None:
479
+ if not isinstance(refs, list) or len(refs) != 1 or not isinstance(refs[0], dict):
480
+ return False
481
+ ref = refs[0]
482
+ if set(ref) != {'source_id', 'input_message_hash'}:
483
+ return False
484
+ parent = conn.execute('''SELECT * FROM turn_sources WHERE turn_id=? AND contact_id=?
485
+ AND (scope='person' OR session_id=?) AND NOT EXISTS (
486
+ SELECT 1 FROM source_attribution_invalidations i WHERE i.source_id=turn_sources.turn_id)
487
+ ''', (ref['source_id'], contact_id, session_id)).fetchone()
488
+ if parent is None or parent['scope'] != 'person':
489
+ return False
490
+ messages = json.loads(parent['messages_json'])
491
+ if {'source_id': parent['turn_id'], 'source_version': canonical_turn_digest(messages)}\
492
+ not in message.get('_supplied_sources', []):
493
+ return False
494
+ matches = [m for m in messages if m.get('role') == 'user'
495
+ and source_message_hash(parent['session_id'], m) == ref['input_message_hash']]
496
+ if len(matches) != 1:
497
+ return False
498
+ request = matches[0]
499
+ else:
500
+ # Ordinary paired turns can carry the request in the same envelope.
501
+ messages = json.loads(source['messages_json'])
502
+ indices = [i for i, m in enumerate(messages) if m == message]
503
+ if len(indices) != 1 or indices[0] == 0:
504
+ return False
505
+ parent, request = source, messages[indices[0] - 1]
506
+ if request.get('role') != 'user':
507
+ return False
508
+ request_hash = source_message_hash(parent['session_id'], request)
509
+ annotations = conn.execute('SELECT target_message_hashes_json FROM source_annotations WHERE target_source_id=?',
510
+ (parent['turn_id'],)).fetchall()
511
+ if any(request_hash in json.loads(row[0]) for row in annotations):
512
+ return False
513
+ return current_work_query(request.get('content'))
514
+
515
+ def prepare_context(self, beliefs, source_hits, *, contact_id, session_id, time_query: MemoryTimeQuery,
516
+ classify_work_replies=False):
517
+ """Expand retrieved keys into complete scoped assertion bundles.
518
+
519
+ Ranking chooses relevant keys. It cannot choose a winner within an
520
+ unresolved conflict or reintroduce text from a corrected assertion.
521
+ """
522
+ from apsimo.turns.idempotency import source_message_hash
523
+ from apsimo.turns.audio import source_text
524
+ from apsimo.intelligence.graph.recall import source_candidates
525
+ turn_ids = list(dict.fromkeys(
526
+ [row["turn_id"] for row in source_hits] + [str(row["source_uri"])[5:] for row in beliefs
527
+ if str(row.get("source_uri") or "").startswith("turn:")]))
528
+ if not turn_ids:
529
+ return filter_unstructured(beliefs, time_query), []
530
+ with closing(self.ledger._connect()) as conn:
531
+ sources = {row["turn_id"]: dict(row) for row in conn.execute(
532
+ "SELECT * FROM turn_sources WHERE contact_id=? AND (scope='person' OR session_id=?) AND turn_id IN ("
533
+ + ",".join("?" for _ in turn_ids) + ")", (contact_id, session_id, *turn_ids))}
534
+ hashes = {source_message_hash(source["session_id"], message)
535
+ for source in sources.values() for message in json.loads(source["messages_json"])
536
+ if any(
537
+ hit["turn_id"] == source["turn_id"] and hit["role"] == message.get("role")
538
+ and hit["content"] in source_text(message.get('content')) for hit in source_hits)}
539
+ claims = self._rows(conn, contact_id, session_id, turn_ids=turn_ids, message_hashes=hashes, limit=512)
540
+ status_replies = set()
541
+ if classify_work_replies:
542
+ # Resolve only exact retrieved messages, never every assistant
543
+ # message in an otherwise long canonical checkpoint.
544
+ pending = {(hit['turn_id'], hit.get('source_message_hash')) for hit in source_hits}
545
+ for source in sources.values():
546
+ for message in json.loads(source['messages_json']):
547
+ identity = (source['turn_id'], source_message_hash(source['session_id'], message))
548
+ if identity not in pending:
549
+ continue
550
+ pending.discard(identity)
551
+ if self._current_work_reply(conn, source, message,
552
+ contact_id=contact_id, session_id=session_id):
553
+ status_replies.add(identity)
554
+ by_turn, by_hash = {}, {}
555
+ for claim in claims:
556
+ by_turn.setdefault(claim["turn_id"], []).append(claim)
557
+ by_hash.setdefault(claim["message_hash"], []).append(claim)
558
+ keys, retained_beliefs = [], []
559
+ for original in beliefs:
560
+ row = dict(original)
561
+ uri = str(row.get("source_uri") or "")
562
+ turn = uri[5:] if uri.startswith("turn:") else None
563
+ known = by_turn.get(turn, [])
564
+ if known:
565
+ keys.extend((c["subject_key"], c["predicate"]) for c in known)
566
+ continue
567
+ if turn in sources:
568
+ row["occurred_at"] = sources[turn]["occurred_at"]
569
+ retained_beliefs.append(row)
570
+ retained_hits = []
571
+ for original in source_hits:
572
+ hit = dict(original)
573
+ hit.pop('_current_work_status_reply', None)
574
+ source = sources.get(hit["turn_id"])
575
+ removed = []
576
+ if source:
577
+ # Carry canonical attribution through selection. Checkpoint
578
+ # scope cannot attest the speaker of every historical message.
579
+ hit.update({name: source[name] for name in ("contact_id", "session_id", "scope")})
580
+ for message in json.loads(source["messages_json"]):
581
+ if hit.get('source_message_hash') and hit['source_message_hash'] != source_message_hash(source['session_id'], message):
582
+ continue
583
+ text = message.get("content")
584
+ if isinstance(text, list):
585
+ text = source_text(text)
586
+ if message.get("role") != hit["role"] or not isinstance(text, str):
587
+ continue
588
+ if ((source['turn_id'], source_message_hash(source['session_id'], message)) in status_replies
589
+ and hit.get('source_message_hash') == source_message_hash(source['session_id'], message)):
590
+ hit['_current_work_status_reply'] = True
591
+ message_claims = by_hash.get(source_message_hash(source["session_id"], message), [])
592
+ # Source FTS chunks overlap. Clip exact message spans into
593
+ # every matching chunk occurrence; never depend on an entire
594
+ # corrected quotation fitting inside one retrieved chunk.
595
+ offset = text.find(hit["content"])
596
+ if offset >= 0 and hit["content"] != text:
597
+ hit["excerpt_truncated"] = True
598
+ while offset >= 0:
599
+ procedures = [c for c in message_claims
600
+ if c.get('memory_quality', {}).get('memory_kind') == 'procedure']
601
+ if procedures:
602
+ # Its unclaimed remainder may also contain a
603
+ # required condition. Select the owning message,
604
+ # never an independently ranked leftover fragment.
605
+ keys.extend((c['subject_key'], c['predicate']) for c in procedures)
606
+ removed.append((0, len(hit['content'])))
607
+ for claim in message_claims:
608
+ start = max(0, claim["span_start"] - offset)
609
+ end = min(len(hit["content"]), claim["span_end"] - offset)
610
+ if start < end:
611
+ keys.append((claim["subject_key"], claim["predicate"]))
612
+ removed.append((start, end))
613
+ offset = text.find(hit["content"], offset + 1)
614
+ cursor = 0
615
+ # Each remainder is still one exact contiguous source quotation.
616
+ # Concatenating separated fragments would fabricate a quotation.
617
+ for start, end in sorted(removed) + [(len(hit["content"]), len(hit["content"]))]:
618
+ if start > cursor:
619
+ fragment = hit["content"][cursor:start]
620
+ if fragment.strip(" .,:;\n\t"):
621
+ retained_hits.append(dict(hit, content=fragment,
622
+ excerpt_truncated=bool(removed) or bool(hit.get("excerpt_truncated"))))
623
+ cursor = max(cursor, end)
624
+ bundles, message_contexts, emitted_messages, groups = [], {}, set(), {}
625
+ unresolved_time_keys = set()
626
+
627
+ def current_group(key):
628
+ if key not in groups:
629
+ with closing(self.ledger._connect()) as conn:
630
+ groups[key] = self._rows(conn, contact_id, session_id, key=key,
631
+ time_query=time_query, distinct_values=True, limit=9)
632
+ if time_query.mode == 'observed_range' and any(
633
+ c.get('event_time', {}).get('precision') == 'calendar_day'
634
+ and (c['event_time']['start'] < time_query.start
635
+ or c['event_time']['end_exclusive'] > time_query.end)
636
+ for c in groups[key]):
637
+ # The event could fall in the overlapping part or the
638
+ # remainder of its source day. Relevance is not proof
639
+ # that it happened within the query's narrower window.
640
+ unresolved_time_keys.add(key)
641
+ if not groups[key] and time_query.mode == 'observed_range':
642
+ # Relevance already selected this source. An unknown
643
+ # episode time may help answer a date question, but it
644
+ # cannot certify a matching date. Never admit a known
645
+ # event outside the requested interval through here.
646
+ groups[key] = [c for c in self._rows(conn, contact_id, session_id, key=key,
647
+ time_query=MemoryTimeQuery(), distinct_values=True, limit=9)
648
+ if c.get('representation') == 'episode' and not c.get('event_at')]
649
+ if groups[key]:
650
+ unresolved_time_keys.add(key)
651
+ return groups[key]
652
+
653
+ def complete_message_context(claim):
654
+ """Keep a current short message or a procedure's conditions together.
655
+
656
+ Keep the owning message together when it is still current. A
657
+ changed message needs the existing source/history readers instead
658
+ of reviving obsolete instructions from its original quotation.
659
+ """
660
+ procedure = claim.get('memory_quality', {}).get('memory_kind') == 'procedure'
661
+ identity = (claim['turn_id'], claim['message_hash'])
662
+ if identity not in message_contexts:
663
+ with closing(self.ledger._connect()) as conn:
664
+ source = conn.execute('''SELECT * FROM turn_sources WHERE turn_id=? AND contact_id=?
665
+ AND (scope='person' OR session_id=?)''',
666
+ (claim['turn_id'], contact_id, session_id)).fetchone()
667
+ message = next((m for m in json.loads(source['messages_json'])
668
+ if source_message_hash(source['session_id'], m) == claim['message_hash']), None) if source else None
669
+ ids = [row['id'] for row in conn.execute(
670
+ 'SELECT id FROM source_claims WHERE turn_id=? AND message_hash=? LIMIT 513', identity)]
671
+ all_claims = self._rows(conn, contact_id, session_id, ids=ids, limit=513)
672
+ current = self._rows(conn, contact_id, session_id, ids=ids,
673
+ time_query=time_query, limit=513)
674
+ text = source_text(message.get('content')) if message else ''
675
+ complete = bool(text and all_claims and len(ids) < 513 and len(all_claims) == len(ids)
676
+ and {c['id'] for c in all_claims} == {c['id'] for c in current}
677
+ and not any(c['superseded_by'] or c['retracted_by'] for c in all_claims)
678
+ and all(len(current_group((c['subject_key'], c['predicate']))) == 1 for c in all_claims))
679
+ message_contexts[identity] = {'source': dict(source) if source else None,
680
+ 'message': message, 'text': text, 'complete': complete, 'claims': all_claims}
681
+ value = message_contexts[identity]
682
+ if not procedure:
683
+ # An unresolved time expression cannot certify a dated fact,
684
+ # but must not strip conditions from its attributed source.
685
+ # The bundle retains query_time_unresolved in that case.
686
+ # Resolved historical windows still use qualified assertions.
687
+ if (not value['complete'] or len(value['text']) > 2000
688
+ or time_query.mode not in {'current', 'unresolved_time'}
689
+ # Derived media needs its exact segment/recognizer basis;
690
+ # a transcript's display prefix is not missing context.
691
+ or any(c.get('evidence_basis') for c in value['claims'])
692
+ or all(c['evidence'].strip() == value['text'].strip() for c in value['claims'])):
693
+ return None
694
+ return value
695
+ # A complete one-message assertion keeps its existing property
696
+ # semantics. This boundary concerns partial-message procedures.
697
+ if value['complete'] and claim['evidence'].strip() == value['text'].strip():
698
+ return None
699
+ return value
700
+
701
+ for key in dict.fromkeys(keys):
702
+ group = current_group(key)
703
+ if not group:
704
+ continue
705
+ overflow = len(group) > 8
706
+ group.sort(key=lambda c: (c["valid_from"] or "", c["recorded_at"], c["id"]))
707
+ # Exact value equality only; substring containment is not agreement.
708
+ values = {norm_value(c["value"]) for c in group}
709
+ def overlaps(a, b):
710
+ return (not a["valid_to"] or not b["valid_from"] or b["valid_from"] < a["valid_to"]) and (
711
+ not b["valid_to"] or not a["valid_from"] or a["valid_from"] < b["valid_to"])
712
+ conflict = any(norm_value(a["value"]) != norm_value(b["value"]) and overlaps(a, b)
713
+ for i, a in enumerate(group) for b in group[i + 1:])
714
+ members = [{"claim_id": c["id"], "source": "turn:" + c["turn_id"],
715
+ "source_message_hash": c["message_hash"], "role": c["role"],
716
+ "value": c["value"], "quote": c["evidence"], "observed_at": c["observed_at"],
717
+ "recorded_at": c["recorded_at"], "valid_from": c["valid_from"], "valid_to": c["valid_to"],
718
+ "event_at": c.get("event_at"), "event_time": c.get("event_time", {
719
+ "status": "legacy_precision_unknown" if c.get("event_at") else "unknown"}),
720
+ "reported_at": c["observed_at"], "validity_basis": c["validity_basis"],
721
+ "operation": c["operation"], "prior_claim_id": c.get("prior_claim_id"),
722
+ **{k: c[k] for k in ('representation', 'evidence_basis', 'epistemic_state', 'source_modality', 'subject_basis') if k in c}}
723
+ for c in group]
724
+ status = "unresolved_conflict" if conflict else ("temporal_history" if len(values) > 1 else "source_assertion")
725
+ source_refs = [(c['turn_id'], c['message_hash']) for c in group]
726
+ source_refs.extend((c['subject_basis']['turn_id'], c['subject_basis']['message_hash'])
727
+ for c in group if c.get('subject_basis'))
728
+ identifier = hashlib.sha256(json.dumps([contact_id, key, [c["id"] for c in group]]).encode()).hexdigest()
729
+ bundle = {"id": "assertions:" + identifier, "kind": "source_quote",
730
+ "history_anchor": {"source_id": group[0]['turn_id'], "claim_id": group[0]['id']},
731
+ "source_turn_ids": list(dict.fromkeys(turn for turn, _ in source_refs)),
732
+ "_source_message_hashes": {turn: list(dict.fromkeys(
733
+ message for source, message in source_refs if source == turn))
734
+ for turn, _ in source_refs},
735
+ "source_uri": "turn:" + group[0]["turn_id"], "claim_status": status,
736
+ "epistemic_state": ('derived_unverified' if any(c.get('evidence_basis') for c in group) else status),
737
+ **({'source_modality': 'audio_transcript'} if any(c.get('evidence_basis') for c in group) else {}),
738
+ "atomic_evidence": True,
739
+ "content_format": "source_assertions_v1",
740
+ # Rank the grounded language the user supplied.
741
+ # Administrative IDs/timestamps in the output JSON
742
+ # are provenance, not the passage's semantic topic.
743
+ # Conflicting peers remain one indivisible candidate.
744
+ "ranking_text": "\n".join(dict.fromkeys(c["evidence"] for c in group)),
745
+ **({"validity_status": "query_time_unresolved"}
746
+ if time_query.mode == "unresolved_time" or key in unresolved_time_keys else {}),
747
+ "contradiction_count": len(values) - 1 if conflict else 0, "relevance": 1 / (61 + len(bundles)),
748
+ "content": json.dumps({"subject": group[0]["subject"], "predicate": key[1],
749
+ "status": status, "assertions": members}, ensure_ascii=False),
750
+ **({"excerpt_truncated": True, "content": json.dumps({
751
+ "subject": group[0]['subject'], "predicate": key[1],
752
+ "status": "incomplete_assertion_history", "distinct_values_at_least": len(values),
753
+ "instruction": "Open assertion history before resolving this property; no value selected."},
754
+ ensure_ascii=False)} if overflow else {})}
755
+ contexts = [(claim, complete_message_context(claim)) for claim in group]
756
+ contexts = [(claim, context) for claim, context in contexts if context is not None]
757
+ if contexts:
758
+ bundle['source_anchors'] = [{'source_id': turn} for turn in
759
+ dict.fromkeys(claim['turn_id'] for claim in group)]
760
+ if len(group) == 1 and contexts[0][1]['complete']:
761
+ claim, context = contexts[0]
762
+ identity = (claim['turn_id'], claim['message_hash'])
763
+ if identity in emitted_messages:
764
+ continue
765
+ emitted_messages.add(identity)
766
+ source, message = context['source'], context['message']
767
+ procedure = claim.get('memory_quality', {}).get('memory_kind') == 'procedure'
768
+ bundle.update(id=('procedure-source:' if procedure else 'message-source:') + hashlib.sha256(json.dumps(identity).encode()).hexdigest(),
769
+ content=context['text'], ranking_text=context['text'],
770
+ **({'procedure_context': 'complete_source_message_text'} if procedure else {
771
+ 'source_context': 'complete_source_message_text',
772
+ 'source_history_anchors': [{'source_id': c['turn_id'], 'claim_id': c['id']}
773
+ for c in context['claims']]}),
774
+ claim_status='source_procedure_context' if procedure else 'source_message_context',
775
+ epistemic_state='derived_unverified' if claim.get('evidence_basis') else 'quotation',
776
+ source_turn_id=claim['turn_id'], source_message_hash=claim['message_hash'],
777
+ role=message['role'], contact_id=source['contact_id'], session_id=source['session_id'],
778
+ scope=source['scope'], occurred_at=source['occurred_at'], ingested_at=source['ingested_at'])
779
+ # Content now holds raw source text, which can itself look
780
+ # like JSON. It is no longer an internal assertion card.
781
+ bundle.pop('content_format', None)
782
+ derived = [{'claim_id': c['id'], 'evidence_basis': c['evidence_basis']}
783
+ for c in context['claims'] if c.get('evidence_basis')]
784
+ if derived:
785
+ bundle.update(source_evidence_bases=derived, epistemic_state='derived_unverified',
786
+ source_modality='audio_transcript')
787
+ # Other represented properties can inherit an exact subject
788
+ # from another source. Retain every opening dependency.
789
+ for represented in context['claims']:
790
+ basis = represented.get('subject_basis')
791
+ if basis:
792
+ if basis['turn_id'] not in bundle['source_turn_ids']:
793
+ bundle['source_turn_ids'].append(basis['turn_id'])
794
+ hashes = bundle['_source_message_hashes'].setdefault(basis['turn_id'], [])
795
+ if basis['message_hash'] not in hashes:
796
+ hashes.append(basis['message_hash'])
797
+ bundle['source_anchors'] = [{'source_id': turn} for turn in bundle['source_turn_ids']]
798
+ else:
799
+ # A conflicting or changed procedure remains discoverable,
800
+ # but its partial assertion is not sufficient instructions.
801
+ # Opening only the selected property's history could miss
802
+ # the sibling whose correction made this message unsafe.
803
+ anchors = {(c['turn_id'], c['subject_key'], c['predicate']): {
804
+ 'source_id': c['turn_id'], 'claim_id': c['id']}
805
+ for _, context in contexts for c in context['claims']}
806
+ bundle.update(procedure_context='full_source_required', excerpt_truncated=True,
807
+ procedure_history_anchors=list(anchors.values()),
808
+ content='Incomplete procedure context. Open the full sources in source_anchors '
809
+ 'using their recalled source versions and inspect each procedure_history_anchors entry before '
810
+ 'following the procedure. Property history alone may omit its conditions.')
811
+ bundles.append(bundle)
812
+ # A complete selected message already includes its unclaimed remainder.
813
+ # Do not rank those fragments independently against their own context.
814
+ retained_hits = [hit for hit in retained_hits
815
+ if (hit['turn_id'], hit.get('source_message_hash')) not in emitted_messages]
816
+ return (filter_unstructured(retained_beliefs, time_query),
817
+ bundles + filter_unstructured(source_candidates(retained_hits), time_query))
818
+
819
+
820
+ async def run_source_claim_worker(ledger, router_provider, *, claims_enabled=True):
821
+ """One consumer, durable jobs and leases; process loss resumes from SQLite."""
822
+ projection = SourceClaimProjection(ledger)
823
+ from apsimo.identity import get_owner_contact_id
824
+ from apsimo.self_model.judgments import SelfJudgments
825
+ judgments = SelfJudgments(ledger, owner_id=get_owner_contact_id())
826
+ from apsimo.self_model.appraisals import AppraisalStore
827
+ appraisals = AppraisalStore(ledger, owner_id=get_owner_contact_id())
828
+ from apsimo.turns.media import SourceMedia
829
+ media = SourceMedia(ledger)
830
+ from apsimo.turns.source_vectors import SourceVectors
831
+ from apsimo.vector import get_store, get_pipeline
832
+ vectors = SourceVectors(ledger, get_store(), get_pipeline())
833
+ vectors.backfill()
834
+ try:
835
+ media.recover_unowned_files()
836
+ except OSError:
837
+ logger.warning("source media orphan recovery deferred")
838
+ reflections = {'judgment': judgments, 'appraisal': appraisals, 'claim': projection}
839
+ reflection_tasks = {name: None for name in reflections}
840
+ next_identity_check = 0.0
841
+ try:
842
+ while True:
843
+ worked = False
844
+ if time.monotonic() >= next_identity_check:
845
+ next_identity_check = time.monotonic() + 30
846
+ try:
847
+ from apsimo.api.routers.social_state import reconcile_pending_identities
848
+ worked = await reconcile_pending_identities(ledger)
849
+ except Exception as exc:
850
+ logger.warning('identity source reconciliation deferred (%s)', type(exc).__name__)
851
+ # Durable model projections share this worker's lifecycle. Their
852
+ # requests must not stall source indexing or media processing.
853
+ if claims_enabled:
854
+ for name, projection_worker in reflections.items():
855
+ task = reflection_tasks[name]
856
+ if task is None or task.done():
857
+ if task is not None:
858
+ try:
859
+ worked = task.result() or worked
860
+ except Exception as exc:
861
+ logger.warning("source %s deferred (%s)", name, type(exc).__name__)
862
+ reflection_tasks[name] = asyncio.create_task(
863
+ projection_worker.process_one(router_provider()))
864
+ try:
865
+ worked = await vectors.process_one() or worked
866
+ except asyncio.CancelledError:
867
+ raise
868
+ except Exception as exc:
869
+ logger.warning("source semantic projection deferred (%s)", type(exc).__name__)
870
+ try:
871
+ if claims_enabled:
872
+ media_worked = await media.process_one(router_provider())
873
+ worked = worked or media_worked
874
+ except asyncio.CancelledError:
875
+ raise
876
+ except Exception as exc:
877
+ logger.warning("source claim worker deferred (%s)", type(exc).__name__)
878
+ await asyncio.sleep(.05 if worked else 2)
879
+ finally:
880
+ tasks = [task for task in reflection_tasks.values() if task is not None]
881
+ for task in tasks:
882
+ task.cancel()
883
+ await asyncio.gather(*tasks, return_exceptions=True)