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