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