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,1081 @@
1
+ """Initiative store for multi-agent Colony.
2
+
3
+ Provides:
4
+ - InitiativeStore: SQLite persistence for initiatives
5
+ - Assignment history tracking
6
+ - Dead letter queue
7
+ - Timeout and expiry checks
8
+ """
9
+
10
+ import hashlib
11
+ import json
12
+ import logging
13
+ import shutil
14
+ import sqlite3
15
+ import uuid
16
+ from contextlib import closing
17
+ from datetime import datetime, timedelta, timezone
18
+ from pathlib import Path
19
+ from typing import Any, Dict, List, Optional, Tuple
20
+
21
+ from .models import AssignmentHistory, InitiativeStatus, StoredInitiative
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # Maximum pending initiatives before rejecting new ones
26
+ MAX_PENDING_INITIATIVES = 1000
27
+
28
+
29
+ def get_state_dir() -> Path:
30
+ """Get Colony state directory."""
31
+ import os
32
+ state_dir = os.environ.get("COLONY_STATE_DIR")
33
+ if state_dir:
34
+ return Path(state_dir)
35
+ return Path.home() / ".colony" / "data"
36
+
37
+
38
+ class InitiativeStore:
39
+ """Manages initiative persistence with SQLite."""
40
+
41
+ def __init__(self, state_dir: Optional[Path] = None):
42
+ self._state_dir = Path(state_dir) if state_dir else get_state_dir()
43
+ self._state_dir.mkdir(parents=True, exist_ok=True)
44
+ self._db_path = self._state_dir / "initiatives.db"
45
+ self._backup_path = self._state_dir / "initiatives.db.backup"
46
+ self._dlq_path = self._state_dir / "dead-letter-queue.jsonl"
47
+
48
+ self._db = self._init_db()
49
+
50
+ def _init_db(self) -> sqlite3.Connection:
51
+ """Initialize database with recovery."""
52
+ try:
53
+ return self._connect()
54
+ except sqlite3.DatabaseError:
55
+ logger.warning("initiatives.db corrupted, attempting recovery")
56
+
57
+ if self._backup_path.exists():
58
+ shutil.copy(self._backup_path, self._db_path)
59
+ logger.info("Restored initiatives.db from backup")
60
+ else:
61
+ self._db_path.unlink(missing_ok=True)
62
+ logger.warning("No backup available, starting fresh")
63
+
64
+ return self._connect()
65
+
66
+ def _connect(self) -> sqlite3.Connection:
67
+ """Connect to database with WAL mode."""
68
+ # check_same_thread=False allows TestClient to access the DB from
69
+ # a different thread (test thread vs event loop thread).
70
+ conn = sqlite3.connect(self._db_path, check_same_thread=False)
71
+ conn.row_factory = sqlite3.Row
72
+
73
+ # WAL mode for better crash recovery
74
+ conn.execute("PRAGMA journal_mode=WAL")
75
+ conn.execute("PRAGMA synchronous=NORMAL")
76
+ conn.execute("PRAGMA busy_timeout=5000")
77
+
78
+ self._create_tables(conn)
79
+
80
+ return conn
81
+
82
+ def _create_tables(self, conn: sqlite3.Connection) -> None:
83
+ """Create initiatives and history tables."""
84
+ conn.execute(
85
+ """
86
+ CREATE TABLE IF NOT EXISTS initiatives (
87
+ id TEXT PRIMARY KEY,
88
+ dedup_key TEXT UNIQUE,
89
+ type TEXT NOT NULL,
90
+ description TEXT NOT NULL,
91
+ priority REAL DEFAULT 0.5,
92
+ rationale TEXT,
93
+ action_hint TEXT,
94
+ entity_id TEXT,
95
+
96
+ source_type TEXT,
97
+ source_id TEXT,
98
+ created_by TEXT,
99
+
100
+ status TEXT DEFAULT 'pending',
101
+ assigned_agent_id TEXT,
102
+ assigned_agent_name TEXT,
103
+ assigned_at TIMESTAMP,
104
+ acknowledged_at TIMESTAMP,
105
+ completed_at TIMESTAMP,
106
+ cancelled_at TIMESTAMP,
107
+ cancelled_by TEXT,
108
+ cancelled_reason TEXT,
109
+ failed_at TIMESTAMP,
110
+ failed_reason TEXT,
111
+
112
+ attempt_count INTEGER DEFAULT 0,
113
+ max_attempts INTEGER DEFAULT 3,
114
+ timeout_seconds INTEGER DEFAULT 300,
115
+ last_attempt_at TIMESTAMP,
116
+
117
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
118
+ expires_at TIMESTAMP,
119
+
120
+ delivery_mode TEXT DEFAULT 'websocket',
121
+ delivery_attempts INTEGER DEFAULT 0,
122
+ last_delivery_at TIMESTAMP,
123
+ delivery_failed_at TIMESTAMP,
124
+ delivery_failed_reason TEXT,
125
+
126
+ result TEXT,
127
+ result_metadata TEXT DEFAULT '{}',
128
+
129
+ preferred_agent_id TEXT,
130
+ stale_reason TEXT,
131
+ recovery_reason TEXT,
132
+ job_id TEXT, -- v0.13.0: linked task-queue job
133
+ context TEXT -- v0.16.0: situational context snapshot (JSON)
134
+ )
135
+ """
136
+ )
137
+
138
+ # Column migrations: v0.13.0 job_id, v0.16.0 context.
139
+ # Old rows keep context NULL — the API serializer maps that to {}.
140
+ try:
141
+ cursor = conn.execute("PRAGMA table_info(initiatives)")
142
+ columns = {row[1] for row in cursor.fetchall()}
143
+ if "job_id" not in columns:
144
+ conn.execute("ALTER TABLE initiatives ADD COLUMN job_id TEXT")
145
+ conn.commit()
146
+ logger.info("Migrated initiatives table: added job_id column")
147
+ if "context" not in columns:
148
+ conn.execute("ALTER TABLE initiatives ADD COLUMN context TEXT")
149
+ conn.commit()
150
+ logger.info("Migrated initiatives table: added context column")
151
+ if "dedup_base" not in columns:
152
+ # v0.21.23: logical (un-bucketed) dedup key for the cross-period
153
+ # in-flight guard. Old rows keep it NULL (they behave as before).
154
+ conn.execute("ALTER TABLE initiatives ADD COLUMN dedup_base TEXT")
155
+ conn.commit()
156
+ logger.info("Migrated initiatives table: added dedup_base column")
157
+ except Exception as exc:
158
+ logger.warning("Initiative migration check failed (non-fatal): %s", exc)
159
+
160
+ # Indexes
161
+ conn.execute(
162
+ "CREATE INDEX IF NOT EXISTS idx_initiatives_status ON initiatives(status)"
163
+ )
164
+ conn.execute(
165
+ "CREATE INDEX IF NOT EXISTS idx_initiatives_assigned ON initiatives(assigned_agent_id)"
166
+ )
167
+ conn.execute(
168
+ "CREATE INDEX IF NOT EXISTS idx_initiatives_dedup ON initiatives(dedup_key)"
169
+ )
170
+ conn.execute(
171
+ "CREATE INDEX IF NOT EXISTS idx_initiatives_dedup_base ON initiatives(dedup_base)"
172
+ )
173
+ conn.execute(
174
+ "CREATE INDEX IF NOT EXISTS idx_initiatives_priority ON initiatives(priority DESC)"
175
+ )
176
+ conn.execute(
177
+ "CREATE INDEX IF NOT EXISTS idx_initiatives_created ON initiatives(created_at DESC)"
178
+ )
179
+
180
+ # Assignment history table
181
+ conn.execute(
182
+ """
183
+ CREATE TABLE IF NOT EXISTS assignment_history (
184
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
185
+ initiative_id TEXT NOT NULL,
186
+ agent_id TEXT NOT NULL,
187
+ agent_name TEXT,
188
+ action TEXT NOT NULL,
189
+ timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
190
+ details TEXT,
191
+
192
+ FOREIGN KEY (initiative_id) REFERENCES initiatives(id)
193
+ )
194
+ """
195
+ )
196
+ conn.execute(
197
+ "CREATE INDEX IF NOT EXISTS idx_history_initiative ON assignment_history(initiative_id)"
198
+ )
199
+ conn.execute(
200
+ "CREATE INDEX IF NOT EXISTS idx_history_agent ON assignment_history(agent_id)"
201
+ )
202
+
203
+ conn.commit()
204
+
205
+ # ------------------------------------------------------------------
206
+ # CRUD Operations
207
+ # ------------------------------------------------------------------
208
+
209
+ def create(self, *args, **kwargs) -> StoredInitiative:
210
+ """Create an initiative. Back-compat shim returning just the initiative; the
211
+ dispatch loop uses :meth:`create_with_outcome` to learn whether it was newly
212
+ created vs deduped."""
213
+ return self.create_with_outcome(*args, **kwargs)[0]
214
+
215
+ def create_with_outcome(
216
+ self,
217
+ type: str,
218
+ description: str,
219
+ priority: float = 0.5,
220
+ rationale: str = "",
221
+ action_hint: Optional[str] = None,
222
+ entity_id: Optional[str] = None,
223
+ dedup_key: Optional[str] = None,
224
+ dedup_base: Optional[str] = None,
225
+ source_type: Optional[str] = None,
226
+ source_id: Optional[str] = None,
227
+ created_by: Optional[str] = None,
228
+ timeout_seconds: int = 300,
229
+ expires_at: Optional[datetime] = None,
230
+ preferred_agent_id: Optional[str] = None,
231
+ job_id: Optional[str] = None,
232
+ context: Optional[Dict[str, Any]] = None,
233
+ **extra,
234
+ ) -> Tuple[StoredInitiative, str]:
235
+ """Create an initiative and report WHAT HAPPENED so the caller knows whether to
236
+ dispatch. The outcome is one of:
237
+
238
+ "created" — a fresh row was inserted; dispatch it.
239
+ "reactivated" — a previously FAILED instance was reset to pending; dispatch it.
240
+ "deduped_active" — an active instance already exists (same dedup_key, or any active
241
+ one sharing dedup_base across a period rollover); do NOT dispatch.
242
+ "deduped_terminal" — this period's instance already completed/cancelled; do NOT
243
+ dispatch (a recurring type re-arms on its next-period dedup_key).
244
+
245
+ This replaces the old id-comparison heuristic in the loop, which could never tell a
246
+ fresh create from a dedup hit (the store assigns its own uuid).
247
+ """
248
+ # Check pending limit
249
+ pending_count = self.count(status=["pending"])
250
+ if pending_count >= MAX_PENDING_INITIATIVES:
251
+ raise ValueError(
252
+ f"Too many pending initiatives (max {MAX_PENDING_INITIATIVES})"
253
+ )
254
+
255
+ # Cross-period in-flight guard: never create a second instance of the same logical
256
+ # work while one is still active, even after the period (dedup_key) rolls over.
257
+ if dedup_base:
258
+ active = self.get_active_by_dedup_base(dedup_base)
259
+ if active:
260
+ logger.debug(
261
+ "dedup_base %s already has active initiative %s; suppressing",
262
+ dedup_base, active.id,
263
+ )
264
+ return active, "deduped_active"
265
+
266
+ # A review of an old proposal may complete after its creation
267
+ # bucket expired. Anchor unchanged-condition re-review to that
268
+ # actual settlement, not immediately to the current wall bucket.
269
+ from .native_work import review_condition
270
+ evidence = review_condition({
271
+ "type": type, "source_type": source_type, "created_by": created_by,
272
+ "action_hint": action_hint, "entity_id": entity_id,
273
+ "description": description, "context": json.dumps(context or {}),
274
+ })
275
+ if evidence is not None:
276
+ from ..intelligence.components.initiative_engine import RECURRENCE_INTERVALS_SECS
277
+ interval = RECURRENCE_INTERVALS_SECS.get(type)
278
+ settled = self._db.execute(
279
+ "SELECT * FROM initiatives WHERE dedup_base=? AND status='completed' "
280
+ "AND completed_at IS NOT NULL "
281
+ "AND json_extract(context,'$.native_review') IS NOT NULL "
282
+ "ORDER BY completed_at DESC,created_at DESC LIMIT 1", (dedup_base,),
283
+ ).fetchone()
284
+ if settled is not None and interval and review_condition(settled) == evidence:
285
+ completed = datetime.fromisoformat(settled['completed_at'])
286
+ if completed.tzinfo is not None and (
287
+ datetime.now(timezone.utc)-completed).total_seconds() < interval:
288
+ return StoredInitiative.from_row(dict(settled)), "deduped_terminal"
289
+ # Stable evidence plus the last settled generation admits a
290
+ # real change even within one old bucket, and re-arms when due.
291
+ # Candidate UUIDs, observation time and prose never form this key.
292
+ material = {"condition": evidence, "period": dedup_key,
293
+ "after": settled['id'] if settled is not None else None}
294
+ digest = hashlib.sha256(json.dumps(material, sort_keys=True).encode()).hexdigest()
295
+ dedup_key = f"{dedup_base}:review:{digest}"
296
+
297
+ # Period-key dedup (at most one row per dedup_key; UNIQUE).
298
+ if dedup_key:
299
+ existing = self.get_by_dedup_key(dedup_key)
300
+ if existing:
301
+ if existing.is_active:
302
+ logger.info(
303
+ "Initiative with dedup_key %s already exists: %s",
304
+ dedup_key, existing.id,
305
+ )
306
+ return existing, "deduped_active"
307
+ # FAILED reactivates so the work can be retried.
308
+ if existing.status == InitiativeStatus.FAILED.value:
309
+ logger.info(
310
+ "Reactivating failed initiative %s with dedup_key %s",
311
+ existing.id, dedup_key,
312
+ )
313
+ reactivated = self.update(
314
+ existing.id,
315
+ status=InitiativeStatus.PENDING.value,
316
+ failed_at=None,
317
+ failed_reason=None,
318
+ attempt_count=0,
319
+ assigned_agent_id=None,
320
+ assigned_agent_name=None,
321
+ assigned_at=None,
322
+ acknowledged_at=None,
323
+ )
324
+ return reactivated, "reactivated"
325
+ # Completed/cancelled for THIS period — already ran; don't re-fire within it.
326
+ # A recurring type gets a new dedup_key next period and re-arms there.
327
+ logger.debug(
328
+ "dedup_key %s already ran this period (status=%s); suppressing",
329
+ dedup_key, existing.status,
330
+ )
331
+ return existing, "deduped_terminal"
332
+
333
+ # Bug 26: Validate priority range
334
+ priority = max(0.0, min(1.0, priority))
335
+
336
+ initiative_id = str(uuid.uuid4())
337
+ now = datetime.now(timezone.utc)
338
+
339
+ self._db.execute(
340
+ """
341
+ INSERT INTO initiatives (
342
+ id, dedup_key, dedup_base, type, description, priority, rationale,
343
+ action_hint, entity_id, source_type, source_id, created_by,
344
+ timeout_seconds, expires_at, preferred_agent_id, job_id,
345
+ context, created_at
346
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
347
+ """,
348
+ [
349
+ initiative_id,
350
+ dedup_key,
351
+ dedup_base,
352
+ type,
353
+ description,
354
+ priority,
355
+ rationale,
356
+ action_hint,
357
+ entity_id,
358
+ source_type,
359
+ source_id,
360
+ created_by,
361
+ timeout_seconds,
362
+ expires_at.isoformat() if expires_at else None,
363
+ preferred_agent_id,
364
+ job_id,
365
+ json.dumps(context) if context is not None else None,
366
+ now.isoformat(),
367
+ ],
368
+ )
369
+ self._db.commit()
370
+
371
+ return self.get(initiative_id), "created"
372
+
373
+ def get(self, initiative_id: str) -> Optional[StoredInitiative]:
374
+ """Get initiative by ID."""
375
+ cursor = self._db.execute(
376
+ "SELECT * FROM initiatives WHERE id = ?",
377
+ [initiative_id],
378
+ )
379
+ row = cursor.fetchone()
380
+ if row:
381
+ return StoredInitiative.from_row(dict(row))
382
+ return None
383
+
384
+ def get_by_dedup_key(self, dedup_key: str) -> Optional[StoredInitiative]:
385
+ """Get initiative by dedup key."""
386
+ cursor = self._db.execute(
387
+ "SELECT * FROM initiatives WHERE dedup_key = ?",
388
+ [dedup_key],
389
+ )
390
+ row = cursor.fetchone()
391
+ if row:
392
+ return StoredInitiative.from_row(dict(row))
393
+ return None
394
+
395
+ def get_active_by_dedup_base(self, dedup_base: str) -> Optional[StoredInitiative]:
396
+ """The most recent ACTIVE initiative sharing this logical (un-bucketed) key, or None.
397
+ Used to suppress a duplicate when a recurring initiative's period rolls over while a
398
+ prior instance is still in flight."""
399
+ cursor = self._db.execute(
400
+ "SELECT * FROM initiatives WHERE dedup_base = ? "
401
+ "AND status IN ('pending', 'assigned', 'acknowledged') "
402
+ "ORDER BY created_at DESC LIMIT 1",
403
+ [dedup_base],
404
+ )
405
+ row = cursor.fetchone()
406
+ if row:
407
+ return StoredInitiative.from_row(dict(row))
408
+ return None
409
+
410
+ # Columns accepted by update(). Kept in sync with _create_tables() so a
411
+ # typo or a renamed column fails loudly at the API boundary instead of
412
+ # being swallowed by an outer except clause that calls the failure
413
+ # "non-fatal" (see autonomy/loop.py:_phase_ghost_cleanup for the bug class
414
+ # this whitelist was added to prevent).
415
+ _UPDATABLE_COLUMNS = frozenset({
416
+ "dedup_key", "dedup_base", "type", "description", "priority", "rationale",
417
+ "action_hint", "entity_id", "source_type", "source_id", "created_by",
418
+ "status", "assigned_agent_id", "assigned_agent_name", "assigned_at",
419
+ "acknowledged_at", "completed_at", "cancelled_at", "cancelled_by",
420
+ "cancelled_reason", "failed_at", "failed_reason",
421
+ "attempt_count", "max_attempts", "timeout_seconds", "last_attempt_at",
422
+ "expires_at",
423
+ "delivery_mode", "delivery_attempts", "last_delivery_at",
424
+ "delivery_failed_at", "delivery_failed_reason",
425
+ "result", "result_metadata",
426
+ "preferred_agent_id", "stale_reason", "recovery_reason", "job_id",
427
+ "context",
428
+ })
429
+
430
+ def update(self, initiative_id: str, **updates) -> Optional[StoredInitiative]:
431
+ """Update initiative fields."""
432
+ if not updates:
433
+ return self.get(initiative_id)
434
+
435
+ unknown = set(updates) - self._UPDATABLE_COLUMNS
436
+ if unknown:
437
+ raise ValueError(
438
+ f"Unknown initiative column(s): {sorted(unknown)}"
439
+ )
440
+
441
+ # Build SET clause
442
+ set_parts = []
443
+ params = []
444
+
445
+ for key, value in updates.items():
446
+ if key in ("result_metadata", "context"):
447
+ set_parts.append(f"{key} = ?")
448
+ if value is None:
449
+ params.append(None)
450
+ else:
451
+ params.append(json.dumps(value) if not isinstance(value, str) else value)
452
+ elif key in (
453
+ "assigned_at",
454
+ "acknowledged_at",
455
+ "completed_at",
456
+ "cancelled_at",
457
+ "failed_at",
458
+ "expires_at",
459
+ "last_attempt_at",
460
+ "last_delivery_at",
461
+ "delivery_failed_at",
462
+ ):
463
+ set_parts.append(f"{key} = ?")
464
+ if isinstance(value, datetime):
465
+ params.append(value.isoformat())
466
+ else:
467
+ params.append(value)
468
+ else:
469
+ set_parts.append(f"{key} = ?")
470
+ params.append(value)
471
+
472
+ if not set_parts:
473
+ return self.get(initiative_id)
474
+
475
+ params.append(initiative_id)
476
+ query = f"UPDATE initiatives SET {', '.join(set_parts)} WHERE id = ?"
477
+
478
+ self._db.execute(query, params)
479
+ self._db.commit()
480
+
481
+ return self.get(initiative_id)
482
+
483
+ def list(
484
+ self,
485
+ status: Optional[List[str]] = None,
486
+ type: Optional[str] = None,
487
+ assigned_agent_id: Optional[str] = None,
488
+ job_id: Optional[str] = None,
489
+ created_before: Optional[datetime] = None,
490
+ created_after: Optional[datetime] = None,
491
+ acknowledged_before: Optional[datetime] = None,
492
+ limit: int = 100,
493
+ offset: int = 0,
494
+ ) -> List[StoredInitiative]:
495
+ """List initiatives with filters."""
496
+ query = "SELECT * FROM initiatives WHERE 1=1"
497
+ params: List[Any] = []
498
+
499
+ if status:
500
+ placeholders = ",".join("?" * len(status))
501
+ query += f" AND status IN ({placeholders})"
502
+ params.extend(status)
503
+
504
+ if type:
505
+ query += " AND type = ?"
506
+ params.append(type)
507
+
508
+ if assigned_agent_id:
509
+ query += " AND assigned_agent_id = ?"
510
+ params.append(assigned_agent_id)
511
+
512
+ if job_id:
513
+ query += " AND job_id = ?"
514
+ params.append(job_id)
515
+
516
+ if created_before:
517
+ query += " AND created_at < ?"
518
+ params.append(created_before.isoformat())
519
+
520
+ if created_after:
521
+ query += " AND created_at > ?"
522
+ params.append(created_after.isoformat())
523
+
524
+ if acknowledged_before:
525
+ query += " AND acknowledged_at < ?"
526
+ params.append(acknowledged_before.isoformat())
527
+
528
+ query += " ORDER BY priority DESC, created_at ASC LIMIT ? OFFSET ?"
529
+ params.extend([limit, offset])
530
+
531
+ cursor = self._db.execute(query, params)
532
+ return [StoredInitiative.from_row(dict(row)) for row in cursor.fetchall()]
533
+
534
+ def count(
535
+ self,
536
+ status: Optional[List[str]] = None,
537
+ assigned_agent_id: Optional[str] = None,
538
+ job_id: Optional[str] = None,
539
+ ) -> int:
540
+ """Count initiatives with filters."""
541
+ query = "SELECT COUNT(*) FROM initiatives WHERE 1=1"
542
+ params: List[Any] = []
543
+
544
+ if status:
545
+ placeholders = ",".join("?" * len(status))
546
+ query += f" AND status IN ({placeholders})"
547
+ params.extend(status)
548
+
549
+ if assigned_agent_id:
550
+ query += " AND assigned_agent_id = ?"
551
+ params.append(assigned_agent_id)
552
+
553
+ if job_id:
554
+ query += " AND job_id = ?"
555
+ params.append(job_id)
556
+
557
+ cursor = self._db.execute(query, params)
558
+ return cursor.fetchone()[0]
559
+
560
+ # ------------------------------------------------------------------
561
+ # Status Transitions
562
+ # ------------------------------------------------------------------
563
+
564
+ def assign(
565
+ self,
566
+ initiative_id: str,
567
+ agent_id: str,
568
+ agent_name: Optional[str] = None,
569
+ ) -> Optional[StoredInitiative]:
570
+ """Assign initiative to agent (atomic)."""
571
+ now = datetime.now(timezone.utc)
572
+
573
+ # Atomic UPDATE - only works on pending initiatives
574
+ cursor = self._db.execute(
575
+ """
576
+ UPDATE initiatives
577
+ SET status = ?,
578
+ assigned_agent_id = ?,
579
+ assigned_agent_name = ?,
580
+ assigned_at = ?
581
+ WHERE id = ? AND status = ?
582
+ """,
583
+ [
584
+ InitiativeStatus.ASSIGNED.value,
585
+ agent_id,
586
+ agent_name,
587
+ now.isoformat(),
588
+ initiative_id,
589
+ InitiativeStatus.PENDING.value,
590
+ ],
591
+ )
592
+ self._db.commit()
593
+
594
+ if cursor.rowcount == 0:
595
+ return None
596
+
597
+ # Log history
598
+ self.log_history(
599
+ initiative_id,
600
+ action="assigned",
601
+ agent_id=agent_id,
602
+ agent_name=agent_name,
603
+ )
604
+
605
+ return self.get(initiative_id)
606
+
607
+ def acknowledge(
608
+ self,
609
+ initiative_id: str,
610
+ agent_id: str,
611
+ ) -> Optional[StoredInitiative]:
612
+ """Mark initiative as acknowledged by agent."""
613
+ now = datetime.now(timezone.utc)
614
+
615
+ initiative = self.get(initiative_id)
616
+ if not initiative or initiative.assigned_agent_id != agent_id:
617
+ return None
618
+
619
+ updated = self.update(
620
+ initiative_id,
621
+ status=InitiativeStatus.ACKNOWLEDGED.value,
622
+ acknowledged_at=now,
623
+ )
624
+
625
+ if updated:
626
+ self.log_history(
627
+ initiative_id,
628
+ action="acknowledged",
629
+ agent_id=agent_id,
630
+ )
631
+
632
+ return updated
633
+
634
+ def complete(
635
+ self,
636
+ initiative_id: str,
637
+ agent_id: str,
638
+ result: Optional[str] = None,
639
+ result_metadata: Optional[Dict[str, Any]] = None,
640
+ ) -> Optional[StoredInitiative]:
641
+ """Mark initiative as completed."""
642
+ now = datetime.now(timezone.utc)
643
+
644
+ initiative = self.get(initiative_id)
645
+ if not initiative:
646
+ return None
647
+
648
+ # Check if expired
649
+ if initiative.is_expired:
650
+ return self.update(
651
+ initiative_id,
652
+ status=InitiativeStatus.FAILED.value,
653
+ failed_at=now,
654
+ failed_reason="initiative_expired",
655
+ )
656
+
657
+ updated = self.update(
658
+ initiative_id,
659
+ status=InitiativeStatus.COMPLETED.value,
660
+ completed_at=now,
661
+ result=result,
662
+ result_metadata=result_metadata or {},
663
+ )
664
+
665
+ if updated:
666
+ self.log_history(
667
+ initiative_id,
668
+ action="completed",
669
+ agent_id=agent_id,
670
+ details={"result": result},
671
+ )
672
+
673
+ return updated
674
+
675
+ def fail(
676
+ self,
677
+ initiative_id: str,
678
+ agent_id: str,
679
+ reason: str,
680
+ retry: bool = False,
681
+ ) -> Optional[StoredInitiative]:
682
+ """Mark initiative as failed."""
683
+ now = datetime.now(timezone.utc)
684
+
685
+ initiative = self.get(initiative_id)
686
+ if not initiative:
687
+ return None
688
+
689
+ new_attempt_count = initiative.attempt_count + 1
690
+
691
+ # If retry requested and attempts remaining, reset to pending
692
+ if retry and new_attempt_count < initiative.max_attempts:
693
+ updated = self.update(
694
+ initiative_id,
695
+ status=InitiativeStatus.PENDING.value,
696
+ assigned_agent_id=None,
697
+ assigned_agent_name=None,
698
+ assigned_at=None,
699
+ acknowledged_at=None,
700
+ attempt_count=new_attempt_count,
701
+ last_attempt_at=now,
702
+ )
703
+
704
+ self.log_history(
705
+ initiative_id,
706
+ action="retry_scheduled",
707
+ agent_id=agent_id,
708
+ details={"reason": reason, "attempt": new_attempt_count},
709
+ )
710
+ else:
711
+ # Mark as failed
712
+ updated = self.update(
713
+ initiative_id,
714
+ status=InitiativeStatus.FAILED.value,
715
+ failed_at=now,
716
+ failed_reason=reason,
717
+ attempt_count=new_attempt_count,
718
+ last_attempt_at=now,
719
+ )
720
+
721
+ self.log_history(
722
+ initiative_id,
723
+ action="failed",
724
+ agent_id=agent_id,
725
+ details={"reason": reason, "attempt": new_attempt_count},
726
+ )
727
+
728
+ # Add to dead letter queue if max attempts reached
729
+ if new_attempt_count >= initiative.max_attempts:
730
+ self._add_to_dlq(initiative, reason)
731
+
732
+ return updated
733
+
734
+ def retry(self, initiative_id: str, actor_id: str) -> Optional[StoredInitiative]:
735
+ """Requeue a failed initiative and its history in one transaction."""
736
+ if not isinstance(actor_id, str) or not actor_id.strip():
737
+ raise ValueError("Retry requires an actor")
738
+ # Do not borrow the shared connection: another operation must never
739
+ # commit this transition without its history or inherit a failed write.
740
+ with closing(sqlite3.connect(self._db_path, timeout=2)) as db, db:
741
+ db.row_factory = sqlite3.Row
742
+ db.execute("BEGIN IMMEDIATE")
743
+ row = db.execute("SELECT * FROM initiatives WHERE id=? AND status='failed'",
744
+ (initiative_id,)).fetchone()
745
+ if row is None:
746
+ return None
747
+ details = {
748
+ "attempt_count": row["attempt_count"],
749
+ "previous_agent_id": row["assigned_agent_id"],
750
+ "failed_reason": row["failed_reason"],
751
+ "failed_at": row["failed_at"],
752
+ }
753
+ db.execute("""UPDATE initiatives SET status='pending', assigned_agent_id=NULL,
754
+ failed_reason=NULL, failed_at=NULL WHERE id=?""", (initiative_id,))
755
+ db.execute("""INSERT INTO assignment_history(initiative_id,agent_id,action,details)
756
+ VALUES(?,?,'retry',?)""", (initiative_id, actor_id, json.dumps(details)))
757
+ return StoredInitiative.from_row(dict(db.execute(
758
+ "SELECT * FROM initiatives WHERE id=?", (initiative_id,)).fetchone()))
759
+
760
+ def cancel(
761
+ self,
762
+ initiative_id: str,
763
+ cancelled_by: str,
764
+ reason: Optional[str] = None,
765
+ ) -> Optional[StoredInitiative]:
766
+ """Cancel an initiative."""
767
+ now = datetime.now(timezone.utc)
768
+
769
+ initiative = self.get(initiative_id)
770
+ if not initiative or not initiative.is_active:
771
+ return None
772
+
773
+ updated = self.update(
774
+ initiative_id,
775
+ status=InitiativeStatus.CANCELLED.value,
776
+ cancelled_at=now,
777
+ cancelled_by=cancelled_by,
778
+ cancelled_reason=reason,
779
+ )
780
+
781
+ if updated:
782
+ self.log_history(
783
+ initiative_id,
784
+ action="cancelled",
785
+ agent_id=cancelled_by,
786
+ details={"reason": reason},
787
+ )
788
+
789
+ return updated
790
+
791
+ # ------------------------------------------------------------------
792
+ # Reassignment
793
+ # ------------------------------------------------------------------
794
+
795
+ def reassign_from_agent(
796
+ self,
797
+ agent_id: str,
798
+ only_pending: bool = True,
799
+ ) -> int:
800
+ """Reassign initiatives from an agent.
801
+
802
+ Args:
803
+ agent_id: Agent to reassign from
804
+ only_pending: If True, only reassign PENDING (not ACKNOWLEDGED)
805
+
806
+ Returns:
807
+ Number of initiatives reassigned
808
+ """
809
+ if only_pending:
810
+ initiatives = self.list(
811
+ status=[InitiativeStatus.PENDING.value],
812
+ assigned_agent_id=agent_id,
813
+ )
814
+ else:
815
+ initiatives = self.list(
816
+ status=[
817
+ InitiativeStatus.PENDING.value,
818
+ InitiativeStatus.ASSIGNED.value,
819
+ InitiativeStatus.ACKNOWLEDGED.value,
820
+ ],
821
+ assigned_agent_id=agent_id,
822
+ )
823
+
824
+ reassigned = 0
825
+ for init in initiatives:
826
+ updated = self.update(
827
+ init.id,
828
+ status=InitiativeStatus.PENDING.value,
829
+ assigned_agent_id=None,
830
+ assigned_agent_name=None,
831
+ assigned_at=None,
832
+ acknowledged_at=None,
833
+ recovery_reason="agent_offline",
834
+ )
835
+
836
+ if updated:
837
+ self.log_history(
838
+ init.id,
839
+ action="reassigned",
840
+ agent_id=agent_id,
841
+ details={"reason": "agent_offline", "only_pending": only_pending},
842
+ )
843
+ reassigned += 1
844
+
845
+ return reassigned
846
+
847
+ # ------------------------------------------------------------------
848
+ # Timeout & Expiry
849
+ # ------------------------------------------------------------------
850
+
851
+ def find_timed_out(self, now: datetime) -> List[StoredInitiative]:
852
+ """Find initiatives that have exceeded their timeout."""
853
+ cursor = self._db.execute(
854
+ """
855
+ SELECT * FROM initiatives
856
+ WHERE status IN ('assigned', 'acknowledged')
857
+ AND timeout_seconds IS NOT NULL
858
+ AND assigned_at IS NOT NULL
859
+ AND datetime(assigned_at, '+' || timeout_seconds || ' seconds') < ?
860
+ """,
861
+ [now.isoformat()],
862
+ )
863
+ return [StoredInitiative.from_row(dict(row)) for row in cursor.fetchall()]
864
+
865
+ def find_expired(self, now: datetime) -> List[StoredInitiative]:
866
+ """Find initiatives that have expired."""
867
+ cursor = self._db.execute(
868
+ """
869
+ SELECT * FROM initiatives
870
+ WHERE status IN ('pending', 'assigned', 'acknowledged')
871
+ AND expires_at IS NOT NULL
872
+ AND expires_at < ?
873
+ """,
874
+ [now.isoformat()],
875
+ )
876
+ return [StoredInitiative.from_row(dict(row)) for row in cursor.fetchall()]
877
+
878
+ def find_stale_acknowledged(
879
+ self,
880
+ threshold: datetime,
881
+ ) -> List[StoredInitiative]:
882
+ """Find initiatives stuck in acknowledged state."""
883
+ cursor = self._db.execute(
884
+ """
885
+ SELECT * FROM initiatives
886
+ WHERE status = ?
887
+ AND acknowledged_at < ?
888
+ """,
889
+ [InitiativeStatus.ACKNOWLEDGED.value, threshold.isoformat()],
890
+ )
891
+ return [StoredInitiative.from_row(dict(row)) for row in cursor.fetchall()]
892
+
893
+ # ------------------------------------------------------------------
894
+ # History
895
+ # ------------------------------------------------------------------
896
+
897
+ def log_history(
898
+ self,
899
+ initiative_id: str,
900
+ action: str,
901
+ agent_id: str,
902
+ agent_name: Optional[str] = None,
903
+ details: Optional[Dict[str, Any]] = None,
904
+ ) -> None:
905
+ """Log assignment history."""
906
+ self._db.execute(
907
+ """
908
+ INSERT INTO assignment_history (
909
+ initiative_id, agent_id, agent_name, action, details
910
+ ) VALUES (?, ?, ?, ?, ?)
911
+ """,
912
+ [
913
+ initiative_id,
914
+ agent_id,
915
+ agent_name,
916
+ action,
917
+ json.dumps(details) if details else None,
918
+ ],
919
+ )
920
+ self._db.commit()
921
+
922
+ def get_history(
923
+ self,
924
+ initiative_id: str,
925
+ limit: int = 50,
926
+ ) -> List[AssignmentHistory]:
927
+ """Get assignment history for an initiative."""
928
+ cursor = self._db.execute(
929
+ """
930
+ SELECT * FROM assignment_history
931
+ WHERE initiative_id = ?
932
+ ORDER BY timestamp DESC
933
+ LIMIT ?
934
+ """,
935
+ [initiative_id, limit],
936
+ )
937
+ return [AssignmentHistory.from_row(dict(row)) for row in cursor.fetchall()]
938
+
939
+ def get_agent_history(
940
+ self,
941
+ agent_id: str,
942
+ limit: int = 50,
943
+ offset: int = 0,
944
+ ) -> List[AssignmentHistory]:
945
+ """Get assignment history for an agent."""
946
+ cursor = self._db.execute(
947
+ """
948
+ SELECT * FROM assignment_history
949
+ WHERE agent_id = ?
950
+ ORDER BY timestamp DESC
951
+ LIMIT ? OFFSET ?
952
+ """,
953
+ [agent_id, limit, offset],
954
+ )
955
+ return [AssignmentHistory.from_row(dict(row)) for row in cursor.fetchall()]
956
+
957
+ def count_agent_assignments_since(
958
+ self,
959
+ agent_id: str,
960
+ since: datetime,
961
+ ) -> int:
962
+ """Count `assigned` history rows for ``agent_id`` newer than ``since``.
963
+
964
+ Used by the assignment engine's hourly rate limit. Counts only the
965
+ ``assigned`` action so reassignment churn (e.g. ghost cleanup) does
966
+ not eat into an agent's hourly budget.
967
+ """
968
+ cursor = self._db.execute(
969
+ """
970
+ SELECT COUNT(*) FROM assignment_history
971
+ WHERE agent_id = ?
972
+ AND action = 'assigned'
973
+ AND timestamp > ?
974
+ """,
975
+ [agent_id, since.isoformat()],
976
+ )
977
+ return cursor.fetchone()[0]
978
+
979
+ # ------------------------------------------------------------------
980
+ # Dead Letter Queue
981
+ # ------------------------------------------------------------------
982
+
983
+ def _add_to_dlq(
984
+ self,
985
+ initiative: StoredInitiative,
986
+ reason: str,
987
+ ) -> None:
988
+ """Add failed initiative to dead letter queue."""
989
+ entry = {
990
+ "initiative_id": initiative.id,
991
+ "type": initiative.type,
992
+ "description": initiative.description,
993
+ "reason": reason,
994
+ "attempt_count": initiative.attempt_count,
995
+ "failed_at": datetime.now(timezone.utc).isoformat(),
996
+ }
997
+
998
+ with open(self._dlq_path, "a") as f:
999
+ f.write(json.dumps(entry) + "\n")
1000
+
1001
+ logger.warning(
1002
+ "Added initiative %s to dead letter queue: %s",
1003
+ initiative.id,
1004
+ reason,
1005
+ )
1006
+
1007
+ def get_dlq_entries(self, limit: int = 50) -> List[Dict[str, Any]]:
1008
+ """Get entries from dead letter queue."""
1009
+ if not self._dlq_path.exists():
1010
+ return []
1011
+
1012
+ entries = []
1013
+ with open(self._dlq_path, "r") as f:
1014
+ for line in f:
1015
+ line = line.strip()
1016
+ if line:
1017
+ try:
1018
+ entries.append(json.loads(line))
1019
+ except json.JSONDecodeError:
1020
+ continue
1021
+
1022
+ return entries[-limit:]
1023
+
1024
+ def remove_from_dlq(self, initiative_id: str) -> bool:
1025
+ """Remove initiative from dead letter queue."""
1026
+ if not self._dlq_path.exists():
1027
+ return False
1028
+
1029
+ entries = []
1030
+ removed = False
1031
+
1032
+ with open(self._dlq_path, "r") as f:
1033
+ for line in f:
1034
+ line = line.strip()
1035
+ if line:
1036
+ try:
1037
+ entry = json.loads(line)
1038
+ if entry.get("initiative_id") == initiative_id:
1039
+ removed = True
1040
+ else:
1041
+ entries.append(entry)
1042
+ except json.JSONDecodeError:
1043
+ continue
1044
+
1045
+ if removed:
1046
+ with open(self._dlq_path, "w") as f:
1047
+ for entry in entries:
1048
+ f.write(json.dumps(entry) + "\n")
1049
+
1050
+ return removed
1051
+
1052
+ # ------------------------------------------------------------------
1053
+ # Cleanup
1054
+ # ------------------------------------------------------------------
1055
+
1056
+ def delete_old(
1057
+ self,
1058
+ status: List[str],
1059
+ before: datetime,
1060
+ ) -> int:
1061
+ """Delete old initiatives with given statuses."""
1062
+ placeholders = ",".join("?" * len(status))
1063
+ cursor = self._db.execute(
1064
+ f"""
1065
+ DELETE FROM initiatives
1066
+ WHERE status IN ({placeholders})
1067
+ AND created_at < ?
1068
+ """,
1069
+ [*status, before.isoformat()],
1070
+ )
1071
+ self._db.commit()
1072
+ return cursor.rowcount
1073
+
1074
+ def backup(self) -> None:
1075
+ """Create backup of database."""
1076
+ shutil.copy2(self._db_path, self._backup_path)
1077
+
1078
+ def close(self) -> None:
1079
+ """Close connection and create backup."""
1080
+ self.backup()
1081
+ self._db.close()