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,2643 @@
1
+ """Initiative Engine — generate proactive suggestions.
2
+
3
+ Generates:
4
+ - Follow-up reminders
5
+ - Relationship maintenance suggestions
6
+ - Health insights
7
+ - Scheduling recommendations
8
+ """
9
+
10
+ import asyncio
11
+ import os
12
+ import re
13
+ import unicodedata
14
+ import uuid as _uuid_module
15
+ from dataclasses import dataclass, field
16
+ from datetime import datetime, timedelta, timezone
17
+ from enum import Enum
18
+ from typing import Any, Dict, List, Optional
19
+
20
+ import logging
21
+
22
+ from apsimo.skills.base import ExecutionResult, InitiativeExecutionContext
23
+ from apsimo.skills.registry import SkillRegistry
24
+ from apsimo.initiatives.context_freshness import is_context_fresh
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ @dataclass
30
+ class InitiativeConfig:
31
+ """Configuration for initiative generation thresholds."""
32
+
33
+ # Contact neglect threshold (days)
34
+ contact_neglect_days: int = 7
35
+
36
+ # Goal block threshold (days before generating initiative)
37
+ goal_block_threshold_days: int = 1
38
+
39
+ # Health score threshold (below this generates alert)
40
+ health_score_threshold: float = 70.0
41
+
42
+ # Calendar gap threshold (hours — gaps larger than this are opportunities)
43
+ calendar_gap_threshold_hours: float = 2.0
44
+
45
+ # Research task age threshold (days — tasks older than this generate initiatives)
46
+ research_task_age_days: int = 1
47
+
48
+ # Signal accumulation threshold (count — above this generates initiative)
49
+ signal_accumulation_threshold: int = 10
50
+
51
+ # Capability gap failure threshold (count — capabilities with at least
52
+ # this many recorded failures in the recent window generate initiatives)
53
+ capability_gap_failures: int = 3
54
+
55
+ # Optional deployment-owned receipt; replaces only the legacy backup check.
56
+ backup_receipt_path: Optional[str] = None
57
+
58
+ @classmethod
59
+ def from_env(cls) -> "InitiativeConfig":
60
+ """Load configuration from environment variables."""
61
+ def _int(env_var: str, default: int) -> int:
62
+ try:
63
+ return int(os.getenv(env_var, str(default)))
64
+ except ValueError:
65
+ logger.warning("Invalid %s, using default %d", env_var, default)
66
+ return default
67
+
68
+ def _float(env_var: str, default: float) -> float:
69
+ try:
70
+ return float(os.getenv(env_var, str(default)))
71
+ except ValueError:
72
+ logger.warning("Invalid %s, using default %.1f", env_var, default)
73
+ return default
74
+
75
+ return cls(
76
+ contact_neglect_days=_int("COLONY_INITIATIVE_CONTACT_NEGLECT_DAYS", 7),
77
+ goal_block_threshold_days=_int("COLONY_INITIATIVE_GOAL_BLOCK_DAYS", 1),
78
+ health_score_threshold=_float("COLONY_INITIATIVE_HEALTH_THRESHOLD", 70.0),
79
+ calendar_gap_threshold_hours=_float("COLONY_INITIATIVE_GAP_THRESHOLD", 2.0),
80
+ research_task_age_days=_int("COLONY_INITIATIVE_RESEARCH_AGE_DAYS", 1),
81
+ signal_accumulation_threshold=_int("COLONY_INITIATIVE_SIGNAL_THRESHOLD", 10),
82
+ capability_gap_failures=_int("COLONY_CAPABILITY_GAP_FAILURES", 3),
83
+ backup_receipt_path=os.getenv("COLONY_INITIATIVE_BACKUP_RECEIPT") or None,
84
+ )
85
+
86
+
87
+ class InitiativeType(str, Enum):
88
+ """Categories of proactive suggestions."""
89
+
90
+ FOLLOW_UP = "follow_up"
91
+ RELATIONSHIP = "relationship"
92
+ INTRODUCTION = "introduction" # propose connecting two people (owner-approved)
93
+ HEALTH = "health"
94
+ SCHEDULING = "scheduling"
95
+ CODING = "coding" # Code execution / refactoring tasks
96
+
97
+ # Self-initiative types (v0.11.0)
98
+ SUBSYSTEM_HEALTH = "subsystem_health"
99
+ DATA_QUALITY = "data_quality"
100
+ OPERATIONAL = "operational"
101
+ CAPABILITY_GAP = "capability_gap"
102
+ KNOWLEDGE_ACQUISITION = "knowledge_acquisition"
103
+ BEHAVIORAL_CORRECTION = "behavioral_correction"
104
+
105
+ # Agent-actionable initiatives (v0.13.0)
106
+ AGENT_ACTION = "agent_action"
107
+
108
+ # Autonomous work domains (v0.16.0) — Colony as the agent's
109
+ # general-purpose work engine, not just relationship upkeep.
110
+ # Context durability per type lives in initiatives/context_freshness.py.
111
+ COMMITMENT = "commitment" # promises and follow-through (durable)
112
+ CALENDAR = "calendar" # calendar awareness and prep (volatile)
113
+ RESEARCH = "research" # long-running research tasks (durable)
114
+ TASK = "task" # task management and follow-up (durable)
115
+ PROJECT = "project" # milestones and project management (durable)
116
+ SYSTEM = "system" # infrastructure monitoring (volatile)
117
+
118
+
119
+ @dataclass
120
+ class Initiative:
121
+ """A proactive suggestion.
122
+
123
+ Attributes:
124
+ id: Unique initiative identifier
125
+ type: Category of suggestion
126
+ description: Human-readable description of what to do
127
+ priority: How important this is (0-1)
128
+ rationale: Why this suggestion was generated
129
+ action_hint: Optional suggested concrete action
130
+ entity_id: Optional related entity (person, task, etc.)
131
+ dedup_key: Optional deduplication key (prevents duplicates)
132
+ expires_at: When this initiative is no longer relevant
133
+ created_at: When the initiative was generated
134
+ """
135
+
136
+ id: str
137
+ type: InitiativeType
138
+ description: str
139
+ priority: float
140
+ rationale: str
141
+ action_hint: Optional[str] = None
142
+ entity_id: Optional[str] = None
143
+ dedup_key: Optional[str] = None
144
+ # Logical (un-bucketed) dedup key. For recurring types, generate() rewrites dedup_key to a
145
+ # per-period key and sets dedup_base to the original, so the store re-arms each period yet
146
+ # still suppresses a duplicate while a prior instance is in flight. None for one-shot types.
147
+ dedup_base: Optional[str] = None
148
+ expires_at: Optional[datetime] = None
149
+ created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
150
+ trigger_data: Optional[Dict[str, Any]] = None
151
+
152
+
153
+ # Recurring initiative classes keyed by their dedup_key PREFIX (the part before the first ":"),
154
+ # mapped to a re-arm period in seconds. An initiative of one of these classes gets a per-period
155
+ # dedup_key so it can fire again each period; anything not listed (deliver, commitment,
156
+ # capability_gap, knowledge_gap, behavioral_correction, agent_action, ...) stays one-shot.
157
+ RECURRENCE_INTERVALS_SECS: Dict[str, int] = {
158
+ "relationship": 72 * 3600,
159
+ "health": 24 * 3600,
160
+ "followup": 12 * 3600,
161
+ "coding": 6 * 3600,
162
+ "task": 6 * 3600,
163
+ "calendar": 6 * 3600,
164
+ "research": 12 * 3600,
165
+ "project": 12 * 3600,
166
+ "system": 6 * 3600,
167
+ "subsystem": 6 * 3600,
168
+ "dataquality": 12 * 3600,
169
+ "operational": 12 * 3600,
170
+ }
171
+
172
+ # Bounded headroom for proactive social proposals (introductions). They are
173
+ # low-priority by design, so a busy operational loop saturating the cap would
174
+ # otherwise permanently starve them. Surface at most this many per cycle even
175
+ # when the cap is full (0 disables).
176
+ _INTRO_HEADROOM = max(0, int(os.environ.get("COLONY_INTRO_HEADROOM", "2")))
177
+
178
+
179
+ def _apply_recurrence_buckets(initiatives: "List[Initiative]") -> None:
180
+ """For each recurring initiative, move its dedup_key to dedup_base and append a time bucket,
181
+ so the store re-arms it every period instead of being permanently blocked by the prior
182
+ period's terminal record. Mutates in place; no-op for one-shot types or already-bucketed."""
183
+ now_ts = datetime.now(timezone.utc).timestamp()
184
+ for init in initiatives:
185
+ key = getattr(init, "dedup_key", None)
186
+ if not key or getattr(init, "dedup_base", None):
187
+ continue
188
+ prefix = key.split(":", 1)[0]
189
+ interval = RECURRENCE_INTERVALS_SECS.get(prefix)
190
+ if not interval:
191
+ continue
192
+ bucket = int(now_ts // interval)
193
+ init.dedup_base = key
194
+ init.dedup_key = f"{key}:{bucket}"
195
+
196
+
197
+ class InitiativeEngine:
198
+ """Generate proactive suggestions.
199
+
200
+ Analyzes context data provided via ``add_context()`` to surface
201
+ actionable suggestions the user hasn't explicitly asked for.
202
+
203
+ Context categories:
204
+ - ``pending_tasks``: List of dicts with keys ``description``, ``days_pending``
205
+ - ``neglected_contacts``: List of dicts with ``name``, ``entity_id``, ``days_since_contact``
206
+ - ``health_alerts``: List of dicts with ``metric``, ``value``, ``target``
207
+ - ``scheduling_opportunities``: List of dicts with ``description``, ``priority``, ``rationale``, ``action_hint``
208
+ - ``completed_tasks``: List of dicts with ``description``, ``entity_id``, ``result`` (Gap C)
209
+
210
+ Args:
211
+ graph_client: Colony graph client for relationship/entity data
212
+ event_bus: Colony event bus for subscribing to relevant events
213
+ mind_model: Mind model for behavioral state awareness
214
+ goal_store: Optional GoalStore for initiative dedup cooldown
215
+ """
216
+
217
+ # Common suffixes that create false duplicates (e.g. "Craig Wiener" vs "Craig Wiener US")
218
+ _NAME_SUFFIXES = {"us", "usa", "uk", "jr", "sr", "ii", "iii", "iv", "dr", "mr", "ms"}
219
+
220
+ @staticmethod
221
+ def _normalize_contact_name(name: str) -> str:
222
+ """Normalize a contact name for deduplication.
223
+
224
+ Steps:
225
+ 1. Strip leading/trailing whitespace
226
+ 2. Lowercase
227
+ 3. Remove accents (NFKD decomposition)
228
+ 4. Remove common location/professional suffixes
229
+ 5. Collapse multiple spaces
230
+ """
231
+ if not name or not isinstance(name, str):
232
+ return ""
233
+ name = name.strip().lower()
234
+ # Decompose accents: "João" → "Joao", "López" → "Lopez"
235
+ name = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode("ascii")
236
+ # Remove common suffixes like " US", " USA", " Jr." etc.
237
+ parts = name.split()
238
+ filtered = []
239
+ for part in parts:
240
+ clean = part.strip(".,")
241
+ if clean not in InitiativeEngine._NAME_SUFFIXES:
242
+ filtered.append(clean)
243
+ name = " ".join(filtered)
244
+ # Collapse multiple spaces
245
+ name = re.sub(r"\s+", " ", name).strip()
246
+ return name
247
+
248
+ @staticmethod
249
+ def _is_meaningful_contact(name: str) -> bool:
250
+ """Filter out junk/system nodes that shouldn't generate initiatives."""
251
+ if not name or not isinstance(name, str):
252
+ return False
253
+ name = name.strip()
254
+ if len(name) < 3:
255
+ return False
256
+ # Single word names are suspicious (unless they're known mononyms)
257
+ words = name.split()
258
+ if len(words) < 2:
259
+ return False
260
+ # Block known system/junk names
261
+ blocked = {
262
+ "another", "best", "can", "conversation", "episodic",
263
+ "gateway", "has", "high", "hydrahost", "infrastructure",
264
+ "integration", "local-llama", "logged", "memories", "memory",
265
+ "mind", "openclaw", "phase", "process", "session", "should",
266
+ "tmux", "vllm", "what", "unknown", "default", "none",
267
+ }
268
+ if name.lower() in blocked:
269
+ return False
270
+ # Block phone numbers and UUID-like strings
271
+ if re.match(r"^\+?\d", name) or re.match(r"^[a-f0-9-]{8,}$", name.replace("-", "")):
272
+ return False
273
+ return True
274
+
275
+ def __init__(
276
+ self,
277
+ graph_client: Any,
278
+ event_bus: Any,
279
+ mind_model: Any,
280
+ store: Optional[Any] = None, # InitiativeStore for persistence
281
+ goal_store: Optional[Any] = None, # GoalStore for dedup cooldown (v0.7.10)
282
+ config: Optional[InitiativeConfig] = None,
283
+ skill_registry: Optional[SkillRegistry] = None,
284
+ observation_store: Optional[Any] = None, # agent-reported snapshots (v0.16.0)
285
+ ) -> None:
286
+ self.graph = graph_client
287
+ self.events = event_bus
288
+ self.mind_model = mind_model
289
+ self._store = store
290
+ self._goal_store = goal_store
291
+ self._observation_store = observation_store
292
+ self._config = config or InitiativeConfig.from_env()
293
+ self._initiatives: List[Initiative] = []
294
+ self._context: Dict[str, List[Dict[str, Any]]] = {}
295
+
296
+ # Skill registry for self-initiative execution (v0.11.0)
297
+ self._skills = skill_registry or SkillRegistry(
298
+ graph_client=graph_client,
299
+ event_bus=event_bus,
300
+ )
301
+
302
+ # Track last graph load to avoid redundant queries within same tick
303
+ self._last_graph_load: Optional[datetime] = None
304
+
305
+ # Track last self-initiative execution per category for cooldown
306
+ self._last_self_initiative_at: Dict[str, datetime] = {}
307
+
308
+ # ------------------------------------------------------------------
309
+ # Context management
310
+ # ------------------------------------------------------------------
311
+
312
+ def add_context(
313
+ self,
314
+ context_type: str,
315
+ items: List[Dict[str, Any]],
316
+ ) -> None:
317
+ """Provide context data for initiative generation.
318
+
319
+ Args:
320
+ context_type: One of "pending_tasks", "neglected_contacts",
321
+ "health_alerts", "scheduling_opportunities"
322
+ items: List of context item dicts (schema depends on type)
323
+ """
324
+ if context_type not in self._context:
325
+ self._context[context_type] = []
326
+ self._context[context_type].extend(items)
327
+ logger.debug("Added %d items to context '%s'", len(items), context_type)
328
+
329
+ def clear_context(self, context_type: Optional[str] = None) -> None:
330
+ """Clear context data, optionally for a specific type only.
331
+
332
+ Args:
333
+ context_type: If provided, only clear this type; else clear all.
334
+ """
335
+ if context_type:
336
+ self._context.pop(context_type, None)
337
+ # Partial clear doesn't reset graph load cache
338
+ else:
339
+ self._context.clear()
340
+ # Reset graph load cache so next generate() reloads from graph (Bug 37)
341
+ self._last_graph_load = None
342
+
343
+ # ------------------------------------------------------------------
344
+ # Neo4j datetime helper (Bug 50/51)
345
+ # ------------------------------------------------------------------
346
+
347
+ @staticmethod
348
+ def _parse_neo4j_datetime(value: Any) -> Optional[datetime]:
349
+ """Convert Neo4j datetime or string to a tz-aware (UTC) Python datetime.
350
+
351
+ Always normalizes to tz-aware UTC. A naive datetime reaching the
352
+ callers (which subtract it from ``datetime.now(timezone.utc)``) raises
353
+ ``TypeError: can't subtract offset-naive and offset-aware datetimes``,
354
+ which the surrounding ``except Exception`` swallows — silently dropping
355
+ every blocked-goal / pending-research initiative for that query variant.
356
+ Normalizing here keeps that whole class of initiative from vanishing.
357
+ """
358
+ if value is None:
359
+ return None
360
+ dt: Optional[datetime] = None
361
+ if isinstance(value, str):
362
+ dt = datetime.fromisoformat(value.replace('Z', '+00:00'))
363
+ elif hasattr(value, 'to_native'):
364
+ # neo4j.time.DateTime
365
+ dt = value.to_native()
366
+ elif isinstance(value, datetime):
367
+ dt = value
368
+ if dt is None:
369
+ return None
370
+ if dt.tzinfo is None:
371
+ dt = dt.replace(tzinfo=timezone.utc)
372
+ return dt
373
+
374
+ # ------------------------------------------------------------------
375
+ # Graph context loading
376
+ # ------------------------------------------------------------------
377
+
378
+ async def _load_graph_context(self) -> None:
379
+ """Load live data from graph and mind model into self._context.
380
+
381
+ This is the core fix — it populates the context dict that
382
+ _generate_* methods read from. All queries are defensive:
383
+ if a subsystem is unavailable, that category is skipped.
384
+
385
+ Respects manually-added context: if a category already has data
386
+ (e.g. from AutonomyLoop._feed_* methods), graph loading for that
387
+ category is skipped to avoid duplicates.
388
+ """
389
+ # Avoid redundant loads within same tick
390
+ if self._last_graph_load and (
391
+ datetime.now(timezone.utc) - self._last_graph_load
392
+ ) < timedelta(seconds=10):
393
+ return
394
+
395
+ self._last_graph_load = datetime.now(timezone.utc)
396
+
397
+ # Only load categories that don't already have manually-fed context
398
+ loaders = []
399
+ if "pending_tasks" not in self._context:
400
+ loaders.append(self._load_blocked_goals())
401
+ loaders.append(self._load_pending_research_tasks())
402
+ if "neglected_contacts" not in self._context:
403
+ loaders.append(self._load_neglected_contacts())
404
+ if "health_alerts" not in self._context:
405
+ loaders.append(self._load_health_trends())
406
+ if "scheduling_opportunities" not in self._context:
407
+ loaders.append(self._load_scheduling_opportunities())
408
+ # Always check signals unless explicitly in context (Bug 40)
409
+ if "pending_signals" not in self._context:
410
+ loaders.append(self._load_pending_signals())
411
+ # Self-initiative context loaders (v0.11.0)
412
+ if "subsystem_health" not in self._context:
413
+ loaders.append(self._load_subsystem_health())
414
+ if "data_quality_issues" not in self._context:
415
+ loaders.append(self._load_data_quality_issues())
416
+ if "operational_tasks" not in self._context:
417
+ loaders.append(self._load_operational_tasks())
418
+ if "initiative_categories" not in self._context:
419
+ loaders.append(self._load_initiative_categories())
420
+ # New self-initiative loaders (v0.11.1)
421
+ if "capability_gaps" not in self._context:
422
+ loaders.append(self._load_capability_gaps())
423
+ if "knowledge_gaps" not in self._context:
424
+ loaders.append(self._load_knowledge_gaps())
425
+ if "behavioral_patterns" not in self._context:
426
+ loaders.append(self._load_behavioral_patterns())
427
+
428
+ if loaders:
429
+ await asyncio.gather(*loaders, return_exceptions=True)
430
+
431
+ async def _load_blocked_goals(self) -> None:
432
+ """Query graph for blocked/stuck goals.
433
+
434
+ Schema-adaptive: tries multiple property names for compatibility
435
+ with different graph schemas. Filters out terminal states
436
+ (abandoned, completed, cancelled) so dead goals don't generate
437
+ follow-up initiatives forever.
438
+ """
439
+ if self.graph is None or not hasattr(self.graph, 'driver'):
440
+ return
441
+
442
+ # Try multiple query variants for schema compatibility.
443
+ # All variants exclude terminal goal states.
444
+ queries = [
445
+ # Colony schema (status='blocked', blocked_at)
446
+ {
447
+ "query": """
448
+ MATCH (g:Goal)
449
+ WHERE g.status = 'blocked'
450
+ AND NOT coalesce(g.state, '') IN ['abandoned', 'completed', 'cancelled']
451
+ AND NOT coalesce(g.status, '') IN ['abandoned', 'completed', 'cancelled']
452
+ AND g.blocked_at < datetime() - duration({days: $days})
453
+ RETURN g.id as id, g.title as title, g.description as description,
454
+ g.blocked_at as blocked_at, g.priority as priority
455
+ ORDER BY g.priority DESC, g.blocked_at ASC
456
+ """,
457
+ "date_field": "blocked_at",
458
+ },
459
+ # Alternative: state='blocked', blocked_at
460
+ {
461
+ "query": """
462
+ MATCH (g:Goal)
463
+ WHERE g.state = 'blocked'
464
+ AND NOT coalesce(g.state, '') IN ['abandoned', 'completed', 'cancelled']
465
+ AND NOT coalesce(g.status, '') IN ['abandoned', 'completed', 'cancelled']
466
+ AND (g.blocked_at < datetime() - duration({days: $days})
467
+ OR g.blocked_at IS NULL)
468
+ RETURN g.id as id, g.title as title, g.description as description,
469
+ g.blocked_at as blocked_at, g.priority as priority
470
+ ORDER BY g.priority DESC, g.blocked_at ASC
471
+ """,
472
+ "date_field": "blocked_at",
473
+ },
474
+ # Fallback: status='blocked', updated_at
475
+ {
476
+ "query": """
477
+ MATCH (g:Goal)
478
+ WHERE (g.status = 'blocked' OR g.state = 'blocked')
479
+ AND NOT coalesce(g.state, '') IN ['abandoned', 'completed', 'cancelled']
480
+ AND NOT coalesce(g.status, '') IN ['abandoned', 'completed', 'cancelled']
481
+ RETURN g.id as id, g.title as title, g.description as description,
482
+ g.updated_at as blocked_at, g.priority as priority
483
+ ORDER BY g.priority DESC, g.updated_at ASC
484
+ """,
485
+ "date_field": "updated_at",
486
+ },
487
+ ]
488
+
489
+ tasks = []
490
+ date_field = "blocked_at" # default
491
+ for variant in queries:
492
+ if tasks: # Stop if we found data
493
+ break
494
+ try:
495
+ async with self.graph.driver.session(database=self.graph.database) as session:
496
+ result = await session.run(
497
+ variant["query"],
498
+ days=self._config.goal_block_threshold_days,
499
+ )
500
+ async for record in result:
501
+ record = dict(record)
502
+ date_field = variant["date_field"]
503
+ blocked_at = self._parse_neo4j_datetime(record.get(date_field))
504
+ # Bug 13: max(0, ...) to prevent negative days
505
+ days_pending = max(0, (datetime.now(timezone.utc) - blocked_at).days) if blocked_at else 0
506
+
507
+ tasks.append({
508
+ "entity_id": record["id"],
509
+ "description": record.get("title", "Unknown goal"),
510
+ "days_pending": days_pending,
511
+ "priority": record.get("priority", 0.5),
512
+ })
513
+
514
+ if tasks:
515
+ logger.debug("Loaded %d blocked goals using %s", len(tasks), date_field)
516
+ except Exception as e:
517
+ logger.debug("Blocked goals query variant failed: %s", e)
518
+ continue
519
+
520
+ self._context["pending_tasks"] = tasks
521
+ if not tasks:
522
+ logger.debug("No blocked goals found with any schema variant")
523
+ self._context.setdefault("pending_tasks", [])
524
+
525
+ async def _load_neglected_contacts(self) -> None:
526
+ """Query graph for contacts with no recent interaction.
527
+
528
+ Only returns contacts that have an explicit interaction date
529
+ older than the threshold. Nodes with no dates at all are NOT
530
+ treated as neglected — they are likely uninitialised or system
531
+ nodes. Skips the host's own contact.
532
+ """
533
+ if self.graph is None or not hasattr(self.graph, 'driver'):
534
+ return
535
+
536
+ # Owner exclusion is a relationship-domain policy: the agent must
537
+ # not generate "check in with" work targeting its own operator.
538
+ # Fail closed — if the owner's identity can't be established we
539
+ # generate nothing rather than risk targeting the owner. (The old
540
+ # COLONY_HOST_CONTACT_ID default of "owner" never matched anything,
541
+ # which let the owner through every time.)
542
+ from apsimo.identity.resolver import (
543
+ OwnerIdentityError,
544
+ get_identity_resolver,
545
+ )
546
+ resolver = get_identity_resolver()
547
+ try:
548
+ await resolver.owner_identities()
549
+ except OwnerIdentityError as exc:
550
+ logger.critical(
551
+ "Owner identity unresolved — skipping neglected-contact "
552
+ "scan (fail closed): %s", exc,
553
+ )
554
+ self._context["neglected_contacts"] = []
555
+ return
556
+
557
+ query = """
558
+ MATCH (p:Person)
559
+ WITH p,
560
+ CASE
561
+ WHEN p.lastCommunication IS NOT NULL
562
+ AND p.lastCommunication < datetime() - duration({days: $days})
563
+ THEN duration.inDays(p.lastCommunication, datetime()).days
564
+ WHEN p.lastInteraction IS NOT NULL
565
+ AND p.lastInteraction < datetime() - duration({days: $days})
566
+ THEN duration.inDays(p.lastInteraction, datetime()).days
567
+ WHEN p.lastSeen IS NOT NULL
568
+ AND p.lastSeen < datetime() - duration({days: $days})
569
+ THEN duration.inDays(p.lastSeen, datetime()).days
570
+ ELSE null
571
+ END AS days_since
572
+ WHERE days_since IS NOT NULL
573
+ RETURN p.id as id, p.name as name, p.score as score, days_since
574
+ ORDER BY days_since DESC
575
+ LIMIT $limit
576
+ """
577
+
578
+ contacts = []
579
+ try:
580
+ async with self.graph.driver.session(database=self.graph.database) as session:
581
+ result = await session.run(
582
+ query,
583
+ days=self._config.contact_neglect_days,
584
+ limit=100,
585
+ )
586
+ async for record in result:
587
+ record = dict(record)
588
+ name = record.get("name", "Unknown")
589
+ entity_id = record.get("id")
590
+ days_since = record.get("days_since")
591
+
592
+ # Skip the owner (any identifier format) and junk nodes
593
+ if await resolver.is_owner(entity_id) or await resolver.is_owner(name):
594
+ continue
595
+ if not self._is_meaningful_contact(name):
596
+ logger.debug("Skipping junk contact: %s (%s)", name, entity_id)
597
+ continue
598
+
599
+ if entity_id is not None and days_since is not None:
600
+ contacts.append({
601
+ "entity_id": entity_id,
602
+ "name": name,
603
+ "days_since_contact": int(days_since),
604
+ "relationship_score": record.get("score"),
605
+ })
606
+
607
+ logger.debug("Loaded %d genuinely neglected contacts", len(contacts))
608
+ except Exception as e:
609
+ logger.warning("Neglected contacts query failed: %s", e)
610
+
611
+ # Relationship provenance + signal floor: graph Person nodes include
612
+ # passively-observed third parties (people mentioned in the owner's
613
+ # channels). Only direct interlocutors with real score signal are
614
+ # relationship candidates; a batch of identical default scores is an
615
+ # ingestion artifact and yields nothing. Fail closed when the contact
616
+ # record (direct-exchange evidence) is unavailable.
617
+ try:
618
+ from apsimo.intelligence.relationships.signal_floor import (
619
+ enrich_interaction_counts, filter_relationship_candidates,
620
+ )
621
+ contacts_store = None
622
+ try:
623
+ from apsimo.api.routers.host import _contacts_store
624
+ contacts_store = _contacts_store
625
+ except Exception:
626
+ contacts_store = None
627
+ await enrich_interaction_counts(contacts, contacts_store)
628
+ contacts = filter_relationship_candidates(contacts)
629
+ except Exception as exc:
630
+ logger.warning(
631
+ "relationship signal floor failed (failing closed): %s", exc)
632
+ contacts = []
633
+
634
+ self._context["neglected_contacts"] = contacts
635
+ if not contacts:
636
+ self._context.setdefault("neglected_contacts", [])
637
+
638
+ async def _load_health_trends(self) -> None:
639
+ """Query graph for health signals and score trends."""
640
+ if self.graph is None or not hasattr(self.graph, 'driver'):
641
+ return
642
+
643
+ query = """
644
+ MATCH (s:Signal)
645
+ WHERE s.metric IN ['energy', 'sleep', 'focus', 'mood']
646
+ AND s.confidence > 0.7
647
+ RETURN s.metric as metric, avg(s.value) as avg_value,
648
+ max(s.timestamp) as last_seen
649
+ ORDER BY last_seen DESC
650
+ """
651
+
652
+ alerts = []
653
+ try:
654
+ async with self.graph.driver.session(database=self.graph.database) as session:
655
+ result = await session.run(query)
656
+ async for record in result:
657
+ record = dict(record)
658
+ metric = record.get("metric")
659
+ avg_value = record.get("avg_value", 0.0)
660
+ if avg_value is not None and avg_value < self._config.health_score_threshold:
661
+ alerts.append({
662
+ "metric": metric,
663
+ "value": float(avg_value),
664
+ "target": self._config.health_score_threshold,
665
+ })
666
+ logger.debug("Loaded %d health alerts", len(alerts))
667
+ except Exception as e:
668
+ logger.debug("Health trends query failed: %s", e)
669
+
670
+ self._context["health_alerts"] = alerts
671
+ if not alerts:
672
+ self._context.setdefault("health_alerts", [])
673
+
674
+ async def _load_scheduling_opportunities(self) -> None:
675
+ """Query graph for scheduling gaps and opportunities."""
676
+ if self.graph is None or not hasattr(self.graph, 'driver'):
677
+ return
678
+
679
+ query = """
680
+ MATCH (o:Owner)
681
+ OPTIONAL MATCH (o)-[:HAS_CALENDAR]->(c:Calendar)
682
+ WITH o, c
683
+ WHERE c IS NULL OR c.last_synced < datetime() - duration({hours: 24})
684
+ RETURN 'calendar_sync' as opportunity,
685
+ CASE WHEN c IS NULL THEN 0.9 ELSE 0.7 END as priority
686
+ LIMIT 5
687
+ """
688
+
689
+ opportunities = []
690
+ try:
691
+ async with self.graph.driver.session(database=self.graph.database) as session:
692
+ result = await session.run(query)
693
+ async for record in result:
694
+ record = dict(record)
695
+ opp_type = record.get("opportunity")
696
+ if opp_type == "calendar_sync":
697
+ opportunities.append({
698
+ "description": "Calendar hasn't synced in 24+ hours",
699
+ "priority": float(record.get("priority", 0.7)),
700
+ "rationale": "Missing recent calendar data may cause scheduling gaps",
701
+ "action_hint": "Sync calendar to get latest availability",
702
+ })
703
+ logger.debug("Loaded %d scheduling opportunities", len(opportunities))
704
+ except Exception as e:
705
+ logger.debug("Scheduling opportunities query failed: %s", e)
706
+
707
+ self._context["scheduling_opportunities"] = opportunities
708
+ if not opportunities:
709
+ self._context.setdefault("scheduling_opportunities", [])
710
+
711
+ async def _load_pending_signals(self) -> None:
712
+ """Query graph for signals awaiting processing."""
713
+ if self.graph is None or not hasattr(self.graph, 'driver'):
714
+ return
715
+
716
+ query = """
717
+ MATCH (s:Signal)
718
+ WHERE s.processed = false OR s.processed IS NULL
719
+ RETURN count(s) as pending_count
720
+ """
721
+
722
+ try:
723
+ async with self.graph.driver.session(database=self.graph.database) as session:
724
+ result = await session.run(query)
725
+ record = await result.single()
726
+ if record:
727
+ count = record.get("pending_count", 0)
728
+ if count > self._config.signal_accumulation_threshold:
729
+ self._context.setdefault("pending_signals", []).append({
730
+ "metric": "pending_signals",
731
+ "value": int(count),
732
+ "target": self._config.signal_accumulation_threshold,
733
+ })
734
+ logger.debug("Signal accumulation: %d pending", count)
735
+ except Exception as e:
736
+ logger.debug("Pending signals query failed: %s", e)
737
+
738
+ async def _load_pending_research_tasks(self) -> None:
739
+ """Query graph for open research tasks that haven't been acted on.
740
+
741
+ Schema-adaptive: tries multiple property names for compatibility.
742
+ Tasks without a due date or with a past due date are considered pending.
743
+ """
744
+ if self.graph is None or not hasattr(self.graph, 'driver'):
745
+ return
746
+
747
+ queries = [
748
+ # Schema variant: Task with status='open' and due_at
749
+ {
750
+ "query": """
751
+ MATCH (t:Task)
752
+ WHERE t.status = 'open'
753
+ AND t.type = 'research'
754
+ AND (t.due_at < datetime() - duration({days: $days})
755
+ OR t.due_at IS NULL)
756
+ RETURN t.id as id, t.title as title, t.description as description,
757
+ t.due_at as due_at, t.priority as priority
758
+ ORDER BY t.priority DESC, t.due_at ASC
759
+ LIMIT 20
760
+ """,
761
+ "date_field": "due_at",
762
+ },
763
+ # Schema variant: Task with state='open' and updated_at
764
+ {
765
+ "query": """
766
+ MATCH (t:Task)
767
+ WHERE t.state = 'open'
768
+ AND (t.type = 'research' OR t.tags CONTAINS 'research')
769
+ AND (t.updated_at < datetime() - duration({days: $days})
770
+ OR t.updated_at IS NULL)
771
+ RETURN t.id as id, t.title as title, t.description as description,
772
+ t.updated_at as due_at, t.priority as priority
773
+ ORDER BY t.priority DESC, t.updated_at ASC
774
+ LIMIT 20
775
+ """,
776
+ "date_field": "updated_at",
777
+ },
778
+ # Fallback: any open task with 'research' in the description
779
+ {
780
+ "query": """
781
+ MATCH (t:Task)
782
+ WHERE (t.status = 'open' OR t.state = 'open')
783
+ AND (t.type = 'research' OR t.description CONTAINS 'research'
784
+ OR coalesce(t.tags, '') CONTAINS 'research')
785
+ RETURN t.id as id, t.title as title, t.description as description,
786
+ coalesce(t.due_at, t.updated_at, datetime()) as due_at,
787
+ t.priority as priority
788
+ ORDER BY t.priority DESC, due_at ASC
789
+ LIMIT 20
790
+ """,
791
+ "date_field": "due_at",
792
+ },
793
+ ]
794
+
795
+ existing_tasks = self._context.get("pending_tasks", [])
796
+ task_count = 0
797
+
798
+ for variant in queries:
799
+ if task_count > 0:
800
+ break
801
+ try:
802
+ async with self.graph.driver.session(database=self.graph.database) as session:
803
+ result = await session.run(
804
+ variant["query"],
805
+ days=self._config.research_task_age_days,
806
+ )
807
+ async for record in result:
808
+ record = dict(record)
809
+ date_field = variant["date_field"]
810
+ due_at = self._parse_neo4j_datetime(record.get(date_field))
811
+ days_pending = max(0, (datetime.now(timezone.utc) - due_at).days) if due_at else 0
812
+
813
+ existing_tasks.append({
814
+ "entity_id": record["id"],
815
+ "description": record.get("title", "Unknown research task"),
816
+ "days_pending": days_pending,
817
+ "priority": record.get("priority", 0.5),
818
+ "is_research": True,
819
+ })
820
+ task_count += 1
821
+
822
+ if task_count > 0:
823
+ logger.debug("Loaded %d research tasks using %s", task_count, date_field)
824
+ except Exception as e:
825
+ logger.debug("Research tasks query variant failed: %s", e)
826
+ continue
827
+
828
+ self._context["pending_tasks"] = existing_tasks
829
+ if task_count == 0:
830
+ logger.debug("No research tasks found with any schema variant")
831
+
832
+ # ------------------------------------------------------------------
833
+ # Self-initiative context loaders (v0.11.0)
834
+ # ------------------------------------------------------------------
835
+
836
+ async def _load_subsystem_health(self) -> None:
837
+ """Query graph for degraded Colony subsystems."""
838
+ if self.graph is None or not hasattr(self.graph, 'driver'):
839
+ return
840
+
841
+ query = """
842
+ MATCH (s:Subsystem)
843
+ WHERE s.status <> 'active'
844
+ OR (s.latency_ms IS NOT NULL AND s.latency_ms > 1000)
845
+ OR (s.error_rate IS NOT NULL AND s.error_rate > 0.1)
846
+ RETURN s.id as id, s.name as name, s.status as status,
847
+ s.latency_ms as latency, s.error_rate as error_rate
848
+ ORDER BY s.error_rate DESC, s.latency_ms DESC
849
+ LIMIT 10
850
+ """
851
+
852
+ issues = []
853
+ try:
854
+ async with self.graph.driver.session(database=self.graph.database) as session:
855
+ result = await session.run(query)
856
+ async for record in result:
857
+ record = dict(record)
858
+ issues.append({
859
+ "entity_id": record.get("id") or record.get("name"),
860
+ "name": record.get("name", "Unknown"),
861
+ "status": record.get("status", "unknown"),
862
+ "latency_ms": record.get("latency"),
863
+ "error_rate": record.get("error_rate"),
864
+ })
865
+ logger.debug("Loaded %d subsystem health issues", len(issues))
866
+ except Exception as e:
867
+ logger.debug("Subsystem health query failed: %s", e)
868
+
869
+ self._context["subsystem_health"] = issues
870
+ if not issues:
871
+ self._context.setdefault("subsystem_health", [])
872
+
873
+ async def _load_data_quality_issues(self) -> None:
874
+ """Query graph for schema drift; unattributed memories are permitted."""
875
+ if self.graph is None or not hasattr(self.graph, 'driver'):
876
+ return
877
+
878
+ issues = []
879
+
880
+ # Check for schema drift: queries referencing non-existent relationships
881
+ try:
882
+ async with self.graph.driver.session(database=self.graph.database) as session:
883
+ result = await session.run("""
884
+ CALL db.schema.visualization() YIELD nodes, relationships
885
+ RETURN [r IN relationships | type(r)] as rel_types
886
+ """)
887
+ record = await result.single()
888
+ if record:
889
+ rel_types = record.get("rel_types", [])
890
+ # Check if BELONGS_TO exists (it shouldn't if schema is clean)
891
+ if "BELONGS_TO" in rel_types:
892
+ issues.append({
893
+ "entity_id": "belongs_to_drift",
894
+ "entity_type": "schema_drift",
895
+ "description": "BELONGS_TO relationship exists but is deprecated",
896
+ })
897
+ except Exception as e:
898
+ logger.debug("Schema drift query failed: %s", e)
899
+
900
+ self._context["data_quality_issues"] = issues
901
+ if not issues:
902
+ self._context.setdefault("data_quality_issues", [])
903
+
904
+ async def _load_operational_tasks(self) -> None:
905
+ """Check for operational hygiene needs (backups, disk space, etc.)."""
906
+ import os
907
+ from pathlib import Path
908
+
909
+ tasks = []
910
+
911
+ # A configured producer receipt supersedes the legacy file-age evidence.
912
+ backup_dir = Path(os.path.expanduser("~/.colony/backups"))
913
+ if self._config.backup_receipt_path:
914
+ from apsimo.initiatives.backup_evidence import backup_review_task
915
+ review = backup_review_task(self._config.backup_receipt_path, datetime.now(timezone.utc))
916
+ if review is not None:
917
+ tasks.append(review)
918
+ elif backup_dir.exists():
919
+ backups = sorted(backup_dir.glob("*.bak"), key=lambda p: p.stat().st_mtime, reverse=True)
920
+ if backups:
921
+ newest_age_days = (datetime.now(timezone.utc).timestamp() - backups[0].stat().st_mtime) / 86400
922
+ if newest_age_days > 7:
923
+ tasks.append({
924
+ "entity_id": "database_backup",
925
+ "entity_type": "backup",
926
+ "description": f"Review backup coverage: newest legacy .bak file was modified {newest_age_days:.0f} days ago; recovery coverage is unverified",
927
+ "age_days": newest_age_days,
928
+ "evidence_scope": "legacy_bak_directory_only",
929
+ "evidence_path": str(backup_dir),
930
+ "latest_file_modified_at": datetime.fromtimestamp(backups[0].stat().st_mtime, timezone.utc).isoformat(),
931
+ "observed_at": datetime.now(timezone.utc).isoformat(),
932
+ })
933
+ else:
934
+ tasks.append({
935
+ "entity_id": "database_backup",
936
+ "entity_type": "backup",
937
+ "description": "Review backup coverage: no legacy .bak files found in the checked directory; other backup locations were not assessed",
938
+ "age_days": 999,
939
+ "evidence_scope": "legacy_bak_directory_only",
940
+ "evidence_path": str(backup_dir),
941
+ "latest_file_modified_at": None,
942
+ "observed_at": datetime.now(timezone.utc).isoformat(),
943
+ })
944
+
945
+ # Check log sizes
946
+ log_dir = Path(os.path.expanduser("~/.colony/logs"))
947
+ if log_dir.exists():
948
+ files = []
949
+ for path in log_dir.glob("*.log"):
950
+ try:
951
+ if path.is_file():
952
+ stat = path.stat()
953
+ files.append({"path": str(path), "size_bytes": stat.st_size,
954
+ "modified_at": datetime.fromtimestamp(stat.st_mtime, timezone.utc).isoformat()})
955
+ except FileNotFoundError:
956
+ continue # An existing rotation may finish during observation.
957
+ total_bytes = sum(item["size_bytes"] for item in files)
958
+ if total_bytes > 100 * 1024 * 1024:
959
+ tasks.append({
960
+ "entity_id": "log_rotation",
961
+ "entity_type": "log_rotation",
962
+ "description": f"Review local log volume: {total_bytes / (1024 * 1024):.1f} MiB exceeds the 100 MiB review threshold; disk pressure and retention are unverified",
963
+ "threshold_mb": 100,
964
+ "total_size_bytes": total_bytes,
965
+ "file_count": len(files),
966
+ "largest_files": sorted(files, key=lambda item: (-item["size_bytes"], item["path"]))[:5],
967
+ "evidence_scope": "local_log_directory_only",
968
+ "evidence_path": str(log_dir),
969
+ "observed_at": datetime.now(timezone.utc).isoformat(),
970
+ })
971
+
972
+ self._context["operational_tasks"] = tasks
973
+ if not tasks:
974
+ self._context.setdefault("operational_tasks", [])
975
+
976
+ async def _load_initiative_categories(self) -> None:
977
+ """Load dynamic initiative categories from the graph."""
978
+ if self.graph is None or not hasattr(self.graph, 'driver'):
979
+ return
980
+
981
+ query = """
982
+ MATCH (c:InitiativeCategory)
983
+ WHERE c.auto_execute = true
984
+ AND (c.last_triggered IS NULL
985
+ OR c.last_triggered < datetime() - duration({minutes: c.cooldown_minutes}))
986
+ RETURN c.id as id, c.name as name, c.description as description,
987
+ c.trigger_query as trigger_query, c.action_type as action_type,
988
+ c.executor_skill as executor_skill, c.priority_formula as priority_formula,
989
+ c.cooldown_minutes as cooldown_minutes, c.auto_execute as auto_execute,
990
+ c.requires_approval as requires_approval
991
+ LIMIT 20
992
+ """
993
+
994
+ categories = []
995
+ try:
996
+ async with self.graph.driver.session(database=self.graph.database) as session:
997
+ result = await session.run(query)
998
+ async for record in result:
999
+ record = dict(record)
1000
+ categories.append({
1001
+ "id": record.get("id"),
1002
+ "name": record.get("name"),
1003
+ "description": record.get("description"),
1004
+ "trigger_query": record.get("trigger_query"),
1005
+ "action_type": record.get("action_type"),
1006
+ "executor_skill": record.get("executor_skill"),
1007
+ "priority_formula": record.get("priority_formula"),
1008
+ "cooldown_minutes": record.get("cooldown_minutes", 30),
1009
+ "auto_execute": record.get("auto_execute", True),
1010
+ "requires_approval": record.get("requires_approval", False),
1011
+ })
1012
+ logger.debug("Loaded %d initiative categories", len(categories))
1013
+ except Exception as e:
1014
+ logger.debug("Initiative category query failed: %s", e)
1015
+
1016
+ self._context["initiative_categories"] = categories
1017
+ if not categories:
1018
+ self._context.setdefault("initiative_categories", [])
1019
+
1020
+ async def _load_capability_gaps(self) -> None:
1021
+ """Query graph for capabilities that have failed repeatedly.
1022
+
1023
+ Schema-adaptive (same idiom as ``_load_blocked_goals``): the
1024
+ primary variant reads ``failure_count`` / ``last_failure_at``
1025
+ directly from :Capability nodes — the fields actually defined in
1026
+ ``intelligence/graph/schema.py``. The fallback variant reads the
1027
+ same counters from [:NEEDS_CAPABILITY] relationships, which is
1028
+ where the original v0.11.1 design recorded failures.
1029
+
1030
+ The failure threshold comes from ``COLONY_CAPABILITY_GAP_FAILURES``
1031
+ (default 3) and only failures within the last 24 hours qualify.
1032
+ """
1033
+ if self.graph is None or not hasattr(self.graph, 'driver'):
1034
+ return
1035
+
1036
+ queries = [
1037
+ # Schema reality: failure counters live on the Capability node
1038
+ """
1039
+ MATCH (c:Capability)
1040
+ WHERE c.failure_count >= $threshold
1041
+ AND c.last_failure_at > datetime() - duration({hours: 24})
1042
+ RETURN c.id as id, c.name as name, c.failure_count as failure_count,
1043
+ c.last_failure_at as last_failure, c.status as failure_mode
1044
+ ORDER BY c.failure_count DESC
1045
+ LIMIT 10
1046
+ """,
1047
+ # v0.11.1 design: counters live on the NEEDS_CAPABILITY edge
1048
+ """
1049
+ MATCH (a:Agent)-[r:NEEDS_CAPABILITY]->(c:Capability)
1050
+ WHERE r.failure_count >= $threshold
1051
+ AND r.last_failure_at > datetime() - duration({hours: 24})
1052
+ RETURN c.id as id, c.name as name, r.failure_count as failure_count,
1053
+ r.last_failure_at as last_failure, r.failure_mode as failure_mode
1054
+ ORDER BY r.failure_count DESC
1055
+ LIMIT 10
1056
+ """,
1057
+ ]
1058
+
1059
+ gaps = []
1060
+ for query in queries:
1061
+ if gaps:
1062
+ break
1063
+ try:
1064
+ async with self.graph.driver.session(database=self.graph.database) as session:
1065
+ result = await session.run(
1066
+ query,
1067
+ threshold=self._config.capability_gap_failures,
1068
+ )
1069
+ async for record in result:
1070
+ record = dict(record)
1071
+ gaps.append({
1072
+ "id": record.get("id") or record.get("name") or "unknown",
1073
+ "name": record.get("name", "Unknown"),
1074
+ "failure_count": record.get("failure_count", 0),
1075
+ "failure_mode": record.get("failure_mode", "unknown"),
1076
+ "last_failure": record.get("last_failure"),
1077
+ "entity_type": "capability_gap",
1078
+ })
1079
+ if gaps:
1080
+ logger.debug("Loaded %d capability gaps", len(gaps))
1081
+ except Exception as e:
1082
+ logger.debug("Capability gap query variant failed: %s", e)
1083
+ continue
1084
+
1085
+ self._context["capability_gaps"] = gaps
1086
+ if not gaps:
1087
+ self._context.setdefault("capability_gaps", [])
1088
+
1089
+ async def _load_knowledge_gaps(self) -> None:
1090
+ """Query graph for open, low-confidence :Concept nodes.
1091
+
1092
+ The Concept node type exists in ``intelligence/graph/schema.py``
1093
+ (confidence_score, encounter_count, status, last_researched_at).
1094
+ Concepts researched within the last 7 days are skipped so the
1095
+ same gap is not re-proposed while research is still fresh.
1096
+ """
1097
+ if self.graph is None or not hasattr(self.graph, 'driver'):
1098
+ return
1099
+
1100
+ query = """
1101
+ MATCH (c:Concept)
1102
+ WHERE c.confidence_score < 0.5
1103
+ AND c.status IN ['open', 'researching']
1104
+ AND (c.last_researched_at IS NULL
1105
+ OR c.last_researched_at < datetime() - duration({days: 7}))
1106
+ RETURN c.id as id, c.name as name, c.confidence_score as confidence_score,
1107
+ c.encounter_count as encounter_count, c.domain as domain,
1108
+ c.source as source
1109
+ ORDER BY c.confidence_score ASC, c.encounter_count DESC
1110
+ LIMIT 10
1111
+ """
1112
+
1113
+ gaps = []
1114
+ try:
1115
+ async with self.graph.driver.session(database=self.graph.database) as session:
1116
+ result = await session.run(query)
1117
+ async for record in result:
1118
+ record = dict(record)
1119
+ gaps.append({
1120
+ "id": record.get("id") or record.get("name") or "unknown",
1121
+ "name": record.get("name", "Unknown"),
1122
+ "confidence_score": record.get("confidence_score", 0.0),
1123
+ "encounter_count": record.get("encounter_count", 0),
1124
+ "domain": record.get("domain", "general"),
1125
+ "source": record.get("source", "unknown"),
1126
+ "entity_type": "knowledge_gap",
1127
+ })
1128
+ logger.debug("Loaded %d knowledge gaps", len(gaps))
1129
+ except Exception as e:
1130
+ logger.debug("Knowledge gap query failed: %s", e)
1131
+
1132
+ self._context["knowledge_gaps"] = gaps
1133
+ if not gaps:
1134
+ self._context.setdefault("knowledge_gaps", [])
1135
+
1136
+ async def _load_behavioral_patterns(self) -> None:
1137
+ """Query graph for active, recurring behavioral patterns.
1138
+
1139
+ The graph :Pattern node (``intelligence/graph/schema.py``) carries
1140
+ ``pattern_type`` (default 'behavioral'; 'correction' is the value
1141
+ the v0.11.1 design used for owner corrections) plus two occurrence
1142
+ counters (``recurrence_count`` and the older ``occurrences``).
1143
+ Both pattern_type values and both counters are accepted here.
1144
+ Note this is distinct from the SQLite PatternStore in
1145
+ ``apsimo/patterns/`` whose pattern_type values
1146
+ (entity_cooccurrence, relation_frequency, ...) never reach the
1147
+ graph.
1148
+ """
1149
+ if self.graph is None or not hasattr(self.graph, 'driver'):
1150
+ return
1151
+
1152
+ query = """
1153
+ MATCH (p:Pattern)
1154
+ WHERE p.pattern_type IN ['behavioral', 'correction']
1155
+ AND p.is_active = true
1156
+ AND coalesce(p.recurrence_count, p.occurrences, 0) >= 3
1157
+ AND (p.last_triggered_at IS NULL
1158
+ OR p.last_triggered_at > datetime() - duration({days: 30}))
1159
+ RETURN p.id as id, p.trigger as trigger, p.action as action,
1160
+ coalesce(p.recurrence_count, p.occurrences, 0) as recurrence_count,
1161
+ p.confidence as confidence, p.pattern_type as pattern_type
1162
+ ORDER BY recurrence_count DESC, p.confidence DESC
1163
+ LIMIT 10
1164
+ """
1165
+
1166
+ patterns = []
1167
+ try:
1168
+ async with self.graph.driver.session(database=self.graph.database) as session:
1169
+ result = await session.run(query)
1170
+ async for record in result:
1171
+ record = dict(record)
1172
+ patterns.append({
1173
+ "id": record.get("id", "unknown"),
1174
+ "trigger": record.get("trigger", ""),
1175
+ "action": record.get("action", ""),
1176
+ "recurrence_count": record.get("recurrence_count", 0),
1177
+ "confidence": record.get("confidence", 0.5),
1178
+ "pattern_type": record.get("pattern_type", "behavioral"),
1179
+ "entity_type": "behavioral_pattern",
1180
+ })
1181
+ logger.debug("Loaded %d behavioral patterns", len(patterns))
1182
+ except Exception as e:
1183
+ logger.debug("Behavioral pattern query failed: %s", e)
1184
+
1185
+ self._context["behavioral_patterns"] = patterns
1186
+ if not patterns:
1187
+ self._context.setdefault("behavioral_patterns", [])
1188
+
1189
+ # ------------------------------------------------------------------
1190
+ # Self-initiative generators (v0.11.0)
1191
+ # ------------------------------------------------------------------
1192
+
1193
+ async def generate(
1194
+ self,
1195
+ types: Optional[List[InitiativeType]] = None,
1196
+ min_priority: float = 0.5,
1197
+ cooldown_tasks: float = 12.0,
1198
+ cooldown_contacts: float = 72.0,
1199
+ max_initiatives: int = 20,
1200
+ ) -> List[Initiative]:
1201
+ """Generate proactive suggestions with deduplication.
1202
+
1203
+ Args:
1204
+ types: If provided, only generate these types. None = all types.
1205
+ min_priority: Minimum priority threshold (0-1)
1206
+ cooldown_tasks: Don't repeat task initiatives within N hours (default 12)
1207
+ cooldown_contacts: Don't repeat contact initiatives within N hours (default 72)
1208
+ max_initiatives: Maximum number of initiatives to generate (default 20)
1209
+ """
1210
+ # Validate parameters (Bug 57, 58)
1211
+ min_priority = max(0.0, min(1.0, min_priority))
1212
+ cooldown_tasks = max(0.0, cooldown_tasks)
1213
+ cooldown_contacts = max(0.0, cooldown_contacts)
1214
+ max_initiatives = max(1, max_initiatives)
1215
+
1216
+ # Load live data from graph before generating
1217
+ await self._load_graph_context()
1218
+
1219
+ # Load agent-reported observations (v0.16.0, agent-as-sensor)
1220
+ self._load_observation_domains()
1221
+
1222
+ # Bug 33: Run generators in parallel with exception handling
1223
+ generators = []
1224
+ if not types or InitiativeType.FOLLOW_UP in types:
1225
+ generators.append(self._generate_follow_ups())
1226
+ generators.append(self._generate_task_completion_follow_ups())
1227
+ if not types or InitiativeType.RELATIONSHIP in types:
1228
+ generators.append(self._generate_relationship_suggestions())
1229
+ if not types or InitiativeType.INTRODUCTION in types:
1230
+ generators.append(self._generate_introduction_initiatives())
1231
+ if not types or InitiativeType.HEALTH in types:
1232
+ generators.append(self._generate_health_suggestions())
1233
+ if not types or InitiativeType.SCHEDULING in types:
1234
+ generators.append(self._generate_scheduling_suggestions())
1235
+ # Autonomous work domains (v0.16.0)
1236
+ if not types or InitiativeType.COMMITMENT in types:
1237
+ generators.append(self._generate_commitment_initiatives())
1238
+ if not types or InitiativeType.CODING in types:
1239
+ generators.append(self._generate_coding_initiatives())
1240
+ if not types or InitiativeType.TASK in types:
1241
+ generators.append(self._generate_task_initiatives())
1242
+ if not types or InitiativeType.CALENDAR in types:
1243
+ generators.append(self._generate_calendar_initiatives())
1244
+ if not types or InitiativeType.RESEARCH in types:
1245
+ generators.append(self._generate_research_initiatives())
1246
+ if not types or InitiativeType.PROJECT in types:
1247
+ generators.append(self._generate_project_initiatives())
1248
+ if not types or InitiativeType.SYSTEM in types:
1249
+ generators.append(self._generate_system_initiatives())
1250
+ # Self-initiative generators (v0.11.0)
1251
+ if not types or InitiativeType.SUBSYSTEM_HEALTH in types:
1252
+ generators.append(self._generate_subsystem_health_initiatives())
1253
+ if not types or InitiativeType.DATA_QUALITY in types:
1254
+ generators.append(self._generate_data_quality_initiatives())
1255
+ if not types or InitiativeType.OPERATIONAL in types:
1256
+ generators.append(self._generate_operational_initiatives())
1257
+ if not types or InitiativeType.CAPABILITY_GAP in types:
1258
+ generators.append(self._generate_capability_gap_initiatives())
1259
+ if not types or InitiativeType.KNOWLEDGE_ACQUISITION in types:
1260
+ generators.append(self._generate_knowledge_acquisition_initiatives())
1261
+ if not types or InitiativeType.BEHAVIORAL_CORRECTION in types:
1262
+ generators.append(self._generate_behavioral_correction_initiatives())
1263
+ # Agent-action generators (v0.13.0)
1264
+ if not types or InitiativeType.AGENT_ACTION in types:
1265
+ generators.append(self._generate_agent_action_initiatives())
1266
+
1267
+ initiatives: List[Initiative] = []
1268
+ if generators:
1269
+ results = await asyncio.gather(*generators, return_exceptions=True)
1270
+ for result in results:
1271
+ if isinstance(result, Exception):
1272
+ logger.warning("Generator failed: %s", result)
1273
+ else:
1274
+ initiatives.extend(result)
1275
+
1276
+ # Re-arm recurring initiatives: rewrite their dedup_key to a per-period key (and stash the
1277
+ # logical key in dedup_base) so the store creates a fresh instance each period instead of
1278
+ # being blocked forever by the prior period's terminal record. One-shot types (deliver,
1279
+ # commitment, capability_gap, ...) are absent from the map and keep their stable key.
1280
+ _apply_recurrence_buckets(initiatives)
1281
+
1282
+ filtered = [i for i in initiatives if i.priority >= min_priority]
1283
+
1284
+ # Dedup: filter by cooldown per entity (v0.7.10)
1285
+ now = datetime.now(timezone.utc)
1286
+ deduped = []
1287
+ for init in filtered:
1288
+ entity_id = getattr(init, "entity_id", None)
1289
+ init_type = getattr(init, "type", InitiativeType.FOLLOW_UP)
1290
+ type_val = init_type.value if hasattr(init_type, "value") else str(init_type)
1291
+
1292
+ # Use initiative_store cooldown for task-type initiatives
1293
+ if self._store and entity_id and type_val == "follow_up":
1294
+ try:
1295
+ cutoff = now - timedelta(hours=cooldown_tasks)
1296
+ recent = self._store.list(
1297
+ status=["pending", "assigned", "acknowledged"],
1298
+ type=type_val,
1299
+ created_after=cutoff,
1300
+ limit=1,
1301
+ )
1302
+ if recent:
1303
+ continue # Still in cooldown
1304
+ except Exception as exc:
1305
+ logger.warning("Dedup cooldown check failed for follow_up: %s", exc)
1306
+
1307
+ # Use initiative_store cooldown for contact-type initiatives
1308
+ elif self._store and entity_id and type_val == "relationship":
1309
+ try:
1310
+ cutoff = now - timedelta(hours=cooldown_contacts)
1311
+ recent = self._store.list(
1312
+ status=["pending", "assigned", "acknowledged"],
1313
+ type=type_val,
1314
+ created_after=cutoff,
1315
+ limit=1,
1316
+ )
1317
+ if recent:
1318
+ continue # Still in cooldown
1319
+ except Exception as exc:
1320
+ logger.warning("Dedup cooldown check failed for relationship: %s", exc)
1321
+
1322
+ deduped.append(init)
1323
+
1324
+ self._initiatives = self._apply_cap(deduped, max_initiatives)
1325
+
1326
+ logger.info(
1327
+ "Generated %d initiatives (requested max %d, min_priority %.2f)",
1328
+ len(self._initiatives),
1329
+ max_initiatives,
1330
+ min_priority,
1331
+ )
1332
+
1333
+ return self._initiatives
1334
+
1335
+ def _apply_cap(
1336
+ self, deduped: List[Initiative], max_initiatives: int
1337
+ ) -> List[Initiative]:
1338
+ """Cap the proposal batch to ``max_initiatives`` by priority, with two
1339
+ starvation guards on the cut tail:
1340
+
1341
+ - **Owed deliverables** (``agent_deliver_message``) are added back
1342
+ UNBOUNDED — someone is actively waiting on them, they are not
1343
+ discretionary, and a busy loop must never drop one.
1344
+ - **Introductions** are topped up to ``_INTRO_HEADROOM`` total —
1345
+ proactive social proposals are low-priority by design, so without a
1346
+ little reserved headroom an operational backlog would permanently
1347
+ starve them.
1348
+ """
1349
+ ranked = sorted(deduped, key=lambda i: i.priority, reverse=True)
1350
+ capped = ranked[:max_initiatives]
1351
+ if len(ranked) <= max_initiatives:
1352
+ return capped
1353
+ cut = ranked[max_initiatives:]
1354
+ owed = [i for i in cut
1355
+ if getattr(i, "action_hint", "") == "agent_deliver_message"]
1356
+ if owed:
1357
+ capped = capped + owed
1358
+ if _INTRO_HEADROOM:
1359
+ already = sum(1 for i in capped
1360
+ if getattr(i, "type", None) == InitiativeType.INTRODUCTION)
1361
+ if already < _INTRO_HEADROOM:
1362
+ intros = [i for i in cut
1363
+ if getattr(i, "type", None) == InitiativeType.INTRODUCTION]
1364
+ if intros:
1365
+ capped = capped + intros[:_INTRO_HEADROOM - already]
1366
+ return capped
1367
+
1368
+ def get_initiatives(self) -> List[Initiative]:
1369
+ """Return current initiatives."""
1370
+ return self._initiatives.copy()
1371
+
1372
+ def get_initiative(self, initiative_id: str) -> Optional[Initiative]:
1373
+ """Get a specific initiative by ID."""
1374
+ for initiative in self._initiatives:
1375
+ if initiative.id == initiative_id:
1376
+ return initiative
1377
+ return None
1378
+
1379
+ def remove_initiative(self, initiative_id: str) -> bool:
1380
+ """Remove an initiative by ID.
1381
+
1382
+ Returns:
1383
+ True if removed, False if not found.
1384
+ """
1385
+ for i, initiative in enumerate(self._initiatives):
1386
+ if initiative.id == initiative_id:
1387
+ del self._initiatives[i]
1388
+ logger.debug("Removed initiative %s", initiative_id)
1389
+ return True
1390
+ return False
1391
+
1392
+ @staticmethod
1393
+ def _is_expired(initiative: "Initiative") -> bool:
1394
+ """Whether *initiative* has passed its ``expires_at`` (no expiry → never).
1395
+
1396
+ A naive ``expires_at`` is interpreted in local time (Python convention);
1397
+ an aware one is compared against UTC. Production generators use aware UTC
1398
+ timestamps; this also tolerates naive ones.
1399
+ """
1400
+ exp = initiative.expires_at
1401
+ if exp is None:
1402
+ return False
1403
+ if exp.tzinfo is None:
1404
+ return exp <= datetime.now()
1405
+ return exp <= datetime.now(timezone.utc)
1406
+
1407
+ async def get_active(self) -> List[Initiative]:
1408
+ """Return current, non-expired initiatives, highest priority first.
1409
+
1410
+ Unlike :meth:`get_initiatives` (which returns everything still held in
1411
+ memory), this drops anything past its ``expires_at`` so expired
1412
+ suggestions are never surfaced.
1413
+ """
1414
+ active = [i for i in self._initiatives if not self._is_expired(i)]
1415
+ active.sort(key=lambda i: i.priority, reverse=True)
1416
+ return active
1417
+
1418
+ async def dismiss(self, initiative_id: str) -> bool:
1419
+ """Dismiss an initiative the user rejected — drop it from the active set.
1420
+
1421
+ Mirrors :meth:`remove_initiative` but is the user-facing verb; when a
1422
+ persistence store is attached, the dismissal is also recorded there so
1423
+ the suggestion isn't immediately regenerated.
1424
+
1425
+ Returns:
1426
+ True if the initiative was present, False otherwise.
1427
+ """
1428
+ removed = self.remove_initiative(initiative_id)
1429
+ if removed and self._store is not None and hasattr(self._store, "cancel"):
1430
+ try:
1431
+ self._store.cancel(initiative_id)
1432
+ except Exception:
1433
+ logger.debug("InitiativeStore.cancel failed for %s", initiative_id, exc_info=True)
1434
+ return removed
1435
+
1436
+ async def execute_initiative(self, initiative_id: str) -> Dict[str, Any]:
1437
+ """Execute a self-initiative using the skill registry (v0.11.0).
1438
+
1439
+ Args:
1440
+ initiative_id: ID of the initiative to execute
1441
+
1442
+ Returns:
1443
+ Dict with "status", "result", and "initiative" keys
1444
+ """
1445
+ initiative = self.get_initiative(initiative_id)
1446
+ if not initiative:
1447
+ return {"status": "not_found", "result": None, "initiative": None}
1448
+
1449
+ # Only self-initiative types can be auto-executed
1450
+ if initiative.type not in {
1451
+ InitiativeType.SUBSYSTEM_HEALTH,
1452
+ InitiativeType.DATA_QUALITY,
1453
+ InitiativeType.OPERATIONAL,
1454
+ InitiativeType.CAPABILITY_GAP,
1455
+ InitiativeType.KNOWLEDGE_ACQUISITION,
1456
+ InitiativeType.BEHAVIORAL_CORRECTION,
1457
+ }:
1458
+ return {
1459
+ "status": "not_self_initiative",
1460
+ "result": None,
1461
+ "initiative": initiative,
1462
+ }
1463
+
1464
+ # Build execution context
1465
+ exec_context = InitiativeExecutionContext(
1466
+ initiative_id=initiative.id,
1467
+ category_id=initiative.type.value,
1468
+ category_name=initiative.type.value,
1469
+ entity_id=initiative.entity_id,
1470
+ entity_type=(initiative.trigger_data or {}).get("entity_type"),
1471
+ trigger_data=initiative.trigger_data or {},
1472
+ priority=initiative.priority,
1473
+ )
1474
+
1475
+ # Find matching skill
1476
+ _SKILL_NAME_MAP = {
1477
+ "subsystem_health": "subsystem_health",
1478
+ "data_quality": "data_quality",
1479
+ "operational": "operational_hygiene",
1480
+ "capability_gap": "capability_gap",
1481
+ "knowledge_acquisition": "knowledge_acquisition",
1482
+ "behavioral_correction": "behavioral_correction",
1483
+ }
1484
+ skill_name = _SKILL_NAME_MAP.get(initiative.type.value, initiative.type.value)
1485
+ category = {"executor_skill": skill_name}
1486
+ skill = await self._skills.find_skill_for_category(category, {})
1487
+
1488
+ if not skill:
1489
+ return {
1490
+ "status": "no_skill",
1491
+ "result": None,
1492
+ "initiative": initiative,
1493
+ }
1494
+
1495
+ # Execute
1496
+ try:
1497
+ result = await skill.execute(exec_context)
1498
+ return {
1499
+ "status": "executed",
1500
+ "result": result,
1501
+ "initiative": initiative,
1502
+ }
1503
+ except Exception as e:
1504
+ logger.error("Initiative execution failed: %s", e)
1505
+ return {
1506
+ "status": "failed",
1507
+ "result": str(e),
1508
+ "initiative": initiative,
1509
+ }
1510
+
1511
+ # ------------------------------------------------------------------
1512
+ # Initiative generators
1513
+ # ------------------------------------------------------------------
1514
+
1515
+ async def _generate_follow_ups(self) -> List[Initiative]:
1516
+ """Generate follow-up initiatives from pending task context."""
1517
+ from apsimo.delivery.reachout_policy import meaningful_reachout_text
1518
+
1519
+ initiatives: List[Initiative] = []
1520
+ for task in self._context.get("pending_tasks", []):
1521
+ # Only generate follow-ups for truly blocked tasks
1522
+ days_pending = task.get("days_pending", 0)
1523
+ if days_pending < self._config.goal_block_threshold_days:
1524
+ continue
1525
+
1526
+ # Only surface reach-outs built from genuine, human-meaningful
1527
+ # content. Skip system/skill-origin or empty-after-sanitise sources
1528
+ # so we never emit near-empty "Follow up on:" junk.
1529
+ desc = meaningful_reachout_text(task.get("description"))
1530
+ if not desc:
1531
+ logger.debug(
1532
+ "follow-up skipped: no meaningful content in source %r",
1533
+ str(task.get("description"))[:80],
1534
+ )
1535
+ continue
1536
+ entity_id = task.get("entity_id")
1537
+ priority = float(task.get("priority", 0.5))
1538
+
1539
+ initiatives.append(
1540
+ Initiative(
1541
+ id=f"followup-{entity_id or _uuid_module.uuid4().hex[:12]}",
1542
+ type=InitiativeType.FOLLOW_UP,
1543
+ description=f"Follow up on: {desc}",
1544
+ priority=min(1.0, 0.5 + (days_pending / 14.0)), # escalate over time
1545
+ rationale=f"Task has been pending for {days_pending} days",
1546
+ action_hint="Check status and unblock if possible",
1547
+ entity_id=entity_id,
1548
+ dedup_key=f"followup:{entity_id}",
1549
+ expires_at=datetime.now(timezone.utc) + timedelta(days=7),
1550
+ )
1551
+ )
1552
+ return initiatives
1553
+
1554
+ async def _generate_task_completion_follow_ups(self) -> List[Initiative]:
1555
+ """Generate Gap C follow-ups: tasks marked complete but with no result captured.
1556
+
1557
+ Scans ``completed_tasks`` context (populated externally e.g. by
1558
+ AutonomyLoop) and produces ``FOLLOW_UP`` initiatives asking the
1559
+ user to document the outcome.
1560
+ """
1561
+ from apsimo.delivery.reachout_policy import meaningful_reachout_text
1562
+
1563
+ initiatives: List[Initiative] = []
1564
+ for task in self._context.get("completed_tasks", []):
1565
+ desc = meaningful_reachout_text(task.get("description"))
1566
+ if not desc:
1567
+ continue
1568
+ entity_id = task.get("entity_id")
1569
+ initiatives.append(
1570
+ Initiative(
1571
+ id=f"gapc-{entity_id or _uuid_module.uuid4().hex[:12]}",
1572
+ type=InitiativeType.FOLLOW_UP,
1573
+ description=f"Document outcome for: {desc}",
1574
+ priority=0.6,
1575
+ rationale="Task completed but no result was captured",
1576
+ action_hint="Record what was accomplished and any next steps",
1577
+ entity_id=entity_id,
1578
+ dedup_key=f"gapc:{entity_id}",
1579
+ expires_at=datetime.now(timezone.utc) + timedelta(days=3),
1580
+ )
1581
+ )
1582
+ return initiatives
1583
+
1584
+ async def _generate_relationship_suggestions(self) -> List[Initiative]:
1585
+ """Generate relationship maintenance suggestions.
1586
+
1587
+ Gates relationship initiatives behind:
1588
+ 1. The subject is not the owner (owner exclusion, fail closed)
1589
+ 2. Colony has a MANAGES edge to the Person
1590
+ 3. Colony has at least one communication channel to the Person
1591
+ """
1592
+ from apsimo.identity.resolver import (
1593
+ OwnerIdentityError,
1594
+ get_identity_resolver,
1595
+ )
1596
+ resolver = get_identity_resolver()
1597
+ try:
1598
+ await resolver.owner_identities()
1599
+ except OwnerIdentityError as exc:
1600
+ logger.critical(
1601
+ "Owner identity unresolved — relationship initiative "
1602
+ "generation disabled (fail closed): %s", exc,
1603
+ )
1604
+ return []
1605
+
1606
+ # Relationship provenance + signal floor (defense in depth): context
1607
+ # may be fed externally (loop affect feeder, thinker), so re-apply the
1608
+ # gates here even though the graph loader also filters. Passively
1609
+ # observed third parties and degenerate identical-score batches are
1610
+ # dropped before any relationship initiative is generated.
1611
+ from apsimo.intelligence.relationships.signal_floor import (
1612
+ enrich_interaction_counts, filter_relationship_candidates,
1613
+ )
1614
+ candidates = list(self._context.get("neglected_contacts", []))
1615
+ try:
1616
+ contacts_store = None
1617
+ try:
1618
+ from apsimo.api.routers.host import _contacts_store
1619
+ contacts_store = _contacts_store
1620
+ except Exception:
1621
+ contacts_store = None
1622
+ await enrich_interaction_counts(candidates, contacts_store)
1623
+ candidates = filter_relationship_candidates(candidates)
1624
+ except Exception as exc:
1625
+ logger.warning(
1626
+ "relationship signal floor failed in generator "
1627
+ "(failing closed): %s", exc)
1628
+ candidates = []
1629
+
1630
+ initiatives: List[Initiative] = []
1631
+ for contact in candidates:
1632
+ entity_id = contact.get("entity_id")
1633
+ name = contact.get("name", "Unknown")
1634
+ days = contact.get("days_since_contact", 0)
1635
+
1636
+ # Owner exclusion — context may be fed externally (loop tick),
1637
+ # so re-check here even though the graph loader also filters.
1638
+ if not entity_id or await resolver.is_owner(entity_id) or await resolver.is_owner(name):
1639
+ logger.debug("Skipping relationship initiative for owner/empty subject: %s", name)
1640
+ continue
1641
+
1642
+ # Gate: check if Colony MANAGES this person
1643
+ has_manages = False
1644
+ if self.graph and entity_id:
1645
+ try:
1646
+ async with self.graph.driver.session(database=self.graph.database) as session:
1647
+ result = await session.run(
1648
+ "MATCH (:Agent)-[:MANAGES]->(p:Person {id: $id}) RETURN count(p) as cnt",
1649
+ id=entity_id,
1650
+ )
1651
+ record = await result.single()
1652
+ has_manages = record is not None and record.get("cnt", 0) > 0
1653
+ except Exception:
1654
+ pass
1655
+
1656
+ if not has_manages:
1657
+ logger.debug("Skipping relationship initiative for %s: no MANAGES edge", name)
1658
+ continue
1659
+
1660
+ initiatives.append(
1661
+ Initiative(
1662
+ id=f"rel-{entity_id or _uuid_module.uuid4().hex[:12]}",
1663
+ type=InitiativeType.RELATIONSHIP,
1664
+ description=f"Check in with {name}",
1665
+ priority=min(1.0, 0.4 + (days / 14.0)),
1666
+ rationale=f"No contact for {days} days",
1667
+ # Agent-directed: evaluate the relationship and choose a
1668
+ # disposition — this is not an instruction to message.
1669
+ action_hint="evaluate_relationship",
1670
+ entity_id=entity_id,
1671
+ dedup_key=f"relationship:{entity_id}",
1672
+ expires_at=datetime.now(timezone.utc) + timedelta(days=3),
1673
+ )
1674
+ )
1675
+ return initiatives
1676
+
1677
+ async def _generate_introduction_initiatives(self) -> List[Initiative]:
1678
+ """Propose connecting two people who share related work (social-graph autonomy).
1679
+
1680
+ PROPOSE-ONLY by construction: an INTRODUCTION is NOT an agent_action, so the
1681
+ loop surfaces it for the OWNER to approve and never auto-executes it. The feed
1682
+ only supplies pairs where both sides are above the trust floor and neither is
1683
+ the owner; this re-checks owner exclusion (fail closed) since context can be
1684
+ fed externally. One-shot dedup_key (no time bucket): propose a given pair once,
1685
+ and the store's terminal-dedup stops it being re-proposed after it is acted on.
1686
+ """
1687
+ from apsimo.identity.resolver import (
1688
+ OwnerIdentityError,
1689
+ get_identity_resolver,
1690
+ )
1691
+ resolver = get_identity_resolver()
1692
+ try:
1693
+ await resolver.owner_identities()
1694
+ except OwnerIdentityError as exc:
1695
+ logger.critical(
1696
+ "Owner identity unresolved — introduction generation disabled "
1697
+ "(fail closed): %s", exc,
1698
+ )
1699
+ return []
1700
+
1701
+ initiatives: List[Initiative] = []
1702
+ for cand in self._context.get("introduction_candidates", []):
1703
+ a_id, b_id = cand.get("a_id"), cand.get("b_id")
1704
+ if not a_id or not b_id or a_id == b_id:
1705
+ continue
1706
+ if await resolver.is_owner(a_id) or await resolver.is_owner(b_id):
1707
+ continue
1708
+ a_name = cand.get("a_name") or a_id
1709
+ b_name = cand.get("b_name") or b_id
1710
+ shared = cand.get("organization") or "shared context"
1711
+ lo, hi = sorted((a_id, b_id))
1712
+ initiatives.append(
1713
+ Initiative(
1714
+ id=f"intro-{lo}-{hi}",
1715
+ type=InitiativeType.INTRODUCTION,
1716
+ description=f"Introduce {a_name} and {b_name} (both at {shared})",
1717
+ # Must clear the generate() min_priority confidence gate
1718
+ # (autonomy default 0.7). An intro is high-CONFIDENCE (real
1719
+ # shared-work signal + both above the trust floor), just
1720
+ # low-urgency; the cap's social headroom handles volume.
1721
+ priority=0.7,
1722
+ rationale=f"Both are connected to {shared} but may not know each other",
1723
+ # Owner-directed proposal: the agent never sends an intro unprompted.
1724
+ action_hint="propose_introduction",
1725
+ entity_id=f"{lo}:{hi}",
1726
+ dedup_key=f"intro:{lo}:{hi}",
1727
+ expires_at=datetime.now(timezone.utc) + timedelta(days=7),
1728
+ )
1729
+ )
1730
+ return initiatives
1731
+
1732
+ async def _generate_health_suggestions(self) -> List[Initiative]:
1733
+ """Generate health insights from signal context."""
1734
+ initiatives: List[Initiative] = []
1735
+ for alert in self._context.get("health_alerts", []):
1736
+ metric = alert.get("metric", "health")
1737
+ value = alert.get("value", 0.0)
1738
+ target = alert.get("target", 70.0)
1739
+ initiatives.append(
1740
+ Initiative(
1741
+ id=f"health-{metric}-{_uuid_module.uuid4().hex[:8]}",
1742
+ type=InitiativeType.HEALTH,
1743
+ description=f"{metric.title()} is low ({value:.0f}% / target {target:.0f}%)",
1744
+ priority=0.7,
1745
+ rationale=f"{metric} below target for sustained period",
1746
+ action_hint=f"Review {metric} patterns and adjust habits",
1747
+ entity_id=metric,
1748
+ dedup_key=f"health:{metric}",
1749
+ expires_at=datetime.now(timezone.utc) + timedelta(hours=24),
1750
+ )
1751
+ )
1752
+ return initiatives
1753
+
1754
+ async def _generate_scheduling_suggestions(self) -> List[Initiative]:
1755
+ """Generate scheduling recommendations from opportunity context."""
1756
+ initiatives: List[Initiative] = []
1757
+ for slot in self._context.get("scheduling_opportunities", []):
1758
+ desc = slot.get("description", "scheduling opportunity")
1759
+ priority = float(slot.get("priority", 0.5))
1760
+ # Bug 45: Add dedup_key based on description hash
1761
+ dedup_key = f"schedule:{hash(desc) % 10000000}"
1762
+ initiatives.append(
1763
+ Initiative(
1764
+ id=f"schedule-{_uuid_module.uuid4().hex[:12]}",
1765
+ type=InitiativeType.SCHEDULING,
1766
+ description=desc,
1767
+ priority=min(1.0, priority),
1768
+ rationale=slot.get("rationale", "Based on observed patterns"),
1769
+ action_hint=slot.get("action_hint"),
1770
+ entity_id=dedup_key.split(":", 1)[1] if ":" in dedup_key else None,
1771
+ dedup_key=dedup_key,
1772
+ expires_at=datetime.now(timezone.utc) + timedelta(days=1),
1773
+ )
1774
+ )
1775
+ return initiatives
1776
+
1777
+ async def _generate_commitment_initiatives(self) -> List[Initiative]:
1778
+ """Generate commitment follow-through initiatives (v0.16.0).
1779
+
1780
+ Reads ``upcoming_commitments`` context fed by the autonomy loop
1781
+ from Colony's own commitment store. The OWNER IS A VALID SUBJECT
1782
+ here — "follow up on what you promised the owner" is exactly the work
1783
+ this type exists for, so no owner-exclusion filter applies.
1784
+ Context is durable: the promise and its deadline do not go stale
1785
+ the way CI status does.
1786
+ """
1787
+ initiatives: List[Initiative] = []
1788
+ now = datetime.now(timezone.utc)
1789
+ for c in self._context.get("upcoming_commitments", []):
1790
+ commitment_id = c.get("commitment_id")
1791
+ if not commitment_id:
1792
+ continue
1793
+ desc = c.get("description", "untitled commitment")
1794
+ hours_until_due = c.get("hours_until_due")
1795
+ overdue = bool(c.get("overdue"))
1796
+
1797
+ # An IMMEDIATE OWED DELIVERABLE (the introspection reflex's second output:
1798
+ # "text me the result") is not a reminder — the host must actually SEND it.
1799
+ # Route it to an agent_action carrying the recipient + content, so the queue
1800
+ # worker delivers it. Falls through to a normal reminder if it is malformed.
1801
+ meta = c.get("metadata") or {}
1802
+ is_deliverable = (
1803
+ (meta.get("kind") == "deliverable" or c.get("source_type") == "introspection")
1804
+ and not meta.get("delivered")
1805
+ )
1806
+ if is_deliverable:
1807
+ content = str(meta.get("content") or "").strip()
1808
+ person = c.get("person_id") or ""
1809
+ channel = meta.get("channel_hint") or "dm"
1810
+ if content and person:
1811
+ initiatives.append(
1812
+ Initiative(
1813
+ id=f"deliver-{commitment_id}",
1814
+ type=InitiativeType.AGENT_ACTION,
1815
+ description=f"Deliver to {person} via {channel}: {content}",
1816
+ # max priority: someone is actively waiting on this, so it must
1817
+ # win the generation cap over routine proactive initiatives.
1818
+ priority=1.0,
1819
+ rationale="Owed deliverable from a recent conversation (introspection)",
1820
+ action_hint="agent_deliver_message",
1821
+ entity_id=commitment_id,
1822
+ dedup_key=f"deliver:{commitment_id}",
1823
+ expires_at=now + timedelta(hours=24),
1824
+ # carried into the job context; RECIPIENT is the target_param the
1825
+ # graduated policy resolves to auto-pass an authorized recipient.
1826
+ trigger_data={
1827
+ "RECIPIENT": person,
1828
+ "MSG": content,
1829
+ "channel_hint": channel,
1830
+ "commitment_id": commitment_id,
1831
+ },
1832
+ )
1833
+ )
1834
+ continue # do not ALSO emit a reminder for this commitment
1835
+
1836
+ # Reminder path: only surface commitments with genuine, meaningful
1837
+ # text (skip system/skill-origin or empty-after-sanitise sources).
1838
+ from apsimo.delivery.reachout_policy import meaningful_reachout_text
1839
+ desc = meaningful_reachout_text(desc)
1840
+ if not desc:
1841
+ logger.debug("commitment reminder skipped: no meaningful content")
1842
+ continue
1843
+
1844
+ if overdue:
1845
+ priority = 0.9
1846
+ rationale = "Commitment is overdue"
1847
+ description = f"Follow up on overdue commitment: {desc}"
1848
+ else:
1849
+ priority = 0.85 if (hours_until_due is not None and hours_until_due < 4) else 0.7
1850
+ rationale = (
1851
+ f"Due in {int(hours_until_due)}h"
1852
+ if hours_until_due is not None else "Deadline approaching"
1853
+ )
1854
+ description = f"Follow up on commitment: {desc}"
1855
+
1856
+ initiatives.append(
1857
+ Initiative(
1858
+ id=f"commitment-{commitment_id}",
1859
+ type=InitiativeType.COMMITMENT,
1860
+ description=description,
1861
+ priority=min(1.0, priority),
1862
+ rationale=rationale,
1863
+ action_hint="commitment_check_deadline",
1864
+ entity_id=commitment_id,
1865
+ dedup_key=f"commitment:{commitment_id}",
1866
+ expires_at=now + timedelta(days=2),
1867
+ trigger_data={
1868
+ "commitment_text": desc,
1869
+ "deadline": c.get("due_at"),
1870
+ "status": c.get("status", "pending"),
1871
+ "person_id": c.get("person_id"),
1872
+ "hours_until_due": hours_until_due,
1873
+ },
1874
+ )
1875
+ )
1876
+ return initiatives
1877
+
1878
+ # ------------------------------------------------------------------
1879
+ # Per-entity context rebuild (v0.16.0)
1880
+ # ------------------------------------------------------------------
1881
+
1882
+ async def rebuild_context(
1883
+ self,
1884
+ type_value: str,
1885
+ entity_id: Optional[str],
1886
+ ) -> Optional[Dict[str, Any]]:
1887
+ """Rebuild the context snapshot for ONE initiative's subject.
1888
+
1889
+ This is the volatile-context refresh path: the batch loaders
1890
+ (``_load_*_context``) only run during the generation tick, so the
1891
+ agent calls this (via POST /initiatives/{id}/context/refresh)
1892
+ before acting on a snapshot that has outlived its freshness TTL.
1893
+
1894
+ Returns None when no per-entity rebuilder is registered for the
1895
+ type — callers must surface that, not silently serve stale data.
1896
+ New volatile types (calendar, coding, system) MUST register a
1897
+ rebuilder here when their batch loader lands.
1898
+ """
1899
+ rebuilders = {
1900
+ "relationship": self._rebuild_relationship_context,
1901
+ "commitment": self._rebuild_commitment_context,
1902
+ }
1903
+ rebuilder = rebuilders.get(type_value)
1904
+ if rebuilder is not None:
1905
+ return await rebuilder(entity_id)
1906
+ # Observation-backed domains share one rebuilder: the freshest
1907
+ # agent-reported snapshot for this entity.
1908
+ if type_value in self._OBSERVATION_DOMAIN_TYPES:
1909
+ return self._rebuild_observation_context(type_value, entity_id)
1910
+ return None
1911
+
1912
+ async def _rebuild_relationship_context(
1913
+ self, entity_id: Optional[str]
1914
+ ) -> Optional[Dict[str, Any]]:
1915
+ """Re-query days-since-contact and score for one Person node."""
1916
+ if not entity_id or self.graph is None or not hasattr(self.graph, "driver"):
1917
+ return None
1918
+ query = """
1919
+ MATCH (p:Person {id: $id})
1920
+ WITH p,
1921
+ coalesce(p.lastCommunication, p.lastInteraction, p.lastSeen) AS last_seen
1922
+ RETURN p.name AS name, p.score AS score,
1923
+ CASE WHEN last_seen IS NULL THEN null
1924
+ ELSE duration.inDays(last_seen, datetime()).days END AS days_since
1925
+ """
1926
+ try:
1927
+ async with self.graph.driver.session(database=self.graph.database) as session:
1928
+ result = await session.run(query, id=entity_id)
1929
+ record = await result.single()
1930
+ if record is None:
1931
+ return None
1932
+ record = dict(record)
1933
+ return {
1934
+ "neglected_contact": {
1935
+ "contact_id": entity_id,
1936
+ "contact_name": record.get("name"),
1937
+ "days_since_contact": record.get("days_since"),
1938
+ "relationship_score": record.get("score"),
1939
+ },
1940
+ "context_captured_at": datetime.now(timezone.utc).isoformat(),
1941
+ }
1942
+ except Exception as exc:
1943
+ logger.warning("Relationship context rebuild failed for %s: %s", entity_id, exc)
1944
+ return None
1945
+
1946
+ async def _rebuild_commitment_context(
1947
+ self, entity_id: Optional[str]
1948
+ ) -> Optional[Dict[str, Any]]:
1949
+ """Re-read one commitment from the commitment store."""
1950
+ if not entity_id:
1951
+ return None
1952
+ try:
1953
+ # Late-bound like SubsystemRegistry — the engine is constructed
1954
+ # before the stores in some boot orders.
1955
+ from apsimo.api.routers.host import _commitment_store
1956
+ except Exception:
1957
+ return None
1958
+ if _commitment_store is None or not hasattr(_commitment_store, "get"):
1959
+ return None
1960
+ try:
1961
+ commitment = _commitment_store.get(entity_id)
1962
+ except Exception as exc:
1963
+ logger.warning("Commitment context rebuild failed for %s: %s", entity_id, exc)
1964
+ return None
1965
+ if not commitment:
1966
+ return None
1967
+ return {
1968
+ "commitment": {
1969
+ "commitment_id": entity_id,
1970
+ "commitment_text": commitment.get("description"),
1971
+ "deadline": commitment.get("due_at"),
1972
+ "status": commitment.get("status"),
1973
+ "person_id": commitment.get("person_id"),
1974
+ },
1975
+ "context_captured_at": datetime.now(timezone.utc).isoformat(),
1976
+ }
1977
+
1978
+ # ------------------------------------------------------------------
1979
+ # Observation-backed domains (v0.16.0, agent-as-sensor)
1980
+ # ------------------------------------------------------------------
1981
+ # The agent observes the world through its own Hermes connections
1982
+ # and reports snapshots to the observation store. These loaders and
1983
+ # generators read observations — Colony never calls external APIs.
1984
+
1985
+ _OBSERVATION_DOMAIN_TYPES = (
1986
+ "coding", "task", "calendar", "research", "project", "system",
1987
+ )
1988
+
1989
+ def _obs_store(self) -> Any:
1990
+ """Observation store, late-bound like the other host singletons."""
1991
+ if self._observation_store is not None:
1992
+ return self._observation_store
1993
+ try:
1994
+ from apsimo.api.routers.observations import get_observation_store
1995
+ return get_observation_store()
1996
+ except Exception:
1997
+ return None
1998
+
1999
+ def _load_observation_domains(self) -> None:
2000
+ """Populate context for each observed domain (batch mode).
2001
+
2002
+ Respects manually-fed context: a domain key that already exists
2003
+ (e.g. injected by a test or the loop) is left untouched.
2004
+ """
2005
+ store = self._obs_store()
2006
+ if store is None:
2007
+ return
2008
+ for domain in self._OBSERVATION_DOMAIN_TYPES:
2009
+ if self._context.get(domain):
2010
+ continue
2011
+ try:
2012
+ observations = store.list(domain, limit=100)
2013
+ except Exception as exc:
2014
+ logger.warning("Observation load failed for %s: %s", domain, exc)
2015
+ continue
2016
+ if observations:
2017
+ self._context[domain] = [
2018
+ {
2019
+ **(o.payload or {}),
2020
+ "entity_id": o.entity_id,
2021
+ "observed_at": o.observed_at.isoformat() if o.observed_at else None,
2022
+ "context_captured_at": o.observed_at.isoformat() if o.observed_at else None,
2023
+ }
2024
+ for o in observations
2025
+ if is_context_fresh(domain, o.observed_at.isoformat() if o.observed_at else None)
2026
+ ]
2027
+
2028
+ @staticmethod
2029
+ def _parse_iso(value: Any) -> Optional[datetime]:
2030
+ if not value or not isinstance(value, str):
2031
+ return None
2032
+ try:
2033
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
2034
+ except (ValueError, TypeError):
2035
+ return None
2036
+ if parsed.tzinfo is None:
2037
+ parsed = parsed.replace(tzinfo=timezone.utc)
2038
+ return parsed
2039
+
2040
+ @staticmethod
2041
+ def _system_error_rate(payload: Dict[str, Any]) -> Optional[float]:
2042
+ value = payload.get("error_rate")
2043
+ try:
2044
+ rate = float(value) if value is not None else 0.0
2045
+ except (TypeError, ValueError):
2046
+ return None
2047
+ return rate if not isinstance(value, bool) and 0 <= rate <= 1 else None
2048
+
2049
+ @staticmethod
2050
+ def _observation_condition_cleared(domain: str, payload: Dict[str, Any]) -> bool:
2051
+ """Has the condition that justified an initiative gone away?
2052
+
2053
+ Used by the refresh path to auto-close volatile initiatives
2054
+ instead of surfacing stale context (CI green again, service
2055
+ recovered, meeting already over).
2056
+ """
2057
+ healthy = {"healthy", "ok", "active", "up", "passing", "success", "green"}
2058
+ if domain == "coding":
2059
+ ci = str(payload.get("ci_status") or "").lower()
2060
+ ci_ok = (not ci) or ci in healthy
2061
+ return ci_ok and not payload.get("review_requested")
2062
+ if domain == "system":
2063
+ status = str(payload.get("status") or "").lower()
2064
+ error_rate = InitiativeEngine._system_error_rate(payload)
2065
+ return status in healthy and error_rate is not None and error_rate <= 0.1
2066
+ if domain == "calendar":
2067
+ start = InitiativeEngine._parse_iso(payload.get("start_time"))
2068
+ return start is not None and start < datetime.now(timezone.utc)
2069
+ if domain == "task":
2070
+ return str(payload.get("state") or "").lower() not in ("open", "")
2071
+ if domain == "project":
2072
+ return int(payload.get("open_issues") or 0) == 0
2073
+ if domain == "research":
2074
+ return str(payload.get("status") or "").lower() in (
2075
+ "done", "complete", "published", "released",
2076
+ )
2077
+ return False
2078
+
2079
+ def _rebuild_observation_context(
2080
+ self, domain: str, entity_id: Optional[str]
2081
+ ) -> Optional[Dict[str, Any]]:
2082
+ """Per-entity refresh = the freshest stored observation."""
2083
+ if not entity_id:
2084
+ return None
2085
+ store = self._obs_store()
2086
+ if store is None:
2087
+ return None
2088
+ try:
2089
+ obs = store.get(domain, entity_id)
2090
+ except Exception as exc:
2091
+ logger.warning("Observation rebuild failed for %s/%s: %s", domain, entity_id, exc)
2092
+ return None
2093
+ if obs is None:
2094
+ return None
2095
+ stamp = obs.observed_at.isoformat() if obs.observed_at else None
2096
+ if not is_context_fresh(domain, stamp):
2097
+ return None
2098
+ return {
2099
+ domain: {**(obs.payload or {}), "entity_id": entity_id, "observed_at": stamp},
2100
+ "context_captured_at": stamp,
2101
+ "condition_cleared": self._observation_condition_cleared(domain, obs.payload or {}),
2102
+ }
2103
+
2104
+ async def _generate_coding_initiatives(self) -> List[Initiative]:
2105
+ """PRs needing review and failing CI, from agent observations."""
2106
+ initiatives: List[Initiative] = []
2107
+ now = datetime.now(timezone.utc)
2108
+ for item in self._context.get("coding", []):
2109
+ entity_id = item.get("entity_id")
2110
+ if not entity_id:
2111
+ continue
2112
+ title = item.get("title", entity_id)
2113
+ ci = str(item.get("ci_status") or "").lower()
2114
+ ci_failing = ci in ("failing", "failure", "failed", "error", "red")
2115
+ review_needed = bool(item.get("review_requested")) and not item.get("draft")
2116
+ if ci_failing:
2117
+ description = f"Investigate failing CI on {title}"
2118
+ action_hint = "coding_check_ci"
2119
+ priority = 0.85
2120
+ rationale = f"CI status: {item.get('ci_status')}"
2121
+ elif review_needed:
2122
+ description = f"Review PR: {title}"
2123
+ action_hint = "coding_check_ci"
2124
+ priority = 0.75
2125
+ rationale = "Review requested"
2126
+ else:
2127
+ continue
2128
+ initiatives.append(
2129
+ Initiative(
2130
+ id=f"coding-{entity_id}",
2131
+ type=InitiativeType.CODING,
2132
+ description=description,
2133
+ priority=priority,
2134
+ rationale=rationale,
2135
+ action_hint=action_hint,
2136
+ entity_id=str(entity_id),
2137
+ dedup_key=f"coding:{entity_id}",
2138
+ expires_at=now + timedelta(hours=24),
2139
+ trigger_data={k: v for k, v in item.items() if k != "entity_id"},
2140
+ )
2141
+ )
2142
+ return initiatives
2143
+
2144
+ async def _generate_task_initiatives(self) -> List[Initiative]:
2145
+ """Open tasks that have gone stale, from agent observations."""
2146
+ initiatives: List[Initiative] = []
2147
+ now = datetime.now(timezone.utc)
2148
+ for item in self._context.get("task", []):
2149
+ entity_id = item.get("entity_id")
2150
+ if not entity_id:
2151
+ continue
2152
+ if str(item.get("state") or "open").lower() != "open":
2153
+ continue
2154
+ stale_days = float(item.get("stale_days") or 0)
2155
+ if stale_days < 3 and not item.get("needs_follow_up"):
2156
+ continue
2157
+ title = item.get("title", entity_id)
2158
+ initiatives.append(
2159
+ Initiative(
2160
+ id=f"task-{entity_id}",
2161
+ type=InitiativeType.TASK,
2162
+ description=f"Follow up on task: {title}",
2163
+ priority=min(1.0, 0.5 + stale_days / 14.0),
2164
+ rationale=f"Open with no movement for {int(stale_days)} days"
2165
+ if stale_days else "Flagged for follow-up",
2166
+ action_hint="task_check_status",
2167
+ entity_id=str(entity_id),
2168
+ dedup_key=f"task:{entity_id}",
2169
+ expires_at=now + timedelta(days=3),
2170
+ trigger_data={k: v for k, v in item.items() if k != "entity_id"},
2171
+ )
2172
+ )
2173
+ return initiatives
2174
+
2175
+ async def _generate_calendar_initiatives(self) -> List[Initiative]:
2176
+ """Upcoming events needing preparation, from agent observations."""
2177
+ initiatives: List[Initiative] = []
2178
+ now = datetime.now(timezone.utc)
2179
+ for item in self._context.get("calendar", []):
2180
+ entity_id = item.get("entity_id")
2181
+ if not entity_id:
2182
+ continue
2183
+ if item.get("needs_prep") is False:
2184
+ continue
2185
+ start = self._parse_iso(item.get("start_time"))
2186
+ if start is None or start < now:
2187
+ continue
2188
+ hours_until = (start - now).total_seconds() / 3600
2189
+ if hours_until > 24:
2190
+ continue
2191
+ title = item.get("title", entity_id)
2192
+ initiatives.append(
2193
+ Initiative(
2194
+ id=f"calendar-{entity_id}",
2195
+ type=InitiativeType.CALENDAR,
2196
+ description=f"Prepare for: {title}",
2197
+ priority=0.85 if hours_until <= 2 else 0.7,
2198
+ rationale=f"Starts in {hours_until:.1f}h",
2199
+ action_hint="calendar_prepare_meeting",
2200
+ entity_id=str(entity_id),
2201
+ dedup_key=f"calendar:{entity_id}",
2202
+ expires_at=start,
2203
+ trigger_data={k: v for k, v in item.items() if k != "entity_id"},
2204
+ )
2205
+ )
2206
+ return initiatives
2207
+
2208
+ async def _generate_research_initiatives(self) -> List[Initiative]:
2209
+ """Tracked research items due a check, from agent observations."""
2210
+ initiatives: List[Initiative] = []
2211
+ now = datetime.now(timezone.utc)
2212
+ for item in self._context.get("research", []):
2213
+ entity_id = item.get("entity_id")
2214
+ if not entity_id:
2215
+ continue
2216
+ status = str(item.get("status") or "").lower()
2217
+ if status in ("done", "complete", "published", "released"):
2218
+ continue
2219
+ last_checked = self._parse_iso(item.get("last_checked"))
2220
+ days_since_check = (
2221
+ (now - last_checked).total_seconds() / 86400
2222
+ if last_checked else None
2223
+ )
2224
+ if days_since_check is not None and days_since_check < self._config.research_task_age_days:
2225
+ continue
2226
+ title = item.get("title", entity_id)
2227
+ initiatives.append(
2228
+ Initiative(
2229
+ id=f"research-{entity_id}",
2230
+ type=InitiativeType.RESEARCH,
2231
+ description=f"Check research item: {title}",
2232
+ priority=0.6,
2233
+ rationale=(
2234
+ f"Not checked for {int(days_since_check)} days"
2235
+ if days_since_check is not None else "Tracked and awaiting status"
2236
+ ),
2237
+ action_hint="research_check_paper",
2238
+ entity_id=str(entity_id),
2239
+ dedup_key=f"research:{entity_id}",
2240
+ expires_at=now + timedelta(days=7),
2241
+ trigger_data={k: v for k, v in item.items() if k != "entity_id"},
2242
+ )
2243
+ )
2244
+ return initiatives
2245
+
2246
+ async def _generate_project_initiatives(self) -> List[Initiative]:
2247
+ """Milestones approaching with open work, from agent observations."""
2248
+ initiatives: List[Initiative] = []
2249
+ now = datetime.now(timezone.utc)
2250
+ for item in self._context.get("project", []):
2251
+ entity_id = item.get("entity_id")
2252
+ if not entity_id:
2253
+ continue
2254
+ open_issues = int(item.get("open_issues") or 0)
2255
+ if open_issues == 0:
2256
+ continue
2257
+ due = self._parse_iso(item.get("due_on"))
2258
+ if due is None:
2259
+ continue
2260
+ days_until = (due - now).total_seconds() / 86400
2261
+ if days_until > 7:
2262
+ continue
2263
+ title = item.get("title", entity_id)
2264
+ initiatives.append(
2265
+ Initiative(
2266
+ id=f"project-{entity_id}",
2267
+ type=InitiativeType.PROJECT,
2268
+ description=(
2269
+ f"Milestone {title}: {open_issues} open issue(s), "
2270
+ f"due in {max(0, int(days_until))}d"
2271
+ ),
2272
+ priority=0.85 if days_until <= 2 else 0.7,
2273
+ rationale="Milestone deadline approaching with open work",
2274
+ action_hint="project_check_progress",
2275
+ entity_id=str(entity_id),
2276
+ dedup_key=f"project:{entity_id}",
2277
+ expires_at=due,
2278
+ trigger_data={k: v for k, v in item.items() if k != "entity_id"},
2279
+ )
2280
+ )
2281
+ return initiatives
2282
+
2283
+ async def _generate_system_initiatives(self) -> List[Initiative]:
2284
+ """Unhealthy services, from agent observations."""
2285
+ initiatives: List[Initiative] = []
2286
+ now = datetime.now(timezone.utc)
2287
+ unhealthy = {"degraded", "down", "error", "failed", "failing", "unhealthy",
2288
+ "unreachable", "offline", "critical", "unavailable"}
2289
+ for item in self._context.get("system", []):
2290
+ entity_id = item.get("entity_id")
2291
+ if not entity_id or not is_context_fresh("system", item.get("observed_at"), now=now):
2292
+ continue
2293
+ status = str(item.get("status") or "").strip().lower()
2294
+ error_rate = self._system_error_rate(item)
2295
+ if status not in unhealthy and not (error_rate is not None and error_rate > 0.1):
2296
+ continue
2297
+ initiatives.append(
2298
+ Initiative(
2299
+ id=f"system-{entity_id}",
2300
+ type=InitiativeType.SYSTEM,
2301
+ description=f"Investigate {entity_id}: {status if status in unhealthy else 'elevated error rate'}",
2302
+ priority=0.9,
2303
+ rationale=item.get("message")
2304
+ or f"observed status={status or 'unspecified'}, error_rate={error_rate}",
2305
+ action_hint="system_check_health",
2306
+ entity_id=str(entity_id),
2307
+ dedup_key=f"system:{entity_id}",
2308
+ expires_at=now + timedelta(hours=2),
2309
+ trigger_data={**{k: v for k, v in item.items() if k != "entity_id"},
2310
+ "review_condition": ("unhealthy_status" if status in unhealthy
2311
+ else "elevated_error_rate"),
2312
+ "context_captured_at": item["observed_at"]},
2313
+ )
2314
+ )
2315
+ return initiatives
2316
+
2317
+ # ------------------------------------------------------------------
2318
+ # Self-initiative generators (v0.11.0)
2319
+ # ------------------------------------------------------------------
2320
+
2321
+ async def _generate_subsystem_health_initiatives(self) -> List[Initiative]:
2322
+ """Generate self-initiatives for degraded subsystems."""
2323
+ initiatives: List[Initiative] = []
2324
+ for issue in self._context.get("subsystem_health", []):
2325
+ entity_id = issue.get("entity_id", "unknown")
2326
+ name = issue.get("name", "Unknown")
2327
+ status = issue.get("status", "unknown")
2328
+ latency = issue.get("latency_ms")
2329
+ error_rate = issue.get("error_rate")
2330
+
2331
+ priority = 0.6
2332
+ if latency and latency > 1000:
2333
+ priority = min(1.0, 0.6 + (latency - 1000) / 2000)
2334
+ if error_rate and error_rate > 0.1:
2335
+ priority = min(1.0, priority + error_rate)
2336
+
2337
+ initiatives.append(
2338
+ Initiative(
2339
+ id=f"subsys-{entity_id}-{_uuid_module.uuid4().hex[:8]}",
2340
+ type=InitiativeType.SUBSYSTEM_HEALTH,
2341
+ description=f"Subsystem {name} is {status}",
2342
+ priority=priority,
2343
+ rationale=f"Latency: {latency}ms, Error rate: {error_rate}" if latency or error_rate else "Status degraded",
2344
+ action_hint=f"Diagnose and restart {name}" if status != "active" else "Investigate latency spike",
2345
+ entity_id=entity_id,
2346
+ dedup_key=f"subsystem:{entity_id}",
2347
+ expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
2348
+ trigger_data={**issue},
2349
+ )
2350
+ )
2351
+ return initiatives
2352
+
2353
+ def _data_quality_issues(self) -> List[Dict[str, Any]]:
2354
+ # The retired no-ABOUT classifier can remain in cached/external context.
2355
+ # Do not turn that permitted graph shape into either kind of repair task.
2356
+ return [issue for issue in self._context.get("data_quality_issues", [])
2357
+ if (issue.get("entity_id"), issue.get("entity_type"))
2358
+ != ("orphan_memories", "orphan_nodes")]
2359
+
2360
+ async def _generate_data_quality_initiatives(self) -> List[Initiative]:
2361
+ """Generate self-initiatives for data quality issues."""
2362
+ initiatives: List[Initiative] = []
2363
+ for issue in self._data_quality_issues():
2364
+ entity_id = issue.get("entity_id", "unknown")
2365
+ entity_type = issue.get("entity_type", "unknown")
2366
+ description = issue.get("description", "Data quality issue")
2367
+ count = issue.get("count", 0)
2368
+
2369
+ priority = 0.5
2370
+ if count > 10:
2371
+ priority = min(1.0, 0.5 + count / 100)
2372
+
2373
+ initiatives.append(
2374
+ Initiative(
2375
+ id=f"dq-{entity_id}-{_uuid_module.uuid4().hex[:8]}",
2376
+ type=InitiativeType.DATA_QUALITY,
2377
+ description=description,
2378
+ priority=priority,
2379
+ rationale=f"Detected {count} affected items" if count else "Schema drift detected",
2380
+ action_hint="Run data quality fix" if entity_type == "orphan_nodes" else "Review schema migration",
2381
+ entity_id=entity_id,
2382
+ dedup_key=f"dataquality:{entity_id}",
2383
+ expires_at=datetime.now(timezone.utc) + timedelta(hours=4),
2384
+ trigger_data={**issue},
2385
+ )
2386
+ )
2387
+ return initiatives
2388
+
2389
+ async def _generate_operational_initiatives(self) -> List[Initiative]:
2390
+ """Generate self-initiatives for operational hygiene."""
2391
+ initiatives: List[Initiative] = []
2392
+ for task in self._context.get("operational_tasks", []):
2393
+ entity_id = task.get("entity_id", "unknown")
2394
+ entity_type = task.get("entity_type", "unknown")
2395
+ description = task.get("description", "Operational task")
2396
+ age_days = task.get("age_days", 0)
2397
+
2398
+ priority = min(1.0, 0.4 + age_days / 14)
2399
+ if task.get('evidence_scope') == 'configured_backup_receipt':
2400
+ # Failed/unavailable evidence needs review without inventing age.
2401
+ priority = max(0.7, priority)
2402
+ log_review = entity_type == "log_rotation" and task.get("evidence_scope") == "local_log_directory_only"
2403
+ if log_review:
2404
+ # Measured excess should reach the default 0.7 proposal gate.
2405
+ # It authorizes investigation, not deletion or a service restart.
2406
+ priority = max(0.7, priority)
2407
+
2408
+ initiatives.append(
2409
+ Initiative(
2410
+ id=f"ops-{entity_id}-{_uuid_module.uuid4().hex[:8]}",
2411
+ type=InitiativeType.OPERATIONAL,
2412
+ description=description,
2413
+ priority=priority,
2414
+ rationale=f"Operational hygiene: {entity_type}",
2415
+ action_hint=("operational_review" if log_review or (entity_type == "backup"
2416
+ and task.get("evidence_scope") in {"legacy_bak_directory_only", "configured_backup_receipt"})
2417
+ else "Execute maintenance task"),
2418
+ entity_id=entity_id,
2419
+ dedup_key=f"operational:{entity_id}",
2420
+ expires_at=datetime.now(timezone.utc) + timedelta(days=1),
2421
+ trigger_data={**task},
2422
+ )
2423
+ )
2424
+ return initiatives
2425
+
2426
+ async def _generate_capability_gap_initiatives(self) -> List[Initiative]:
2427
+ """Generate self-initiatives proposing to acquire/repair capabilities.
2428
+
2429
+ Reads the ``capability_gaps`` context populated by
2430
+ ``_load_capability_gaps()`` from :Capability nodes whose
2431
+ ``failure_count`` met the COLONY_CAPABILITY_GAP_FAILURES threshold
2432
+ in the last 24h. Limitation: no code in the current tree writes
2433
+ those failure counters (the v0.11.1 ToolExecutor detection hook
2434
+ was never implemented), so this only surfaces gaps recorded by an
2435
+ external writer or operator until that hook lands.
2436
+
2437
+ Defensive: returns [] on any failure; never raises.
2438
+ """
2439
+ initiatives: List[Initiative] = []
2440
+ try:
2441
+ for gap in self._context.get("capability_gaps", []):
2442
+ entity_id = gap.get("id") or gap.get("entity_id") or "unknown"
2443
+ name = gap.get("name", "Unknown capability")
2444
+ failure_count = int(gap.get("failure_count", 0) or 0)
2445
+ failure_mode = gap.get("failure_mode", "unknown")
2446
+
2447
+ # 0.5 floor, +0.05 per recorded failure, capped at 0.75
2448
+ priority = min(0.75, 0.5 + failure_count * 0.05)
2449
+
2450
+ initiatives.append(
2451
+ Initiative(
2452
+ id=f"capgap-{entity_id}-{_uuid_module.uuid4().hex[:8]}",
2453
+ type=InitiativeType.CAPABILITY_GAP,
2454
+ description=f"Acquire or repair capability: {name}",
2455
+ priority=priority,
2456
+ rationale=(
2457
+ f"Capability '{name}' recorded {failure_count} "
2458
+ f"failure(s) in the last 24h (mode: {failure_mode}, "
2459
+ f"threshold: {self._config.capability_gap_failures})"
2460
+ ),
2461
+ action_hint="Register, repair, or research the failing capability",
2462
+ entity_id=str(entity_id),
2463
+ dedup_key=f"capability_gap:{entity_id}",
2464
+ expires_at=datetime.now(timezone.utc) + timedelta(hours=24),
2465
+ trigger_data={**gap},
2466
+ )
2467
+ )
2468
+ logger.debug("Generated %d capability gap initiatives", len(initiatives))
2469
+ except Exception as exc:
2470
+ logger.debug("Capability gap generation failed: %s", exc)
2471
+ return []
2472
+ return initiatives
2473
+
2474
+ async def _generate_knowledge_acquisition_initiatives(self) -> List[Initiative]:
2475
+ """Generate research initiatives for low-confidence knowledge areas.
2476
+
2477
+ Reads the ``knowledge_gaps`` context populated by
2478
+ ``_load_knowledge_gaps()`` from open/researching :Concept nodes
2479
+ with confidence_score < 0.5. Limitation: the :Concept node type
2480
+ exists in the graph schema but the v0.11.1 web-search detection
2481
+ hook that was meant to create Concept nodes was never implemented,
2482
+ so gaps only surface once something writes Concept nodes.
2483
+
2484
+ Defensive: returns [] on any failure; never raises.
2485
+ """
2486
+ initiatives: List[Initiative] = []
2487
+ try:
2488
+ for gap in self._context.get("knowledge_gaps", []):
2489
+ entity_id = gap.get("id") or gap.get("entity_id") or "unknown"
2490
+ name = gap.get("name", "Unknown concept")
2491
+ confidence = float(gap.get("confidence_score", 0.0) or 0.0)
2492
+ encounter_count = int(gap.get("encounter_count", 0) or 0)
2493
+
2494
+ # Lower confidence -> higher priority, within 0.5-0.75:
2495
+ # confidence 1.0 -> 0.5, confidence 0.0 -> 0.75
2496
+ priority = min(0.75, max(0.5, 0.5 + (1.0 - confidence) * 0.25))
2497
+
2498
+ initiatives.append(
2499
+ Initiative(
2500
+ id=f"knowgap-{entity_id}-{_uuid_module.uuid4().hex[:8]}",
2501
+ type=InitiativeType.KNOWLEDGE_ACQUISITION,
2502
+ description=f"Research concept: {name}",
2503
+ priority=priority,
2504
+ rationale=(
2505
+ f"Concept '{name}' encountered {encounter_count} "
2506
+ f"time(s) but confidence is only {confidence:.2f}"
2507
+ ),
2508
+ action_hint="Queue background research and update world model",
2509
+ entity_id=str(entity_id),
2510
+ dedup_key=f"knowledge_gap:{entity_id}",
2511
+ expires_at=datetime.now(timezone.utc) + timedelta(days=3),
2512
+ trigger_data={**gap},
2513
+ )
2514
+ )
2515
+ logger.debug("Generated %d knowledge acquisition initiatives", len(initiatives))
2516
+ except Exception as exc:
2517
+ logger.debug("Knowledge acquisition generation failed: %s", exc)
2518
+ return []
2519
+ return initiatives
2520
+
2521
+ async def _generate_behavioral_correction_initiatives(self) -> List[Initiative]:
2522
+ """Generate correction initiatives for recurring behavioral patterns.
2523
+
2524
+ Reads the ``behavioral_patterns`` context populated by
2525
+ ``_load_behavioral_patterns()`` from active :Pattern nodes with
2526
+ pattern_type 'behavioral' or 'correction' and 3+ occurrences.
2527
+ Limitation: the v0.11.1 correction-detection hook that was meant
2528
+ to write these Pattern nodes was never implemented, so patterns
2529
+ only surface once something writes graph :Pattern nodes (the
2530
+ SQLite PatternStore is a separate store and does not feed this).
2531
+
2532
+ Defensive: returns [] on any failure; never raises.
2533
+ """
2534
+ initiatives: List[Initiative] = []
2535
+ try:
2536
+ for pattern in self._context.get("behavioral_patterns", []):
2537
+ entity_id = pattern.get("id") or pattern.get("entity_id") or "unknown"
2538
+ trigger = pattern.get("trigger") or "unspecified trigger"
2539
+ action = pattern.get("action") or ""
2540
+ recurrence = int(pattern.get("recurrence_count", 0) or 0)
2541
+
2542
+ # 0.5 floor, +0.05 per recurrence, capped at 0.75
2543
+ priority = min(0.75, 0.5 + recurrence * 0.05)
2544
+
2545
+ rationale = f"Pattern recurred {recurrence} time(s)"
2546
+ if action:
2547
+ rationale += f"; expected behavior: {action[:80]}"
2548
+
2549
+ initiatives.append(
2550
+ Initiative(
2551
+ id=f"behav-{entity_id}-{_uuid_module.uuid4().hex[:8]}",
2552
+ type=InitiativeType.BEHAVIORAL_CORRECTION,
2553
+ description=f"Correct recurring behavior: {trigger[:80]}",
2554
+ priority=priority,
2555
+ rationale=rationale,
2556
+ action_hint="Encode the correction as a preference or config rule",
2557
+ entity_id=str(entity_id),
2558
+ dedup_key=f"behavioral_correction:{entity_id}",
2559
+ expires_at=datetime.now(timezone.utc) + timedelta(days=7),
2560
+ trigger_data={**pattern},
2561
+ )
2562
+ )
2563
+ logger.debug("Generated %d behavioral correction initiatives", len(initiatives))
2564
+ except Exception as exc:
2565
+ logger.debug("Behavioral correction generation failed: %s", exc)
2566
+ return []
2567
+ return initiatives
2568
+
2569
+ async def _generate_agent_action_initiatives(self) -> List[Initiative]:
2570
+ """Generate agent-actionable initiatives for autonomous execution (v0.13.0).
2571
+
2572
+ These initiatives are routed to the task queue instead of the delivery
2573
+ bridge, and claimed by the host agent's external worker.
2574
+ """
2575
+ initiatives: List[Initiative] = []
2576
+
2577
+ # 1. Repo status check — generated at most once every 4 hours
2578
+ now = datetime.now(timezone.utc)
2579
+ last_repo_check = self._last_self_initiative_at.get("repo_status")
2580
+ if last_repo_check and (now - last_repo_check) < timedelta(hours=4):
2581
+ pass # Still within cooldown
2582
+ else:
2583
+ self._last_self_initiative_at["repo_status"] = now
2584
+ # The working checkout is deployment-specific: COLONY_WORK_REPO
2585
+ # names the repo the hygiene check inspects (see action_registry).
2586
+ work_repo = os.environ.get("COLONY_WORK_REPO", "").strip()
2587
+ repo_label = os.path.basename(work_repo.rstrip("/")) or "work-repo"
2588
+ initiatives.append(
2589
+ Initiative(
2590
+ id=f"repo-check-{_uuid_module.uuid4().hex[:8]}",
2591
+ type=InitiativeType.AGENT_ACTION,
2592
+ description="Check the working repo for uncommitted changes",
2593
+ priority=0.4,
2594
+ rationale="Periodic hygiene check to prevent stale work",
2595
+ action_hint="agent_check_repo_status",
2596
+ entity_id=repo_label,
2597
+ dedup_key=f"agent_action:agent_check_repo_status:{repo_label}",
2598
+ expires_at=now + timedelta(hours=4),
2599
+ )
2600
+ )
2601
+
2602
+ # 2. Health check initiatives from subsystem health context
2603
+ for issue in self._context.get("subsystem_health", [])[:3]:
2604
+ entity_id = issue.get("entity_id", "unknown")
2605
+ name = issue.get("name", "Unknown")
2606
+ status_val = issue.get("status", "unknown")
2607
+ if status_val != "active":
2608
+ initiatives.append(
2609
+ Initiative(
2610
+ id=f"agent-health-{entity_id}-{_uuid_module.uuid4().hex[:8]}",
2611
+ type=InitiativeType.AGENT_ACTION,
2612
+ description=f"Investigate degraded subsystem: {name}",
2613
+ priority=min(1.0, 0.7 + (issue.get("error_rate", 0) or 0)),
2614
+ rationale=f"Subsystem {name} is {status_val}",
2615
+ action_hint="agent_investigate_subsystem",
2616
+ entity_id=entity_id,
2617
+ dedup_key=f"agent_action:agent_investigate_subsystem:{entity_id}",
2618
+ expires_at=datetime.now(timezone.utc) + timedelta(hours=2),
2619
+ trigger_data={**issue},
2620
+ )
2621
+ )
2622
+
2623
+ # 3. Data quality — auto-fixable issues
2624
+ for issue in self._data_quality_issues()[:2]:
2625
+ entity_id = issue.get("entity_id", "unknown")
2626
+ entity_type = issue.get("entity_type", "unknown")
2627
+ if entity_type == "orphan_nodes":
2628
+ initiatives.append(
2629
+ Initiative(
2630
+ id=f"agent-dq-{entity_id}-{_uuid_module.uuid4().hex[:8]}",
2631
+ type=InitiativeType.AGENT_ACTION,
2632
+ description=f"Clean up orphan nodes in {entity_id}",
2633
+ priority=0.5,
2634
+ rationale="Auto-fixable data quality issue",
2635
+ action_hint="agent_cleanup_orphans",
2636
+ entity_id=entity_id,
2637
+ dedup_key=f"agent_action:agent_cleanup_orphans:{entity_id}",
2638
+ expires_at=datetime.now(timezone.utc) + timedelta(hours=6),
2639
+ trigger_data={**issue},
2640
+ )
2641
+ )
2642
+
2643
+ return initiatives