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,752 @@
1
+ """Durable server-side idempotency for host turn ingestion.
2
+
3
+ The ledger is intentionally small and independent of graph/vector stores. A
4
+ turn ID is reserved durably before any downstream effect runs. Identical
5
+ replays return the stored response; different canonical content for the same
6
+ ID is a conflict. An interrupted first attempt is marked ``ambiguous`` and is
7
+ never automatically replayed because some downstream effects may already have
8
+ occurred.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import json
15
+ import os
16
+ import re
17
+ import sqlite3
18
+ import threading
19
+ import uuid
20
+ from contextlib import closing
21
+ from dataclasses import dataclass
22
+ from datetime import datetime, timezone
23
+ from enum import Enum
24
+ from functools import lru_cache
25
+ from pathlib import Path
26
+ from typing import Any, Mapping, Optional
27
+
28
+
29
+ class SourceErased(ValueError):
30
+ """The requested source or an exact message copy was deliberately erased."""
31
+
32
+
33
+ class SourceInputPending(ValueError):
34
+ """An exact admitted parent has not reached the canonical ledger yet."""
35
+
36
+
37
+ def source_message_hash(session_id: str, message: Mapping[str, Any]) -> str:
38
+ # Ledger-owned normalization metadata retains the input message identity.
39
+ # Public message schemas cannot supply this top-level field.
40
+ original = message.get("_source_message_hash")
41
+ if isinstance(original, str) and re.fullmatch(r"[0-9a-f]{64}", original):
42
+ return original
43
+ return canonical_turn_digest({"session_id": session_id, "role": message.get("role"), "content": message.get("content")})
44
+
45
+
46
+ class ReservationOutcome(str, Enum):
47
+ CREATED = "created"
48
+ REPLAYED = "replayed"
49
+ CONFLICT = "conflict"
50
+ IN_PROGRESS = "in_progress"
51
+ AMBIGUOUS = "ambiguous"
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class Reservation:
56
+ outcome: ReservationOutcome
57
+ response: Optional[dict[str, Any]] = None
58
+
59
+
60
+ def canonical_turn_digest(payload: Any) -> str:
61
+ """Hash the normalized turn envelope, preserving list order and values."""
62
+ if hasattr(payload, "model_dump"):
63
+ payload = payload.model_dump(mode="json", exclude_none=False)
64
+ # Preserve digests already stored before the additive checkpoint field.
65
+ if isinstance(payload, dict) and payload.get("checkpoint_messages") is None:
66
+ payload = {key: value for key, value in payload.items() if key != "checkpoint_messages"}
67
+ if isinstance(payload, dict) and payload.get("assistant_source_refs") is None:
68
+ payload = {key: value for key, value in payload.items() if key != "assistant_source_refs"}
69
+ if isinstance(payload, dict) and payload.get("assistant_input_refs") is None:
70
+ payload = {key: value for key, value in payload.items() if key != "assistant_input_refs"}
71
+ if isinstance(payload, dict) and payload.get("source_only") is None:
72
+ payload = {key: value for key, value in payload.items() if key != "source_only"}
73
+ canonical = json.dumps(
74
+ payload,
75
+ ensure_ascii=False,
76
+ separators=(",", ":"),
77
+ sort_keys=True,
78
+ )
79
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
80
+
81
+
82
+ def _lexical_chunks(messages):
83
+ """The existing FTS writer's exact chunks, also used to recover ownership."""
84
+ from apsimo.turns.audio import source_text
85
+ for message in messages:
86
+ content = source_text(message.get("content"))
87
+ if not content.strip():
88
+ continue
89
+ # Overlap preserves boundary phrases; full messages remain canonical.
90
+ for start in range(0, len(content), 1800):
91
+ yield message, content, content[start:start + 2000]
92
+
93
+
94
+ class TurnIdempotencyLedger:
95
+ """SQLite reservation ledger safe across threads and sidecar processes."""
96
+
97
+ def __init__(self, db_path: str | Path) -> None:
98
+ self.db_path = Path(db_path)
99
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
100
+ self._init_lock = threading.Lock()
101
+ self._initialized = False
102
+ self._initialize()
103
+
104
+ def _connect(self) -> sqlite3.Connection:
105
+ conn = sqlite3.connect(str(self.db_path), timeout=10.0)
106
+ os.chmod(self.db_path, 0o600)
107
+ conn.row_factory = sqlite3.Row
108
+ conn.execute("PRAGMA busy_timeout=10000")
109
+ conn.execute("PRAGMA synchronous=FULL")
110
+ conn.execute("PRAGMA secure_delete=ON")
111
+ return conn
112
+
113
+ def _initialize(self) -> None:
114
+ if self._initialized:
115
+ return
116
+ with self._init_lock:
117
+ if self._initialized:
118
+ return
119
+ with closing(self._connect()) as conn, conn:
120
+ conn.execute("PRAGMA journal_mode=WAL")
121
+ conn.execute("PRAGMA synchronous=FULL")
122
+ conn.execute(
123
+ """
124
+ CREATE TABLE IF NOT EXISTS turn_ingestion (
125
+ turn_id TEXT PRIMARY KEY,
126
+ content_sha256 TEXT NOT NULL,
127
+ state TEXT NOT NULL CHECK (
128
+ state IN ('processing', 'completed', 'ambiguous')
129
+ ),
130
+ response_json TEXT,
131
+ created_at TEXT NOT NULL DEFAULT (
132
+ strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
133
+ ),
134
+ completed_at TEXT,
135
+ error TEXT
136
+ )
137
+ """
138
+ )
139
+ conn.execute("""
140
+ CREATE TABLE IF NOT EXISTS turn_sources (
141
+ turn_id TEXT PRIMARY KEY,
142
+ content_sha256 TEXT NOT NULL,
143
+ contact_id TEXT NOT NULL,
144
+ session_id TEXT NOT NULL,
145
+ scope TEXT NOT NULL CHECK(scope IN ('person', 'session')),
146
+ messages_json TEXT NOT NULL,
147
+ occurred_at TEXT,
148
+ ingested_at TEXT NOT NULL DEFAULT (
149
+ strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
150
+ )
151
+ )
152
+ """)
153
+ conn.execute("""
154
+ CREATE VIRTUAL TABLE IF NOT EXISTS turn_source_search USING fts5(
155
+ turn_id UNINDEXED, role UNINDEXED, content,
156
+ tokenize='unicode61'
157
+ )
158
+ """)
159
+ conn.execute("""
160
+ CREATE TABLE IF NOT EXISTS source_erasures (
161
+ sequence INTEGER PRIMARY KEY AUTOINCREMENT,
162
+ contact_id TEXT NOT NULL,
163
+ turn_id TEXT NOT NULL UNIQUE,
164
+ session_id TEXT NOT NULL,
165
+ message_hashes_json TEXT NOT NULL,
166
+ erased_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
167
+ )
168
+ """)
169
+ # Keep predecessor tombstone semantics and cursor allocation.
170
+ # A partial event uses an opaque event ID in source_erasures;
171
+ # predecessor code still removes its exact message hashes but
172
+ # does not mistake the surviving source for a whole tombstone.
173
+ conn.execute('''CREATE TABLE IF NOT EXISTS source_erasure_revisions (
174
+ sequence INTEGER PRIMARY KEY, source_turn_id TEXT NOT NULL,
175
+ source_version TEXT NOT NULL, source_scope TEXT NOT NULL,
176
+ whole_source INTEGER NOT NULL CHECK(whole_source IN (0,1)),
177
+ UNIQUE(source_turn_id,source_version,whole_source))''')
178
+ conn.execute("CREATE TABLE IF NOT EXISTS source_projection_erasures (turn_id TEXT NOT NULL, source_turn_id TEXT NOT NULL, PRIMARY KEY(turn_id, source_turn_id))")
179
+ from apsimo.beliefs.source_projection import initialize
180
+ initialize(conn)
181
+ from apsimo.turns.media import initialize as initialize_media
182
+ initialize_media(conn)
183
+ from apsimo.turns.source_vectors import initialize as initialize_vectors
184
+ initialize_vectors(conn)
185
+ from apsimo.self_model.judgments import initialize as initialize_judgments
186
+ initialize_judgments(conn)
187
+ from apsimo.self_model.appraisals import initialize as initialize_appraisals
188
+ initialize_appraisals(conn)
189
+ from apsimo.turns.source_attribution import initialize as initialize_attribution
190
+ initialize_attribution(conn)
191
+ from apsimo.turns.source_annotations import initialize as initialize_annotations
192
+ initialize_annotations(conn)
193
+ self._initialized = True
194
+
195
+ def append_source_annotation(self, **kwargs):
196
+ """Append an attributed correction without rewriting its original source."""
197
+ from .source_annotations import append
198
+ return append(self, **kwargs)
199
+
200
+ def record_source(
201
+ self, turn_id: str, *, contact_id: str, session_id: str,
202
+ messages: list[dict[str, Any]], scope: str = "person",
203
+ occurred_at: str | None = None,
204
+ timezone_name: str | None = None,
205
+ derive_claims: bool = True,
206
+ runtime_judgment: bool = False,
207
+ ) -> bool:
208
+ """Atomically retain source JSON and its rebuildable lexical index.
209
+
210
+ Checkpoint sources are session-scoped because a full transcript does
211
+ not attest the speaker of every old message. Ordinary attributed turns
212
+ can be recalled by that person across sessions. No inference or affect
213
+ update occurs in this storage path. True means newly committed.
214
+
215
+ Reviewed historical imports can set derive_claims=False to retain
216
+ quotations without scheduling assertion learning. Text indexing still
217
+ uses the same source ledger and semantic projection queue.
218
+ The internal runtime_judgment option queues server-observed execution
219
+ evidence without ordinary claim extraction; HTTP writers cannot set it.
220
+ """
221
+ if not turn_id or not contact_id or not session_id or not messages:
222
+ raise ValueError("source requires an id, participant, session and messages")
223
+ if scope not in {"person", "session"}:
224
+ raise ValueError("invalid source scope")
225
+ if occurred_at is not None:
226
+ if not isinstance(occurred_at, str) or len(occurred_at) > 64:
227
+ raise ValueError("source occurrence time must be an ISO timestamp")
228
+ occurred = datetime.fromisoformat(occurred_at)
229
+ if occurred.tzinfo is None:
230
+ raise ValueError("source occurrence time requires a timezone")
231
+ occurred_at = occurred.astimezone(timezone.utc).isoformat()
232
+ encoded = json.dumps(messages, ensure_ascii=True, sort_keys=True, separators=(",", ":"), allow_nan=False)
233
+ if len(encoded.encode()) > 8 * 1024 * 1024:
234
+ raise ValueError("source exceeds 8 MiB")
235
+ digest = canonical_turn_digest({
236
+ "messages": messages, "contact_id": contact_id,
237
+ "session_id": session_id, "scope": scope,
238
+ "occurred_at": occurred_at,
239
+ })
240
+ messages = json.loads(encoded) # Resolving lineage never mutates caller-owned input.
241
+ with closing(self._connect()) as conn, conn:
242
+ conn.execute("BEGIN IMMEDIATE")
243
+ rules = self._erasure_rules(conn, contact_id)
244
+ # Source IDs are globally unique. Reattributing an erased source
245
+ # must not resurrect it under a different contact after restore or
246
+ # a reviewed historical mapping change. Exact message-copy rules
247
+ # below remain scoped to their original contact and session.
248
+ if conn.execute("SELECT 1 FROM source_erasures WHERE turn_id=?", (turn_id,)).fetchone():
249
+ raise SourceErased("source was erased")
250
+ for message in messages:
251
+ if message.get('_supplied_inputs'):
252
+ if message.get('role') != 'assistant':
253
+ raise ValueError('invalid_source_input_dependency')
254
+ resolved = self._resolve_input_dependencies(conn, contact_id, session_id,
255
+ message['_supplied_inputs'], turn_id=turn_id)
256
+ existing = message.get('_supplied_sources', [])
257
+ message['_supplied_sources'] = list({(ref['source_id'], ref['source_version']): ref
258
+ for ref in [*existing, *resolved]}.values())
259
+ self._validate_dependencies(conn, turn_id, contact_id, session_id, messages)
260
+ retained = self._retained_messages(messages, session_id, rules)
261
+ if retained != messages:
262
+ for cause in self._erasure_causes(messages, session_id, rules):
263
+ conn.execute("INSERT OR IGNORE INTO source_projection_erasures(turn_id,source_turn_id) VALUES (?,?)", (turn_id, cause))
264
+ self._append_erasure(conn, turn_id=turn_id, contact_id=contact_id,
265
+ session_id=session_id, scope=scope, messages=messages,
266
+ removed=[message for message in messages if message not in retained], whole=False)
267
+ messages = retained
268
+ if not messages:
269
+ raise SourceErased("source contains only erased messages")
270
+ encoded = json.dumps(messages, ensure_ascii=True, sort_keys=True, separators=(",", ":"), allow_nan=False)
271
+ row = conn.execute("SELECT content_sha256 FROM turn_sources WHERE turn_id=?", (turn_id,)).fetchone()
272
+ if row:
273
+ if row[0] != digest:
274
+ raise ValueError("source id already contains different evidence")
275
+ return False
276
+ from apsimo.turns.media import normalize_messages, SourceMedia
277
+ messages = normalize_messages(conn, SourceMedia(self).store, turn_id, session_id, messages)
278
+ encoded = json.dumps(messages, ensure_ascii=True, sort_keys=True, separators=(",", ":"), allow_nan=False)
279
+ conn.execute(
280
+ "INSERT INTO turn_sources "
281
+ "(turn_id, content_sha256, contact_id, session_id, scope, messages_json, occurred_at) "
282
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
283
+ (turn_id, digest, contact_id, session_id, scope, encoded, occurred_at),
284
+ )
285
+ self._index_messages(conn, turn_id, messages)
286
+ from apsimo.beliefs.source_projection import enqueue
287
+ if derive_claims:
288
+ enqueue(conn, turn_id, messages, scope=scope, timezone_name=timezone_name)
289
+ if derive_claims or runtime_judgment:
290
+ from apsimo.self_model.judgments import enqueue as enqueue_judgments
291
+ enqueue_judgments(conn, turn_id, contact_id, messages, scope=scope,
292
+ runtime_observation=runtime_judgment)
293
+ from apsimo.self_model.appraisals import enqueue as enqueue_appraisals
294
+ enqueue_appraisals(conn, turn_id, contact_id, messages, scope=scope,
295
+ runtime_observation=runtime_judgment)
296
+ from apsimo.turns.source_vectors import enqueue as enqueue_vectors
297
+ enqueue_vectors(conn, turn_id)
298
+ return True
299
+
300
+ @staticmethod
301
+ def _index_messages(conn: sqlite3.Connection, turn_id: str, messages: list[dict[str, Any]]) -> None:
302
+ for message, _, chunk in _lexical_chunks(messages):
303
+ conn.execute(
304
+ "INSERT INTO turn_source_search(turn_id, role, content) VALUES (?, ?, ?)",
305
+ (turn_id, message["role"], chunk),
306
+ )
307
+
308
+
309
+ @staticmethod
310
+ def _erasure_rules(conn: sqlite3.Connection, contact_id: str) -> list[dict[str, Any]]:
311
+ return [dict(row) for row in conn.execute(
312
+ '''SELECT e.sequence,e.contact_id,e.session_id,e.message_hashes_json,
313
+ coalesce(r.source_turn_id,e.turn_id) AS turn_id,
314
+ coalesce(r.whole_source,1) AS whole_source,r.source_version
315
+ FROM source_erasures e LEFT JOIN source_erasure_revisions r USING(sequence)
316
+ WHERE e.contact_id=?''', (contact_id,)
317
+ )]
318
+
319
+ @staticmethod
320
+ def _retained_messages(messages: list[dict[str, Any]], session_id: str, rules: list[dict[str, Any]]) -> list[dict[str, Any]]:
321
+ return [message for message in messages
322
+ if not TurnIdempotencyLedger._erasure_causes([message], session_id, rules)]
323
+
324
+ @staticmethod
325
+ def _erasure_causes(messages, session_id, rules):
326
+ causes = set()
327
+ for message in messages:
328
+ refs = message.get('_supplied_sources', []) if message.get('role') == 'assistant' else []
329
+ for rule in rules:
330
+ exact = (rule['session_id'] == session_id and source_message_hash(session_id, message)
331
+ in json.loads(rule['message_hashes_json']))
332
+ dependent = any(ref.get('source_id') == rule['turn_id'] and (
333
+ rule['whole_source'] or ref.get('source_version') == rule['source_version'])
334
+ for ref in refs if isinstance(ref, dict))
335
+ if exact or dependent:
336
+ causes.add(rule['turn_id'])
337
+ return causes
338
+
339
+ @staticmethod
340
+ def _resolve_input_dependencies(conn, contact_id, session_id, refs, *, turn_id=''):
341
+ if not isinstance(refs, list):
342
+ raise ValueError('invalid_source_input_dependency')
343
+ result = []
344
+ for ref in refs:
345
+ if (not isinstance(ref, dict) or set(ref) != {'source_id', 'input_message_hash'}
346
+ or not isinstance(ref['source_id'], str) or not 1 <= len(ref['source_id']) <= 256
347
+ or ref['source_id'] == turn_id or not isinstance(ref['input_message_hash'], str)
348
+ or not re.fullmatch('[0-9a-f]{64}', ref['input_message_hash'])):
349
+ raise ValueError('invalid_source_input_dependency')
350
+ # An old revision can have been erased while delivery was offline.
351
+ # Check that exact input first, even when some parent content remains.
352
+ erased = conn.execute('''SELECT e.message_hashes_json,r.source_version
353
+ FROM source_erasures e JOIN source_erasure_revisions r USING(sequence)
354
+ WHERE r.source_turn_id=? AND e.contact_id=?
355
+ AND (r.source_scope='person' OR e.session_id=?) ORDER BY sequence''',
356
+ (ref['source_id'], contact_id, session_id)).fetchall()
357
+ version = next((row['source_version'] for row in erased
358
+ if ref['input_message_hash'] in json.loads(row['message_hashes_json'])), None)
359
+ if version is None:
360
+ parent = conn.execute('''SELECT * FROM turn_sources WHERE turn_id=? AND contact_id=?
361
+ AND NOT EXISTS (SELECT 1 FROM source_attribution_invalidations i
362
+ WHERE i.source_id=turn_sources.turn_id)''', (ref['source_id'], contact_id)).fetchone()
363
+ if parent is None:
364
+ if conn.execute('SELECT 1 FROM turn_sources WHERE turn_id=?', (ref['source_id'],)).fetchone():
365
+ raise ValueError('invalid_source_input_dependency')
366
+ raise SourceInputPending('source_input_parent_pending')
367
+ messages = json.loads(parent['messages_json'])
368
+ if (not (parent['scope'] == 'person' or parent['session_id'] == session_id)
369
+ or not any(message.get('role') == 'user' and
370
+ source_message_hash(parent['session_id'], message) == ref['input_message_hash']
371
+ for message in messages)):
372
+ raise ValueError('invalid_source_input_dependency')
373
+ version = canonical_turn_digest(messages)
374
+ result.append({'source_id': ref['source_id'], 'source_version': version})
375
+ return result
376
+
377
+ def resolve_input_dependencies(self, *, contact_id, session_id, refs, turn_id=''):
378
+ """Preflight before reserving effects; record_source rechecks atomically."""
379
+ with closing(self._connect()) as conn:
380
+ return self._resolve_input_dependencies(conn, contact_id, session_id, refs, turn_id=turn_id)
381
+
382
+ @staticmethod
383
+ def _validate_dependencies(conn, turn_id, contact_id, session_id, messages):
384
+ for message in messages:
385
+ refs = message.get('_supplied_sources', [])
386
+ # The complete source envelope already has an 8 MiB bound. A
387
+ # separate count cap would reject valid long native histories only
388
+ # after generation, or tempt callers to silently drop dependencies.
389
+ if not isinstance(refs, list) or (refs and message.get('role') != 'assistant'):
390
+ raise ValueError('invalid_source_dependency')
391
+ for ref in refs:
392
+ if (not isinstance(ref, dict) or set(ref) != {'source_id', 'source_version'}
393
+ or not isinstance(ref['source_id'], str) or not 1 <= len(ref['source_id']) <= 256
394
+ or ref['source_id'] == turn_id or not isinstance(ref['source_version'], str)
395
+ or not re.fullmatch('[0-9a-f]{64}', ref['source_version'])):
396
+ raise ValueError('invalid_source_dependency')
397
+ parent = conn.execute('''SELECT * FROM turn_sources WHERE turn_id=? AND contact_id=?
398
+ AND NOT EXISTS (SELECT 1 FROM source_attribution_invalidations i
399
+ WHERE i.source_id=turn_sources.turn_id)''',
400
+ (ref['source_id'], contact_id)).fetchone()
401
+ if (parent and (parent['scope'] == 'person' or parent['session_id'] == session_id)
402
+ and canonical_turn_digest(json.loads(parent['messages_json'])) == ref['source_version']):
403
+ continue
404
+ # A delayed answer may truthfully reference the exact revision
405
+ # already erased. Accept its envelope, then redact that answer.
406
+ erased = conn.execute('''SELECT 1 FROM source_erasures e JOIN source_erasure_revisions r USING(sequence)
407
+ WHERE r.source_turn_id=? AND e.contact_id=? AND r.source_version=?
408
+ AND (r.source_scope='person' OR e.session_id=?)''',
409
+ (ref['source_id'], contact_id, ref['source_version'], session_id)).fetchone()
410
+ if not erased:
411
+ raise ValueError('invalid_source_dependency')
412
+
413
+ @staticmethod
414
+ def _append_erasure(conn, *, turn_id, contact_id, session_id, scope, messages, removed, whole):
415
+ version = canonical_turn_digest(messages)
416
+ if conn.execute('''SELECT 1 FROM source_erasure_revisions WHERE
417
+ source_turn_id=? AND source_version=? AND whole_source=?''', (turn_id, version, int(whole))).fetchone():
418
+ return
419
+ hashes = {h for row in conn.execute('''SELECT e.message_hashes_json FROM source_erasures e
420
+ LEFT JOIN source_erasure_revisions r USING(sequence)
421
+ WHERE coalesce(r.source_turn_id,e.turn_id)=?''', (turn_id,))
422
+ for h in json.loads(row[0])}
423
+ hashes.update(source_message_hash(session_id, message) for message in removed)
424
+ event_id = turn_id if whole else 'erased-message:' + uuid.uuid4().hex
425
+ if not whole:
426
+ while conn.execute('SELECT 1 FROM turn_sources WHERE turn_id=? UNION SELECT 1 FROM source_erasures WHERE turn_id=?',
427
+ (event_id, event_id)).fetchone():
428
+ event_id = 'erased-message:' + uuid.uuid4().hex
429
+ cursor = conn.execute('''INSERT OR IGNORE INTO source_erasures
430
+ (contact_id,turn_id,session_id,message_hashes_json) VALUES (?,?,?,?)''',
431
+ (contact_id, event_id, session_id, json.dumps(sorted(hashes))))
432
+ if cursor.rowcount:
433
+ conn.execute('''INSERT INTO source_erasure_revisions
434
+ (sequence,source_turn_id,source_version,source_scope,whole_source) VALUES (?,?,?,?,?)''',
435
+ (cursor.lastrowid, turn_id, version, scope, int(whole)))
436
+
437
+ def source_references(self, turn_ids, *, contact_id, session_id):
438
+ """Structured selected-source revisions, never parsed from generated prose."""
439
+ refs = []
440
+ with closing(self._connect()) as conn:
441
+ for turn_id in dict.fromkeys(turn_ids):
442
+ row = conn.execute('''SELECT messages_json FROM turn_sources WHERE turn_id=?
443
+ AND contact_id=? AND (scope='person' OR session_id=?)
444
+ AND NOT EXISTS (SELECT 1 FROM source_attribution_invalidations i
445
+ WHERE i.source_id=turn_sources.turn_id)''',
446
+ (turn_id, contact_id, session_id)).fetchone()
447
+ if row:
448
+ refs.append({'source_id': turn_id, 'source_version': canonical_turn_digest(json.loads(row[0]))})
449
+ return refs
450
+
451
+ def is_source_erased(self, turn_id: str, contact_id: str | None = None) -> bool:
452
+ with closing(self._connect()) as conn:
453
+ sql, args = "SELECT 1 FROM source_erasures WHERE turn_id=?", [turn_id]
454
+ if contact_id is not None:
455
+ sql += " AND contact_id=?"
456
+ args.append(contact_id)
457
+ return conn.execute(sql, args).fetchone() is not None
458
+
459
+ def is_projection_erased(self, turn_id: str) -> bool:
460
+ with closing(self._connect()) as conn:
461
+ return conn.execute("SELECT 1 FROM source_projection_erasures WHERE turn_id=?", (turn_id,)).fetchone() is not None
462
+
463
+ def retained_messages(self, *, contact_id: str, session_id: str, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
464
+ with closing(self._connect()) as conn:
465
+ return self._retained_messages(messages, session_id, self._erasure_rules(conn, contact_id))
466
+
467
+ def erasure_watermark(self, contact_id: str) -> int:
468
+ with closing(self._connect()) as conn:
469
+ return int(conn.execute("SELECT coalesce(max(sequence), 0) FROM source_erasures WHERE contact_id=?", (contact_id,)).fetchone()[0])
470
+
471
+ def erasure_feed(self, contact_id: str, after: int = 0, limit: int = 250) -> dict[str, Any]:
472
+ with closing(self._connect()) as conn:
473
+ head = conn.execute("SELECT coalesce(max(sequence), 0) FROM source_erasures WHERE contact_id=?", (contact_id,)).fetchone()[0]
474
+ if after > head:
475
+ raise ValueError("erasure cursor exceeds server history; restore requires reconciliation")
476
+ rows = conn.execute('''SELECT e.*,r.source_turn_id,r.source_version,coalesce(r.whole_source,1) AS whole_source
477
+ FROM source_erasures e LEFT JOIN source_erasure_revisions r USING(sequence)
478
+ WHERE e.contact_id=? AND e.sequence>? ORDER BY e.sequence LIMIT ?''', (contact_id, after, max(1, min(limit, 500)))).fetchall()
479
+ events = [{"sequence": row["sequence"], "turn_id": row["turn_id"], "session_id": row["session_id"],
480
+ "message_hashes": json.loads(row["message_hashes_json"]),
481
+ "whole_source": bool(row['whole_source']), 'source_version': row['source_version'],
482
+ 'source_turn_id': row['source_turn_id'] or row['turn_id']}
483
+ for row in rows]
484
+ through = events[-1]["sequence"] if events else after
485
+ return {"contact_id": contact_id, "head": head, "through": through, "events": events, "complete": through == head}
486
+
487
+ def erase_sources(self, *, contact_id: str, turn_ids: list[str] | None = None, old_text: str | None = None, session_id: str | None = None) -> dict[str, Any]:
488
+ """Selective source erasure, including exact checkpoint message copies.
489
+
490
+ Tombstones retain IDs and hashes only. Unknown IDs are rejected rather
491
+ than allowing a contact to reserve or erase another contact's future ID.
492
+ Exact-text compatibility is deliberately unambiguous and session-bound.
493
+ """
494
+ if not contact_id or bool(turn_ids) == bool(old_text):
495
+ raise ValueError("select source IDs or exact old_text")
496
+ with closing(self._connect()) as conn, conn:
497
+ conn.execute("BEGIN IMMEDIATE")
498
+ rows = conn.execute("SELECT * FROM turn_sources WHERE contact_id=?", (contact_id,)).fetchall()
499
+ by_id = {row["turn_id"]: row for row in rows}
500
+ if old_text:
501
+ if not session_id:
502
+ raise ValueError("exact text erasure requires a session")
503
+ matches = [row["turn_id"] for row in rows if row["session_id"] == session_id
504
+ and any(message.get("content") == old_text for message in json.loads(row["messages_json"]))]
505
+ # A checkpoint plus its ordinary source are two matches. Callers
506
+ # must select explicit provenance rather than guessing ownership.
507
+ if len(matches) != 1:
508
+ raise ValueError("ambiguous_source" if matches else "source_not_found")
509
+ turn_ids = matches
510
+ selected = list(dict.fromkeys(turn_ids or []))
511
+ if not 1 <= len(selected) <= 100 or any(not item or len(item) > 256 for item in selected):
512
+ raise ValueError("select 1..100 source IDs")
513
+ existing_rules = self._erasure_rules(conn, contact_id)
514
+ known_erased = {rule["turn_id"] for rule in existing_rules if rule["whole_source"]}
515
+ if any(item not in by_id and item not in known_erased for item in selected):
516
+ raise ValueError("source_not_found")
517
+ for turn_id in selected:
518
+ row = by_id.get(turn_id)
519
+ if row is None:
520
+ continue
521
+ messages = json.loads(row["messages_json"])
522
+ self._append_erasure(conn, turn_id=turn_id, contact_id=contact_id,
523
+ session_id=row['session_id'], scope=row['scope'], messages=messages,
524
+ removed=messages, whole=True)
525
+ affected = []
526
+ remaining_rows = {row['turn_id']: dict(row) for row in rows}
527
+ # Each changed row loses at least one message. This finite closure
528
+ # follows only recorded supplied-source revisions, never topic text.
529
+ changed = True
530
+ while changed:
531
+ changed = False
532
+ for row in list(remaining_rows.values()):
533
+ rules = self._erasure_rules(conn, contact_id)
534
+ erased_ids = {rule['turn_id'] for rule in rules if rule['whole_source']}
535
+ messages = json.loads(row["messages_json"])
536
+ retained = [] if row["turn_id"] in erased_ids else self._retained_messages(messages, row["session_id"], rules)
537
+ if retained == messages:
538
+ continue
539
+ changed = True
540
+ if row['turn_id'] not in erased_ids:
541
+ self._append_erasure(conn, turn_id=row['turn_id'], contact_id=contact_id,
542
+ session_id=row['session_id'], scope=row['scope'], messages=messages,
543
+ removed=[message for message in messages if message not in retained], whole=False)
544
+ from apsimo.beliefs.source_projection import erase_removed
545
+ erase_removed(conn, row["turn_id"], row["session_id"], retained)
546
+ from apsimo.turns.media import erase_removed as erase_media
547
+ erase_media(conn, row["turn_id"], row["session_id"], retained)
548
+ from apsimo.self_model.perspective import erase_removed as erase_preferences
549
+ erase_preferences(conn, row["turn_id"], row["session_id"], retained)
550
+ from apsimo.self_model.judgments import erase_removed as erase_judgments
551
+ erase_judgments(conn, row["turn_id"], row["session_id"], retained)
552
+ from apsimo.self_model.appraisals import erase_removed as erase_appraisals
553
+ erase_appraisals(conn, row["turn_id"], row["session_id"], retained)
554
+ affected.append(row["turn_id"])
555
+ for selected_id in selected:
556
+ conn.execute("INSERT OR IGNORE INTO source_projection_erasures(turn_id,source_turn_id) VALUES (?,?)", (row["turn_id"], selected_id))
557
+ conn.execute("DELETE FROM turn_source_search WHERE turn_id=?", (row["turn_id"],))
558
+ if retained:
559
+ row['messages_json'] = json.dumps(retained, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
560
+ conn.execute("UPDATE turn_sources SET messages_json=? WHERE turn_id=?", (row['messages_json'], row["turn_id"]))
561
+ self._index_messages(conn, row["turn_id"], retained)
562
+ from apsimo.turns.source_vectors import enqueue as enqueue_vectors
563
+ enqueue_vectors(conn, row['turn_id'])
564
+ else:
565
+ del remaining_rows[row['turn_id']]
566
+ conn.execute("DELETE FROM turn_sources WHERE turn_id=?", (row["turn_id"],))
567
+ conn.execute('DELETE FROM source_vector_jobs WHERE turn_id=?', (row['turn_id'],))
568
+ # Keep the immutable request digest, but invalidate cached
569
+ # summaries and turn effects after partial source redaction.
570
+ conn.execute("UPDATE turn_ingestion SET response_json=NULL, error=NULL WHERE turn_id=?", (row["turn_id"],))
571
+ # Preserve cleanup targets so a retry after a graph outage also
572
+ # removes copies that disappeared from the source table already.
573
+ for selected_id in selected:
574
+ affected.extend(row[0] for row in conn.execute("SELECT turn_id FROM source_projection_erasures WHERE source_turn_id=?", (selected_id,)))
575
+ watermark = conn.execute("SELECT coalesce(max(sequence),0) FROM source_erasures WHERE contact_id=?", (contact_id,)).fetchone()[0]
576
+ from apsimo.turns.media import SourceMedia
577
+ media = SourceMedia(self)
578
+ try:
579
+ media.collect_orphans(limit=100)
580
+ media_cleanup = media.cleanup_status()
581
+ except OSError:
582
+ media_cleanup = "pending"
583
+ return {"source_ids": selected, "affected_source_ids": list(dict.fromkeys(affected)),
584
+ "watermark": watermark, "media_cleanup": media_cleanup}
585
+
586
+ def search_sources(
587
+ self, query: str, *, contact_id: str, session_id: str, limit: int = 5,
588
+ ) -> list[dict[str, Any]]:
589
+ """Recall direct source excerpts inside the already-authorized viewer."""
590
+ if not contact_id:
591
+ return []
592
+ stop = {"the", "and", "that", "this", "what", "when", "where", "how", "you", "your", "was", "were", "are", "for", "with", "remember", "about"}
593
+ words = list(dict.fromkeys(
594
+ word.lower() for word in re.findall(r"\w+", query[:4096])
595
+ if len(word) > 2 and word.lower() not in stop
596
+ ))[:12]
597
+ if not words:
598
+ return []
599
+ expression = " OR ".join('"' + word + '"' for word in words)
600
+ from apsimo.turns.audio import evidence_metadata
601
+
602
+ @lru_cache(maxsize=4)
603
+ def canonical_chunks(session, encoded):
604
+ # Reconstruct only scoped FTS matches, not the whole source store.
605
+ # The per-query cache is bounded; source envelopes are capped at 8 MiB.
606
+ chunks, messages = {}, {}
607
+ for message, text, chunk in _lexical_chunks(json.loads(encoded)):
608
+ # A long message can have thousands of chunks. Hash its original
609
+ # envelope and recover its modality once, not once per chunk.
610
+ key = id(message)
611
+ if key not in messages:
612
+ messages[key] = (source_message_hash(session, message), evidence_metadata(message))
613
+ message_hash, metadata = messages[key]
614
+ owners = chunks.setdefault((message.get('role'), chunk), {})
615
+ owners[message_hash] = {'source_message_hash': message_hash,
616
+ **metadata,
617
+ **({'excerpt_truncated': True} if chunk != text else {})}
618
+ return {key: json.dumps(list(owners.values()), sort_keys=True)
619
+ for key, owners in chunks.items()}
620
+
621
+ def owners(session, encoded, role, chunk):
622
+ return canonical_chunks(session, encoded).get((role, chunk), '[]')
623
+
624
+ with closing(self._connect()) as conn:
625
+ conn.create_function('canonical_lexical_owners', 4, owners)
626
+ rows = conn.execute("""
627
+ SELECT DISTINCT f.turn_id, f.role, f.content, s.contact_id, s.session_id, s.scope,
628
+ s.occurred_at, s.ingested_at, owner.value AS ownership
629
+ FROM turn_source_search AS f
630
+ JOIN turn_sources AS s ON s.turn_id=f.turn_id
631
+ JOIN json_each(canonical_lexical_owners(
632
+ s.session_id, s.messages_json, f.role, f.content)) AS owner
633
+ WHERE turn_source_search MATCH ? AND s.contact_id=?
634
+ AND (s.scope='person' OR s.session_id=?)
635
+ AND NOT EXISTS (SELECT 1 FROM source_attribution_invalidations i WHERE i.source_id=s.turn_id)
636
+ ORDER BY bm25(turn_source_search)
637
+ LIMIT ?
638
+ """, (expression, contact_id, session_id, max(1, min(limit, 20)))).fetchall()
639
+ # Exact chunk ownership and duplicate projection rows are resolved before
640
+ # LIMIT, so stale rows cannot spend the canonical result budget. This also
641
+ # reads predecessor-written indexes without a migration or startup rebuild.
642
+ result = []
643
+ for row in rows:
644
+ value = dict(row)
645
+ ownership = json.loads(value.pop('ownership'))
646
+ result.append({**value, **ownership})
647
+ return result
648
+
649
+ def reserve(self, turn_id: str, content_sha256: str) -> Reservation:
650
+ turn_id = (turn_id or "").strip()
651
+ if not turn_id or len(turn_id) > 256:
652
+ raise ValueError("turn_id must contain 1..256 non-whitespace characters")
653
+ if len(content_sha256) != 64:
654
+ raise ValueError("content_sha256 must be a SHA-256 hex digest")
655
+
656
+ conn = self._connect()
657
+ try:
658
+ conn.execute("BEGIN IMMEDIATE")
659
+ row = conn.execute(
660
+ "SELECT content_sha256, state, response_json "
661
+ "FROM turn_ingestion WHERE turn_id=?",
662
+ (turn_id,),
663
+ ).fetchone()
664
+ if row is None:
665
+ conn.execute(
666
+ "INSERT INTO turn_ingestion "
667
+ "(turn_id, content_sha256, state) VALUES (?, ?, 'processing')",
668
+ (turn_id, content_sha256),
669
+ )
670
+ conn.commit()
671
+ return Reservation(ReservationOutcome.CREATED)
672
+
673
+ conn.commit()
674
+ if row["content_sha256"] != content_sha256:
675
+ return Reservation(ReservationOutcome.CONFLICT)
676
+ if row["state"] == "completed":
677
+ response = None
678
+ if row["response_json"]:
679
+ response = json.loads(row["response_json"])
680
+ return Reservation(ReservationOutcome.REPLAYED, response=response)
681
+ if row["state"] == "ambiguous":
682
+ return Reservation(ReservationOutcome.AMBIGUOUS)
683
+ return Reservation(ReservationOutcome.IN_PROGRESS)
684
+ except Exception:
685
+ conn.rollback()
686
+ raise
687
+ finally:
688
+ conn.close()
689
+
690
+ def complete(
691
+ self,
692
+ turn_id: str,
693
+ content_sha256: str,
694
+ response: Mapping[str, Any],
695
+ ) -> None:
696
+ response_json = json.dumps(
697
+ dict(response), ensure_ascii=False, separators=(",", ":"), sort_keys=True
698
+ )
699
+ with closing(self._connect()) as conn, conn:
700
+ changed = conn.execute(
701
+ """
702
+ UPDATE turn_ingestion
703
+ SET state='completed', response_json=?,
704
+ completed_at=strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
705
+ error=NULL
706
+ WHERE turn_id=? AND content_sha256=? AND state='processing'
707
+ """,
708
+ (response_json, turn_id, content_sha256),
709
+ ).rowcount
710
+ if changed != 1:
711
+ row = conn.execute(
712
+ "SELECT content_sha256, state FROM turn_ingestion WHERE turn_id=?",
713
+ (turn_id,),
714
+ ).fetchone()
715
+ if not row or row["content_sha256"] != content_sha256:
716
+ raise RuntimeError("turn reservation changed before completion")
717
+ if row["state"] != "completed":
718
+ raise RuntimeError(f"turn cannot complete from state {row['state']}")
719
+
720
+ def mark_ambiguous(
721
+ self,
722
+ turn_id: str,
723
+ content_sha256: str,
724
+ error: BaseException,
725
+ ) -> None:
726
+ with closing(self._connect()) as conn, conn:
727
+ conn.execute(
728
+ """
729
+ UPDATE turn_ingestion
730
+ SET state='ambiguous', error=?
731
+ WHERE turn_id=? AND content_sha256=? AND state='processing'
732
+ """,
733
+ (f"{type(error).__name__}: {error}"[:1000], turn_id, content_sha256),
734
+ )
735
+
736
+ def get(self, turn_id: str) -> Optional[dict[str, Any]]:
737
+ """Read-only reconciliation view used by tests and future tooling."""
738
+ with closing(self._connect()) as conn, conn:
739
+ row = conn.execute(
740
+ "SELECT * FROM turn_ingestion WHERE turn_id=?", (turn_id,)
741
+ ).fetchone()
742
+ return dict(row) if row else None
743
+
744
+
745
+ @lru_cache(maxsize=16)
746
+ def _cached_ledger(db_path: str) -> TurnIdempotencyLedger:
747
+ return TurnIdempotencyLedger(db_path)
748
+
749
+
750
+ def get_turn_idempotency_ledger(state_dir: str | Path) -> TurnIdempotencyLedger:
751
+ path = Path(state_dir).resolve() / "turn-idempotency.db"
752
+ return _cached_ledger(str(path))