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/cli.py ADDED
@@ -0,0 +1,2688 @@
1
+ """Apsimo CLI, also available through the legacy ``colony`` alias."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ import time
12
+ import uuid
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+
16
+
17
+ def main() -> None:
18
+ parser = argparse.ArgumentParser(
19
+ prog="apsimo",
20
+ description="Apsimo intelligence sidecar server",
21
+ )
22
+ parser.add_argument("--instance", help="Private Apsimo state directory (otherwise use selected Hermes profile binding)")
23
+ sub = parser.add_subparsers(dest="command")
24
+ from apsimo.qualification.cli import add_parser as add_model_parser
25
+ add_model_parser(sub)
26
+
27
+ # --- init ---
28
+ init_p = sub.add_parser("init", help="Initialize Apsimo identity and setup")
29
+ init_p.add_argument("--dir", default=None, help="Private instance directory (default for new instances: selected Hermes home/apsimo)")
30
+ init_p.add_argument("--passphrase", default=None, help="Encrypt Apsimo private key with passphrase (prompted if --encrypt)")
31
+ init_p.add_argument("--encrypt", action="store_true", help="Encrypt Apsimo private key")
32
+ init_p.add_argument("--claim-genesis", action="store_true", help="Claim Genesis status (first Apsimo only)")
33
+ # Non-interactive mode flags
34
+ init_p.add_argument("--non-interactive", "-n", action="store_true", help="Run without prompts (requires all required flags)")
35
+ # Harness configuration (new approach)
36
+ init_p.add_argument("--mcp-harnesses", help="Connect coding harnesses via MCP (comma-separated: claude-code,codex,crush,opencode)")
37
+ init_p.add_argument("--agent-harness", choices=["hermes"], help="Connect agent harness via plugin (OpenClaw support was removed in v0.21.14)")
38
+ init_p.add_argument("--hermes-home", default=None, help="Selected Hermes home; guided setup lists native profiles, noninteractive defaults to HERMES_HOME or ~/.hermes")
39
+ init_p.add_argument("--hermes-python", help="Python interpreter of an existing supported Hermes installation")
40
+ init_p.add_argument("--agent-name", help="Name for a new private identity; existing SOUL is preserved")
41
+ init_p.add_argument("--agent-values", help="Comma-separated guiding values for a new private agent")
42
+ init_p.add_argument("--timezone", help="Named timezone for the private agent's expectations, for example Europe/Paris")
43
+ init_p.add_argument("--quiet-hours", help="Optional local follow-up quiet window, HH:MM-HH:MM; grants no outreach permission")
44
+ init_p.add_argument("--whatsapp-read-receipts", choices=["on", "off"], help="Set this Hermes profile's read receipts for accepted WhatsApp messages; omission preserves its setting")
45
+ init_p.add_argument("--preferences-only", action="store_true", help="Update only an existing Hermes config preference; skip instance and model setup")
46
+ init_p.add_argument("--preview", action="store_true", help="With --preferences-only, show changed preference paths without writing")
47
+ init_p.add_argument("--model-url", help="One local OpenAI-compatible API root")
48
+ init_p.add_argument("--model", help="Model identifier at that endpoint")
49
+ init_p.add_argument("--adapter-wheel", help="Use this canonical apsimo-hermes wheel instead of the installed distribution")
50
+ init_p.add_argument("--refresh-adapter", action="store_true", help="Refresh an existing stopped instance's adapter from the selected package; retain private state")
51
+ init_p.add_argument("--replace-memory-provider", action="store_true", help="Explicitly replace selection of another memory provider; retain its files and a config backup")
52
+ init_p.add_argument("--no-harness", action="store_true", help="Skip all harness setup (standalone mode)")
53
+ # Backward compatibility
54
+ init_p.add_argument("--host-framework", choices=["openclaw", "hermes", "claude-code", "codex", "crush", "standalone"], help="Host framework (deprecated: use --agent-harness or --mcp-harnesses)")
55
+ init_p.add_argument("--contact-name", help="Contact name for this user")
56
+ init_p.add_argument("--bind", default="127.0.0.1", help="Sidecar bind address (0.0.0.0 for all interfaces)")
57
+ init_p.add_argument("--port", type=int, default=7777, help="Sidecar port")
58
+ init_p.add_argument("--tier", type=int, choices=range(0, 8), metavar="TIER", help="Embedding tier (0-7)")
59
+ init_p.add_argument("--neo4j-password", default="", help="Neo4j password (empty to skip)")
60
+ init_p.add_argument("--skip-model-download", action="store_true", help="Defer embedding model download to first start")
61
+ init_p.add_argument("--start", action="store_true", help="Start sidecar after init")
62
+ init_p.add_argument("--local-work", action="store_true", help="Enable explicitly accepted local drafts through the selected Hermes scheduler")
63
+ init_p.add_argument("--native-goals", action="store_true", help="Opt in to native persistent task tools on the existing Hermes profile; its gateway must be running")
64
+ init_p.add_argument("--native-reviews", action="store_true", help="Opt in to bounded read-only operational reviews using the existing planning role")
65
+
66
+ # --- start ---
67
+ start_p = sub.add_parser("start", help="Start the sidecar server")
68
+ start_p.add_argument("--host", default=None, help="Override listen host")
69
+ start_p.add_argument("--port", type=int, default=None, help="Override listen port")
70
+ start_p.add_argument("--detach", "-d", action="store_true", help="Run in background (daemon mode)")
71
+ start_p.add_argument("--force", "-f", action="store_true", help="Kill existing process on port if needed")
72
+
73
+ # --- stop ---
74
+ sub.add_parser("stop", help="Stop the running sidecar")
75
+
76
+ # --- status ---
77
+ sub.add_parser("status", help="Check sidecar health and pipeline status")
78
+
79
+ # --- service ---
80
+ service_p = sub.add_parser("service", help="Manage the selected private instance user service")
81
+ service_sub = service_p.add_subparsers(dest="service_command")
82
+ service_sub.add_parser("install", help="Install and enable autostart (does not start the process)")
83
+ service_sub.add_parser("uninstall", help="Remove the selected service; retain private data")
84
+ service_sub.add_parser("start", help="Start the selected service and wait for HTTP readiness")
85
+ service_sub.add_parser("stop", help="Stop the selected service")
86
+ service_sub.add_parser("restart", help="Restart the selected service")
87
+ service_sub.add_parser("status", help="Show manager state and HTTP readiness")
88
+
89
+ # --- validate ---
90
+ val_p = sub.add_parser("validate", help="Run end-to-end pipeline validation (uses LLM credits)")
91
+ val_p.add_argument("--yes", "-y", action="store_true", help="Skip confirmation prompt")
92
+
93
+ # --- doctor ---
94
+ doctor_p = sub.add_parser("doctor", help="Diagnose configuration and runtime health")
95
+ doctor_p.add_argument("--json", action="store_true", help="Machine-readable JSON output")
96
+ doctor_p.add_argument("--url", default=None, help="Sidecar URL (default: from .env)")
97
+ doctor_p.add_argument("--api-key", default=None, help="API key (default: from .env)")
98
+ doctor_p.add_argument("--timeout", type=float, default=10.0, help="HTTP timeout in seconds (default: 10)")
99
+ doctor_p.add_argument("--fix", action="store_true",
100
+ help="Apply safe automatic fixes (LLM config baseUrl/apiKey), then re-check")
101
+ doctor_p.add_argument("--clean-orphans", action="store_true",
102
+ help="Kill orphaned sidecar processes (pre-v0.19 flag, preserved)")
103
+
104
+ # --- generate-types ---
105
+ sub.add_parser("generate-types", help="Export OpenAPI spec (for TypeScript generation)")
106
+
107
+ # --- seed ---
108
+ seed_p = sub.add_parser("seed", help="Show the retired built-in seeding disposition")
109
+ seed_p.add_argument("--verify", action="store_true", help="Compatibility flag; show the retirement disposition")
110
+ seed_p.add_argument("--force", action="store_true", help="Compatibility flag; built-in seeding remains retired")
111
+
112
+ # --- backfill ---
113
+ backfill_p = sub.add_parser("backfill", help="Re-embed all vectors with current model")
114
+ backfill_p.add_argument("--collection", default=None, help="Specific collection to backfill (default: all)")
115
+ backfill_p.add_argument("--batch-size", type=int, default=64, help="Batch size for embedding")
116
+
117
+ # --- migrate-tier ---
118
+ migrate_p = sub.add_parser("migrate-tier", help="Migrate vectors from old model to current")
119
+ migrate_p.add_argument("--old-model", default=None, help="Old model ID to migrate from (default: all)")
120
+ migrate_p.add_argument("--batch-size", type=int, default=64, help="Batch size for embedding")
121
+ migrate_p.add_argument("--wait-seconds", type=float, default=600,
122
+ help="Maximum time to wait for completion; the server job continues (default: 600)")
123
+
124
+ # --- activate-multimodal ---
125
+ mm_p = sub.add_parser("activate-multimodal", help="Enable multimodal embeddings and rerank")
126
+ mm_p.add_argument("--model", default=None, help="Multimodal model ID (default: auto-detect from tier)")
127
+ mm_p.add_argument("--storage", default="local", choices=["local", "embed_only"], help="Image storage mode")
128
+
129
+ # --- mcp ---
130
+ mcp_p = sub.add_parser("mcp", help="Apsimo MCP server and harness configuration")
131
+ mcp_sub = mcp_p.add_subparsers(dest="mcp_command")
132
+ mcp_run = mcp_sub.add_parser("run", help="Start MCP server (stdio transport)")
133
+ mcp_run.add_argument("--transport", choices=["stdio", "http"], default="stdio", help="Transport mode")
134
+ mcp_run.add_argument("--host", default="127.0.0.1", help="HTTP host (for http transport)")
135
+ mcp_run.add_argument("--port", type=int, default=7778, help="HTTP port (for http transport)")
136
+
137
+ mcp_setup = mcp_sub.add_parser("setup", help="Configure a coding harness to use Apsimo")
138
+ mcp_setup.add_argument("--harness", choices=["claude-code", "codex", "crush", "opencode", "hermes", "all"], default=None, help="Specific harness to configure")
139
+ mcp_setup.add_argument("--contact-id", default=None, help="Your identifier (skip prompt)")
140
+ mcp_setup.add_argument("--dry-run", action="store_true", help="Show changes without writing")
141
+ mcp_setup.add_argument("--print-config", action="store_true", help="Print MCP config snippet (for distributed setups)")
142
+ mcp_setup.add_argument("--sidecar-url", default=None, help="Sidecar URL (for remote Apsimo, e.g., http://192.168.1.100:7777)")
143
+ mcp_setup.add_argument("--mcp-command", dest="mcp_server_command", default=None, help="MCP server command (for standalone mode)")
144
+ mcp_setup.add_argument("--mcp-args", default=None, help="MCP server args (for standalone mode)")
145
+
146
+ mcp_remove = mcp_sub.add_parser("remove", help="Remove Apsimo from a harness config")
147
+ mcp_remove.add_argument("--harness", choices=["claude-code", "codex", "crush", "opencode", "hermes", "all"], default=None, help="Specific harness to remove")
148
+ mcp_remove.add_argument("--dry-run", action="store_true", help="Show changes without writing")
149
+
150
+ mcp_sub.add_parser("detect", help="Detect installed coding harnesses")
151
+
152
+ # --- key ---
153
+ key_p = sub.add_parser("key", help="Manage Apsimo cryptographic identity")
154
+ key_sub = key_p.add_subparsers(dest="key_command")
155
+ key_sub.add_parser("info", help="Show colony_id and public key")
156
+ key_sub.add_parser("generate", help="Generate a new keypair (replaces existing)")
157
+ key_gen = key_sub.add_parser("set-passphrase", help="Encrypt private key with a passphrase")
158
+ key_gen.add_argument("--passphrase", default=None, help="New passphrase (prompted if not given)")
159
+ key_sub.add_parser("manifest", help="Create a colony manifest (shareable public identity)")
160
+ key_genesis = key_sub.add_parser("claim-genesis", help="Claim Genesis status for this Apsimo (first Apsimo only)")
161
+ key_genesis.add_argument("--force", action="store_true", help="Overwrite existing Genesis manifest")
162
+
163
+ # --- node ---
164
+ node_p = sub.add_parser("node", help="Manage this device's node identity")
165
+ node_sub = node_p.add_subparsers(dest="node_command")
166
+ node_sub.add_parser("info", help="Show node_id, public key, and certificate status")
167
+
168
+ # --- backup ---
169
+ backup_p = sub.add_parser("backup", help="Export Apsimo identity or full state as a portable backup")
170
+ backup_p.add_argument("--full", action="store_true", help="Full-state backup (databases, identity, config, vectors, graph)")
171
+ backup_p.add_argument("--output", "-o", default=None, help="Output file/directory path")
172
+ backup_p.add_argument("--passphrase", default=None, help="Encrypt backup with this passphrase (prompted if --encrypt)")
173
+ backup_p.add_argument("--encrypt", action="store_true", help="Encrypt backup (prompts for passphrase)")
174
+ backup_p.add_argument("--no-graph", action="store_true", help="Skip Neo4j graph export (--full only)")
175
+ backup_p.add_argument("--no-vectors", action="store_true", help="Skip LanceDB vector store (--full only)")
176
+
177
+ # --- restore ---
178
+ restore_p = sub.add_parser("restore", help="Restore Apsimo from a backup")
179
+ restore_mode = restore_p.add_mutually_exclusive_group()
180
+ restore_mode.add_argument("--full", action="store_true", help="Reconstruct full archive state; current authority and erasures still require reconciliation")
181
+ restore_mode.add_argument("--memory-only", action="store_true", help="Recover canonical memory using a surviving current source ledger")
182
+ restore_p.add_argument("--input", "-i", default=None, help="Backup file path (default: prompts for it)")
183
+ restore_p.add_argument("--current-state", default=None, help="Surviving authoritative source state (--memory-only)")
184
+ restore_p.add_argument("--output", "-o", default=None, help="Fresh memory bundle destination (--memory-only)")
185
+ restore_p.add_argument("--passphrase", default=None, help="Passphrase to decrypt (default: prompts for it)")
186
+ restore_p.add_argument("--force-identity", action="store_true", help="Allow restoring onto a different colony identity")
187
+ mm_p.add_argument("--safety", default="basic", choices=["off", "basic", "strict"], help="Image safety level")
188
+ mm_p.add_argument("--skip-download", action="store_true", help="Skip model download")
189
+
190
+ # --- persona ---
191
+ persona_p = sub.add_parser("persona", help="Manage persona deployment")
192
+ persona_sub = persona_p.add_subparsers(dest="persona_command")
193
+
194
+ persona_setup = persona_sub.add_parser("setup", help="Legacy manifest deployment; use apsimo init for new installations")
195
+ persona_setup.add_argument("repo", help="Path to persona repo containing persona.yaml")
196
+ persona_setup.add_argument("--config", default=None, help="Variables YAML file (non-interactive)")
197
+
198
+ persona_validate = persona_sub.add_parser("validate", help="Validate a persona manifest (dry run)")
199
+ persona_validate.add_argument("repo", help="Path to persona repo containing persona.yaml")
200
+
201
+ persona_services = persona_sub.add_parser("services", help="Manage persona services")
202
+ persona_services.add_argument("action", choices=["status", "start", "stop", "restart", "install", "uninstall"])
203
+ persona_services.add_argument("service_name", nargs="?", default=None, help="Specific service name (for restart)")
204
+
205
+ persona_backup_p = persona_sub.add_parser("backup", help="Backup Apsimo + persona state")
206
+ persona_backup_p.add_argument("--output", "-o", default=None, help="Output directory")
207
+ persona_backup_p.add_argument("--encrypt", action="store_true", help="Encrypt backup")
208
+ persona_backup_p.add_argument("--passphrase", default=None, help="Encryption passphrase")
209
+
210
+ persona_restore_p = persona_sub.add_parser("restore", help="Restore persona from backup archive")
211
+ persona_restore_p.add_argument("archive", help="Path to backup archive")
212
+ persona_restore_p.add_argument("--passphrase", default=None, help="Decryption passphrase")
213
+ persona_restore_p.add_argument("--force-identity", action="store_true", help="Allow identity mismatch")
214
+
215
+ persona_sub.add_parser("uninstall", help="Stop services, remove overlays, deregister channels")
216
+
217
+ # --- secrets ---
218
+ secrets_p = sub.add_parser("secrets", help="Manage the encrypted secrets store (connector credentials, API keys)")
219
+ secrets_sub = secrets_p.add_subparsers(dest="secrets_cmd", required=True)
220
+ secrets_sub.add_parser("list", help="Show configured secrets grouped by category")
221
+ sget = secrets_sub.add_parser("get", help="Retrieve a secret value")
222
+ sget.add_argument("key")
223
+ sset = secrets_sub.add_parser("set", help="Store a secret (e.g. connector/imap/password)")
224
+ sset.add_argument("key")
225
+ sset.add_argument("value")
226
+ sdel = secrets_sub.add_parser("delete", help="Remove a secret")
227
+ sdel.add_argument("key")
228
+ secrets_sub.add_parser("backend", help="Show the active secrets backend")
229
+ secrets_sub.add_parser("status", help="Check backend availability")
230
+
231
+ # --- autonomy ---
232
+ autonomy_p = sub.add_parser("autonomy", help="Inspect or wake the autonomy loop in the running sidecar")
233
+ autonomy_p.add_argument("autonomy_args", nargs=argparse.REMAINDER,
234
+ help="Autonomy subcommand (status/cycle)")
235
+
236
+ # --- feeds ---
237
+ feeds_p = sub.add_parser("feeds", help="Manage spec-driven intelligence feeds")
238
+ feeds_p.add_argument("feeds_args", nargs=argparse.REMAINDER,
239
+ help="Feeds subcommand (create/validate/list/status/pause/resume/run/delete)")
240
+
241
+ # --- agent ---
242
+ agent_p = sub.add_parser("agent", help="Manage connected agents")
243
+ agent_sub = agent_p.add_subparsers(dest="agent_command")
244
+
245
+ agent_invite = agent_sub.add_parser("invite", help="Generate a setup code for remote agent")
246
+ agent_invite.add_argument("--expires", type=int, default=900, help="Invite expiry in seconds (default: 900)")
247
+ agent_invite.add_argument("--max-uses", type=int, default=1, help="Max uses (default: 1)")
248
+ agent_invite.add_argument("--capabilities", default="messaging", help="Grant capabilities (comma-separated)")
249
+ agent_invite.add_argument("--primary", action="store_true", help="Grant primary status")
250
+ agent_invite.add_argument("--label", default=None, help="Label for this invite")
251
+
252
+ agent_connect = agent_sub.add_parser("connect", help="Connect a remote agent using setup code")
253
+ agent_connect.add_argument("--setup-code", required=True, help="Setup code from apsimo agent invite")
254
+ agent_connect.add_argument("--apsimo-url", "--colony-url", dest="colony_url", default=None, help="Apsimo URL (auto-detect if on Tailscale)")
255
+ agent_connect.add_argument("--name", default=None, help="Agent name (default: hostname)")
256
+ agent_connect.add_argument("--capabilities", default=None, help="Request capabilities (comma-separated)")
257
+
258
+ agent_list = agent_sub.add_parser("list", help="List registered agents")
259
+ agent_list.add_argument("--status", choices=["online", "busy", "offline", "suspended", "revoked"], default=None, help="Filter by status")
260
+ agent_list.add_argument("--capability", default=None, help="Filter by capability")
261
+
262
+ agent_show = agent_sub.add_parser("show", help="Show agent details")
263
+ agent_show.add_argument("agent_id", help="Agent ID")
264
+
265
+ agent_revoke = agent_sub.add_parser("revoke", help="Revoke an agent's access")
266
+ agent_revoke.add_argument("agent_id", help="Agent ID to revoke")
267
+ agent_revoke.add_argument("--reason", default=None, help="Reason for revocation")
268
+
269
+ agent_sub.add_parser("disconnect", help="Disconnect this agent from Apsimo")
270
+
271
+ # --- initiative ---
272
+ init_p = sub.add_parser("initiative", help="Manage initiatives")
273
+ init_sub = init_p.add_subparsers(dest="initiative_command")
274
+
275
+ init_list = init_sub.add_parser("list", help="List initiatives")
276
+ init_list.add_argument("--status", default=None, help="Filter by status (pending, assigned, acknowledged, completed, failed, cancelled)")
277
+ init_list.add_argument("--agent", default=None, help="Filter by assigned agent")
278
+ init_list.add_argument("--limit", type=int, default=20, help="Max results (default: 20)")
279
+
280
+ init_show = init_sub.add_parser("show", help="Show initiative details")
281
+ init_show.add_argument("initiative_id", help="Initiative ID")
282
+
283
+ init_cancel = init_sub.add_parser("cancel", help="Cancel an initiative")
284
+ init_cancel.add_argument("initiative_id", help="Initiative ID")
285
+ init_cancel.add_argument("--reason", default=None, help="Reason for cancellation")
286
+
287
+ bench_p = sub.add_parser(
288
+ "benchmark", help="Selfhood benchmark: weekly self-improvement metrics")
289
+ bench_p.add_argument("--weeks", type=int, default=8,
290
+ help="Weeks of rollups to show (default: 8)")
291
+ bench_p.add_argument("--compute", action="store_true",
292
+ help="Compute rollups now (default: previous week)")
293
+ bench_p.add_argument("--week", default="",
294
+ help="ISO week to compute (e.g. 2026-W27)")
295
+
296
+ args = parser.parse_args()
297
+ if args.instance:
298
+ os.environ["COLONY_INSTANCE_SELECTED"] = "1"
299
+ from apsimo.environment import clear_environment_aliases, apply_environment_aliases
300
+ clear_environment_aliases()
301
+ os.environ.pop("COLONY_STATE_DIR", None)
302
+ os.environ["APSIMO_STATE_DIR"] = str(Path(args.instance).expanduser().resolve())
303
+ apply_environment_aliases()
304
+
305
+ if args.command == "models":
306
+ from apsimo.qualification.cli import run
307
+ try:
308
+ code = run(args)
309
+ except (ValueError, OSError, KeyError) as exc:
310
+ print(f"Model qualification failed: {type(exc).__name__}", file=sys.stderr)
311
+ raise SystemExit(2) from None
312
+ raise SystemExit(code)
313
+
314
+ if args.command == "init":
315
+ # Run setup wizard
316
+ from apsimo.setup import run_init
317
+ code = run_init(root_dir=args.dir, args=args)
318
+ if code != 0:
319
+ sys.exit(code)
320
+ if args.preferences_only:
321
+ return
322
+
323
+ # Initialize Apsimo identity if not already done
324
+ _load_dotenv()
325
+ state_dir = os.environ.get("COLONY_STATE_DIR", args.dir)
326
+ id_path = Path(state_dir) / "colony-id"
327
+ if not id_path.exists():
328
+ _cmd_init(args)
329
+ else:
330
+ print(f" Apsimo identity already exists: {id_path.read_text().strip()}")
331
+
332
+ elif args.command == "start":
333
+ _load_dotenv()
334
+ host = args.host or os.environ.get("COLONY_SIDECAR_HOST", "127.0.0.1")
335
+ port = args.port or int(os.environ.get("COLONY_SIDECAR_PORT", "7777"))
336
+
337
+ # Fail closed: never serve an unauthenticated API on a public interface.
338
+ _guard_bind_auth(host)
339
+
340
+ # Service-aware: error out if launchd is managing the sidecar
341
+ if _is_service_loaded():
342
+ print("❌ A user service is managing this sidecar.")
343
+ print(" Use 'apsimo service stop' and 'apsimo service start' instead,")
344
+ sys.exit(1)
345
+
346
+ # Check and start Neo4j if needed (both foreground and daemon mode)
347
+ if os.environ.get("COLONY_GRAPH_ENABLED", "true").lower() not in {"0", "false", "off"}:
348
+ _check_and_start_neo4j()
349
+
350
+ if args.detach:
351
+ _cmd_start_daemon(host, port, args.force)
352
+ else:
353
+ # Foreground mode — check port first
354
+ existing_pid = _find_pid_on_port(port)
355
+ if existing_pid:
356
+ if os.environ.get("COLONY_INSTALL_PROFILE") == "local":
357
+ print("Selected port is already in use; no process was stopped.")
358
+ raise SystemExit(1)
359
+ if args.force:
360
+ print(f"Killing existing process {existing_pid} on port {port}...")
361
+ try:
362
+ os.kill(existing_pid, 15) # SIGTERM
363
+ time.sleep(2)
364
+ if _find_pid_on_port(port):
365
+ os.kill(existing_pid, 9) # SIGKILL
366
+ time.sleep(1)
367
+ print("Process killed.")
368
+ except ProcessLookupError:
369
+ pass
370
+ else:
371
+ print(f"Error: Port {port} is already in use (PID {existing_pid})")
372
+ print("Use --force to kill existing process, or stop it first with: apsimo stop")
373
+ sys.exit(1)
374
+
375
+ import uvicorn
376
+ try:
377
+ ws_max_size = int(
378
+ os.environ.get("COLONY_MAX_WS_FRAME_BYTES", "") or 1 * 1024 * 1024
379
+ )
380
+ except ValueError:
381
+ ws_max_size = 1 * 1024 * 1024
382
+ uvicorn.run(
383
+ "apsimo.server:app",
384
+ host=host,
385
+ port=port,
386
+ log_level=os.environ.get("LOG_LEVEL", "info").lower(),
387
+ ws_max_size=ws_max_size,
388
+ )
389
+
390
+ elif args.command == "stop":
391
+ _load_dotenv()
392
+ # Service-aware: error out if launchd is managing the sidecar
393
+ if _is_service_loaded():
394
+ print("❌ Sidecar is managed by a user service.")
395
+ print(" Use 'apsimo service stop' instead.")
396
+ sys.exit(1)
397
+ _cmd_stop()
398
+
399
+ elif args.command == "status":
400
+ _load_dotenv()
401
+ _cmd_status()
402
+
403
+ elif args.command == "service":
404
+ _load_dotenv()
405
+ if os.environ.get("COLONY_INSTALL_PROFILE") == "local" and args.service_command:
406
+ from apsimo.services.instance import manage, ServiceError
407
+ try:
408
+ manage(args.service_command)
409
+ except (ServiceError, ValueError, OSError) as exc:
410
+ print(f"Service operation failed: {exc}", file=sys.stderr)
411
+ raise SystemExit(1) from None
412
+ return
413
+ if not hasattr(args, "service_command") or not args.service_command:
414
+ print("❌ No service subcommand given")
415
+ print(" Usage: apsimo service {install|uninstall|start|stop|restart|status}")
416
+ sys.exit(1)
417
+ elif args.service_command == "install":
418
+ _cmd_service_install()
419
+ elif args.service_command == "uninstall":
420
+ _cmd_service_uninstall()
421
+ elif args.service_command == "start":
422
+ _cmd_service_start()
423
+ elif args.service_command == "stop":
424
+ _cmd_service_stop()
425
+ elif args.service_command == "restart":
426
+ _cmd_service_restart()
427
+ elif args.service_command == "status":
428
+ _cmd_service_status()
429
+
430
+ elif args.command == "generate-types":
431
+ _load_dotenv()
432
+ import json
433
+ from apsimo.server import create_app
434
+ app = create_app()
435
+ spec = app.openapi()
436
+ out = os.environ.get("COLONY_OPENAPI_OUT", "openapi.json")
437
+ with open(out, "w") as f:
438
+ json.dump(spec, f, indent=2)
439
+ n = len(spec.get("components", {}).get("schemas", {}))
440
+ p = len(spec.get("paths", {}))
441
+ print(f"✅ OpenAPI spec written to {out} ({n} schemas, {p} paths)")
442
+
443
+ elif args.command == "seed":
444
+ from apsimo.seed import seed_self_knowledge_summary
445
+ print(seed_self_knowledge_summary())
446
+
447
+ elif args.command == "backfill":
448
+ _load_dotenv()
449
+ import httpx
450
+ host = os.environ.get("COLONY_SIDECAR_HOST", "127.0.0.1")
451
+ port = os.environ.get("COLONY_SIDECAR_PORT", "7777")
452
+ api_key = os.environ.get("COLONY_API_KEY", "")
453
+ try:
454
+ resp = httpx.post(
455
+ f"http://{host}:{port}/v1/host/memory/backfill",
456
+ json={"identity": {"host_id": "cli"}, "collection": args.collection, "batch_size": args.batch_size},
457
+ headers={"Authorization": f"Bearer {api_key}"},
458
+ timeout=10,
459
+ )
460
+ if resp.status_code == 200:
461
+ data = resp.json()
462
+ task_id = data.get("task_id", "")
463
+ print(f"Backfill started (task_id={task_id})")
464
+ while True:
465
+ time.sleep(2)
466
+ status_resp = httpx.get(
467
+ f"http://{host}:{port}/v1/host/memory/backfill/{task_id}",
468
+ headers={"Authorization": f"Bearer {api_key}"},
469
+ timeout=10,
470
+ )
471
+ if status_resp.status_code == 200:
472
+ sd = status_resp.json()
473
+ if sd.get("status") == "completed":
474
+ print(f"Backfill complete: {sd.get('processed', 0)} processed, {sd.get('skipped', 0)} skipped, {sd.get('failed', 0)} failed")
475
+ break
476
+ elif sd.get("status") == "failed":
477
+ print(f"Backfill failed: {sd.get('errors', [])}")
478
+ break
479
+ else:
480
+ print(f" ... {sd.get('processed', 0)} processed so far")
481
+ else:
482
+ print(f"Backfill failed: {resp.status_code} {resp.text}")
483
+ except Exception as e:
484
+ print(f"Could not connect to sidecar: {e}")
485
+
486
+ elif args.command == "migrate-tier":
487
+ if not 0 < args.wait_seconds < float("inf"):
488
+ parser.error("--wait-seconds must be finite and greater than zero")
489
+ _load_dotenv()
490
+ import httpx
491
+ host = os.environ.get("COLONY_SIDECAR_HOST", "127.0.0.1")
492
+ port = os.environ.get("COLONY_SIDECAR_PORT", "7777")
493
+ api_key = os.environ.get("COLONY_API_KEY", "")
494
+ try:
495
+ resp = httpx.post(
496
+ f"http://{host}:{port}/v1/host/memory/migrate",
497
+ json={"identity": {"host_id": "cli"}, "old_model_id": args.old_model, "batch_size": args.batch_size},
498
+ headers={"Authorization": f"Bearer {api_key}"},
499
+ timeout=10,
500
+ )
501
+ if resp.status_code == 200:
502
+ data = resp.json()
503
+ task_id = data.get("task_id", "")
504
+ print(f"Migration started (task_id={task_id})")
505
+ deadline = time.monotonic() + args.wait_seconds
506
+ status_url = f"http://{host}:{port}/v1/host/memory/migrate/{task_id}"
507
+ while time.monotonic() < deadline:
508
+ time.sleep(min(2, max(0, deadline - time.monotonic())))
509
+ status_resp = httpx.get(
510
+ status_url,
511
+ headers={"Authorization": f"Bearer {api_key}"},
512
+ timeout=10,
513
+ )
514
+ if status_resp.status_code == 200:
515
+ sd = status_resp.json()
516
+ if sd.get("status") == "completed":
517
+ print(f"Migration complete: {sd.get('vectors_migrated', 0)} vectors migrated, {sd.get('collections_migrated', 0)} collections")
518
+ break
519
+ elif sd.get("status") == "failed":
520
+ print(f"Migration failed: {sd.get('errors', [])}")
521
+ print("Re-run apsimo migrate-tier with the same settings to resume staged work.")
522
+ raise SystemExit(1)
523
+ elif sd.get("status") == "resumable":
524
+ print("Migration is interrupted, not complete. Re-run apsimo migrate-tier with the same settings to resume staged work.")
525
+ raise SystemExit(2)
526
+ else:
527
+ print(f" ... {sd.get('vectors_migrated', 0)} vectors migrated so far")
528
+ else:
529
+ print(f"Migration status unavailable (HTTP {status_resp.status_code}); completion is not confirmed.")
530
+ print("After checking the sidecar, re-run apsimo migrate-tier with the same settings to resume staged work.")
531
+ raise SystemExit(2)
532
+ else:
533
+ print(f"Stopped waiting; the server migration may still be running. Check GET {status_url}.")
534
+ print("After an interruption, re-run apsimo migrate-tier with the same settings to resume staged work.")
535
+ raise SystemExit(2)
536
+ else:
537
+ print(f"Migration failed: {resp.status_code} {resp.text}")
538
+ raise SystemExit(1)
539
+ except Exception as e:
540
+ print(f"Could not connect to sidecar: {e}")
541
+ print("Completion is not confirmed. Check the sidecar before resuming apsimo migrate-tier.")
542
+ raise SystemExit(1)
543
+
544
+ elif args.command == "activate-multimodal":
545
+ _load_dotenv()
546
+ env_path = Path(os.environ.get("COLONY_STATE_DIR", ".")) / ".env"
547
+ if not env_path.exists():
548
+ # Try sidecar directory
549
+ env_path = Path(__file__).parent / ".env"
550
+ if not env_path.exists():
551
+ print("No .env file found. Run 'apsimo init' first.")
552
+ return
553
+
554
+ # Determine multimodal model from tier
555
+ model = args.model
556
+ reranker_model = ""
557
+ dims = 0
558
+
559
+ if not model:
560
+ try:
561
+ from apsimo.vector.tiers import TIER_TABLE
562
+ from apsimo.vector.scanner import HardwareScanner
563
+ scanner = HardwareScanner()
564
+ scan = scanner.scan()
565
+ tier = scanner.recommend_tier(scan)
566
+ if tier and tier.multimodal_embedder:
567
+ model = tier.multimodal_embedder.model_id
568
+ dims = tier.multimodal_embedder.dims
569
+ if tier.multimodal_reranker:
570
+ reranker_model = tier.multimodal_reranker.model_id
571
+ else:
572
+ print("Your hardware tier does not support multimodal embeddings.")
573
+ print("Available from Tier 1 (4GB+) with jina-clip-v2.")
574
+ return
575
+ except Exception as exc:
576
+ print(f"Could not auto-detect tier: {exc}")
577
+ print("Use --model to specify a multimodal model ID.")
578
+ return
579
+
580
+ print(f"Activating multimodal embeddings:")
581
+ print(f" Model: {model}")
582
+ print(f" Dims: {dims}")
583
+ if reranker_model:
584
+ print(f" Reranker: {reranker_model}")
585
+ print(f" Storage: {args.storage}")
586
+ print(f" Safety: {args.safety}")
587
+ print()
588
+
589
+ answer = input("Continue? [y/N]: ").strip().lower()
590
+ if answer not in ("y", "yes"):
591
+ print("Cancelled.")
592
+ return
593
+
594
+ # Update .env
595
+ lines = env_path.read_text().splitlines()
596
+ updates = {
597
+ "COLONY_MULTIMODAL": "true",
598
+ "COLONY_EMBED_MODEL": model,
599
+ "COLONY_IMAGE_STORAGE": args.storage,
600
+ "COLONY_IMAGE_SAFETY": args.safety,
601
+ "COLONY_STRIP_EXIF_GPS": "true",
602
+ }
603
+ if dims:
604
+ updates["COLONY_EMBED_DIMS"] = str(dims)
605
+ if reranker_model:
606
+ updates["COLONY_RERANKER_MODEL"] = reranker_model
607
+
608
+ existing_keys = set()
609
+ for i, line in enumerate(lines):
610
+ if "=" in line and not line.strip().startswith("#"):
611
+ key = line.split("=", 1)[0].strip()
612
+ existing_keys.add(key)
613
+ if key in updates:
614
+ lines[i] = f"{key}={updates[key]}"
615
+
616
+ # Add new keys not yet in .env
617
+ for key, value in updates.items():
618
+ if key not in existing_keys:
619
+ lines.append(f"{key}={value}")
620
+
621
+ env_path.write_text("\n".join(lines) + "\n")
622
+ print(f"\n✅ .env updated with multimodal config")
623
+
624
+ # Download model
625
+ if not args.skip_download:
626
+ print(f"Downloading multimodal model {model}...")
627
+ try:
628
+ from sentence_transformers import SentenceTransformer
629
+ SentenceTransformer(model)
630
+ print(f"✅ Model downloaded and cached")
631
+ except Exception as exc:
632
+ print(f"⚠️ Model download failed: {exc}")
633
+ print("The model will download on first start instead.")
634
+
635
+ print()
636
+ print("Restart the sidecar to activate multimodal: apsimo start")
637
+ print("If you have existing text vectors, run: apsimo migrate-tier")
638
+
639
+ elif args.command == "validate":
640
+ _load_dotenv()
641
+ _cmd_validate(args)
642
+
643
+ elif args.command == "mcp":
644
+ _load_dotenv()
645
+ _cmd_mcp(args)
646
+
647
+ elif args.command == "doctor":
648
+ _cmd_doctor(args)
649
+
650
+ elif args.command == "key":
651
+ _cmd_key(args)
652
+
653
+ elif args.command == "node":
654
+ _cmd_node(args)
655
+
656
+ elif args.command == "backup":
657
+ _cmd_backup(args)
658
+
659
+ elif args.command == "restore":
660
+ _cmd_restore(args)
661
+
662
+ elif args.command == "agent":
663
+ _cmd_agent(args)
664
+
665
+ elif args.command == "initiative":
666
+ _cmd_initiative(args)
667
+
668
+ elif args.command == "benchmark":
669
+ _cmd_benchmark(args)
670
+
671
+ elif args.command == "persona":
672
+ _cmd_persona(args)
673
+
674
+ elif args.command == "secrets":
675
+ _load_dotenv()
676
+ from apsimo.secrets import cli as _secrets_cli
677
+ _handler = getattr(_secrets_cli, f"cmd_secrets_{args.secrets_cmd}")
678
+ _handler(args)
679
+
680
+ elif args.command == "autonomy":
681
+ _load_dotenv()
682
+ from apsimo.autonomy.cli import run_autonomy_command
683
+ sys.exit(run_autonomy_command(args.autonomy_args))
684
+
685
+ elif args.command == "feeds":
686
+ from apsimo.feeds.cli import main as feeds_main
687
+ feeds_main(args.feeds_args)
688
+
689
+ else:
690
+ parser.print_help()
691
+
692
+
693
+ def _cmd_backup(args) -> None:
694
+ """Export Apsimo identity or full state as a portable backup."""
695
+ _load_dotenv()
696
+ state_dir = os.environ.get("COLONY_STATE_DIR", os.getcwd())
697
+
698
+ passphrase = None
699
+ if args.passphrase:
700
+ passphrase = args.passphrase.encode()
701
+ elif args.encrypt:
702
+ import getpass
703
+ passphrase = getpass.getpass("Backup passphrase: ").encode()
704
+
705
+ if args.full:
706
+ from apsimo.backup import create_full_backup
707
+ output_dir = args.output or os.path.expanduser("~/colony-backups")
708
+ try:
709
+ archive = create_full_backup(
710
+ state_dir, output_dir,
711
+ passphrase=passphrase,
712
+ include_graph=not getattr(args, "no_graph", False),
713
+ include_vectors=not getattr(args, "no_vectors", False),
714
+ )
715
+ print(f" Full backup saved to {archive}")
716
+ except FileNotFoundError as e:
717
+ print(f" Error: {e}")
718
+ print(" Run 'apsimo init' first to create an identity.")
719
+ raise SystemExit(1)
720
+ return
721
+
722
+ try:
723
+ from apsimo.chain.identity import backup_colony
724
+ backup = backup_colony(state_dir, passphrase=passphrase)
725
+ backup_json = json.dumps(backup, indent=2) + "\n"
726
+
727
+ if args.output:
728
+ Path(args.output).write_text(backup_json)
729
+ print(f" Backup saved to {args.output}")
730
+ else:
731
+ print(backup_json)
732
+ except FileNotFoundError as e:
733
+ print(f" Error: {e}")
734
+ print(" Run 'apsimo init' first to create an identity.")
735
+ raise SystemExit(1)
736
+
737
+
738
+ def _cmd_restore(args) -> None:
739
+ """Restore Apsimo from a backup -- interactive by default."""
740
+ _load_dotenv()
741
+ state_dir = os.environ.get("COLONY_STATE_DIR", os.getcwd())
742
+ memory_only = getattr(args, "memory_only", False)
743
+ if memory_only and (not getattr(args, "current_state", None)
744
+ or not getattr(args, "output", None)
745
+ or getattr(args, "force_identity", False)):
746
+ print(" Memory recovery requires --current-state and a fresh --output; identity cannot be overridden.")
747
+ raise SystemExit(2)
748
+ if not memory_only and (getattr(args, "current_state", None) or getattr(args, "output", None)):
749
+ print(" --current-state and --output require --memory-only.")
750
+ raise SystemExit(2)
751
+
752
+ if args.input:
753
+ backup_path = args.input
754
+ else:
755
+ backup_path = input(" Backup file path: ").strip()
756
+
757
+ if not backup_path or not Path(backup_path).exists():
758
+ print(f" Error: File not found: {backup_path}")
759
+ raise SystemExit(1)
760
+
761
+ passphrase = None
762
+ if args.passphrase:
763
+ passphrase = args.passphrase.encode()
764
+
765
+ if args.full or memory_only:
766
+ if passphrase is None and backup_path.endswith(".enc"):
767
+ import getpass
768
+ passphrase = getpass.getpass(" Backup passphrase: ").encode()
769
+
770
+ from apsimo.backup import restore_full_backup, restore_source_memory
771
+ try:
772
+ if memory_only:
773
+ summary = restore_source_memory(
774
+ backup_path, args.output, current_state=args.current_state,
775
+ passphrase=passphrase,
776
+ )
777
+ print(f"\n Current source memory recovered to {args.output}")
778
+ print(f" Sources: {summary['source_count']}; original images: {summary['source_images']}")
779
+ print(" Files: turn-idempotency.db, owned images and source-memory-recovery.json.")
780
+ print(" Install only these memory files with writers stopped and separately current runtime bindings.")
781
+ print(" The bundle does not restore runtime authority or completed-effect state.")
782
+ return
783
+ summary = restore_full_backup(
784
+ backup_path, state_dir,
785
+ passphrase=passphrase,
786
+ force_identity=getattr(args, "force_identity", False),
787
+ )
788
+ print(f"\n Archive reconstructed: {summary['colony_id']}")
789
+ print(f" Databases: {', '.join(summary.get('databases', []))}")
790
+ print(" Reconcile current authority, erasures and completed effects before starting services.")
791
+ print(" Use --memory-only for bounded recovery from a surviving current source ledger.")
792
+ except ValueError as e:
793
+ print(f" Error: {e}")
794
+ raise SystemExit(1)
795
+ return
796
+
797
+ # Legacy identity-only restore
798
+ id_path = Path(state_dir) / "colony-id"
799
+ if id_path.exists():
800
+ print(" A Apsimo identity already exists in this state directory.")
801
+ existing_id = id_path.read_text().strip()
802
+ print(f" Existing colony_id: {existing_id}")
803
+ confirm = input(" Overwrite? [y/N] ").strip().lower()
804
+ if confirm != "y":
805
+ print(" Restore cancelled.")
806
+ return
807
+
808
+ try:
809
+ backup_data = json.loads(Path(backup_path).read_text())
810
+ except json.JSONDecodeError:
811
+ print(" Error: Invalid backup JSON")
812
+ raise SystemExit(1)
813
+
814
+ if backup_data.get("encrypted") and passphrase is None:
815
+ import getpass
816
+ passphrase = getpass.getpass(" Backup passphrase: ").encode()
817
+
818
+ try:
819
+ from apsimo.chain.identity import restore_colony
820
+ colony_id = restore_colony(state_dir, backup_data, passphrase=passphrase)
821
+ print(f"\n Apsimo restored: {colony_id}")
822
+ if backup_data.get("genesis"):
823
+ print(f" Genesis status restored")
824
+ print(f"\n Run 'apsimo start' to bring the Apsimo online.")
825
+ except ValueError as e:
826
+ print(f" Error: {e}")
827
+ raise SystemExit(1)
828
+
829
+
830
+ def _cmd_agent(args) -> None:
831
+ """Handle agent subcommands."""
832
+ _load_dotenv()
833
+
834
+ import httpx
835
+ host = os.environ.get("COLONY_SIDECAR_HOST", "127.0.0.1")
836
+ port = os.environ.get("COLONY_SIDECAR_PORT", "7777")
837
+ api_key = os.environ.get("COLONY_API_KEY", "")
838
+ base_url = f"http://{host}:{port}/v1/host"
839
+ headers = {"Authorization": f"Bearer {api_key}"}
840
+
841
+ if args.agent_command == "invite":
842
+ resp = httpx.post(
843
+ f"{base_url}/agents/invite",
844
+ json={
845
+ "expires_in_seconds": args.expires,
846
+ "max_uses": args.max_uses,
847
+ "granted_capabilities": args.capabilities.split(",") if args.capabilities else ["messaging"],
848
+ "granted_is_primary": args.primary,
849
+ "label": args.label,
850
+ },
851
+ headers=headers,
852
+ timeout=10,
853
+ )
854
+ if resp.status_code != 200:
855
+ print(f"Error: {resp.text}")
856
+ raise SystemExit(1)
857
+ data = resp.json()
858
+ print(f"Setup code: {data['code']}")
859
+ print(f"Expires at: {data['expires_at']}")
860
+ print(f"\nRun on remote agent:")
861
+ print(f" {data['setup_command']}")
862
+
863
+ elif args.agent_command == "connect":
864
+ import socket
865
+ name = args.name or socket.gethostname()
866
+ resp = httpx.post(
867
+ f"{base_url}/agents/connect",
868
+ json={
869
+ "setup_code": args.setup_code,
870
+ "name": name,
871
+ "node_public_key": str(uuid.uuid4()), # DEV ONLY: real keypair generated at first startup in server.py
872
+ "capabilities": args.capabilities.split(",") if args.capabilities else None,
873
+ },
874
+ headers=headers,
875
+ timeout=10,
876
+ )
877
+ if resp.status_code != 200:
878
+ print(f"Error: {resp.text}")
879
+ raise SystemExit(1)
880
+ data = resp.json()
881
+ # Save agent config
882
+ agent_dir = Path.home() / ".colony"
883
+ agent_dir.mkdir(exist_ok=True)
884
+ agent_config = agent_dir / "agent.json"
885
+ agent_config.write_text(json.dumps(data, indent=2))
886
+ print(f"Agent connected: {data['agent_id']}")
887
+ print(f"Node ID: {data['node_id']}")
888
+ print(f"Apsimo ID: {data['colony_id']}")
889
+ print(f"WebSocket URL: {data.get('websocket_url', 'N/A')}")
890
+ print(f"\nConfig saved to: {agent_config}")
891
+
892
+ elif args.agent_command == "list":
893
+ params = {}
894
+ if args.status:
895
+ params["status"] = args.status
896
+ if args.capability:
897
+ params["capability"] = args.capability
898
+ resp = httpx.get(f"{base_url}/agents", params=params, headers=headers, timeout=10)
899
+ if resp.status_code != 200:
900
+ print(f"Error: {resp.text}")
901
+ raise SystemExit(1)
902
+ data = resp.json()
903
+ agents = data.get("agents", [])
904
+ if not agents:
905
+ print("No agents found.")
906
+ return
907
+ print(f"{'Agent ID':<36} {'Name':<20} {'Status':<10} {'Capabilities'}")
908
+ print("-" * 100)
909
+ for a in agents:
910
+ caps = ", ".join(a.get("capabilities", []))
911
+ print(f"{a['agent_id']:<36} {a['name']:<20} {a['status']:<10} {caps}")
912
+
913
+ elif args.agent_command == "show":
914
+ resp = httpx.get(f"{base_url}/agents/{args.agent_id}", headers=headers, timeout=10)
915
+ if resp.status_code != 200:
916
+ print(f"Error: {resp.text}")
917
+ raise SystemExit(1)
918
+ a = resp.json()
919
+ print(f"Agent ID: {a['agent_id']}")
920
+ print(f"Node ID: {a['node_id']}")
921
+ print(f"Name: {a['name']}")
922
+ print(f"Apsimo ID: {a['colony_id']}")
923
+ print(f"Connection: {a['connection_mode']}")
924
+ print(f"Status: {a['status']}")
925
+ print(f"Primary: {a['is_primary']}")
926
+ print(f"Priority: {a['priority']}")
927
+ print(f"Capabilities: {', '.join(a['capabilities'])}")
928
+ print(f"Current assignments: {a['current_assignments']}")
929
+ print(f"Max concurrent: {a['max_concurrent']}")
930
+ print(f"Registered: {a['registered_at']}")
931
+ if a.get('last_seen_at'):
932
+ print(f"Last seen: {a['last_seen_at']}")
933
+
934
+ elif args.agent_command == "revoke":
935
+ resp = httpx.delete(f"{base_url}/agents/{args.agent_id}", headers=headers, timeout=10)
936
+ if resp.status_code != 200:
937
+ print(f"Error: {resp.text}")
938
+ raise SystemExit(1)
939
+ print(f"Agent {args.agent_id} revoked.")
940
+
941
+ elif args.agent_command == "disconnect":
942
+ # Read agent config to get agent_id
943
+ agent_config = Path.home() / ".colony" / "agent.json"
944
+ if not agent_config.exists():
945
+ print("No agent config found. Not connected?")
946
+ return
947
+ data = json.loads(agent_config.read_text())
948
+ agent_id = data.get("agent_id")
949
+ if agent_id:
950
+ resp = httpx.delete(f"{base_url}/agents/{agent_id}", headers=headers, timeout=10)
951
+ if resp.status_code == 200:
952
+ print(f"Disconnected agent {agent_id}")
953
+ # Remove config
954
+ agent_config.unlink(missing_ok=True)
955
+ print("Agent config removed.")
956
+
957
+ else:
958
+ print("Usage: apsimo agent [invite|connect|list|show|revoke|disconnect]")
959
+
960
+
961
+ def _cmd_benchmark(args) -> None:
962
+ """Show (and optionally compute) the selfhood-benchmark scorecard."""
963
+ _load_dotenv()
964
+ import httpx
965
+ host = os.environ.get("COLONY_SIDECAR_HOST", "127.0.0.1")
966
+ port = os.environ.get("COLONY_SIDECAR_PORT", "7777")
967
+ api_key = os.environ.get("COLONY_API_KEY", "")
968
+ base_url = f"http://{host}:{port}/v1/host/self/benchmark"
969
+ headers = {"Authorization": f"Bearer {api_key}"}
970
+
971
+ if args.compute:
972
+ resp = httpx.post(f"{base_url}/compute",
973
+ params={"week": args.week} if args.week else None,
974
+ headers=headers, timeout=120)
975
+ if resp.status_code != 200:
976
+ print(f"Error: {resp.text}")
977
+ raise SystemExit(1)
978
+ data = resp.json()
979
+ if not data.get("available"):
980
+ print("Benchmark unavailable (COLONY_BENCHMARK_ENABLED=false?)")
981
+ raise SystemExit(1)
982
+ if data.get("error"):
983
+ print(f"Error: {data['error']}")
984
+ raise SystemExit(1)
985
+ print(f"Computed {data.get('week')}: "
986
+ f"{len(data.get('metrics') or {})} metrics")
987
+
988
+ resp = httpx.get(base_url, params={"weeks": args.weeks},
989
+ headers=headers, timeout=15)
990
+ if resp.status_code != 200:
991
+ print(f"Error: {resp.text}")
992
+ raise SystemExit(1)
993
+ data = resp.json()
994
+ if not data.get("available"):
995
+ print("Benchmark unavailable (COLONY_BENCHMARK_ENABLED=false?)")
996
+ raise SystemExit(1)
997
+ weeks = data.get("weeks") or []
998
+ if not weeks:
999
+ print("No rollups yet (first weekly compute pending). "
1000
+ "Run: apsimo benchmark --compute")
1001
+ return
1002
+ rollups = data.get("rollups", {})
1003
+ trends = data.get("trends", {})
1004
+ metrics = sorted({m for wk in weeks for m in rollups.get(wk, {})})
1005
+ hdr = f"{'metric':<28}" + "".join(f"{wk:>12}" for wk in weeks)
1006
+ print(hdr)
1007
+ print("-" * len(hdr))
1008
+ for m in metrics:
1009
+ row = f"{m:<28}"
1010
+ for wk in weeks:
1011
+ v = (rollups.get(wk, {}).get(m) or {}).get("value")
1012
+ row += f"{v:>12.2f}" if v is not None else f"{'-':>12}"
1013
+ d = trends.get(m)
1014
+ if d is not None:
1015
+ row += f" {'+' if d >= 0 else ''}{d:.2f} wow"
1016
+ print(row)
1017
+
1018
+
1019
+ def _cmd_initiative(args) -> None:
1020
+ """Handle initiative subcommands."""
1021
+ _load_dotenv()
1022
+
1023
+ import httpx
1024
+ host = os.environ.get("COLONY_SIDECAR_HOST", "127.0.0.1")
1025
+ port = os.environ.get("COLONY_SIDECAR_PORT", "7777")
1026
+ api_key = os.environ.get("COLONY_API_KEY", "")
1027
+ base_url = f"http://{host}:{port}/v1/host"
1028
+ headers = {"Authorization": f"Bearer {api_key}"}
1029
+
1030
+ if args.initiative_command == "list":
1031
+ params = {"limit": args.limit}
1032
+ if args.status:
1033
+ params["status"] = args.status
1034
+ if args.agent:
1035
+ params["agent_id"] = args.agent
1036
+ resp = httpx.get(f"{base_url}/initiatives", params=params, headers=headers, timeout=10)
1037
+ if resp.status_code != 200:
1038
+ print(f"Error: {resp.text}")
1039
+ raise SystemExit(1)
1040
+ data = resp.json()
1041
+ initiatives = data.get("initiatives", [])
1042
+ if not initiatives:
1043
+ print("No initiatives found.")
1044
+ return
1045
+ print(f"{'ID':<36} {'Type':<15} {'Status':<12} {'Priority':<8} {'Description'}")
1046
+ print("-" * 120)
1047
+ for i in initiatives:
1048
+ desc = i['description'][:60] + "..." if len(i['description']) > 60 else i['description']
1049
+ print(f"{i['id']:<36} {i['initiative_type']:<15} {i['status']:<12} {i['priority']:<8} {desc}")
1050
+
1051
+ elif args.initiative_command == "show":
1052
+ resp = httpx.get(f"{base_url}/initiatives/{args.initiative_id}", headers=headers, timeout=10)
1053
+ if resp.status_code != 200:
1054
+ print(f"Error: {resp.text}")
1055
+ raise SystemExit(1)
1056
+ i = resp.json()
1057
+ print(f"Initiative ID: {i['id']}")
1058
+ print(f"Type: {i['initiative_type']}")
1059
+ print(f"Status: {i['status']}")
1060
+ print(f"Priority: {i['priority']}")
1061
+ print(f"Description: {i['description']}")
1062
+ if i.get('assigned_agent_id'):
1063
+ print(f"Assigned to: {i['assigned_agent_id']}")
1064
+ if i.get('result'):
1065
+ print(f"Result: {i['result']}")
1066
+ if i.get('error_message'):
1067
+ print(f"Error: {i['error_message']}")
1068
+ print(f"Created: {i['created_at']}")
1069
+ if i.get('completed_at'):
1070
+ print(f"Completed: {i['completed_at']}")
1071
+ if i.get('failed_at'):
1072
+ print(f"Failed: {i['failed_at']}")
1073
+
1074
+ elif args.initiative_command == "cancel":
1075
+ resp = httpx.post(
1076
+ f"{base_url}/initiatives/{args.initiative_id}/cancel",
1077
+ json={"reason": args.reason},
1078
+ headers=headers,
1079
+ timeout=10,
1080
+ )
1081
+ if resp.status_code != 200:
1082
+ print(f"Error: {resp.text}")
1083
+ raise SystemExit(1)
1084
+ print(f"Initiative {args.initiative_id} cancelled.")
1085
+
1086
+ else:
1087
+ print("Usage: apsimo initiative [list|show|cancel]")
1088
+
1089
+
1090
+ def _cmd_init(args) -> None:
1091
+ """Initialize a new Apsimo identity."""
1092
+ _load_dotenv()
1093
+ state_dir = os.environ.get("COLONY_STATE_DIR", os.getcwd())
1094
+
1095
+ from apsimo.chain.identity import get_or_create_colony_id
1096
+ from apsimo.chain.local_keys import LocalKeyManager
1097
+
1098
+ id_path = Path(state_dir) / "colony-id"
1099
+ if id_path.exists():
1100
+ existing = id_path.read_text().strip()
1101
+ print(f" Apsimo already initialized: {existing}")
1102
+ print(f" Run 'apsimo key info' to see details.")
1103
+ return
1104
+
1105
+ # Create colony_id
1106
+ colony_id = get_or_create_colony_id(state_dir)
1107
+ print(f" Apsimo ID: {colony_id}")
1108
+
1109
+ # Determine passphrase
1110
+ passphrase = None
1111
+ if args.encrypt:
1112
+ import getpass
1113
+ passphrase = getpass.getpass("Apsimo key passphrase: ").encode()
1114
+ elif args.passphrase:
1115
+ passphrase = args.passphrase.encode()
1116
+
1117
+ # Generate Apsimo keypair
1118
+ keys_dir = os.path.join(state_dir, "colony-keys")
1119
+ km = LocalKeyManager.generate(keys_dir=keys_dir, colony_id=colony_id, passphrase=passphrase)
1120
+ print(f" Public Key: {km.public_key_hex()}")
1121
+ print(f" Keypair saved to {keys_dir}/")
1122
+
1123
+ # Claim Genesis if requested
1124
+ if args.claim_genesis:
1125
+ from apsimo.chain.identity import create_genesis_manifest
1126
+ priv_path = os.path.join(keys_dir, "private.pem")
1127
+ private_pem = Path(priv_path).read_bytes()
1128
+ genesis_path = os.path.join(state_dir, "genesis.json")
1129
+ create_genesis_manifest(colony_id, km.public_key_hex(), genesis_path,
1130
+ private_key_pem=private_pem, passphrase=passphrase)
1131
+ print(f" ⚡ Genesis claimed and manifest signed")
1132
+
1133
+ print(f"\n Apsimo initialized. Run 'apsimo start' to bring it online.")
1134
+
1135
+
1136
+ def _cmd_node(args) -> None:
1137
+ """Manage this device's node identity."""
1138
+ _load_dotenv()
1139
+ state_dir = os.environ.get("COLONY_STATE_DIR", os.getcwd())
1140
+
1141
+ if args.node_command == "info":
1142
+ from apsimo.chain.node import get_node_info
1143
+ from apsimo.chain.identity import get_or_create_colony_id
1144
+ colony_id = get_or_create_colony_id(state_dir)
1145
+ info = get_node_info(state_dir)
1146
+ print(f" Apsimo ID: {colony_id}")
1147
+ print(f" Node ID: {info.get('node_id', '(not created — run apsimo start)')}")
1148
+ print(f" Node Key: {info.get('node_public_key', '(none)')}")
1149
+ print(f" Certified: {'yes' if info.get('certified') else 'no'}")
1150
+ if info.get('issued_at'):
1151
+ print(f" Issued At: {info['issued_at']}")
1152
+ else:
1153
+ print(" Usage: apsimo node {info}")
1154
+
1155
+
1156
+ def _cmd_key(args) -> None:
1157
+ """Manage Apsimo cryptographic identity."""
1158
+ _load_dotenv()
1159
+ state_dir = os.environ.get("COLONY_STATE_DIR", os.getcwd())
1160
+
1161
+ if args.key_command == "info":
1162
+ from apsimo.chain.identity import get_or_create_colony_id, get_genesis_manifest
1163
+ colony_id = get_or_create_colony_id(state_dir)
1164
+ keys_dir = os.path.join(state_dir, "colony-keys")
1165
+ passphrase = os.environ.get("COLONY_KEY_PASSPHRASE", "")
1166
+ passphrase_bytes = passphrase.encode() if passphrase else None
1167
+ try:
1168
+ from apsimo.chain.local_keys import LocalKeyManager
1169
+ km = LocalKeyManager(keys_dir=keys_dir, colony_id=colony_id, passphrase=passphrase_bytes)
1170
+ pubkey = km.public_key_hex()
1171
+ print(f" Apsimo ID: {colony_id}")
1172
+ print(f" Public Key: {pubkey}")
1173
+ manifest = get_genesis_manifest()
1174
+ if manifest and manifest.get("colony_id") == colony_id:
1175
+ print(f" Genesis: YES (trust anchor)")
1176
+ else:
1177
+ print(f" Genesis: no")
1178
+ except FileNotFoundError:
1179
+ print(f" Apsimo ID: {colony_id}")
1180
+ print(f" Public Key: (no keypair — run 'apsimo key generate')")
1181
+
1182
+ elif args.key_command == "generate":
1183
+ from apsimo.chain.identity import get_or_create_colony_id
1184
+ colony_id = get_or_create_colony_id(state_dir)
1185
+ keys_dir = os.path.join(state_dir, "colony-keys")
1186
+ passphrase = os.environ.get("COLONY_KEY_PASSPHRASE", "")
1187
+ passphrase_bytes = passphrase.encode() if passphrase else None
1188
+ from apsimo.chain.local_keys import LocalKeyManager
1189
+ km = LocalKeyManager.generate(keys_dir=keys_dir, colony_id=colony_id, passphrase=passphrase_bytes)
1190
+ print(f" Generated new Ed25519 keypair for colony {colony_id}")
1191
+ print(f" Public Key: {km.public_key_hex()}")
1192
+
1193
+ elif args.key_command == "set-passphrase":
1194
+ from apsimo.chain.identity import get_or_create_colony_id
1195
+ colony_id = get_or_create_colony_id(state_dir)
1196
+ keys_dir = os.path.join(state_dir, "colony-keys")
1197
+ existing_pass = os.environ.get("COLONY_KEY_PASSPHRASE", "")
1198
+ existing_pass_bytes = existing_pass.encode() if existing_pass else None
1199
+ passphrase = args.passphrase
1200
+ if not passphrase:
1201
+ import getpass
1202
+ passphrase = getpass.getpass("New passphrase: ")
1203
+ from apsimo.chain.local_keys import LocalKeyManager
1204
+ km = LocalKeyManager(keys_dir=keys_dir, colony_id=colony_id, passphrase=existing_pass_bytes)
1205
+ km.set_passphrase(passphrase.encode())
1206
+ print(f" Passphrase set for colony {colony_id}")
1207
+
1208
+ elif args.key_command == "manifest":
1209
+ from apsimo.chain.identity import get_or_create_colony_id, create_colony_manifest
1210
+ colony_id = get_or_create_colony_id(state_dir)
1211
+ keys_dir = os.path.join(state_dir, "colony-keys")
1212
+ passphrase = os.environ.get("COLONY_KEY_PASSPHRASE", "")
1213
+ passphrase_bytes = passphrase.encode() if passphrase else None
1214
+ from apsimo.chain.local_keys import LocalKeyManager
1215
+ km = LocalKeyManager(keys_dir=keys_dir, colony_id=colony_id, passphrase=passphrase_bytes)
1216
+ manifest_path = os.path.join(state_dir, "colony-manifest.json")
1217
+ manifest = create_colony_manifest(colony_id, km.public_key_hex(), manifest_path)
1218
+ print(f" Manifest saved to {manifest_path}")
1219
+ print(f" Share this file with other Colonies to establish trust.")
1220
+
1221
+ elif args.key_command == "claim-genesis":
1222
+ from apsimo.chain.identity import get_or_create_colony_id, create_genesis_manifest, get_genesis_manifest
1223
+ colony_id = get_or_create_colony_id(state_dir)
1224
+
1225
+ existing = get_genesis_manifest()
1226
+ if existing and not args.force:
1227
+ print(" Genesis manifest already exists.")
1228
+ print(f" Existing Genesis colony_id: {existing.get('colony_id')}")
1229
+ print(" Use --force to overwrite (NOT recommended if other Colonies trust this manifest)")
1230
+ return
1231
+
1232
+ keys_dir = os.path.join(state_dir, "colony-keys")
1233
+ passphrase = os.environ.get("COLONY_KEY_PASSPHRASE", "")
1234
+ passphrase_bytes = passphrase.encode() if passphrase else None
1235
+ from apsimo.chain.local_keys import LocalKeyManager
1236
+ try:
1237
+ km = LocalKeyManager(keys_dir=keys_dir, colony_id=colony_id, passphrase=passphrase_bytes)
1238
+ pubkey = km.public_key_hex()
1239
+ except FileNotFoundError:
1240
+ km = LocalKeyManager.generate(keys_dir=keys_dir, colony_id=colony_id)
1241
+ pubkey = km.public_key_hex()
1242
+
1243
+ # Read private key PEM for signing
1244
+ priv_path = os.path.join(keys_dir, "private.pem")
1245
+ private_pem = Path(priv_path).read_bytes()
1246
+
1247
+ genesis_path = os.path.join(state_dir, "genesis.json")
1248
+ manifest = create_genesis_manifest(
1249
+ colony_id, pubkey, genesis_path,
1250
+ private_key_pem=private_pem,
1251
+ passphrase=passphrase_bytes,
1252
+ )
1253
+ print(f" ⚡ Genesis claimed for colony {colony_id}")
1254
+ print(f" Public Key: {pubkey}")
1255
+ print(f" Manifest signed with your private key and saved to {genesis_path}")
1256
+ print(f"")
1257
+ print(f" IMPORTANT: Commit genesis.json to the Apsimo repo so other")
1258
+ print(f" Colonies can recognize you as the trust anchor.")
1259
+ print(f" The manifest is cryptographically signed — it cannot be forged.")
1260
+ print(f" Your private key never leaves this machine.")
1261
+
1262
+ else:
1263
+ print(" Usage: apsimo key {info|generate|set-passphrase|manifest|claim-genesis}")
1264
+
1265
+
1266
+
1267
+ def _is_loopback_host(host: str) -> bool:
1268
+ """True if the bind host only accepts local connections."""
1269
+ h = (host or "").strip().lower()
1270
+ return h in {"127.0.0.1", "::1", "localhost", ""}
1271
+
1272
+
1273
+ def _guard_bind_auth(host: str) -> None:
1274
+ """Refuse to start an unauthenticated sidecar on a non-loopback interface.
1275
+
1276
+ Auth is enforced by ApiKeyMiddleware, but when both COLONY_API_KEY and
1277
+ COLONY_API_KEYRING_PATH are unset the API runs in dev mode. That is only
1278
+ safe on loopback. Binding to 0.0.0.0 / a LAN address without either auth
1279
+ mechanism exposes every endpoint to the network, so fail closed.
1280
+ Set COLONY_ALLOW_OPEN_BIND=1 to override (e.g. behind a trusted proxy).
1281
+ """
1282
+ if _is_loopback_host(host):
1283
+ return
1284
+ if os.environ.get("COLONY_API_KEY") or os.environ.get("COLONY_API_KEYRING_PATH"):
1285
+ return
1286
+ if os.environ.get("COLONY_ALLOW_OPEN_BIND", "").strip().lower() in {"1", "true", "yes", "on"}:
1287
+ print(
1288
+ f"⚠️ Sidecar binding to non-loopback host {host!r} with NO "
1289
+ "API authentication (COLONY_ALLOW_OPEN_BIND override set) — the API is "
1290
+ "open to the network.",
1291
+ file=sys.stderr,
1292
+ )
1293
+ return
1294
+ print(
1295
+ f"❌ Refusing to start: binding to {host!r} (non-loopback) with no "
1296
+ "API authentication — the API would be open to the network.\n"
1297
+ " Fix one of:\n"
1298
+ " • set COLONY_API_KEY=<secret> or COLONY_API_KEYRING_PATH=<file> "
1299
+ "to require bearer/X-API-Key auth, or\n"
1300
+ " • bind to 127.0.0.1 (default) and reach it via SSH/proxy, or\n"
1301
+ " • set COLONY_ALLOW_OPEN_BIND=1 to intentionally serve open "
1302
+ "(only behind a trusted network/proxy).",
1303
+ file=sys.stderr,
1304
+ )
1305
+ sys.exit(2)
1306
+
1307
+
1308
+ def _find_pid_on_port(port: int) -> int | None:
1309
+ """Find the PID of a process listening on the given port.
1310
+
1311
+ NOTE: This only finds LISTEN sockets, not client connections.
1312
+ """
1313
+ pids = _find_pids_on_port(port)
1314
+ return pids[0] if pids else None
1315
+
1316
+
1317
+ def _find_pids_on_port(port: int) -> list[int]:
1318
+ """Find ALL PIDs in LISTEN state on the given port."""
1319
+ try:
1320
+ result = subprocess.run(
1321
+ ["lsof", "-ti", f":{port}", "-sTCP:LISTEN"],
1322
+ capture_output=True, text=True, timeout=5,
1323
+ )
1324
+ return [int(p) for p in result.stdout.strip().splitlines() if p.isdigit()]
1325
+ except Exception:
1326
+ return []
1327
+
1328
+
1329
+ def _is_service_loaded() -> bool:
1330
+ """Check the selected user service, retaining legacy launchd behavior."""
1331
+ if os.environ.get("COLONY_INSTALL_PROFILE") == "local":
1332
+ from apsimo.services.instance import InstanceService
1333
+ service = InstanceService.selected()
1334
+ if os.environ.get("COLONY_INSTANCE_SERVICE") == service.label:
1335
+ return False # This foreground invocation belongs to that manager.
1336
+ if not service.link.exists() and not service.link.is_symlink():
1337
+ return False
1338
+ state = service.status()
1339
+ return (state["loaded"] if service.platform == "darwin" else
1340
+ state.get("state") in {"active", "activating", "deactivating"})
1341
+ if not shutil.which("launchctl"):
1342
+ return False
1343
+ result = subprocess.run(
1344
+ ["launchctl", "list", "ai.aevonix.colony-sidecar"],
1345
+ capture_output=True, text=True,
1346
+ )
1347
+ if result.returncode != 0:
1348
+ return False # Label not known to launchd
1349
+ # Output format: "PID\tStatus\tLabel" or "-\tStatus\tLabel"
1350
+ parts = result.stdout.strip().split()
1351
+ return len(parts) >= 1 and parts[0].isdigit()
1352
+
1353
+
1354
+ def _get_plist_path() -> Path:
1355
+ """Return the path to the launchd plist file."""
1356
+ return Path.home() / "Library" / "LaunchAgents" / "ai.aevonix.colony-sidecar.plist"
1357
+
1358
+
1359
+ def _get_uvicorn_path() -> str:
1360
+ """Return the path to the uvicorn executable."""
1361
+ venv_uvicorn = Path.home() / ".colony-venv" / "bin" / "uvicorn"
1362
+ if venv_uvicorn.exists():
1363
+ return str(venv_uvicorn)
1364
+ # Fallback: find in PATH
1365
+ for path_dir in os.environ.get("PATH", "/usr/bin:/bin").split(":"):
1366
+ candidate = Path(path_dir) / "uvicorn"
1367
+ if candidate.exists():
1368
+ return str(candidate)
1369
+ return "uvicorn"
1370
+
1371
+
1372
+ def _get_state_dir() -> Path:
1373
+ """Return the Apsimo state directory."""
1374
+ from apsimo import get_state_dir
1375
+ return get_state_dir()
1376
+
1377
+
1378
+ def _find_orphan_processes() -> list[int]:
1379
+ """Find orphaned colony sidecar processes (parent died).
1380
+
1381
+ Returns list of PIDs that are:
1382
+ - Running uvicorn/apsimo
1383
+ - Have parent PID 1 (init) or parent doesn't exist
1384
+ """
1385
+ orphans = []
1386
+ try:
1387
+ # Find all python processes running uvicorn or apsimo
1388
+ result = subprocess.run(
1389
+ ["ps", "aux"],
1390
+ capture_output=True, text=True, timeout=10
1391
+ )
1392
+ if result.returncode != 0:
1393
+ return orphans
1394
+
1395
+ for line in result.stdout.splitlines():
1396
+ # Look for colony sidecar processes
1397
+ if "uvicorn" in line and any(module in line for module in ("apsimo", "colony_sidecar")):
1398
+ parts = line.split()
1399
+ if len(parts) >= 2:
1400
+ try:
1401
+ pid = int(parts[1])
1402
+ ppid = int(parts[2]) if len(parts) > 2 else 0
1403
+ # Parent PID 1 = orphan (reparented to init)
1404
+ # Also check if parent exists
1405
+ if ppid == 1:
1406
+ orphans.append(pid)
1407
+ elif ppid > 1:
1408
+ try:
1409
+ os.kill(ppid, 0) # Check if parent exists
1410
+ except OSError:
1411
+ # Parent doesn't exist, this is an orphan
1412
+ orphans.append(pid)
1413
+ except (ValueError, OSError):
1414
+ pass
1415
+ except Exception:
1416
+ pass
1417
+ return orphans
1418
+
1419
+
1420
+ def _cleanup_orphans(kill: bool = False) -> int:
1421
+ """Find and optionally kill orphaned colony processes.
1422
+
1423
+ Args:
1424
+ kill: If True, kill the orphans; if False, just report them
1425
+
1426
+ Returns:
1427
+ Number of orphans found
1428
+ """
1429
+ orphans = _find_orphan_processes()
1430
+ if not orphans:
1431
+ return 0
1432
+
1433
+ if kill:
1434
+ for pid in orphans:
1435
+ try:
1436
+ os.kill(pid, 15) # SIGTERM
1437
+ print(f" Killed orphan process {pid}")
1438
+ except ProcessLookupError:
1439
+ pass
1440
+ except Exception as e:
1441
+ print(f" Failed to kill {pid}: {e}")
1442
+ time.sleep(1)
1443
+ # Check if any survived
1444
+ for pid in orphans:
1445
+ try:
1446
+ os.kill(pid, 0)
1447
+ os.kill(pid, 9) # SIGKILL
1448
+ print(f" Force-killed stubborn process {pid}")
1449
+ except ProcessLookupError:
1450
+ pass
1451
+
1452
+ return len(orphans)
1453
+
1454
+
1455
+ def _neo4j_health_check(password: str, timeout_s: int = 5) -> tuple[bool, str]:
1456
+ """Check if Neo4j is healthy (connect + auth + query). Returns (success, error_message)."""
1457
+ from neo4j import GraphDatabase
1458
+ from neo4j.exceptions import AuthError, ServiceUnavailable
1459
+
1460
+ try:
1461
+ driver = GraphDatabase.driver(
1462
+ "bolt://localhost:7687",
1463
+ auth=("neo4j", password),
1464
+ connection_timeout=timeout_s
1465
+ )
1466
+ with driver.session() as session:
1467
+ session.run("RETURN 1").single()
1468
+ driver.close()
1469
+ return True, ""
1470
+ except AuthError:
1471
+ return False, "auth_failed"
1472
+ except ServiceUnavailable:
1473
+ return False, "not_responding"
1474
+ except Exception as e:
1475
+ return False, str(e)
1476
+
1477
+
1478
+ def _neo4j_poll_health(password: str, timeout_s: int = 30) -> tuple[bool, str]:
1479
+ """Poll Neo4j health until ready or timeout. Returns (success, error_message)."""
1480
+ timeout_s = int(os.environ.get("COLONY_NEO4J_STARTUP_TIMEOUT", timeout_s))
1481
+
1482
+ for i in range(1, timeout_s + 1):
1483
+ success, error = _neo4j_health_check(password, timeout_s=2)
1484
+ if success:
1485
+ return True, ""
1486
+ if error == "auth_failed":
1487
+ # Auth failure is immediate, no point retrying
1488
+ return False, error
1489
+ if i < timeout_s:
1490
+ print(f" Waiting for Neo4j ({i}/{timeout_s}s)...")
1491
+ time.sleep(1)
1492
+
1493
+ return False, "timeout"
1494
+
1495
+
1496
+ def _check_and_start_neo4j() -> bool:
1497
+ """Check if Neo4j is running, start it if needed. Returns True if Neo4j is available."""
1498
+ from pathlib import Path
1499
+
1500
+ # Check if Docker is available
1501
+ try:
1502
+ result = subprocess.run(["docker", "--version"], capture_output=True, timeout=5)
1503
+ if result.returncode != 0:
1504
+ return False
1505
+ except Exception:
1506
+ return False
1507
+
1508
+ # Check for Neo4j credentials in .env
1509
+ env_path = Path.home() / ".env"
1510
+ neo4j_password = None
1511
+ if env_path.exists():
1512
+ try:
1513
+ for line in env_path.read_text().splitlines():
1514
+ if line.startswith("NEO4J_PASSWORD="):
1515
+ neo4j_password = line.split("=", 1)[1].strip()
1516
+ break
1517
+ except Exception:
1518
+ pass
1519
+
1520
+ if not neo4j_password:
1521
+ # No credentials configured, skip Neo4j
1522
+ return False
1523
+
1524
+ # Check container state
1525
+ container_running = False
1526
+ container_exists = False
1527
+
1528
+ try:
1529
+ result = subprocess.run(
1530
+ ["docker", "ps", "--filter", "name=neo4j-colony", "--format", "{{.Names}}"],
1531
+ capture_output=True, text=True, timeout=10
1532
+ )
1533
+ container_running = "neo4j-colony" in result.stdout
1534
+ except Exception:
1535
+ pass
1536
+
1537
+ if not container_running:
1538
+ try:
1539
+ result = subprocess.run(
1540
+ ["docker", "ps", "-a", "--filter", "name=neo4j-colony", "--format", "{{.Names}}"],
1541
+ capture_output=True, text=True, timeout=10
1542
+ )
1543
+ container_exists = "neo4j-colony" in result.stdout
1544
+ except Exception:
1545
+ pass
1546
+
1547
+ # Scenario 1: Container already running
1548
+ if container_running:
1549
+ print(" Neo4j container already running")
1550
+ success, error = _neo4j_health_check(neo4j_password)
1551
+ if success:
1552
+ print(" ✅ Neo4j ready")
1553
+ return True
1554
+
1555
+ # Quick check failed, try restart
1556
+ print(" Neo4j health check failed, restarting...")
1557
+ try:
1558
+ subprocess.run(["docker", "restart", "neo4j-colony"], capture_output=True, timeout=30)
1559
+ except Exception:
1560
+ pass
1561
+
1562
+ success, error = _neo4j_poll_health(neo4j_password)
1563
+ if success:
1564
+ print(" ✅ Neo4j recovered after restart")
1565
+ return True
1566
+
1567
+ if error == "auth_failed":
1568
+ print(" ❌ Neo4j auth failed — password in .env doesn't match container")
1569
+ print(" Reset: docker rm -f neo4j-colony && apsimo init")
1570
+ else:
1571
+ print(" ❌ Neo4j not responding after restart")
1572
+ print(" Check logs: docker logs neo4j-colony")
1573
+ print(" Reset: docker rm -f neo4j-colony && apsimo init")
1574
+ print(" ⚠️ Graph memory degraded")
1575
+ return False
1576
+
1577
+ # Scenario 2: Container exists but stopped
1578
+ if container_exists:
1579
+ print(" Starting Neo4j container...")
1580
+ try:
1581
+ subprocess.run(["docker", "start", "neo4j-colony"], capture_output=True, timeout=30)
1582
+ except Exception:
1583
+ pass
1584
+
1585
+ success, error = _neo4j_poll_health(neo4j_password)
1586
+ if success:
1587
+ print(" ✅ Neo4j ready")
1588
+ return True
1589
+
1590
+ if error == "auth_failed":
1591
+ print(" ❌ Neo4j auth failed — password in .env doesn't match container")
1592
+ print(" Reset: docker rm -f neo4j-colony && apsimo init")
1593
+ else:
1594
+ print(" ❌ Neo4j not ready after 30s")
1595
+ print(" Check logs: docker logs neo4j-colony")
1596
+ print(" ⚠️ Graph memory degraded")
1597
+ return False
1598
+
1599
+ # Scenario 3: No container, create new one
1600
+ print(" Creating Neo4j container...")
1601
+ neo4j_data = Path.home() / ".colony" / "neo4j-data"
1602
+ neo4j_data.mkdir(parents=True, exist_ok=True)
1603
+
1604
+ try:
1605
+ cmd = [
1606
+ "docker", "run", "-d",
1607
+ "--name", "neo4j-colony",
1608
+ "-p", "7474:7474",
1609
+ "-p", "7687:7687",
1610
+ "-e", f"NEO4J_AUTH=neo4j/{neo4j_password}",
1611
+ "-v", f"{neo4j_data}:/data",
1612
+ "neo4j:5.15"
1613
+ ]
1614
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
1615
+ if result.returncode != 0:
1616
+ print(f" ⚠️ Failed to create Neo4j container: {result.stderr.strip()}")
1617
+ print(" ⚠️ Graph memory degraded")
1618
+ return False
1619
+ except Exception as exc:
1620
+ print(f" ⚠️ Failed to create Neo4j container: {exc}")
1621
+ print(" ⚠️ Graph memory degraded")
1622
+ return False
1623
+
1624
+ success, error = _neo4j_poll_health(neo4j_password)
1625
+ if success:
1626
+ print(" ✅ Neo4j ready")
1627
+ return True
1628
+
1629
+ print(" ❌ Neo4j not ready after 30s")
1630
+ print(" Check logs: docker logs neo4j-colony")
1631
+ print(" ⚠️ Graph memory degraded")
1632
+ return False
1633
+
1634
+
1635
+ def _cmd_start_daemon(host: str, port: int, force: bool) -> None:
1636
+ """Start the sidecar as a background daemon."""
1637
+ # Clean up any orphaned processes first
1638
+ local_instance = os.environ.get("COLONY_INSTALL_PROFILE") == "local"
1639
+ if local_instance:
1640
+ from apsimo.setup import _check_port
1641
+ if _check_port(port):
1642
+ print("Selected port is already in use; no process was stopped.")
1643
+ raise SystemExit(1)
1644
+ orphan_count = 0 if local_instance else _cleanup_orphans(kill=True)
1645
+ if orphan_count:
1646
+ print(f" Cleaned up {orphan_count} orphan process(es)")
1647
+
1648
+ # Check if port is already in use
1649
+ existing_pids = _find_pids_on_port(port)
1650
+ if existing_pids:
1651
+ if local_instance:
1652
+ print("Selected port is already in use; no process was stopped.")
1653
+ raise SystemExit(1)
1654
+ if force:
1655
+ for pid in existing_pids:
1656
+ print(f" Killing existing process {pid} on port {port}...")
1657
+ try:
1658
+ os.kill(pid, 15) # SIGTERM
1659
+ except ProcessLookupError:
1660
+ pass
1661
+ # Wait up to 5s for all to die
1662
+ for _ in range(10):
1663
+ if not _find_pids_on_port(port):
1664
+ break
1665
+ time.sleep(0.5)
1666
+ # Escalate to SIGKILL for any survivors
1667
+ for pid in _find_pids_on_port(port):
1668
+ try:
1669
+ os.kill(pid, 9) # SIGKILL
1670
+ except ProcessLookupError:
1671
+ pass
1672
+ time.sleep(0.5)
1673
+ print(f" ✅ Process(es) killed")
1674
+ else:
1675
+ print(f" ⚠️ Port {port} is already in use (PIDs {existing_pids})")
1676
+ try:
1677
+ answer = input(" Kill existing process and restart? [Y/n] ").strip().lower()
1678
+ except EOFError:
1679
+ answer = "y" # Default to yes when no stdin (e.g. scripts)
1680
+ if answer in ("n", "no"):
1681
+ print(" Cancelled.")
1682
+ return
1683
+ for pid in existing_pids:
1684
+ try:
1685
+ os.kill(pid, 15)
1686
+ except ProcessLookupError:
1687
+ pass
1688
+ for _ in range(10):
1689
+ if not _find_pids_on_port(port):
1690
+ break
1691
+ time.sleep(0.5)
1692
+ for pid in _find_pids_on_port(port):
1693
+ try:
1694
+ os.kill(pid, 9)
1695
+ except ProcessLookupError:
1696
+ pass
1697
+ time.sleep(0.5)
1698
+ print(f" ✅ Process(es) killed")
1699
+
1700
+ # Build env from .env values
1701
+ env = {**os.environ}
1702
+ env["COLONY_SIDECAR_HOST"] = host
1703
+ env["COLONY_SIDECAR_PORT"] = str(port)
1704
+
1705
+ # Start uvicorn
1706
+ log_path = Path(os.environ.get("COLONY_STATE_DIR", ".")) / "sidecar.log"
1707
+ print(f" Starting Apsimo sidecar on {host}:{port}...")
1708
+ print(f" Log: {log_path}")
1709
+
1710
+ proc = subprocess.Popen(
1711
+ [sys.executable, "-m", "uvicorn",
1712
+ "apsimo.server:app",
1713
+ "--host", host,
1714
+ "--port", str(port)],
1715
+ stdout=open(log_path, "w"),
1716
+ stderr=subprocess.STDOUT,
1717
+ start_new_session=True,
1718
+ env=env,
1719
+ )
1720
+
1721
+ # Write PID file
1722
+ pid_path = Path(os.environ.get("COLONY_STATE_DIR", ".")) / "sidecar.pid"
1723
+ pid_path.write_text(str(proc.pid))
1724
+ # Wait for health check
1725
+ _load_dotenv()
1726
+ api_key = os.environ.get("COLONY_CLIENT_API_KEY") or os.environ.get("COLONY_API_KEY", "dev-mode-no-key")
1727
+ try:
1728
+ healthy = _wait_for_sidecar(host, port, api_key, timeout=20.0)
1729
+ except BaseException:
1730
+ if local_instance and proc.poll() is None:
1731
+ proc.terminate()
1732
+ raise
1733
+ finally:
1734
+ if local_instance and proc.poll() is None:
1735
+ # macOS Python launchers exec the framework interpreter during startup.
1736
+ # Retain the serving child, including after an interrupted wait.
1737
+ record = pid_path.with_name("sidecar-process.json")
1738
+ record.write_text(json.dumps({"pid": proc.pid, "signature": _process_signature(proc.pid)}))
1739
+ record.chmod(0o600)
1740
+ if healthy:
1741
+ import httpx
1742
+ try:
1743
+ r = httpx.get(
1744
+ f"http://{host}:{port}/v1/host/health",
1745
+ headers={"X-API-Key": api_key},
1746
+ timeout=2,
1747
+ )
1748
+ data = r.json()
1749
+ caps = len(data.get("capabilities", []))
1750
+ print(f" ✅ Sidecar running (PID {proc.pid}, {caps} capabilities)")
1751
+ # Check E2E validation status
1752
+ stamp = Path(os.environ.get("COLONY_STATE_DIR", ".")) / ".colony-e2e-validated"
1753
+ if not stamp.exists():
1754
+ print(f" ⚠️ E2E pipeline not validated — run 'apsimo validate' to test")
1755
+ return
1756
+ except Exception:
1757
+ pass
1758
+ else:
1759
+ print(f" ❌ Sidecar didn't become healthy within 20s")
1760
+ # Print log tail
1761
+ if log_path.exists():
1762
+ try:
1763
+ lines = log_path.read_text().splitlines()
1764
+ tail = lines[-20:] if len(lines) > 20 else lines
1765
+ print(f"\n Last log lines:")
1766
+ for line in tail:
1767
+ print(f" {line}")
1768
+ except Exception:
1769
+ pass
1770
+ print(f" PID: {proc.pid}")
1771
+ sys.exit(1)
1772
+
1773
+
1774
+ def _wait_for_sidecar(host: str, port: int, api_key: str, timeout: float = 10.0) -> bool:
1775
+ """Poll /health until the sidecar responds or timeout."""
1776
+ import httpx
1777
+ headers = {"X-API-Key": api_key}
1778
+ deadline = time.time() + timeout
1779
+ while time.time() < deadline:
1780
+ try:
1781
+ resp = httpx.get(
1782
+ f"http://{host}:{port}/v1/host/health",
1783
+ headers=headers,
1784
+ timeout=2,
1785
+ )
1786
+ if resp.status_code == 200:
1787
+ return True
1788
+ except Exception:
1789
+ pass
1790
+ time.sleep(0.5)
1791
+ return False
1792
+
1793
+
1794
+ def _process_signature(pid: int) -> str:
1795
+ """PID creation time and command prevent a stale file targeting PID reuse."""
1796
+ result = subprocess.run(["ps", "-p", str(pid), "-o", "lstart=", "-o", "command="],
1797
+ capture_output=True, text=True, timeout=5)
1798
+ return result.stdout.strip() if result.returncode == 0 else ""
1799
+
1800
+
1801
+ def _cmd_stop() -> None:
1802
+ """Stop the running sidecar."""
1803
+ # Try PID file first
1804
+ pid_path = Path(os.environ.get("COLONY_STATE_DIR", ".")) / "sidecar.pid"
1805
+ port = int(os.environ.get("COLONY_SIDECAR_PORT", "7777"))
1806
+
1807
+ pid = None
1808
+ if pid_path.exists():
1809
+ try:
1810
+ pid = int(pid_path.read_text().strip())
1811
+ except ValueError:
1812
+ pass
1813
+
1814
+ # New private instances never infer process ownership from a port alone.
1815
+ if not pid and os.environ.get("COLONY_INSTALL_PROFILE") == "local":
1816
+ print("No recorded process for this private instance.")
1817
+ return
1818
+ if os.environ.get("COLONY_INSTALL_PROFILE") == "local":
1819
+ try:
1820
+ record_path = pid_path.with_name("sidecar-process.json")
1821
+ record = json.loads(record_path.read_text())
1822
+ expected = record.get("signature")
1823
+ if record.get("pid") != pid or not expected or _process_signature(pid) != expected:
1824
+ raise ValueError()
1825
+ except (OSError, ValueError, subprocess.SubprocessError):
1826
+ print("Recorded process is gone or changed; no process was stopped.")
1827
+ return
1828
+ os.kill(pid, 15)
1829
+ deadline = time.monotonic() + 5
1830
+ while time.monotonic() < deadline and _process_signature(pid) == expected:
1831
+ time.sleep(.1)
1832
+ if _process_signature(pid) == expected:
1833
+ print("Sidecar has not stopped yet; inspect this instance's log before retrying.")
1834
+ raise SystemExit(1)
1835
+ pid_path.unlink(missing_ok=True)
1836
+ record_path.unlink(missing_ok=True)
1837
+ print("Private instance stopped.")
1838
+ return
1839
+ # Fallback: find by port
1840
+ if not pid:
1841
+ pid = _find_pid_on_port(port)
1842
+
1843
+ if not pid:
1844
+ print(f" No sidecar process found on port {port}")
1845
+ return
1846
+
1847
+ try:
1848
+ os.kill(pid, 15) # SIGTERM
1849
+ print(f" Stopping sidecar (PID {pid})...")
1850
+ time.sleep(2)
1851
+
1852
+ # Check if still alive
1853
+ if _find_pid_on_port(port):
1854
+ print(f" Process didn't stop gracefully, killing...")
1855
+ os.kill(pid, 9) # SIGKILL
1856
+ time.sleep(1)
1857
+
1858
+ print(f" ✅ Sidecar stopped")
1859
+
1860
+ # Clean up PID file
1861
+ if pid_path.exists():
1862
+ pid_path.unlink()
1863
+
1864
+ except ProcessLookupError:
1865
+ print(f" Process {pid} already gone")
1866
+ if pid_path.exists():
1867
+ pid_path.unlink()
1868
+
1869
+ # Check for orphan cleanup
1870
+ orphan_count = _cleanup_orphans(kill=True)
1871
+ if orphan_count:
1872
+ print(f" Cleaned up {orphan_count} orphan process(es)")
1873
+
1874
+
1875
+ def _cmd_status() -> None:
1876
+ """Check sidecar health and pipeline status."""
1877
+ import httpx
1878
+
1879
+ host = os.environ.get("COLONY_SIDECAR_HOST", "127.0.0.1")
1880
+ port = os.environ.get("COLONY_SIDECAR_PORT", "7777")
1881
+ url = f"http://{host}:{port}"
1882
+ api_key = os.environ.get("COLONY_CLIENT_API_KEY") or os.environ.get("COLONY_API_KEY", "")
1883
+ headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
1884
+
1885
+ try:
1886
+ resp = httpx.get(f"{url}/v1/host/health", headers=headers, timeout=5)
1887
+ resp.raise_for_status()
1888
+ data = resp.json()
1889
+ status = data.get("status", "unknown")
1890
+ caps = data.get("capabilities", [])
1891
+ notes = data.get("notes", {})
1892
+ if os.environ.get("COLONY_INSTALL_PROFILE") == "local":
1893
+ print(f"Private instance: {os.environ['COLONY_STATE_DIR']}")
1894
+ print(f"Sidecar HTTP status: {status}; endpoint: {url}")
1895
+ response = httpx.get(f"{url}/v1/host/memory/sources/claims/status", headers=headers,
1896
+ params={"contact_id": os.environ["COLONY_OWNER_CONTACT_ID"]}, timeout=5)
1897
+ response.raise_for_status()
1898
+ print("Source projection: " + json.dumps(response.json(), sort_keys=True))
1899
+ print("Verify recollection by telling Hermes a fact, then asking in a new session.")
1900
+ print("Graph/vector services and consequential background workers are disabled in this profile.")
1901
+ return
1902
+
1903
+ # Status icon
1904
+ icon = "🟢" if status == "ok" else "🔴"
1905
+ print(f"{icon} Apsimo Sidecar — {status}")
1906
+ print(f" URL: {url}")
1907
+ print(f" Capabilities: {len(caps)}")
1908
+
1909
+ # Show notable notes
1910
+ for k, v in notes.items():
1911
+ if "fail" in str(v).lower() or "error" in str(v).lower() or "not wired" in str(v).lower():
1912
+ print(f" ⚠️ {k}: {v}")
1913
+
1914
+ # Detailed auth migration evidence is intentionally separate from the
1915
+ # public health response and requires legacy or scoped auth:admin.
1916
+ auth_response = httpx.get(
1917
+ f"{url}/v1/host/admin/auth/status", headers=headers, timeout=5,
1918
+ )
1919
+ if auth_response.status_code == 200:
1920
+ auth_status = auth_response.json()
1921
+ telemetry = auth_status.get("telemetry") or {}
1922
+ totals = telemetry.get("totals") or {}
1923
+ principals = telemetry.get("principals") or {}
1924
+ grants = auth_status.get("contact_grants") or {}
1925
+ print(
1926
+ " Auth migration: scoped=%d legacy=%d denied=%d exact_contacts=%d"
1927
+ % (
1928
+ int(totals.get("scoped_allow") or 0),
1929
+ int(totals.get("legacy_allow") or 0),
1930
+ int(totals.get("deny") or 0),
1931
+ int(grants.get("total_exact_person_ids") or 0),
1932
+ )
1933
+ )
1934
+ legacy_last_seen = (principals.get("legacy") or {}).get("last_seen_at")
1935
+ if legacy_last_seen:
1936
+ print(f" Legacy last seen: {legacy_last_seen}")
1937
+ if telemetry.get("error") or grants.get("error"):
1938
+ print(" ⚠️ Auth migration telemetry/grants report an error; run apsimo doctor")
1939
+ elif auth_response.status_code in (401, 403):
1940
+ print(" ⚠️ Auth migration status requires an auth:admin credential")
1941
+
1942
+ # Check E2E validation stamp
1943
+ stamp = Path(os.environ.get("COLONY_STATE_DIR", ".")) / ".colony-e2e-validated"
1944
+ if stamp.exists():
1945
+ stamp_data = json.loads(stamp.read_text())
1946
+ validated_at = stamp_data.get("validated_at", "unknown")
1947
+ print(f" ✅ E2E validated: {validated_at}")
1948
+ else:
1949
+ print(f" ⚠️ E2E pipeline not validated")
1950
+ print(f" Run 'apsimo validate' to test the full pipeline")
1951
+
1952
+ except Exception as exc:
1953
+ print(f"🔴 Sidecar not reachable: {exc}")
1954
+ # Check if process exists
1955
+ pid = _find_pid_on_port(int(port))
1956
+ if pid:
1957
+ print(f" Process {pid} is on port {port} but not responding")
1958
+ else:
1959
+ print(f" No process on port {port}")
1960
+ print(f" Start with: apsimo start")
1961
+ sys.exit(1)
1962
+
1963
+
1964
+ def _cmd_service_install() -> None:
1965
+ """Install the launchd service."""
1966
+ plist_path = _get_plist_path()
1967
+ log_dir = _get_state_dir().parent / "logs"
1968
+ log_dir.mkdir(parents=True, exist_ok=True)
1969
+
1970
+ # Validate uvicorn exists
1971
+ uvicorn_path = _get_uvicorn_path()
1972
+ if not Path(uvicorn_path).exists():
1973
+ print(f"❌ uvicorn not found at {uvicorn_path}")
1974
+ print("Make sure the Apsimo virtual environment is set up.")
1975
+ sys.exit(1)
1976
+
1977
+ _load_dotenv()
1978
+ host = os.environ.get("COLONY_SIDECAR_HOST", "127.0.0.1")
1979
+ port = os.environ.get("COLONY_SIDECAR_PORT", "7777")
1980
+ log_level = os.environ.get("LOG_LEVEL", "info").lower()
1981
+ working_dir = str(Path(__file__).parent.parent)
1982
+ home = str(Path.home())
1983
+ state_dir = str(_get_state_dir())
1984
+ pythonpath = working_dir
1985
+
1986
+ # Build PATH with venv first
1987
+ venv_bin = str(Path.home() / ".colony-venv" / "bin")
1988
+ path_env = f"{venv_bin}:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
1989
+
1990
+ # Read template
1991
+ template_path = Path(__file__).parent / "service_template.plist"
1992
+ if not template_path.exists():
1993
+ print(f"❌ Template not found: {template_path}")
1994
+ sys.exit(1)
1995
+ template = template_path.read_text()
1996
+
1997
+ plist_content = template.format(
1998
+ uvicorn_path=uvicorn_path,
1999
+ host=host,
2000
+ port=port,
2001
+ log_level=log_level,
2002
+ working_dir=working_dir,
2003
+ home=home,
2004
+ path=path_env,
2005
+ state_dir=state_dir,
2006
+ pythonpath=pythonpath,
2007
+ log_path=str(log_dir / "sidecar.log"),
2008
+ )
2009
+
2010
+ plist_path.write_text(plist_content)
2011
+ print(f"✅ Plist written to {plist_path}")
2012
+
2013
+ # Check if already loaded
2014
+ result = subprocess.run(
2015
+ ["launchctl", "list", "ai.aevonix.colony-sidecar"],
2016
+ capture_output=True, text=True,
2017
+ )
2018
+ if result.returncode == 0:
2019
+ print(" Service already loaded, reloading...")
2020
+ subprocess.run(["launchctl", "unload", str(plist_path)], capture_output=True)
2021
+ time.sleep(0.5)
2022
+
2023
+ subprocess.run(["launchctl", "load", str(plist_path)], check=True)
2024
+ print("✅ Service loaded")
2025
+ print(f" Logs: {log_dir / 'sidecar.log'}")
2026
+ print(f" Check status: apsimo service status")
2027
+
2028
+
2029
+ def _cmd_service_uninstall() -> None:
2030
+ """Uninstall the launchd service."""
2031
+ plist_path = _get_plist_path()
2032
+ if plist_path.exists():
2033
+ subprocess.run(["launchctl", "unload", str(plist_path)], capture_output=True)
2034
+ plist_path.unlink()
2035
+ print(f"✅ Service uninstalled: {plist_path}")
2036
+ else:
2037
+ print("ℹ️ Service not installed")
2038
+
2039
+
2040
+ def _cmd_service_start() -> None:
2041
+ """Start the launchd service."""
2042
+ plist_path = _get_plist_path()
2043
+ if not plist_path.exists():
2044
+ print("❌ Service not installed. Run: apsimo service install")
2045
+ sys.exit(1)
2046
+
2047
+ # Check if already loaded
2048
+ if _is_service_loaded():
2049
+ print("ℹ️ Service already loaded and running")
2050
+ return
2051
+
2052
+ subprocess.run(["launchctl", "load", str(plist_path)], check=True)
2053
+ print("✅ Service started")
2054
+
2055
+
2056
+ def _cmd_service_stop() -> None:
2057
+ """Stop the launchd service (unload)."""
2058
+ plist_path = _get_plist_path()
2059
+ if not plist_path.exists():
2060
+ print("ℹ️ Service not installed")
2061
+ return
2062
+
2063
+ subprocess.run(["launchctl", "unload", str(plist_path)], capture_output=True)
2064
+ print("✅ Service stopped")
2065
+
2066
+
2067
+ def _cmd_service_restart() -> None:
2068
+ """Restart the launchd service."""
2069
+ plist_path = _get_plist_path()
2070
+ if not plist_path.exists():
2071
+ print("❌ Service not installed. Run: apsimo service install")
2072
+ sys.exit(1)
2073
+
2074
+ subprocess.run(["launchctl", "unload", str(plist_path)], capture_output=True)
2075
+ time.sleep(0.5)
2076
+ subprocess.run(["launchctl", "load", str(plist_path)], check=True)
2077
+ print("✅ Service restarted")
2078
+
2079
+
2080
+ def _cmd_service_status() -> None:
2081
+ """Show launchd service status."""
2082
+ result = subprocess.run(
2083
+ ["launchctl", "list", "ai.aevonix.colony-sidecar"],
2084
+ capture_output=True, text=True,
2085
+ )
2086
+ if result.returncode != 0:
2087
+ print("🔴 Service not installed or not loaded")
2088
+ print(" Install: apsimo service install")
2089
+ return
2090
+
2091
+ lines = result.stdout.strip().splitlines()
2092
+ if not lines:
2093
+ print("⚠️ Unexpected output from launchctl")
2094
+ return
2095
+
2096
+ parts = lines[0].split()
2097
+ if len(parts) >= 3:
2098
+ pid_str, status, label = parts[0], parts[1], parts[2]
2099
+ if pid_str == "-":
2100
+ print(f"🟡 Service loaded but not running")
2101
+ print(f" Label: {label}")
2102
+ print(f" Status: {status}")
2103
+ else:
2104
+ print(f"🟢 Service running")
2105
+ print(f" PID: {pid_str}")
2106
+ print(f" Label: {label}")
2107
+ else:
2108
+ print(f"⚠️ Unexpected format: {lines[0]}")
2109
+
2110
+
2111
+ def _cmd_mcp(args) -> None:
2112
+ """Handle apsimo mcp subcommands."""
2113
+ from apsimo.mcp.server import create_server, run_stdio, run_http
2114
+ from apsimo.mcp.config import (
2115
+ HARNESS_DEFS, detect_harnesses, add_to_harness, remove_from_harness,
2116
+ )
2117
+
2118
+ if not hasattr(args, "mcp_command") or not args.mcp_command:
2119
+ # Default: run the MCP server
2120
+ run_stdio()
2121
+ return
2122
+
2123
+ if args.mcp_command == "run":
2124
+ if args.transport == "http":
2125
+ run_http(host=args.host, port=args.port)
2126
+ else:
2127
+ run_stdio()
2128
+
2129
+ elif args.mcp_command == "detect":
2130
+ detected = detect_harnesses()
2131
+ print("Detected coding harnesses:")
2132
+ for hid, installed in detected.items():
2133
+ status = "installed" if installed else "not found"
2134
+ icon = " \u2705" if installed else " \u274c"
2135
+ print(f"{icon} {HARNESS_DEFS[hid]['display']:15s} {status}")
2136
+
2137
+ elif args.mcp_command == "setup":
2138
+ # Explicit launch overrides apply once at this command boundary.
2139
+ from apsimo.environment import clear_environment_aliases, apply_environment_aliases
2140
+ overrides = {"MCP_COMMAND": getattr(args, "mcp_server_command", None),
2141
+ "MCP_ARGS": getattr(args, "mcp_args", None)}
2142
+ if any(value is not None for value in overrides.values()):
2143
+ clear_environment_aliases()
2144
+ for key, value in overrides.items():
2145
+ if value is not None:
2146
+ os.environ.pop("COLONY_" + key, None)
2147
+ os.environ["APSIMO_" + key] = value
2148
+ apply_environment_aliases()
2149
+ # Handle --print-config (for distributed setups)
2150
+ if getattr(args, 'print_config', False):
2151
+ import json
2152
+ from apsimo.mcp.config import _mcp_config
2153
+
2154
+ contact_id = args.contact_id or os.environ.get("USER", "user")
2155
+ harness = args.harness or "crush"
2156
+
2157
+ # Get harness definition
2158
+ hdef = HARNESS_DEFS.get(harness)
2159
+ if not hdef:
2160
+ print(f"Unknown harness: {harness}")
2161
+ print(f"Available: {', '.join(HARNESS_DEFS.keys())}")
2162
+ return
2163
+
2164
+ # Build MCP config with optional overrides
2165
+ sidecar_url = getattr(args, 'sidecar_url', None)
2166
+ needs_type = hdef.get("mcp_type") == "stdio"
2167
+ mcp_config = _mcp_config(contact_id, hdef["source_tag"], include_type=needs_type, sidecar_url=sidecar_url)
2168
+
2169
+ # Print the full config snippet
2170
+ full_config = {hdef.get("mcp_key", "mcp_servers"): {"apsimo": mcp_config}}
2171
+ print(json.dumps(full_config, indent=2))
2172
+ print()
2173
+ print(f"# Add this to {hdef['config_path']}")
2174
+ print(f"# Contact ID: {contact_id}")
2175
+ print(f"# Source: {hdef['source_tag']}")
2176
+ return
2177
+
2178
+ detected = detect_harnesses()
2179
+ installed = {k: v for k, v in detected.items() if v}
2180
+
2181
+ if not installed:
2182
+ print(" No coding harnesses detected.")
2183
+ print(" Install one of: Claude Code, Codex, Crush, OpenCode, or Hermes")
2184
+ print(" Then run: apsimo mcp setup")
2185
+ print()
2186
+ print(" For distributed setups (Apsimo on remote machine):")
2187
+ print(" apsimo mcp setup --print-config --sidecar-url http://HOST:7777 --harness crush")
2188
+ return
2189
+
2190
+ # Get contact ID
2191
+ contact_id = args.contact_id
2192
+ if not contact_id:
2193
+ try:
2194
+ contact_id = input(" What should Apsimo call you? ").strip()
2195
+ except EOFError:
2196
+ contact_id = os.environ.get("USER", "user")
2197
+ if not contact_id:
2198
+ contact_id = os.environ.get("USER", "user")
2199
+
2200
+ # Determine which harnesses to configure
2201
+ if args.harness == "all":
2202
+ selected = list(installed.keys())
2203
+ elif args.harness:
2204
+ if args.harness not in installed:
2205
+ print(f" {HARNESS_DEFS[args.harness]['display']} is not installed")
2206
+ return
2207
+ selected = [args.harness]
2208
+ else:
2209
+ # Interactive selection
2210
+ print(" Detected coding harnesses:")
2211
+ options = list(installed.keys())
2212
+ for i, hid in enumerate(options, 1):
2213
+ print(f" [{i}] {HARNESS_DEFS[hid]['display']}")
2214
+ print()
2215
+ try:
2216
+ choice = input(" Which should Apsimo connect? (comma-separated, or 'all') [all]: ").strip()
2217
+ except EOFError:
2218
+ choice = "all"
2219
+
2220
+ if not choice or choice.lower() == "all":
2221
+ selected = options
2222
+ else:
2223
+ indices = [int(x.strip()) for x in choice.split(",") if x.strip().isdigit()]
2224
+ selected = [options[i - 1] for i in indices if 1 <= i <= len(options)]
2225
+
2226
+ if not selected:
2227
+ print(" No harnesses selected. Run 'apsimo mcp setup' again when ready.")
2228
+ return
2229
+
2230
+ # Configure each selected harness
2231
+ for hid in selected:
2232
+ hdef = HARNESS_DEFS[hid]
2233
+ print(f" Configuring {hdef['display']}...")
2234
+ diff = add_to_harness(hid, contact_id, dry_run=args.dry_run,
2235
+ sidecar_url=getattr(args, "sidecar_url", None))
2236
+ if diff is None:
2237
+ print(f" Already configured — skipping")
2238
+ elif args.dry_run:
2239
+ print(f" Would add (dry run):")
2240
+ print(diff)
2241
+ else:
2242
+ print(f" Added Apsimo MCP (source: {hdef['source_tag']})")
2243
+
2244
+ # Write skill
2245
+ from apsimo.harness_integration import write_apsimo_skill
2246
+ if write_apsimo_skill(hid):
2247
+ print(f" ✅ Diagnostic skill installed")
2248
+
2249
+ if args.dry_run:
2250
+ print(" Run without --dry-run to apply changes")
2251
+ else:
2252
+ print(f" Contact ID: {contact_id}")
2253
+ print(f" Start the sidecar with: apsimo start")
2254
+
2255
+ elif args.mcp_command == "remove":
2256
+ if args.harness == "all":
2257
+ targets = list(HARNESS_DEFS.keys())
2258
+ elif args.harness:
2259
+ targets = [args.harness]
2260
+ else:
2261
+ detected = detect_harnesses()
2262
+ targets = [k for k, v in detected.items() if v]
2263
+
2264
+ if not targets:
2265
+ print(" No harnesses to remove from.")
2266
+ return
2267
+
2268
+ for hid in targets:
2269
+ hdef = HARNESS_DEFS.get(hid)
2270
+ if not hdef:
2271
+ continue
2272
+ diff = remove_from_harness(hid, dry_run=args.dry_run)
2273
+ if diff:
2274
+ prefix = " Would remove" if args.dry_run else " Removed"
2275
+ print(f"{prefix}: {hdef['display']}")
2276
+
2277
+ # Remove skill (not dry_run)
2278
+ if not args.dry_run:
2279
+ from apsimo.harness_integration import remove_apsimo_skill
2280
+ if remove_apsimo_skill(hid):
2281
+ print(f" ✅ Diagnostic skill removed")
2282
+ else:
2283
+ print(f" {hdef['display']} — Apsimo not configured, skipping")
2284
+
2285
+ if args.dry_run:
2286
+ print(" Run without --dry-run to apply changes")
2287
+ else:
2288
+ print(" Apsimo MCP removed from harness configs")
2289
+
2290
+
2291
+ def _cmd_validate(args) -> None:
2292
+ """Run end-to-end pipeline validation."""
2293
+ import httpx
2294
+
2295
+ host = os.environ.get("COLONY_SIDECAR_HOST", "127.0.0.1")
2296
+ port = os.environ.get("COLONY_SIDECAR_PORT", "7777")
2297
+ url = f"http://{host}:{port}"
2298
+ api_key = os.environ.get("COLONY_API_KEY", "")
2299
+ headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
2300
+ state_dir = os.environ.get("COLONY_STATE_DIR", ".")
2301
+
2302
+ print("🧪 Apsimo E2E Pipeline Validation")
2303
+ print("=" * 40)
2304
+ print()
2305
+
2306
+ # Step 0: Confirm LLM usage
2307
+ if not args.yes:
2308
+ print("This test will send one prompt through the LLM to verify the full pipeline.")
2309
+ print("It uses a small amount of LLM API credits.")
2310
+ try:
2311
+ answer = input("Continue? [y/N] ").strip().lower()
2312
+ except EOFError:
2313
+ print(" \u26a0\ufe0f No stdin — use --yes to skip confirmation")
2314
+ return
2315
+ if answer not in ("y", "yes"):
2316
+ print("Cancelled.")
2317
+ return
2318
+
2319
+ # Step 1: Check sidecar is running
2320
+ print("\n[1/5] Checking sidecar...")
2321
+ try:
2322
+ resp = httpx.get(f"{url}/v1/host/health", headers=headers, timeout=5)
2323
+ data = resp.json()
2324
+ if data.get("status") != "ok":
2325
+ print(f" ❌ Sidecar not healthy: {data.get('status')}")
2326
+ return
2327
+ print(f" ✅ Sidecar running ({len(data.get('capabilities', []))} capabilities)")
2328
+ except Exception as e:
2329
+ print(f" ❌ Sidecar not reachable: {e}")
2330
+ print(" Start with: apsimo start")
2331
+ return
2332
+
2333
+ # Step 2: Seed test data
2334
+ print("\n[2/5] Seeding test data...")
2335
+ test_contact = f"validate-{uuid.uuid4().hex[:6]}"
2336
+
2337
+ r = httpx.post(f"{url}/v1/host/commitments", headers=headers,
2338
+ json={"person_id": test_contact, "description": "Validate E2E pipeline", "priority": 2}, timeout=5)
2339
+ if r.status_code not in (200, 201):
2340
+ print(f" ❌ Could not create commitment: {r.status_code}")
2341
+ return
2342
+ cid = r.json().get("id")
2343
+ print(f" ✅ Test commitment created")
2344
+
2345
+ r = httpx.post(f"{url}/v1/host/affect/events", headers=headers,
2346
+ json={"contact_id": test_contact, "valence": 0.6, "arousal": 0.4, "trigger": "validation test"}, timeout=5)
2347
+ print(f" ✅ Test affect recorded" if r.status_code in (200, 201) else f" ⚠️ Affect failed: {r.status_code}")
2348
+
2349
+ r = httpx.post(f"{url}/v1/host/mind/facts", headers=headers,
2350
+ json={"contact_id": test_contact, "fact": "Running pipeline validation", "category": "test", "confidence": 0.5}, timeout=5)
2351
+ print(f" ✅ Test fact recorded" if r.status_code in (200, 201) else f" ⚠️ Fact failed: {r.status_code}")
2352
+
2353
+ # Step 3: Context assembly
2354
+ print("\n[3/5] Testing context assembly...")
2355
+ r = httpx.post(f"{url}/v1/host/context/assemble", headers=headers,
2356
+ json={"identity": {"host_id": "validate"}, "context": {"session_id": "validate", "contact_id": test_contact},
2357
+ "incoming_message": {"role": "user", "content": "What am I working on?"}}, timeout=10)
2358
+ if r.status_code != 200:
2359
+ print(f" ❌ Context assembly failed: {r.status_code}")
2360
+ return
2361
+
2362
+ sections = r.json().get("sections", [])
2363
+ section_ids = [s["id"] for s in sections]
2364
+ expected = ["colony-commitments", "colony-affect", "colony-shared-facts"]
2365
+ found = [e for e in expected if e in section_ids]
2366
+ print(f" ✅ Context assembly: {len(sections)} sections, {len(found)}/{len(expected)} cognitive sections present")
2367
+
2368
+ # Step 4: Check LLM is configured (live-fire the sidecar's own router —
2369
+ # this is the pipeline Apsimo actually reasons with, harness-independent)
2370
+ print("\n[4/5] Checking LLM configuration...")
2371
+ llm_ok = False
2372
+ llm_latency = None
2373
+ try:
2374
+ r = httpx.get(f"{url}/v1/host/health/llm", headers=headers, timeout=30)
2375
+ if r.status_code == 200 and (r.json() or {}).get("ok"):
2376
+ body = r.json()
2377
+ llm_ok = True
2378
+ llm_latency = body.get("latency_ms")
2379
+ print(f" ✅ LLM router answered (tier={body.get('tier', '?')}, "
2380
+ f"latency={llm_latency}ms)")
2381
+ else:
2382
+ detail = ""
2383
+ try:
2384
+ detail = (r.json() or {}).get("error") or ""
2385
+ except Exception:
2386
+ pass
2387
+ print(f" ⚠️ LLM router check failed (HTTP {r.status_code}) {detail}")
2388
+ print(" Configure models via POST /v1/host/configure or re-run "
2389
+ "'apsimo init' (llm-config step)")
2390
+ except Exception as exc:
2391
+ print(f" ⚠️ Could not reach the LLM health endpoint: {exc}")
2392
+
2393
+ # Step 5: Full pipeline — context assembly reached the cognitive sections
2394
+ # (step 3) and the router answers (step 4); that IS the full sidecar
2395
+ # pipeline. A connected harness exercises it end-to-end on its next turn.
2396
+ print("\n[5/5] Pipeline verdict...")
2397
+ if llm_ok and len(found) >= 2:
2398
+ print(" ✅ Full sidecar pipeline working — context assembly + LLM router live")
2399
+ elif llm_ok:
2400
+ print(" ⚠️ LLM live but cognitive context sections are thin — seed data "
2401
+ "or run a few turns, then re-validate")
2402
+ else:
2403
+ print(" ⚪ LLM not verified — context pipeline validated, reasoning not")
2404
+
2405
+ # Cleanup: delete test commitment
2406
+ if cid:
2407
+ httpx.delete(f"{url}/v1/host/commitments/{cid}", headers=headers, timeout=5)
2408
+
2409
+ # Write validation stamp
2410
+ stamp_path = Path(state_dir) / ".colony-e2e-validated"
2411
+ stamp_data = {
2412
+ "validated_at": datetime.now(timezone.utc).isoformat(),
2413
+ "context_sections": len(sections),
2414
+ "cognitive_sections": len(found),
2415
+ "llm_tested": llm_ok,
2416
+ }
2417
+ stamp_path.write_text(json.dumps(stamp_data, indent=2))
2418
+
2419
+ # Summary
2420
+ print()
2421
+ all_ok = len(found) >= 2 # At least commitments + one other
2422
+ if all_ok:
2423
+ print("🟢 Pipeline validation passed")
2424
+ print(f" Context assembly: {len(sections)} sections")
2425
+ print(f" Cognitive sections: {', '.join(found)}")
2426
+ if not llm_ok:
2427
+ print(f" ⚠️ LLM pipeline not tested — configure LLM and re-run 'apsimo validate'")
2428
+ else:
2429
+ print("🔴 Pipeline validation incomplete")
2430
+ print(f" Missing sections: {set(expected) - set(found)}")
2431
+ print(f" Check sidecar logs and configuration")
2432
+
2433
+
2434
+ def _cmd_doctor(args) -> None:
2435
+ """Diagnose configuration and runtime health (v0.19.0 check engine)."""
2436
+ _load_dotenv()
2437
+ from apsimo.doctor import (
2438
+ default_api_key,
2439
+ default_colony_url,
2440
+ exit_code,
2441
+ format_report,
2442
+ results_to_json,
2443
+ run_doctor,
2444
+ )
2445
+
2446
+ url = args.url or default_colony_url()
2447
+ api_key = args.api_key if args.api_key is not None else default_api_key()
2448
+
2449
+ if getattr(args, "clean_orphans", False):
2450
+ killed = _cleanup_orphans(kill=True)
2451
+ print(f"🧹 Cleaned {killed} orphaned sidecar process(es)\n")
2452
+
2453
+ if getattr(args, "fix", False):
2454
+ # Safe, idempotent config repairs only — everything else gets a
2455
+ # printed remedy instead of an automatic change.
2456
+ try:
2457
+ from apsimo.setup import repair_persisted_llm_config
2458
+
2459
+ fixed = repair_persisted_llm_config()
2460
+ if fixed:
2461
+ print("🔧 Applied LLM config fixes: " + ", ".join(fixed) + "\n")
2462
+ else:
2463
+ print("🔧 No automatic fixes applicable\n")
2464
+ except Exception as exc:
2465
+ print(f"🔧 Automatic fixes unavailable: {exc}\n")
2466
+
2467
+ results = run_doctor(colony_url=url, api_key=api_key, timeout=args.timeout)
2468
+
2469
+ if args.json:
2470
+ print(json.dumps(results_to_json(results), indent=2))
2471
+ else:
2472
+ color = sys.stdout.isatty() and not os.environ.get("NO_COLOR")
2473
+ print(format_report(results, colony_url=url, color=color))
2474
+
2475
+ sys.exit(exit_code(results))
2476
+
2477
+
2478
+ def _load_dotenv() -> None:
2479
+ from apsimo.util.instance import load_environment
2480
+ load_environment()
2481
+ from apsimo.environment import apply_environment_aliases
2482
+ apply_environment_aliases()
2483
+
2484
+
2485
+ def _cmd_persona(args) -> None:
2486
+ """Handle persona subcommands."""
2487
+ _load_dotenv()
2488
+
2489
+ cmd = getattr(args, "persona_command", None)
2490
+ if not cmd:
2491
+ print("Usage: apsimo persona [setup|validate|services|backup|restore|uninstall]")
2492
+ return
2493
+
2494
+ state_dir = os.environ.get("COLONY_STATE_DIR", str(Path.home() / ".colony" / "data"))
2495
+
2496
+ if cmd == "validate":
2497
+ from apsimo.persona.manifest import load_manifest
2498
+ try:
2499
+ manifest = load_manifest(args.repo)
2500
+ except Exception as e:
2501
+ print(f" Manifest error: {e}")
2502
+ raise SystemExit(1)
2503
+ from apsimo.persona.engine import PersonaEngine
2504
+ engine = PersonaEngine(manifest, Path(args.repo), state_dir=Path(state_dir))
2505
+ issues = engine.validate()
2506
+ if issues:
2507
+ print(" Validation issues:")
2508
+ for issue in issues:
2509
+ print(f" - {issue}")
2510
+ raise SystemExit(1)
2511
+ print(f" Persona '{manifest.name}' v{manifest.version} is valid")
2512
+ print(f" Services: {len(manifest.services)}")
2513
+ print(f" Companion apps: {len(manifest.companion_apps)}")
2514
+ print(f" Tunnels: {len(manifest.tunnels)}")
2515
+ print(f" Secrets: {len(manifest.secrets)}")
2516
+ print(f" Variables: {len(manifest.variables)}")
2517
+
2518
+ elif cmd == "setup":
2519
+ print("Legacy persona setup is deprecated for new installations; use apsimo init. "
2520
+ "Existing manifest deployments and their backup/restore data remain supported.", file=sys.stderr)
2521
+ from apsimo.persona.manifest import load_manifest
2522
+ try:
2523
+ manifest = load_manifest(args.repo)
2524
+ except Exception as e:
2525
+ print(f" Manifest error: {e}")
2526
+ raise SystemExit(1)
2527
+
2528
+ provided_vars = None
2529
+ provided_secrets = None
2530
+ if args.config:
2531
+ try:
2532
+ import yaml
2533
+ config_data = yaml.safe_load(Path(args.config).read_text())
2534
+ provided_vars = config_data.get("variables", {})
2535
+ provided_secrets = config_data.get("secrets", {})
2536
+ except Exception as e:
2537
+ print(f" Config file error: {e}")
2538
+ raise SystemExit(1)
2539
+
2540
+ colony_url = f"http://{os.environ.get('COLONY_SIDECAR_HOST', '127.0.0.1')}:{os.environ.get('COLONY_SIDECAR_PORT', '7777')}"
2541
+ api_key = os.environ.get("COLONY_API_KEY", "")
2542
+
2543
+ from apsimo.persona.engine import PersonaEngine
2544
+ engine = PersonaEngine(
2545
+ manifest, Path(args.repo),
2546
+ state_dir=Path(state_dir),
2547
+ colony_url=colony_url,
2548
+ colony_api_key=api_key,
2549
+ )
2550
+ summary = engine.setup(
2551
+ variables=provided_vars,
2552
+ secrets=provided_secrets,
2553
+ interactive=args.config is None,
2554
+ )
2555
+ if "errors" in summary:
2556
+ print(" Setup failed:")
2557
+ for err in summary["errors"]:
2558
+ print(f" - {err}")
2559
+ raise SystemExit(1)
2560
+ if summary.get("warnings"):
2561
+ print(f" Persona '{manifest.name}' setup finished with unapplied settings")
2562
+ for warning in summary["warnings"]:
2563
+ print(f" {warning}")
2564
+ else:
2565
+ print(f" Persona '{manifest.name}' setup complete")
2566
+ print(f" Steps: {', '.join(summary.get('steps', []))}")
2567
+
2568
+ elif cmd == "services":
2569
+ active_persona = _find_active_persona(state_dir)
2570
+ if not active_persona:
2571
+ print(" No active persona found")
2572
+ raise SystemExit(1)
2573
+ manifest, repo_path, engine = active_persona
2574
+
2575
+ action = args.action
2576
+ if action == "status":
2577
+ statuses = engine.services_status()
2578
+ for s in statuses:
2579
+ print(f" {s['name']}: {s['status']}")
2580
+ elif action == "start":
2581
+ results = engine.services_start()
2582
+ for r in results:
2583
+ print(f" {r['name']}: {r['result']}")
2584
+ elif action == "stop":
2585
+ results = engine.services_stop()
2586
+ for r in results:
2587
+ print(f" {r['name']}: {r['result']}")
2588
+ elif action == "install":
2589
+ engine._install_services()
2590
+ print(" Service definitions installed")
2591
+ elif action == "uninstall":
2592
+ results = engine.services_uninstall()
2593
+ for r in results:
2594
+ print(f" {r['name']}: {r['result']}")
2595
+
2596
+ elif cmd == "backup":
2597
+ from apsimo.backup import create_full_backup
2598
+
2599
+ active = _find_active_persona(state_dir)
2600
+ host_paths = []
2601
+ if active:
2602
+ _, _, engine = active
2603
+ manifest = engine._manifest
2604
+ if manifest.backup:
2605
+ host_paths = manifest.backup.host_state + manifest.backup.custom
2606
+
2607
+ passphrase = None
2608
+ if args.passphrase:
2609
+ passphrase = args.passphrase.encode()
2610
+ elif args.encrypt:
2611
+ import getpass
2612
+ passphrase = getpass.getpass("Backup passphrase: ").encode()
2613
+
2614
+ output_dir = args.output or os.path.expanduser("~/colony-backups")
2615
+ archive = create_full_backup(
2616
+ state_dir, output_dir,
2617
+ passphrase=passphrase,
2618
+ include_host_paths=host_paths if host_paths else None,
2619
+ )
2620
+ print(f" Persona backup saved to {archive}")
2621
+
2622
+ elif cmd == "restore":
2623
+ from apsimo.backup import restore_full_backup
2624
+
2625
+ passphrase = None
2626
+ if args.passphrase:
2627
+ passphrase = args.passphrase.encode()
2628
+ elif args.archive.endswith(".enc"):
2629
+ import getpass
2630
+ passphrase = getpass.getpass("Backup passphrase: ").encode()
2631
+
2632
+ summary = restore_full_backup(
2633
+ args.archive, state_dir,
2634
+ passphrase=passphrase,
2635
+ force_identity=getattr(args, "force_identity", False),
2636
+ )
2637
+ print(f" Restored colony: {summary['colony_id']}")
2638
+ print(f" Databases: {', '.join(summary.get('databases', []))}")
2639
+ print(" Reconcile current authority, erasures and completed effects before starting services.")
2640
+
2641
+ elif cmd == "uninstall":
2642
+ active = _find_active_persona(state_dir)
2643
+ if not active:
2644
+ print(" No active persona found")
2645
+ return
2646
+ _, _, engine = active
2647
+ results = engine.services_uninstall()
2648
+ for r in results:
2649
+ print(f" {r['name']}: {r['result']}")
2650
+ print(" Persona uninstalled")
2651
+
2652
+ else:
2653
+ print("Usage: apsimo persona [setup|validate|services|backup|restore|uninstall]")
2654
+
2655
+
2656
+ def _find_active_persona(state_dir: str):
2657
+ """Find the active persona from saved state. Returns (manifest, repo_path, engine) or None."""
2658
+ persona_base = Path.home() / ".colony" / "persona"
2659
+ if not persona_base.is_dir():
2660
+ return None
2661
+
2662
+ for persona_dir in persona_base.iterdir():
2663
+ if not persona_dir.is_dir():
2664
+ continue
2665
+ manifest_snapshot = persona_dir / "manifest.json"
2666
+ if manifest_snapshot.exists():
2667
+ try:
2668
+ from apsimo.persona.manifest import PersonaManifest
2669
+ from apsimo.persona.engine import PersonaEngine
2670
+
2671
+ data = json.loads(manifest_snapshot.read_text())
2672
+ manifest = PersonaManifest.model_validate(data)
2673
+
2674
+ repo_path = Path(".")
2675
+ engine = PersonaEngine(
2676
+ manifest, repo_path,
2677
+ state_dir=Path(state_dir),
2678
+ colony_url=f"http://{os.environ.get('COLONY_SIDECAR_HOST', '127.0.0.1')}:{os.environ.get('COLONY_SIDECAR_PORT', '7777')}",
2679
+ colony_api_key=os.environ.get("COLONY_API_KEY", ""),
2680
+ )
2681
+ return manifest, repo_path, engine
2682
+ except Exception:
2683
+ continue
2684
+ return None
2685
+
2686
+
2687
+ if __name__ == "__main__":
2688
+ main()