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,1052 @@
1
+ """SQLite-backed commitment store."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import logging
8
+ import sqlite3
9
+ import threading
10
+ import uuid
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Any, Dict, List, Optional
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ # Statuses that mean "still awaiting action". 'overdue' is a pending item
18
+ # whose due date passed (the condition worker flips it so the overdue event
19
+ # fires exactly once) — every open-work query must include BOTH, or a flipped
20
+ # item silently vanishes from dedup lists, prompt sections, and workspace
21
+ # ingest while still being owed.
22
+ OPEN_STATUSES = ("pending", "overdue")
23
+
24
+ # How a resolution outcome maps onto the terminal status. 'done' is the only
25
+ # outcome that counts as kept; everything else is a cancellation whose reason
26
+ # the system learns from (see resolution_stats / recent_rejections).
27
+ OUTCOME_TO_STATUS = {
28
+ "done": "fulfilled",
29
+ "invalid": "cancelled",
30
+ "duplicate": "cancelled",
31
+ "wont_do": "cancelled",
32
+ "obsolete": "cancelled",
33
+ }
34
+
35
+
36
+ RESOLUTION_RECOVERY_CAPABILITY = "commitment_resolution_recovery_v1"
37
+ _RESOLUTION_RECOVERY_SCHEMA = "ColonyCommitmentResolutionRecoveryV1"
38
+ _RESOLUTION_OPERATION_TABLE = "commitment_resolution_operations"
39
+ _RESOLUTION_OPERATION_UPDATE_TRIGGER = (
40
+ "commitment_resolution_operations_no_update"
41
+ )
42
+ _RESOLUTION_OPERATION_DELETE_TRIGGER = (
43
+ "commitment_resolution_operations_no_delete"
44
+ )
45
+ _RESOLUTION_BOUND_DELETE_TRIGGER = "commitment_resolution_bound_row_no_delete"
46
+
47
+ _RESOLUTION_OPERATION_TABLE_SQL = """\
48
+ CREATE TABLE commitment_resolution_operations (
49
+ operation_id TEXT PRIMARY KEY,
50
+ commitment_id TEXT NOT NULL UNIQUE,
51
+ outcome TEXT NOT NULL,
52
+ note TEXT NOT NULL,
53
+ note_digest TEXT NOT NULL,
54
+ resolved_by TEXT NOT NULL,
55
+ status TEXT NOT NULL,
56
+ resolved_at TEXT NOT NULL,
57
+ record_digest TEXT NOT NULL UNIQUE
58
+ )"""
59
+
60
+ _RESOLUTION_OPERATION_UPDATE_TRIGGER_SQL = """\
61
+ CREATE TRIGGER commitment_resolution_operations_no_update
62
+ BEFORE UPDATE ON commitment_resolution_operations
63
+ BEGIN
64
+ SELECT RAISE(ABORT, 'commitment resolution operations are immutable');
65
+ END"""
66
+
67
+ _RESOLUTION_OPERATION_DELETE_TRIGGER_SQL = """\
68
+ CREATE TRIGGER commitment_resolution_operations_no_delete
69
+ BEFORE DELETE ON commitment_resolution_operations
70
+ BEGIN
71
+ SELECT RAISE(ABORT, 'commitment resolution operations are immutable');
72
+ END"""
73
+
74
+ _RESOLUTION_BOUND_DELETE_TRIGGER_SQL = """\
75
+ CREATE TRIGGER commitment_resolution_bound_row_no_delete
76
+ BEFORE DELETE ON commitments
77
+ WHEN EXISTS (
78
+ SELECT 1 FROM commitment_resolution_operations
79
+ WHERE commitment_id=OLD.id
80
+ )
81
+ BEGIN
82
+ SELECT RAISE(ABORT, 'operation-bound commitments cannot be deleted');
83
+ END"""
84
+
85
+ _RESOLUTION_RECOVERY_OBJECTS = {
86
+ _RESOLUTION_OPERATION_TABLE: (
87
+ "table", _RESOLUTION_OPERATION_TABLE,
88
+ _RESOLUTION_OPERATION_TABLE_SQL,
89
+ ),
90
+ _RESOLUTION_OPERATION_UPDATE_TRIGGER: (
91
+ "trigger", _RESOLUTION_OPERATION_TABLE,
92
+ _RESOLUTION_OPERATION_UPDATE_TRIGGER_SQL,
93
+ ),
94
+ _RESOLUTION_OPERATION_DELETE_TRIGGER: (
95
+ "trigger", _RESOLUTION_OPERATION_TABLE,
96
+ _RESOLUTION_OPERATION_DELETE_TRIGGER_SQL,
97
+ ),
98
+ _RESOLUTION_BOUND_DELETE_TRIGGER: (
99
+ "trigger", "commitments", _RESOLUTION_BOUND_DELETE_TRIGGER_SQL,
100
+ ),
101
+ }
102
+
103
+ _RESOLUTION_OPERATION_COLUMNS = (
104
+ (0, "operation_id", "TEXT", 0, None, 1),
105
+ (1, "commitment_id", "TEXT", 1, None, 0),
106
+ (2, "outcome", "TEXT", 1, None, 0),
107
+ (3, "note", "TEXT", 1, None, 0),
108
+ (4, "note_digest", "TEXT", 1, None, 0),
109
+ (5, "resolved_by", "TEXT", 1, None, 0),
110
+ (6, "status", "TEXT", 1, None, 0),
111
+ (7, "resolved_at", "TEXT", 1, None, 0),
112
+ (8, "record_digest", "TEXT", 1, None, 0),
113
+ )
114
+
115
+ _RESOLUTION_OPERATION_INDEXES = {
116
+ "sqlite_autoindex_commitment_resolution_operations_1": (
117
+ "pk", "operation_id",
118
+ ),
119
+ "sqlite_autoindex_commitment_resolution_operations_2": (
120
+ "u", "commitment_id",
121
+ ),
122
+ "sqlite_autoindex_commitment_resolution_operations_3": (
123
+ "u", "record_digest",
124
+ ),
125
+ }
126
+
127
+
128
+ class CommitmentResolutionSchemaError(RuntimeError):
129
+ """The durable resolution-recovery schema is absent or untrustworthy."""
130
+
131
+
132
+ def _sql_signature(sql: Optional[str]) -> str:
133
+ """Stable exact-DDL comparison without depending on indentation."""
134
+
135
+ return " ".join((sql or "").strip().rstrip(";").split())
136
+
137
+
138
+ class CommitmentResolutionConflict(ValueError):
139
+ """A terminal commitment does not match a bound cascade operation."""
140
+
141
+ settlement_error_code = "operation_conflict"
142
+
143
+
144
+ def _canonical_json(value: Dict[str, Any]) -> str:
145
+ return json.dumps(
146
+ value, sort_keys=True, separators=(",", ":"), ensure_ascii=False,
147
+ )
148
+
149
+
150
+ def _operation_material(
151
+ *,
152
+ operation_id: str,
153
+ commitment_id: str,
154
+ outcome: str,
155
+ note: str,
156
+ note_digest: str,
157
+ resolved_by: str,
158
+ status: str,
159
+ resolved_at: str,
160
+ ) -> Dict[str, Any]:
161
+ if (
162
+ type(operation_id) is not str or not operation_id
163
+ or operation_id != operation_id.strip() or len(operation_id) > 192
164
+ ):
165
+ raise ValueError("commitment resolution operation_id is invalid")
166
+ if type(commitment_id) is not str or not commitment_id:
167
+ raise ValueError("commitment resolution operation commitment is invalid")
168
+ if outcome not in OUTCOME_TO_STATUS or OUTCOME_TO_STATUS[outcome] != status:
169
+ raise ValueError("commitment resolution operation outcome is invalid")
170
+ if type(note) is not str or len(note) > 300:
171
+ raise ValueError("commitment resolution operation note is invalid")
172
+ if (
173
+ type(note_digest) is not str or len(note_digest) != 64
174
+ or any(ch not in "0123456789abcdef" for ch in note_digest)
175
+ ):
176
+ raise ValueError("commitment resolution operation note digest is invalid")
177
+ if type(resolved_by) is not str or len(resolved_by) > 128:
178
+ raise ValueError("commitment resolution operation resolver is invalid")
179
+ if type(resolved_at) is not str or not resolved_at:
180
+ raise ValueError("commitment resolution operation time is invalid")
181
+ payload = {
182
+ "schema": "ColonyCommitmentResolutionOperationV1",
183
+ "version": 1,
184
+ "operation_id": operation_id,
185
+ "commitment_id": commitment_id,
186
+ "outcome": outcome,
187
+ "note": note,
188
+ "note_digest": note_digest,
189
+ "resolved_by": resolved_by,
190
+ "status": status,
191
+ "resolved_at": resolved_at,
192
+ }
193
+ return {
194
+ **payload,
195
+ "record_digest": hashlib.sha256(
196
+ _canonical_json(payload).encode("utf-8")
197
+ ).hexdigest(),
198
+ }
199
+
200
+
201
+ def _normalize_desc(text: str) -> str:
202
+ """Lowercased, punctuation-collapsed form used for duplicate detection."""
203
+ out = []
204
+ for ch in (text or "").lower():
205
+ out.append(ch if ch.isalnum() else " ")
206
+ return " ".join("".join(out).split())
207
+
208
+
209
+ def _similar_desc(a: str, b: str) -> bool:
210
+ """True when two normalized descriptions describe the same item: exact,
211
+ containment, or high token overlap (Jaccard >= 0.6)."""
212
+ if not a or not b:
213
+ return False
214
+ if a == b or a in b or b in a:
215
+ return True
216
+ ta, tb = set(a.split()), set(b.split())
217
+ if not ta or not tb:
218
+ return False
219
+ return len(ta & tb) / len(ta | tb) >= 0.6
220
+
221
+
222
+ class CommitmentStore:
223
+ """Persistent store for commitment tracking.
224
+
225
+ Thread-safe via a threading lock. All datetime values stored as
226
+ ISO 8601 UTC strings.
227
+ """
228
+
229
+ def __init__(self, db_path: Path) -> None:
230
+ self._db_path = db_path
231
+ self._lock = threading.Lock()
232
+ self._init_db()
233
+
234
+ def _connect(self) -> sqlite3.Connection:
235
+ conn = sqlite3.connect(str(self._db_path), check_same_thread=False)
236
+ conn.row_factory = sqlite3.Row
237
+ conn.execute("PRAGMA journal_mode=WAL")
238
+ return conn
239
+
240
+ def _init_db(self) -> None:
241
+ with self._lock:
242
+ conn = self._connect()
243
+ try:
244
+ conn.executescript("""
245
+ CREATE TABLE IF NOT EXISTS commitments (
246
+ id TEXT PRIMARY KEY,
247
+ person_id TEXT NOT NULL,
248
+ description TEXT NOT NULL,
249
+ made_at TEXT NOT NULL,
250
+ due_at TEXT,
251
+ fulfilled_at TEXT,
252
+ status TEXT NOT NULL DEFAULT 'pending',
253
+ source_context TEXT,
254
+ source_type TEXT NOT NULL DEFAULT 'manual',
255
+ priority INTEGER NOT NULL DEFAULT 50,
256
+ metadata TEXT
257
+ );
258
+
259
+ CREATE INDEX IF NOT EXISTS idx_commitments_person
260
+ ON commitments(person_id);
261
+ CREATE INDEX IF NOT EXISTS idx_commitments_status
262
+ ON commitments(status);
263
+ CREATE INDEX IF NOT EXISTS idx_commitments_due
264
+ ON commitments(due_at) WHERE status = 'pending';
265
+ CREATE INDEX IF NOT EXISTS idx_commitments_person_status
266
+ ON commitments(person_id, status);
267
+ """)
268
+ conn.commit()
269
+ conn.execute("BEGIN IMMEDIATE")
270
+ existing = self._resolution_recovery_object_names(conn)
271
+ expected = set(_RESOLUTION_RECOVERY_OBJECTS)
272
+ if existing and existing != expected:
273
+ raise CommitmentResolutionSchemaError(
274
+ "commitment resolution recovery schema is partial"
275
+ )
276
+ if not existing:
277
+ for _kind, _table, ddl in (
278
+ _RESOLUTION_RECOVERY_OBJECTS[name]
279
+ for name in _RESOLUTION_RECOVERY_OBJECTS
280
+ ):
281
+ conn.execute(ddl)
282
+ self._validate_resolution_recovery_schema(conn)
283
+ conn.commit()
284
+ except Exception:
285
+ conn.rollback()
286
+ raise
287
+ finally:
288
+ conn.close()
289
+
290
+ @staticmethod
291
+ def _resolution_recovery_object_names(
292
+ conn: sqlite3.Connection,
293
+ ) -> set[str]:
294
+ placeholders = ",".join("?" for _ in _RESOLUTION_RECOVERY_OBJECTS)
295
+ rows = conn.execute(
296
+ f"SELECT name FROM sqlite_master WHERE name IN ({placeholders})",
297
+ tuple(_RESOLUTION_RECOVERY_OBJECTS),
298
+ ).fetchall()
299
+ return {str(row["name"]) for row in rows}
300
+
301
+ @classmethod
302
+ def _validate_resolution_recovery_schema(
303
+ cls, conn: sqlite3.Connection,
304
+ ) -> None:
305
+ """Reject any same-name, partial, or weakened recovery schema."""
306
+
307
+ placeholders = ",".join("?" for _ in _RESOLUTION_RECOVERY_OBJECTS)
308
+ rows = conn.execute(
309
+ "SELECT type,name,tbl_name,sql FROM sqlite_master "
310
+ f"WHERE name IN ({placeholders})",
311
+ tuple(_RESOLUTION_RECOVERY_OBJECTS),
312
+ ).fetchall()
313
+ objects = {str(row["name"]): row for row in rows}
314
+ if set(objects) != set(_RESOLUTION_RECOVERY_OBJECTS):
315
+ raise CommitmentResolutionSchemaError(
316
+ "commitment resolution recovery schema is incomplete"
317
+ )
318
+ for name, (object_type, table_name, expected_sql) in (
319
+ _RESOLUTION_RECOVERY_OBJECTS.items()
320
+ ):
321
+ row = objects[name]
322
+ if (
323
+ row["type"] != object_type
324
+ or row["tbl_name"] != table_name
325
+ or _sql_signature(row["sql"]) != _sql_signature(expected_sql)
326
+ ):
327
+ raise CommitmentResolutionSchemaError(
328
+ "commitment resolution recovery object is invalid"
329
+ )
330
+
331
+ protected_triggers = {
332
+ str(row["name"]): str(row["tbl_name"])
333
+ for row in conn.execute(
334
+ """SELECT name,tbl_name FROM sqlite_master
335
+ WHERE type='trigger'
336
+ AND tbl_name IN (
337
+ 'commitments',
338
+ 'commitment_resolution_operations'
339
+ )"""
340
+ ).fetchall()
341
+ }
342
+ expected_triggers = {
343
+ name: table_name
344
+ for name, (object_type, table_name, _sql) in (
345
+ _RESOLUTION_RECOVERY_OBJECTS.items()
346
+ )
347
+ if object_type == "trigger"
348
+ }
349
+ if protected_triggers != expected_triggers:
350
+ raise CommitmentResolutionSchemaError(
351
+ "commitment resolution recovery trigger set is invalid"
352
+ )
353
+
354
+ columns = tuple(
355
+ tuple(row)
356
+ for row in conn.execute(
357
+ "PRAGMA table_info(commitment_resolution_operations)"
358
+ ).fetchall()
359
+ )
360
+ if columns != _RESOLUTION_OPERATION_COLUMNS:
361
+ raise CommitmentResolutionSchemaError(
362
+ "commitment resolution recovery columns are invalid"
363
+ )
364
+
365
+ indexes: Dict[str, tuple[str, str]] = {}
366
+ for row in conn.execute(
367
+ "PRAGMA index_list(commitment_resolution_operations)"
368
+ ).fetchall():
369
+ if int(row["unique"]) != 1 or int(row["partial"]) != 0:
370
+ raise CommitmentResolutionSchemaError(
371
+ "commitment resolution recovery indexes are invalid"
372
+ )
373
+ index_name = str(row["name"])
374
+ quoted_index_name = index_name.replace('"', '""')
375
+ index_columns = conn.execute(
376
+ f'PRAGMA index_info("{quoted_index_name}")'
377
+ ).fetchall()
378
+ if len(index_columns) != 1:
379
+ raise CommitmentResolutionSchemaError(
380
+ "commitment resolution recovery indexes are invalid"
381
+ )
382
+ indexes[index_name] = (
383
+ str(row["origin"]), str(index_columns[0]["name"]),
384
+ )
385
+ if indexes != _RESOLUTION_OPERATION_INDEXES:
386
+ raise CommitmentResolutionSchemaError(
387
+ "commitment resolution recovery indexes are invalid"
388
+ )
389
+ if conn.execute(
390
+ "PRAGMA foreign_key_list(commitment_resolution_operations)"
391
+ ).fetchall():
392
+ raise CommitmentResolutionSchemaError(
393
+ "commitment resolution recovery constraints are invalid"
394
+ )
395
+
396
+ def resolution_recovery_readiness(self) -> Dict[str, Any]:
397
+ """Revalidate and publish the generic durable-recovery contract."""
398
+
399
+ with self._lock:
400
+ conn = self._connect()
401
+ try:
402
+ conn.execute("BEGIN")
403
+ self._validate_resolution_recovery_schema(conn)
404
+ conn.commit()
405
+ except Exception:
406
+ conn.rollback()
407
+ raise
408
+ finally:
409
+ conn.close()
410
+ return {
411
+ "ready": True,
412
+ "capability": RESOLUTION_RECOVERY_CAPABILITY,
413
+ "schema": _RESOLUTION_RECOVERY_SCHEMA,
414
+ "version": 1,
415
+ }
416
+
417
+ @staticmethod
418
+ def _row_to_dict(row: sqlite3.Row) -> Dict[str, Any]:
419
+ d = dict(row)
420
+ if d.get("metadata"):
421
+ try:
422
+ d["metadata"] = json.loads(d["metadata"])
423
+ except (json.JSONDecodeError, TypeError):
424
+ d["metadata"] = None
425
+ else:
426
+ d["metadata"] = None
427
+ return d
428
+
429
+ @staticmethod
430
+ def _operation_receipt(row: sqlite3.Row) -> Dict[str, Any]:
431
+ receipt = {
432
+ "schema": "ColonyCommitmentResolutionOperationV1",
433
+ "version": 1,
434
+ "operation_id": row["operation_id"],
435
+ "commitment_id": row["commitment_id"],
436
+ "outcome": row["outcome"],
437
+ "note": row["note"],
438
+ "note_digest": row["note_digest"],
439
+ "resolved_by": row["resolved_by"],
440
+ "status": row["status"],
441
+ "resolved_at": row["resolved_at"],
442
+ "record_digest": row["record_digest"],
443
+ }
444
+ expected = _operation_material(
445
+ operation_id=receipt["operation_id"],
446
+ commitment_id=receipt["commitment_id"],
447
+ outcome=receipt["outcome"],
448
+ note=receipt["note"],
449
+ note_digest=receipt["note_digest"],
450
+ resolved_by=receipt["resolved_by"],
451
+ status=receipt["status"],
452
+ resolved_at=receipt["resolved_at"],
453
+ )
454
+ if receipt != expected:
455
+ raise ValueError(
456
+ "commitment resolution operation integrity check failed"
457
+ )
458
+ return receipt
459
+
460
+ def get_resolution_operation(
461
+ self, commitment_id: str,
462
+ ) -> Optional[Dict[str, Any]]:
463
+ """Return the immutable exact-operation proof for one commitment."""
464
+
465
+ with self._lock:
466
+ conn = self._connect()
467
+ try:
468
+ conn.execute("BEGIN")
469
+ self._validate_resolution_recovery_schema(conn)
470
+ row = conn.execute(
471
+ """SELECT * FROM commitment_resolution_operations
472
+ WHERE commitment_id=?""",
473
+ (commitment_id,),
474
+ ).fetchone()
475
+ receipt = (
476
+ self._operation_receipt(row) if row is not None else None
477
+ )
478
+ conn.commit()
479
+ return receipt
480
+ except Exception:
481
+ conn.rollback()
482
+ raise
483
+ finally:
484
+ conn.close()
485
+
486
+ # ------------------------------------------------------------------
487
+ # CRUD
488
+ # ------------------------------------------------------------------
489
+
490
+ def create(
491
+ self,
492
+ person_id: str,
493
+ description: str,
494
+ due_at: Optional[str] = None,
495
+ priority: int = 50,
496
+ source_type: str = "manual",
497
+ source_context: Optional[str] = None,
498
+ metadata: Optional[Dict[str, Any]] = None,
499
+ *, dedupe: bool = False,
500
+ ) -> Dict[str, Any]:
501
+ """Create, optionally reusing the same open obligation under one write lock."""
502
+ commitment_id = str(uuid.uuid4())
503
+ now = datetime.now(timezone.utc).isoformat()
504
+
505
+ # Validate due_at is in the future AND normalize it to canonical UTC ISO.
506
+ # get_overdue() compares due_at as a STRING against a +00:00 `now`, so a
507
+ # naive or non-UTC-offset stored value sorts wrong — overdue commitments
508
+ # then surface late or never (a forgotten promise). Persist the
509
+ # normalized value, not the caller's raw string.
510
+ if due_at:
511
+ try:
512
+ due_dt = datetime.fromisoformat(due_at)
513
+ if due_dt.tzinfo is None:
514
+ due_dt = due_dt.replace(tzinfo=timezone.utc)
515
+ due_dt = due_dt.astimezone(timezone.utc)
516
+ if due_dt < datetime.now(timezone.utc):
517
+ raise ValueError("due_at must be in the future")
518
+ due_at = due_dt.isoformat()
519
+ except ValueError:
520
+ raise
521
+
522
+ meta_json = json.dumps(metadata) if metadata else None
523
+
524
+ with self._lock:
525
+ conn = self._connect()
526
+ try:
527
+ conn.execute("BEGIN IMMEDIATE")
528
+ if dedupe:
529
+ existing = self._find_open_duplicate(conn, person_id, description)
530
+ if existing is not None:
531
+ conn.commit()
532
+ return {**existing, "deduped": True}
533
+ conn.execute(
534
+ """INSERT INTO commitments
535
+ (id, person_id, description, made_at, due_at, status,
536
+ source_type, source_context, priority, metadata)
537
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
538
+ (commitment_id, person_id, description, now, due_at,
539
+ "pending", source_type, source_context, priority, meta_json),
540
+ )
541
+ conn.commit()
542
+ row = conn.execute(
543
+ "SELECT * FROM commitments WHERE id = ?", (commitment_id,)
544
+ ).fetchone()
545
+ return self._row_to_dict(row)
546
+ finally:
547
+ conn.close()
548
+
549
+ def get(self, commitment_id: str) -> Optional[Dict[str, Any]]:
550
+ """Get a single commitment by ID. Returns None if not found."""
551
+ with self._lock:
552
+ conn = self._connect()
553
+ try:
554
+ row = conn.execute(
555
+ "SELECT * FROM commitments WHERE id = ?", (commitment_id,)
556
+ ).fetchone()
557
+ return self._row_to_dict(row) if row else None
558
+ finally:
559
+ conn.close()
560
+
561
+ def list(
562
+ self,
563
+ person_id: Optional[str] = None,
564
+ status: Optional[List[str]] = None,
565
+ overdue_only: bool = False,
566
+ limit: int = 50,
567
+ offset: int = 0,
568
+ ) -> Dict[str, Any]:
569
+ """List commitments with optional filters.
570
+
571
+ Returns dict with commitments, total, limit, offset.
572
+ """
573
+ conditions: List[str] = []
574
+ params: List[Any] = []
575
+
576
+ if person_id:
577
+ conditions.append("person_id = ?")
578
+ params.append(person_id)
579
+
580
+ if overdue_only:
581
+ conditions.append("status = 'overdue'")
582
+ elif status:
583
+ placeholders = ",".join("?" for _ in status)
584
+ conditions.append(f"status IN ({placeholders})")
585
+ params.extend(status)
586
+
587
+ where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
588
+
589
+ with self._lock:
590
+ conn = self._connect()
591
+ try:
592
+ total = conn.execute(
593
+ f"SELECT COUNT(*) FROM commitments {where}", params
594
+ ).fetchone()[0]
595
+
596
+ rows = conn.execute(
597
+ f"SELECT * FROM commitments {where} ORDER BY made_at DESC LIMIT ? OFFSET ?",
598
+ params + [limit, offset],
599
+ ).fetchall()
600
+
601
+ return {
602
+ "commitments": [self._row_to_dict(r) for r in rows],
603
+ "total": total,
604
+ "limit": limit,
605
+ "offset": offset,
606
+ }
607
+ finally:
608
+ conn.close()
609
+
610
+ def update(
611
+ self,
612
+ commitment_id: str,
613
+ status: Optional[str] = None,
614
+ fulfilled_at: Optional[str] = None,
615
+ description: Optional[str] = None,
616
+ due_at: Optional[str] = None,
617
+ priority: Optional[int] = None,
618
+ metadata: Optional[Dict[str, Any]] = None,
619
+ ) -> Optional[Dict[str, Any]]:
620
+ """Update a commitment. Returns updated record or None if not found.
621
+
622
+ Validates status transitions:
623
+ pending → fulfilled, overdue, cancelled
624
+ overdue → fulfilled, cancelled
625
+ fulfilled, cancelled → no transitions allowed (terminal)
626
+ """
627
+ VALID_TRANSITIONS = {
628
+ "pending": {"fulfilled", "overdue", "cancelled"},
629
+ "overdue": {"fulfilled", "cancelled"},
630
+ }
631
+
632
+ with self._lock:
633
+ conn = self._connect()
634
+ try:
635
+ conn.execute("BEGIN IMMEDIATE")
636
+ self._validate_resolution_recovery_schema(conn)
637
+ current = conn.execute(
638
+ "SELECT * FROM commitments WHERE id = ?", (commitment_id,)
639
+ ).fetchone()
640
+ if not current:
641
+ conn.commit()
642
+ return None
643
+
644
+ current_status = current["status"]
645
+
646
+ if status and status != current_status:
647
+ allowed = VALID_TRANSITIONS.get(current_status, set())
648
+ if status not in allowed:
649
+ raise ValueError(
650
+ f"Cannot transition from '{current_status}' to '{status}'"
651
+ )
652
+
653
+ # Auto-fill fulfilled_at when transitioning to fulfilled
654
+ if status == "fulfilled" and not fulfilled_at:
655
+ fulfilled_at = datetime.now(timezone.utc).isoformat()
656
+
657
+ # Build UPDATE statement
658
+ updates: List[str] = []
659
+ params: List[Any] = []
660
+
661
+ if status is not None:
662
+ updates.append("status = ?")
663
+ params.append(status)
664
+ if fulfilled_at is not None:
665
+ updates.append("fulfilled_at = ?")
666
+ params.append(fulfilled_at)
667
+ if description is not None:
668
+ updates.append("description = ?")
669
+ params.append(description)
670
+ if due_at is not None:
671
+ updates.append("due_at = ?")
672
+ params.append(due_at)
673
+ if priority is not None:
674
+ updates.append("priority = ?")
675
+ params.append(priority)
676
+ if metadata is not None:
677
+ updates.append("metadata = ?")
678
+ params.append(json.dumps(metadata))
679
+
680
+ if not updates:
681
+ conn.commit()
682
+ return self._row_to_dict(current)
683
+
684
+ params.append(commitment_id)
685
+ conn.execute(
686
+ f"UPDATE commitments SET {', '.join(updates)} WHERE id = ?",
687
+ params,
688
+ )
689
+ conn.commit()
690
+
691
+ row = conn.execute(
692
+ "SELECT * FROM commitments WHERE id = ?", (commitment_id,)
693
+ ).fetchone()
694
+ return self._row_to_dict(row)
695
+ except Exception:
696
+ conn.rollback()
697
+ raise
698
+ finally:
699
+ conn.close()
700
+
701
+ def delete(self, commitment_id: str) -> bool:
702
+ """Delete a commitment. Only allowed for terminal states.
703
+
704
+ Returns True if deleted, False if not found or not terminal.
705
+ """
706
+ with self._lock:
707
+ conn = self._connect()
708
+ try:
709
+ conn.execute("BEGIN IMMEDIATE")
710
+ self._validate_resolution_recovery_schema(conn)
711
+ current = conn.execute(
712
+ "SELECT status FROM commitments WHERE id = ?", (commitment_id,)
713
+ ).fetchone()
714
+ if not current:
715
+ conn.commit()
716
+ return False
717
+ if current["status"] not in ("fulfilled", "cancelled"):
718
+ conn.commit()
719
+ return False
720
+ operation = conn.execute(
721
+ """SELECT 1 FROM commitment_resolution_operations
722
+ WHERE commitment_id=?""",
723
+ (commitment_id,),
724
+ ).fetchone()
725
+ if operation is not None:
726
+ conn.commit()
727
+ return False
728
+ conn.execute(
729
+ "DELETE FROM commitments WHERE id = ?", (commitment_id,)
730
+ )
731
+ conn.commit()
732
+ return True
733
+ except Exception:
734
+ conn.rollback()
735
+ raise
736
+ finally:
737
+ conn.close()
738
+
739
+ # ------------------------------------------------------------------
740
+ # Query helpers
741
+ # ------------------------------------------------------------------
742
+
743
+ def get_overdue(self) -> List[Dict[str, Any]]:
744
+ """Get open commitments (pending OR already flipped to overdue) whose
745
+ due_at has passed. Including 'overdue' matters: the condition worker
746
+ flips pending→overdue, and a pending-only query would make flipped
747
+ items invisible to everything that surfaces owed work."""
748
+ now = datetime.now(timezone.utc).isoformat()
749
+ with self._lock:
750
+ conn = self._connect()
751
+ try:
752
+ rows = conn.execute(
753
+ """SELECT * FROM commitments
754
+ WHERE status IN ('pending', 'overdue')
755
+ AND due_at IS NOT NULL AND due_at < ?
756
+ ORDER BY due_at ASC""",
757
+ (now,),
758
+ ).fetchall()
759
+ return [self._row_to_dict(r) for r in rows]
760
+ finally:
761
+ conn.close()
762
+
763
+ def get_pending_for_person(self, person_id: str) -> List[Dict[str, Any]]:
764
+ """Get OPEN commitments (pending + overdue) for a specific person.
765
+ Callers use this as "what is still owed" — an item that went overdue
766
+ is owed more, not less, so it must stay in this list (it is also the
767
+ dedup list the introspection extractor sees)."""
768
+ with self._lock:
769
+ conn = self._connect()
770
+ try:
771
+ rows = conn.execute(
772
+ """SELECT * FROM commitments
773
+ WHERE person_id = ? AND status IN ('pending', 'overdue')
774
+ ORDER BY priority DESC, due_at ASC""",
775
+ (person_id,),
776
+ ).fetchall()
777
+ return [self._row_to_dict(r) for r in rows]
778
+ finally:
779
+ conn.close()
780
+
781
+ def _find_open_duplicate(self, conn, person_id, description):
782
+ norm = _normalize_desc(description)
783
+ if not norm:
784
+ return None
785
+ rows = conn.execute(
786
+ "SELECT * FROM commitments WHERE person_id=? AND status IN (?,?) ORDER BY made_at DESC",
787
+ (person_id, *OPEN_STATUSES))
788
+ for row in rows:
789
+ if _similar_desc(norm, _normalize_desc(row["description"] or "")):
790
+ return self._row_to_dict(row)
791
+ return None
792
+
793
+ def find_open_duplicate(
794
+ self, person_id: str, description: str
795
+ ) -> Optional[Dict[str, Any]]:
796
+ """Inspect the existing normalized/containment/token-overlap predicate.
797
+
798
+ Writers needing deduplication must use create(dedupe=True), which checks
799
+ and inserts in the same SQLite transaction across independent processes.
800
+ """
801
+ with self._lock:
802
+ conn = self._connect()
803
+ try:
804
+ return self._find_open_duplicate(conn, person_id, description)
805
+ finally:
806
+ conn.close()
807
+
808
+ # ------------------------------------------------------------------
809
+ # Resolution: settle an item with a reason the system can learn from
810
+ # ------------------------------------------------------------------
811
+
812
+ def resolve(
813
+ self,
814
+ commitment_id: str,
815
+ outcome: str = "done",
816
+ note: Optional[str] = None,
817
+ resolved_by: str = "owner",
818
+ operation_id: Optional[str] = None,
819
+ ) -> Optional[Dict[str, Any]]:
820
+ """Settle a commitment with an outcome (done | invalid | duplicate |
821
+ wont_do | obsolete). Legacy calls remain state-idempotent. A caller
822
+ supplying ``operation_id`` gets exact idempotency: an existing terminal
823
+ row is accepted only when its stored operation, outcome, note, resolver,
824
+ and resulting status all match. Emits the matching commitment.* event
825
+ on an actual transition."""
826
+ status = OUTCOME_TO_STATUS.get(outcome)
827
+ if status is None:
828
+ raise ValueError(
829
+ f"unknown outcome '{outcome}' (expected one of "
830
+ f"{sorted(OUTCOME_TO_STATUS)})")
831
+ if operation_id is not None and (
832
+ type(operation_id) is not str
833
+ or not operation_id
834
+ or operation_id != operation_id.strip()
835
+ or len(operation_id) > 192
836
+ ):
837
+ raise ValueError("commitment resolution operation_id is invalid")
838
+ full_note = note or ""
839
+ note_text = full_note[:300]
840
+ resolver = str(resolved_by)
841
+ expected_resolution = {
842
+ "outcome": outcome,
843
+ "note": note_text,
844
+ "note_digest": hashlib.sha256(full_note.encode("utf-8")).hexdigest(),
845
+ "by": resolver,
846
+ }
847
+ if operation_id is not None:
848
+ expected_resolution["operation_id"] = operation_id
849
+
850
+ transitioned = False
851
+ row: Optional[Dict[str, Any]] = None
852
+ with self._lock:
853
+ conn = self._connect()
854
+ try:
855
+ conn.execute("BEGIN IMMEDIATE")
856
+ self._validate_resolution_recovery_schema(conn)
857
+ stored = conn.execute(
858
+ "SELECT * FROM commitments WHERE id = ?", (commitment_id,)
859
+ ).fetchone()
860
+ stored_operation = conn.execute(
861
+ """SELECT * FROM commitment_resolution_operations
862
+ WHERE commitment_id=?""",
863
+ (commitment_id,),
864
+ ).fetchone()
865
+ operation = (
866
+ self._operation_receipt(stored_operation)
867
+ if stored_operation is not None else None
868
+ )
869
+ if stored is None:
870
+ if operation is not None:
871
+ raise CommitmentResolutionConflict(
872
+ "operation-bound commitment row is missing"
873
+ )
874
+ conn.commit()
875
+ return None
876
+ current = self._row_to_dict(stored)
877
+ if current["status"] in ("fulfilled", "cancelled"):
878
+ if operation_id is not None:
879
+ expected_operation = {
880
+ "operation_id": operation_id,
881
+ "commitment_id": commitment_id,
882
+ "outcome": outcome,
883
+ "note": note_text,
884
+ "note_digest": expected_resolution["note_digest"],
885
+ "resolved_by": resolver,
886
+ "status": status,
887
+ }
888
+ if (
889
+ operation is None
890
+ or any(
891
+ operation.get(name) != value
892
+ for name, value in expected_operation.items()
893
+ )
894
+ or current["status"] != status
895
+ ):
896
+ raise CommitmentResolutionConflict(
897
+ "terminal commitment has a different operation"
898
+ )
899
+ conn.commit()
900
+ return current
901
+ if current["status"] not in OPEN_STATUSES:
902
+ raise CommitmentResolutionConflict(
903
+ "commitment is not open or terminal"
904
+ )
905
+ if operation is not None:
906
+ raise CommitmentResolutionConflict(
907
+ "open commitment already has a resolution operation"
908
+ )
909
+
910
+ meta = dict(current.get("metadata") or {})
911
+ resolved_at = datetime.now(timezone.utc).isoformat()
912
+ meta["resolution"] = {
913
+ **expected_resolution,
914
+ "at": resolved_at,
915
+ }
916
+ if operation_id is not None:
917
+ operation = _operation_material(
918
+ operation_id=operation_id,
919
+ commitment_id=commitment_id,
920
+ outcome=outcome,
921
+ note=note_text,
922
+ note_digest=expected_resolution["note_digest"],
923
+ resolved_by=resolver,
924
+ status=status,
925
+ resolved_at=resolved_at,
926
+ )
927
+ conn.execute(
928
+ """INSERT INTO commitment_resolution_operations
929
+ (operation_id,commitment_id,outcome,note,note_digest,
930
+ resolved_by,status,resolved_at,record_digest)
931
+ VALUES (?,?,?,?,?,?,?,?,?)""",
932
+ (
933
+ operation["operation_id"],
934
+ operation["commitment_id"], operation["outcome"],
935
+ operation["note"], operation["note_digest"],
936
+ operation["resolved_by"], operation["status"],
937
+ operation["resolved_at"], operation["record_digest"],
938
+ ),
939
+ )
940
+ fulfilled_at = (
941
+ resolved_at
942
+ if status == "fulfilled" else current.get("fulfilled_at")
943
+ )
944
+ changed = conn.execute(
945
+ """UPDATE commitments
946
+ SET status=?,fulfilled_at=?,metadata=?
947
+ WHERE id=? AND status=?""",
948
+ (
949
+ status, fulfilled_at, json.dumps(meta),
950
+ commitment_id, current["status"],
951
+ ),
952
+ ).rowcount
953
+ if changed != 1:
954
+ raise CommitmentResolutionConflict(
955
+ "commitment changed before resolution was recorded"
956
+ )
957
+ conn.commit()
958
+ stored = conn.execute(
959
+ "SELECT * FROM commitments WHERE id = ?", (commitment_id,)
960
+ ).fetchone()
961
+ row = self._row_to_dict(stored)
962
+ transitioned = True
963
+ except Exception:
964
+ conn.rollback()
965
+ raise
966
+ finally:
967
+ conn.close()
968
+
969
+ if transitioned and row is not None:
970
+ try:
971
+ from apsimo.events.broadcaster import emit
972
+ emit(f"commitment.{status}", {
973
+ "commitment_id": row["id"],
974
+ "person_id": row["person_id"],
975
+ "outcome": outcome,
976
+ "resolved_by": resolver,
977
+ })
978
+ except Exception:
979
+ pass
980
+ return row
981
+
982
+ def resolution_stats(self, days: int = 30) -> Dict[str, Any]:
983
+ """Per-source_type counts of how items created in the window ended up:
984
+ fulfilled, cancelled (with outcome breakdown), still open. This is the
985
+ calibration signal for whatever generates items — a source whose items
986
+ keep getting cancelled as invalid should get more conservative."""
987
+ cutoff = datetime.now(timezone.utc).timestamp() - days * 86400
988
+ with self._lock:
989
+ conn = self._connect()
990
+ try:
991
+ rows = conn.execute("SELECT * FROM commitments").fetchall()
992
+ finally:
993
+ conn.close()
994
+ by_source: Dict[str, Dict[str, Any]] = {}
995
+ for r in rows:
996
+ d = self._row_to_dict(r)
997
+ try:
998
+ made = datetime.fromisoformat(d["made_at"]).timestamp()
999
+ except (ValueError, TypeError):
1000
+ continue
1001
+ if made < cutoff:
1002
+ continue
1003
+ src = d.get("source_type") or "manual"
1004
+ s = by_source.setdefault(src, {
1005
+ "created": 0, "fulfilled": 0, "cancelled": 0,
1006
+ "open": 0, "outcomes": {},
1007
+ })
1008
+ s["created"] += 1
1009
+ status = d.get("status")
1010
+ if status in OPEN_STATUSES:
1011
+ s["open"] += 1
1012
+ elif status in ("fulfilled", "cancelled"):
1013
+ s[status] += 1
1014
+ res = (d.get("metadata") or {}).get("resolution") or {}
1015
+ oc = res.get("outcome") or (
1016
+ "done" if status == "fulfilled" else "unspecified")
1017
+ s["outcomes"][oc] = s["outcomes"].get(oc, 0) + 1
1018
+ return {"days": days, "by_source": by_source}
1019
+
1020
+ def recent_rejections(
1021
+ self, limit: int = 6,
1022
+ source_types: Optional[List[str]] = None,
1023
+ ) -> List[Dict[str, Any]]:
1024
+ """Most recently cancelled items judged invalid or duplicate — the
1025
+ negative examples the extraction side injects into its prompt so the
1026
+ same bad item is not recorded again."""
1027
+ with self._lock:
1028
+ conn = self._connect()
1029
+ try:
1030
+ rows = conn.execute(
1031
+ """SELECT * FROM commitments WHERE status = 'cancelled'
1032
+ ORDER BY made_at DESC LIMIT 200""",
1033
+ ).fetchall()
1034
+ finally:
1035
+ conn.close()
1036
+ out: List[Dict[str, Any]] = []
1037
+ for r in rows:
1038
+ d = self._row_to_dict(r)
1039
+ if source_types and (d.get("source_type") or "manual") not in source_types:
1040
+ continue
1041
+ res = (d.get("metadata") or {}).get("resolution") or {}
1042
+ if res.get("outcome") not in ("invalid", "duplicate"):
1043
+ continue
1044
+ out.append({
1045
+ "description": d.get("description") or "",
1046
+ "outcome": res.get("outcome"),
1047
+ "note": res.get("note") or "",
1048
+ "at": res.get("at") or "",
1049
+ })
1050
+ if len(out) >= limit:
1051
+ break
1052
+ return out