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
apsimo/setup.py ADDED
@@ -0,0 +1,2818 @@
1
+ """Apsimo setup wizard - ``apsimo init``.
2
+
3
+ Guides the user through first-time configuration:
4
+ 1. Install dependencies
5
+ 2. Dependency checks
6
+ 3. Harness integration (Hermes plugin / MCP)
7
+ 4. Docker setup (if needed)
8
+ 5. Neo4j setup (auto-start via Docker or manual)
9
+ 6. Write .env
10
+ 7. Database setup
11
+ 8. Autonomy & approvals (owner contact, approval policy, gates, home channel)
12
+ 9. Self-knowledge seeding
13
+ 10. Start sidecar + verify (10e: schedule agent workers via crontab)
14
+ 11. Summary
15
+ 12. Health check (colony doctor)
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import os
22
+ import platform
23
+ import secrets
24
+ import shutil
25
+ import socket
26
+ import subprocess
27
+ import sys
28
+ import time
29
+ from pathlib import Path
30
+
31
+ try:
32
+ import tomllib
33
+ except ImportError:
34
+ try:
35
+ import tomli as tomllib # type: ignore[no-redef]
36
+ except ImportError:
37
+ tomllib = None # type: ignore[assignment]
38
+
39
+
40
+ # ── Docker Status Enum ───────────────────────────────────────────────────────
41
+
42
+ from enum import Enum
43
+
44
+
45
+ class DockerStatus(str, Enum):
46
+ """Docker installation/runtime status."""
47
+
48
+ # Good states
49
+ RUNNING = "running" # Docker daemon is running
50
+
51
+ # Install states
52
+ NOT_INSTALLED = "not_installed" # No docker binary found
53
+ DESKTOP_INSTALLED_NOT_RUNNING = "desktop_not_running" # macOS: Docker Desktop app exists, daemon off
54
+
55
+ # Runtime states
56
+ INSTALLED_NOT_RUNNING = "not_running" # docker binary exists, daemon off
57
+ PERMISSION_DENIED = "permission_denied" # Linux: user not in docker group
58
+
59
+ # Alternative runtimes
60
+ COLIMA_INSTALLED = "colima" # macOS: Colima installed
61
+ ORBSTACK_INSTALLED = "orbstack" # macOS: OrbStack installed
62
+ PODMAN_INSTALLED = "podman" # Linux: Podman installed (docker-compatible)
63
+
64
+ # Errors
65
+ ERROR = "error" # Unexpected error
66
+
67
+
68
+ # ── ANSI helpers ────────────────────────────────────────────────────────────
69
+
70
+ def _green(msg: str) -> str:
71
+ return f"\033[92m{msg}\033[0m"
72
+
73
+ def _red(msg: str) -> str:
74
+ return f"\033[91m{msg}\033[0m"
75
+
76
+ def _yellow(msg: str) -> str:
77
+ return f"\033[93m{msg}\033[0m"
78
+
79
+ def _bold(msg: str) -> str:
80
+ return f"\033[1m{msg}\033[0m"
81
+
82
+ def _prompt(prompt: str, default: str = "", non_interactive: bool = False, ask=None) -> str:
83
+ """Prompt for input with a default value. Returns default on EOF or non-interactive mode.
84
+
85
+ Also checks for COLONY_INIT_DEFAULTS env var for scripted defaults.
86
+ Format: COLONY_INIT_DEFAULTS='key1=val1,key2=val2'
87
+
88
+ ``ask`` is an injectable input callable (defaults to ``input``) so tests
89
+ can script answers without monkeypatching stdin. UX is unchanged.
90
+ """
91
+ if non_interactive:
92
+ return default
93
+
94
+ # Check for scripted defaults
95
+ defaults_env = os.environ.get("COLONY_INIT_DEFAULTS", "")
96
+ if defaults_env:
97
+ for pair in defaults_env.split(","):
98
+ if "=" in pair:
99
+ key, val = pair.split("=", 1)
100
+ # Map prompt keywords to defaults
101
+ prompt_lower = prompt.lower()
102
+ if key.lower() in prompt_lower or prompt_lower in key.lower():
103
+ return val
104
+
105
+ suffix = f" [{default}]" if default else ""
106
+ try:
107
+ val = (ask or input)(f"{prompt}{suffix}: ").strip()
108
+ return val or default
109
+ except EOFError:
110
+ # Gracefully handle piped input exhaustion
111
+ print() # Add newline for clean output
112
+ return default
113
+ except KeyboardInterrupt:
114
+ print("\nCancelled.")
115
+ sys.exit(130)
116
+
117
+
118
+ # ── Check helpers ───────────────────────────────────────────────────────────
119
+
120
+ def _check_command(cmd: str) -> bool:
121
+ return shutil.which(cmd) is not None
122
+
123
+ def _check_python() -> tuple[bool, str]:
124
+ version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
125
+ ok = sys.version_info >= (3, 11)
126
+ return ok, version
127
+
128
+ def _check_neo4j() -> tuple[bool, str]:
129
+ """Check if Neo4j is reachable on the default bolt port."""
130
+ try:
131
+ with socket.create_connection(("localhost", 7687), timeout=2):
132
+ return True, "localhost:7687"
133
+ except (ConnectionRefusedError, OSError):
134
+ return False, "not reachable"
135
+
136
+
137
+ def _check_neo4j_auth() -> bool:
138
+ """Check if Neo4j requires authentication. Returns True if auth is required."""
139
+ try:
140
+ from neo4j import AsyncGraphDatabase
141
+ import asyncio
142
+
143
+ async def test_auth():
144
+ # Try connecting without auth
145
+ driver = AsyncGraphDatabase.driver("bolt://localhost:7687")
146
+ try:
147
+ async with driver.session() as session:
148
+ await session.run("RETURN 1")
149
+ return False # No auth required
150
+ except Exception:
151
+ return True # Auth required
152
+ finally:
153
+ await driver.close()
154
+
155
+ return asyncio.run(test_auth())
156
+ except Exception:
157
+ return True # Assume auth required if we can't test
158
+
159
+ def _check_port(port: int) -> bool:
160
+ try:
161
+ with socket.create_connection(("localhost", port), timeout=1):
162
+ return True
163
+ except (ConnectionRefusedError, OSError):
164
+ return False
165
+
166
+ def _check_docker() -> tuple[DockerStatus, str]:
167
+ """Check Docker installation and runtime status.
168
+
169
+ Returns:
170
+ (status, message) where status is DockerStatus enum
171
+ and message is human-readable details
172
+ """
173
+ system = platform.system().lower()
174
+
175
+ # Check for docker binary
176
+ docker_path = shutil.which("docker")
177
+
178
+ if not docker_path:
179
+ # Docker binary not in PATH
180
+ # On macOS, check if Docker Desktop app exists
181
+ if system == "darwin":
182
+ if Path("/Applications/Docker.app").exists():
183
+ return DockerStatus.DESKTOP_INSTALLED_NOT_RUNNING, "Docker Desktop installed but not in PATH"
184
+ if Path("/Applications/OrbStack.app").exists():
185
+ return DockerStatus.ORBSTACK_INSTALLED, "OrbStack installed"
186
+
187
+ # Check for alternative runtimes
188
+ if shutil.which("colima"):
189
+ return DockerStatus.COLIMA_INSTALLED, "Colima installed (run 'colima start')"
190
+
191
+ if system == "linux" and shutil.which("podman"):
192
+ return DockerStatus.PODMAN_INSTALLED, "Podman installed"
193
+
194
+ return DockerStatus.NOT_INSTALLED, "Docker not found"
195
+
196
+ # Docker binary exists — check if daemon is running
197
+ try:
198
+ result = subprocess.run(
199
+ ["docker", "info"],
200
+ capture_output=True,
201
+ text=True,
202
+ timeout=5,
203
+ )
204
+
205
+ if result.returncode == 0:
206
+ return DockerStatus.RUNNING, "Docker daemon running"
207
+
208
+ stderr = result.stderr or ""
209
+
210
+ # Parse specific error conditions
211
+ if "Cannot connect to the Docker daemon" in stderr:
212
+ # Daemon not running
213
+ # Check if this is Docker Desktop on macOS
214
+ if system == "darwin":
215
+ if Path("/Applications/Docker.app").exists():
216
+ return DockerStatus.DESKTOP_INSTALLED_NOT_RUNNING, "Docker Desktop installed but not running"
217
+
218
+ return DockerStatus.INSTALLED_NOT_RUNNING, "Docker installed but daemon not running"
219
+
220
+ if "permission denied" in stderr.lower():
221
+ return DockerStatus.PERMISSION_DENIED, "Permission denied — add user to 'docker' group"
222
+
223
+ # Unknown error
224
+ return DockerStatus.ERROR, f"docker info failed: {stderr[:100]}"
225
+
226
+ except subprocess.TimeoutExpired:
227
+ return DockerStatus.ERROR, "docker info timed out"
228
+ except Exception as e:
229
+ return DockerStatus.ERROR, str(e)
230
+
231
+ def _detect_coding_harnesses() -> list[str]:
232
+ """Detect installed coding harnesses that support MCP."""
233
+ harnesses = []
234
+ if Path.home().joinpath(".claude").exists():
235
+ harnesses.append("claude-code")
236
+ if Path.home().joinpath(".codex").exists():
237
+ harnesses.append("codex")
238
+ if Path.home().joinpath(".crush.json").exists():
239
+ harnesses.append("crush")
240
+ if Path.home().joinpath(".opencode").exists():
241
+ harnesses.append("opencode")
242
+ return harnesses
243
+
244
+
245
+ def _detect_agent_harnesses() -> list[str]:
246
+ """Detect installed agent harnesses."""
247
+ harnesses = []
248
+ if shutil.which("hermes") or Path.home().joinpath(".hermes").exists():
249
+ harnesses.append("hermes")
250
+ return harnesses
251
+
252
+
253
+ def _check_nodejs_stability() -> tuple[bool, str, str]:
254
+ """Check if Node.js is installed system-wide (stable) or via version manager (unstable).
255
+
256
+ Returns:
257
+ (is_stable, version, path) - is_stable is True if installed system-wide
258
+ """
259
+ node_path = shutil.which("node")
260
+
261
+ # Try via login shell if not found
262
+ if not node_path:
263
+ try:
264
+ result = subprocess.run(
265
+ ["bash", "-l", "-c", "which node"],
266
+ capture_output=True, text=True, timeout=5
267
+ )
268
+ if result.returncode == 0:
269
+ node_path = result.stdout.strip()
270
+ except Exception:
271
+ pass
272
+
273
+ if not node_path:
274
+ return False, "not found", ""
275
+
276
+ # Get version
277
+ try:
278
+ result = subprocess.run(
279
+ [node_path, "--version"],
280
+ capture_output=True, text=True, timeout=5
281
+ )
282
+ version = result.stdout.strip().lstrip("v") if result.returncode == 0 else "unknown"
283
+ except Exception:
284
+ version = "unknown"
285
+
286
+ # Check for version manager paths (unstable for production)
287
+ unstable_patterns = ["/.nvm/", "/.volta/", "/.asdf/", "/.local/share/nvm/", "/.fnm/", "/.local/share/mise/", "/.local/share/rtx/"]
288
+ for pattern in unstable_patterns:
289
+ if pattern in node_path:
290
+ return False, version, node_path
291
+
292
+ return True, version, node_path
293
+
294
+ def _wait_for_neo4j(timeout: int = 60) -> bool:
295
+ """Wait for Neo4j to become reachable on bolt port."""
296
+ import time
297
+ start = time.time()
298
+ while time.time() - start < timeout:
299
+ ok, _ = _check_neo4j()
300
+ if ok:
301
+ return True
302
+ time.sleep(2)
303
+ return False
304
+
305
+ def _wait_for_docker(timeout: int = 120) -> bool:
306
+ """Wait for Docker daemon to become available."""
307
+ import time
308
+ start = time.time()
309
+ while time.time() - start < timeout:
310
+ status, _ = _check_docker()
311
+ if status == DockerStatus.RUNNING:
312
+ return True
313
+ time.sleep(3)
314
+ return False
315
+
316
+
317
+ # ── Docker Desktop Detection (macOS) ───────────────────────────────────────────
318
+
319
+
320
+ def _detect_docker_desktop() -> dict:
321
+ """Detect Docker Desktop installation on macOS.
322
+
323
+ Returns dict with:
324
+ - installed: bool
325
+ - path: Optional[Path]
326
+ - version: Optional[str]
327
+ - running: bool
328
+ """
329
+ if platform.system() != "Darwin":
330
+ return {"installed": False}
331
+
332
+ result = {
333
+ "installed": False,
334
+ "path": None,
335
+ "version": None,
336
+ "running": False,
337
+ }
338
+
339
+ app_path = Path("/Applications/Docker.app")
340
+ if not app_path.exists():
341
+ return result
342
+
343
+ result["installed"] = True
344
+ result["path"] = app_path
345
+
346
+ # Check if running (look for Docker.app process)
347
+ try:
348
+ check = subprocess.run(
349
+ ["pgrep", "-x", "Docker"],
350
+ capture_output=True,
351
+ timeout=2,
352
+ )
353
+ result["running"] = check.returncode == 0
354
+ except Exception:
355
+ pass
356
+
357
+ # Get version from Info.plist
358
+ try:
359
+ plist_path = app_path / "Contents" / "Info.plist"
360
+ if plist_path.exists():
361
+ pl_result = subprocess.run(
362
+ ["plutil", "-extract", "CFBundleShortVersionString", "raw", str(plist_path)],
363
+ capture_output=True,
364
+ text=True,
365
+ timeout=2,
366
+ )
367
+ if pl_result.returncode == 0:
368
+ result["version"] = pl_result.stdout.strip()
369
+ except Exception:
370
+ pass
371
+
372
+ return result
373
+
374
+
375
+ def _start_docker_desktop() -> bool:
376
+ """Start Docker Desktop on macOS.
377
+
378
+ Returns:
379
+ True if started successfully
380
+ """
381
+ if platform.system() != "Darwin":
382
+ return False
383
+
384
+ app_path = Path("/Applications/Docker.app")
385
+ if not app_path.exists():
386
+ return False
387
+
388
+ try:
389
+ subprocess.run(
390
+ ["open", "-a", "Docker"],
391
+ capture_output=True,
392
+ timeout=5,
393
+ )
394
+ return True
395
+ except Exception:
396
+ return False
397
+
398
+
399
+ # ── Linux-Specific Handling ────────────────────────────────────────────────────
400
+
401
+
402
+ def _check_docker_group() -> bool:
403
+ """Check if current user is in docker group (Linux only).
404
+
405
+ Returns:
406
+ True if user has docker group membership
407
+ """
408
+ if platform.system() != "Linux":
409
+ return True # Not applicable
410
+
411
+ try:
412
+ import grp
413
+ import os
414
+
415
+ docker_group = grp.getgrnam("docker")
416
+ user_groups = os.getgroups()
417
+
418
+ return docker_group.gr_gid in user_groups
419
+ except KeyError:
420
+ # docker group doesn't exist
421
+ return False
422
+ except Exception:
423
+ return False
424
+
425
+
426
+ def _suggest_docker_group_fix() -> str:
427
+ """Return instructions for adding user to docker group."""
428
+ user = os.environ.get("USER", "your_username")
429
+ return f"""
430
+ Permission denied — your user is not in the 'docker' group.
431
+
432
+ To fix, run:
433
+ sudo usermod -aG docker {user}
434
+
435
+ Then log out and log back in for changes to take effect.
436
+ Or run: newgrp docker
437
+ """
438
+
439
+
440
+ def _start_docker_daemon_linux() -> bool:
441
+ """Attempt to start Docker daemon on Linux.
442
+
443
+ Returns:
444
+ True if daemon started successfully
445
+ """
446
+ # Try systemd first
447
+ if shutil.which("systemctl"):
448
+ try:
449
+ result = subprocess.run(
450
+ ["sudo", "systemctl", "start", "docker"],
451
+ capture_output=True,
452
+ timeout=10,
453
+ )
454
+ if result.returncode == 0:
455
+ return True
456
+ except Exception:
457
+ pass
458
+
459
+ # Try service command
460
+ if shutil.which("service"):
461
+ try:
462
+ result = subprocess.run(
463
+ ["sudo", "service", "docker", "start"],
464
+ capture_output=True,
465
+ timeout=10,
466
+ )
467
+ if result.returncode == 0:
468
+ return True
469
+ except Exception:
470
+ pass
471
+
472
+ return False
473
+
474
+
475
+ def _check_alternative_runtimes() -> tuple[bool, str] | None:
476
+ """Check for alternative Docker-compatible runtimes.
477
+
478
+ Returns:
479
+ (runtime_name, start_command) or None
480
+ """
481
+ system = platform.system().lower()
482
+
483
+ # macOS alternatives
484
+ if system == "darwin":
485
+ if shutil.which("colima"):
486
+ return ("Colima", "colima start")
487
+ if Path("/Applications/OrbStack.app").exists():
488
+ return ("OrbStack", "open -a OrbStack")
489
+
490
+ # Linux alternatives
491
+ if system == "linux":
492
+ if shutil.which("podman"):
493
+ return ("Podman", "podman system service")
494
+
495
+ return None
496
+
497
+
498
+ # ── Docker install ──────────────────────────────────────────────────────────
499
+
500
+
501
+ def _install_docker() -> bool:
502
+ """Attempt to install Docker based on platform. Returns True if installed."""
503
+ system = platform.system().lower()
504
+
505
+ if system == "linux":
506
+ # Check if Docker is actually already installed but just not detected
507
+ docker_path = shutil.which("docker")
508
+ if docker_path:
509
+ print(f" ⚠️ Docker binary found at {docker_path} but not working.")
510
+ print(" This might be a configuration issue.")
511
+ return False
512
+
513
+ print(" Installing Docker via get.docker.com...")
514
+ print(" (This requires sudo — you may be prompted for your password)")
515
+ try:
516
+ result = subprocess.run(
517
+ ["bash", "-c", "curl -fsSL https://get.docker.com | sudo sh"],
518
+ capture_output=True, text=True, timeout=300,
519
+ )
520
+ if result.returncode == 0:
521
+ print(" ✅ Docker installed")
522
+ # Add current user to docker group
523
+ user = os.environ.get("USER", "")
524
+ if user:
525
+ subprocess.run(
526
+ ["sudo", "usermod", "-aG", "docker", user],
527
+ capture_output=True, timeout=10,
528
+ )
529
+ print(" ✅ Added user to docker group (log out/in or run 'newgrp docker')")
530
+ return True
531
+ else:
532
+ # Check if it failed because Docker is already installed
533
+ combined = (result.stderr + result.stdout).lower()
534
+ if "already installed" in combined or "already the newest" in combined:
535
+ print(" ✅ Docker already installed")
536
+ return True
537
+ print(f" ❌ Docker install failed: {result.stderr[:200]}")
538
+ return False
539
+ except Exception as exc:
540
+ print(f" ❌ Docker install failed: {exc}")
541
+ return False
542
+
543
+ elif system == "darwin":
544
+ # Check if Docker Desktop is already installed
545
+ if Path("/Applications/Docker.app").exists():
546
+ print(" ✅ Docker Desktop already installed")
547
+ print(" Starting Docker Desktop...")
548
+ _start_docker_desktop()
549
+ return True
550
+
551
+ # Check if OrbStack is installed
552
+ if Path("/Applications/OrbStack.app").exists():
553
+ print(" ✅ OrbStack already installed")
554
+ print(" Starting OrbStack...")
555
+ subprocess.run(["open", "-a", "OrbStack"], capture_output=True)
556
+ return True
557
+
558
+ # Check for Homebrew
559
+ if shutil.which("brew"):
560
+ print(" Installing Docker Desktop via Homebrew...")
561
+ try:
562
+ result = subprocess.run(
563
+ ["brew", "install", "--cask", "docker"],
564
+ capture_output=True, text=True, timeout=300,
565
+ )
566
+ if result.returncode == 0:
567
+ print(" ✅ Docker Desktop installed")
568
+ print(" ⚠️ Please open Docker Desktop from Applications and wait for it to start.")
569
+ print(" Then re-run 'apsimo init' to continue.")
570
+ return True
571
+
572
+ # Check if it failed because already installed
573
+ combined = (result.stdout + result.stderr).lower()
574
+ if "already installed" in combined:
575
+ print(" ✅ Docker Desktop already installed")
576
+ print(" Opening Docker Desktop...")
577
+ _start_docker_desktop()
578
+ return True
579
+
580
+ print(f" ❌ brew install failed: {result.stderr[:200]}")
581
+ return False
582
+ except Exception as exc:
583
+ print(f" ❌ Docker install failed: {exc}")
584
+ return False
585
+ else:
586
+ print(" Homebrew not found. Install Docker Desktop manually:")
587
+ print(" https://docs.docker.com/desktop/install/mac-install/")
588
+ return False
589
+ else:
590
+ print(f" Unsupported platform: {system}")
591
+ print(" Install Docker manually: https://docs.docker.com/get-docker/")
592
+ return False
593
+
594
+
595
+ # ── Docker Setup Handler ───────────────────────────────────────────────────────
596
+
597
+
598
+ def _handle_docker_setup(non_interactive: bool = False) -> bool:
599
+ """Handle Docker detection and setup.
600
+
601
+ Returns:
602
+ True if Docker is available and running
603
+ """
604
+ system = platform.system().lower()
605
+
606
+ # Check Docker status
607
+ status, message = _check_docker()
608
+
609
+ print(_bold("Step 4: Docker"))
610
+ print()
611
+
612
+ if status == DockerStatus.RUNNING:
613
+ print(f" ✅ Docker is running")
614
+ print()
615
+ return True
616
+
617
+ elif status == DockerStatus.DESKTOP_INSTALLED_NOT_RUNNING:
618
+ # macOS: Docker Desktop installed but not running
619
+ print(f" Docker Desktop is installed but not running.")
620
+ print()
621
+
622
+ start = _prompt(" Start Docker Desktop now? [Y/n]", "Y", non_interactive)
623
+ if start.lower() in ("y", "yes", ""):
624
+ print(" Starting Docker Desktop...")
625
+ if _start_docker_desktop():
626
+ print(" Waiting for Docker daemon...")
627
+ if _wait_for_docker():
628
+ print(" ✅ Docker is running")
629
+ print()
630
+ return True
631
+ else:
632
+ print(" ⚠️ Docker Desktop started but daemon not ready yet.")
633
+ print(" Wait for Docker to fully start, then re-run 'apsimo init'.")
634
+ return False
635
+ else:
636
+ print(" ❌ Failed to start Docker Desktop")
637
+ print(" Open Docker Desktop from Applications manually.")
638
+ return False
639
+ else:
640
+ print(" Start Docker Desktop manually and re-run 'apsimo init'.")
641
+ return False
642
+
643
+ elif status == DockerStatus.INSTALLED_NOT_RUNNING:
644
+ # Docker installed but daemon not running
645
+ print(f" Docker is installed but the daemon is not running.")
646
+ print()
647
+
648
+ if system == "linux":
649
+ start = _prompt(" Start Docker daemon? [Y/n]", "Y", non_interactive)
650
+ if start.lower() in ("y", "yes", ""):
651
+ print(" Starting Docker daemon (may require sudo)...")
652
+ if _start_docker_daemon_linux():
653
+ print(" Waiting for Docker daemon...")
654
+ if _wait_for_docker():
655
+ print(" ✅ Docker is running")
656
+ print()
657
+ return True
658
+ else:
659
+ print(" ⚠️ Daemon started but not ready yet.")
660
+ return False
661
+ else:
662
+ print(" ❌ Failed to start Docker daemon")
663
+ print(" Try: sudo systemctl start docker")
664
+ return False
665
+ else:
666
+ print(" Start the Docker daemon and re-run 'apsimo init'.")
667
+ return False
668
+
669
+ elif status == DockerStatus.PERMISSION_DENIED:
670
+ # Linux: user not in docker group
671
+ print(_suggest_docker_group_fix())
672
+ return False
673
+
674
+ elif status == DockerStatus.COLIMA_INSTALLED:
675
+ # macOS: Colima installed
676
+ print(" Colima is installed but not running.")
677
+ print()
678
+ start = _prompt(" Start Colima now? [Y/n]", "Y", non_interactive)
679
+ if start.lower() in ("y", "yes", ""):
680
+ print(" Starting Colima...")
681
+ try:
682
+ subprocess.run(["colima", "start"], check=True, timeout=60)
683
+ if _wait_for_docker():
684
+ print(" ✅ Colima is running")
685
+ print()
686
+ return True
687
+ except subprocess.CalledProcessError as e:
688
+ print(f" ❌ Failed to start Colima: {e}")
689
+ return False
690
+ except Exception as e:
691
+ print(f" ❌ Failed to start Colima: {e}")
692
+ return False
693
+ else:
694
+ print(" Run 'colima start' and re-run 'apsimo init'.")
695
+ return False
696
+
697
+ elif status == DockerStatus.ORBSTACK_INSTALLED:
698
+ # macOS: OrbStack installed
699
+ print(" OrbStack is installed but not running.")
700
+ print()
701
+ start = _prompt(" Start OrbStack now? [Y/n]", "Y", non_interactive)
702
+ if start.lower() in ("y", "yes", ""):
703
+ print(" Starting OrbStack...")
704
+ try:
705
+ subprocess.run(["open", "-a", "OrbStack"], check=True, timeout=5)
706
+ if _wait_for_docker():
707
+ print(" ✅ OrbStack is running")
708
+ print()
709
+ return True
710
+ except Exception as e:
711
+ print(f" ❌ Failed to start OrbStack: {e}")
712
+ return False
713
+ else:
714
+ print(" Open OrbStack and re-run 'apsimo init'.")
715
+ return False
716
+
717
+ elif status == DockerStatus.PODMAN_INSTALLED:
718
+ # Linux: Podman installed
719
+ print(" Podman is installed (Docker-compatible).")
720
+ print(" Apsimo can use Podman's Docker socket compatibility.")
721
+ print()
722
+
723
+ # Check if podman socket is running
724
+ socket_path = Path.home() / ".local" / "share" / "containers" / "podman" / "machine" / "cni" / "podman.sock"
725
+ if socket_path.exists():
726
+ print(" ✅ Podman socket available")
727
+ return True
728
+
729
+ start = _prompt(" Start Podman socket? [Y/n]", "Y", non_interactive)
730
+ if start.lower() in ("y", "yes", ""):
731
+ print(" Starting Podman socket...")
732
+ try:
733
+ subprocess.run(["podman", "system", "service", "--time=0"], check=True, timeout=10)
734
+ return True
735
+ except Exception as e:
736
+ print(f" ❌ Failed to start Podman socket: {e}")
737
+ return False
738
+ return False
739
+
740
+ elif status == DockerStatus.NOT_INSTALLED:
741
+ # Docker not installed
742
+ print(" Docker is required for Neo4j (graph memory).")
743
+ print()
744
+
745
+ # Check for alternatives first
746
+ alt_runtime = _check_alternative_runtimes()
747
+ if alt_runtime:
748
+ name, cmd = alt_runtime
749
+ print(f" Alternative runtime detected: {name}")
750
+ print(f" Run '{cmd}' to start, then re-run 'apsimo init'.")
751
+ return False
752
+
753
+ install = _prompt(" Install Docker now? [Y/n]", "Y", non_interactive)
754
+ if install.lower() in ("y", "yes", ""):
755
+ if _install_docker():
756
+ print(" Waiting for Docker daemon...")
757
+ if _wait_for_docker():
758
+ print(" ✅ Docker is running")
759
+ print()
760
+ return True
761
+ else:
762
+ print(" ⚠️ Docker installed but daemon not reachable yet.")
763
+ print(" Start Docker and re-run 'apsimo init'.")
764
+ else:
765
+ print()
766
+ print(" Install Docker manually: https://docs.docker.com/get-docker/")
767
+ else:
768
+ print(" Skipping Docker — Neo4j will not be available.")
769
+ return False
770
+
771
+ else:
772
+ # Error or unknown status
773
+ print(f" ⚠️ Docker check failed: {message}")
774
+ print()
775
+ print(" Ensure Docker is installed and running, then re-run 'apsimo init'.")
776
+ return False
777
+
778
+
779
+ # ── Neo4j start ─────────────────────────────────────────────────────────────
780
+
781
+ def _start_neo4j_docker(neo4j_password: str) -> bool:
782
+ """Start Neo4j via docker run. Returns True on success."""
783
+ # Check if neo4j-colony container already exists
784
+ try:
785
+ result = subprocess.run(
786
+ ["docker", "ps", "-a", "--filter", "name=neo4j-colony", "--format", "{{.Names}}"],
787
+ capture_output=True, text=True, timeout=10
788
+ )
789
+ if "neo4j-colony" in result.stdout:
790
+ # Container exists, start it
791
+ subprocess.run(["docker", "start", "neo4j-colony"], capture_output=True, timeout=10)
792
+ return True
793
+ except Exception:
794
+ pass
795
+
796
+ # Create data directory
797
+ neo4j_data = Path.home() / ".colony" / "neo4j-data"
798
+ neo4j_data.mkdir(parents=True, exist_ok=True)
799
+
800
+ # Run Neo4j container. The credential travels via the process env
801
+ # (`-e NEO4J_AUTH` with no value makes docker read it from there) so
802
+ # the password never appears in argv where `ps` exposes it.
803
+ try:
804
+ cmd = [
805
+ "docker", "run", "-d",
806
+ "--name", "neo4j-colony",
807
+ "-p", "7474:7474",
808
+ "-p", "7687:7687",
809
+ "-e", "NEO4J_AUTH",
810
+ "-v", f"{neo4j_data}:/data",
811
+ "neo4j:5.15"
812
+ ]
813
+ env = {**os.environ, "NEO4J_AUTH": f"neo4j/{neo4j_password}"}
814
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=60, env=env)
815
+ if result.returncode != 0:
816
+ print(f" ⚠️ docker run failed: {result.stderr.strip()}")
817
+ return False
818
+ return True
819
+ except Exception as exc:
820
+ print(f" ⚠️ docker run failed: {exc}")
821
+ return False
822
+
823
+
824
+ def _setup_mcp_harnesses(harnesses: list[str], api_key: str, sidecar_url: str, non_interactive: bool = False) -> dict[str, bool]:
825
+ """Configure MCP for multiple coding harnesses.
826
+
827
+ Returns:
828
+ Dict mapping harness name to success status
829
+ """
830
+ results = {}
831
+ for harness in harnesses:
832
+ results[harness] = _setup_mcp_harness(harness, api_key, sidecar_url, non_interactive)
833
+ return results
834
+
835
+
836
+ def _setup_mcp_harness(harness: str, api_key: str, sidecar_url: str, non_interactive: bool = False) -> bool:
837
+ """Configure MCP for a single coding harness."""
838
+ try:
839
+ from apsimo.mcp.config import add_to_harness
840
+
841
+ # Get contact_id from environment or default
842
+ contact_id = os.environ.get("COLONY_MCP_CONTACT_ID", os.environ.get("USER", "user"))
843
+
844
+ result = add_to_harness(harness, contact_id, dry_run=False, sidecar_url=sidecar_url)
845
+
846
+ if result is not None:
847
+ print(f" ✅ {harness} MCP configured")
848
+
849
+ # Write skill
850
+ from apsimo.harness_integration import write_colony_skill
851
+ if write_colony_skill(harness):
852
+ print(f" ✅ {harness} diagnostic skill installed")
853
+
854
+ return True
855
+ else:
856
+ print(f" ⚪ {harness} already configured")
857
+ return True
858
+ except Exception as exc:
859
+ print(f" ⚠️ MCP config failed for {harness}: {exc}")
860
+ return False
861
+
862
+
863
+ def _resolve_hermes_home(hermes_home: str | Path | None = None) -> Path:
864
+ """Resolve the selected Hermes home without guessing profile layouts."""
865
+ selected = hermes_home or os.environ.get("HERMES_HOME")
866
+ return Path(selected or Path.home() / ".hermes").expanduser().resolve()
867
+
868
+
869
+ def _read_hermes_config(config_path: Path) -> tuple[bytes | None, dict]:
870
+ """Parse configuration without exposing YAML values in error messages."""
871
+ import yaml
872
+
873
+ class UniqueKeyLoader(yaml.SafeLoader):
874
+ def construct_mapping(self, node, deep=False):
875
+ # Reject ambiguous duplicate/merge keys rather than silently losing
876
+ # configuration. Only the chosen host's configuration is inspected.
877
+ self.flatten_mapping(node)
878
+ result = {}
879
+ for key_node, value_node in node.value:
880
+ key = self.construct_object(key_node, deep=deep)
881
+ if key in result:
882
+ raise ValueError("Duplicate YAML mapping key")
883
+ result[key] = self.construct_object(value_node, deep=deep)
884
+ return result
885
+
886
+ if config_path.is_symlink():
887
+ raise ValueError("Symlinked Hermes config requires a reviewed migration")
888
+ original = config_path.read_bytes() if config_path.exists() else None
889
+ try:
890
+ config = yaml.load(original.decode("utf-8"), Loader=UniqueKeyLoader) if original else {}
891
+ except (UnicodeError, yaml.YAMLError, TypeError, ValueError):
892
+ raise ValueError("Hermes config is invalid or has ambiguous YAML keys") from None
893
+ if config is None:
894
+ config = {}
895
+ if not isinstance(config, dict):
896
+ raise ValueError("Hermes config must be a YAML mapping")
897
+ return original, config
898
+
899
+
900
+ HERMES_MEMORY_SPILL_CHARS = 65536
901
+
902
+
903
+ def _align_hermes_memory_spill(config: dict) -> bool:
904
+ """Keep native head/tail previews from cutting Apsimo's evidence envelope.
905
+
906
+ Selected memory is capped at 24k characters; 64 KiB leaves headroom for its
907
+ citations and other default context sections. This is a
908
+ transfer allowance, not a larger retrieval budget. Custom larger context
909
+ producers must align their own limits. Existing disabled/larger spill
910
+ settings remain the operator's choice.
911
+ """
912
+ hooks = config.get('hooks', {})
913
+ if not isinstance(hooks, dict):
914
+ raise ValueError('Hermes hooks settings must be a YAML mapping')
915
+ spill = hooks.get('output_spill', {})
916
+ if not isinstance(spill, dict):
917
+ raise ValueError('Hermes hook output_spill settings must be a YAML mapping')
918
+ if spill.get('enabled') is False:
919
+ return False
920
+ try:
921
+ maximum = int(spill.get('max_chars', 10000))
922
+ except (ValueError, TypeError, OverflowError):
923
+ maximum = 10000
924
+ if maximum >= HERMES_MEMORY_SPILL_CHARS:
925
+ return False
926
+ config['hooks'] = {**hooks, 'output_spill': {**spill, 'max_chars': HERMES_MEMORY_SPILL_CHARS}}
927
+ return True
928
+
929
+
930
+ def _canonicalize_hermes_binding(config: dict) -> bool:
931
+ """Change selection names only; private settings and protocol values survive."""
932
+ import copy
933
+ from .util.instance import plugin_settings
934
+ before = copy.deepcopy(config)
935
+ if 'plugins' in config:
936
+ if not isinstance(config['plugins'], dict):
937
+ raise ValueError('Hermes plugins settings must be a mapping')
938
+ plugins = config['plugins'] = dict(config['plugins'])
939
+ selected = plugin_settings(config)
940
+ if 'colony' in plugins or 'apsimo' in plugins:
941
+ plugins.pop('colony', None)
942
+ plugins['apsimo'] = dict(selected)
943
+ for key in ('enabled', 'disabled'):
944
+ if key in plugins:
945
+ if not isinstance(plugins[key], list):
946
+ raise ValueError('Hermes plugins enabled/disabled must be lists')
947
+ plugins[key] = list(dict.fromkeys(
948
+ {'colony':'apsimo', 'colony-memory':'apsimo-memory'}.get(name, name)
949
+ for name in plugins[key]))
950
+ if 'entries' in plugins:
951
+ entries = plugins['entries'] = dict(plugins['entries'])
952
+ for old, new in (('colony','apsimo'), ('colony-memory','apsimo-memory')):
953
+ if old in entries:
954
+ if new in entries and entries[new] != entries[old]:
955
+ raise ValueError('Conflicting old and new plugin entry settings')
956
+ entries[new] = entries.pop(old)
957
+ memory = config.get('memory')
958
+ if isinstance(memory, dict) and memory.get('provider') in ('colony', 'colony-memory', 'apsimo'):
959
+ config['memory'] = {**memory, 'provider':'apsimo-memory'}
960
+ def names(values):
961
+ if not isinstance(values, list):
962
+ raise ValueError('Hermes toolsets must be lists')
963
+ def name(value):
964
+ if not isinstance(value, str):
965
+ raise ValueError('Hermes toolset names must be strings')
966
+ prefix = '-' if value.startswith('-') else ''
967
+ raw = value[len(prefix):]
968
+ return prefix + {'colony':'apsimo', 'colony_local_work':'apsimo_local_work',
969
+ 'colony_review':'apsimo_review'}.get(raw, raw)
970
+ return list(dict.fromkeys(name(value) for value in values))
971
+ if 'toolsets' in config:
972
+ config['toolsets'] = names(config['toolsets'])
973
+ if 'platform_toolsets' in config:
974
+ config['platform_toolsets'] = {key:names(value) for key,value in config['platform_toolsets'].items()}
975
+ if isinstance(config.get('agent'), dict) and 'disabled_toolsets' in config['agent']:
976
+ config['agent'] = {**config['agent'], 'disabled_toolsets':names(config['agent']['disabled_toolsets'])}
977
+ return config != before
978
+
979
+
980
+ def _prepare_hermes_config(
981
+ config_path: Path, sidecar_url: str, contact_id: str,
982
+ ) -> tuple[bytes | None, bytes]:
983
+ """Prepare a narrow semantic update; preserve existing secrets and identity.
984
+
985
+ PyYAML preserves values, not comments/formatting. The original bytes are
986
+ retained in a private backup when an actual configuration change is made.
987
+ This prepares configuration only: it does not qualify or activate Hermes.
988
+ """
989
+ import copy
990
+ import json
991
+ from urllib.parse import urlsplit
992
+ import yaml
993
+
994
+ try:
995
+ url = urlsplit(sidecar_url)
996
+ port = url.port # Access validates numeric syntax and the 0..65535 range.
997
+ valid_url = (url.scheme in {"http", "https"} and url.hostname
998
+ and not url.username and not url.password
999
+ and not url.query and not url.fragment
1000
+ and (port is None or port > 0))
1001
+ except ValueError:
1002
+ valid_url = False
1003
+ if not valid_url:
1004
+ raise ValueError("Sidecar URL must be HTTP(S), with a valid port and no embedded credentials or query parameters")
1005
+
1006
+ original, config = _read_hermes_config(config_path)
1007
+ before = copy.deepcopy(config)
1008
+ _canonicalize_hermes_binding(config)
1009
+
1010
+ def mapping(parent: dict, key: str) -> dict:
1011
+ if key not in parent:
1012
+ parent[key] = {}
1013
+ if not isinstance(parent[key], dict):
1014
+ raise ValueError("Hermes memory/plugin settings must be YAML mappings")
1015
+ parent[key] = dict(parent[key]) # Do not mutate unrelated YAML alias users.
1016
+ return parent[key]
1017
+
1018
+ memory = mapping(config, "memory")
1019
+ provider = memory.get("provider")
1020
+ if provider not in (None, "", "colony", "colony-memory", "apsimo", "apsimo-memory"):
1021
+ raise ValueError("Another memory provider is configured; migrate it explicitly before staging Apsimo")
1022
+ memory_config = mapping(memory, "config")
1023
+ plugins = mapping(config, "plugins")
1024
+ plugin_config = mapping(plugins, "apsimo")
1025
+ native_path = config_path.with_name("apsimo-memory.json")
1026
+ legacy_path = config_path.with_name("colony-memory.json")
1027
+ if native_path.exists() and legacy_path.exists() and native_path.read_bytes() != legacy_path.read_bytes():
1028
+ raise ValueError("Conflicting old and new native memory settings")
1029
+ if not native_path.exists():
1030
+ native_path = legacy_path
1031
+ try:
1032
+ native_config = json.loads(native_path.read_text()) if native_path.exists() else {}
1033
+ except (OSError, ValueError):
1034
+ raise ValueError("Native Apsimo memory configuration is invalid") from None
1035
+ if not isinstance(native_config, dict):
1036
+ raise ValueError("Native Apsimo memory configuration must be an object")
1037
+
1038
+ # Do not redirect an existing private instance or replace its contact just
1039
+ # because the init wizard supplies defaults for a fresh installation.
1040
+ for settings in (memory_config, plugin_config, native_config):
1041
+ if settings.get("url") not in (None, "", sidecar_url):
1042
+ raise ValueError("Existing Apsimo endpoint differs; use a reviewed instance migration")
1043
+ if contact_id and settings.get("contact_id") not in (None, "", contact_id):
1044
+ raise ValueError("Existing Apsimo contact differs; preserve its identity or migrate explicitly")
1045
+ bindings = [plugin_config.get("owner_contact_id"), native_config.get("contact_id"),
1046
+ memory_config.get("contact_id"), plugin_config.get("contact_id")]
1047
+ selected_contact = contact_id or next((value for value in bindings if value), None)
1048
+ if not isinstance(selected_contact, str) or not selected_contact.strip():
1049
+ raise ValueError("Provide --contact-name or retain an existing Apsimo contact binding")
1050
+ if any(value not in (None, "", selected_contact) for value in bindings):
1051
+ raise ValueError("Existing Apsimo contact bindings disagree; reconcile them before staging")
1052
+
1053
+ memory["provider"] = "apsimo-memory"
1054
+ for settings in (memory_config, plugin_config):
1055
+ if settings.get("url") in (None, ""):
1056
+ settings["url"] = sidecar_url
1057
+ if settings.get("api_key") in (None, ""):
1058
+ settings["api_key"] = "${APSIMO_API_KEY}"
1059
+ if settings.get("contact_id") in (None, ""):
1060
+ settings["contact_id"] = selected_contact
1061
+ _align_hermes_memory_spill(config)
1062
+ # Do not install/select a custom context engine, enable the general plugin,
1063
+ # or alter coexistence latches. The supported native spill allowance keeps
1064
+ # the already selected evidence and its source revisions together.
1065
+ if original is not None and config == before:
1066
+ return original, original
1067
+ return original, yaml.safe_dump(config, sort_keys=False, allow_unicode=True).encode("utf-8")
1068
+
1069
+
1070
+ def _atomic_hermes_config_write(config_path: Path, original: bytes | None, updated: bytes) -> None:
1071
+ """Replace config atomically, preserving the previous bytes privately."""
1072
+ import stat
1073
+ import tempfile
1074
+
1075
+ current = config_path.read_bytes() if config_path.exists() else None
1076
+ if config_path.is_symlink() or current != original:
1077
+ raise ValueError("Hermes config changed during staging; retry after reconciling it")
1078
+ if current == updated:
1079
+ return
1080
+ config_path.parent.mkdir(parents=True, exist_ok=True)
1081
+ mode = stat.S_IMODE(config_path.stat().st_mode) if current is not None else 0o600
1082
+ if original is not None:
1083
+ with tempfile.NamedTemporaryFile(
1084
+ prefix=f".{config_path.name}.colony-backup-", dir=config_path.parent, delete=False,
1085
+ ) as backup:
1086
+ backup.write(original)
1087
+ backup.flush()
1088
+ os.fsync(backup.fileno())
1089
+ temporary = None
1090
+ try:
1091
+ with tempfile.NamedTemporaryFile(
1092
+ prefix=f".{config_path.name}.colony-stage-", dir=config_path.parent, delete=False,
1093
+ ) as staged:
1094
+ temporary = Path(staged.name)
1095
+ os.fchmod(staged.fileno(), mode)
1096
+ staged.write(updated)
1097
+ staged.flush()
1098
+ os.fsync(staged.fileno())
1099
+ if config_path.is_symlink() or (config_path.read_bytes() if config_path.exists() else None) != original:
1100
+ raise ValueError("Hermes config changed during staging; retry after reconciling it")
1101
+ os.replace(temporary, config_path)
1102
+ finally:
1103
+ if temporary is not None:
1104
+ temporary.unlink(missing_ok=True)
1105
+
1106
+
1107
+ def _write_hermes_config(config_path: Path, api_key: str, sidecar_url: str, contact_id: str) -> None:
1108
+ # Retain the existing call signature; raw credentials never enter config.
1109
+ original, updated = _prepare_hermes_config(config_path, sidecar_url, contact_id)
1110
+ _atomic_hermes_config_write(config_path, original, updated)
1111
+
1112
+
1113
+ def _hermes_plugin_files(colony_repo: Path, hermes_home: Path) -> list[tuple[bytes, Path]]:
1114
+ """The existing source-checkout layout, with every required file checked."""
1115
+ files = []
1116
+ if any((hermes_home/'plugins'/name).exists() for name in ('colony', 'colony-memory')):
1117
+ raise ValueError('Legacy directory adapters require explicit managed refresh before source staging')
1118
+ plugin_modules = tuple(path.name for path in sorted(
1119
+ (colony_repo / "plugins/hermes-plugin").glob("*.py")) if path.name != "__init__.py")
1120
+ for source, target, names in (
1121
+ ("plugins/apsimo-memory", "plugins/apsimo-memory",
1122
+ ("__init__.py", "provider.py", "cli.py", "plugin.yaml", "SKILL.md")),
1123
+ ("plugins/hermes-plugin", "plugins/apsimo",
1124
+ ("__init__.py", *plugin_modules, "plugin.yaml")),
1125
+ ("plugins/hermes-plugin/apsimo_hostworker", "plugins/apsimo/apsimo_hostworker", ("__init__.py",)),
1126
+ ("hostworker/apsimo_hostworker", "plugins/apsimo/apsimo_hostworker", ("catalog.py", "contract.py")),
1127
+ ):
1128
+ files.extend((colony_repo / source / name, hermes_home / target / name) for name in names)
1129
+ prepared = []
1130
+ for source, target in files:
1131
+ if not source.is_file():
1132
+ raise ValueError("Required source-checkout plugin resources are missing; packaged attachment is not supported yet")
1133
+ content = source.read_bytes()
1134
+ for parent in (target, *target.parents):
1135
+ if parent == hermes_home:
1136
+ break
1137
+ if parent.is_symlink():
1138
+ raise ValueError("Symlinked plugin destinations require a reviewed upgrade")
1139
+ if parent.exists() and parent != target and not parent.is_dir():
1140
+ raise ValueError("A plugin destination parent is not a directory")
1141
+ if target.exists() and (not target.is_file() or target.read_bytes() != content):
1142
+ raise ValueError("Existing plugin files differ; use a reviewed upgrade instead of overwriting them")
1143
+ prepared.append((content, target))
1144
+ return prepared
1145
+
1146
+
1147
+ def _setup_hermes_plugin(
1148
+ api_key: str, sidecar_url: str, non_interactive: bool = False,
1149
+ contact_id: str = "", *, hermes_home: str | Path | None = None,
1150
+ ) -> bool:
1151
+ """Stage source-checkout plugins/config in one explicitly selected home.
1152
+
1153
+ This is not runtime activation or private-agent creation. No live probes,
1154
+ restarts, custom compressor, global launchd add-ons or credentials are
1155
+ installed. Existing different plugin files require an explicit upgrade.
1156
+ """
1157
+ import tempfile
1158
+
1159
+ try:
1160
+ selected_home = _resolve_hermes_home(hermes_home)
1161
+ if selected_home.exists() and not selected_home.is_dir():
1162
+ raise ValueError("Selected Hermes home is not a directory")
1163
+ config_path = selected_home / "config.yaml"
1164
+ original, updated = _prepare_hermes_config(config_path, sidecar_url, contact_id)
1165
+ colony_repo = Path(__file__).resolve().parents[2]
1166
+ files = _hermes_plugin_files(colony_repo, selected_home)
1167
+ # All configuration, resources and destinations are checked before the
1168
+ # first mkdir/copy. New files are linked into place atomically; existing
1169
+ # files are never overwritten by this staging-only path.
1170
+ for content, target in files:
1171
+ if target.exists():
1172
+ if not target.is_file() or target.is_symlink() or target.read_bytes() != content:
1173
+ raise ValueError("Plugin destination changed during staging; reconcile it before retrying")
1174
+ continue
1175
+ target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
1176
+ with tempfile.NamedTemporaryFile(dir=target.parent, delete=False) as staged:
1177
+ temporary = Path(staged.name)
1178
+ try:
1179
+ staged.write(content)
1180
+ staged.flush()
1181
+ os.fchmod(staged.fileno(), 0o644)
1182
+ os.fsync(staged.fileno())
1183
+ os.link(temporary, target)
1184
+ finally:
1185
+ temporary.unlink(missing_ok=True)
1186
+ _atomic_hermes_config_write(config_path, original, updated)
1187
+ except (OSError, ValueError) as exc:
1188
+ # Parser errors and URL/contact conflicts above deliberately omit
1189
+ # configuration values, which can contain private information.
1190
+ detail = str(exc) if isinstance(exc, ValueError) else type(exc).__name__
1191
+ print(f" Hermes staging failed: {detail}")
1192
+ print(" Hermes was not restarted. Inspect any staged files before retrying.")
1193
+ return False
1194
+
1195
+ print(f" Apsimo plugin files and config staged in {selected_home}")
1196
+ print(" Staging only: Hermes activation and live memory behaviour were not verified.")
1197
+ print(" Existing settings and secret references were retained; changed YAML formatting may be normalized.")
1198
+ print(" Before activating, qualify the runtime, make APSIMO_API_KEY available through its private environment,")
1199
+ print(" install the apsimo-hermes adapter package in the target Hermes interpreter for durable checkpoints,")
1200
+ print(" and configure general-plugin activation/coexistence for that runtime.")
1201
+ print(" No restart, custom compressor, global services or hardware adapters were installed.")
1202
+ return True
1203
+
1204
+
1205
+ def _write_env(env_path: Path, values: dict[str, str]) -> None:
1206
+ lines = [
1207
+ "# Apsimo Sidecar Configuration",
1208
+ "# Generated by 'apsimo init'",
1209
+ "#",
1210
+ "# Apsimo is a sidecar — it gets LLM credentials from its host",
1211
+ "# (Hermes, MCP harnesses, etc.) at runtime via POST /v1/host/configure.",
1212
+ "# You do NOT need to configure LLM keys here.",
1213
+ "",
1214
+ ]
1215
+ for key, val in values.items():
1216
+ lines.append(f"{key}={val}")
1217
+ env_path.write_text("\n".join(lines) + "\n")
1218
+
1219
+
1220
+ def _write_config_yaml(config_path: Path, values: dict[str, str], framework: str) -> None:
1221
+ """Write a YAML config file for easier inspection and editing."""
1222
+ lines = [
1223
+ "# Apsimo Sidecar Configuration",
1224
+ "# Generated by 'apsimo init'",
1225
+ "",
1226
+ f"host: {framework}",
1227
+ "",
1228
+ "sidecar:",
1229
+ f" port: {values.get('COLONY_SIDECAR_PORT', '7777')}",
1230
+ f" bind: {values.get('COLONY_SIDECAR_HOST', '127.0.0.1')}",
1231
+ " auth:",
1232
+ f" enabled: {'true' if values.get('COLONY_API_KEY') else 'false'}",
1233
+ "",
1234
+ "storage:",
1235
+ " type: sqlite",
1236
+ " path: ~/.colony/data/colony.db",
1237
+ "",
1238
+ "neo4j:",
1239
+ f" enabled: {'true' if values.get('NEO4J_PASSWORD') else 'false'}",
1240
+ f" uri: {values.get('NEO4J_URI', 'bolt://localhost:7687')}",
1241
+ " user: neo4j",
1242
+ "",
1243
+ "embedding:",
1244
+ f" provider: {values.get('COLONY_EMBED_PROVIDER', 'cpu')}",
1245
+ f" model: {values.get('COLONY_EMBED_MODEL', '')}",
1246
+ f" dims: {values.get('COLONY_EMBED_DIMS', '384')}",
1247
+ "",
1248
+ ]
1249
+ if values.get('COLONY_RERANKER_MODEL'):
1250
+ lines.append(f"reranker: {values.get('COLONY_RERANKER_MODEL')}")
1251
+ config_path.write_text("\n".join(lines) + "\n")
1252
+
1253
+ def _estimate_model_gb(spec) -> float:
1254
+ """Rough memory estimate for a model based on param count string.
1255
+ FP16 = 2 bytes/param + 20% overhead for tokenizer/buffers.
1256
+ """
1257
+ try:
1258
+ params_str = spec.params.lower().replace("b", "").replace("m", "")
1259
+ if "m" in spec.params.lower():
1260
+ return float(params_str) * 2 / 1024 * 1.2 # MB to GB
1261
+ else:
1262
+ return float(params_str) * 2 * 1.2 # billions * 2 bytes + overhead
1263
+ except Exception:
1264
+ return 2.0 # Safe default
1265
+
1266
+
1267
+ def _load_existing_env(env_path: Path) -> dict[str, str]:
1268
+ if not env_path.exists():
1269
+ return {}
1270
+ from dotenv import dotenv_values
1271
+ # Setup must read the same quoting as the runtime. Keep literal values;
1272
+ # inspecting an existing instance must not substitute process variables.
1273
+ return {key: value for key, value in dotenv_values(env_path, interpolate=False).items()
1274
+ if value is not None}
1275
+
1276
+
1277
+ # ── LLM host config helpers (.colony-llm-config.json) ──────────────────────
1278
+
1279
+ # Providers that speak the OpenAI-compatible API. LiteLLM routes these via
1280
+ # OPENAI_API_BASE, which must point at the ``/v1`` API root.
1281
+ OPENAI_COMPAT_PROVIDERS = frozenset({
1282
+ "zai", "local", "custom", "lmstudio", "vllm", "openai",
1283
+ "openai-compatible", "openai_compatible",
1284
+ })
1285
+
1286
+
1287
+ def normalize_llm_base_url(url: str, provider: str) -> tuple[str, bool]:
1288
+ """Ensure ``baseUrl`` ends with ``/v1`` for OpenAI-compatible providers.
1289
+
1290
+ A bare ``host:port`` base URL silently 404s on chat completions because
1291
+ LiteLLM appends ``/chat/completions`` directly. Returns ``(url, changed)``;
1292
+ non-OpenAI-compatible providers (e.g. ollama, anthropic) pass through.
1293
+ """
1294
+ if not url:
1295
+ return url, False
1296
+ if (provider or "").strip().lower() not in OPENAI_COMPAT_PROVIDERS:
1297
+ return url, False
1298
+ stripped = url.rstrip("/")
1299
+ if stripped.endswith("/v1"):
1300
+ return url, False
1301
+ return stripped + "/v1", True
1302
+
1303
+
1304
+ def ensure_api_key(cfg: dict) -> tuple[dict, bool]:
1305
+ """Never persist an empty ``apiKey`` for OpenAI-compatible providers.
1306
+
1307
+ LiteLLM requires a non-empty api_key even for keyless local servers
1308
+ (vLLM, LM Studio, llama.cpp, ...) — an empty string breaks the auth
1309
+ header. Defaults to ``"local-no-key"``. Returns ``(cfg, changed)``;
1310
+ the input dict is never mutated.
1311
+ """
1312
+ provider = (cfg.get("provider") or "").strip().lower()
1313
+ if provider in OPENAI_COMPAT_PROVIDERS and not (cfg.get("apiKey") or "").strip():
1314
+ fixed = dict(cfg)
1315
+ fixed["apiKey"] = "local-no-key"
1316
+ return fixed, True
1317
+ return cfg, False
1318
+
1319
+
1320
+ def apply_llm_config_fixes(cfg: dict) -> tuple[dict, list[str]]:
1321
+ """Apply both LLM host-config footgun fixes. Returns ``(cfg, notes)``."""
1322
+ notes: list[str] = []
1323
+ fixed = dict(cfg)
1324
+ url, changed = normalize_llm_base_url(fixed.get("baseUrl", ""), fixed.get("provider", ""))
1325
+ if changed:
1326
+ fixed["baseUrl"] = url
1327
+ notes.append(f"baseUrl did not end with /v1 — normalized to {url}")
1328
+ fixed, changed = ensure_api_key(fixed)
1329
+ if changed:
1330
+ notes.append(
1331
+ 'apiKey was empty — set to "local-no-key" '
1332
+ "(LiteLLM requires a non-empty value even for keyless servers)"
1333
+ )
1334
+ return fixed, notes
1335
+
1336
+
1337
+ def write_llm_host_config(path: Path, cfg: dict) -> tuple[dict, list[str]]:
1338
+ """Persist an LLM host config, applying both footgun fixes at the source.
1339
+
1340
+ Every wizard write of ``.colony-llm-config.json`` must go through here.
1341
+ Returns the (possibly fixed) config and the human-readable fix notes.
1342
+ """
1343
+ import json
1344
+ fixed, notes = apply_llm_config_fixes(cfg)
1345
+ path.write_text(json.dumps(fixed, indent=2))
1346
+ return fixed, notes
1347
+
1348
+
1349
+ def repair_persisted_llm_config() -> list[str]:
1350
+ """Quiet variant of :func:`_normalize_persisted_llm_config` for
1351
+ ``colony doctor --fix``: applies the same footgun fixes in place and
1352
+ returns the fix notes (empty when nothing needed changing)."""
1353
+ import json
1354
+ from apsimo import get_state_dir
1355
+
1356
+ config_path = get_state_dir() / ".colony-llm-config.json"
1357
+ if not config_path.exists():
1358
+ return []
1359
+ cfg = json.loads(config_path.read_text())
1360
+ fixed, notes = apply_llm_config_fixes(cfg)
1361
+ if notes:
1362
+ write_llm_host_config(config_path, fixed)
1363
+ return notes
1364
+
1365
+
1366
+ def _normalize_persisted_llm_config() -> None:
1367
+ """Fix footguns in an already-persisted ``.colony-llm-config.json``, if any."""
1368
+ import json
1369
+ try:
1370
+ from apsimo import get_state_dir
1371
+ config_path = get_state_dir() / ".colony-llm-config.json"
1372
+ if not config_path.exists():
1373
+ print(" ⚪ No persisted LLM host config yet (written on first host connect)")
1374
+ return
1375
+ cfg = json.loads(config_path.read_text())
1376
+ fixed, notes = apply_llm_config_fixes(cfg)
1377
+ if notes:
1378
+ write_llm_host_config(config_path, fixed)
1379
+ for note in notes:
1380
+ print(f" ⚠️ {note}")
1381
+ print(f" ✅ LLM host config updated ({config_path})")
1382
+ else:
1383
+ print(f" ✅ LLM host config OK ({config_path})")
1384
+ except Exception as exc:
1385
+ print(f" ⚪ LLM config check skipped: {exc}")
1386
+
1387
+
1388
+ # ── Autonomy & approvals step ───────────────────────────────────────────────
1389
+
1390
+ # Gateways the owner can register handles for in the wizard.
1391
+ OWNER_HANDLE_GATEWAYS = (
1392
+ "whatsapp", "telegram", "imessage", "email", "sms", "signal", "discord", "slack",
1393
+ )
1394
+
1395
+ # Platforms that can act as the home channel for proactive delivery.
1396
+ HOME_CHANNEL_PLATFORMS = ("whatsapp", "telegram", "discord", "slack", "signal")
1397
+
1398
+
1399
+ async def build_owner_contact(
1400
+ store,
1401
+ display_name: str,
1402
+ handles: list[tuple[str, str]] | None = None,
1403
+ *,
1404
+ trust_tier: str = "inner_circle",
1405
+ interaction_allowed: bool = True,
1406
+ import_source: str = "wizard",
1407
+ ) -> str:
1408
+ """Create the owner contact (plus handles) and return its contact_id.
1409
+
1410
+ The first handle becomes primary. A handle already owned by another
1411
+ contact is skipped rather than failing the owner record — identity
1412
+ fail-closed needs the owner cid to exist either way.
1413
+ """
1414
+ contact = await store.create(
1415
+ display_name=display_name,
1416
+ trust_tier=trust_tier,
1417
+ interaction_allowed=interaction_allowed,
1418
+ import_source=import_source,
1419
+ )
1420
+ for i, (gateway, address) in enumerate(handles or []):
1421
+ try:
1422
+ await store.add_handle(
1423
+ contact.contact_id,
1424
+ gateway=gateway,
1425
+ address=address,
1426
+ is_primary=(i == 0),
1427
+ source="wizard",
1428
+ verified=True,
1429
+ )
1430
+ except ValueError:
1431
+ # Address already assigned to another contact — skip it.
1432
+ continue
1433
+ return contact.contact_id
1434
+
1435
+
1436
+ def collect_owner_handles(ask=None, non_interactive: bool = False) -> list[tuple[str, str]]:
1437
+ """Interactive gateway/address loop for the owner's handles."""
1438
+ handles: list[tuple[str, str]] = []
1439
+ if non_interactive:
1440
+ return handles
1441
+ print(" Add ways to reach you (leave gateway blank to finish).")
1442
+ print(f" Gateways: {', '.join(OWNER_HANDLE_GATEWAYS)}")
1443
+ while True:
1444
+ gateway = _prompt(" Gateway (blank to finish)", "", non_interactive, ask=ask).strip().lower()
1445
+ if not gateway:
1446
+ break
1447
+ if gateway not in OWNER_HANDLE_GATEWAYS:
1448
+ print(f" Invalid gateway. Choose one of: {', '.join(OWNER_HANDLE_GATEWAYS)}")
1449
+ continue
1450
+ address = _prompt(f" {gateway} address", "", non_interactive, ask=ask).strip()
1451
+ if not address:
1452
+ print(" No address given — skipped.")
1453
+ continue
1454
+ handles.append((gateway, address))
1455
+ return handles
1456
+
1457
+
1458
+ def _run_owner_identity(
1459
+ values: dict[str, str],
1460
+ existing: dict[str, str],
1461
+ non_interactive: bool = False,
1462
+ ask=None,
1463
+ ) -> dict[str, str]:
1464
+ """Owner identity sub-step. Returns env updates ({} on failure)."""
1465
+ from apsimo.contacts.config import ContactsConfig
1466
+ from apsimo.contacts.store import SQLiteContactStore
1467
+
1468
+ print(" Apsimo fails closed on identity: owner-exclusion filters, check-ins")
1469
+ print(" and outreach authorization all need to know who you are. Without an")
1470
+ print(" owner contact, autonomous outreach stays disabled.")
1471
+ print()
1472
+
1473
+ # Make sure ContactsConfig.from_env() resolves to the wizard's DB path.
1474
+ if values.get("COLONY_CONTACTS_DB") and not os.environ.get("COLONY_CONTACTS_DB"):
1475
+ os.environ["COLONY_CONTACTS_DB"] = values["COLONY_CONTACTS_DB"]
1476
+ config = ContactsConfig.from_env()
1477
+
1478
+ async def _get(cid: str):
1479
+ store = SQLiteContactStore(config=config)
1480
+ await store.connect()
1481
+ try:
1482
+ return await store.get(cid)
1483
+ finally:
1484
+ await store.close()
1485
+
1486
+ # Idempotent re-run: keep an owner that still resolves.
1487
+ prior_cid = (
1488
+ existing.get("COLONY_OWNER_CONTACT_ID")
1489
+ or os.environ.get("COLONY_OWNER_CONTACT_ID", "")
1490
+ ).strip()
1491
+ if prior_cid:
1492
+ try:
1493
+ prior = asyncio.run(_get(prior_cid))
1494
+ except Exception:
1495
+ prior = None
1496
+ if prior is not None:
1497
+ print(f" Owner contact already configured: {prior.display_name} ({prior_cid})")
1498
+ keep = _prompt(" Keep this owner? [Y/n]", "Y", non_interactive, ask=ask)
1499
+ if keep.strip().lower() in ("y", "yes", ""):
1500
+ print(f" ✅ Owner contact kept: {prior_cid}")
1501
+ return {"COLONY_OWNER_CONTACT_ID": prior_cid}
1502
+ else:
1503
+ print(f" ⚠️ COLONY_OWNER_CONTACT_ID={prior_cid} no longer resolves — recreating.")
1504
+
1505
+ display_name = _prompt(
1506
+ " Your name (what Apsimo calls its owner)",
1507
+ os.environ.get("USER", ""), non_interactive, ask=ask,
1508
+ ).strip() or os.environ.get("USER", "owner")
1509
+ handles = collect_owner_handles(ask=ask, non_interactive=non_interactive)
1510
+
1511
+ async def _create() -> str:
1512
+ store = SQLiteContactStore(config=config)
1513
+ await store.connect()
1514
+ try:
1515
+ return await build_owner_contact(store, display_name, handles)
1516
+ finally:
1517
+ await store.close()
1518
+
1519
+ try:
1520
+ cid = asyncio.run(_create())
1521
+ except Exception as exc:
1522
+ print(f" ⚠️ Could not create owner contact: {exc}")
1523
+ print(" Set COLONY_OWNER_CONTACT_ID in .env manually once resolved.")
1524
+ return {}
1525
+ print(f" ✅ Owner contact created: {display_name} ({cid})")
1526
+ if handles:
1527
+ print(f" Handles: {', '.join(f'{g}:{a}' for g, a in handles)}")
1528
+ return {"COLONY_OWNER_CONTACT_ID": cid}
1529
+
1530
+
1531
+ def collect_autonomy_env(
1532
+ existing: dict[str, str],
1533
+ ask=None,
1534
+ non_interactive: bool = False,
1535
+ ) -> dict[str, str]:
1536
+ """Collect approval-policy, autonomy-gate and home-channel env values.
1537
+
1538
+ Pure prompt-assembly: takes the existing env (so re-runs default to the
1539
+ current values) and an injectable ``ask`` callable; returns the env
1540
+ updates the wizard persists.
1541
+ """
1542
+ updates: dict[str, str] = {}
1543
+
1544
+ # ── Approval policy ──
1545
+ print(" How much should Apsimo check in before acting?")
1546
+ print(" [1] strict — every mutating or outbound agent action waits")
1547
+ print(" for your approval (default, safest)")
1548
+ print(" [2] graduated — only destructive actions and outreach to people")
1549
+ print(" you haven't authorized wait for you; everything")
1550
+ print(" else runs with an audit trail")
1551
+ current_policy = (existing.get("COLONY_APPROVAL_POLICY", "") or "").strip().lower()
1552
+ default_choice = "2" if current_policy == "graduated" else "1"
1553
+ choice = _prompt(" Approval policy [1/2]", default_choice, non_interactive, ask=ask).strip().lower()
1554
+ policy = "graduated" if choice in ("2", "graduated") else "strict"
1555
+ updates["COLONY_APPROVAL_POLICY"] = policy
1556
+ print(f" ✅ Approval policy: {policy}")
1557
+ print()
1558
+
1559
+ # ── Autonomy gates ──
1560
+ def _gate(env_key: str, question: str, blurb: str) -> None:
1561
+ enabled = (existing.get(env_key, "") or "").strip().lower() == "true"
1562
+ default = "Y" if enabled else "N"
1563
+ hint = "[Y/n]" if enabled else "[y/N]"
1564
+ print(f" {blurb}")
1565
+ answer = _prompt(f" {question} {hint}", default, non_interactive, ask=ask)
1566
+ on = answer.strip().lower() in ("y", "yes", "true")
1567
+ updates[env_key] = "true" if on else "false"
1568
+ print(f" {'✅ Enabled' if on else '⚪ Disabled'}")
1569
+ print()
1570
+
1571
+ _gate(
1572
+ "COLONY_ENABLE_SKILL_SYNTHESIS",
1573
+ "Enable skill synthesis?",
1574
+ "Successful novel agent work is captured as draft skills you approve.",
1575
+ )
1576
+
1577
+ # ── Autonomy posture (one preset drives all fourteen mode flags) ──
1578
+ print(" How autonomous should this Apsimo be?")
1579
+ print(" [1] passive — observe and remember only; nothing thinks or acts")
1580
+ print(" [2] calibration — everything runs in shadow and EARNS live autonomy")
1581
+ print(" through its real track record (recommended)")
1582
+ print(" [3] autonomous — thinking, projects, beliefs, workers, connectors")
1583
+ print(" run live, still bounded by approvals, boundaries,")
1584
+ print(" and the immutable floor")
1585
+ print(" (Individual COLONY_*_MODE env vars always override the preset.)")
1586
+ current_preset = (existing.get("COLONY_AUTONOMY_PRESET", "") or "").strip().lower()
1587
+ preset_default = {"passive": "1", "calibration": "2", "autonomous": "3"}.get(
1588
+ current_preset, "2")
1589
+ preset_choice = _prompt(
1590
+ " Autonomy preset [1/2/3]", preset_default, non_interactive, ask=ask,
1591
+ ).strip().lower()
1592
+ preset = {"1": "passive", "2": "calibration", "3": "autonomous",
1593
+ "passive": "passive", "calibration": "calibration",
1594
+ "autonomous": "autonomous"}.get(preset_choice, "calibration")
1595
+ updates["COLONY_AUTONOMY_PRESET"] = preset
1596
+ print(f" ✅ Autonomy preset: {preset}")
1597
+ if preset == "calibration":
1598
+ print(" Shadow runs build a track record; the trust engine graduates")
1599
+ print(" each capability class to ask-first and then act-first, and")
1600
+ print(" notifies you on every graduation.")
1601
+ print()
1602
+
1603
+ # ── Relationship intelligence (who the agent talks to) ──
1604
+ print(" Relationship intelligence: the agent unifies each person across")
1605
+ print(" channels and profiles who it talks to. Unknown senders become")
1606
+ print(" shadow contacts so history accrues from first contact.")
1607
+ existing_shadow = (existing.get("COLONY_IDENTITY_SHADOW_CONTACTS", "")
1608
+ or "").strip().lower()
1609
+ default_shadow = "N" if existing_shadow == "false" else "Y"
1610
+ hint = "[Y/n]" if default_shadow == "Y" else "[y/N]"
1611
+ ans = _prompt(f" Auto-create shadow contacts for unknown senders? {hint}",
1612
+ default_shadow, non_interactive, ask=ask)
1613
+ on = ans.strip().lower() in ("y", "yes", "true", "")
1614
+ updates["COLONY_IDENTITY_SHADOW_CONTACTS"] = "true" if on else "false"
1615
+ print(f" {'✅ Enabled' if on else '⚪ Disabled'}")
1616
+ print()
1617
+
1618
+ # ── Home channel ──
1619
+ print(" Which platform reaches you for proactive updates?")
1620
+ print(f" Options: {', '.join(HOME_CHANNEL_PLATFORMS)}, none")
1621
+ print(" ('none' means initiatives queue for your review but are never pushed)")
1622
+ existing_platform = ""
1623
+ existing_chat = ""
1624
+ for p in HOME_CHANNEL_PLATFORMS:
1625
+ if existing.get(f"{p.upper()}_HOME_CHANNEL"):
1626
+ existing_platform, existing_chat = p, existing[f"{p.upper()}_HOME_CHANNEL"]
1627
+ break
1628
+ while True:
1629
+ platform_choice = _prompt(
1630
+ " Home channel platform", existing_platform or "none", non_interactive, ask=ask
1631
+ ).strip().lower()
1632
+ if platform_choice in HOME_CHANNEL_PLATFORMS or platform_choice in ("", "none"):
1633
+ break
1634
+ print(f" Invalid platform. Choose one of: {', '.join(HOME_CHANNEL_PLATFORMS)}, none")
1635
+ if platform_choice in ("", "none"):
1636
+ print(" ⚪ No home channel — initiatives will queue but never be pushed")
1637
+ else:
1638
+ default_chat = existing_chat if platform_choice == existing_platform else ""
1639
+ chat_id = _prompt(
1640
+ f" {platform_choice} channel/contact id", default_chat, non_interactive, ask=ask
1641
+ ).strip()
1642
+ if chat_id:
1643
+ updates[f"{platform_choice.upper()}_HOME_CHANNEL"] = chat_id
1644
+ print(f" ✅ Home channel: {platform_choice} → {chat_id}")
1645
+ else:
1646
+ print(" ⚪ No channel id — initiatives will queue but never be pushed")
1647
+
1648
+ return updates
1649
+
1650
+
1651
+ def run_autonomy_step(
1652
+ values: dict[str, str],
1653
+ existing: dict[str, str],
1654
+ non_interactive: bool = False,
1655
+ ask=None,
1656
+ ) -> dict[str, str]:
1657
+ """Step 8: Autonomy & approvals.
1658
+
1659
+ Owner identity, approval policy, thinking/synthesis gates, home channel,
1660
+ plus the LLM host-config footgun check. Returns the env updates to merge
1661
+ into the values the wizard persists.
1662
+ """
1663
+ print(_bold("Step 8: Autonomy & approvals"))
1664
+ print()
1665
+
1666
+ updates: dict[str, str] = {}
1667
+ updates.update(_run_owner_identity(values, existing, non_interactive, ask=ask))
1668
+ print()
1669
+ updates.update(collect_autonomy_env({**existing, **values}, ask=ask, non_interactive=non_interactive))
1670
+ print()
1671
+
1672
+ # LLM host config footgun check (baseUrl /v1 + non-empty apiKey).
1673
+ _normalize_persisted_llm_config()
1674
+ return updates
1675
+
1676
+
1677
+ # ── Scheduled agent workers (v0.20.0) ───────────────────────────────────────
1678
+ #
1679
+ # The agent-side workers (queue worker + skills sync) only do their job
1680
+ # when something actually schedules them. The wizard offers to install
1681
+ # crontab entries; the pure helpers below (command/line construction and
1682
+ # crontab merging) are extracted for tests.
1683
+
1684
+ #: The schedulable agent-side workers: console-script name, module path
1685
+ #: (for the `python -m` fallback) and cron schedule.
1686
+ WORKER_SPECS = (
1687
+ {
1688
+ "name": "colony-queue-worker",
1689
+ "module": "apsimo.workers.queue_worker",
1690
+ "schedule": "*/5 * * * *",
1691
+ "blurb": "claims approved agent_action jobs every 5 minutes and hands "
1692
+ "them to your agent (without it, auto-approved jobs sit QUEUED forever)",
1693
+ },
1694
+ {
1695
+ "name": "colony-skills-sync",
1696
+ "module": "apsimo.workers.skills_sync",
1697
+ "schedule": "0 9 * * *",
1698
+ "blurb": "reports your agent's installed skill index to Apsimo once a "
1699
+ "day so it proposes work the agent can actually do",
1700
+ },
1701
+ )
1702
+
1703
+
1704
+ def build_worker_command(name: str, module: str, which=shutil.which, python: str = "") -> str:
1705
+ """Command for one worker: the console script when installed on PATH,
1706
+ else ``<python> -m <module>`` (works from any environment where the
1707
+ package is importable)."""
1708
+ script = which(name)
1709
+ if script:
1710
+ return script
1711
+ return f"{python or sys.executable} -m {module}"
1712
+
1713
+
1714
+ def build_cron_lines(
1715
+ env_file: str,
1716
+ log_dir: str,
1717
+ workdir: str = "",
1718
+ which=shutil.which,
1719
+ python: str = "",
1720
+ ) -> list[str]:
1721
+ """Build the crontab lines for both workers.
1722
+
1723
+ Each line sources the wizard's .env (``set -a`` so every var is
1724
+ exported to the worker), runs from a stable directory, and appends
1725
+ stdout+stderr to ``<log_dir>/cron-<name>.log``.
1726
+ """
1727
+ workdir = workdir or str(Path.home())
1728
+ lines: list[str] = []
1729
+ for spec in WORKER_SPECS:
1730
+ cmd = build_worker_command(spec["name"], spec["module"], which=which, python=python)
1731
+ prefix = f"cd {workdir} && set -a; . {env_file}; set +a;"
1732
+ log = f"{log_dir}/cron-{spec['name']}.log"
1733
+ lines.append(f"{spec['schedule']} {prefix} {cmd} >> {log} 2>&1")
1734
+ return lines
1735
+
1736
+
1737
+ def merge_crontab(existing: str, new_lines: list[str]) -> tuple[str, list[str]]:
1738
+ """Merge worker cron lines into an existing crontab (idempotent).
1739
+
1740
+ A new line is skipped when the existing crontab already references
1741
+ that worker in either form (console-script name or ``-m`` module
1742
+ path), so re-running the wizard never duplicates entries and never
1743
+ clobbers hand-tuned schedules. Existing content is preserved
1744
+ verbatim. Returns ``(merged_text, lines_actually_added)``.
1745
+ """
1746
+ existing = existing or ""
1747
+ added: list[str] = []
1748
+ for line in new_lines:
1749
+ markers = [line.strip()]
1750
+ for spec in WORKER_SPECS:
1751
+ if spec["name"] in line or spec["module"] in line:
1752
+ markers = [spec["name"], spec["module"]]
1753
+ break
1754
+ if any(m in existing for m in markers):
1755
+ continue
1756
+ added.append(line)
1757
+ if not added:
1758
+ return existing, []
1759
+ merged = existing.rstrip("\n")
1760
+ merged = (merged + "\n" if merged else "") + "\n".join(added) + "\n"
1761
+ return merged, added
1762
+
1763
+
1764
+ def install_cron_jobs(lines: list[str], run=subprocess.run) -> list[str]:
1765
+ """Merge ``lines`` into the user crontab via ``crontab -l`` / ``crontab -``.
1766
+
1767
+ Returns the lines actually added ([] when everything was already
1768
+ installed). Raises ``RuntimeError`` when the write fails. ``run`` is
1769
+ injectable for tests.
1770
+ """
1771
+ read = run(["crontab", "-l"], capture_output=True, text=True, timeout=10)
1772
+ # `crontab -l` exits non-zero with "no crontab for <user>" — treat as empty.
1773
+ existing = read.stdout if read.returncode == 0 else ""
1774
+ merged, added = merge_crontab(existing, lines)
1775
+ if not added:
1776
+ return []
1777
+ write = run(["crontab", "-"], input=merged, capture_output=True, text=True, timeout=10)
1778
+ if write.returncode != 0:
1779
+ raise RuntimeError(
1780
+ f"crontab write failed: {(write.stderr or write.stdout or '').strip()}"
1781
+ )
1782
+ return added
1783
+
1784
+
1785
+ def run_workers_step(
1786
+ env_path: Path,
1787
+ non_interactive: bool = False,
1788
+ ask=None,
1789
+ run=subprocess.run,
1790
+ which=shutil.which,
1791
+ ) -> None:
1792
+ """Step 10e: Scheduled agent workers.
1793
+
1794
+ Explains the two cron-driven workers, asks whether the agent lives on
1795
+ this machine, and (on macOS/Linux with crontab available) installs
1796
+ the schedule entries idempotently. Otherwise prints the exact lines
1797
+ for manual installation. Never raises — scheduling is a convenience,
1798
+ not a setup blocker.
1799
+ """
1800
+ print(_bold("Step 10e: Scheduled agent workers"))
1801
+ print()
1802
+ print(" Apsimo needs two small workers scheduled on the agent's machine:")
1803
+ for spec in WORKER_SPECS:
1804
+ print(f" • {spec['name']} — {spec['blurb']}")
1805
+ print()
1806
+
1807
+ # Cron lines are built the same way for install and manual fallback.
1808
+ state_dir = os.environ.get("COLONY_STATE_DIR", "")
1809
+ workdir = str(Path(state_dir).expanduser().parent) if state_dir else str(Path.home())
1810
+ colony_home = Path(os.environ.get("COLONY_HOME", Path.home() / ".colony"))
1811
+ log_dir = colony_home / "logs"
1812
+ lines = build_cron_lines(
1813
+ env_file=str(env_path.expanduser().resolve()),
1814
+ log_dir=str(log_dir),
1815
+ workdir=workdir,
1816
+ which=which,
1817
+ )
1818
+
1819
+ def _print_manual():
1820
+ print(" Add these crontab lines yourself when ready (crontab -e):")
1821
+ for line in lines:
1822
+ print(f" {line}")
1823
+ print()
1824
+
1825
+ answer = _prompt(" Is your agent on this machine? [Y/n]", "Y", non_interactive, ask=ask)
1826
+ if answer.strip().lower() not in ("y", "yes", ""):
1827
+ print(" ⚪ Skipping local schedule install (agent is elsewhere).")
1828
+ _print_manual()
1829
+ return
1830
+
1831
+ system = platform.system()
1832
+ if system not in ("Darwin", "Linux") or not which("crontab"):
1833
+ reason = "crontab not found" if system in ("Darwin", "Linux") else f"unsupported platform ({system})"
1834
+ print(f" ⚪ Cannot install automatically ({reason}).")
1835
+ _print_manual()
1836
+ return
1837
+
1838
+ install = _prompt(" Install the crontab entries now? [Y/n]", "Y", non_interactive, ask=ask)
1839
+ if install.strip().lower() not in ("y", "yes", ""):
1840
+ print(" ⚪ Skipped.")
1841
+ _print_manual()
1842
+ return
1843
+
1844
+ try:
1845
+ log_dir.mkdir(parents=True, exist_ok=True)
1846
+ added = install_cron_jobs(lines, run=run)
1847
+ if added:
1848
+ print(f" ✅ Installed {len(added)} crontab entr{'y' if len(added) == 1 else 'ies'}:")
1849
+ for line in added:
1850
+ print(f" {line}")
1851
+ else:
1852
+ print(" ✅ Crontab entries already installed (nothing to do).")
1853
+ print(f" Logs: {log_dir}/cron-<worker>.log")
1854
+ except Exception as exc:
1855
+ print(f" ⚠️ Crontab install failed: {exc}")
1856
+ _print_manual()
1857
+ print()
1858
+
1859
+
1860
+ # ── Final health check (colony doctor) ──────────────────────────────────────
1861
+
1862
+ def _print_doctor_results(results) -> None:
1863
+ """Print doctor CheckResults in the wizard's own style."""
1864
+ icons = {
1865
+ "ok": "✅", "pass": "✅", "passed": "✅",
1866
+ "warn": "⚠️", "warning": "⚠️",
1867
+ "skip": "⚪", "skipped": "⚪",
1868
+ "fail": "❌", "failed": "❌", "error": "❌",
1869
+ }
1870
+ for check in results or []:
1871
+ name = getattr(check, "name", str(check))
1872
+ status = getattr(check, "status", "")
1873
+ status = str(getattr(status, "value", status)).lower()
1874
+ detail = getattr(check, "detail", "") or ""
1875
+ remedy = getattr(check, "remedy", "") or ""
1876
+ icon = icons.get(status, "⚪")
1877
+ line = f" {icon} {name}"
1878
+ if detail:
1879
+ line += f": {detail}"
1880
+ print(line)
1881
+ if remedy and icon in ("⚠️", "❌"):
1882
+ print(f" ↳ {remedy}")
1883
+
1884
+
1885
+ def _offer_doctor_run(
1886
+ non_interactive: bool = False,
1887
+ ask=None,
1888
+ colony_url: str = "",
1889
+ api_key: str = "",
1890
+ ) -> None:
1891
+ """Final wizard step: offer to run colony doctor's checks.
1892
+
1893
+ The doctor module may be mid-build, so import lazily and degrade to a
1894
+ pointer at the CLI when unavailable. Server checks self-skip when the
1895
+ sidecar isn't running yet.
1896
+ """
1897
+ print(_bold("Step 12: Health check (colony doctor)"))
1898
+ print()
1899
+ try:
1900
+ from apsimo.doctor import run_doctor
1901
+ except Exception:
1902
+ print(" ⚪ Doctor not available yet — run `colony doctor` after starting the sidecar.")
1903
+ print()
1904
+ return
1905
+
1906
+ answer = _prompt(" Run the doctor now? [Y/n]", "Y", non_interactive, ask=ask)
1907
+ if answer.strip().lower() not in ("y", "yes", ""):
1908
+ print(" ⚪ Skipped — run `colony doctor` any time.")
1909
+ print()
1910
+ return
1911
+
1912
+ print(" Running local checks (server checks skip if the sidecar isn't up)...")
1913
+ try:
1914
+ import inspect
1915
+ base_kwargs: dict = {}
1916
+ if colony_url:
1917
+ base_kwargs["colony_url"] = colony_url
1918
+ if api_key:
1919
+ base_kwargs["api_key"] = api_key
1920
+ results = None
1921
+ # Be tolerant of signature drift while doctor.py is mid-build.
1922
+ for kwargs in (base_kwargs, {}):
1923
+ try:
1924
+ results = run_doctor(**kwargs)
1925
+ break
1926
+ except TypeError:
1927
+ continue
1928
+ if inspect.iscoroutine(results):
1929
+ results = asyncio.run(results)
1930
+ results = list(results or [])
1931
+ if results:
1932
+ _print_doctor_results(results)
1933
+ else:
1934
+ print(" ⚪ Doctor returned no results")
1935
+ except Exception as exc:
1936
+ print(f" ⚪ Doctor run failed: {exc}")
1937
+ print(" Run `colony doctor` after starting the sidecar.")
1938
+ print()
1939
+
1940
+
1941
+ # ── Main wizard ─────────────────────────────────────────────────────────────
1942
+
1943
+ def run_init(root_dir: str | None = None, args=None) -> int:
1944
+ """Run the interactive setup wizard. Returns exit code.
1945
+
1946
+ Args:
1947
+ root_dir: Root directory for config files
1948
+ args: Parsed argparse Namespace for non-interactive mode
1949
+ """
1950
+ if (getattr(args, 'preferences_only', False) or getattr(args, 'preview', False)
1951
+ or getattr(args, 'whatsapp_read_receipts', None) is not None):
1952
+ if (getattr(args, 'no_harness', False) or getattr(args, 'mcp_harnesses', None)
1953
+ or getattr(args, 'host_framework', None) not in (None, 'hermes')):
1954
+ print('Hermes profile preferences cannot be combined with another harness setup.')
1955
+ return 1
1956
+ from apsimo.setup_hermes import run
1957
+ return run(root_dir, args)
1958
+ if (not getattr(args, 'no_harness', False) and not getattr(args, 'mcp_harnesses', None)
1959
+ and getattr(args, 'host_framework', None) in (None, 'hermes')):
1960
+ from apsimo.setup_hermes import run
1961
+ return run(root_dir, args)
1962
+
1963
+ base = Path(root_dir) if root_dir else Path(".")
1964
+ env_path = base / ".env"
1965
+ colony_root = Path(__file__).resolve().parents[2] # colony/
1966
+
1967
+ # Non-interactive mode from CLI args
1968
+ non_interactive = getattr(args, 'non_interactive', False) if args else False
1969
+
1970
+ # Create ~/.colony directory for consolidated storage
1971
+ colony_home = Path.home() / ".colony"
1972
+ colony_home.mkdir(parents=True, exist_ok=True)
1973
+ (colony_home / "data").mkdir(parents=True, exist_ok=True)
1974
+
1975
+ print()
1976
+ print(_bold("🔧 Apsimo Sidecar Setup Wizard"))
1977
+ print(_bold("=" * 40))
1978
+ print()
1979
+
1980
+ # ── Step 1: Install dependencies ────────────────────────────────────
1981
+
1982
+ print(_bold("Step 1: Install dependencies"))
1983
+ print()
1984
+ try:
1985
+ result = subprocess.run(
1986
+ [sys.executable, "-m", "pip", "install", "-e", ".[neo4j]", "-q"],
1987
+ capture_output=True, text=True, timeout=120,
1988
+ cwd=str(Path(__file__).resolve().parents[1]),
1989
+ )
1990
+ if result.returncode == 0:
1991
+ print(" ✅ Dependencies installed")
1992
+ else:
1993
+ print(f" ⚠️ pip install had warnings (non-critical)")
1994
+ except Exception as exc:
1995
+ print(f" ⚠️ Could not auto-install dependencies: {exc}")
1996
+ print(" Run manually: pip install -e .[neo4j]")
1997
+
1998
+ print()
1999
+
2000
+ # ── Step 2: Dependency checks ───────────────────────────────────────
2001
+
2002
+ print(_bold("Step 2: Dependency checks"))
2003
+ print()
2004
+
2005
+ py_ok, py_ver = _check_python()
2006
+ print(f" Python: {py_ver} {'✅' if py_ok else '❌ (need 3.11+)'}")
2007
+
2008
+ for dep in ["fastapi", "uvicorn", "pydantic", "neo4j", "litellm"]:
2009
+ try:
2010
+ __import__(dep)
2011
+ print(f" {dep}: ✅")
2012
+ except ImportError:
2013
+ print(f" {dep}: ❌ (missing)")
2014
+
2015
+ docker_status, docker_msg = _check_docker()
2016
+ docker_ok = docker_status == DockerStatus.RUNNING
2017
+ print(f" Docker: {'✅ available' if docker_ok else '⚪ ' + docker_msg}")
2018
+
2019
+ port = 7777
2020
+ port_taken = _check_port(port)
2021
+ print(f" Port {port}: {'⚠️ in use' if port_taken else '✅ available'}")
2022
+
2023
+ if not py_ok:
2024
+ print(_red("\nPython 3.11+ required. Please upgrade and re-run."))
2025
+ return 1
2026
+
2027
+ print()
2028
+
2029
+ # ── Step 3: Harness integration ────────────────────────────────────────
2030
+
2031
+ print(_bold("Step 3: Harness integration"))
2032
+ print()
2033
+
2034
+ # Initialize tracking variables
2035
+ mcp_harnesses = []
2036
+ agent_harness = None
2037
+ contact_id = args.contact_name if (args and args.contact_name) else None
2038
+
2039
+ # Non-interactive mode: use CLI args
2040
+ if non_interactive:
2041
+ # Backward compatibility: map --host-framework to new flags
2042
+ if args and args.host_framework:
2043
+ hf = args.host_framework
2044
+ if hf == "hermes":
2045
+ agent_harness = hf
2046
+ elif hf == "openclaw":
2047
+ print(" ⚠️ OpenClaw support was removed in v0.21.14 — "
2048
+ "running standalone. Use --agent-harness hermes or "
2049
+ "'colony mcp setup'.")
2050
+ elif hf in ("claude-code", "codex", "crush"):
2051
+ mcp_harnesses = [hf]
2052
+ # "standalone" = no harness
2053
+
2054
+ # New flags take precedence
2055
+ if args and getattr(args, 'mcp_harnesses', None):
2056
+ mcp_harnesses = [h.strip() for h in args.mcp_harnesses.split(",")]
2057
+ if args and getattr(args, 'agent_harness', None):
2058
+ agent_harness = args.agent_harness
2059
+ if args and getattr(args, 'no_harness', False):
2060
+ mcp_harnesses = []
2061
+ agent_harness = None
2062
+
2063
+ if mcp_harnesses:
2064
+ print(f" MCP harnesses: {', '.join(mcp_harnesses)} (non-interactive)")
2065
+ if agent_harness:
2066
+ print(f" Agent harness: {agent_harness} (non-interactive)")
2067
+ if not mcp_harnesses and not agent_harness:
2068
+ print(" Running standalone (no harness)")
2069
+ else:
2070
+ # Interactive mode: detect and offer choices
2071
+ print(" Apsimo integrates with coding agents and agent frameworks.")
2072
+ print()
2073
+
2074
+ # Detect coding harnesses
2075
+ coding_detected = _detect_coding_harnesses()
2076
+ if coding_detected:
2077
+ print(" Detected coding harnesses:")
2078
+ for i, h in enumerate(coding_detected, 1):
2079
+ print(f" [{i}] {h}")
2080
+ print()
2081
+
2082
+ choice = _prompt(f" Connect these via MCP? [Y/n]", "Y", non_interactive)
2083
+ if choice.lower() in ("y", "yes", ""):
2084
+ mcp_harnesses = coding_detected.copy()
2085
+
2086
+ # Detect agent harnesses
2087
+ agent_detected = _detect_agent_harnesses()
2088
+ if agent_detected:
2089
+ print()
2090
+ print(" Detected agent harnesses:")
2091
+ for h in agent_detected:
2092
+ print(f" - {h}")
2093
+ print()
2094
+
2095
+ # Offer agent harness setup
2096
+ print(" Configure an agent harness?")
2097
+ print(" [1] Hermes: persistent agent framework (plugins staged")
2098
+ print(" to the selected Hermes home)")
2099
+ print(" [2] Skip — run standalone (coding tools can still connect")
2100
+ print(" via 'colony mcp setup')")
2101
+ print()
2102
+
2103
+ # Loop until valid choice
2104
+ while True:
2105
+ choice = _prompt(" Choice [2]", "2", non_interactive)
2106
+
2107
+ if choice == "1":
2108
+ # Hermes doesn't require Node.js - it works with any Python setup
2109
+ hermes_detected = "hermes" in agent_detected
2110
+ if not hermes_detected:
2111
+ print()
2112
+ print(" ⚠️ Hermes not detected on this system.")
2113
+ print(" Plugin files will be staged in the selected Hermes home")
2114
+ print(" Install Hermes later: "
2115
+ "https://github.com/NousResearch/hermes-agent")
2116
+ print()
2117
+ cont = _prompt(" Continue with Hermes setup? [Y/n]", "Y", non_interactive)
2118
+ if cont.lower() not in ("y", "yes", ""):
2119
+ continue
2120
+ agent_harness = "hermes"
2121
+ break
2122
+ elif choice == "2":
2123
+ break # standalone
2124
+ else:
2125
+ print(" Invalid choice. Enter 1 or 2.")
2126
+ continue
2127
+
2128
+ # Get contact name if any harness is connected
2129
+ if mcp_harnesses or agent_harness:
2130
+ print()
2131
+ contact_id = _prompt(" What should Apsimo call you?", os.environ.get("USER", ""), non_interactive)
2132
+
2133
+ print()
2134
+
2135
+ # ── Step 4: Docker setup ────────────────────────────────────────────
2136
+
2137
+ docker_ok = _handle_docker_setup(non_interactive)
2138
+
2139
+ # ── Step 5: Neo4j setup ─────────────────────────────────────────────
2140
+
2141
+ print(_bold("Step 5: Neo4j graph memory"))
2142
+ print()
2143
+
2144
+ neo4j_ok, neo4j_info = _check_neo4j()
2145
+ neo4j_password = ""
2146
+ neo4j_generated = False
2147
+ neo4j_auth_required = True
2148
+ # One strong random password per install — reused across the paths below
2149
+ # so Docker-start and manual setup both get a unique value by default.
2150
+ _candidate = secrets.token_urlsafe(24)
2151
+
2152
+ if neo4j_ok:
2153
+ print(f" ✅ Neo4j is already running ({neo4j_info})")
2154
+
2155
+ # Check if Neo4j requires auth
2156
+ try:
2157
+ neo4j_auth_required = _check_neo4j_auth()
2158
+ except Exception:
2159
+ neo4j_auth_required = True # Assume auth required if check fails
2160
+
2161
+ if not neo4j_auth_required:
2162
+ print(" Neo4j is running without authentication (auth disabled).")
2163
+ neo4j_password = ""
2164
+ print(" ✅ No password needed — connection will use no auth")
2165
+ else:
2166
+ print(" Enter the password this Neo4j instance was configured with.")
2167
+ neo4j_password = _prompt(" Neo4j password", "", non_interactive)
2168
+ elif docker_ok:
2169
+ print(" Neo4j is required for graph memory (persistent knowledge,")
2170
+ print(" connections, world model).")
2171
+ print()
2172
+ start_neo4j = _prompt(" Start Neo4j via Docker? [Y/n]", "Y", non_interactive)
2173
+ if start_neo4j.lower() in ("y", "yes", ""):
2174
+ neo4j_password = _candidate
2175
+ neo4j_generated = True
2176
+ print(" Starting Neo4j with a newly-generated password...")
2177
+ if _start_neo4j_docker(neo4j_password):
2178
+ print(" Waiting for Neo4j to become ready...")
2179
+ if _wait_for_neo4j():
2180
+ print(f" ✅ Neo4j started (bolt://localhost:7687)")
2181
+ else:
2182
+ print(" ⚠️ Neo4j started but not reachable yet (may need a moment)")
2183
+ else:
2184
+ print(" ❌ Failed to start Neo4j via Docker")
2185
+ neo4j_generated = False
2186
+ neo4j_password = _prompt(
2187
+ " Enter Neo4j password (or leave blank to skip)", "", non_interactive
2188
+ )
2189
+ else:
2190
+ neo4j_password = _prompt(
2191
+ " Enter Neo4j password (blank to skip, or accept generated)",
2192
+ _candidate, non_interactive
2193
+ )
2194
+ neo4j_generated = (neo4j_password == _candidate)
2195
+ else:
2196
+ print(" Neo4j requires Docker, which is not available.")
2197
+ print(" Memory will be degraded until Docker + Neo4j are set up.")
2198
+ neo4j_password = _prompt(
2199
+ " Enter Neo4j password (blank to skip, or accept generated)",
2200
+ _candidate, non_interactive
2201
+ )
2202
+ neo4j_generated = (neo4j_password == _candidate)
2203
+
2204
+ print()
2205
+
2206
+ # ── Step 6: Write .env ──────────────────────────────────────────────
2207
+
2208
+ print(_bold("Step 6: Writing configuration"))
2209
+ print()
2210
+
2211
+ existing = _load_existing_env(env_path)
2212
+ # Auto-detect embedding tier and let user confirm or step down
2213
+ embed_provider = existing.get("COLONY_EMBED_PROVIDER", "")
2214
+ embed_model = existing.get("COLONY_EMBED_MODEL", "")
2215
+ embed_dims = existing.get("COLONY_EMBED_DIMS", "")
2216
+ reranker_model = existing.get("COLONY_RERANKER_MODEL", "")
2217
+ tier = None # Will be set if auto-detection runs
2218
+
2219
+ if not embed_provider or not embed_model:
2220
+ try:
2221
+ from apsimo.vector.scanner import scan
2222
+ from apsimo.vector.tiers import get_tier_by_memory, TIERS
2223
+ hw = scan()
2224
+ detected_tier = get_tier_by_memory(hw.vram_gb, hw.ram_gb)
2225
+ detected_index = TIERS.index(detected_tier)
2226
+
2227
+ # Non-interactive mode: use CLI tier if provided
2228
+ if non_interactive and args and args.tier is not None:
2229
+ tier_index = args.tier
2230
+ tier = TIERS[tier_index]
2231
+ print(f" Selected tier {tier_index}: {tier.label} (non-interactive)")
2232
+ else:
2233
+ print()
2234
+ print(_bold(" Embedding + Reranker Tier Selection"))
2235
+ print()
2236
+ print(f" Detected: {hw.gpu_name} ({hw.vram_gb}GB VRAM, {hw.ram_gb}GB RAM)")
2237
+ print(f" Recommended tier: {detected_tier.label}")
2238
+ print()
2239
+ print(" Available tiers (lower = less memory, faster startup):")
2240
+ print()
2241
+
2242
+ for i, t in enumerate(TIERS):
2243
+ marker = " ← recommended" if i == detected_index else ""
2244
+ emb = f"{t.text_embedder.model_id} ({t.text_embedder.params})" if t.text_embedder else "none"
2245
+ rnk = f"{t.text_reranker.model_id} ({t.text_reranker.params})" if t.text_reranker else "none"
2246
+ # Rough memory estimates (params * 2 bytes for FP16 + overhead)
2247
+ emb_mem = _estimate_model_gb(t.text_embedder) if t.text_embedder else 0
2248
+ rnk_mem = _estimate_model_gb(t.text_reranker) if t.text_reranker else 0
2249
+ total_mem = emb_mem + rnk_mem
2250
+ mem_str = f"~{total_mem:.1f}GB" if total_mem > 0 else "~0.5GB"
2251
+ print(f" [{i}] {t.memory_range}: {t.label}{marker}")
2252
+ print(f" Embedder: {emb} | Reranker: {rnk}")
2253
+ print(f" Estimated memory: {mem_str}")
2254
+ print()
2255
+
2256
+ choice = _prompt(f" Select tier [0-{len(TIERS)-1}]", str(detected_index), non_interactive)
2257
+ try:
2258
+ tier_index = int(choice)
2259
+ tier_index = max(0, min(tier_index, len(TIERS) - 1))
2260
+ except ValueError:
2261
+ tier_index = detected_index
2262
+
2263
+ tier = TIERS[tier_index]
2264
+
2265
+ spec = tier.text_embedder
2266
+ if spec:
2267
+ if hw.gpu_type == "cuda":
2268
+ embed_provider = "cuda"
2269
+ elif hw.gpu_type == "mlx":
2270
+ # Prefer native MLX when the package is available
2271
+ try:
2272
+ import mlx_embeddings # noqa: F401
2273
+ embed_provider = "native_mlx"
2274
+ except ImportError:
2275
+ embed_provider = "mlx"
2276
+ else:
2277
+ embed_provider = "cpu"
2278
+ embed_model = spec.model_id
2279
+ embed_dims = str(spec.dims)
2280
+ if tier.text_reranker:
2281
+ reranker_model = tier.text_reranker.model_id
2282
+ print()
2283
+ print(f" ✅ Selected tier {tier_index}: {tier.label}")
2284
+ print(f" Embedder: {spec.model_id} ({spec.params})")
2285
+ if tier.text_reranker:
2286
+ print(f" Reranker: {tier.text_reranker.model_id} ({tier.text_reranker.params})")
2287
+ else:
2288
+ print(f" Reranker: none")
2289
+ # Note for Apple Silicon users about native MLX
2290
+ if hw.gpu_type == "mlx":
2291
+ print()
2292
+ if embed_provider == "native_mlx":
2293
+ print(" 🎯 Apple Silicon detected: using native MLX framework (fastest path)")
2294
+ else:
2295
+ print(" ⚠️ Apple Silicon detected: using PyTorch MPS fallback.")
2296
+ print(" Install mlx-embeddings for native MLX: pip install mlx-embeddings mlx-lm")
2297
+ embed_mode = _prompt(" Choose: [1] Local model [2] API embeddings [3] Skip embeddings", "1", non_interactive)
2298
+ if embed_mode == "2":
2299
+ embed_provider = "openai_api"
2300
+ reranker_model = ""
2301
+ print(f" ✅ Using API embeddings (inherits host LLM key)")
2302
+ elif embed_mode == "3":
2303
+ embed_provider = "skip"
2304
+ embed_model = ""
2305
+ embed_dims = ""
2306
+ reranker_model = ""
2307
+ print(f" ✅ Embeddings skipped — Apsimo will run without vector search")
2308
+ else:
2309
+ embed_provider = "cpu"
2310
+ embed_model = "sentence-transformers/all-MiniLM-L6-v2"
2311
+ embed_dims = "384"
2312
+ except Exception as exc:
2313
+ print(f" ⚠️ Hardware scan failed: {exc}")
2314
+ print(" Falling back to safe defaults...")
2315
+
2316
+ # Try to detect high-RAM systems even without GPU info
2317
+ try:
2318
+ # Simple RAM check without full scanner
2319
+ system = platform.system().lower()
2320
+ ram_gb = 8 # default
2321
+
2322
+ if system == "linux":
2323
+ try:
2324
+ with open("/proc/meminfo") as f:
2325
+ for line in f:
2326
+ if line.startswith("MemTotal:"):
2327
+ ram_gb = int(line.split()[1]) // (1024 * 1024) + 1
2328
+ break
2329
+ except Exception:
2330
+ pass
2331
+ elif system == "darwin":
2332
+ result = subprocess.run(["sysctl", "-n", "hw.memsize"], capture_output=True, text=True, timeout=5)
2333
+ if result.returncode == 0:
2334
+ ram_gb = int(result.stdout.strip()) // (1024 ** 3)
2335
+
2336
+ # High-RAM system (>= 64GB) deserves better than tier 0
2337
+ if ram_gb >= 128:
2338
+ print(f" Detected high-memory system ({ram_gb}GB RAM)")
2339
+ embed_provider = "cpu"
2340
+ embed_model = "BAAI/bge-large-en-v1.5"
2341
+ embed_dims = "1024"
2342
+ print(" Using BGE-large (CPU) for better quality")
2343
+ elif ram_gb >= 64:
2344
+ print(f" Detected high-memory system ({ram_gb}GB RAM)")
2345
+ embed_provider = "cpu"
2346
+ embed_model = "BAAI/bge-base-en-v1.5"
2347
+ embed_dims = "768"
2348
+ print(" Using BGE-base (CPU) for better quality")
2349
+ else:
2350
+ embed_provider = "cpu"
2351
+ embed_model = "sentence-transformers/all-MiniLM-L6-v2"
2352
+ embed_dims = "384"
2353
+ print(f" Using MiniLM (CPU) — {ram_gb}GB RAM detected")
2354
+ except Exception:
2355
+ # Ultimate fallback
2356
+ embed_provider = "cpu"
2357
+ embed_model = "sentence-transformers/all-MiniLM-L6-v2"
2358
+ embed_dims = "384"
2359
+
2360
+ # Get bind address and port from CLI args or prompt
2361
+ bind_address = existing.get("COLONY_SIDECAR_HOST", "127.0.0.1")
2362
+ sidecar_port = existing.get("COLONY_SIDECAR_PORT", "7777")
2363
+
2364
+ if non_interactive and args:
2365
+ bind_address = args.bind
2366
+ sidecar_port = str(args.port)
2367
+ elif not non_interactive:
2368
+ print(_bold("Step 6a: Bind Address"))
2369
+ print(" The sidecar can bind to localhost (127.0.0.1) or all interfaces (0.0.0.0)")
2370
+ bind_address = _prompt(" Bind address", bind_address, non_interactive)
2371
+ sidecar_port = _prompt(" Port", sidecar_port, non_interactive)
2372
+ print()
2373
+
2374
+ values: dict[str, str] = {
2375
+ "COLONY_SIDECAR_PORT": sidecar_port,
2376
+ "COLONY_SIDECAR_HOST": bind_address,
2377
+ "NEO4J_URI": existing.get("NEO4J_URI", "bolt://localhost:7687"),
2378
+ "NEO4J_USER": existing.get("NEO4J_USER", "neo4j"),
2379
+ "NEO4J_PASSWORD": neo4j_password or existing.get("NEO4J_PASSWORD", ""),
2380
+ "NEO4J_DATABASE": existing.get("NEO4J_DATABASE", "neo4j"),
2381
+ "WORLD_MODEL_BACKEND": existing.get("WORLD_MODEL_BACKEND", "neo4j" if neo4j_password else "sqlite"),
2382
+ "COLONY_API_KEY": existing.get("COLONY_API_KEY", secrets.token_urlsafe(32)),
2383
+ "COLONY_CONTACTS_DB": existing.get("COLONY_CONTACTS_DB", str(colony_home / "data" / "contacts.db")),
2384
+ "COLONY_EMBED_PROVIDER": embed_provider,
2385
+ "COLONY_EMBED_MODEL": embed_model,
2386
+ "COLONY_EMBED_DIMS": embed_dims,
2387
+ "COLONY_RERANKER_MODEL": reranker_model,
2388
+ "LOG_LEVEL": existing.get("LOG_LEVEL", "info"),
2389
+ }
2390
+
2391
+ # Compute sidecar URL for harness setup
2392
+ sidecar_url = f"http://{values['COLONY_SIDECAR_HOST']}:{values['COLONY_SIDECAR_PORT']}"
2393
+
2394
+ _write_env(env_path, values)
2395
+ print(f" ✅ Written to {env_path}")
2396
+
2397
+ # Determine framework name for config
2398
+ if agent_harness:
2399
+ framework = agent_harness
2400
+ elif mcp_harnesses:
2401
+ framework = mcp_harnesses[0] # Primary MCP harness
2402
+ else:
2403
+ framework = "standalone"
2404
+
2405
+ # Also write config.yaml for easier inspection
2406
+ config_yaml_path = colony_home / "config.yaml"
2407
+ _write_config_yaml(config_yaml_path, values, framework)
2408
+ print(f" ✅ Written to {config_yaml_path}")
2409
+ if neo4j_generated:
2410
+ print(
2411
+ " 🔐 Neo4j password was auto-generated and saved to .env — "
2412
+ "rotate it any time by editing NEO4J_PASSWORD and restarting."
2413
+ )
2414
+
2415
+ # Configure agent harness plugin now that we have the API key
2416
+ if agent_harness == "hermes":
2417
+ print()
2418
+ if not _setup_hermes_plugin(
2419
+ values["COLONY_API_KEY"], sidecar_url, non_interactive, contact_id or "",
2420
+ hermes_home=getattr(args, "hermes_home", None),
2421
+ ):
2422
+ return 1
2423
+
2424
+ # Configure MCP harnesses
2425
+ if mcp_harnesses:
2426
+ print()
2427
+ print(" Configuring MCP harnesses...")
2428
+ results = _setup_mcp_harnesses(mcp_harnesses, values["COLONY_API_KEY"], sidecar_url, non_interactive)
2429
+ for harness, success in results.items():
2430
+ if success:
2431
+ print(f" ✅ {harness} configured")
2432
+ else:
2433
+ print(f" ⚠️ {harness} configuration failed")
2434
+
2435
+ print()
2436
+
2437
+ # —— Step 7: Download embedding + reranker models
2438
+ if embed_provider == "skip":
2439
+ print(_bold("Step 7: Embeddings skipped"))
2440
+ print()
2441
+ print(" Apsimo will run without vector search. You can enable embeddings later")
2442
+ print(" by editing COLONY_EMBED_PROVIDER in .env and restarting.")
2443
+ elif embed_provider in ("cuda", "cpu", "mlx", "native_mlx") and embed_model:
2444
+ print(_bold("Step 7: Download embedding model"))
2445
+ print()
2446
+
2447
+ # Check for HF_TOKEN
2448
+ hf_token = os.environ.get("HF_TOKEN")
2449
+ if not hf_token:
2450
+ print(" ⚠️ No HF_TOKEN set — downloads may be slower due to rate limits")
2451
+ print(" Get a token at: https://huggingface.co/settings/tokens")
2452
+ print(" Set it with: echo 'HF_TOKEN=hf_xxx' >> ~/.colony/.env")
2453
+ print()
2454
+
2455
+ print(f" Downloading {embed_model}...")
2456
+ print(f" (This may take a while on first run — models are cached by HuggingFace)")
2457
+ try:
2458
+ if embed_provider == "native_mlx":
2459
+ from mlx_embeddings import load
2460
+ load(embed_model, lazy=False)
2461
+ else:
2462
+ from sentence_transformers import SentenceTransformer
2463
+ SentenceTransformer(embed_model)
2464
+ print(f" ✅ Embedding model downloaded and cached")
2465
+ except Exception as exc:
2466
+ print(f" ⚠️ Model download failed: {exc}")
2467
+ print(f" The model will download on first start instead.")
2468
+
2469
+ if reranker_model:
2470
+ print(f" Downloading reranker {reranker_model}...")
2471
+ try:
2472
+ if embed_provider == "native_mlx":
2473
+ from mlx_lm import load as mlx_lm_load
2474
+ mlx_lm_load(reranker_model, lazy=False)
2475
+ else:
2476
+ from sentence_transformers import CrossEncoder
2477
+ CrossEncoder(reranker_model)
2478
+ print(f" ✅ Reranker model downloaded and cached")
2479
+ except Exception as exc:
2480
+ print(f" ⚠️ Reranker download failed: {exc}")
2481
+ print(f" The model will download on first start instead.")
2482
+ else:
2483
+ print(_bold("Step 7: Embedding model (API mode — no download needed)"))
2484
+ print()
2485
+
2486
+ # ── Step 7b: Multimodal activation ──────────────────────────────────
2487
+ print(_bold("Step 7b: Multimodal embeddings"))
2488
+ print()
2489
+
2490
+ multimodal_enabled = "false"
2491
+ multimodal_model = ""
2492
+ multimodal_reranker = ""
2493
+
2494
+ # Check if the selected tier supports multimodal
2495
+ if embed_provider != "skip":
2496
+ try:
2497
+ from apsimo.vector.tiers import TIERS
2498
+ selected_tier = None
2499
+ for t in TIERS:
2500
+ matches_model = t.text_embedder and t.text_embedder.model_id == embed_model
2501
+ matches_label = tier and t.label == tier.label
2502
+ if matches_model or matches_label:
2503
+ selected_tier = t
2504
+ break
2505
+
2506
+ if selected_tier and selected_tier.multimodal_embedder:
2507
+ mm_model = selected_tier.multimodal_embedder
2508
+ mm_reranker = selected_tier.multimodal_reranker
2509
+ print(f" Your tier supports multimodal embeddings: {mm_model.model_id}")
2510
+ print(f" This enables image search and cross-modal retrieval (text → image, image → text)")
2511
+ if mm_reranker:
2512
+ print(f" Multimodal reranker: {mm_reranker.model_id}")
2513
+ print()
2514
+ print(" Note: Enabling multimodal replaces the text-only embedder with the multimodal model.")
2515
+ print(" Both text and image vectors will be in the same space — cross-modal search works.")
2516
+ print()
2517
+
2518
+ answer = _prompt(" Enable multimodal? [y/N]", "N", non_interactive).lower()
2519
+ if answer in ("y", "yes"):
2520
+ multimodal_enabled = "true"
2521
+ multimodal_model = mm_model.model_id
2522
+ values["COLONY_MULTIMODAL"] = "true"
2523
+ values["COLONY_EMBED_MODEL"] = mm_model.model_id
2524
+ values["COLONY_EMBED_DIMS"] = str(mm_model.dims)
2525
+ if mm_reranker:
2526
+ multimodal_reranker = mm_reranker.model_id
2527
+ values["COLONY_RERANKER_MODEL"] = mm_reranker.model_id
2528
+ values["COLONY_IMAGE_STORAGE"] = "local"
2529
+ values["COLONY_STRIP_EXIF_GPS"] = "true"
2530
+ values["COLONY_IMAGE_SAFETY"] = "basic"
2531
+ print(f" ✅ Multimodal enabled: {mm_model.model_id} ({mm_model.dims}d)")
2532
+ if embed_provider in ("cuda", "cpu", "mlx"):
2533
+ print(f" Downloading multimodal model...")
2534
+ try:
2535
+ from sentence_transformers import SentenceTransformer
2536
+ SentenceTransformer(mm_model.model_id)
2537
+ print(f" ✅ Multimodal model downloaded and cached")
2538
+ except Exception as exc:
2539
+ print(f" ⚠️ Model download failed: {exc}")
2540
+ else:
2541
+ print(" ⚪ Multimodal skipped — text-only embeddings active")
2542
+ else:
2543
+ print(" ⚪ Your tier does not support multimodal embeddings")
2544
+ print(" (Available from Tier 1 / 4GB+ with jina-clip-v2)")
2545
+ except Exception as exc:
2546
+ print(f" ⚪ Multimodal check skipped: {exc}")
2547
+
2548
+ print()
2549
+
2550
+ # ── Step 8: Database setup ──────────────────────────────────────────
2551
+
2552
+ print(_bold("Step 7: Database setup"))
2553
+ print()
2554
+
2555
+ contacts_db = base / values.get("COLONY_CONTACTS_DB", "colony-contacts.db")
2556
+ try:
2557
+ from apsimo.contacts.store import SQLiteContactStore, ContactsConfig
2558
+ SQLiteContactStore(config=ContactsConfig(sqlite_path=str(contacts_db)))
2559
+ print(f" ✅ Contacts DB initialized ({contacts_db})")
2560
+ except Exception as exc:
2561
+ print(f" ⚠️ Contacts DB init failed: {exc}")
2562
+
2563
+ try:
2564
+ from apsimo.goals.store import GoalStore
2565
+ goals_db = base / "colony-goals.db"
2566
+ GoalStore(db_path=str(goals_db))
2567
+ print(f" ✅ Goals DB initialized ({goals_db})")
2568
+ except Exception as exc:
2569
+ print(f" ⚠️ Goals DB init failed: {exc}")
2570
+
2571
+ if neo4j_password:
2572
+ try:
2573
+ from apsimo.intelligence.graph.client import ColonyGraph, GraphConfig
2574
+ from pydantic import SecretStr
2575
+
2576
+ async def _test_neo4j():
2577
+ from neo4j import AsyncGraphDatabase
2578
+ driver = AsyncGraphDatabase.driver(
2579
+ values["NEO4J_URI"],
2580
+ auth=(values["NEO4J_USER"], neo4j_password),
2581
+ )
2582
+ try:
2583
+ async with driver.session(database=values.get("NEO4J_DATABASE", "neo4j")) as session:
2584
+ await session.run("RETURN 1")
2585
+ print(f" ✅ Neo4j connected ({values['NEO4J_URI']})")
2586
+ finally:
2587
+ await driver.close()
2588
+
2589
+ asyncio.run(_test_neo4j())
2590
+ except Exception as exc:
2591
+ print(f" ⚠️ Neo4j connection failed: {exc}")
2592
+ else:
2593
+ print(" ⚪ Neo4j skipped (no password — memory will be degraded)")
2594
+
2595
+ print()
2596
+
2597
+ # ── Step 8: Autonomy & approvals ────────────────────────────────────
2598
+
2599
+ # ContactsConfig.from_env() must resolve to the DB the wizard just set up.
2600
+ os.environ["COLONY_CONTACTS_DB"] = str(contacts_db)
2601
+ autonomy_updates = run_autonomy_step(values, existing, non_interactive)
2602
+ if autonomy_updates:
2603
+ values.update(autonomy_updates)
2604
+ _write_env(env_path, values)
2605
+ print(f" ✅ Updated {env_path}")
2606
+ # Make the new settings visible to everything the wizard starts next.
2607
+ os.environ.update(autonomy_updates)
2608
+
2609
+ print()
2610
+
2611
+ # ── Step 10: Start sidecar + verify ─────────────────────────────────
2612
+
2613
+ print(_bold("Step 10: Start sidecar and verify"))
2614
+ print()
2615
+
2616
+ start_now = _prompt(" Start the Apsimo sidecar now? [Y/n]", "Y", non_interactive)
2617
+ sidecar_started = False
2618
+
2619
+ if start_now.lower() in ("y", "yes", ""):
2620
+ print(" Starting Apsimo sidecar...")
2621
+ # Use 'apsimo start -d' which handles port conflicts, PID tracking, etc.
2622
+ sidecar_result = subprocess.run(
2623
+ [sys.executable, "-m", "apsimo", "start",
2624
+ "--host", values["COLONY_SIDECAR_HOST"],
2625
+ "--port", values["COLONY_SIDECAR_PORT"],
2626
+ "--detach", "--force"],
2627
+ capture_output=True, text=True, timeout=30,
2628
+ cwd=str(base),
2629
+ env={**os.environ, **values},
2630
+ )
2631
+ # Check if it started
2632
+ sidecar_url = f"http://{values['COLONY_SIDECAR_HOST']}:{values['COLONY_SIDECAR_PORT']}"
2633
+ for attempt in range(15):
2634
+ time.sleep(1)
2635
+ try:
2636
+ import httpx
2637
+ r = httpx.get(f"{sidecar_url}/v1/host/health", timeout=2)
2638
+ if r.status_code == 200:
2639
+ sidecar_started = True
2640
+ caps = r.json().get("capabilities", [])
2641
+ print(f" ✅ Sidecar running — {len(caps)} capabilities")
2642
+
2643
+ break
2644
+ except Exception:
2645
+ pass
2646
+
2647
+ if not sidecar_started:
2648
+ print(" ⚠️ Sidecar didn't respond within 15s")
2649
+ print(" It may still be starting. Run 'colony status' to check.")
2650
+ else:
2651
+ print(" ⚪ Skipping sidecar start")
2652
+
2653
+ # ── Step 10b: Verify LLM credentials ────────────────────────────────
2654
+
2655
+ # ── Step 10c: Restart gateway ───────────────────────────────────────
2656
+
2657
+ # Hermes activation is deliberately separate from source-checkout staging.
2658
+
2659
+ # ── Step 10d: Run colony doctor ─────────────────────────────────────
2660
+
2661
+ if sidecar_started:
2662
+ print()
2663
+ print(" Running health check ('colony doctor')...")
2664
+ try:
2665
+ env_with_key = {**os.environ, "COLONY_URL": sidecar_url, "COLONY_API_KEY": values["COLONY_API_KEY"]}
2666
+ doc_result = subprocess.run(
2667
+ [sys.executable, "-m", "apsimo", "doctor", "--url", sidecar_url],
2668
+ capture_output=True, text=True, timeout=30,
2669
+ cwd=str(base),
2670
+ env=env_with_key,
2671
+ )
2672
+ # Print doctor output
2673
+ if doc_result.stdout:
2674
+ for line in doc_result.stdout.strip().splitlines():
2675
+ print(f" {line}")
2676
+ if doc_result.returncode == 0:
2677
+ print(" ✅ All subsystems healthy")
2678
+ else:
2679
+ print(_yellow(" ⚠️ Some subsystem checks failed — see above"))
2680
+ except Exception as exc:
2681
+ print(f" ⚪ Doctor check skipped: {exc}")
2682
+ print(" Run manually: COLONY_API_KEY=<key> colony doctor")
2683
+
2684
+ # Verify data flow — create test commitment and check context assembly
2685
+ print()
2686
+ print(" Verifying data flow (context assembly integration)...")
2687
+ try:
2688
+ import httpx
2689
+ # Create a test commitment
2690
+ r = httpx.post(
2691
+ f"{sidecar_url}/v1/host/commitments",
2692
+ headers={"Authorization": f"Bearer {values['COLONY_API_KEY']}"},
2693
+ json={"person_id": "setup-test", "description": "Setup verification commitment"},
2694
+ timeout=5,
2695
+ )
2696
+ if r.status_code in (200, 201):
2697
+ # Assemble context and check commitments appear
2698
+ r = httpx.post(
2699
+ f"{sidecar_url}/v1/host/context/assemble",
2700
+ headers={"Authorization": f"Bearer {values['COLONY_API_KEY']}"},
2701
+ json={
2702
+ "identity": {"host_id": "setup"},
2703
+ "context": {"session_id": "setup", "contact_id": "setup-test"},
2704
+ "incoming_message": {"role": "user", "content": "test"},
2705
+ },
2706
+ timeout=10,
2707
+ )
2708
+ if r.status_code == 200:
2709
+ sections = r.json().get("sections", [])
2710
+ section_ids = [s["id"] for s in sections]
2711
+ if "colony-commitments" in section_ids:
2712
+ print(" ✅ Data flow verified — commitments appear in context assembly")
2713
+ else:
2714
+ print(_yellow(" ⚠️ Commitments not appearing in context assembly"))
2715
+ print(f" Sections returned: {section_ids}")
2716
+ else:
2717
+ print(_yellow(f" ⚠️ Context assembly returned {r.status_code}"))
2718
+ else:
2719
+ print(_yellow(f" ⚠️ Could not create test commitment ({r.status_code})"))
2720
+ except Exception as exc:
2721
+ print(f" ⚪ Data flow verification skipped: {exc}")
2722
+
2723
+ # ── Step 10e: Scheduled agent workers ───────────────────────────────
2724
+
2725
+ print()
2726
+ run_workers_step(env_path, non_interactive)
2727
+
2728
+ # ── Step 11: Summary ─────────────────────────────────────────────────
2729
+
2730
+ print()
2731
+ print(_bold("Step 11: Initialization finished"))
2732
+ print()
2733
+ print(" Configuration has been written; runtime capabilities remain unverified.")
2734
+ print(" Verify memory, tools and governance through observed end-to-end results.")
2735
+ print()
2736
+
2737
+ if not sidecar_started:
2738
+ print(" Start the sidecar:")
2739
+ print(f" {_green('apsimo start')}")
2740
+ print()
2741
+ print(" Then verify:")
2742
+ print(f" {_green('colony status')}")
2743
+ print()
2744
+
2745
+ # Show harness-specific instructions
2746
+ if not agent_harness and not mcp_harnesses:
2747
+ print(" Standalone mode is configured.")
2748
+ print(f" API endpoint: http://{values['COLONY_SIDECAR_HOST']}:{values['COLONY_SIDECAR_PORT']}")
2749
+ print(" API docs: http://localhost:7777/docs")
2750
+ print()
2751
+ print(" To connect a harness later:")
2752
+ print(" colony mcp setup --harness <claude-code|codex|crush|opencode>")
2753
+ print(" apsimo init --agent-harness hermes")
2754
+ print()
2755
+ elif agent_harness == "hermes":
2756
+ selected_home = _resolve_hermes_home(getattr(args, "hermes_home", None))
2757
+ print(f" Hermes plugin files/config are staged in {selected_home}.")
2758
+ print(" Runtime compatibility, credentials and general-plugin coexistence still need qualification.")
2759
+ print(" Hermes was not activated; live recall and tool behaviour remain unverified.")
2760
+ print()
2761
+
2762
+ if not neo4j_password:
2763
+ print(" No new Neo4j password was supplied. Verify graph connectivity separately.")
2764
+ print()
2765
+ elif neo4j_generated:
2766
+ print(_yellow(
2767
+ " ℹ️ Your Neo4j password is a random value in .env. Rotate it "
2768
+ "whenever you like; docker-compose reads it from that file."
2769
+ ))
2770
+ print()
2771
+
2772
+ # MCP harness setup
2773
+ if mcp_harnesses and sidecar_started:
2774
+ print()
2775
+ print(_bold(" Configuring MCP harnesses..."))
2776
+ try:
2777
+ from apsimo.mcp.config import add_to_harness, HARNESS_DEFS
2778
+ for hid in mcp_harnesses:
2779
+ hdef = HARNESS_DEFS.get(hid)
2780
+ if not hdef:
2781
+ continue
2782
+ diff = add_to_harness(hid, contact_id or os.environ.get("USER", "user"))
2783
+ if diff is None:
2784
+ print(f" {hdef['display']}: already configured")
2785
+ else:
2786
+ print(f" {hdef['display']}: configured (source: {hdef['source_tag']})")
2787
+ except ImportError:
2788
+ print(" MCP SDK not installed — run: pip install colonyai[mcp]")
2789
+ except Exception as exc:
2790
+ print(f" MCP setup failed: {exc}")
2791
+ print()
2792
+
2793
+ # E2E validation prompt
2794
+ if sidecar_started and (mcp_harnesses or agent_harness == "hermes"):
2795
+ print()
2796
+ if agent_harness == "hermes":
2797
+ print(" The sidecar is running; Hermes integration is staged, not verified.")
2798
+ elif mcp_harnesses:
2799
+ print(" The sidecar is running with MCP harnesses configured.")
2800
+ print(" You can validate the full pipeline (sidecar + context + LLM) with:")
2801
+ print(f" {_green('colony validate')}")
2802
+ print(" This sends one test prompt and uses a small amount of LLM credits.")
2803
+ print(f" Until validated, {_yellow('colony status')} and {_yellow('colony doctor')} will show a warning.")
2804
+ print()
2805
+ elif sidecar_started:
2806
+ print()
2807
+ print(f" Validate your setup: {_green('colony validate')}")
2808
+ print()
2809
+
2810
+ # ── Step 12: Health check (colony doctor) ───────────────────────────
2811
+
2812
+ _offer_doctor_run(
2813
+ non_interactive,
2814
+ colony_url=sidecar_url,
2815
+ api_key=values.get("COLONY_API_KEY", ""),
2816
+ )
2817
+
2818
+ return 0