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,1260 @@
|
|
|
1
|
+
"""ProactiveDeliveryBridge — queues and manages proactive message delivery.
|
|
2
|
+
|
|
3
|
+
The autonomy loop calls deliver() with an initiative or insight. The bridge:
|
|
4
|
+
1. Rate-limits per person
|
|
5
|
+
2. Queues the message in pending deliveries
|
|
6
|
+
3. The gateway polls GET /v1/delivery/pending and sends via platform adapters
|
|
7
|
+
|
|
8
|
+
Delivery channels:
|
|
9
|
+
PUSH → deliver immediately (queued for gateway polling)
|
|
10
|
+
IN_SESSION → store for injection into next conversation's system prompt
|
|
11
|
+
DIGEST → accumulate for bundled morning briefing (wired but not scheduled by default)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import uuid
|
|
19
|
+
import json
|
|
20
|
+
import hashlib
|
|
21
|
+
import sqlite3
|
|
22
|
+
import stat
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
import urllib.parse
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from datetime import datetime, timedelta, timezone
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any, Dict, List, Optional
|
|
30
|
+
|
|
31
|
+
from apsimo.delivery.rate_limiter import DeliveryRateLimiter
|
|
32
|
+
from apsimo.delivery.channels import ChannelRegistry
|
|
33
|
+
from apsimo.workers.queue_worker import encode_hermes_webhook
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
# Default internal port for the gateway's /internal/deliver endpoint.
|
|
38
|
+
_DEFAULT_GATEWAY_INTERNAL_PORT = 7779
|
|
39
|
+
_GATEWAY_CONTRACTS = frozenset(("legacy_delivery", "governed_admission_v1"))
|
|
40
|
+
_GOVERNED_GATEWAY_PLATFORMS = frozenset(("whatsapp",))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _strict_gateway_json(raw: str) -> Optional[Dict[str, Any]]:
|
|
44
|
+
if not isinstance(raw, str) or len(raw.encode("utf-8")) > 64 * 1024:
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
def pairs(values):
|
|
48
|
+
result: Dict[str, Any] = {}
|
|
49
|
+
for key, value in values:
|
|
50
|
+
if key in result:
|
|
51
|
+
raise ValueError("duplicate gateway response field")
|
|
52
|
+
result[key] = value
|
|
53
|
+
return result
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
value = json.loads(
|
|
57
|
+
raw,
|
|
58
|
+
object_pairs_hook=pairs,
|
|
59
|
+
parse_constant=lambda _value: (_ for _ in ()).throw(
|
|
60
|
+
ValueError("non-finite gateway response number")
|
|
61
|
+
),
|
|
62
|
+
)
|
|
63
|
+
except (TypeError, ValueError, UnicodeError, RecursionError):
|
|
64
|
+
return None
|
|
65
|
+
return value if isinstance(value, dict) else None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class GatewayPushResult:
|
|
70
|
+
"""Truthful result of handing one message to a gateway boundary.
|
|
71
|
+
|
|
72
|
+
``accepted`` means only that the immediate boundary accepted the request.
|
|
73
|
+
``provider_delivered`` is a separate fact so a governed approval queue can
|
|
74
|
+
never be reported as a completed send.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
accepted: bool
|
|
78
|
+
provider_delivered: bool
|
|
79
|
+
contract: str
|
|
80
|
+
admission_state: str = ""
|
|
81
|
+
delivery_id: str = ""
|
|
82
|
+
terminal: bool = False
|
|
83
|
+
observation_new: bool = True
|
|
84
|
+
|
|
85
|
+
def __bool__(self) -> bool:
|
|
86
|
+
return self.accepted
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class _GatewayOutcomeStore:
|
|
90
|
+
"""PII-free durable lifecycle cache for one governed gateway boundary."""
|
|
91
|
+
|
|
92
|
+
_STATES = frozenset(
|
|
93
|
+
("queued", "awaiting_approval", "accepted", "delivered", "failed", "ambiguous")
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def __init__(self, path: Path, *, clock, poll_seconds: float) -> None:
|
|
97
|
+
raw_path = Path(path).expanduser()
|
|
98
|
+
self.path = raw_path if raw_path.is_absolute() else Path.cwd() / raw_path
|
|
99
|
+
self.clock = clock
|
|
100
|
+
self.poll_seconds = float(poll_seconds)
|
|
101
|
+
self._lock = threading.RLock()
|
|
102
|
+
# resolve() would hide a symlink before the safety check. Inspect the
|
|
103
|
+
# existing ancestry before and after creating a missing private state
|
|
104
|
+
# directory, and let SQLite see the same unresolved absolute path.
|
|
105
|
+
for component in (self.path, *self.path.parents):
|
|
106
|
+
if component.is_symlink():
|
|
107
|
+
raise ValueError("gateway outcome path is unsafe")
|
|
108
|
+
parent_existed = self.path.parent.exists()
|
|
109
|
+
self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
110
|
+
if not parent_existed:
|
|
111
|
+
os.chmod(self.path.parent, 0o700)
|
|
112
|
+
for component in (self.path, *self.path.parents):
|
|
113
|
+
if component.is_symlink():
|
|
114
|
+
raise ValueError("gateway outcome path is unsafe")
|
|
115
|
+
self._conn = sqlite3.connect(
|
|
116
|
+
str(self.path), timeout=30.0, isolation_level=None, check_same_thread=False
|
|
117
|
+
)
|
|
118
|
+
os.chmod(self.path, 0o600)
|
|
119
|
+
if stat.S_IMODE(self.path.stat().st_mode) != 0o600:
|
|
120
|
+
self._conn.close()
|
|
121
|
+
raise ValueError("gateway outcome database must be private")
|
|
122
|
+
self._conn.row_factory = sqlite3.Row
|
|
123
|
+
self._conn.execute("PRAGMA busy_timeout=30000")
|
|
124
|
+
self._conn.execute("PRAGMA journal_mode=DELETE")
|
|
125
|
+
self._conn.execute("PRAGMA synchronous=FULL")
|
|
126
|
+
version = int(self._conn.execute("PRAGMA user_version").fetchone()[0])
|
|
127
|
+
if version not in (0, 1):
|
|
128
|
+
self._conn.close()
|
|
129
|
+
raise ValueError("gateway outcome schema is unsupported")
|
|
130
|
+
self._conn.executescript(
|
|
131
|
+
"""
|
|
132
|
+
CREATE TABLE IF NOT EXISTS governed_gateway_outcomes (
|
|
133
|
+
delivery_id TEXT PRIMARY KEY,
|
|
134
|
+
request_sha256 TEXT NOT NULL,
|
|
135
|
+
state TEXT NOT NULL,
|
|
136
|
+
intent_id TEXT NOT NULL,
|
|
137
|
+
provider_delivered INTEGER NOT NULL,
|
|
138
|
+
next_poll_at REAL NOT NULL,
|
|
139
|
+
created_at REAL NOT NULL,
|
|
140
|
+
updated_at REAL NOT NULL
|
|
141
|
+
);
|
|
142
|
+
"""
|
|
143
|
+
)
|
|
144
|
+
if version == 0:
|
|
145
|
+
self._conn.execute("PRAGMA user_version=1")
|
|
146
|
+
|
|
147
|
+
@staticmethod
|
|
148
|
+
def _row(row) -> Dict[str, Any]:
|
|
149
|
+
value = dict(row)
|
|
150
|
+
if value.get("state") not in _GatewayOutcomeStore._STATES:
|
|
151
|
+
raise ValueError("gateway outcome state is invalid")
|
|
152
|
+
value["provider_delivered"] = bool(value["provider_delivered"])
|
|
153
|
+
return value
|
|
154
|
+
|
|
155
|
+
def reserve(self, delivery_id: str, request_sha256: str) -> Dict[str, Any]:
|
|
156
|
+
now = float(self.clock())
|
|
157
|
+
with self._lock:
|
|
158
|
+
self._conn.execute("BEGIN IMMEDIATE")
|
|
159
|
+
try:
|
|
160
|
+
row = self._conn.execute(
|
|
161
|
+
"SELECT * FROM governed_gateway_outcomes WHERE delivery_id=?",
|
|
162
|
+
(delivery_id,),
|
|
163
|
+
).fetchone()
|
|
164
|
+
if row is None:
|
|
165
|
+
self._conn.execute(
|
|
166
|
+
"""INSERT INTO governed_gateway_outcomes
|
|
167
|
+
(delivery_id,request_sha256,state,intent_id,
|
|
168
|
+
provider_delivered,next_poll_at,created_at,updated_at)
|
|
169
|
+
VALUES (?,?,'queued','',0,0,?,?)""",
|
|
170
|
+
(delivery_id, request_sha256, now, now),
|
|
171
|
+
)
|
|
172
|
+
elif row["request_sha256"] != request_sha256:
|
|
173
|
+
raise ValueError("gateway delivery identity conflicts")
|
|
174
|
+
self._conn.execute("COMMIT")
|
|
175
|
+
except Exception:
|
|
176
|
+
self._conn.execute("ROLLBACK")
|
|
177
|
+
raise
|
|
178
|
+
return self.get(delivery_id)
|
|
179
|
+
|
|
180
|
+
def get(self, delivery_id: str) -> Dict[str, Any]:
|
|
181
|
+
with self._lock:
|
|
182
|
+
row = self._conn.execute(
|
|
183
|
+
"SELECT * FROM governed_gateway_outcomes WHERE delivery_id=?",
|
|
184
|
+
(delivery_id,),
|
|
185
|
+
).fetchone()
|
|
186
|
+
if row is None:
|
|
187
|
+
raise ValueError("gateway outcome is unavailable")
|
|
188
|
+
return self._row(row)
|
|
189
|
+
|
|
190
|
+
def observe(
|
|
191
|
+
self, delivery_id: str, *, state: str, intent_id: str,
|
|
192
|
+
provider_delivered: bool,
|
|
193
|
+
) -> tuple[Dict[str, Any], bool]:
|
|
194
|
+
if state not in self._STATES or state == "queued":
|
|
195
|
+
raise ValueError("gateway outcome transition is invalid")
|
|
196
|
+
if provider_delivered is not (state == "delivered"):
|
|
197
|
+
raise ValueError("gateway provider-delivery outcome is inconsistent")
|
|
198
|
+
now = float(self.clock())
|
|
199
|
+
terminal = state in {"delivered", "failed", "ambiguous"}
|
|
200
|
+
next_poll_at = 0.0 if terminal else now + self.poll_seconds
|
|
201
|
+
with self._lock:
|
|
202
|
+
current = self.get(delivery_id)
|
|
203
|
+
transitions = {
|
|
204
|
+
"queued": {
|
|
205
|
+
"awaiting_approval", "accepted", "delivered", "failed", "ambiguous",
|
|
206
|
+
},
|
|
207
|
+
"awaiting_approval": {
|
|
208
|
+
"awaiting_approval", "accepted", "delivered", "failed", "ambiguous",
|
|
209
|
+
},
|
|
210
|
+
"accepted": {"accepted", "delivered", "failed", "ambiguous"},
|
|
211
|
+
"delivered": {"delivered"},
|
|
212
|
+
"failed": {"failed"},
|
|
213
|
+
"ambiguous": {"ambiguous"},
|
|
214
|
+
}
|
|
215
|
+
if state not in transitions[current["state"]]:
|
|
216
|
+
raise ValueError("gateway outcome transition regressed")
|
|
217
|
+
# A governed intent is immutable once the producer has admitted
|
|
218
|
+
# it. A direct provider delivery may legitimately be the first
|
|
219
|
+
# and only observation and carry the boundary's empty intent ID.
|
|
220
|
+
# Every pending or failed/ambiguous producer observation requires
|
|
221
|
+
# a nonempty intent, and every later observation must match it.
|
|
222
|
+
current_intent = str(current["intent_id"])
|
|
223
|
+
if current["state"] == "queued":
|
|
224
|
+
if state != "delivered" and not intent_id:
|
|
225
|
+
raise ValueError("gateway intent identity is missing")
|
|
226
|
+
elif not current_intent or intent_id != current_intent:
|
|
227
|
+
raise ValueError("gateway intent identity changed")
|
|
228
|
+
if current["state"] in {"delivered", "failed", "ambiguous"}:
|
|
229
|
+
if (
|
|
230
|
+
state != current["state"]
|
|
231
|
+
or intent_id != current_intent
|
|
232
|
+
or provider_delivered is not current["provider_delivered"]
|
|
233
|
+
):
|
|
234
|
+
raise ValueError("gateway terminal outcome is immutable")
|
|
235
|
+
return current, False
|
|
236
|
+
changed = bool(
|
|
237
|
+
current["state"] != state
|
|
238
|
+
or current["intent_id"] != intent_id
|
|
239
|
+
or current["provider_delivered"] is not provider_delivered
|
|
240
|
+
)
|
|
241
|
+
self._conn.execute(
|
|
242
|
+
"""UPDATE governed_gateway_outcomes
|
|
243
|
+
SET state=?,intent_id=?,provider_delivered=?,next_poll_at=?,updated_at=?
|
|
244
|
+
WHERE delivery_id=?""",
|
|
245
|
+
(
|
|
246
|
+
state, intent_id, 1 if provider_delivered else 0,
|
|
247
|
+
next_poll_at, now, delivery_id,
|
|
248
|
+
),
|
|
249
|
+
)
|
|
250
|
+
return self.get(delivery_id), changed
|
|
251
|
+
|
|
252
|
+
def defer(self, delivery_id: str) -> Dict[str, Any]:
|
|
253
|
+
now = float(self.clock())
|
|
254
|
+
with self._lock:
|
|
255
|
+
self._conn.execute(
|
|
256
|
+
"""UPDATE governed_gateway_outcomes
|
|
257
|
+
SET next_poll_at=?,updated_at=? WHERE delivery_id=?""",
|
|
258
|
+
(now + self.poll_seconds, now, delivery_id),
|
|
259
|
+
)
|
|
260
|
+
return self.get(delivery_id)
|
|
261
|
+
|
|
262
|
+
def pending_delivery_ids(self, *, limit: int = 100) -> tuple[str, ...]:
|
|
263
|
+
bounded = max(1, min(100, int(limit)))
|
|
264
|
+
with self._lock:
|
|
265
|
+
rows = self._conn.execute(
|
|
266
|
+
"""SELECT delivery_id FROM governed_gateway_outcomes
|
|
267
|
+
WHERE state IN ('accepted','awaiting_approval')
|
|
268
|
+
ORDER BY created_at ASC,delivery_id ASC LIMIT ?""",
|
|
269
|
+
(bounded,),
|
|
270
|
+
).fetchall()
|
|
271
|
+
return tuple(str(row["delivery_id"]) for row in rows)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@dataclass
|
|
275
|
+
class PendingDelivery:
|
|
276
|
+
"""A proactive message waiting to be sent to a user."""
|
|
277
|
+
delivery_id: str
|
|
278
|
+
person_id: str
|
|
279
|
+
content: str
|
|
280
|
+
channel: str # "push" | "in_session" | "digest"
|
|
281
|
+
urgency: float
|
|
282
|
+
source: str # "initiative" | "insight" | "anomaly"
|
|
283
|
+
initiative_id: Optional[str]
|
|
284
|
+
queued_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
285
|
+
sent: bool = False
|
|
286
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class ProactiveDeliveryBridge:
|
|
290
|
+
"""Routes autonomy loop outputs (initiatives, insights) to users via the gateway.
|
|
291
|
+
|
|
292
|
+
Two delivery paths are supported:
|
|
293
|
+
|
|
294
|
+
1. **Poll path** (default): The gateway polls /v1/delivery/pending every few
|
|
295
|
+
seconds and POSTs each pending delivery to the appropriate platform adapter.
|
|
296
|
+
|
|
297
|
+
2. **Push path** (when gateway_url is set): ``push_to_gateway()`` POSTs
|
|
298
|
+
directly to the gateway's internal ``POST /internal/deliver`` endpoint so
|
|
299
|
+
messages are delivered immediately without polling latency.
|
|
300
|
+
"""
|
|
301
|
+
|
|
302
|
+
def __init__(
|
|
303
|
+
self,
|
|
304
|
+
rate_limiter: Optional[DeliveryRateLimiter] = None,
|
|
305
|
+
gateway_url: Optional[str] = None,
|
|
306
|
+
gateway_api_key: Optional[str] = None,
|
|
307
|
+
channel_registry: Optional[ChannelRegistry] = None,
|
|
308
|
+
gateway_contract: Optional[str] = None,
|
|
309
|
+
gateway_outcome_db: Optional[str] = None,
|
|
310
|
+
gateway_poll_seconds: float = 5.0,
|
|
311
|
+
clock=time.time,
|
|
312
|
+
) -> None:
|
|
313
|
+
if rate_limiter is None:
|
|
314
|
+
# Persist rate-limit state so a crashloop can't reset the daily
|
|
315
|
+
# caps. Lives alongside other sidecar state under COLONY_STATE_DIR.
|
|
316
|
+
state_dir = os.environ.get("COLONY_STATE_DIR", ".")
|
|
317
|
+
db_path = Path(state_dir) / "colony-delivery-rate-limit.db"
|
|
318
|
+
rate_limiter = DeliveryRateLimiter(db_path=db_path)
|
|
319
|
+
self._rate_limiter = rate_limiter
|
|
320
|
+
self._pending: List[PendingDelivery] = []
|
|
321
|
+
self._sent: List[PendingDelivery] = [] # short history for observability
|
|
322
|
+
self._sent_max: int = 500 # cap to prevent unbounded growth
|
|
323
|
+
|
|
324
|
+
# Gateway push path — optional direct delivery via /internal/deliver
|
|
325
|
+
_port = int(os.environ.get("COLONY_GATEWAY_INTERNAL_PORT", _DEFAULT_GATEWAY_INTERNAL_PORT))
|
|
326
|
+
self._gateway_url: str = (
|
|
327
|
+
gateway_url
|
|
328
|
+
or os.environ.get("COLONY_GATEWAY_INTERNAL_URL", "")
|
|
329
|
+
or f"http://localhost:{_port}"
|
|
330
|
+
)
|
|
331
|
+
self._gateway_api_key: str = (
|
|
332
|
+
gateway_api_key
|
|
333
|
+
or os.environ.get("COLONY_GATEWAY_API_KEY", "")
|
|
334
|
+
or os.environ.get("COLONY_API_KEY", "")
|
|
335
|
+
)
|
|
336
|
+
self._gateway_contract = str(
|
|
337
|
+
gateway_contract
|
|
338
|
+
or os.environ.get("COLONY_GATEWAY_CONTRACT", "legacy_delivery")
|
|
339
|
+
).strip().lower()
|
|
340
|
+
if self._gateway_contract not in _GATEWAY_CONTRACTS:
|
|
341
|
+
raise ValueError("unsupported gateway response contract")
|
|
342
|
+
if self._gateway_contract == "governed_admission_v1":
|
|
343
|
+
parsed = urllib.parse.urlsplit(self._gateway_url)
|
|
344
|
+
if (
|
|
345
|
+
parsed.scheme != "http"
|
|
346
|
+
or parsed.hostname not in {"127.0.0.1", "localhost", "::1"}
|
|
347
|
+
or parsed.username
|
|
348
|
+
or parsed.password
|
|
349
|
+
or parsed.query
|
|
350
|
+
or parsed.fragment
|
|
351
|
+
or parsed.path not in ("", "/")
|
|
352
|
+
or parsed.port is None
|
|
353
|
+
or not self._gateway_api_key
|
|
354
|
+
):
|
|
355
|
+
raise ValueError(
|
|
356
|
+
"governed gateway admission requires one authenticated loopback origin"
|
|
357
|
+
)
|
|
358
|
+
try:
|
|
359
|
+
bounded_poll = float(gateway_poll_seconds)
|
|
360
|
+
except (TypeError, ValueError) as error:
|
|
361
|
+
raise ValueError("gateway lifecycle poll interval is invalid") from error
|
|
362
|
+
if not 1.0 <= bounded_poll <= 300.0:
|
|
363
|
+
raise ValueError("gateway lifecycle poll interval is invalid")
|
|
364
|
+
self._clock = clock
|
|
365
|
+
self._gateway_poll_seconds = bounded_poll
|
|
366
|
+
self._gateway_outcomes: Optional[_GatewayOutcomeStore] = None
|
|
367
|
+
if self._gateway_contract == "governed_admission_v1":
|
|
368
|
+
state_dir = Path(os.environ.get("COLONY_STATE_DIR", "."))
|
|
369
|
+
outcome_path = Path(gateway_outcome_db) if gateway_outcome_db else (
|
|
370
|
+
state_dir / "colony-governed-gateway-outcomes.db"
|
|
371
|
+
)
|
|
372
|
+
self._gateway_outcomes = _GatewayOutcomeStore(
|
|
373
|
+
outcome_path, clock=clock, poll_seconds=bounded_poll
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
# Channel registry for per-person delivery routing
|
|
377
|
+
self._channel_registry = channel_registry or ChannelRegistry.load()
|
|
378
|
+
|
|
379
|
+
# Home channel config read from env vars — used to resolve
|
|
380
|
+
# platform/chat_id when only person_id is available.
|
|
381
|
+
self._home_channels: Dict[str, Dict[str, str]] = self._load_home_channels()
|
|
382
|
+
|
|
383
|
+
def governed_gateway_admission_enabled(
|
|
384
|
+
self, platform: Optional[str] = None,
|
|
385
|
+
) -> bool:
|
|
386
|
+
"""Whether governed admission owns this exact platform route.
|
|
387
|
+
|
|
388
|
+
A no-argument call reports whether lifecycle reconciliation should run.
|
|
389
|
+
Send-authority callers must provide a platform so a governed WhatsApp
|
|
390
|
+
sidecar cannot silently exempt an RCS/SMS route from Colony's legacy
|
|
391
|
+
non-owner approval gate.
|
|
392
|
+
"""
|
|
393
|
+
|
|
394
|
+
if self._gateway_contract != "governed_admission_v1":
|
|
395
|
+
return False
|
|
396
|
+
if platform is None:
|
|
397
|
+
return True
|
|
398
|
+
return str(platform).strip().lower() in _GOVERNED_GATEWAY_PLATFORMS
|
|
399
|
+
|
|
400
|
+
def _gateway_contract_for_platform(self, platform: str) -> str:
|
|
401
|
+
if self.governed_gateway_admission_enabled(platform):
|
|
402
|
+
return "governed_admission_v1"
|
|
403
|
+
return "legacy_delivery"
|
|
404
|
+
|
|
405
|
+
def governed_gateway_poll_seconds(self) -> float:
|
|
406
|
+
"""Bounded cadence for unattended governed-lifecycle reconciliation."""
|
|
407
|
+
|
|
408
|
+
return self._gateway_poll_seconds
|
|
409
|
+
|
|
410
|
+
def governed_pending_delivery_ids(self, *, limit: int = 100) -> tuple[str, ...]:
|
|
411
|
+
"""PII-free durable identities that still need a terminal observation."""
|
|
412
|
+
|
|
413
|
+
if self._gateway_outcomes is None:
|
|
414
|
+
return ()
|
|
415
|
+
return self._gateway_outcomes.pending_delivery_ids(limit=limit)
|
|
416
|
+
|
|
417
|
+
def _stored_gateway_result(
|
|
418
|
+
self, row: Dict[str, Any], *, observation_new: bool = False
|
|
419
|
+
) -> GatewayPushResult:
|
|
420
|
+
state = str(row["state"])
|
|
421
|
+
return GatewayPushResult(
|
|
422
|
+
accepted=state in {"accepted", "awaiting_approval", "delivered"},
|
|
423
|
+
provider_delivered=row["provider_delivered"] is True,
|
|
424
|
+
contract=self._gateway_contract,
|
|
425
|
+
admission_state=state,
|
|
426
|
+
delivery_id=str(row["delivery_id"]),
|
|
427
|
+
terminal=state in {"delivered", "failed", "ambiguous"},
|
|
428
|
+
observation_new=observation_new,
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
def _defer_or_fail_gateway(
|
|
432
|
+
self,
|
|
433
|
+
row: Optional[Dict[str, Any]],
|
|
434
|
+
delivery_id: str,
|
|
435
|
+
*,
|
|
436
|
+
gateway_contract: Optional[str] = None,
|
|
437
|
+
) -> GatewayPushResult:
|
|
438
|
+
effective_contract = gateway_contract or self._gateway_contract
|
|
439
|
+
if (
|
|
440
|
+
self._gateway_outcomes is not None
|
|
441
|
+
and row is not None
|
|
442
|
+
and row.get("state") in {"accepted", "awaiting_approval"}
|
|
443
|
+
):
|
|
444
|
+
return self._stored_gateway_result(
|
|
445
|
+
self._gateway_outcomes.defer(delivery_id), observation_new=False
|
|
446
|
+
)
|
|
447
|
+
return GatewayPushResult(
|
|
448
|
+
False, False, effective_contract,
|
|
449
|
+
delivery_id=delivery_id, observation_new=False,
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
# ------------------------------------------------------------------
|
|
453
|
+
# Home channel resolution
|
|
454
|
+
# ------------------------------------------------------------------
|
|
455
|
+
|
|
456
|
+
@staticmethod
|
|
457
|
+
def _load_home_channels() -> Dict[str, Dict[str, str]]:
|
|
458
|
+
"""Build a {platform: {chat_id, name}} mapping from env vars."""
|
|
459
|
+
channels: Dict[str, Dict[str, str]] = {}
|
|
460
|
+
env_map = {
|
|
461
|
+
"telegram": ("TELEGRAM_HOME_CHANNEL", "TELEGRAM_HOME_CHANNEL_NAME"),
|
|
462
|
+
"whatsapp": ("WHATSAPP_HOME_CHANNEL", "WHATSAPP_HOME_CHANNEL_NAME"),
|
|
463
|
+
"discord": ("DISCORD_HOME_CHANNEL", "DISCORD_HOME_CHANNEL_NAME"),
|
|
464
|
+
"slack": ("SLACK_HOME_CHANNEL", "SLACK_HOME_CHANNEL_NAME"),
|
|
465
|
+
"signal": ("SIGNAL_HOME_CHANNEL", "SIGNAL_HOME_CHANNEL_NAME"),
|
|
466
|
+
}
|
|
467
|
+
for platform, (chat_env, name_env) in env_map.items():
|
|
468
|
+
chat_id = os.environ.get(chat_env, "")
|
|
469
|
+
if chat_id:
|
|
470
|
+
channels[platform] = {
|
|
471
|
+
"chat_id": chat_id,
|
|
472
|
+
"name": os.environ.get(name_env, platform.title()),
|
|
473
|
+
}
|
|
474
|
+
return channels
|
|
475
|
+
|
|
476
|
+
def resolve_home_channel(self) -> Optional[Dict[str, str]]:
|
|
477
|
+
"""Resolve the first configured home channel.
|
|
478
|
+
|
|
479
|
+
Returns:
|
|
480
|
+
Dict with platform, chat_id, account_id or None if not configured.
|
|
481
|
+
Platform is normalized to lowercase OpenClaw channel name.
|
|
482
|
+
"""
|
|
483
|
+
for platform, info in self._home_channels.items():
|
|
484
|
+
return {
|
|
485
|
+
"platform": platform.lower(), # whatsapp, telegram, discord, slack, signal
|
|
486
|
+
"chat_id": info["chat_id"],
|
|
487
|
+
"account_id": "default", # Could be made configurable later
|
|
488
|
+
}
|
|
489
|
+
return None
|
|
490
|
+
|
|
491
|
+
# ------------------------------------------------------------------
|
|
492
|
+
# Public API
|
|
493
|
+
# ------------------------------------------------------------------
|
|
494
|
+
|
|
495
|
+
def deliver(
|
|
496
|
+
self,
|
|
497
|
+
person_id: str,
|
|
498
|
+
content: str,
|
|
499
|
+
channel: str = "push",
|
|
500
|
+
urgency: float = 0.5,
|
|
501
|
+
source: str = "initiative",
|
|
502
|
+
initiative_id: Optional[str] = None,
|
|
503
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
504
|
+
) -> Optional[str]:
|
|
505
|
+
"""Queue a proactive message for delivery.
|
|
506
|
+
|
|
507
|
+
Returns the delivery_id if queued, None if rate-limited.
|
|
508
|
+
"""
|
|
509
|
+
allowed, reason = self._rate_limiter.can_deliver(person_id, urgency=urgency)
|
|
510
|
+
if not allowed:
|
|
511
|
+
logger.debug(
|
|
512
|
+
"Proactive delivery blocked for %s: %s (urgency=%.2f)",
|
|
513
|
+
person_id,
|
|
514
|
+
reason,
|
|
515
|
+
urgency,
|
|
516
|
+
)
|
|
517
|
+
return None
|
|
518
|
+
|
|
519
|
+
delivery = PendingDelivery(
|
|
520
|
+
delivery_id=str(uuid.uuid4()),
|
|
521
|
+
person_id=person_id,
|
|
522
|
+
content=content,
|
|
523
|
+
channel=channel,
|
|
524
|
+
urgency=urgency,
|
|
525
|
+
source=source,
|
|
526
|
+
initiative_id=initiative_id,
|
|
527
|
+
metadata=metadata or {},
|
|
528
|
+
)
|
|
529
|
+
self._pending.append(delivery)
|
|
530
|
+
logger.info(
|
|
531
|
+
"Proactive delivery queued: %s → %s (channel=%s, urgency=%.2f)",
|
|
532
|
+
delivery.delivery_id,
|
|
533
|
+
person_id,
|
|
534
|
+
channel,
|
|
535
|
+
urgency,
|
|
536
|
+
)
|
|
537
|
+
return delivery.delivery_id
|
|
538
|
+
|
|
539
|
+
async def push_to_gateway(
|
|
540
|
+
self,
|
|
541
|
+
platform: str,
|
|
542
|
+
chat_id: str,
|
|
543
|
+
message: str,
|
|
544
|
+
source: str = "initiative",
|
|
545
|
+
delivery_id: str = "",
|
|
546
|
+
source_id: str = "",
|
|
547
|
+
) -> GatewayPushResult:
|
|
548
|
+
"""Push a proactive message directly to the gateway's /internal/deliver endpoint.
|
|
549
|
+
|
|
550
|
+
The legacy contract preserves its historic HTTP-200 delivery meaning.
|
|
551
|
+
The opt-in governed contract durably tracks the exact boundary outcome
|
|
552
|
+
from admission through provider delivery or terminal failure. The
|
|
553
|
+
caller remains responsible for prior rate-limit checks if needed.
|
|
554
|
+
"""
|
|
555
|
+
gateway_contract = self._gateway_contract_for_platform(platform)
|
|
556
|
+
payload = {
|
|
557
|
+
"platform": platform,
|
|
558
|
+
"chat_id": chat_id,
|
|
559
|
+
"message": message,
|
|
560
|
+
"source": source,
|
|
561
|
+
}
|
|
562
|
+
# Optional, deployment-neutral correlation fields. Existing gateways
|
|
563
|
+
# continue to receive the original four-field contract when callers do
|
|
564
|
+
# not provide them. Governed sidecars can use these stable source
|
|
565
|
+
# identities to make retries durable and idempotent without teaching
|
|
566
|
+
# Colony anything about a deployment's authority model.
|
|
567
|
+
if delivery_id:
|
|
568
|
+
payload["delivery_id"] = delivery_id
|
|
569
|
+
if source_id:
|
|
570
|
+
payload["source_id"] = source_id
|
|
571
|
+
|
|
572
|
+
stored: Optional[Dict[str, Any]] = None
|
|
573
|
+
if gateway_contract == "governed_admission_v1":
|
|
574
|
+
if not delivery_id or not source_id or self._gateway_outcomes is None:
|
|
575
|
+
logger.warning(
|
|
576
|
+
"Governed gateway lifecycle requires stable delivery and source IDs"
|
|
577
|
+
)
|
|
578
|
+
return GatewayPushResult(
|
|
579
|
+
False,
|
|
580
|
+
False,
|
|
581
|
+
gateway_contract,
|
|
582
|
+
delivery_id=delivery_id,
|
|
583
|
+
observation_new=False,
|
|
584
|
+
)
|
|
585
|
+
request_sha256 = hashlib.sha256(
|
|
586
|
+
json.dumps(
|
|
587
|
+
payload,
|
|
588
|
+
ensure_ascii=False,
|
|
589
|
+
allow_nan=False,
|
|
590
|
+
separators=(",", ":"),
|
|
591
|
+
sort_keys=True,
|
|
592
|
+
).encode("utf-8")
|
|
593
|
+
).hexdigest()
|
|
594
|
+
try:
|
|
595
|
+
stored = self._gateway_outcomes.reserve(delivery_id, request_sha256)
|
|
596
|
+
except (OSError, sqlite3.Error, ValueError):
|
|
597
|
+
logger.warning(
|
|
598
|
+
"Governed gateway delivery identity could not be reserved",
|
|
599
|
+
exc_info=True,
|
|
600
|
+
)
|
|
601
|
+
return GatewayPushResult(
|
|
602
|
+
False,
|
|
603
|
+
False,
|
|
604
|
+
gateway_contract,
|
|
605
|
+
delivery_id=delivery_id,
|
|
606
|
+
observation_new=False,
|
|
607
|
+
)
|
|
608
|
+
if stored["state"] in {"delivered", "failed", "ambiguous"}:
|
|
609
|
+
return self._stored_gateway_result(stored, observation_new=False)
|
|
610
|
+
if (
|
|
611
|
+
stored["state"] in {"accepted", "awaiting_approval"}
|
|
612
|
+
and float(stored["next_poll_at"]) > float(self._clock())
|
|
613
|
+
):
|
|
614
|
+
return self._stored_gateway_result(stored, observation_new=False)
|
|
615
|
+
|
|
616
|
+
try:
|
|
617
|
+
import aiohttp
|
|
618
|
+
except ImportError:
|
|
619
|
+
logger.warning("aiohttp not available — cannot push to gateway")
|
|
620
|
+
return self._defer_or_fail_gateway(
|
|
621
|
+
stored, delivery_id, gateway_contract=gateway_contract,
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
url = f"{self._gateway_url.rstrip('/')}/internal/deliver"
|
|
625
|
+
headers: Dict[str, str] = {"Content-Type": "application/json"}
|
|
626
|
+
if self._gateway_api_key:
|
|
627
|
+
headers["Authorization"] = f"Bearer {self._gateway_api_key}"
|
|
628
|
+
|
|
629
|
+
try:
|
|
630
|
+
async with aiohttp.ClientSession() as session:
|
|
631
|
+
async with session.post(
|
|
632
|
+
url,
|
|
633
|
+
json=payload,
|
|
634
|
+
headers=headers,
|
|
635
|
+
timeout=aiohttp.ClientTimeout(total=5.0),
|
|
636
|
+
) as resp:
|
|
637
|
+
body = await resp.text()
|
|
638
|
+
if resp.status == 200 and gateway_contract == "legacy_delivery":
|
|
639
|
+
logger.info(
|
|
640
|
+
"Proactive message pushed to gateway (platform=%s, chat_id=%s, source=%s)",
|
|
641
|
+
platform,
|
|
642
|
+
chat_id,
|
|
643
|
+
source,
|
|
644
|
+
)
|
|
645
|
+
return GatewayPushResult(
|
|
646
|
+
True, True, gateway_contract,
|
|
647
|
+
admission_state="delivered", delivery_id=delivery_id,
|
|
648
|
+
)
|
|
649
|
+
if resp.status == 200 and gateway_contract == "governed_admission_v1":
|
|
650
|
+
content_type = str(
|
|
651
|
+
getattr(resp, "headers", {}).get("Content-Type", "")
|
|
652
|
+
).split(";", 1)[0].strip().lower()
|
|
653
|
+
document = _strict_gateway_json(body)
|
|
654
|
+
expected_fields = {
|
|
655
|
+
"schema", "version", "delivery_id", "state",
|
|
656
|
+
"intent_id", "provider_delivered",
|
|
657
|
+
}
|
|
658
|
+
admission_receipt = bool(
|
|
659
|
+
isinstance(document, dict)
|
|
660
|
+
and document.get("provider_delivered") is False
|
|
661
|
+
and document.get("state") in {
|
|
662
|
+
"accepted", "awaiting_approval"
|
|
663
|
+
}
|
|
664
|
+
and isinstance(document.get("intent_id"), str)
|
|
665
|
+
and bool(document.get("intent_id"))
|
|
666
|
+
)
|
|
667
|
+
delivery_receipt = bool(
|
|
668
|
+
isinstance(document, dict)
|
|
669
|
+
and document.get("provider_delivered") is True
|
|
670
|
+
and document.get("state") == "delivered"
|
|
671
|
+
and isinstance(document.get("intent_id"), str)
|
|
672
|
+
)
|
|
673
|
+
terminal_failure_receipt = bool(
|
|
674
|
+
isinstance(document, dict)
|
|
675
|
+
and document.get("provider_delivered") is False
|
|
676
|
+
and document.get("state") in {"failed", "ambiguous"}
|
|
677
|
+
and isinstance(document.get("intent_id"), str)
|
|
678
|
+
and bool(document.get("intent_id"))
|
|
679
|
+
)
|
|
680
|
+
if (
|
|
681
|
+
content_type != "application/json"
|
|
682
|
+
or not isinstance(document, dict)
|
|
683
|
+
or set(document) != expected_fields
|
|
684
|
+
or document.get("schema") != "GatewayBoundaryOutcomeV1"
|
|
685
|
+
or document.get("version") != 1
|
|
686
|
+
or document.get("delivery_id") != delivery_id
|
|
687
|
+
or not (
|
|
688
|
+
admission_receipt
|
|
689
|
+
or delivery_receipt
|
|
690
|
+
or terminal_failure_receipt
|
|
691
|
+
)
|
|
692
|
+
):
|
|
693
|
+
logger.warning(
|
|
694
|
+
"Governed gateway admission response failed exact attestation"
|
|
695
|
+
)
|
|
696
|
+
return self._defer_or_fail_gateway(
|
|
697
|
+
stored,
|
|
698
|
+
delivery_id,
|
|
699
|
+
gateway_contract=gateway_contract,
|
|
700
|
+
)
|
|
701
|
+
if admission_receipt:
|
|
702
|
+
logger.info(
|
|
703
|
+
"Gateway admitted proactive message without provider-delivery "
|
|
704
|
+
"claim (platform=%s, source=%s, state=%s)",
|
|
705
|
+
platform, source, document["state"],
|
|
706
|
+
)
|
|
707
|
+
elif delivery_receipt:
|
|
708
|
+
logger.info(
|
|
709
|
+
"Gateway attested provider delivery "
|
|
710
|
+
"(platform=%s, source=%s)", platform, source,
|
|
711
|
+
)
|
|
712
|
+
else:
|
|
713
|
+
logger.warning(
|
|
714
|
+
"Gateway attested terminal outcome without delivery "
|
|
715
|
+
"(platform=%s, source=%s, state=%s)",
|
|
716
|
+
platform,
|
|
717
|
+
source,
|
|
718
|
+
document["state"],
|
|
719
|
+
)
|
|
720
|
+
assert self._gateway_outcomes is not None
|
|
721
|
+
observed, changed = self._gateway_outcomes.observe(
|
|
722
|
+
delivery_id,
|
|
723
|
+
state=str(document["state"]),
|
|
724
|
+
intent_id=str(document["intent_id"]),
|
|
725
|
+
provider_delivered=bool(document["provider_delivered"]),
|
|
726
|
+
)
|
|
727
|
+
return self._stored_gateway_result(
|
|
728
|
+
observed, observation_new=changed
|
|
729
|
+
)
|
|
730
|
+
logger.warning(
|
|
731
|
+
"Gateway /internal/deliver returned %d: %s",
|
|
732
|
+
resp.status,
|
|
733
|
+
body[:200],
|
|
734
|
+
)
|
|
735
|
+
return self._defer_or_fail_gateway(
|
|
736
|
+
stored, delivery_id, gateway_contract=gateway_contract,
|
|
737
|
+
)
|
|
738
|
+
except Exception as exc:
|
|
739
|
+
logger.warning("push_to_gateway failed: %s", exc)
|
|
740
|
+
return self._defer_or_fail_gateway(
|
|
741
|
+
stored, delivery_id, gateway_contract=gateway_contract,
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
def _prepare_initiative_dispatch(self, initiative: Dict[str, Any]) -> Dict[str, Any]:
|
|
745
|
+
"""Build everything needed to dispatch an initiative to Hermes.
|
|
746
|
+
|
|
747
|
+
Pure/side-effect-free: resolves the recipient bucket and target
|
|
748
|
+
channel, builds the webhook payload, and signs the exact bytes that
|
|
749
|
+
go on the wire. Both :meth:`push_initiative` (which sends) and
|
|
750
|
+
:meth:`preview_initiative` (which does not) share this so the shadow
|
|
751
|
+
view is byte-identical to what a real send would transmit.
|
|
752
|
+
|
|
753
|
+
Returns a dict with: url, headers, body_bytes, payload, person_id
|
|
754
|
+
(rate-limit recipient bucket), urgency (0-1), channel_hint, target
|
|
755
|
+
({user_chat, home_chat}).
|
|
756
|
+
"""
|
|
757
|
+
# Hermes webhook URL — override via env var for flexibility
|
|
758
|
+
hermes_webhook_url = os.environ.get(
|
|
759
|
+
"COLONY_HERMES_WEBHOOK_URL",
|
|
760
|
+
"http://127.0.0.1:8644/webhooks/colony-initiatives",
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
headers: Dict[str, str] = {"Content-Type": "application/json"}
|
|
764
|
+
webhook_secret = os.environ.get("COLONY_HERMES_WEBHOOK_SECRET", "")
|
|
765
|
+
|
|
766
|
+
# Resolve agent name from env var — never hardcode
|
|
767
|
+
agent_name = os.environ.get("COLONY_AGENT_NAME", "the assistant")
|
|
768
|
+
initiative_id = initiative.get("id") or str(uuid.uuid4())
|
|
769
|
+
dedup_subject = initiative.get("entity_id") or initiative_id
|
|
770
|
+
|
|
771
|
+
# Rate-limit urgency stays on the 0-1 scale the limiter expects.
|
|
772
|
+
urgency = float(initiative.get("priority", 0.5) or 0.5)
|
|
773
|
+
|
|
774
|
+
# Normalize priority: if it's a float <= 1.0, scale to 0-100
|
|
775
|
+
raw_priority = initiative.get("priority", 0.5)
|
|
776
|
+
if isinstance(raw_priority, float) and raw_priority <= 1.0:
|
|
777
|
+
priority = int(raw_priority * 100)
|
|
778
|
+
else:
|
|
779
|
+
priority = int(raw_priority)
|
|
780
|
+
|
|
781
|
+
payload = {
|
|
782
|
+
"type": "initiative",
|
|
783
|
+
"occurred_at": datetime.now(timezone.utc).isoformat(),
|
|
784
|
+
"payload": {
|
|
785
|
+
"initiative_type": initiative.get("type", "unknown"),
|
|
786
|
+
"title": initiative.get("title", ""),
|
|
787
|
+
"description": initiative.get("description", ""),
|
|
788
|
+
"priority": priority,
|
|
789
|
+
"status": "pending",
|
|
790
|
+
"id": initiative_id,
|
|
791
|
+
"dedup_key": (
|
|
792
|
+
f"{initiative.get('type', 'unknown')}:{dedup_subject}"
|
|
793
|
+
),
|
|
794
|
+
"agent_name": agent_name,
|
|
795
|
+
"context": {
|
|
796
|
+
"trigger": initiative.get("rationale", ""),
|
|
797
|
+
"suggested_actions": [initiative.get("suggested_action", "review_and_decide")]
|
|
798
|
+
if initiative.get("suggested_action")
|
|
799
|
+
else [],
|
|
800
|
+
"constraints": {},
|
|
801
|
+
"metadata": {
|
|
802
|
+
"source": "autonomy_loop",
|
|
803
|
+
"entity_id": initiative.get("entity_id"),
|
|
804
|
+
"entity_type": initiative.get("entity_type"),
|
|
805
|
+
},
|
|
806
|
+
},
|
|
807
|
+
"created_at": initiative.get("generated_at", datetime.now(timezone.utc).isoformat()),
|
|
808
|
+
"expires_at": None,
|
|
809
|
+
},
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
# Populate delivery_context for channel routing
|
|
813
|
+
raw_entity_id = initiative.get("entity_id", "")
|
|
814
|
+
initiative_type = initiative.get("type", "")
|
|
815
|
+
|
|
816
|
+
# Self-initiatives always route to home channel (v0.11.0)
|
|
817
|
+
is_self_initiative = initiative_type in {
|
|
818
|
+
"subsystem_health", "data_quality", "operational",
|
|
819
|
+
"capability_gap", "knowledge_acquisition", "behavioral_correction",
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
channel_hint = initiative.get("channel_hint", "home" if is_self_initiative else "dm")
|
|
823
|
+
|
|
824
|
+
if not raw_entity_id or is_self_initiative:
|
|
825
|
+
# System/self initiative — no DM, always home
|
|
826
|
+
person_id = os.environ.get("COLONY_OWNER_CONTACT_ID", "owner")
|
|
827
|
+
user_channel = None
|
|
828
|
+
home_channel = self._channel_registry.resolve("__system__", "home")
|
|
829
|
+
else:
|
|
830
|
+
# Relationship initiatives target a specific person (entity_id IS person_id).
|
|
831
|
+
# All other initiative types (follow_up, health, etc.) target the owner.
|
|
832
|
+
if initiative_type == "relationship":
|
|
833
|
+
person_id = raw_entity_id
|
|
834
|
+
else:
|
|
835
|
+
person_id = os.environ.get("COLONY_OWNER_CONTACT_ID", "owner")
|
|
836
|
+
|
|
837
|
+
user_channel = self._channel_registry.resolve(person_id, "dm")
|
|
838
|
+
home_channel = self._channel_registry.resolve(person_id, "home")
|
|
839
|
+
|
|
840
|
+
delivery_context = {}
|
|
841
|
+
if user_channel:
|
|
842
|
+
delivery_context["user_chat"] = f"{user_channel.platform}:{user_channel.chat_id}"
|
|
843
|
+
if home_channel:
|
|
844
|
+
delivery_context["home_chat"] = f"{home_channel.platform}:{home_channel.chat_id}"
|
|
845
|
+
|
|
846
|
+
if delivery_context:
|
|
847
|
+
payload["delivery_context"] = delivery_context
|
|
848
|
+
payload["channel_hint"] = channel_hint
|
|
849
|
+
|
|
850
|
+
# Serialize the payload exactly once and sign the bytes that go on the
|
|
851
|
+
# wire. Sending `json=payload` would let aiohttp re-serialize, so the
|
|
852
|
+
# HMAC could disagree with the receiver's view of the body.
|
|
853
|
+
body_bytes, headers = encode_hermes_webhook(
|
|
854
|
+
payload,
|
|
855
|
+
secret=webhook_secret,
|
|
856
|
+
)
|
|
857
|
+
|
|
858
|
+
return {
|
|
859
|
+
"url": hermes_webhook_url,
|
|
860
|
+
"headers": headers,
|
|
861
|
+
"body_bytes": body_bytes,
|
|
862
|
+
"payload": payload,
|
|
863
|
+
"person_id": person_id,
|
|
864
|
+
"urgency": urgency,
|
|
865
|
+
"channel_hint": channel_hint,
|
|
866
|
+
"target": dict(delivery_context),
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
async def _prepare_initiative_dispatch_async(
|
|
870
|
+
self, initiative: Dict[str, Any],
|
|
871
|
+
) -> Dict[str, Any]:
|
|
872
|
+
"""Prepare dispatch with an exact async contact route when governed.
|
|
873
|
+
|
|
874
|
+
Legacy and owner/default routing retains the synchronous registry
|
|
875
|
+
priority order. A governed relationship message to a non-owner is
|
|
876
|
+
stricter: it must resolve one verified WhatsApp handle from the
|
|
877
|
+
canonical async contact store and can never fall back to a home chat.
|
|
878
|
+
This resolves identity/transport only; authorization remains at the
|
|
879
|
+
governed producer boundary.
|
|
880
|
+
"""
|
|
881
|
+
prep = self._prepare_initiative_dispatch(initiative)
|
|
882
|
+
if not self.governed_gateway_admission_enabled("whatsapp"):
|
|
883
|
+
return prep
|
|
884
|
+
if str(initiative.get("type") or "") != "relationship":
|
|
885
|
+
return prep
|
|
886
|
+
|
|
887
|
+
person_id = str(prep.get("person_id") or "").strip()
|
|
888
|
+
configured_owner = str(
|
|
889
|
+
os.environ.get("COLONY_OWNER_CONTACT_ID", "owner") or "owner"
|
|
890
|
+
).strip()
|
|
891
|
+
if not person_id or person_id in {"owner", configured_owner}:
|
|
892
|
+
return prep
|
|
893
|
+
|
|
894
|
+
resolver = getattr(
|
|
895
|
+
self._channel_registry, "resolve_exact_verified_dm", None,
|
|
896
|
+
)
|
|
897
|
+
channel = None
|
|
898
|
+
if callable(resolver):
|
|
899
|
+
try:
|
|
900
|
+
channel = await resolver(person_id, platform="whatsapp")
|
|
901
|
+
except Exception:
|
|
902
|
+
logger.debug(
|
|
903
|
+
"Governed exact contact route resolution failed",
|
|
904
|
+
exc_info=True,
|
|
905
|
+
)
|
|
906
|
+
|
|
907
|
+
# Non-owner governed outreach has no fallback target. In particular,
|
|
908
|
+
# a configured home chat must never turn a missing/ambiguous DM into a
|
|
909
|
+
# group disclosure.
|
|
910
|
+
target: Dict[str, str] = {}
|
|
911
|
+
if channel is not None:
|
|
912
|
+
target["user_chat"] = f"{channel.platform}:{channel.chat_id}"
|
|
913
|
+
prep["target"] = target
|
|
914
|
+
payload = prep["payload"]
|
|
915
|
+
if target:
|
|
916
|
+
payload["delivery_context"] = dict(target)
|
|
917
|
+
payload["channel_hint"] = "dm"
|
|
918
|
+
else:
|
|
919
|
+
payload.pop("delivery_context", None)
|
|
920
|
+
payload.pop("channel_hint", None)
|
|
921
|
+
|
|
922
|
+
body_bytes, headers = encode_hermes_webhook(
|
|
923
|
+
payload,
|
|
924
|
+
secret=os.environ.get("COLONY_HERMES_WEBHOOK_SECRET", ""),
|
|
925
|
+
)
|
|
926
|
+
prep["body_bytes"] = body_bytes
|
|
927
|
+
prep["headers"] = headers
|
|
928
|
+
return prep
|
|
929
|
+
|
|
930
|
+
def preview_initiative(self, initiative: Dict[str, Any]) -> Dict[str, Any]:
|
|
931
|
+
"""Resolve where/what an initiative WOULD be delivered, without sending.
|
|
932
|
+
|
|
933
|
+
Read-only. Returns the same recipient/target/payload a real
|
|
934
|
+
:meth:`push_initiative` would transmit, for shadow logging and
|
|
935
|
+
operator review.
|
|
936
|
+
"""
|
|
937
|
+
prep = self._prepare_initiative_dispatch(initiative)
|
|
938
|
+
return {
|
|
939
|
+
"person_id": prep["person_id"],
|
|
940
|
+
"urgency": prep["urgency"],
|
|
941
|
+
"channel_hint": prep["channel_hint"],
|
|
942
|
+
"target": prep["target"],
|
|
943
|
+
"initiative_type": initiative.get("type", "unknown"),
|
|
944
|
+
"title": initiative.get("title", ""),
|
|
945
|
+
"description": initiative.get("description", ""),
|
|
946
|
+
"rationale": initiative.get("rationale", ""),
|
|
947
|
+
"suggested_action": initiative.get("suggested_action", ""),
|
|
948
|
+
"webhook_payload": prep["payload"],
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
async def preview_initiative_async(
|
|
952
|
+
self, initiative: Dict[str, Any],
|
|
953
|
+
) -> Dict[str, Any]:
|
|
954
|
+
"""Async preview used by the guarded autonomous delivery path."""
|
|
955
|
+
prep = await self._prepare_initiative_dispatch_async(initiative)
|
|
956
|
+
return {
|
|
957
|
+
"person_id": prep["person_id"],
|
|
958
|
+
"urgency": prep["urgency"],
|
|
959
|
+
"channel_hint": prep["channel_hint"],
|
|
960
|
+
"target": prep["target"],
|
|
961
|
+
"initiative_type": initiative.get("type", "unknown"),
|
|
962
|
+
"title": initiative.get("title", ""),
|
|
963
|
+
"description": initiative.get("description", ""),
|
|
964
|
+
"rationale": initiative.get("rationale", ""),
|
|
965
|
+
"suggested_action": initiative.get("suggested_action", ""),
|
|
966
|
+
"webhook_payload": prep["payload"],
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
async def push_initiative(self, initiative: Dict[str, Any]) -> bool:
|
|
970
|
+
"""Push a structured initiative to Hermes via webhook.
|
|
971
|
+
|
|
972
|
+
Returns True if Hermes accepted (202), False otherwise.
|
|
973
|
+
"""
|
|
974
|
+
try:
|
|
975
|
+
import aiohttp
|
|
976
|
+
except ImportError:
|
|
977
|
+
logger.warning("aiohttp not available — cannot push initiative")
|
|
978
|
+
return False
|
|
979
|
+
|
|
980
|
+
prep = self._prepare_initiative_dispatch(initiative)
|
|
981
|
+
hermes_webhook_url = prep["url"]
|
|
982
|
+
headers = prep["headers"]
|
|
983
|
+
body_bytes = prep["body_bytes"]
|
|
984
|
+
priority = prep["payload"]["payload"]["priority"]
|
|
985
|
+
|
|
986
|
+
try:
|
|
987
|
+
async with aiohttp.ClientSession() as session:
|
|
988
|
+
async with session.post(
|
|
989
|
+
hermes_webhook_url,
|
|
990
|
+
data=body_bytes,
|
|
991
|
+
headers=headers,
|
|
992
|
+
timeout=aiohttp.ClientTimeout(total=10.0),
|
|
993
|
+
) as resp:
|
|
994
|
+
if resp.status == 202:
|
|
995
|
+
logger.info(
|
|
996
|
+
"Initiative pushed to Hermes: %s (type=%s, priority=%d)",
|
|
997
|
+
initiative.get("id"),
|
|
998
|
+
initiative.get("type"),
|
|
999
|
+
priority,
|
|
1000
|
+
)
|
|
1001
|
+
return True
|
|
1002
|
+
body = await resp.text()
|
|
1003
|
+
logger.warning(
|
|
1004
|
+
"Hermes webhook returned %d: %s",
|
|
1005
|
+
resp.status, body[:200]
|
|
1006
|
+
)
|
|
1007
|
+
return False
|
|
1008
|
+
except Exception as exc:
|
|
1009
|
+
logger.warning("push_initiative failed: %s", exc)
|
|
1010
|
+
return False
|
|
1011
|
+
|
|
1012
|
+
def get_pending(self, gateway_id: str = "", limit: int = 20) -> List[Dict[str, Any]]:
|
|
1013
|
+
"""Return pending PUSH deliveries for the gateway to send.
|
|
1014
|
+
|
|
1015
|
+
Only returns unsent PUSH channel deliveries. IN_SESSION deliveries are
|
|
1016
|
+
fetched separately via get_in_session_context().
|
|
1017
|
+
"""
|
|
1018
|
+
results = []
|
|
1019
|
+
for d in self._pending:
|
|
1020
|
+
if d.sent:
|
|
1021
|
+
continue
|
|
1022
|
+
if d.channel != "push":
|
|
1023
|
+
continue
|
|
1024
|
+
results.append(self._to_dict(d))
|
|
1025
|
+
if len(results) >= limit:
|
|
1026
|
+
break
|
|
1027
|
+
return results
|
|
1028
|
+
|
|
1029
|
+
def mark_sent(self, delivery_id: str) -> bool:
|
|
1030
|
+
"""Mark a delivery as sent (called by gateway after successful send)."""
|
|
1031
|
+
for d in self._pending:
|
|
1032
|
+
if d.delivery_id == delivery_id:
|
|
1033
|
+
d.sent = True
|
|
1034
|
+
self._rate_limiter.record_delivery(d.person_id)
|
|
1035
|
+
self._sent.append(d)
|
|
1036
|
+
if len(self._sent) > self._sent_max:
|
|
1037
|
+
self._sent = self._sent[-self._sent_max:]
|
|
1038
|
+
logger.info("Delivery %s marked sent (person=%s)", delivery_id, d.person_id)
|
|
1039
|
+
return True
|
|
1040
|
+
logger.debug("mark_sent: delivery %s not found", delivery_id)
|
|
1041
|
+
return False
|
|
1042
|
+
|
|
1043
|
+
def get_in_session_context(self, person_id: str) -> Optional[str]:
|
|
1044
|
+
"""Return pending IN_SESSION deliveries formatted for prompt injection.
|
|
1045
|
+
|
|
1046
|
+
Does NOT mark them as consumed — they survive until explicitly
|
|
1047
|
+
acknowledged or expired (see expire_in_session_deliveries).
|
|
1048
|
+
"""
|
|
1049
|
+
now = datetime.now(timezone.utc)
|
|
1050
|
+
in_session = [
|
|
1051
|
+
d for d in self._pending
|
|
1052
|
+
if d.person_id == person_id
|
|
1053
|
+
and d.channel == "in_session"
|
|
1054
|
+
and not d.sent
|
|
1055
|
+
and (now - d.queued_at).total_seconds() < 86400 # 24h max age
|
|
1056
|
+
]
|
|
1057
|
+
if not in_session:
|
|
1058
|
+
return None
|
|
1059
|
+
|
|
1060
|
+
lines = ["[Things to mention this session]"]
|
|
1061
|
+
for d in in_session:
|
|
1062
|
+
lines.append(f"• {d.content}")
|
|
1063
|
+
|
|
1064
|
+
return "\n".join(lines)
|
|
1065
|
+
|
|
1066
|
+
def expire_in_session_deliveries(self, max_age_hours: float = 24) -> int:
|
|
1067
|
+
"""Mark IN_SESSION deliveries older than max_age_hours as sent (expired).
|
|
1068
|
+
|
|
1069
|
+
Returns the count expired.
|
|
1070
|
+
"""
|
|
1071
|
+
cutoff = datetime.now(timezone.utc) - timedelta(hours=max_age_hours)
|
|
1072
|
+
expired = 0
|
|
1073
|
+
for d in self._pending:
|
|
1074
|
+
if d.channel == "in_session" and not d.sent and d.queued_at < cutoff:
|
|
1075
|
+
d.sent = True
|
|
1076
|
+
self._rate_limiter.record_delivery(d.person_id)
|
|
1077
|
+
expired += 1
|
|
1078
|
+
if expired:
|
|
1079
|
+
logger.info("Expired %d stale in_session deliveries", expired)
|
|
1080
|
+
return expired
|
|
1081
|
+
|
|
1082
|
+
def acknowledge_delivery(self, initiative_id: str) -> bool:
|
|
1083
|
+
"""Mark any pending delivery matching initiative_id as sent.
|
|
1084
|
+
|
|
1085
|
+
Called when the agent explicitly acknowledges an initiative.
|
|
1086
|
+
"""
|
|
1087
|
+
for d in self._pending:
|
|
1088
|
+
if d.initiative_id == initiative_id and not d.sent:
|
|
1089
|
+
d.sent = True
|
|
1090
|
+
self._rate_limiter.record_delivery(d.person_id)
|
|
1091
|
+
logger.info("Delivery %s acknowledged (initiative=%s)", d.delivery_id, initiative_id)
|
|
1092
|
+
return True
|
|
1093
|
+
return False
|
|
1094
|
+
|
|
1095
|
+
# ------------------------------------------------------------------
|
|
1096
|
+
# DIGEST channel
|
|
1097
|
+
# ------------------------------------------------------------------
|
|
1098
|
+
|
|
1099
|
+
def get_pending_digest(self, person_id: str) -> List[PendingDelivery]:
|
|
1100
|
+
"""Return all unsent DIGEST-channel deliveries for ``person_id``."""
|
|
1101
|
+
return [
|
|
1102
|
+
d for d in self._pending
|
|
1103
|
+
if d.person_id == person_id and d.channel == "digest" and not d.sent
|
|
1104
|
+
]
|
|
1105
|
+
|
|
1106
|
+
def pending_digest_recipients(self) -> List[str]:
|
|
1107
|
+
"""List distinct ``person_id`` values with pending DIGEST items."""
|
|
1108
|
+
seen = set()
|
|
1109
|
+
ordered: List[str] = []
|
|
1110
|
+
for d in self._pending:
|
|
1111
|
+
if d.channel != "digest" or d.sent:
|
|
1112
|
+
continue
|
|
1113
|
+
if d.person_id in seen:
|
|
1114
|
+
continue
|
|
1115
|
+
seen.add(d.person_id)
|
|
1116
|
+
ordered.append(d.person_id)
|
|
1117
|
+
return ordered
|
|
1118
|
+
|
|
1119
|
+
def build_digest_bundle(
|
|
1120
|
+
self,
|
|
1121
|
+
person_id: str,
|
|
1122
|
+
*,
|
|
1123
|
+
header: str = "Daily digest",
|
|
1124
|
+
) -> Optional[str]:
|
|
1125
|
+
"""Format this person's pending DIGEST items into a single bundled text
|
|
1126
|
+
block. Does not mark anything consumed — pair with ``consume_digest``.
|
|
1127
|
+
Items are sorted by urgency descending, then by queue time."""
|
|
1128
|
+
items = self.get_pending_digest(person_id)
|
|
1129
|
+
if not items:
|
|
1130
|
+
return None
|
|
1131
|
+
items = sorted(items, key=lambda d: (-d.urgency, d.queued_at))
|
|
1132
|
+
lines = [f"[{header}]"]
|
|
1133
|
+
for d in items:
|
|
1134
|
+
prefix = "\u203c" if d.urgency >= 0.8 else "\u2022"
|
|
1135
|
+
lines.append(f"{prefix} {d.content}")
|
|
1136
|
+
return "\n".join(lines)
|
|
1137
|
+
|
|
1138
|
+
def consume_digest(self, person_id: str) -> int:
|
|
1139
|
+
"""Mark all pending DIGEST deliveries for ``person_id`` as sent.
|
|
1140
|
+
|
|
1141
|
+
Returns the count consumed. Each consumed item is also recorded
|
|
1142
|
+
against the rate limiter so the digest flush respects per-person
|
|
1143
|
+
caps consistently with the other channels.
|
|
1144
|
+
"""
|
|
1145
|
+
consumed = 0
|
|
1146
|
+
for d in self._pending:
|
|
1147
|
+
if d.person_id == person_id and d.channel == "digest" and not d.sent:
|
|
1148
|
+
d.sent = True
|
|
1149
|
+
self._rate_limiter.record_delivery(d.person_id)
|
|
1150
|
+
self._sent.append(d)
|
|
1151
|
+
consumed += 1
|
|
1152
|
+
if consumed and len(self._sent) > self._sent_max:
|
|
1153
|
+
self._sent = self._sent[-self._sent_max:]
|
|
1154
|
+
return consumed
|
|
1155
|
+
|
|
1156
|
+
async def flush_digests_to_gateway(
|
|
1157
|
+
self,
|
|
1158
|
+
*,
|
|
1159
|
+
platform: Optional[str] = None,
|
|
1160
|
+
chat_id: Optional[str] = None,
|
|
1161
|
+
header: str = "Daily digest",
|
|
1162
|
+
) -> Dict[str, Any]:
|
|
1163
|
+
"""Bundle each recipient's pending DIGEST items and push the bundle
|
|
1164
|
+
to the gateway.
|
|
1165
|
+
|
|
1166
|
+
When ``platform``/``chat_id`` are omitted, the bridge falls back to
|
|
1167
|
+
the configured home channel (see ``resolve_home_channel``). If no
|
|
1168
|
+
home channel is configured, the flush is a no-op that returns
|
|
1169
|
+
``{"sent": 0, "reason": "no_home_channel"}`` so a scheduler can
|
|
1170
|
+
still drain the item count at call time.
|
|
1171
|
+
|
|
1172
|
+
Returns a summary dict: ``{"sent": N, "admitted": N, "skipped": M,
|
|
1173
|
+
"recipients": [...], "reason": ...}``. ``admitted`` is explicitly
|
|
1174
|
+
not provider delivery.
|
|
1175
|
+
"""
|
|
1176
|
+
recipients = self.pending_digest_recipients()
|
|
1177
|
+
if not recipients:
|
|
1178
|
+
return {"sent": 0, "admitted": 0, "skipped": 0, "recipients": []}
|
|
1179
|
+
|
|
1180
|
+
if not platform or not chat_id:
|
|
1181
|
+
home = self.resolve_home_channel()
|
|
1182
|
+
if home is None:
|
|
1183
|
+
return {
|
|
1184
|
+
"sent": 0,
|
|
1185
|
+
"admitted": 0,
|
|
1186
|
+
"skipped": len(recipients),
|
|
1187
|
+
"recipients": recipients,
|
|
1188
|
+
"reason": "no_home_channel",
|
|
1189
|
+
}
|
|
1190
|
+
platform = platform or home["platform"]
|
|
1191
|
+
chat_id = chat_id or home["chat_id"]
|
|
1192
|
+
|
|
1193
|
+
sent = 0
|
|
1194
|
+
admitted = 0
|
|
1195
|
+
skipped = 0
|
|
1196
|
+
for person_id in recipients:
|
|
1197
|
+
bundle = self.build_digest_bundle(person_id, header=header)
|
|
1198
|
+
if not bundle:
|
|
1199
|
+
continue
|
|
1200
|
+
digest_source_ids = sorted(
|
|
1201
|
+
delivery.delivery_id
|
|
1202
|
+
for delivery in self.get_pending_digest(person_id)
|
|
1203
|
+
)
|
|
1204
|
+
digest_identity = hashlib.sha256(
|
|
1205
|
+
json.dumps(
|
|
1206
|
+
digest_source_ids,
|
|
1207
|
+
ensure_ascii=False,
|
|
1208
|
+
separators=(",", ":"),
|
|
1209
|
+
).encode("utf-8")
|
|
1210
|
+
).hexdigest()
|
|
1211
|
+
outcome = await self.push_to_gateway(
|
|
1212
|
+
platform=platform,
|
|
1213
|
+
chat_id=chat_id,
|
|
1214
|
+
message=bundle,
|
|
1215
|
+
source="digest",
|
|
1216
|
+
delivery_id="digest:" + digest_identity,
|
|
1217
|
+
source_id="digest:" + digest_identity,
|
|
1218
|
+
)
|
|
1219
|
+
if bool(outcome) and getattr(
|
|
1220
|
+
outcome, "provider_delivered", bool(outcome)
|
|
1221
|
+
) is True:
|
|
1222
|
+
self.consume_digest(person_id)
|
|
1223
|
+
sent += 1
|
|
1224
|
+
elif bool(outcome):
|
|
1225
|
+
# Admission to a governed approval/dispatch queue is durable
|
|
1226
|
+
# work, but it is not provider delivery and must not consume
|
|
1227
|
+
# the delivery-rate budget or pending digest evidence.
|
|
1228
|
+
admitted += 1
|
|
1229
|
+
else:
|
|
1230
|
+
skipped += 1
|
|
1231
|
+
return {
|
|
1232
|
+
"sent": sent,
|
|
1233
|
+
"admitted": admitted,
|
|
1234
|
+
"skipped": skipped,
|
|
1235
|
+
"recipients": recipients,
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
def purge_sent(self) -> int:
|
|
1239
|
+
"""Remove sent deliveries from the pending queue. Returns count purged."""
|
|
1240
|
+
before = len(self._pending)
|
|
1241
|
+
self._pending = [d for d in self._pending if not d.sent]
|
|
1242
|
+
return before - len(self._pending)
|
|
1243
|
+
|
|
1244
|
+
# ------------------------------------------------------------------
|
|
1245
|
+
# Helpers
|
|
1246
|
+
# ------------------------------------------------------------------
|
|
1247
|
+
|
|
1248
|
+
@staticmethod
|
|
1249
|
+
def _to_dict(d: PendingDelivery) -> Dict[str, Any]:
|
|
1250
|
+
return {
|
|
1251
|
+
"delivery_id": d.delivery_id,
|
|
1252
|
+
"person_id": d.person_id,
|
|
1253
|
+
"content": d.content,
|
|
1254
|
+
"channel": d.channel,
|
|
1255
|
+
"urgency": d.urgency,
|
|
1256
|
+
"source": d.source,
|
|
1257
|
+
"initiative_id": d.initiative_id,
|
|
1258
|
+
"queued_at": d.queued_at.isoformat(),
|
|
1259
|
+
"metadata": d.metadata,
|
|
1260
|
+
}
|