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,1260 @@
1
+ """ProactiveDeliveryBridge — queues and manages proactive message delivery.
2
+
3
+ The autonomy loop calls deliver() with an initiative or insight. The bridge:
4
+ 1. Rate-limits per person
5
+ 2. Queues the message in pending deliveries
6
+ 3. The gateway polls GET /v1/delivery/pending and sends via platform adapters
7
+
8
+ Delivery channels:
9
+ PUSH → deliver immediately (queued for gateway polling)
10
+ IN_SESSION → store for injection into next conversation's system prompt
11
+ DIGEST → accumulate for bundled morning briefing (wired but not scheduled by default)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import os
18
+ import uuid
19
+ import json
20
+ import hashlib
21
+ import sqlite3
22
+ import stat
23
+ import threading
24
+ import time
25
+ import urllib.parse
26
+ from dataclasses import dataclass, field
27
+ from datetime import datetime, timedelta, timezone
28
+ from pathlib import Path
29
+ from typing import Any, Dict, List, Optional
30
+
31
+ from apsimo.delivery.rate_limiter import DeliveryRateLimiter
32
+ from apsimo.delivery.channels import ChannelRegistry
33
+ from apsimo.workers.queue_worker import encode_hermes_webhook
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ # Default internal port for the gateway's /internal/deliver endpoint.
38
+ _DEFAULT_GATEWAY_INTERNAL_PORT = 7779
39
+ _GATEWAY_CONTRACTS = frozenset(("legacy_delivery", "governed_admission_v1"))
40
+ _GOVERNED_GATEWAY_PLATFORMS = frozenset(("whatsapp",))
41
+
42
+
43
+ def _strict_gateway_json(raw: str) -> Optional[Dict[str, Any]]:
44
+ if not isinstance(raw, str) or len(raw.encode("utf-8")) > 64 * 1024:
45
+ return None
46
+
47
+ def pairs(values):
48
+ result: Dict[str, Any] = {}
49
+ for key, value in values:
50
+ if key in result:
51
+ raise ValueError("duplicate gateway response field")
52
+ result[key] = value
53
+ return result
54
+
55
+ try:
56
+ value = json.loads(
57
+ raw,
58
+ object_pairs_hook=pairs,
59
+ parse_constant=lambda _value: (_ for _ in ()).throw(
60
+ ValueError("non-finite gateway response number")
61
+ ),
62
+ )
63
+ except (TypeError, ValueError, UnicodeError, RecursionError):
64
+ return None
65
+ return value if isinstance(value, dict) else None
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class GatewayPushResult:
70
+ """Truthful result of handing one message to a gateway boundary.
71
+
72
+ ``accepted`` means only that the immediate boundary accepted the request.
73
+ ``provider_delivered`` is a separate fact so a governed approval queue can
74
+ never be reported as a completed send.
75
+ """
76
+
77
+ accepted: bool
78
+ provider_delivered: bool
79
+ contract: str
80
+ admission_state: str = ""
81
+ delivery_id: str = ""
82
+ terminal: bool = False
83
+ observation_new: bool = True
84
+
85
+ def __bool__(self) -> bool:
86
+ return self.accepted
87
+
88
+
89
+ class _GatewayOutcomeStore:
90
+ """PII-free durable lifecycle cache for one governed gateway boundary."""
91
+
92
+ _STATES = frozenset(
93
+ ("queued", "awaiting_approval", "accepted", "delivered", "failed", "ambiguous")
94
+ )
95
+
96
+ def __init__(self, path: Path, *, clock, poll_seconds: float) -> None:
97
+ raw_path = Path(path).expanduser()
98
+ self.path = raw_path if raw_path.is_absolute() else Path.cwd() / raw_path
99
+ self.clock = clock
100
+ self.poll_seconds = float(poll_seconds)
101
+ self._lock = threading.RLock()
102
+ # resolve() would hide a symlink before the safety check. Inspect the
103
+ # existing ancestry before and after creating a missing private state
104
+ # directory, and let SQLite see the same unresolved absolute path.
105
+ for component in (self.path, *self.path.parents):
106
+ if component.is_symlink():
107
+ raise ValueError("gateway outcome path is unsafe")
108
+ parent_existed = self.path.parent.exists()
109
+ self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
110
+ if not parent_existed:
111
+ os.chmod(self.path.parent, 0o700)
112
+ for component in (self.path, *self.path.parents):
113
+ if component.is_symlink():
114
+ raise ValueError("gateway outcome path is unsafe")
115
+ self._conn = sqlite3.connect(
116
+ str(self.path), timeout=30.0, isolation_level=None, check_same_thread=False
117
+ )
118
+ os.chmod(self.path, 0o600)
119
+ if stat.S_IMODE(self.path.stat().st_mode) != 0o600:
120
+ self._conn.close()
121
+ raise ValueError("gateway outcome database must be private")
122
+ self._conn.row_factory = sqlite3.Row
123
+ self._conn.execute("PRAGMA busy_timeout=30000")
124
+ self._conn.execute("PRAGMA journal_mode=DELETE")
125
+ self._conn.execute("PRAGMA synchronous=FULL")
126
+ version = int(self._conn.execute("PRAGMA user_version").fetchone()[0])
127
+ if version not in (0, 1):
128
+ self._conn.close()
129
+ raise ValueError("gateway outcome schema is unsupported")
130
+ self._conn.executescript(
131
+ """
132
+ CREATE TABLE IF NOT EXISTS governed_gateway_outcomes (
133
+ delivery_id TEXT PRIMARY KEY,
134
+ request_sha256 TEXT NOT NULL,
135
+ state TEXT NOT NULL,
136
+ intent_id TEXT NOT NULL,
137
+ provider_delivered INTEGER NOT NULL,
138
+ next_poll_at REAL NOT NULL,
139
+ created_at REAL NOT NULL,
140
+ updated_at REAL NOT NULL
141
+ );
142
+ """
143
+ )
144
+ if version == 0:
145
+ self._conn.execute("PRAGMA user_version=1")
146
+
147
+ @staticmethod
148
+ def _row(row) -> Dict[str, Any]:
149
+ value = dict(row)
150
+ if value.get("state") not in _GatewayOutcomeStore._STATES:
151
+ raise ValueError("gateway outcome state is invalid")
152
+ value["provider_delivered"] = bool(value["provider_delivered"])
153
+ return value
154
+
155
+ def reserve(self, delivery_id: str, request_sha256: str) -> Dict[str, Any]:
156
+ now = float(self.clock())
157
+ with self._lock:
158
+ self._conn.execute("BEGIN IMMEDIATE")
159
+ try:
160
+ row = self._conn.execute(
161
+ "SELECT * FROM governed_gateway_outcomes WHERE delivery_id=?",
162
+ (delivery_id,),
163
+ ).fetchone()
164
+ if row is None:
165
+ self._conn.execute(
166
+ """INSERT INTO governed_gateway_outcomes
167
+ (delivery_id,request_sha256,state,intent_id,
168
+ provider_delivered,next_poll_at,created_at,updated_at)
169
+ VALUES (?,?,'queued','',0,0,?,?)""",
170
+ (delivery_id, request_sha256, now, now),
171
+ )
172
+ elif row["request_sha256"] != request_sha256:
173
+ raise ValueError("gateway delivery identity conflicts")
174
+ self._conn.execute("COMMIT")
175
+ except Exception:
176
+ self._conn.execute("ROLLBACK")
177
+ raise
178
+ return self.get(delivery_id)
179
+
180
+ def get(self, delivery_id: str) -> Dict[str, Any]:
181
+ with self._lock:
182
+ row = self._conn.execute(
183
+ "SELECT * FROM governed_gateway_outcomes WHERE delivery_id=?",
184
+ (delivery_id,),
185
+ ).fetchone()
186
+ if row is None:
187
+ raise ValueError("gateway outcome is unavailable")
188
+ return self._row(row)
189
+
190
+ def observe(
191
+ self, delivery_id: str, *, state: str, intent_id: str,
192
+ provider_delivered: bool,
193
+ ) -> tuple[Dict[str, Any], bool]:
194
+ if state not in self._STATES or state == "queued":
195
+ raise ValueError("gateway outcome transition is invalid")
196
+ if provider_delivered is not (state == "delivered"):
197
+ raise ValueError("gateway provider-delivery outcome is inconsistent")
198
+ now = float(self.clock())
199
+ terminal = state in {"delivered", "failed", "ambiguous"}
200
+ next_poll_at = 0.0 if terminal else now + self.poll_seconds
201
+ with self._lock:
202
+ current = self.get(delivery_id)
203
+ transitions = {
204
+ "queued": {
205
+ "awaiting_approval", "accepted", "delivered", "failed", "ambiguous",
206
+ },
207
+ "awaiting_approval": {
208
+ "awaiting_approval", "accepted", "delivered", "failed", "ambiguous",
209
+ },
210
+ "accepted": {"accepted", "delivered", "failed", "ambiguous"},
211
+ "delivered": {"delivered"},
212
+ "failed": {"failed"},
213
+ "ambiguous": {"ambiguous"},
214
+ }
215
+ if state not in transitions[current["state"]]:
216
+ raise ValueError("gateway outcome transition regressed")
217
+ # A governed intent is immutable once the producer has admitted
218
+ # it. A direct provider delivery may legitimately be the first
219
+ # and only observation and carry the boundary's empty intent ID.
220
+ # Every pending or failed/ambiguous producer observation requires
221
+ # a nonempty intent, and every later observation must match it.
222
+ current_intent = str(current["intent_id"])
223
+ if current["state"] == "queued":
224
+ if state != "delivered" and not intent_id:
225
+ raise ValueError("gateway intent identity is missing")
226
+ elif not current_intent or intent_id != current_intent:
227
+ raise ValueError("gateway intent identity changed")
228
+ if current["state"] in {"delivered", "failed", "ambiguous"}:
229
+ if (
230
+ state != current["state"]
231
+ or intent_id != current_intent
232
+ or provider_delivered is not current["provider_delivered"]
233
+ ):
234
+ raise ValueError("gateway terminal outcome is immutable")
235
+ return current, False
236
+ changed = bool(
237
+ current["state"] != state
238
+ or current["intent_id"] != intent_id
239
+ or current["provider_delivered"] is not provider_delivered
240
+ )
241
+ self._conn.execute(
242
+ """UPDATE governed_gateway_outcomes
243
+ SET state=?,intent_id=?,provider_delivered=?,next_poll_at=?,updated_at=?
244
+ WHERE delivery_id=?""",
245
+ (
246
+ state, intent_id, 1 if provider_delivered else 0,
247
+ next_poll_at, now, delivery_id,
248
+ ),
249
+ )
250
+ return self.get(delivery_id), changed
251
+
252
+ def defer(self, delivery_id: str) -> Dict[str, Any]:
253
+ now = float(self.clock())
254
+ with self._lock:
255
+ self._conn.execute(
256
+ """UPDATE governed_gateway_outcomes
257
+ SET next_poll_at=?,updated_at=? WHERE delivery_id=?""",
258
+ (now + self.poll_seconds, now, delivery_id),
259
+ )
260
+ return self.get(delivery_id)
261
+
262
+ def pending_delivery_ids(self, *, limit: int = 100) -> tuple[str, ...]:
263
+ bounded = max(1, min(100, int(limit)))
264
+ with self._lock:
265
+ rows = self._conn.execute(
266
+ """SELECT delivery_id FROM governed_gateway_outcomes
267
+ WHERE state IN ('accepted','awaiting_approval')
268
+ ORDER BY created_at ASC,delivery_id ASC LIMIT ?""",
269
+ (bounded,),
270
+ ).fetchall()
271
+ return tuple(str(row["delivery_id"]) for row in rows)
272
+
273
+
274
+ @dataclass
275
+ class PendingDelivery:
276
+ """A proactive message waiting to be sent to a user."""
277
+ delivery_id: str
278
+ person_id: str
279
+ content: str
280
+ channel: str # "push" | "in_session" | "digest"
281
+ urgency: float
282
+ source: str # "initiative" | "insight" | "anomaly"
283
+ initiative_id: Optional[str]
284
+ queued_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
285
+ sent: bool = False
286
+ metadata: Dict[str, Any] = field(default_factory=dict)
287
+
288
+
289
+ class ProactiveDeliveryBridge:
290
+ """Routes autonomy loop outputs (initiatives, insights) to users via the gateway.
291
+
292
+ Two delivery paths are supported:
293
+
294
+ 1. **Poll path** (default): The gateway polls /v1/delivery/pending every few
295
+ seconds and POSTs each pending delivery to the appropriate platform adapter.
296
+
297
+ 2. **Push path** (when gateway_url is set): ``push_to_gateway()`` POSTs
298
+ directly to the gateway's internal ``POST /internal/deliver`` endpoint so
299
+ messages are delivered immediately without polling latency.
300
+ """
301
+
302
+ def __init__(
303
+ self,
304
+ rate_limiter: Optional[DeliveryRateLimiter] = None,
305
+ gateway_url: Optional[str] = None,
306
+ gateway_api_key: Optional[str] = None,
307
+ channel_registry: Optional[ChannelRegistry] = None,
308
+ gateway_contract: Optional[str] = None,
309
+ gateway_outcome_db: Optional[str] = None,
310
+ gateway_poll_seconds: float = 5.0,
311
+ clock=time.time,
312
+ ) -> None:
313
+ if rate_limiter is None:
314
+ # Persist rate-limit state so a crashloop can't reset the daily
315
+ # caps. Lives alongside other sidecar state under COLONY_STATE_DIR.
316
+ state_dir = os.environ.get("COLONY_STATE_DIR", ".")
317
+ db_path = Path(state_dir) / "colony-delivery-rate-limit.db"
318
+ rate_limiter = DeliveryRateLimiter(db_path=db_path)
319
+ self._rate_limiter = rate_limiter
320
+ self._pending: List[PendingDelivery] = []
321
+ self._sent: List[PendingDelivery] = [] # short history for observability
322
+ self._sent_max: int = 500 # cap to prevent unbounded growth
323
+
324
+ # Gateway push path — optional direct delivery via /internal/deliver
325
+ _port = int(os.environ.get("COLONY_GATEWAY_INTERNAL_PORT", _DEFAULT_GATEWAY_INTERNAL_PORT))
326
+ self._gateway_url: str = (
327
+ gateway_url
328
+ or os.environ.get("COLONY_GATEWAY_INTERNAL_URL", "")
329
+ or f"http://localhost:{_port}"
330
+ )
331
+ self._gateway_api_key: str = (
332
+ gateway_api_key
333
+ or os.environ.get("COLONY_GATEWAY_API_KEY", "")
334
+ or os.environ.get("COLONY_API_KEY", "")
335
+ )
336
+ self._gateway_contract = str(
337
+ gateway_contract
338
+ or os.environ.get("COLONY_GATEWAY_CONTRACT", "legacy_delivery")
339
+ ).strip().lower()
340
+ if self._gateway_contract not in _GATEWAY_CONTRACTS:
341
+ raise ValueError("unsupported gateway response contract")
342
+ if self._gateway_contract == "governed_admission_v1":
343
+ parsed = urllib.parse.urlsplit(self._gateway_url)
344
+ if (
345
+ parsed.scheme != "http"
346
+ or parsed.hostname not in {"127.0.0.1", "localhost", "::1"}
347
+ or parsed.username
348
+ or parsed.password
349
+ or parsed.query
350
+ or parsed.fragment
351
+ or parsed.path not in ("", "/")
352
+ or parsed.port is None
353
+ or not self._gateway_api_key
354
+ ):
355
+ raise ValueError(
356
+ "governed gateway admission requires one authenticated loopback origin"
357
+ )
358
+ try:
359
+ bounded_poll = float(gateway_poll_seconds)
360
+ except (TypeError, ValueError) as error:
361
+ raise ValueError("gateway lifecycle poll interval is invalid") from error
362
+ if not 1.0 <= bounded_poll <= 300.0:
363
+ raise ValueError("gateway lifecycle poll interval is invalid")
364
+ self._clock = clock
365
+ self._gateway_poll_seconds = bounded_poll
366
+ self._gateway_outcomes: Optional[_GatewayOutcomeStore] = None
367
+ if self._gateway_contract == "governed_admission_v1":
368
+ state_dir = Path(os.environ.get("COLONY_STATE_DIR", "."))
369
+ outcome_path = Path(gateway_outcome_db) if gateway_outcome_db else (
370
+ state_dir / "colony-governed-gateway-outcomes.db"
371
+ )
372
+ self._gateway_outcomes = _GatewayOutcomeStore(
373
+ outcome_path, clock=clock, poll_seconds=bounded_poll
374
+ )
375
+
376
+ # Channel registry for per-person delivery routing
377
+ self._channel_registry = channel_registry or ChannelRegistry.load()
378
+
379
+ # Home channel config read from env vars — used to resolve
380
+ # platform/chat_id when only person_id is available.
381
+ self._home_channels: Dict[str, Dict[str, str]] = self._load_home_channels()
382
+
383
+ def governed_gateway_admission_enabled(
384
+ self, platform: Optional[str] = None,
385
+ ) -> bool:
386
+ """Whether governed admission owns this exact platform route.
387
+
388
+ A no-argument call reports whether lifecycle reconciliation should run.
389
+ Send-authority callers must provide a platform so a governed WhatsApp
390
+ sidecar cannot silently exempt an RCS/SMS route from Colony's legacy
391
+ non-owner approval gate.
392
+ """
393
+
394
+ if self._gateway_contract != "governed_admission_v1":
395
+ return False
396
+ if platform is None:
397
+ return True
398
+ return str(platform).strip().lower() in _GOVERNED_GATEWAY_PLATFORMS
399
+
400
+ def _gateway_contract_for_platform(self, platform: str) -> str:
401
+ if self.governed_gateway_admission_enabled(platform):
402
+ return "governed_admission_v1"
403
+ return "legacy_delivery"
404
+
405
+ def governed_gateway_poll_seconds(self) -> float:
406
+ """Bounded cadence for unattended governed-lifecycle reconciliation."""
407
+
408
+ return self._gateway_poll_seconds
409
+
410
+ def governed_pending_delivery_ids(self, *, limit: int = 100) -> tuple[str, ...]:
411
+ """PII-free durable identities that still need a terminal observation."""
412
+
413
+ if self._gateway_outcomes is None:
414
+ return ()
415
+ return self._gateway_outcomes.pending_delivery_ids(limit=limit)
416
+
417
+ def _stored_gateway_result(
418
+ self, row: Dict[str, Any], *, observation_new: bool = False
419
+ ) -> GatewayPushResult:
420
+ state = str(row["state"])
421
+ return GatewayPushResult(
422
+ accepted=state in {"accepted", "awaiting_approval", "delivered"},
423
+ provider_delivered=row["provider_delivered"] is True,
424
+ contract=self._gateway_contract,
425
+ admission_state=state,
426
+ delivery_id=str(row["delivery_id"]),
427
+ terminal=state in {"delivered", "failed", "ambiguous"},
428
+ observation_new=observation_new,
429
+ )
430
+
431
+ def _defer_or_fail_gateway(
432
+ self,
433
+ row: Optional[Dict[str, Any]],
434
+ delivery_id: str,
435
+ *,
436
+ gateway_contract: Optional[str] = None,
437
+ ) -> GatewayPushResult:
438
+ effective_contract = gateway_contract or self._gateway_contract
439
+ if (
440
+ self._gateway_outcomes is not None
441
+ and row is not None
442
+ and row.get("state") in {"accepted", "awaiting_approval"}
443
+ ):
444
+ return self._stored_gateway_result(
445
+ self._gateway_outcomes.defer(delivery_id), observation_new=False
446
+ )
447
+ return GatewayPushResult(
448
+ False, False, effective_contract,
449
+ delivery_id=delivery_id, observation_new=False,
450
+ )
451
+
452
+ # ------------------------------------------------------------------
453
+ # Home channel resolution
454
+ # ------------------------------------------------------------------
455
+
456
+ @staticmethod
457
+ def _load_home_channels() -> Dict[str, Dict[str, str]]:
458
+ """Build a {platform: {chat_id, name}} mapping from env vars."""
459
+ channels: Dict[str, Dict[str, str]] = {}
460
+ env_map = {
461
+ "telegram": ("TELEGRAM_HOME_CHANNEL", "TELEGRAM_HOME_CHANNEL_NAME"),
462
+ "whatsapp": ("WHATSAPP_HOME_CHANNEL", "WHATSAPP_HOME_CHANNEL_NAME"),
463
+ "discord": ("DISCORD_HOME_CHANNEL", "DISCORD_HOME_CHANNEL_NAME"),
464
+ "slack": ("SLACK_HOME_CHANNEL", "SLACK_HOME_CHANNEL_NAME"),
465
+ "signal": ("SIGNAL_HOME_CHANNEL", "SIGNAL_HOME_CHANNEL_NAME"),
466
+ }
467
+ for platform, (chat_env, name_env) in env_map.items():
468
+ chat_id = os.environ.get(chat_env, "")
469
+ if chat_id:
470
+ channels[platform] = {
471
+ "chat_id": chat_id,
472
+ "name": os.environ.get(name_env, platform.title()),
473
+ }
474
+ return channels
475
+
476
+ def resolve_home_channel(self) -> Optional[Dict[str, str]]:
477
+ """Resolve the first configured home channel.
478
+
479
+ Returns:
480
+ Dict with platform, chat_id, account_id or None if not configured.
481
+ Platform is normalized to lowercase OpenClaw channel name.
482
+ """
483
+ for platform, info in self._home_channels.items():
484
+ return {
485
+ "platform": platform.lower(), # whatsapp, telegram, discord, slack, signal
486
+ "chat_id": info["chat_id"],
487
+ "account_id": "default", # Could be made configurable later
488
+ }
489
+ return None
490
+
491
+ # ------------------------------------------------------------------
492
+ # Public API
493
+ # ------------------------------------------------------------------
494
+
495
+ def deliver(
496
+ self,
497
+ person_id: str,
498
+ content: str,
499
+ channel: str = "push",
500
+ urgency: float = 0.5,
501
+ source: str = "initiative",
502
+ initiative_id: Optional[str] = None,
503
+ metadata: Optional[Dict[str, Any]] = None,
504
+ ) -> Optional[str]:
505
+ """Queue a proactive message for delivery.
506
+
507
+ Returns the delivery_id if queued, None if rate-limited.
508
+ """
509
+ allowed, reason = self._rate_limiter.can_deliver(person_id, urgency=urgency)
510
+ if not allowed:
511
+ logger.debug(
512
+ "Proactive delivery blocked for %s: %s (urgency=%.2f)",
513
+ person_id,
514
+ reason,
515
+ urgency,
516
+ )
517
+ return None
518
+
519
+ delivery = PendingDelivery(
520
+ delivery_id=str(uuid.uuid4()),
521
+ person_id=person_id,
522
+ content=content,
523
+ channel=channel,
524
+ urgency=urgency,
525
+ source=source,
526
+ initiative_id=initiative_id,
527
+ metadata=metadata or {},
528
+ )
529
+ self._pending.append(delivery)
530
+ logger.info(
531
+ "Proactive delivery queued: %s → %s (channel=%s, urgency=%.2f)",
532
+ delivery.delivery_id,
533
+ person_id,
534
+ channel,
535
+ urgency,
536
+ )
537
+ return delivery.delivery_id
538
+
539
+ async def push_to_gateway(
540
+ self,
541
+ platform: str,
542
+ chat_id: str,
543
+ message: str,
544
+ source: str = "initiative",
545
+ delivery_id: str = "",
546
+ source_id: str = "",
547
+ ) -> GatewayPushResult:
548
+ """Push a proactive message directly to the gateway's /internal/deliver endpoint.
549
+
550
+ The legacy contract preserves its historic HTTP-200 delivery meaning.
551
+ The opt-in governed contract durably tracks the exact boundary outcome
552
+ from admission through provider delivery or terminal failure. The
553
+ caller remains responsible for prior rate-limit checks if needed.
554
+ """
555
+ gateway_contract = self._gateway_contract_for_platform(platform)
556
+ payload = {
557
+ "platform": platform,
558
+ "chat_id": chat_id,
559
+ "message": message,
560
+ "source": source,
561
+ }
562
+ # Optional, deployment-neutral correlation fields. Existing gateways
563
+ # continue to receive the original four-field contract when callers do
564
+ # not provide them. Governed sidecars can use these stable source
565
+ # identities to make retries durable and idempotent without teaching
566
+ # Colony anything about a deployment's authority model.
567
+ if delivery_id:
568
+ payload["delivery_id"] = delivery_id
569
+ if source_id:
570
+ payload["source_id"] = source_id
571
+
572
+ stored: Optional[Dict[str, Any]] = None
573
+ if gateway_contract == "governed_admission_v1":
574
+ if not delivery_id or not source_id or self._gateway_outcomes is None:
575
+ logger.warning(
576
+ "Governed gateway lifecycle requires stable delivery and source IDs"
577
+ )
578
+ return GatewayPushResult(
579
+ False,
580
+ False,
581
+ gateway_contract,
582
+ delivery_id=delivery_id,
583
+ observation_new=False,
584
+ )
585
+ request_sha256 = hashlib.sha256(
586
+ json.dumps(
587
+ payload,
588
+ ensure_ascii=False,
589
+ allow_nan=False,
590
+ separators=(",", ":"),
591
+ sort_keys=True,
592
+ ).encode("utf-8")
593
+ ).hexdigest()
594
+ try:
595
+ stored = self._gateway_outcomes.reserve(delivery_id, request_sha256)
596
+ except (OSError, sqlite3.Error, ValueError):
597
+ logger.warning(
598
+ "Governed gateway delivery identity could not be reserved",
599
+ exc_info=True,
600
+ )
601
+ return GatewayPushResult(
602
+ False,
603
+ False,
604
+ gateway_contract,
605
+ delivery_id=delivery_id,
606
+ observation_new=False,
607
+ )
608
+ if stored["state"] in {"delivered", "failed", "ambiguous"}:
609
+ return self._stored_gateway_result(stored, observation_new=False)
610
+ if (
611
+ stored["state"] in {"accepted", "awaiting_approval"}
612
+ and float(stored["next_poll_at"]) > float(self._clock())
613
+ ):
614
+ return self._stored_gateway_result(stored, observation_new=False)
615
+
616
+ try:
617
+ import aiohttp
618
+ except ImportError:
619
+ logger.warning("aiohttp not available — cannot push to gateway")
620
+ return self._defer_or_fail_gateway(
621
+ stored, delivery_id, gateway_contract=gateway_contract,
622
+ )
623
+
624
+ url = f"{self._gateway_url.rstrip('/')}/internal/deliver"
625
+ headers: Dict[str, str] = {"Content-Type": "application/json"}
626
+ if self._gateway_api_key:
627
+ headers["Authorization"] = f"Bearer {self._gateway_api_key}"
628
+
629
+ try:
630
+ async with aiohttp.ClientSession() as session:
631
+ async with session.post(
632
+ url,
633
+ json=payload,
634
+ headers=headers,
635
+ timeout=aiohttp.ClientTimeout(total=5.0),
636
+ ) as resp:
637
+ body = await resp.text()
638
+ if resp.status == 200 and gateway_contract == "legacy_delivery":
639
+ logger.info(
640
+ "Proactive message pushed to gateway (platform=%s, chat_id=%s, source=%s)",
641
+ platform,
642
+ chat_id,
643
+ source,
644
+ )
645
+ return GatewayPushResult(
646
+ True, True, gateway_contract,
647
+ admission_state="delivered", delivery_id=delivery_id,
648
+ )
649
+ if resp.status == 200 and gateway_contract == "governed_admission_v1":
650
+ content_type = str(
651
+ getattr(resp, "headers", {}).get("Content-Type", "")
652
+ ).split(";", 1)[0].strip().lower()
653
+ document = _strict_gateway_json(body)
654
+ expected_fields = {
655
+ "schema", "version", "delivery_id", "state",
656
+ "intent_id", "provider_delivered",
657
+ }
658
+ admission_receipt = bool(
659
+ isinstance(document, dict)
660
+ and document.get("provider_delivered") is False
661
+ and document.get("state") in {
662
+ "accepted", "awaiting_approval"
663
+ }
664
+ and isinstance(document.get("intent_id"), str)
665
+ and bool(document.get("intent_id"))
666
+ )
667
+ delivery_receipt = bool(
668
+ isinstance(document, dict)
669
+ and document.get("provider_delivered") is True
670
+ and document.get("state") == "delivered"
671
+ and isinstance(document.get("intent_id"), str)
672
+ )
673
+ terminal_failure_receipt = bool(
674
+ isinstance(document, dict)
675
+ and document.get("provider_delivered") is False
676
+ and document.get("state") in {"failed", "ambiguous"}
677
+ and isinstance(document.get("intent_id"), str)
678
+ and bool(document.get("intent_id"))
679
+ )
680
+ if (
681
+ content_type != "application/json"
682
+ or not isinstance(document, dict)
683
+ or set(document) != expected_fields
684
+ or document.get("schema") != "GatewayBoundaryOutcomeV1"
685
+ or document.get("version") != 1
686
+ or document.get("delivery_id") != delivery_id
687
+ or not (
688
+ admission_receipt
689
+ or delivery_receipt
690
+ or terminal_failure_receipt
691
+ )
692
+ ):
693
+ logger.warning(
694
+ "Governed gateway admission response failed exact attestation"
695
+ )
696
+ return self._defer_or_fail_gateway(
697
+ stored,
698
+ delivery_id,
699
+ gateway_contract=gateway_contract,
700
+ )
701
+ if admission_receipt:
702
+ logger.info(
703
+ "Gateway admitted proactive message without provider-delivery "
704
+ "claim (platform=%s, source=%s, state=%s)",
705
+ platform, source, document["state"],
706
+ )
707
+ elif delivery_receipt:
708
+ logger.info(
709
+ "Gateway attested provider delivery "
710
+ "(platform=%s, source=%s)", platform, source,
711
+ )
712
+ else:
713
+ logger.warning(
714
+ "Gateway attested terminal outcome without delivery "
715
+ "(platform=%s, source=%s, state=%s)",
716
+ platform,
717
+ source,
718
+ document["state"],
719
+ )
720
+ assert self._gateway_outcomes is not None
721
+ observed, changed = self._gateway_outcomes.observe(
722
+ delivery_id,
723
+ state=str(document["state"]),
724
+ intent_id=str(document["intent_id"]),
725
+ provider_delivered=bool(document["provider_delivered"]),
726
+ )
727
+ return self._stored_gateway_result(
728
+ observed, observation_new=changed
729
+ )
730
+ logger.warning(
731
+ "Gateway /internal/deliver returned %d: %s",
732
+ resp.status,
733
+ body[:200],
734
+ )
735
+ return self._defer_or_fail_gateway(
736
+ stored, delivery_id, gateway_contract=gateway_contract,
737
+ )
738
+ except Exception as exc:
739
+ logger.warning("push_to_gateway failed: %s", exc)
740
+ return self._defer_or_fail_gateway(
741
+ stored, delivery_id, gateway_contract=gateway_contract,
742
+ )
743
+
744
+ def _prepare_initiative_dispatch(self, initiative: Dict[str, Any]) -> Dict[str, Any]:
745
+ """Build everything needed to dispatch an initiative to Hermes.
746
+
747
+ Pure/side-effect-free: resolves the recipient bucket and target
748
+ channel, builds the webhook payload, and signs the exact bytes that
749
+ go on the wire. Both :meth:`push_initiative` (which sends) and
750
+ :meth:`preview_initiative` (which does not) share this so the shadow
751
+ view is byte-identical to what a real send would transmit.
752
+
753
+ Returns a dict with: url, headers, body_bytes, payload, person_id
754
+ (rate-limit recipient bucket), urgency (0-1), channel_hint, target
755
+ ({user_chat, home_chat}).
756
+ """
757
+ # Hermes webhook URL — override via env var for flexibility
758
+ hermes_webhook_url = os.environ.get(
759
+ "COLONY_HERMES_WEBHOOK_URL",
760
+ "http://127.0.0.1:8644/webhooks/colony-initiatives",
761
+ )
762
+
763
+ headers: Dict[str, str] = {"Content-Type": "application/json"}
764
+ webhook_secret = os.environ.get("COLONY_HERMES_WEBHOOK_SECRET", "")
765
+
766
+ # Resolve agent name from env var — never hardcode
767
+ agent_name = os.environ.get("COLONY_AGENT_NAME", "the assistant")
768
+ initiative_id = initiative.get("id") or str(uuid.uuid4())
769
+ dedup_subject = initiative.get("entity_id") or initiative_id
770
+
771
+ # Rate-limit urgency stays on the 0-1 scale the limiter expects.
772
+ urgency = float(initiative.get("priority", 0.5) or 0.5)
773
+
774
+ # Normalize priority: if it's a float <= 1.0, scale to 0-100
775
+ raw_priority = initiative.get("priority", 0.5)
776
+ if isinstance(raw_priority, float) and raw_priority <= 1.0:
777
+ priority = int(raw_priority * 100)
778
+ else:
779
+ priority = int(raw_priority)
780
+
781
+ payload = {
782
+ "type": "initiative",
783
+ "occurred_at": datetime.now(timezone.utc).isoformat(),
784
+ "payload": {
785
+ "initiative_type": initiative.get("type", "unknown"),
786
+ "title": initiative.get("title", ""),
787
+ "description": initiative.get("description", ""),
788
+ "priority": priority,
789
+ "status": "pending",
790
+ "id": initiative_id,
791
+ "dedup_key": (
792
+ f"{initiative.get('type', 'unknown')}:{dedup_subject}"
793
+ ),
794
+ "agent_name": agent_name,
795
+ "context": {
796
+ "trigger": initiative.get("rationale", ""),
797
+ "suggested_actions": [initiative.get("suggested_action", "review_and_decide")]
798
+ if initiative.get("suggested_action")
799
+ else [],
800
+ "constraints": {},
801
+ "metadata": {
802
+ "source": "autonomy_loop",
803
+ "entity_id": initiative.get("entity_id"),
804
+ "entity_type": initiative.get("entity_type"),
805
+ },
806
+ },
807
+ "created_at": initiative.get("generated_at", datetime.now(timezone.utc).isoformat()),
808
+ "expires_at": None,
809
+ },
810
+ }
811
+
812
+ # Populate delivery_context for channel routing
813
+ raw_entity_id = initiative.get("entity_id", "")
814
+ initiative_type = initiative.get("type", "")
815
+
816
+ # Self-initiatives always route to home channel (v0.11.0)
817
+ is_self_initiative = initiative_type in {
818
+ "subsystem_health", "data_quality", "operational",
819
+ "capability_gap", "knowledge_acquisition", "behavioral_correction",
820
+ }
821
+
822
+ channel_hint = initiative.get("channel_hint", "home" if is_self_initiative else "dm")
823
+
824
+ if not raw_entity_id or is_self_initiative:
825
+ # System/self initiative — no DM, always home
826
+ person_id = os.environ.get("COLONY_OWNER_CONTACT_ID", "owner")
827
+ user_channel = None
828
+ home_channel = self._channel_registry.resolve("__system__", "home")
829
+ else:
830
+ # Relationship initiatives target a specific person (entity_id IS person_id).
831
+ # All other initiative types (follow_up, health, etc.) target the owner.
832
+ if initiative_type == "relationship":
833
+ person_id = raw_entity_id
834
+ else:
835
+ person_id = os.environ.get("COLONY_OWNER_CONTACT_ID", "owner")
836
+
837
+ user_channel = self._channel_registry.resolve(person_id, "dm")
838
+ home_channel = self._channel_registry.resolve(person_id, "home")
839
+
840
+ delivery_context = {}
841
+ if user_channel:
842
+ delivery_context["user_chat"] = f"{user_channel.platform}:{user_channel.chat_id}"
843
+ if home_channel:
844
+ delivery_context["home_chat"] = f"{home_channel.platform}:{home_channel.chat_id}"
845
+
846
+ if delivery_context:
847
+ payload["delivery_context"] = delivery_context
848
+ payload["channel_hint"] = channel_hint
849
+
850
+ # Serialize the payload exactly once and sign the bytes that go on the
851
+ # wire. Sending `json=payload` would let aiohttp re-serialize, so the
852
+ # HMAC could disagree with the receiver's view of the body.
853
+ body_bytes, headers = encode_hermes_webhook(
854
+ payload,
855
+ secret=webhook_secret,
856
+ )
857
+
858
+ return {
859
+ "url": hermes_webhook_url,
860
+ "headers": headers,
861
+ "body_bytes": body_bytes,
862
+ "payload": payload,
863
+ "person_id": person_id,
864
+ "urgency": urgency,
865
+ "channel_hint": channel_hint,
866
+ "target": dict(delivery_context),
867
+ }
868
+
869
+ async def _prepare_initiative_dispatch_async(
870
+ self, initiative: Dict[str, Any],
871
+ ) -> Dict[str, Any]:
872
+ """Prepare dispatch with an exact async contact route when governed.
873
+
874
+ Legacy and owner/default routing retains the synchronous registry
875
+ priority order. A governed relationship message to a non-owner is
876
+ stricter: it must resolve one verified WhatsApp handle from the
877
+ canonical async contact store and can never fall back to a home chat.
878
+ This resolves identity/transport only; authorization remains at the
879
+ governed producer boundary.
880
+ """
881
+ prep = self._prepare_initiative_dispatch(initiative)
882
+ if not self.governed_gateway_admission_enabled("whatsapp"):
883
+ return prep
884
+ if str(initiative.get("type") or "") != "relationship":
885
+ return prep
886
+
887
+ person_id = str(prep.get("person_id") or "").strip()
888
+ configured_owner = str(
889
+ os.environ.get("COLONY_OWNER_CONTACT_ID", "owner") or "owner"
890
+ ).strip()
891
+ if not person_id or person_id in {"owner", configured_owner}:
892
+ return prep
893
+
894
+ resolver = getattr(
895
+ self._channel_registry, "resolve_exact_verified_dm", None,
896
+ )
897
+ channel = None
898
+ if callable(resolver):
899
+ try:
900
+ channel = await resolver(person_id, platform="whatsapp")
901
+ except Exception:
902
+ logger.debug(
903
+ "Governed exact contact route resolution failed",
904
+ exc_info=True,
905
+ )
906
+
907
+ # Non-owner governed outreach has no fallback target. In particular,
908
+ # a configured home chat must never turn a missing/ambiguous DM into a
909
+ # group disclosure.
910
+ target: Dict[str, str] = {}
911
+ if channel is not None:
912
+ target["user_chat"] = f"{channel.platform}:{channel.chat_id}"
913
+ prep["target"] = target
914
+ payload = prep["payload"]
915
+ if target:
916
+ payload["delivery_context"] = dict(target)
917
+ payload["channel_hint"] = "dm"
918
+ else:
919
+ payload.pop("delivery_context", None)
920
+ payload.pop("channel_hint", None)
921
+
922
+ body_bytes, headers = encode_hermes_webhook(
923
+ payload,
924
+ secret=os.environ.get("COLONY_HERMES_WEBHOOK_SECRET", ""),
925
+ )
926
+ prep["body_bytes"] = body_bytes
927
+ prep["headers"] = headers
928
+ return prep
929
+
930
+ def preview_initiative(self, initiative: Dict[str, Any]) -> Dict[str, Any]:
931
+ """Resolve where/what an initiative WOULD be delivered, without sending.
932
+
933
+ Read-only. Returns the same recipient/target/payload a real
934
+ :meth:`push_initiative` would transmit, for shadow logging and
935
+ operator review.
936
+ """
937
+ prep = self._prepare_initiative_dispatch(initiative)
938
+ return {
939
+ "person_id": prep["person_id"],
940
+ "urgency": prep["urgency"],
941
+ "channel_hint": prep["channel_hint"],
942
+ "target": prep["target"],
943
+ "initiative_type": initiative.get("type", "unknown"),
944
+ "title": initiative.get("title", ""),
945
+ "description": initiative.get("description", ""),
946
+ "rationale": initiative.get("rationale", ""),
947
+ "suggested_action": initiative.get("suggested_action", ""),
948
+ "webhook_payload": prep["payload"],
949
+ }
950
+
951
+ async def preview_initiative_async(
952
+ self, initiative: Dict[str, Any],
953
+ ) -> Dict[str, Any]:
954
+ """Async preview used by the guarded autonomous delivery path."""
955
+ prep = await self._prepare_initiative_dispatch_async(initiative)
956
+ return {
957
+ "person_id": prep["person_id"],
958
+ "urgency": prep["urgency"],
959
+ "channel_hint": prep["channel_hint"],
960
+ "target": prep["target"],
961
+ "initiative_type": initiative.get("type", "unknown"),
962
+ "title": initiative.get("title", ""),
963
+ "description": initiative.get("description", ""),
964
+ "rationale": initiative.get("rationale", ""),
965
+ "suggested_action": initiative.get("suggested_action", ""),
966
+ "webhook_payload": prep["payload"],
967
+ }
968
+
969
+ async def push_initiative(self, initiative: Dict[str, Any]) -> bool:
970
+ """Push a structured initiative to Hermes via webhook.
971
+
972
+ Returns True if Hermes accepted (202), False otherwise.
973
+ """
974
+ try:
975
+ import aiohttp
976
+ except ImportError:
977
+ logger.warning("aiohttp not available — cannot push initiative")
978
+ return False
979
+
980
+ prep = self._prepare_initiative_dispatch(initiative)
981
+ hermes_webhook_url = prep["url"]
982
+ headers = prep["headers"]
983
+ body_bytes = prep["body_bytes"]
984
+ priority = prep["payload"]["payload"]["priority"]
985
+
986
+ try:
987
+ async with aiohttp.ClientSession() as session:
988
+ async with session.post(
989
+ hermes_webhook_url,
990
+ data=body_bytes,
991
+ headers=headers,
992
+ timeout=aiohttp.ClientTimeout(total=10.0),
993
+ ) as resp:
994
+ if resp.status == 202:
995
+ logger.info(
996
+ "Initiative pushed to Hermes: %s (type=%s, priority=%d)",
997
+ initiative.get("id"),
998
+ initiative.get("type"),
999
+ priority,
1000
+ )
1001
+ return True
1002
+ body = await resp.text()
1003
+ logger.warning(
1004
+ "Hermes webhook returned %d: %s",
1005
+ resp.status, body[:200]
1006
+ )
1007
+ return False
1008
+ except Exception as exc:
1009
+ logger.warning("push_initiative failed: %s", exc)
1010
+ return False
1011
+
1012
+ def get_pending(self, gateway_id: str = "", limit: int = 20) -> List[Dict[str, Any]]:
1013
+ """Return pending PUSH deliveries for the gateway to send.
1014
+
1015
+ Only returns unsent PUSH channel deliveries. IN_SESSION deliveries are
1016
+ fetched separately via get_in_session_context().
1017
+ """
1018
+ results = []
1019
+ for d in self._pending:
1020
+ if d.sent:
1021
+ continue
1022
+ if d.channel != "push":
1023
+ continue
1024
+ results.append(self._to_dict(d))
1025
+ if len(results) >= limit:
1026
+ break
1027
+ return results
1028
+
1029
+ def mark_sent(self, delivery_id: str) -> bool:
1030
+ """Mark a delivery as sent (called by gateway after successful send)."""
1031
+ for d in self._pending:
1032
+ if d.delivery_id == delivery_id:
1033
+ d.sent = True
1034
+ self._rate_limiter.record_delivery(d.person_id)
1035
+ self._sent.append(d)
1036
+ if len(self._sent) > self._sent_max:
1037
+ self._sent = self._sent[-self._sent_max:]
1038
+ logger.info("Delivery %s marked sent (person=%s)", delivery_id, d.person_id)
1039
+ return True
1040
+ logger.debug("mark_sent: delivery %s not found", delivery_id)
1041
+ return False
1042
+
1043
+ def get_in_session_context(self, person_id: str) -> Optional[str]:
1044
+ """Return pending IN_SESSION deliveries formatted for prompt injection.
1045
+
1046
+ Does NOT mark them as consumed — they survive until explicitly
1047
+ acknowledged or expired (see expire_in_session_deliveries).
1048
+ """
1049
+ now = datetime.now(timezone.utc)
1050
+ in_session = [
1051
+ d for d in self._pending
1052
+ if d.person_id == person_id
1053
+ and d.channel == "in_session"
1054
+ and not d.sent
1055
+ and (now - d.queued_at).total_seconds() < 86400 # 24h max age
1056
+ ]
1057
+ if not in_session:
1058
+ return None
1059
+
1060
+ lines = ["[Things to mention this session]"]
1061
+ for d in in_session:
1062
+ lines.append(f"• {d.content}")
1063
+
1064
+ return "\n".join(lines)
1065
+
1066
+ def expire_in_session_deliveries(self, max_age_hours: float = 24) -> int:
1067
+ """Mark IN_SESSION deliveries older than max_age_hours as sent (expired).
1068
+
1069
+ Returns the count expired.
1070
+ """
1071
+ cutoff = datetime.now(timezone.utc) - timedelta(hours=max_age_hours)
1072
+ expired = 0
1073
+ for d in self._pending:
1074
+ if d.channel == "in_session" and not d.sent and d.queued_at < cutoff:
1075
+ d.sent = True
1076
+ self._rate_limiter.record_delivery(d.person_id)
1077
+ expired += 1
1078
+ if expired:
1079
+ logger.info("Expired %d stale in_session deliveries", expired)
1080
+ return expired
1081
+
1082
+ def acknowledge_delivery(self, initiative_id: str) -> bool:
1083
+ """Mark any pending delivery matching initiative_id as sent.
1084
+
1085
+ Called when the agent explicitly acknowledges an initiative.
1086
+ """
1087
+ for d in self._pending:
1088
+ if d.initiative_id == initiative_id and not d.sent:
1089
+ d.sent = True
1090
+ self._rate_limiter.record_delivery(d.person_id)
1091
+ logger.info("Delivery %s acknowledged (initiative=%s)", d.delivery_id, initiative_id)
1092
+ return True
1093
+ return False
1094
+
1095
+ # ------------------------------------------------------------------
1096
+ # DIGEST channel
1097
+ # ------------------------------------------------------------------
1098
+
1099
+ def get_pending_digest(self, person_id: str) -> List[PendingDelivery]:
1100
+ """Return all unsent DIGEST-channel deliveries for ``person_id``."""
1101
+ return [
1102
+ d for d in self._pending
1103
+ if d.person_id == person_id and d.channel == "digest" and not d.sent
1104
+ ]
1105
+
1106
+ def pending_digest_recipients(self) -> List[str]:
1107
+ """List distinct ``person_id`` values with pending DIGEST items."""
1108
+ seen = set()
1109
+ ordered: List[str] = []
1110
+ for d in self._pending:
1111
+ if d.channel != "digest" or d.sent:
1112
+ continue
1113
+ if d.person_id in seen:
1114
+ continue
1115
+ seen.add(d.person_id)
1116
+ ordered.append(d.person_id)
1117
+ return ordered
1118
+
1119
+ def build_digest_bundle(
1120
+ self,
1121
+ person_id: str,
1122
+ *,
1123
+ header: str = "Daily digest",
1124
+ ) -> Optional[str]:
1125
+ """Format this person's pending DIGEST items into a single bundled text
1126
+ block. Does not mark anything consumed — pair with ``consume_digest``.
1127
+ Items are sorted by urgency descending, then by queue time."""
1128
+ items = self.get_pending_digest(person_id)
1129
+ if not items:
1130
+ return None
1131
+ items = sorted(items, key=lambda d: (-d.urgency, d.queued_at))
1132
+ lines = [f"[{header}]"]
1133
+ for d in items:
1134
+ prefix = "\u203c" if d.urgency >= 0.8 else "\u2022"
1135
+ lines.append(f"{prefix} {d.content}")
1136
+ return "\n".join(lines)
1137
+
1138
+ def consume_digest(self, person_id: str) -> int:
1139
+ """Mark all pending DIGEST deliveries for ``person_id`` as sent.
1140
+
1141
+ Returns the count consumed. Each consumed item is also recorded
1142
+ against the rate limiter so the digest flush respects per-person
1143
+ caps consistently with the other channels.
1144
+ """
1145
+ consumed = 0
1146
+ for d in self._pending:
1147
+ if d.person_id == person_id and d.channel == "digest" and not d.sent:
1148
+ d.sent = True
1149
+ self._rate_limiter.record_delivery(d.person_id)
1150
+ self._sent.append(d)
1151
+ consumed += 1
1152
+ if consumed and len(self._sent) > self._sent_max:
1153
+ self._sent = self._sent[-self._sent_max:]
1154
+ return consumed
1155
+
1156
+ async def flush_digests_to_gateway(
1157
+ self,
1158
+ *,
1159
+ platform: Optional[str] = None,
1160
+ chat_id: Optional[str] = None,
1161
+ header: str = "Daily digest",
1162
+ ) -> Dict[str, Any]:
1163
+ """Bundle each recipient's pending DIGEST items and push the bundle
1164
+ to the gateway.
1165
+
1166
+ When ``platform``/``chat_id`` are omitted, the bridge falls back to
1167
+ the configured home channel (see ``resolve_home_channel``). If no
1168
+ home channel is configured, the flush is a no-op that returns
1169
+ ``{"sent": 0, "reason": "no_home_channel"}`` so a scheduler can
1170
+ still drain the item count at call time.
1171
+
1172
+ Returns a summary dict: ``{"sent": N, "admitted": N, "skipped": M,
1173
+ "recipients": [...], "reason": ...}``. ``admitted`` is explicitly
1174
+ not provider delivery.
1175
+ """
1176
+ recipients = self.pending_digest_recipients()
1177
+ if not recipients:
1178
+ return {"sent": 0, "admitted": 0, "skipped": 0, "recipients": []}
1179
+
1180
+ if not platform or not chat_id:
1181
+ home = self.resolve_home_channel()
1182
+ if home is None:
1183
+ return {
1184
+ "sent": 0,
1185
+ "admitted": 0,
1186
+ "skipped": len(recipients),
1187
+ "recipients": recipients,
1188
+ "reason": "no_home_channel",
1189
+ }
1190
+ platform = platform or home["platform"]
1191
+ chat_id = chat_id or home["chat_id"]
1192
+
1193
+ sent = 0
1194
+ admitted = 0
1195
+ skipped = 0
1196
+ for person_id in recipients:
1197
+ bundle = self.build_digest_bundle(person_id, header=header)
1198
+ if not bundle:
1199
+ continue
1200
+ digest_source_ids = sorted(
1201
+ delivery.delivery_id
1202
+ for delivery in self.get_pending_digest(person_id)
1203
+ )
1204
+ digest_identity = hashlib.sha256(
1205
+ json.dumps(
1206
+ digest_source_ids,
1207
+ ensure_ascii=False,
1208
+ separators=(",", ":"),
1209
+ ).encode("utf-8")
1210
+ ).hexdigest()
1211
+ outcome = await self.push_to_gateway(
1212
+ platform=platform,
1213
+ chat_id=chat_id,
1214
+ message=bundle,
1215
+ source="digest",
1216
+ delivery_id="digest:" + digest_identity,
1217
+ source_id="digest:" + digest_identity,
1218
+ )
1219
+ if bool(outcome) and getattr(
1220
+ outcome, "provider_delivered", bool(outcome)
1221
+ ) is True:
1222
+ self.consume_digest(person_id)
1223
+ sent += 1
1224
+ elif bool(outcome):
1225
+ # Admission to a governed approval/dispatch queue is durable
1226
+ # work, but it is not provider delivery and must not consume
1227
+ # the delivery-rate budget or pending digest evidence.
1228
+ admitted += 1
1229
+ else:
1230
+ skipped += 1
1231
+ return {
1232
+ "sent": sent,
1233
+ "admitted": admitted,
1234
+ "skipped": skipped,
1235
+ "recipients": recipients,
1236
+ }
1237
+
1238
+ def purge_sent(self) -> int:
1239
+ """Remove sent deliveries from the pending queue. Returns count purged."""
1240
+ before = len(self._pending)
1241
+ self._pending = [d for d in self._pending if not d.sent]
1242
+ return before - len(self._pending)
1243
+
1244
+ # ------------------------------------------------------------------
1245
+ # Helpers
1246
+ # ------------------------------------------------------------------
1247
+
1248
+ @staticmethod
1249
+ def _to_dict(d: PendingDelivery) -> Dict[str, Any]:
1250
+ return {
1251
+ "delivery_id": d.delivery_id,
1252
+ "person_id": d.person_id,
1253
+ "content": d.content,
1254
+ "channel": d.channel,
1255
+ "urgency": d.urgency,
1256
+ "source": d.source,
1257
+ "initiative_id": d.initiative_id,
1258
+ "queued_at": d.queued_at.isoformat(),
1259
+ "metadata": d.metadata,
1260
+ }