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,752 @@
|
|
|
1
|
+
"""Durable server-side idempotency for host turn ingestion.
|
|
2
|
+
|
|
3
|
+
The ledger is intentionally small and independent of graph/vector stores. A
|
|
4
|
+
turn ID is reserved durably before any downstream effect runs. Identical
|
|
5
|
+
replays return the stored response; different canonical content for the same
|
|
6
|
+
ID is a conflict. An interrupted first attempt is marked ``ambiguous`` and is
|
|
7
|
+
never automatically replayed because some downstream effects may already have
|
|
8
|
+
occurred.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import sqlite3
|
|
18
|
+
import threading
|
|
19
|
+
import uuid
|
|
20
|
+
from contextlib import closing
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from enum import Enum
|
|
24
|
+
from functools import lru_cache
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any, Mapping, Optional
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SourceErased(ValueError):
|
|
30
|
+
"""The requested source or an exact message copy was deliberately erased."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SourceInputPending(ValueError):
|
|
34
|
+
"""An exact admitted parent has not reached the canonical ledger yet."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def source_message_hash(session_id: str, message: Mapping[str, Any]) -> str:
|
|
38
|
+
# Ledger-owned normalization metadata retains the input message identity.
|
|
39
|
+
# Public message schemas cannot supply this top-level field.
|
|
40
|
+
original = message.get("_source_message_hash")
|
|
41
|
+
if isinstance(original, str) and re.fullmatch(r"[0-9a-f]{64}", original):
|
|
42
|
+
return original
|
|
43
|
+
return canonical_turn_digest({"session_id": session_id, "role": message.get("role"), "content": message.get("content")})
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ReservationOutcome(str, Enum):
|
|
47
|
+
CREATED = "created"
|
|
48
|
+
REPLAYED = "replayed"
|
|
49
|
+
CONFLICT = "conflict"
|
|
50
|
+
IN_PROGRESS = "in_progress"
|
|
51
|
+
AMBIGUOUS = "ambiguous"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class Reservation:
|
|
56
|
+
outcome: ReservationOutcome
|
|
57
|
+
response: Optional[dict[str, Any]] = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def canonical_turn_digest(payload: Any) -> str:
|
|
61
|
+
"""Hash the normalized turn envelope, preserving list order and values."""
|
|
62
|
+
if hasattr(payload, "model_dump"):
|
|
63
|
+
payload = payload.model_dump(mode="json", exclude_none=False)
|
|
64
|
+
# Preserve digests already stored before the additive checkpoint field.
|
|
65
|
+
if isinstance(payload, dict) and payload.get("checkpoint_messages") is None:
|
|
66
|
+
payload = {key: value for key, value in payload.items() if key != "checkpoint_messages"}
|
|
67
|
+
if isinstance(payload, dict) and payload.get("assistant_source_refs") is None:
|
|
68
|
+
payload = {key: value for key, value in payload.items() if key != "assistant_source_refs"}
|
|
69
|
+
if isinstance(payload, dict) and payload.get("assistant_input_refs") is None:
|
|
70
|
+
payload = {key: value for key, value in payload.items() if key != "assistant_input_refs"}
|
|
71
|
+
if isinstance(payload, dict) and payload.get("source_only") is None:
|
|
72
|
+
payload = {key: value for key, value in payload.items() if key != "source_only"}
|
|
73
|
+
canonical = json.dumps(
|
|
74
|
+
payload,
|
|
75
|
+
ensure_ascii=False,
|
|
76
|
+
separators=(",", ":"),
|
|
77
|
+
sort_keys=True,
|
|
78
|
+
)
|
|
79
|
+
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _lexical_chunks(messages):
|
|
83
|
+
"""The existing FTS writer's exact chunks, also used to recover ownership."""
|
|
84
|
+
from apsimo.turns.audio import source_text
|
|
85
|
+
for message in messages:
|
|
86
|
+
content = source_text(message.get("content"))
|
|
87
|
+
if not content.strip():
|
|
88
|
+
continue
|
|
89
|
+
# Overlap preserves boundary phrases; full messages remain canonical.
|
|
90
|
+
for start in range(0, len(content), 1800):
|
|
91
|
+
yield message, content, content[start:start + 2000]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class TurnIdempotencyLedger:
|
|
95
|
+
"""SQLite reservation ledger safe across threads and sidecar processes."""
|
|
96
|
+
|
|
97
|
+
def __init__(self, db_path: str | Path) -> None:
|
|
98
|
+
self.db_path = Path(db_path)
|
|
99
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
self._init_lock = threading.Lock()
|
|
101
|
+
self._initialized = False
|
|
102
|
+
self._initialize()
|
|
103
|
+
|
|
104
|
+
def _connect(self) -> sqlite3.Connection:
|
|
105
|
+
conn = sqlite3.connect(str(self.db_path), timeout=10.0)
|
|
106
|
+
os.chmod(self.db_path, 0o600)
|
|
107
|
+
conn.row_factory = sqlite3.Row
|
|
108
|
+
conn.execute("PRAGMA busy_timeout=10000")
|
|
109
|
+
conn.execute("PRAGMA synchronous=FULL")
|
|
110
|
+
conn.execute("PRAGMA secure_delete=ON")
|
|
111
|
+
return conn
|
|
112
|
+
|
|
113
|
+
def _initialize(self) -> None:
|
|
114
|
+
if self._initialized:
|
|
115
|
+
return
|
|
116
|
+
with self._init_lock:
|
|
117
|
+
if self._initialized:
|
|
118
|
+
return
|
|
119
|
+
with closing(self._connect()) as conn, conn:
|
|
120
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
121
|
+
conn.execute("PRAGMA synchronous=FULL")
|
|
122
|
+
conn.execute(
|
|
123
|
+
"""
|
|
124
|
+
CREATE TABLE IF NOT EXISTS turn_ingestion (
|
|
125
|
+
turn_id TEXT PRIMARY KEY,
|
|
126
|
+
content_sha256 TEXT NOT NULL,
|
|
127
|
+
state TEXT NOT NULL CHECK (
|
|
128
|
+
state IN ('processing', 'completed', 'ambiguous')
|
|
129
|
+
),
|
|
130
|
+
response_json TEXT,
|
|
131
|
+
created_at TEXT NOT NULL DEFAULT (
|
|
132
|
+
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
133
|
+
),
|
|
134
|
+
completed_at TEXT,
|
|
135
|
+
error TEXT
|
|
136
|
+
)
|
|
137
|
+
"""
|
|
138
|
+
)
|
|
139
|
+
conn.execute("""
|
|
140
|
+
CREATE TABLE IF NOT EXISTS turn_sources (
|
|
141
|
+
turn_id TEXT PRIMARY KEY,
|
|
142
|
+
content_sha256 TEXT NOT NULL,
|
|
143
|
+
contact_id TEXT NOT NULL,
|
|
144
|
+
session_id TEXT NOT NULL,
|
|
145
|
+
scope TEXT NOT NULL CHECK(scope IN ('person', 'session')),
|
|
146
|
+
messages_json TEXT NOT NULL,
|
|
147
|
+
occurred_at TEXT,
|
|
148
|
+
ingested_at TEXT NOT NULL DEFAULT (
|
|
149
|
+
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
""")
|
|
153
|
+
conn.execute("""
|
|
154
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS turn_source_search USING fts5(
|
|
155
|
+
turn_id UNINDEXED, role UNINDEXED, content,
|
|
156
|
+
tokenize='unicode61'
|
|
157
|
+
)
|
|
158
|
+
""")
|
|
159
|
+
conn.execute("""
|
|
160
|
+
CREATE TABLE IF NOT EXISTS source_erasures (
|
|
161
|
+
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
162
|
+
contact_id TEXT NOT NULL,
|
|
163
|
+
turn_id TEXT NOT NULL UNIQUE,
|
|
164
|
+
session_id TEXT NOT NULL,
|
|
165
|
+
message_hashes_json TEXT NOT NULL,
|
|
166
|
+
erased_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
167
|
+
)
|
|
168
|
+
""")
|
|
169
|
+
# Keep predecessor tombstone semantics and cursor allocation.
|
|
170
|
+
# A partial event uses an opaque event ID in source_erasures;
|
|
171
|
+
# predecessor code still removes its exact message hashes but
|
|
172
|
+
# does not mistake the surviving source for a whole tombstone.
|
|
173
|
+
conn.execute('''CREATE TABLE IF NOT EXISTS source_erasure_revisions (
|
|
174
|
+
sequence INTEGER PRIMARY KEY, source_turn_id TEXT NOT NULL,
|
|
175
|
+
source_version TEXT NOT NULL, source_scope TEXT NOT NULL,
|
|
176
|
+
whole_source INTEGER NOT NULL CHECK(whole_source IN (0,1)),
|
|
177
|
+
UNIQUE(source_turn_id,source_version,whole_source))''')
|
|
178
|
+
conn.execute("CREATE TABLE IF NOT EXISTS source_projection_erasures (turn_id TEXT NOT NULL, source_turn_id TEXT NOT NULL, PRIMARY KEY(turn_id, source_turn_id))")
|
|
179
|
+
from apsimo.beliefs.source_projection import initialize
|
|
180
|
+
initialize(conn)
|
|
181
|
+
from apsimo.turns.media import initialize as initialize_media
|
|
182
|
+
initialize_media(conn)
|
|
183
|
+
from apsimo.turns.source_vectors import initialize as initialize_vectors
|
|
184
|
+
initialize_vectors(conn)
|
|
185
|
+
from apsimo.self_model.judgments import initialize as initialize_judgments
|
|
186
|
+
initialize_judgments(conn)
|
|
187
|
+
from apsimo.self_model.appraisals import initialize as initialize_appraisals
|
|
188
|
+
initialize_appraisals(conn)
|
|
189
|
+
from apsimo.turns.source_attribution import initialize as initialize_attribution
|
|
190
|
+
initialize_attribution(conn)
|
|
191
|
+
from apsimo.turns.source_annotations import initialize as initialize_annotations
|
|
192
|
+
initialize_annotations(conn)
|
|
193
|
+
self._initialized = True
|
|
194
|
+
|
|
195
|
+
def append_source_annotation(self, **kwargs):
|
|
196
|
+
"""Append an attributed correction without rewriting its original source."""
|
|
197
|
+
from .source_annotations import append
|
|
198
|
+
return append(self, **kwargs)
|
|
199
|
+
|
|
200
|
+
def record_source(
|
|
201
|
+
self, turn_id: str, *, contact_id: str, session_id: str,
|
|
202
|
+
messages: list[dict[str, Any]], scope: str = "person",
|
|
203
|
+
occurred_at: str | None = None,
|
|
204
|
+
timezone_name: str | None = None,
|
|
205
|
+
derive_claims: bool = True,
|
|
206
|
+
runtime_judgment: bool = False,
|
|
207
|
+
) -> bool:
|
|
208
|
+
"""Atomically retain source JSON and its rebuildable lexical index.
|
|
209
|
+
|
|
210
|
+
Checkpoint sources are session-scoped because a full transcript does
|
|
211
|
+
not attest the speaker of every old message. Ordinary attributed turns
|
|
212
|
+
can be recalled by that person across sessions. No inference or affect
|
|
213
|
+
update occurs in this storage path. True means newly committed.
|
|
214
|
+
|
|
215
|
+
Reviewed historical imports can set derive_claims=False to retain
|
|
216
|
+
quotations without scheduling assertion learning. Text indexing still
|
|
217
|
+
uses the same source ledger and semantic projection queue.
|
|
218
|
+
The internal runtime_judgment option queues server-observed execution
|
|
219
|
+
evidence without ordinary claim extraction; HTTP writers cannot set it.
|
|
220
|
+
"""
|
|
221
|
+
if not turn_id or not contact_id or not session_id or not messages:
|
|
222
|
+
raise ValueError("source requires an id, participant, session and messages")
|
|
223
|
+
if scope not in {"person", "session"}:
|
|
224
|
+
raise ValueError("invalid source scope")
|
|
225
|
+
if occurred_at is not None:
|
|
226
|
+
if not isinstance(occurred_at, str) or len(occurred_at) > 64:
|
|
227
|
+
raise ValueError("source occurrence time must be an ISO timestamp")
|
|
228
|
+
occurred = datetime.fromisoformat(occurred_at)
|
|
229
|
+
if occurred.tzinfo is None:
|
|
230
|
+
raise ValueError("source occurrence time requires a timezone")
|
|
231
|
+
occurred_at = occurred.astimezone(timezone.utc).isoformat()
|
|
232
|
+
encoded = json.dumps(messages, ensure_ascii=True, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
|
233
|
+
if len(encoded.encode()) > 8 * 1024 * 1024:
|
|
234
|
+
raise ValueError("source exceeds 8 MiB")
|
|
235
|
+
digest = canonical_turn_digest({
|
|
236
|
+
"messages": messages, "contact_id": contact_id,
|
|
237
|
+
"session_id": session_id, "scope": scope,
|
|
238
|
+
"occurred_at": occurred_at,
|
|
239
|
+
})
|
|
240
|
+
messages = json.loads(encoded) # Resolving lineage never mutates caller-owned input.
|
|
241
|
+
with closing(self._connect()) as conn, conn:
|
|
242
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
243
|
+
rules = self._erasure_rules(conn, contact_id)
|
|
244
|
+
# Source IDs are globally unique. Reattributing an erased source
|
|
245
|
+
# must not resurrect it under a different contact after restore or
|
|
246
|
+
# a reviewed historical mapping change. Exact message-copy rules
|
|
247
|
+
# below remain scoped to their original contact and session.
|
|
248
|
+
if conn.execute("SELECT 1 FROM source_erasures WHERE turn_id=?", (turn_id,)).fetchone():
|
|
249
|
+
raise SourceErased("source was erased")
|
|
250
|
+
for message in messages:
|
|
251
|
+
if message.get('_supplied_inputs'):
|
|
252
|
+
if message.get('role') != 'assistant':
|
|
253
|
+
raise ValueError('invalid_source_input_dependency')
|
|
254
|
+
resolved = self._resolve_input_dependencies(conn, contact_id, session_id,
|
|
255
|
+
message['_supplied_inputs'], turn_id=turn_id)
|
|
256
|
+
existing = message.get('_supplied_sources', [])
|
|
257
|
+
message['_supplied_sources'] = list({(ref['source_id'], ref['source_version']): ref
|
|
258
|
+
for ref in [*existing, *resolved]}.values())
|
|
259
|
+
self._validate_dependencies(conn, turn_id, contact_id, session_id, messages)
|
|
260
|
+
retained = self._retained_messages(messages, session_id, rules)
|
|
261
|
+
if retained != messages:
|
|
262
|
+
for cause in self._erasure_causes(messages, session_id, rules):
|
|
263
|
+
conn.execute("INSERT OR IGNORE INTO source_projection_erasures(turn_id,source_turn_id) VALUES (?,?)", (turn_id, cause))
|
|
264
|
+
self._append_erasure(conn, turn_id=turn_id, contact_id=contact_id,
|
|
265
|
+
session_id=session_id, scope=scope, messages=messages,
|
|
266
|
+
removed=[message for message in messages if message not in retained], whole=False)
|
|
267
|
+
messages = retained
|
|
268
|
+
if not messages:
|
|
269
|
+
raise SourceErased("source contains only erased messages")
|
|
270
|
+
encoded = json.dumps(messages, ensure_ascii=True, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
|
271
|
+
row = conn.execute("SELECT content_sha256 FROM turn_sources WHERE turn_id=?", (turn_id,)).fetchone()
|
|
272
|
+
if row:
|
|
273
|
+
if row[0] != digest:
|
|
274
|
+
raise ValueError("source id already contains different evidence")
|
|
275
|
+
return False
|
|
276
|
+
from apsimo.turns.media import normalize_messages, SourceMedia
|
|
277
|
+
messages = normalize_messages(conn, SourceMedia(self).store, turn_id, session_id, messages)
|
|
278
|
+
encoded = json.dumps(messages, ensure_ascii=True, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
|
279
|
+
conn.execute(
|
|
280
|
+
"INSERT INTO turn_sources "
|
|
281
|
+
"(turn_id, content_sha256, contact_id, session_id, scope, messages_json, occurred_at) "
|
|
282
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
283
|
+
(turn_id, digest, contact_id, session_id, scope, encoded, occurred_at),
|
|
284
|
+
)
|
|
285
|
+
self._index_messages(conn, turn_id, messages)
|
|
286
|
+
from apsimo.beliefs.source_projection import enqueue
|
|
287
|
+
if derive_claims:
|
|
288
|
+
enqueue(conn, turn_id, messages, scope=scope, timezone_name=timezone_name)
|
|
289
|
+
if derive_claims or runtime_judgment:
|
|
290
|
+
from apsimo.self_model.judgments import enqueue as enqueue_judgments
|
|
291
|
+
enqueue_judgments(conn, turn_id, contact_id, messages, scope=scope,
|
|
292
|
+
runtime_observation=runtime_judgment)
|
|
293
|
+
from apsimo.self_model.appraisals import enqueue as enqueue_appraisals
|
|
294
|
+
enqueue_appraisals(conn, turn_id, contact_id, messages, scope=scope,
|
|
295
|
+
runtime_observation=runtime_judgment)
|
|
296
|
+
from apsimo.turns.source_vectors import enqueue as enqueue_vectors
|
|
297
|
+
enqueue_vectors(conn, turn_id)
|
|
298
|
+
return True
|
|
299
|
+
|
|
300
|
+
@staticmethod
|
|
301
|
+
def _index_messages(conn: sqlite3.Connection, turn_id: str, messages: list[dict[str, Any]]) -> None:
|
|
302
|
+
for message, _, chunk in _lexical_chunks(messages):
|
|
303
|
+
conn.execute(
|
|
304
|
+
"INSERT INTO turn_source_search(turn_id, role, content) VALUES (?, ?, ?)",
|
|
305
|
+
(turn_id, message["role"], chunk),
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@staticmethod
|
|
310
|
+
def _erasure_rules(conn: sqlite3.Connection, contact_id: str) -> list[dict[str, Any]]:
|
|
311
|
+
return [dict(row) for row in conn.execute(
|
|
312
|
+
'''SELECT e.sequence,e.contact_id,e.session_id,e.message_hashes_json,
|
|
313
|
+
coalesce(r.source_turn_id,e.turn_id) AS turn_id,
|
|
314
|
+
coalesce(r.whole_source,1) AS whole_source,r.source_version
|
|
315
|
+
FROM source_erasures e LEFT JOIN source_erasure_revisions r USING(sequence)
|
|
316
|
+
WHERE e.contact_id=?''', (contact_id,)
|
|
317
|
+
)]
|
|
318
|
+
|
|
319
|
+
@staticmethod
|
|
320
|
+
def _retained_messages(messages: list[dict[str, Any]], session_id: str, rules: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
321
|
+
return [message for message in messages
|
|
322
|
+
if not TurnIdempotencyLedger._erasure_causes([message], session_id, rules)]
|
|
323
|
+
|
|
324
|
+
@staticmethod
|
|
325
|
+
def _erasure_causes(messages, session_id, rules):
|
|
326
|
+
causes = set()
|
|
327
|
+
for message in messages:
|
|
328
|
+
refs = message.get('_supplied_sources', []) if message.get('role') == 'assistant' else []
|
|
329
|
+
for rule in rules:
|
|
330
|
+
exact = (rule['session_id'] == session_id and source_message_hash(session_id, message)
|
|
331
|
+
in json.loads(rule['message_hashes_json']))
|
|
332
|
+
dependent = any(ref.get('source_id') == rule['turn_id'] and (
|
|
333
|
+
rule['whole_source'] or ref.get('source_version') == rule['source_version'])
|
|
334
|
+
for ref in refs if isinstance(ref, dict))
|
|
335
|
+
if exact or dependent:
|
|
336
|
+
causes.add(rule['turn_id'])
|
|
337
|
+
return causes
|
|
338
|
+
|
|
339
|
+
@staticmethod
|
|
340
|
+
def _resolve_input_dependencies(conn, contact_id, session_id, refs, *, turn_id=''):
|
|
341
|
+
if not isinstance(refs, list):
|
|
342
|
+
raise ValueError('invalid_source_input_dependency')
|
|
343
|
+
result = []
|
|
344
|
+
for ref in refs:
|
|
345
|
+
if (not isinstance(ref, dict) or set(ref) != {'source_id', 'input_message_hash'}
|
|
346
|
+
or not isinstance(ref['source_id'], str) or not 1 <= len(ref['source_id']) <= 256
|
|
347
|
+
or ref['source_id'] == turn_id or not isinstance(ref['input_message_hash'], str)
|
|
348
|
+
or not re.fullmatch('[0-9a-f]{64}', ref['input_message_hash'])):
|
|
349
|
+
raise ValueError('invalid_source_input_dependency')
|
|
350
|
+
# An old revision can have been erased while delivery was offline.
|
|
351
|
+
# Check that exact input first, even when some parent content remains.
|
|
352
|
+
erased = conn.execute('''SELECT e.message_hashes_json,r.source_version
|
|
353
|
+
FROM source_erasures e JOIN source_erasure_revisions r USING(sequence)
|
|
354
|
+
WHERE r.source_turn_id=? AND e.contact_id=?
|
|
355
|
+
AND (r.source_scope='person' OR e.session_id=?) ORDER BY sequence''',
|
|
356
|
+
(ref['source_id'], contact_id, session_id)).fetchall()
|
|
357
|
+
version = next((row['source_version'] for row in erased
|
|
358
|
+
if ref['input_message_hash'] in json.loads(row['message_hashes_json'])), None)
|
|
359
|
+
if version is None:
|
|
360
|
+
parent = conn.execute('''SELECT * FROM turn_sources WHERE turn_id=? AND contact_id=?
|
|
361
|
+
AND NOT EXISTS (SELECT 1 FROM source_attribution_invalidations i
|
|
362
|
+
WHERE i.source_id=turn_sources.turn_id)''', (ref['source_id'], contact_id)).fetchone()
|
|
363
|
+
if parent is None:
|
|
364
|
+
if conn.execute('SELECT 1 FROM turn_sources WHERE turn_id=?', (ref['source_id'],)).fetchone():
|
|
365
|
+
raise ValueError('invalid_source_input_dependency')
|
|
366
|
+
raise SourceInputPending('source_input_parent_pending')
|
|
367
|
+
messages = json.loads(parent['messages_json'])
|
|
368
|
+
if (not (parent['scope'] == 'person' or parent['session_id'] == session_id)
|
|
369
|
+
or not any(message.get('role') == 'user' and
|
|
370
|
+
source_message_hash(parent['session_id'], message) == ref['input_message_hash']
|
|
371
|
+
for message in messages)):
|
|
372
|
+
raise ValueError('invalid_source_input_dependency')
|
|
373
|
+
version = canonical_turn_digest(messages)
|
|
374
|
+
result.append({'source_id': ref['source_id'], 'source_version': version})
|
|
375
|
+
return result
|
|
376
|
+
|
|
377
|
+
def resolve_input_dependencies(self, *, contact_id, session_id, refs, turn_id=''):
|
|
378
|
+
"""Preflight before reserving effects; record_source rechecks atomically."""
|
|
379
|
+
with closing(self._connect()) as conn:
|
|
380
|
+
return self._resolve_input_dependencies(conn, contact_id, session_id, refs, turn_id=turn_id)
|
|
381
|
+
|
|
382
|
+
@staticmethod
|
|
383
|
+
def _validate_dependencies(conn, turn_id, contact_id, session_id, messages):
|
|
384
|
+
for message in messages:
|
|
385
|
+
refs = message.get('_supplied_sources', [])
|
|
386
|
+
# The complete source envelope already has an 8 MiB bound. A
|
|
387
|
+
# separate count cap would reject valid long native histories only
|
|
388
|
+
# after generation, or tempt callers to silently drop dependencies.
|
|
389
|
+
if not isinstance(refs, list) or (refs and message.get('role') != 'assistant'):
|
|
390
|
+
raise ValueError('invalid_source_dependency')
|
|
391
|
+
for ref in refs:
|
|
392
|
+
if (not isinstance(ref, dict) or set(ref) != {'source_id', 'source_version'}
|
|
393
|
+
or not isinstance(ref['source_id'], str) or not 1 <= len(ref['source_id']) <= 256
|
|
394
|
+
or ref['source_id'] == turn_id or not isinstance(ref['source_version'], str)
|
|
395
|
+
or not re.fullmatch('[0-9a-f]{64}', ref['source_version'])):
|
|
396
|
+
raise ValueError('invalid_source_dependency')
|
|
397
|
+
parent = conn.execute('''SELECT * FROM turn_sources WHERE turn_id=? AND contact_id=?
|
|
398
|
+
AND NOT EXISTS (SELECT 1 FROM source_attribution_invalidations i
|
|
399
|
+
WHERE i.source_id=turn_sources.turn_id)''',
|
|
400
|
+
(ref['source_id'], contact_id)).fetchone()
|
|
401
|
+
if (parent and (parent['scope'] == 'person' or parent['session_id'] == session_id)
|
|
402
|
+
and canonical_turn_digest(json.loads(parent['messages_json'])) == ref['source_version']):
|
|
403
|
+
continue
|
|
404
|
+
# A delayed answer may truthfully reference the exact revision
|
|
405
|
+
# already erased. Accept its envelope, then redact that answer.
|
|
406
|
+
erased = conn.execute('''SELECT 1 FROM source_erasures e JOIN source_erasure_revisions r USING(sequence)
|
|
407
|
+
WHERE r.source_turn_id=? AND e.contact_id=? AND r.source_version=?
|
|
408
|
+
AND (r.source_scope='person' OR e.session_id=?)''',
|
|
409
|
+
(ref['source_id'], contact_id, ref['source_version'], session_id)).fetchone()
|
|
410
|
+
if not erased:
|
|
411
|
+
raise ValueError('invalid_source_dependency')
|
|
412
|
+
|
|
413
|
+
@staticmethod
|
|
414
|
+
def _append_erasure(conn, *, turn_id, contact_id, session_id, scope, messages, removed, whole):
|
|
415
|
+
version = canonical_turn_digest(messages)
|
|
416
|
+
if conn.execute('''SELECT 1 FROM source_erasure_revisions WHERE
|
|
417
|
+
source_turn_id=? AND source_version=? AND whole_source=?''', (turn_id, version, int(whole))).fetchone():
|
|
418
|
+
return
|
|
419
|
+
hashes = {h for row in conn.execute('''SELECT e.message_hashes_json FROM source_erasures e
|
|
420
|
+
LEFT JOIN source_erasure_revisions r USING(sequence)
|
|
421
|
+
WHERE coalesce(r.source_turn_id,e.turn_id)=?''', (turn_id,))
|
|
422
|
+
for h in json.loads(row[0])}
|
|
423
|
+
hashes.update(source_message_hash(session_id, message) for message in removed)
|
|
424
|
+
event_id = turn_id if whole else 'erased-message:' + uuid.uuid4().hex
|
|
425
|
+
if not whole:
|
|
426
|
+
while conn.execute('SELECT 1 FROM turn_sources WHERE turn_id=? UNION SELECT 1 FROM source_erasures WHERE turn_id=?',
|
|
427
|
+
(event_id, event_id)).fetchone():
|
|
428
|
+
event_id = 'erased-message:' + uuid.uuid4().hex
|
|
429
|
+
cursor = conn.execute('''INSERT OR IGNORE INTO source_erasures
|
|
430
|
+
(contact_id,turn_id,session_id,message_hashes_json) VALUES (?,?,?,?)''',
|
|
431
|
+
(contact_id, event_id, session_id, json.dumps(sorted(hashes))))
|
|
432
|
+
if cursor.rowcount:
|
|
433
|
+
conn.execute('''INSERT INTO source_erasure_revisions
|
|
434
|
+
(sequence,source_turn_id,source_version,source_scope,whole_source) VALUES (?,?,?,?,?)''',
|
|
435
|
+
(cursor.lastrowid, turn_id, version, scope, int(whole)))
|
|
436
|
+
|
|
437
|
+
def source_references(self, turn_ids, *, contact_id, session_id):
|
|
438
|
+
"""Structured selected-source revisions, never parsed from generated prose."""
|
|
439
|
+
refs = []
|
|
440
|
+
with closing(self._connect()) as conn:
|
|
441
|
+
for turn_id in dict.fromkeys(turn_ids):
|
|
442
|
+
row = conn.execute('''SELECT messages_json FROM turn_sources WHERE turn_id=?
|
|
443
|
+
AND contact_id=? AND (scope='person' OR session_id=?)
|
|
444
|
+
AND NOT EXISTS (SELECT 1 FROM source_attribution_invalidations i
|
|
445
|
+
WHERE i.source_id=turn_sources.turn_id)''',
|
|
446
|
+
(turn_id, contact_id, session_id)).fetchone()
|
|
447
|
+
if row:
|
|
448
|
+
refs.append({'source_id': turn_id, 'source_version': canonical_turn_digest(json.loads(row[0]))})
|
|
449
|
+
return refs
|
|
450
|
+
|
|
451
|
+
def is_source_erased(self, turn_id: str, contact_id: str | None = None) -> bool:
|
|
452
|
+
with closing(self._connect()) as conn:
|
|
453
|
+
sql, args = "SELECT 1 FROM source_erasures WHERE turn_id=?", [turn_id]
|
|
454
|
+
if contact_id is not None:
|
|
455
|
+
sql += " AND contact_id=?"
|
|
456
|
+
args.append(contact_id)
|
|
457
|
+
return conn.execute(sql, args).fetchone() is not None
|
|
458
|
+
|
|
459
|
+
def is_projection_erased(self, turn_id: str) -> bool:
|
|
460
|
+
with closing(self._connect()) as conn:
|
|
461
|
+
return conn.execute("SELECT 1 FROM source_projection_erasures WHERE turn_id=?", (turn_id,)).fetchone() is not None
|
|
462
|
+
|
|
463
|
+
def retained_messages(self, *, contact_id: str, session_id: str, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
464
|
+
with closing(self._connect()) as conn:
|
|
465
|
+
return self._retained_messages(messages, session_id, self._erasure_rules(conn, contact_id))
|
|
466
|
+
|
|
467
|
+
def erasure_watermark(self, contact_id: str) -> int:
|
|
468
|
+
with closing(self._connect()) as conn:
|
|
469
|
+
return int(conn.execute("SELECT coalesce(max(sequence), 0) FROM source_erasures WHERE contact_id=?", (contact_id,)).fetchone()[0])
|
|
470
|
+
|
|
471
|
+
def erasure_feed(self, contact_id: str, after: int = 0, limit: int = 250) -> dict[str, Any]:
|
|
472
|
+
with closing(self._connect()) as conn:
|
|
473
|
+
head = conn.execute("SELECT coalesce(max(sequence), 0) FROM source_erasures WHERE contact_id=?", (contact_id,)).fetchone()[0]
|
|
474
|
+
if after > head:
|
|
475
|
+
raise ValueError("erasure cursor exceeds server history; restore requires reconciliation")
|
|
476
|
+
rows = conn.execute('''SELECT e.*,r.source_turn_id,r.source_version,coalesce(r.whole_source,1) AS whole_source
|
|
477
|
+
FROM source_erasures e LEFT JOIN source_erasure_revisions r USING(sequence)
|
|
478
|
+
WHERE e.contact_id=? AND e.sequence>? ORDER BY e.sequence LIMIT ?''', (contact_id, after, max(1, min(limit, 500)))).fetchall()
|
|
479
|
+
events = [{"sequence": row["sequence"], "turn_id": row["turn_id"], "session_id": row["session_id"],
|
|
480
|
+
"message_hashes": json.loads(row["message_hashes_json"]),
|
|
481
|
+
"whole_source": bool(row['whole_source']), 'source_version': row['source_version'],
|
|
482
|
+
'source_turn_id': row['source_turn_id'] or row['turn_id']}
|
|
483
|
+
for row in rows]
|
|
484
|
+
through = events[-1]["sequence"] if events else after
|
|
485
|
+
return {"contact_id": contact_id, "head": head, "through": through, "events": events, "complete": through == head}
|
|
486
|
+
|
|
487
|
+
def erase_sources(self, *, contact_id: str, turn_ids: list[str] | None = None, old_text: str | None = None, session_id: str | None = None) -> dict[str, Any]:
|
|
488
|
+
"""Selective source erasure, including exact checkpoint message copies.
|
|
489
|
+
|
|
490
|
+
Tombstones retain IDs and hashes only. Unknown IDs are rejected rather
|
|
491
|
+
than allowing a contact to reserve or erase another contact's future ID.
|
|
492
|
+
Exact-text compatibility is deliberately unambiguous and session-bound.
|
|
493
|
+
"""
|
|
494
|
+
if not contact_id or bool(turn_ids) == bool(old_text):
|
|
495
|
+
raise ValueError("select source IDs or exact old_text")
|
|
496
|
+
with closing(self._connect()) as conn, conn:
|
|
497
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
498
|
+
rows = conn.execute("SELECT * FROM turn_sources WHERE contact_id=?", (contact_id,)).fetchall()
|
|
499
|
+
by_id = {row["turn_id"]: row for row in rows}
|
|
500
|
+
if old_text:
|
|
501
|
+
if not session_id:
|
|
502
|
+
raise ValueError("exact text erasure requires a session")
|
|
503
|
+
matches = [row["turn_id"] for row in rows if row["session_id"] == session_id
|
|
504
|
+
and any(message.get("content") == old_text for message in json.loads(row["messages_json"]))]
|
|
505
|
+
# A checkpoint plus its ordinary source are two matches. Callers
|
|
506
|
+
# must select explicit provenance rather than guessing ownership.
|
|
507
|
+
if len(matches) != 1:
|
|
508
|
+
raise ValueError("ambiguous_source" if matches else "source_not_found")
|
|
509
|
+
turn_ids = matches
|
|
510
|
+
selected = list(dict.fromkeys(turn_ids or []))
|
|
511
|
+
if not 1 <= len(selected) <= 100 or any(not item or len(item) > 256 for item in selected):
|
|
512
|
+
raise ValueError("select 1..100 source IDs")
|
|
513
|
+
existing_rules = self._erasure_rules(conn, contact_id)
|
|
514
|
+
known_erased = {rule["turn_id"] for rule in existing_rules if rule["whole_source"]}
|
|
515
|
+
if any(item not in by_id and item not in known_erased for item in selected):
|
|
516
|
+
raise ValueError("source_not_found")
|
|
517
|
+
for turn_id in selected:
|
|
518
|
+
row = by_id.get(turn_id)
|
|
519
|
+
if row is None:
|
|
520
|
+
continue
|
|
521
|
+
messages = json.loads(row["messages_json"])
|
|
522
|
+
self._append_erasure(conn, turn_id=turn_id, contact_id=contact_id,
|
|
523
|
+
session_id=row['session_id'], scope=row['scope'], messages=messages,
|
|
524
|
+
removed=messages, whole=True)
|
|
525
|
+
affected = []
|
|
526
|
+
remaining_rows = {row['turn_id']: dict(row) for row in rows}
|
|
527
|
+
# Each changed row loses at least one message. This finite closure
|
|
528
|
+
# follows only recorded supplied-source revisions, never topic text.
|
|
529
|
+
changed = True
|
|
530
|
+
while changed:
|
|
531
|
+
changed = False
|
|
532
|
+
for row in list(remaining_rows.values()):
|
|
533
|
+
rules = self._erasure_rules(conn, contact_id)
|
|
534
|
+
erased_ids = {rule['turn_id'] for rule in rules if rule['whole_source']}
|
|
535
|
+
messages = json.loads(row["messages_json"])
|
|
536
|
+
retained = [] if row["turn_id"] in erased_ids else self._retained_messages(messages, row["session_id"], rules)
|
|
537
|
+
if retained == messages:
|
|
538
|
+
continue
|
|
539
|
+
changed = True
|
|
540
|
+
if row['turn_id'] not in erased_ids:
|
|
541
|
+
self._append_erasure(conn, turn_id=row['turn_id'], contact_id=contact_id,
|
|
542
|
+
session_id=row['session_id'], scope=row['scope'], messages=messages,
|
|
543
|
+
removed=[message for message in messages if message not in retained], whole=False)
|
|
544
|
+
from apsimo.beliefs.source_projection import erase_removed
|
|
545
|
+
erase_removed(conn, row["turn_id"], row["session_id"], retained)
|
|
546
|
+
from apsimo.turns.media import erase_removed as erase_media
|
|
547
|
+
erase_media(conn, row["turn_id"], row["session_id"], retained)
|
|
548
|
+
from apsimo.self_model.perspective import erase_removed as erase_preferences
|
|
549
|
+
erase_preferences(conn, row["turn_id"], row["session_id"], retained)
|
|
550
|
+
from apsimo.self_model.judgments import erase_removed as erase_judgments
|
|
551
|
+
erase_judgments(conn, row["turn_id"], row["session_id"], retained)
|
|
552
|
+
from apsimo.self_model.appraisals import erase_removed as erase_appraisals
|
|
553
|
+
erase_appraisals(conn, row["turn_id"], row["session_id"], retained)
|
|
554
|
+
affected.append(row["turn_id"])
|
|
555
|
+
for selected_id in selected:
|
|
556
|
+
conn.execute("INSERT OR IGNORE INTO source_projection_erasures(turn_id,source_turn_id) VALUES (?,?)", (row["turn_id"], selected_id))
|
|
557
|
+
conn.execute("DELETE FROM turn_source_search WHERE turn_id=?", (row["turn_id"],))
|
|
558
|
+
if retained:
|
|
559
|
+
row['messages_json'] = json.dumps(retained, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
|
|
560
|
+
conn.execute("UPDATE turn_sources SET messages_json=? WHERE turn_id=?", (row['messages_json'], row["turn_id"]))
|
|
561
|
+
self._index_messages(conn, row["turn_id"], retained)
|
|
562
|
+
from apsimo.turns.source_vectors import enqueue as enqueue_vectors
|
|
563
|
+
enqueue_vectors(conn, row['turn_id'])
|
|
564
|
+
else:
|
|
565
|
+
del remaining_rows[row['turn_id']]
|
|
566
|
+
conn.execute("DELETE FROM turn_sources WHERE turn_id=?", (row["turn_id"],))
|
|
567
|
+
conn.execute('DELETE FROM source_vector_jobs WHERE turn_id=?', (row['turn_id'],))
|
|
568
|
+
# Keep the immutable request digest, but invalidate cached
|
|
569
|
+
# summaries and turn effects after partial source redaction.
|
|
570
|
+
conn.execute("UPDATE turn_ingestion SET response_json=NULL, error=NULL WHERE turn_id=?", (row["turn_id"],))
|
|
571
|
+
# Preserve cleanup targets so a retry after a graph outage also
|
|
572
|
+
# removes copies that disappeared from the source table already.
|
|
573
|
+
for selected_id in selected:
|
|
574
|
+
affected.extend(row[0] for row in conn.execute("SELECT turn_id FROM source_projection_erasures WHERE source_turn_id=?", (selected_id,)))
|
|
575
|
+
watermark = conn.execute("SELECT coalesce(max(sequence),0) FROM source_erasures WHERE contact_id=?", (contact_id,)).fetchone()[0]
|
|
576
|
+
from apsimo.turns.media import SourceMedia
|
|
577
|
+
media = SourceMedia(self)
|
|
578
|
+
try:
|
|
579
|
+
media.collect_orphans(limit=100)
|
|
580
|
+
media_cleanup = media.cleanup_status()
|
|
581
|
+
except OSError:
|
|
582
|
+
media_cleanup = "pending"
|
|
583
|
+
return {"source_ids": selected, "affected_source_ids": list(dict.fromkeys(affected)),
|
|
584
|
+
"watermark": watermark, "media_cleanup": media_cleanup}
|
|
585
|
+
|
|
586
|
+
def search_sources(
|
|
587
|
+
self, query: str, *, contact_id: str, session_id: str, limit: int = 5,
|
|
588
|
+
) -> list[dict[str, Any]]:
|
|
589
|
+
"""Recall direct source excerpts inside the already-authorized viewer."""
|
|
590
|
+
if not contact_id:
|
|
591
|
+
return []
|
|
592
|
+
stop = {"the", "and", "that", "this", "what", "when", "where", "how", "you", "your", "was", "were", "are", "for", "with", "remember", "about"}
|
|
593
|
+
words = list(dict.fromkeys(
|
|
594
|
+
word.lower() for word in re.findall(r"\w+", query[:4096])
|
|
595
|
+
if len(word) > 2 and word.lower() not in stop
|
|
596
|
+
))[:12]
|
|
597
|
+
if not words:
|
|
598
|
+
return []
|
|
599
|
+
expression = " OR ".join('"' + word + '"' for word in words)
|
|
600
|
+
from apsimo.turns.audio import evidence_metadata
|
|
601
|
+
|
|
602
|
+
@lru_cache(maxsize=4)
|
|
603
|
+
def canonical_chunks(session, encoded):
|
|
604
|
+
# Reconstruct only scoped FTS matches, not the whole source store.
|
|
605
|
+
# The per-query cache is bounded; source envelopes are capped at 8 MiB.
|
|
606
|
+
chunks, messages = {}, {}
|
|
607
|
+
for message, text, chunk in _lexical_chunks(json.loads(encoded)):
|
|
608
|
+
# A long message can have thousands of chunks. Hash its original
|
|
609
|
+
# envelope and recover its modality once, not once per chunk.
|
|
610
|
+
key = id(message)
|
|
611
|
+
if key not in messages:
|
|
612
|
+
messages[key] = (source_message_hash(session, message), evidence_metadata(message))
|
|
613
|
+
message_hash, metadata = messages[key]
|
|
614
|
+
owners = chunks.setdefault((message.get('role'), chunk), {})
|
|
615
|
+
owners[message_hash] = {'source_message_hash': message_hash,
|
|
616
|
+
**metadata,
|
|
617
|
+
**({'excerpt_truncated': True} if chunk != text else {})}
|
|
618
|
+
return {key: json.dumps(list(owners.values()), sort_keys=True)
|
|
619
|
+
for key, owners in chunks.items()}
|
|
620
|
+
|
|
621
|
+
def owners(session, encoded, role, chunk):
|
|
622
|
+
return canonical_chunks(session, encoded).get((role, chunk), '[]')
|
|
623
|
+
|
|
624
|
+
with closing(self._connect()) as conn:
|
|
625
|
+
conn.create_function('canonical_lexical_owners', 4, owners)
|
|
626
|
+
rows = conn.execute("""
|
|
627
|
+
SELECT DISTINCT f.turn_id, f.role, f.content, s.contact_id, s.session_id, s.scope,
|
|
628
|
+
s.occurred_at, s.ingested_at, owner.value AS ownership
|
|
629
|
+
FROM turn_source_search AS f
|
|
630
|
+
JOIN turn_sources AS s ON s.turn_id=f.turn_id
|
|
631
|
+
JOIN json_each(canonical_lexical_owners(
|
|
632
|
+
s.session_id, s.messages_json, f.role, f.content)) AS owner
|
|
633
|
+
WHERE turn_source_search MATCH ? AND s.contact_id=?
|
|
634
|
+
AND (s.scope='person' OR s.session_id=?)
|
|
635
|
+
AND NOT EXISTS (SELECT 1 FROM source_attribution_invalidations i WHERE i.source_id=s.turn_id)
|
|
636
|
+
ORDER BY bm25(turn_source_search)
|
|
637
|
+
LIMIT ?
|
|
638
|
+
""", (expression, contact_id, session_id, max(1, min(limit, 20)))).fetchall()
|
|
639
|
+
# Exact chunk ownership and duplicate projection rows are resolved before
|
|
640
|
+
# LIMIT, so stale rows cannot spend the canonical result budget. This also
|
|
641
|
+
# reads predecessor-written indexes without a migration or startup rebuild.
|
|
642
|
+
result = []
|
|
643
|
+
for row in rows:
|
|
644
|
+
value = dict(row)
|
|
645
|
+
ownership = json.loads(value.pop('ownership'))
|
|
646
|
+
result.append({**value, **ownership})
|
|
647
|
+
return result
|
|
648
|
+
|
|
649
|
+
def reserve(self, turn_id: str, content_sha256: str) -> Reservation:
|
|
650
|
+
turn_id = (turn_id or "").strip()
|
|
651
|
+
if not turn_id or len(turn_id) > 256:
|
|
652
|
+
raise ValueError("turn_id must contain 1..256 non-whitespace characters")
|
|
653
|
+
if len(content_sha256) != 64:
|
|
654
|
+
raise ValueError("content_sha256 must be a SHA-256 hex digest")
|
|
655
|
+
|
|
656
|
+
conn = self._connect()
|
|
657
|
+
try:
|
|
658
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
659
|
+
row = conn.execute(
|
|
660
|
+
"SELECT content_sha256, state, response_json "
|
|
661
|
+
"FROM turn_ingestion WHERE turn_id=?",
|
|
662
|
+
(turn_id,),
|
|
663
|
+
).fetchone()
|
|
664
|
+
if row is None:
|
|
665
|
+
conn.execute(
|
|
666
|
+
"INSERT INTO turn_ingestion "
|
|
667
|
+
"(turn_id, content_sha256, state) VALUES (?, ?, 'processing')",
|
|
668
|
+
(turn_id, content_sha256),
|
|
669
|
+
)
|
|
670
|
+
conn.commit()
|
|
671
|
+
return Reservation(ReservationOutcome.CREATED)
|
|
672
|
+
|
|
673
|
+
conn.commit()
|
|
674
|
+
if row["content_sha256"] != content_sha256:
|
|
675
|
+
return Reservation(ReservationOutcome.CONFLICT)
|
|
676
|
+
if row["state"] == "completed":
|
|
677
|
+
response = None
|
|
678
|
+
if row["response_json"]:
|
|
679
|
+
response = json.loads(row["response_json"])
|
|
680
|
+
return Reservation(ReservationOutcome.REPLAYED, response=response)
|
|
681
|
+
if row["state"] == "ambiguous":
|
|
682
|
+
return Reservation(ReservationOutcome.AMBIGUOUS)
|
|
683
|
+
return Reservation(ReservationOutcome.IN_PROGRESS)
|
|
684
|
+
except Exception:
|
|
685
|
+
conn.rollback()
|
|
686
|
+
raise
|
|
687
|
+
finally:
|
|
688
|
+
conn.close()
|
|
689
|
+
|
|
690
|
+
def complete(
|
|
691
|
+
self,
|
|
692
|
+
turn_id: str,
|
|
693
|
+
content_sha256: str,
|
|
694
|
+
response: Mapping[str, Any],
|
|
695
|
+
) -> None:
|
|
696
|
+
response_json = json.dumps(
|
|
697
|
+
dict(response), ensure_ascii=False, separators=(",", ":"), sort_keys=True
|
|
698
|
+
)
|
|
699
|
+
with closing(self._connect()) as conn, conn:
|
|
700
|
+
changed = conn.execute(
|
|
701
|
+
"""
|
|
702
|
+
UPDATE turn_ingestion
|
|
703
|
+
SET state='completed', response_json=?,
|
|
704
|
+
completed_at=strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
|
|
705
|
+
error=NULL
|
|
706
|
+
WHERE turn_id=? AND content_sha256=? AND state='processing'
|
|
707
|
+
""",
|
|
708
|
+
(response_json, turn_id, content_sha256),
|
|
709
|
+
).rowcount
|
|
710
|
+
if changed != 1:
|
|
711
|
+
row = conn.execute(
|
|
712
|
+
"SELECT content_sha256, state FROM turn_ingestion WHERE turn_id=?",
|
|
713
|
+
(turn_id,),
|
|
714
|
+
).fetchone()
|
|
715
|
+
if not row or row["content_sha256"] != content_sha256:
|
|
716
|
+
raise RuntimeError("turn reservation changed before completion")
|
|
717
|
+
if row["state"] != "completed":
|
|
718
|
+
raise RuntimeError(f"turn cannot complete from state {row['state']}")
|
|
719
|
+
|
|
720
|
+
def mark_ambiguous(
|
|
721
|
+
self,
|
|
722
|
+
turn_id: str,
|
|
723
|
+
content_sha256: str,
|
|
724
|
+
error: BaseException,
|
|
725
|
+
) -> None:
|
|
726
|
+
with closing(self._connect()) as conn, conn:
|
|
727
|
+
conn.execute(
|
|
728
|
+
"""
|
|
729
|
+
UPDATE turn_ingestion
|
|
730
|
+
SET state='ambiguous', error=?
|
|
731
|
+
WHERE turn_id=? AND content_sha256=? AND state='processing'
|
|
732
|
+
""",
|
|
733
|
+
(f"{type(error).__name__}: {error}"[:1000], turn_id, content_sha256),
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
def get(self, turn_id: str) -> Optional[dict[str, Any]]:
|
|
737
|
+
"""Read-only reconciliation view used by tests and future tooling."""
|
|
738
|
+
with closing(self._connect()) as conn, conn:
|
|
739
|
+
row = conn.execute(
|
|
740
|
+
"SELECT * FROM turn_ingestion WHERE turn_id=?", (turn_id,)
|
|
741
|
+
).fetchone()
|
|
742
|
+
return dict(row) if row else None
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
@lru_cache(maxsize=16)
|
|
746
|
+
def _cached_ledger(db_path: str) -> TurnIdempotencyLedger:
|
|
747
|
+
return TurnIdempotencyLedger(db_path)
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def get_turn_idempotency_ledger(state_dir: str | Path) -> TurnIdempotencyLedger:
|
|
751
|
+
path = Path(state_dir).resolve() / "turn-idempotency.db"
|
|
752
|
+
return _cached_ledger(str(path))
|