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