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,406 @@
|
|
|
1
|
+
"""Colony Vector — image preprocessing, EXIF extraction, and thumbnail generation.
|
|
2
|
+
|
|
3
|
+
Handles all image input normalization: loading from paths, URLs, base64, or raw
|
|
4
|
+
bytes. Extracts EXIF metadata, generates thumbnails, and validates format/size.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import hashlib
|
|
11
|
+
import io
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import struct
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Optional
|
|
18
|
+
from urllib.parse import urlparse
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Constants
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
MAX_IMAGE_SIZE_BYTES = 20 * 1024 * 1024 # 20 MB
|
|
27
|
+
MAX_IMAGE_DIMENSION = 4096
|
|
28
|
+
THUMBNAIL_SIZE = (128, 128)
|
|
29
|
+
THUMBNAIL_QUALITY = 75
|
|
30
|
+
SUPPORTED_FORMATS = {"jpeg", "jpg", "png", "webp", "gif"}
|
|
31
|
+
|
|
32
|
+
# MIME type mapping
|
|
33
|
+
MIME_MAP = {
|
|
34
|
+
b"\xff\xd8\xff": "image/jpeg",
|
|
35
|
+
b"\x89PNG": "image/png",
|
|
36
|
+
b"RIFF": "image/webp", # WebP starts with RIFF
|
|
37
|
+
b"GIF8": "image/gif",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# Image validation
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def detect_mime(data: bytes) -> str:
|
|
47
|
+
"""Detect MIME type from magic bytes."""
|
|
48
|
+
for magic, mime in MIME_MAP.items():
|
|
49
|
+
if data[:len(magic)] == magic:
|
|
50
|
+
return mime
|
|
51
|
+
return ""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def validate_image(data: bytes, max_size: int = MAX_IMAGE_SIZE_BYTES) -> list[str]:
|
|
55
|
+
"""Validate image data. Returns list of error messages (empty = valid)."""
|
|
56
|
+
errors: list[str] = []
|
|
57
|
+
if len(data) == 0:
|
|
58
|
+
errors.append("Empty image data")
|
|
59
|
+
if len(data) > max_size:
|
|
60
|
+
errors.append(f"Image size {len(data)} exceeds limit {max_size}")
|
|
61
|
+
mime = detect_mime(data)
|
|
62
|
+
if not mime:
|
|
63
|
+
errors.append("Unrecognized image format (must be JPEG, PNG, WebP, or GIF)")
|
|
64
|
+
return errors
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
# Image loading
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _is_url(s: str) -> bool:
|
|
73
|
+
try:
|
|
74
|
+
result = urlparse(s)
|
|
75
|
+
return result.scheme in ("http", "https")
|
|
76
|
+
except Exception:
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _is_base64(s: str) -> bool:
|
|
81
|
+
"""Check if a string looks like base64-encoded data."""
|
|
82
|
+
if s.startswith("data:"):
|
|
83
|
+
return True
|
|
84
|
+
if len(s) < 100:
|
|
85
|
+
return False
|
|
86
|
+
try:
|
|
87
|
+
# Quick check — doesn't need to be perfect
|
|
88
|
+
if len(s) % 4 == 0 and all(c in base64._urlsafe_b64alphabet.decode() or c == '=' for c in s[:100]):
|
|
89
|
+
return True
|
|
90
|
+
except Exception:
|
|
91
|
+
pass
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _decode_base64(s: str) -> bytes:
|
|
96
|
+
"""Decode base64 image data, handling data URIs."""
|
|
97
|
+
if s.startswith("data:"):
|
|
98
|
+
# data:image/jpeg;base64,<data>
|
|
99
|
+
_, _, encoded = s.partition(",")
|
|
100
|
+
if not encoded:
|
|
101
|
+
raise ValueError("Empty base64 data in data URI")
|
|
102
|
+
return base64.b64decode(encoded)
|
|
103
|
+
return base64.b64decode(s)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
async def load_image(source: str | bytes, mime_type: str = "") -> tuple[bytes, str]:
|
|
107
|
+
"""Load image data from a path, URL, base64 string, or raw bytes.
|
|
108
|
+
|
|
109
|
+
Returns (raw_bytes, mime_type).
|
|
110
|
+
"""
|
|
111
|
+
import httpx
|
|
112
|
+
|
|
113
|
+
if isinstance(source, bytes):
|
|
114
|
+
mime = mime_type or detect_mime(source)
|
|
115
|
+
return source, mime
|
|
116
|
+
|
|
117
|
+
if not isinstance(source, str):
|
|
118
|
+
raise ValueError(f"Unsupported image source type: {type(source)}")
|
|
119
|
+
|
|
120
|
+
# URL
|
|
121
|
+
if _is_url(source):
|
|
122
|
+
async with httpx.AsyncClient(timeout=30) as client:
|
|
123
|
+
resp = await client.get(source, follow_redirects=True)
|
|
124
|
+
resp.raise_for_status()
|
|
125
|
+
data = resp.content
|
|
126
|
+
mime = mime_type or resp.headers.get("content-type", "").split(";")[0] or detect_mime(data)
|
|
127
|
+
return data, mime
|
|
128
|
+
|
|
129
|
+
# Base64
|
|
130
|
+
if _is_base64(source):
|
|
131
|
+
data = _decode_base64(source)
|
|
132
|
+
mime = mime_type or detect_mime(data)
|
|
133
|
+
return data, mime
|
|
134
|
+
|
|
135
|
+
# File path
|
|
136
|
+
path = Path(source)
|
|
137
|
+
if not path.exists():
|
|
138
|
+
raise FileNotFoundError(f"Image file not found: {source}")
|
|
139
|
+
data = path.read_bytes()
|
|
140
|
+
mime = mime_type or _mime_from_extension(path) or detect_mime(data)
|
|
141
|
+
return data, mime
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _mime_from_extension(path: Path) -> str:
|
|
145
|
+
ext = path.suffix.lower().lstrip(".")
|
|
146
|
+
mime_map = {"jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png", "webp": "image/webp", "gif": "image/gif"}
|
|
147
|
+
return mime_map.get(ext, "")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
# EXIF extraction
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def extract_exif(data: bytes) -> dict[str, Any]:
|
|
156
|
+
"""Extract EXIF metadata from image bytes.
|
|
157
|
+
|
|
158
|
+
Uses Pillow when available for comprehensive extraction.
|
|
159
|
+
Falls back to minimal JPEG parser for GPS and date only.
|
|
160
|
+
"""
|
|
161
|
+
exif: dict[str, Any] = {}
|
|
162
|
+
|
|
163
|
+
# Try Pillow first (comprehensive)
|
|
164
|
+
try:
|
|
165
|
+
from PIL import Image
|
|
166
|
+
from PIL.ExifTags import TAGS, GPSTAGS
|
|
167
|
+
|
|
168
|
+
img = Image.open(io.BytesIO(data))
|
|
169
|
+
raw_exif = img.getexif()
|
|
170
|
+
if not raw_exif:
|
|
171
|
+
return exif
|
|
172
|
+
|
|
173
|
+
# Extract standard tags
|
|
174
|
+
tag_map = {
|
|
175
|
+
0x010F: "camera_make",
|
|
176
|
+
0x0110: "camera_model",
|
|
177
|
+
0x0132: "captured_at",
|
|
178
|
+
0x829A: "exposure_time",
|
|
179
|
+
0x920A: "focal_length",
|
|
180
|
+
0x8827: "iso",
|
|
181
|
+
}
|
|
182
|
+
for tag_id, value in raw_exif.items():
|
|
183
|
+
tag_name = TAGS.get(tag_id, tag_map.get(tag_id))
|
|
184
|
+
if tag_name in tag_map.values():
|
|
185
|
+
exif[tag_name] = str(value)
|
|
186
|
+
elif tag_id in tag_map:
|
|
187
|
+
exif[tag_map[tag_id]] = str(value)
|
|
188
|
+
|
|
189
|
+
# Extract GPS
|
|
190
|
+
gps_ifd = raw_exif.get_ifd(0x8825) # GPS IFD tag
|
|
191
|
+
if gps_ifd:
|
|
192
|
+
gps_data = {}
|
|
193
|
+
for tag_id, value in gps_ifd.items():
|
|
194
|
+
tag_name = GPSTAGS.get(tag_id, str(tag_id))
|
|
195
|
+
gps_data[tag_name] = value
|
|
196
|
+
|
|
197
|
+
# Convert GPS coordinates to decimal
|
|
198
|
+
lat = _gps_to_decimal(gps_data.get("GPSLatitude"), gps_data.get("GPSLatitudeRef"))
|
|
199
|
+
lon = _gps_to_decimal(gps_data.get("GPSLongitude"), gps_data.get("GPSLongitudeRef"))
|
|
200
|
+
if lat is not None:
|
|
201
|
+
exif["gps_lat"] = lat
|
|
202
|
+
if lon is not None:
|
|
203
|
+
exif["gps_lon"] = lon
|
|
204
|
+
|
|
205
|
+
return exif
|
|
206
|
+
|
|
207
|
+
except ImportError:
|
|
208
|
+
pass # Fall through to minimal parser
|
|
209
|
+
except Exception as exc:
|
|
210
|
+
logger.debug("Pillow EXIF extraction failed: %s — trying minimal parser", exc)
|
|
211
|
+
|
|
212
|
+
# Fallback: minimal JPEG EXIF parser (GPS + date only)
|
|
213
|
+
if data[:2] != b"\xff\xd8":
|
|
214
|
+
return exif # Not JPEG
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
i = 2
|
|
218
|
+
while i < len(data) - 4:
|
|
219
|
+
if data[i:i+2] == b"\xff\xe1": # APP1 marker
|
|
220
|
+
length = struct.unpack(">H", data[i+2:i+4])[0]
|
|
221
|
+
exif_data = data[i+4:i+2+length]
|
|
222
|
+
if exif_data[:6] == b"Exif\x00\x00":
|
|
223
|
+
exif = _parse_exif_tiff(exif_data[6:])
|
|
224
|
+
break
|
|
225
|
+
elif data[i:i+2] == b"\xff":
|
|
226
|
+
length = struct.unpack(">H", data[i+2:i+4])[0]
|
|
227
|
+
i += 2 + length
|
|
228
|
+
else:
|
|
229
|
+
break
|
|
230
|
+
except Exception as exc:
|
|
231
|
+
logger.debug("Minimal EXIF extraction failed: %s", exc)
|
|
232
|
+
|
|
233
|
+
return exif
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _gps_to_decimal(coords, ref) -> Optional[float]:
|
|
237
|
+
"""Convert GPS coordinates (degrees, minutes, seconds) to decimal."""
|
|
238
|
+
if not coords or len(coords) < 3:
|
|
239
|
+
return None
|
|
240
|
+
try:
|
|
241
|
+
degrees = float(coords[0])
|
|
242
|
+
minutes = float(coords[1])
|
|
243
|
+
seconds = float(coords[2])
|
|
244
|
+
decimal = degrees + minutes / 60.0 + seconds / 3600.0
|
|
245
|
+
if ref in ("S", "W"):
|
|
246
|
+
decimal = -decimal
|
|
247
|
+
return round(decimal, 6)
|
|
248
|
+
except (TypeError, ValueError, IndexError):
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _parse_exif_tiff(data: bytes) -> dict[str, Any]:
|
|
253
|
+
"""Parse TIFF header from EXIF data, extracting GPS and key fields."""
|
|
254
|
+
result: dict[str, Any] = {}
|
|
255
|
+
try:
|
|
256
|
+
# Byte order
|
|
257
|
+
if data[:2] == b"II":
|
|
258
|
+
order = "<"
|
|
259
|
+
elif data[:2] == b"MM":
|
|
260
|
+
order = ">"
|
|
261
|
+
else:
|
|
262
|
+
return result
|
|
263
|
+
|
|
264
|
+
# TIFF magic (42)
|
|
265
|
+
magic = struct.unpack(order + "H", data[2:4])[0]
|
|
266
|
+
if magic != 42:
|
|
267
|
+
return result
|
|
268
|
+
|
|
269
|
+
# IFD0 offset
|
|
270
|
+
ifd0_offset = struct.unpack(order + "I", data[4:8])[0]
|
|
271
|
+
|
|
272
|
+
# Parse IFD0 entries
|
|
273
|
+
gps_tags = {0x0001: "gps_lat_ref", 0x0002: "gps_lat",
|
|
274
|
+
0x0003: "gps_lon_ref", 0x0004: "gps_lon"}
|
|
275
|
+
date_tag = 0x0132 # DateTime
|
|
276
|
+
|
|
277
|
+
num_entries = struct.unpack(order + "H", data[ifd0_offset:ifd0_offset+2])[0]
|
|
278
|
+
for j in range(num_entries):
|
|
279
|
+
entry_offset = ifd0_offset + 2 + j * 12
|
|
280
|
+
if entry_offset + 12 > len(data):
|
|
281
|
+
break
|
|
282
|
+
tag = struct.unpack(order + "H", data[entry_offset:entry_offset+2])[0]
|
|
283
|
+
fmt = struct.unpack(order + "H", data[entry_offset+2:entry_offset+4])[0]
|
|
284
|
+
count = struct.unpack(order + "I", data[entry_offset+4:entry_offset+8])[0]
|
|
285
|
+
value_offset = data[entry_offset+8:entry_offset+12]
|
|
286
|
+
|
|
287
|
+
if tag in gps_tags:
|
|
288
|
+
key = gps_tags[tag]
|
|
289
|
+
if fmt == 2 and count <= 4: # ASCII
|
|
290
|
+
result[key] = value_offset[:count].decode("ascii", errors="ignore").strip("\x00")
|
|
291
|
+
elif tag == date_tag:
|
|
292
|
+
if fmt == 2:
|
|
293
|
+
# Date string may be at offset
|
|
294
|
+
if count <= 4:
|
|
295
|
+
result["captured_at"] = value_offset[:count].decode("ascii", errors="ignore").strip("\x00")
|
|
296
|
+
else:
|
|
297
|
+
str_offset = struct.unpack(order + "I", value_offset)[0]
|
|
298
|
+
if str_offset + count <= len(data):
|
|
299
|
+
result["captured_at"] = data[str_offset:str_offset+count].decode("ascii", errors="ignore").strip("\x00")
|
|
300
|
+
except Exception as exc:
|
|
301
|
+
logger.debug("TIFF/EXIF parsing failed: %s", exc)
|
|
302
|
+
|
|
303
|
+
return result
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# ---------------------------------------------------------------------------
|
|
307
|
+
# Resize / thumbnail
|
|
308
|
+
# ---------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def resize_image(data: bytes, max_dim: int = MAX_IMAGE_DIMENSION) -> tuple[bytes, int, int]:
|
|
312
|
+
"""Resize image if it exceeds max_dim on either axis.
|
|
313
|
+
|
|
314
|
+
Returns (resized_bytes, width, height). If no resize needed,
|
|
315
|
+
returns original data with dimensions from PIL (or 0,0 if PIL unavailable).
|
|
316
|
+
"""
|
|
317
|
+
try:
|
|
318
|
+
from PIL import Image
|
|
319
|
+
|
|
320
|
+
img = Image.open(io.BytesIO(data))
|
|
321
|
+
w, h = img.size
|
|
322
|
+
|
|
323
|
+
if max(w, h) <= max_dim:
|
|
324
|
+
return data, w, h
|
|
325
|
+
|
|
326
|
+
# Resize maintaining aspect ratio
|
|
327
|
+
ratio = max_dim / max(w, h)
|
|
328
|
+
new_w = int(w * ratio)
|
|
329
|
+
new_h = int(h * ratio)
|
|
330
|
+
|
|
331
|
+
# Convert to RGB if necessary (for JPEG output)
|
|
332
|
+
if img.mode in ("RGBA", "P"):
|
|
333
|
+
img = img.convert("RGB")
|
|
334
|
+
|
|
335
|
+
img = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
|
|
336
|
+
buf = io.BytesIO()
|
|
337
|
+
img.save(buf, format="JPEG", quality=90)
|
|
338
|
+
return buf.getvalue(), new_w, new_h
|
|
339
|
+
|
|
340
|
+
except ImportError:
|
|
341
|
+
logger.warning("Pillow not installed — cannot resize images")
|
|
342
|
+
return data, 0, 0
|
|
343
|
+
except Exception as exc:
|
|
344
|
+
logger.warning("Image resize failed: %s", exc)
|
|
345
|
+
return data, 0, 0
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def generate_thumbnail(data: bytes, size: tuple[int, int] = THUMBNAIL_SIZE, quality: int = THUMBNAIL_QUALITY) -> bytes:
|
|
349
|
+
"""Generate a JPEG thumbnail from image data."""
|
|
350
|
+
try:
|
|
351
|
+
from PIL import Image
|
|
352
|
+
|
|
353
|
+
img = Image.open(io.BytesIO(data))
|
|
354
|
+
if img.mode in ("RGBA", "P"):
|
|
355
|
+
img = img.convert("RGB")
|
|
356
|
+
img.thumbnail(size, Image.Resampling.LANCZOS)
|
|
357
|
+
buf = io.BytesIO()
|
|
358
|
+
img.save(buf, format="JPEG", quality=quality)
|
|
359
|
+
return buf.getvalue()
|
|
360
|
+
|
|
361
|
+
except ImportError:
|
|
362
|
+
logger.warning("Pillow not installed — cannot generate thumbnails")
|
|
363
|
+
return b""
|
|
364
|
+
except Exception as exc:
|
|
365
|
+
logger.warning("Thumbnail generation failed: %s", exc)
|
|
366
|
+
return b""
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
# ---------------------------------------------------------------------------
|
|
370
|
+
# Image hash and dedup
|
|
371
|
+
# ---------------------------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def compute_image_hash(data: bytes) -> str:
|
|
375
|
+
"""SHA-256 hash of raw image bytes for deduplication."""
|
|
376
|
+
return hashlib.sha256(data).hexdigest()
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def strip_gps_exif(data: bytes) -> bytes:
|
|
380
|
+
"""Strip GPS data from JPEG EXIF.
|
|
381
|
+
|
|
382
|
+
Returns modified bytes, or original if not JPEG or stripping fails.
|
|
383
|
+
"""
|
|
384
|
+
try:
|
|
385
|
+
from PIL import Image
|
|
386
|
+
from PIL.ExifTags import TAGS
|
|
387
|
+
|
|
388
|
+
img = Image.open(io.BytesIO(data))
|
|
389
|
+
exif_data = img.getexif()
|
|
390
|
+
if not exif_data:
|
|
391
|
+
return data
|
|
392
|
+
|
|
393
|
+
# Remove GPS IFD (tag 34853)
|
|
394
|
+
gps_ifd = 34853
|
|
395
|
+
if gps_ifd in exif_data:
|
|
396
|
+
del exif_data[gps_ifd]
|
|
397
|
+
|
|
398
|
+
# Save back
|
|
399
|
+
buf = io.BytesIO()
|
|
400
|
+
img.save(buf, format="JPEG", quality=90, exif=exif_data)
|
|
401
|
+
return buf.getvalue()
|
|
402
|
+
|
|
403
|
+
except ImportError:
|
|
404
|
+
return data
|
|
405
|
+
except Exception:
|
|
406
|
+
return data
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""Colony Vector — local image storage.
|
|
2
|
+
|
|
3
|
+
Stores images on the local filesystem with dedup by hash,
|
|
4
|
+
thumbnail generation, and metadata tracking.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import tempfile
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from apsimo.vector.multimodal_types import ImageInput
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ImageStorageMode(str):
|
|
23
|
+
LOCAL = "local"
|
|
24
|
+
EMBED_ONLY = "embed_only"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class StoredImage:
|
|
29
|
+
"""Reference to a stored image."""
|
|
30
|
+
|
|
31
|
+
image_hash: str
|
|
32
|
+
path: str # Full path to stored image
|
|
33
|
+
thumbnail_path: str # Full path to thumbnail
|
|
34
|
+
mime_type: str
|
|
35
|
+
width: int
|
|
36
|
+
height: int
|
|
37
|
+
size_bytes: int
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class LocalImageStore:
|
|
41
|
+
"""Store images on the local filesystem.
|
|
42
|
+
|
|
43
|
+
Directory layout:
|
|
44
|
+
$COLONY_STATE_DIR/images/
|
|
45
|
+
originals/{hash}.jpg
|
|
46
|
+
thumbs/{hash}.jpg
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, state_dir: Optional[str] = None, *, source_evidence: bool = False) -> None:
|
|
50
|
+
base = Path(state_dir or os.environ.get("COLONY_STATE_DIR", "."))
|
|
51
|
+
self._base_dir = base / "images"
|
|
52
|
+
if source_evidence:
|
|
53
|
+
self._base_dir /= "sources"
|
|
54
|
+
self._originals_dir = self._base_dir / "originals"
|
|
55
|
+
self._thumbs_dir = self._base_dir / "thumbs"
|
|
56
|
+
|
|
57
|
+
def _ensure_dirs(self) -> None:
|
|
58
|
+
self._originals_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
59
|
+
self._thumbs_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
60
|
+
for directory in (self._base_dir, self._originals_dir, self._thumbs_dir):
|
|
61
|
+
directory.chmod(0o700)
|
|
62
|
+
|
|
63
|
+
def _original_path(self, image_hash: str, mime_type: str) -> Path:
|
|
64
|
+
ext = {"image/jpeg": "jpg", "image/webp": "webp", "image/gif": "gif", "audio/wav": "wav"}.get(mime_type, "png")
|
|
65
|
+
return self._originals_dir / f"{image_hash}.{ext}"
|
|
66
|
+
|
|
67
|
+
def _thumbnail_path(self, image_hash: str) -> Path:
|
|
68
|
+
return self._thumbs_dir / f"{image_hash}.jpg"
|
|
69
|
+
|
|
70
|
+
@staticmethod
|
|
71
|
+
def _write_durable(path: Path, data: bytes) -> None:
|
|
72
|
+
# The caller serializes source asset ownership in the source ledger.
|
|
73
|
+
# The file is durable before a source transaction can reference it.
|
|
74
|
+
fd, temporary = tempfile.mkstemp(prefix=".pending-", dir=path.parent)
|
|
75
|
+
try:
|
|
76
|
+
with os.fdopen(fd, "wb") as stream:
|
|
77
|
+
stream.write(data)
|
|
78
|
+
stream.flush()
|
|
79
|
+
os.fsync(stream.fileno())
|
|
80
|
+
os.replace(temporary, path)
|
|
81
|
+
directory = os.open(path.parent, os.O_RDONLY)
|
|
82
|
+
try:
|
|
83
|
+
os.fsync(directory)
|
|
84
|
+
finally:
|
|
85
|
+
os.close(directory)
|
|
86
|
+
finally:
|
|
87
|
+
Path(temporary).unlink(missing_ok=True)
|
|
88
|
+
|
|
89
|
+
def store_original(self, image: ImageInput) -> StoredImage:
|
|
90
|
+
"""Retain exact image bytes separately from a regenerable thumbnail."""
|
|
91
|
+
self._ensure_dirs()
|
|
92
|
+
image_hash = hashlib.sha256(image.data).hexdigest()
|
|
93
|
+
original = self._original_path(image_hash, image.mime_type)
|
|
94
|
+
thumbnail = self._thumbnail_path(image_hash)
|
|
95
|
+
if not original.exists() or hashlib.sha256(original.read_bytes()).hexdigest() != image_hash:
|
|
96
|
+
self._write_durable(original, image.data)
|
|
97
|
+
if not thumbnail.exists():
|
|
98
|
+
from apsimo.vector.image_preprocess import generate_thumbnail
|
|
99
|
+
data = generate_thumbnail(image.data)
|
|
100
|
+
if data:
|
|
101
|
+
self._write_durable(thumbnail, data)
|
|
102
|
+
return StoredImage(image_hash, str(original), str(thumbnail) if thumbnail.exists() else "",
|
|
103
|
+
image.mime_type, image.width, image.height, len(image.data))
|
|
104
|
+
|
|
105
|
+
def store_source_audio(self, data: bytes) -> str:
|
|
106
|
+
"""Use the existing canonical original namespace without a thumbnail.
|
|
107
|
+
|
|
108
|
+
The caller validates WAV bounds and holds the source ownership lock.
|
|
109
|
+
Legacy image/vector storage does not accept audio through this method.
|
|
110
|
+
"""
|
|
111
|
+
if self._base_dir.name != 'sources':
|
|
112
|
+
raise ValueError('audio requires canonical source ownership')
|
|
113
|
+
self._ensure_dirs()
|
|
114
|
+
digest = hashlib.sha256(data).hexdigest()
|
|
115
|
+
path = self._original_path(digest, 'audio/wav')
|
|
116
|
+
if not path.exists() or hashlib.sha256(path.read_bytes()).hexdigest() != digest:
|
|
117
|
+
self._write_durable(path, data)
|
|
118
|
+
return digest
|
|
119
|
+
|
|
120
|
+
def store_source_document(self, data: bytes) -> str:
|
|
121
|
+
"""Retain already bounded PDF bytes in the existing source namespace.
|
|
122
|
+
|
|
123
|
+
Keep the legacy unknown-MIME path mapping: predecessor backup/read and
|
|
124
|
+
erasure use it too. The MIME is application/pdf; the internal suffix is
|
|
125
|
+
not a declaration of image content and no thumbnail is generated.
|
|
126
|
+
"""
|
|
127
|
+
if self._base_dir.name != 'sources':
|
|
128
|
+
raise ValueError('documents require canonical source ownership')
|
|
129
|
+
self._ensure_dirs()
|
|
130
|
+
digest = hashlib.sha256(data).hexdigest()
|
|
131
|
+
path = self._original_path(digest, 'application/pdf')
|
|
132
|
+
if not path.exists() or hashlib.sha256(path.read_bytes()).hexdigest() != digest:
|
|
133
|
+
self._write_durable(path, data)
|
|
134
|
+
return digest
|
|
135
|
+
|
|
136
|
+
def store_source_video(self, data: bytes) -> str:
|
|
137
|
+
"""Retain bounded MP4 bytes using the existing source original mapping.
|
|
138
|
+
|
|
139
|
+
As for PDF, the predecessor-compatible internal suffix is not MIME.
|
|
140
|
+
Ownership, backup and deletion continue to use the same asset namespace.
|
|
141
|
+
"""
|
|
142
|
+
if self._base_dir.name != 'sources':
|
|
143
|
+
raise ValueError('video requires canonical source ownership')
|
|
144
|
+
self._ensure_dirs()
|
|
145
|
+
digest = hashlib.sha256(data).hexdigest()
|
|
146
|
+
path = self._original_path(digest, 'video/mp4')
|
|
147
|
+
if not path.exists() or hashlib.sha256(path.read_bytes()).hexdigest() != digest:
|
|
148
|
+
self._write_durable(path, data)
|
|
149
|
+
return digest
|
|
150
|
+
|
|
151
|
+
def delete_original(self, image_hash: str) -> bool:
|
|
152
|
+
"""Delete an owned original and thumbnail after ledger reference checks."""
|
|
153
|
+
if len(image_hash) != 64 or any(c not in "0123456789abcdef" for c in image_hash):
|
|
154
|
+
raise ValueError("invalid image hash")
|
|
155
|
+
deleted = False
|
|
156
|
+
for directory, extensions in ((self._originals_dir, ("jpg", "png", "jpeg", "webp", "gif", "wav")),
|
|
157
|
+
(self._thumbs_dir, ("jpg",))):
|
|
158
|
+
for ext in extensions:
|
|
159
|
+
path = directory / f"{image_hash}.{ext}"
|
|
160
|
+
if path.exists():
|
|
161
|
+
path.unlink()
|
|
162
|
+
deleted = True
|
|
163
|
+
if directory.exists():
|
|
164
|
+
fd = os.open(directory, os.O_RDONLY)
|
|
165
|
+
try:
|
|
166
|
+
os.fsync(fd)
|
|
167
|
+
finally:
|
|
168
|
+
os.close(fd)
|
|
169
|
+
return deleted
|
|
170
|
+
|
|
171
|
+
async def store(self, image: ImageInput) -> StoredImage:
|
|
172
|
+
"""Store an image and its thumbnail. Returns reference info.
|
|
173
|
+
|
|
174
|
+
If image already exists (same hash), skips writing but returns reference.
|
|
175
|
+
"""
|
|
176
|
+
self._ensure_dirs()
|
|
177
|
+
|
|
178
|
+
original_path = self._original_path(image.image_hash, image.mime_type)
|
|
179
|
+
thumbnail_path = self._thumbnail_path(image.image_hash)
|
|
180
|
+
|
|
181
|
+
# Store original (skip if already exists — dedup)
|
|
182
|
+
if not original_path.exists():
|
|
183
|
+
original_path.write_bytes(image.data)
|
|
184
|
+
|
|
185
|
+
# Generate and store thumbnail
|
|
186
|
+
if not thumbnail_path.exists():
|
|
187
|
+
from apsimo.vector.image_preprocess import generate_thumbnail
|
|
188
|
+
thumb_data = generate_thumbnail(image.data)
|
|
189
|
+
if thumb_data:
|
|
190
|
+
thumbnail_path.write_bytes(thumb_data)
|
|
191
|
+
|
|
192
|
+
return StoredImage(
|
|
193
|
+
image_hash=image.image_hash,
|
|
194
|
+
path=str(original_path),
|
|
195
|
+
thumbnail_path=str(thumbnail_path) if thumbnail_path.exists() else "",
|
|
196
|
+
mime_type=image.mime_type,
|
|
197
|
+
width=image.width,
|
|
198
|
+
height=image.height,
|
|
199
|
+
size_bytes=len(image.data),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
async def exists(self, image_hash: str) -> bool:
|
|
203
|
+
"""Check if an image with this hash is already stored."""
|
|
204
|
+
# Check all possible extensions
|
|
205
|
+
for ext in ("jpg", "png", "jpeg", "webp", "gif"):
|
|
206
|
+
if (self._originals_dir / f"{image_hash}.{ext}").exists():
|
|
207
|
+
return True
|
|
208
|
+
return False
|
|
209
|
+
|
|
210
|
+
async def get(self, image_hash: str) -> Optional[bytes]:
|
|
211
|
+
"""Retrieve original image bytes by hash."""
|
|
212
|
+
for ext in ("jpg", "png", "jpeg", "webp", "gif"):
|
|
213
|
+
path = self._originals_dir / f"{image_hash}.{ext}"
|
|
214
|
+
if path.exists():
|
|
215
|
+
return path.read_bytes()
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
async def get_thumbnail(self, image_hash: str) -> Optional[bytes]:
|
|
219
|
+
"""Retrieve thumbnail bytes by hash."""
|
|
220
|
+
path = self._thumbnail_path(image_hash)
|
|
221
|
+
if path.exists():
|
|
222
|
+
return path.read_bytes()
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
async def delete(self, image_hash: str) -> bool:
|
|
226
|
+
"""Delete an image and its thumbnail."""
|
|
227
|
+
deleted = False
|
|
228
|
+
for ext in ("jpg", "png", "jpeg", "webp", "gif"):
|
|
229
|
+
path = self._originals_dir / f"{image_hash}.{ext}"
|
|
230
|
+
if path.exists():
|
|
231
|
+
path.unlink()
|
|
232
|
+
deleted = True
|
|
233
|
+
thumb = self._thumbnail_path(image_hash)
|
|
234
|
+
if thumb.exists():
|
|
235
|
+
thumb.unlink()
|
|
236
|
+
deleted = True
|
|
237
|
+
return deleted
|
|
238
|
+
|
|
239
|
+
async def count(self) -> int:
|
|
240
|
+
"""Count stored images."""
|
|
241
|
+
if not self._originals_dir.exists():
|
|
242
|
+
return 0
|
|
243
|
+
return len(list(self._originals_dir.iterdir()))
|
|
244
|
+
|
|
245
|
+
async def total_size_bytes(self) -> int:
|
|
246
|
+
"""Total storage used by originals + thumbnails."""
|
|
247
|
+
total = 0
|
|
248
|
+
for d in (self._originals_dir, self._thumbs_dir):
|
|
249
|
+
if d.exists():
|
|
250
|
+
for f in d.iterdir():
|
|
251
|
+
if f.is_file():
|
|
252
|
+
total += f.stat().st_size
|
|
253
|
+
return total
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class EmbedOnlyStore:
|
|
257
|
+
"""No-op store — images are embedded but not persisted."""
|
|
258
|
+
|
|
259
|
+
async def store(self, image: ImageInput) -> StoredImage:
|
|
260
|
+
return StoredImage(
|
|
261
|
+
image_hash=image.image_hash,
|
|
262
|
+
path="",
|
|
263
|
+
thumbnail_path="",
|
|
264
|
+
mime_type=image.mime_type,
|
|
265
|
+
width=image.width,
|
|
266
|
+
height=image.height,
|
|
267
|
+
size_bytes=len(image.data),
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
async def exists(self, image_hash: str) -> bool:
|
|
271
|
+
return False
|
|
272
|
+
|
|
273
|
+
async def get(self, image_hash: str) -> Optional[bytes]:
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
async def get_thumbnail(self, image_hash: str) -> Optional[bytes]:
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
async def delete(self, image_hash: str) -> bool:
|
|
280
|
+
return False
|
|
281
|
+
|
|
282
|
+
async def count(self) -> int:
|
|
283
|
+
return 0
|
|
284
|
+
|
|
285
|
+
async def total_size_bytes(self) -> int:
|
|
286
|
+
return 0
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def make_image_store(mode: str = "", state_dir: Optional[str] = None):
|
|
290
|
+
"""Create an image store based on config."""
|
|
291
|
+
mode = mode or os.environ.get("COLONY_IMAGE_STORAGE", "local")
|
|
292
|
+
|
|
293
|
+
if mode == ImageStorageMode.EMBED_ONLY:
|
|
294
|
+
return EmbedOnlyStore()
|
|
295
|
+
else:
|
|
296
|
+
return LocalImageStore(state_dir=state_dir)
|