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,251 @@
|
|
|
1
|
+
"""Candidate links and exact owner corrections in the existing contact store.
|
|
2
|
+
|
|
3
|
+
These functions establish attribution, never tool grants. The authenticated
|
|
4
|
+
host owns caller authorization and the source-ledger correction transaction.
|
|
5
|
+
"""
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _json(value):
|
|
11
|
+
return json.dumps(value, sort_keys=True, separators=(',', ':'), allow_nan=False)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _refs(values):
|
|
15
|
+
if not isinstance(values, (list, tuple)) or len(values) > 100:
|
|
16
|
+
raise ValueError('invalid_identity_evidence')
|
|
17
|
+
if any(not isinstance(v, str) or not v.strip() or len(v) > 256 for v in values):
|
|
18
|
+
raise ValueError('invalid_identity_evidence')
|
|
19
|
+
return sorted(set(values))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def usable_handle(handle):
|
|
23
|
+
"""Legacy name guesses are hypotheses, even before an operator migrates them."""
|
|
24
|
+
return bool(getattr(handle, 'verified', False)) or getattr(handle, 'source', '') != 'auto:scoped-name'
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def normalized_handle(gateway, address):
|
|
28
|
+
from .store import _normalize_email, _normalize_phone
|
|
29
|
+
if not isinstance(gateway, str) or not isinstance(address, str):
|
|
30
|
+
raise ValueError('invalid_identity_handle')
|
|
31
|
+
gateway, address = gateway.strip().lower(), address.strip()
|
|
32
|
+
if not gateway or len(gateway) > 64 or not address or len(address) > 512:
|
|
33
|
+
raise ValueError('invalid_identity_handle')
|
|
34
|
+
if gateway == 'rcs':
|
|
35
|
+
gateway = 'sms'
|
|
36
|
+
if gateway == 'email':
|
|
37
|
+
address = _normalize_email(address)
|
|
38
|
+
elif gateway in ('sms', 'imessage', 'signal'):
|
|
39
|
+
address = _normalize_phone(address)
|
|
40
|
+
return gateway, address
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def propose(store, *, contact_id, gateway, address, evidence_refs=(), source='auto:scoped-name'):
|
|
44
|
+
from .store import _now_iso
|
|
45
|
+
gateway, address = normalized_handle(gateway, address)
|
|
46
|
+
refs = _refs(evidence_refs)
|
|
47
|
+
if await store.get(contact_id) is None:
|
|
48
|
+
raise ValueError('identity_contact_not_found')
|
|
49
|
+
if not isinstance(source, str) or not source or len(source) > 128:
|
|
50
|
+
raise ValueError('invalid_identity_source')
|
|
51
|
+
candidate_id = 'identity-candidate:' + hashlib.sha256(_json([contact_id, gateway, address]).encode()).hexdigest()
|
|
52
|
+
db = store._require_db()
|
|
53
|
+
await db.execute('''INSERT OR IGNORE INTO contact_identity_candidates
|
|
54
|
+
(candidate_id,contact_id,gateway,address,source,evidence_refs_json,created_at)
|
|
55
|
+
VALUES (?,?,?,?,?,?,?)''', (candidate_id, contact_id, gateway, address, source, _json(refs), _now_iso()))
|
|
56
|
+
await db.commit()
|
|
57
|
+
async with db.execute('SELECT * FROM contact_identity_candidates WHERE contact_id=? AND gateway=? AND address=?',
|
|
58
|
+
(contact_id, gateway, address)) as cursor:
|
|
59
|
+
result = dict(await cursor.fetchone())
|
|
60
|
+
result['evidence_refs'] = json.loads(result.pop('evidence_refs_json'))
|
|
61
|
+
result['authority_granted'] = False
|
|
62
|
+
return result
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def correct(store, *, operation_id, performed_by, gateway, address,
|
|
66
|
+
expected_contact_id, contact_id, evidence_refs, affected_source_ids=()):
|
|
67
|
+
"""Move/remove one exact handle, preserving old attribution in a durable receipt.
|
|
68
|
+
|
|
69
|
+
expected_contact_id=None means no current handle is expected. contact_id=None
|
|
70
|
+
rejects candidate links and removes the explicitly expected handle, if any.
|
|
71
|
+
The host supplies and subsequently reconciles the exact affected source IDs.
|
|
72
|
+
A lost response may repeat the operation; changed input under its ID fails.
|
|
73
|
+
"""
|
|
74
|
+
from .store import _gen_id, _now_iso
|
|
75
|
+
gateway, address = normalized_handle(gateway, address)
|
|
76
|
+
refs, sources = _refs(evidence_refs), _refs(affected_source_ids)
|
|
77
|
+
if not refs:
|
|
78
|
+
raise ValueError('identity_correction_requires_evidence')
|
|
79
|
+
for value in (operation_id, performed_by):
|
|
80
|
+
if not isinstance(value, str) or not value.strip() or len(value) > 256:
|
|
81
|
+
raise ValueError('invalid_identity_correction')
|
|
82
|
+
for value in (expected_contact_id, contact_id):
|
|
83
|
+
if value is not None and (not isinstance(value, str) or not value.startswith('cid-') or len(value) > 256):
|
|
84
|
+
raise ValueError('invalid_identity_contact')
|
|
85
|
+
request_hash = hashlib.sha256(_json([operation_id, performed_by, gateway, address,
|
|
86
|
+
expected_contact_id, contact_id, refs, sources]).encode()).hexdigest()
|
|
87
|
+
db = await store._open_provision_connection()
|
|
88
|
+
try:
|
|
89
|
+
await db.execute('BEGIN IMMEDIATE')
|
|
90
|
+
async with db.execute('SELECT * FROM contact_identity_operations WHERE operation_id=?', (operation_id,)) as cur:
|
|
91
|
+
prior = await cur.fetchone()
|
|
92
|
+
if prior:
|
|
93
|
+
if prior['request_sha256'] != request_hash:
|
|
94
|
+
raise ValueError('identity_operation_conflict')
|
|
95
|
+
await db.rollback()
|
|
96
|
+
return json.loads(prior['result_json'])
|
|
97
|
+
async with db.execute('SELECT * FROM contact_handles WHERE gateway=? AND address=?', (gateway, address)) as cur:
|
|
98
|
+
handle = await cur.fetchone()
|
|
99
|
+
old_id = handle['contact_id'] if handle else None
|
|
100
|
+
if old_id != expected_contact_id:
|
|
101
|
+
raise ValueError('identity_preimage_changed')
|
|
102
|
+
if contact_id is not None:
|
|
103
|
+
async with db.execute('SELECT contact_id FROM contacts WHERE contact_id=? AND deleted_at IS NULL', (contact_id,)) as cur:
|
|
104
|
+
if await cur.fetchone() is None:
|
|
105
|
+
raise ValueError('identity_contact_not_found')
|
|
106
|
+
now = _now_iso()
|
|
107
|
+
handle_id = handle['handle_id'] if handle else None
|
|
108
|
+
if contact_id is None:
|
|
109
|
+
if handle:
|
|
110
|
+
await db.execute('DELETE FROM contact_handles WHERE handle_id=?', (handle_id,))
|
|
111
|
+
elif handle:
|
|
112
|
+
await db.execute('''UPDATE contact_handles SET contact_id=?, verified=1,
|
|
113
|
+
confidence=1,source='owner_operator',is_primary=CASE WHEN contact_id=? THEN is_primary ELSE 0 END
|
|
114
|
+
WHERE handle_id=?''', (contact_id, contact_id, handle_id))
|
|
115
|
+
else:
|
|
116
|
+
handle_id = _gen_id('hdl')
|
|
117
|
+
await db.execute('''INSERT INTO contact_handles
|
|
118
|
+
(handle_id,contact_id,gateway,address,is_primary,verified,confidence,source,created_at)
|
|
119
|
+
VALUES (?,?,?,?,0,1,1,'owner_operator',?)''', (handle_id, contact_id, gateway, address, now))
|
|
120
|
+
await db.execute('''UPDATE contact_identity_candidates SET status=CASE WHEN contact_id=?
|
|
121
|
+
THEN 'confirmed' ELSE 'rejected' END,resolved_at=? WHERE gateway=? AND address=? AND status='pending' ''',
|
|
122
|
+
(contact_id, now, gateway, address))
|
|
123
|
+
result = {'schema': 'IdentityCorrectionV1', 'operation_id': operation_id,
|
|
124
|
+
'old_contact_id': old_id, 'contact_id': contact_id, 'handle_id': handle_id,
|
|
125
|
+
'gateway': gateway, 'address_sha256': hashlib.sha256(address.encode()).hexdigest(),
|
|
126
|
+
'evidence_refs': refs, 'affected_source_ids': sources, 'recorded_at': now,
|
|
127
|
+
'performed_by': performed_by, 'authority_granted': False,
|
|
128
|
+
'source_reconciliation_required': bool(sources)}
|
|
129
|
+
for cid in sorted({cid for cid in (old_id, contact_id) if cid}):
|
|
130
|
+
await db.execute('''INSERT INTO contact_audit
|
|
131
|
+
(id,contact_id,action,detail,performed_by,created_at) VALUES (?,?,?,?,?,?)''',
|
|
132
|
+
(_gen_id('aud'), cid, 'identity_corrected', _json(result), performed_by, now))
|
|
133
|
+
await db.execute('INSERT INTO contact_identity_operations VALUES (?,?,?,?)',
|
|
134
|
+
(operation_id, request_hash, _json(result), now))
|
|
135
|
+
await db.commit()
|
|
136
|
+
except Exception:
|
|
137
|
+
await db.rollback()
|
|
138
|
+
raise
|
|
139
|
+
finally:
|
|
140
|
+
await db.close()
|
|
141
|
+
from apsimo.identity.resolver import reset_identity_resolver
|
|
142
|
+
reset_identity_resolver()
|
|
143
|
+
return result
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def evidence(store, contact_id):
|
|
147
|
+
if await store.get(contact_id) is None:
|
|
148
|
+
raise ValueError('identity_contact_not_found')
|
|
149
|
+
handles = await store.get_handles(contact_id)
|
|
150
|
+
db = store._require_db()
|
|
151
|
+
async with db.execute('SELECT * FROM contact_identity_candidates WHERE contact_id=? ORDER BY created_at DESC LIMIT 100', (contact_id,)) as cur:
|
|
152
|
+
candidates = [dict(row) for row in await cur.fetchall()]
|
|
153
|
+
for row in candidates:
|
|
154
|
+
row['evidence_refs'] = json.loads(row.pop('evidence_refs_json'))
|
|
155
|
+
async with db.execute('''SELECT result_json FROM contact_identity_operations
|
|
156
|
+
WHERE json_extract(result_json,'$.old_contact_id')=? OR json_extract(result_json,'$.contact_id')=?
|
|
157
|
+
ORDER BY created_at DESC,operation_id LIMIT 20''', (contact_id, contact_id)) as cur:
|
|
158
|
+
operations = [json.loads(row['result_json']) for row in await cur.fetchall()]
|
|
159
|
+
summaries = [{key: op[key] for key in ('operation_id', 'old_contact_id', 'contact_id', 'recorded_at',
|
|
160
|
+
'source_reconciliation_required')} | {
|
|
161
|
+
'source_reconciliation_status': op.get('source_reconciliation_status',
|
|
162
|
+
'pending' if op['source_reconciliation_required'] else 'complete'),
|
|
163
|
+
'source_count': len(op['affected_source_ids']), 'source_ids': op['affected_source_ids'][:10],
|
|
164
|
+
'sources_truncated': len(op['affected_source_ids']) > 10,
|
|
165
|
+
'conflict': op.get('source_reconciliation_conflict')}
|
|
166
|
+
for op in operations]
|
|
167
|
+
return {'contact_id': contact_id, 'handles': [h.to_dict() | {
|
|
168
|
+
'identity_status': 'confirmed' if h.verified else 'observed' if usable_handle(h) else 'tentative',
|
|
169
|
+
'usable_for_attribution': usable_handle(h)} for h in handles], 'candidates': candidates,
|
|
170
|
+
'operations': summaries, 'authority_granted': False}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
async def pending_reconciliations(store, *, limit=100):
|
|
174
|
+
"""Recover unfinished cross-store corrections from their existing receipts."""
|
|
175
|
+
db = store._require_db()
|
|
176
|
+
async with db.execute('''SELECT result_json FROM contact_identity_operations
|
|
177
|
+
WHERE json_extract(result_json,'$.source_reconciliation_required')=1
|
|
178
|
+
AND coalesce(json_extract(result_json,'$.source_reconciliation_status'),'pending')!='conflicted'
|
|
179
|
+
ORDER BY created_at,operation_id LIMIT ?''', (max(1, min(int(limit), 500)),)) as cur:
|
|
180
|
+
return [json.loads(row['result_json']) for row in await cur.fetchall()]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
async def mark_sources_reconciled(store, *, operation_id, source_result):
|
|
184
|
+
"""Finish an exact source correction without introducing a second queue."""
|
|
185
|
+
db = await store._open_provision_connection()
|
|
186
|
+
try:
|
|
187
|
+
await db.execute('BEGIN IMMEDIATE')
|
|
188
|
+
async with db.execute('SELECT result_json FROM contact_identity_operations WHERE operation_id=?', (operation_id,)) as cur:
|
|
189
|
+
row = await cur.fetchone()
|
|
190
|
+
if row is None:
|
|
191
|
+
raise ValueError('identity_operation_not_found')
|
|
192
|
+
result = json.loads(row['result_json'])
|
|
193
|
+
if (source_result.get('schema') != 'SourceAttributionCorrectionV1'
|
|
194
|
+
or source_result.get('operation_id') != operation_id
|
|
195
|
+
or source_result.get('old_contact_id') != result['old_contact_id']
|
|
196
|
+
or source_result.get('contact_id') != result['contact_id']
|
|
197
|
+
or sorted(source_result.get('source_ids', [])) != sorted(result['affected_source_ids'])):
|
|
198
|
+
raise ValueError('identity_reconciliation_mismatch')
|
|
199
|
+
summary = {key: source_result[key] for key in ('operation_id', 'source_ids',
|
|
200
|
+
'affected_source_ids', 'invalidated_source_ids', 'recorded_at')}
|
|
201
|
+
if not result['source_reconciliation_required']:
|
|
202
|
+
if result.get('source_reconciliation') != summary:
|
|
203
|
+
raise ValueError('identity_reconciliation_conflict')
|
|
204
|
+
await db.rollback()
|
|
205
|
+
return result
|
|
206
|
+
result['source_reconciliation_required'] = False
|
|
207
|
+
result['source_reconciliation_status'] = 'complete'
|
|
208
|
+
result.pop('source_reconciliation_conflict', None)
|
|
209
|
+
result['source_reconciliation'] = summary
|
|
210
|
+
await db.execute('UPDATE contact_identity_operations SET result_json=? WHERE operation_id=?',
|
|
211
|
+
(_json(result), operation_id))
|
|
212
|
+
await db.commit()
|
|
213
|
+
return result
|
|
214
|
+
except Exception:
|
|
215
|
+
await db.rollback()
|
|
216
|
+
raise
|
|
217
|
+
finally:
|
|
218
|
+
await db.close()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
async def mark_sources_conflicted(store, *, operation_id, code):
|
|
222
|
+
"""Retain a permanent conflict for inspection without retrying it forever."""
|
|
223
|
+
from .store import _now_iso
|
|
224
|
+
if code not in {'source_erased', 'source_attribution_preimage_changed'}:
|
|
225
|
+
raise ValueError('invalid_identity_reconciliation_conflict')
|
|
226
|
+
db = await store._open_provision_connection()
|
|
227
|
+
try:
|
|
228
|
+
await db.execute('BEGIN IMMEDIATE')
|
|
229
|
+
async with db.execute('SELECT result_json FROM contact_identity_operations WHERE operation_id=?', (operation_id,)) as cur:
|
|
230
|
+
row = await cur.fetchone()
|
|
231
|
+
if row is None:
|
|
232
|
+
raise ValueError('identity_operation_not_found')
|
|
233
|
+
result = json.loads(row['result_json'])
|
|
234
|
+
if not result['source_reconciliation_required']:
|
|
235
|
+
raise ValueError('identity_reconciliation_already_complete')
|
|
236
|
+
if result.get('source_reconciliation_status') == 'conflicted':
|
|
237
|
+
if result['source_reconciliation_conflict']['code'] != code:
|
|
238
|
+
raise ValueError('identity_reconciliation_conflict_changed')
|
|
239
|
+
await db.rollback()
|
|
240
|
+
return result
|
|
241
|
+
result['source_reconciliation_status'] = 'conflicted'
|
|
242
|
+
result['source_reconciliation_conflict'] = {'code': code, 'recorded_at': _now_iso()}
|
|
243
|
+
await db.execute('UPDATE contact_identity_operations SET result_json=? WHERE operation_id=?',
|
|
244
|
+
(_json(result), operation_id))
|
|
245
|
+
await db.commit()
|
|
246
|
+
return result
|
|
247
|
+
except Exception:
|
|
248
|
+
await db.rollback()
|
|
249
|
+
raise
|
|
250
|
+
finally:
|
|
251
|
+
await db.close()
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Colony Contacts — import pipeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import hashlib
|
|
7
|
+
import io
|
|
8
|
+
import logging
|
|
9
|
+
import re
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from typing import List, Optional
|
|
12
|
+
|
|
13
|
+
from .config import ContactsConfig
|
|
14
|
+
from .importers.batch import BatchImportResult, ImportOutcome, ImportRecord
|
|
15
|
+
from .importers.macos_contacts import RawContactRecord
|
|
16
|
+
from .store import SQLiteContactStore, _normalize_phone, _normalize_email, _gen_id
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("colony.contacts.importer")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _pii_hash(value: Optional[str]) -> str:
|
|
22
|
+
"""Short hash for PII-safe log correlation (never log raw contact data)."""
|
|
23
|
+
if not value:
|
|
24
|
+
return "∅"
|
|
25
|
+
return hashlib.sha256(value.encode("utf-8")).hexdigest()[:8]
|
|
26
|
+
|
|
27
|
+
# vCard property pattern
|
|
28
|
+
_VCARD_PROP = re.compile(r'^([A-Z\-]+)(?:;[^:]*)?:(.*)$')
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _parse_vcard(vcard_data: str) -> List[RawContactRecord]:
|
|
32
|
+
"""Parse one or more vCard 4.0 entries into RawContactRecord objects."""
|
|
33
|
+
records: List[RawContactRecord] = []
|
|
34
|
+
current: Optional[dict] = None
|
|
35
|
+
for line in vcard_data.splitlines():
|
|
36
|
+
line = line.strip()
|
|
37
|
+
if line.upper() == "BEGIN:VCARD":
|
|
38
|
+
current = {"phones": [], "emails": [], "given": None, "family": None, "org": None, "fn": None}
|
|
39
|
+
elif line.upper() == "END:VCARD" and current is not None:
|
|
40
|
+
given = current["given"]
|
|
41
|
+
family = current["family"]
|
|
42
|
+
display = current["fn"] or (" ".join(p for p in [given, family] if p) or None)
|
|
43
|
+
if not given and display:
|
|
44
|
+
parts = display.split(None, 1)
|
|
45
|
+
given = parts[0] if parts else None
|
|
46
|
+
family = parts[1] if len(parts) > 1 else None
|
|
47
|
+
records.append(RawContactRecord(
|
|
48
|
+
given_name=given,
|
|
49
|
+
family_name=family,
|
|
50
|
+
organization=current["org"],
|
|
51
|
+
phone_numbers=current["phones"],
|
|
52
|
+
email_addresses=current["emails"],
|
|
53
|
+
source="vcard",
|
|
54
|
+
))
|
|
55
|
+
current = None
|
|
56
|
+
elif current is not None:
|
|
57
|
+
m = _VCARD_PROP.match(line)
|
|
58
|
+
if not m:
|
|
59
|
+
continue
|
|
60
|
+
prop, val = m.group(1).upper(), m.group(2).strip()
|
|
61
|
+
if prop == "FN":
|
|
62
|
+
current["fn"] = val
|
|
63
|
+
elif prop == "N":
|
|
64
|
+
parts = val.split(";")
|
|
65
|
+
current["family"] = parts[0] if parts else None
|
|
66
|
+
current["given"] = parts[1] if len(parts) > 1 else None
|
|
67
|
+
elif prop in ("TEL", "X-TEL") or prop.startswith("TEL"):
|
|
68
|
+
if val:
|
|
69
|
+
current["phones"].append(val)
|
|
70
|
+
elif prop == "EMAIL":
|
|
71
|
+
if val:
|
|
72
|
+
current["emails"].append(val.lower())
|
|
73
|
+
elif prop == "ORG":
|
|
74
|
+
current["org"] = val
|
|
75
|
+
return records
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _parse_csv(csv_data: str, source: str) -> List[RawContactRecord]:
|
|
79
|
+
"""Parse a CSV string into RawContactRecord objects."""
|
|
80
|
+
records: List[RawContactRecord] = []
|
|
81
|
+
reader = csv.DictReader(io.StringIO(csv_data))
|
|
82
|
+
for row in reader:
|
|
83
|
+
# Support common column names
|
|
84
|
+
given = row.get("given_name") or row.get("First Name") or row.get("first_name") or None
|
|
85
|
+
family = row.get("family_name") or row.get("Last Name") or row.get("last_name") or None
|
|
86
|
+
org = row.get("organization") or row.get("Organization") or row.get("Company") or None
|
|
87
|
+
phone_raw = row.get("phone") or row.get("Phone") or row.get("primary_phone") or ""
|
|
88
|
+
email_raw = row.get("email") or row.get("Email") or row.get("primary_email") or ""
|
|
89
|
+
phones = [p.strip() for p in phone_raw.split("|") if p.strip()] if phone_raw else []
|
|
90
|
+
emails = [e.strip().lower() for e in email_raw.split("|") if e.strip()] if email_raw else []
|
|
91
|
+
if not given and not family and not phones and not emails:
|
|
92
|
+
continue
|
|
93
|
+
records.append(RawContactRecord(
|
|
94
|
+
given_name=given or None,
|
|
95
|
+
family_name=family or None,
|
|
96
|
+
organization=org or None,
|
|
97
|
+
phone_numbers=phones,
|
|
98
|
+
email_addresses=emails,
|
|
99
|
+
source=source,
|
|
100
|
+
))
|
|
101
|
+
return records
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ── Abstract interface ────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
class ContactImporter(ABC):
|
|
107
|
+
"""Orchestrates contact import from any source through deduplication."""
|
|
108
|
+
|
|
109
|
+
@abstractmethod
|
|
110
|
+
async def import_raw(self, records: List[RawContactRecord], source: str) -> BatchImportResult:
|
|
111
|
+
"""Import a list of RawContactRecord objects through dedup pipeline."""
|
|
112
|
+
|
|
113
|
+
@abstractmethod
|
|
114
|
+
async def import_from_macos_contacts(self) -> BatchImportResult:
|
|
115
|
+
"""Import all contacts from the macOS Contacts framework."""
|
|
116
|
+
|
|
117
|
+
@abstractmethod
|
|
118
|
+
async def import_from_vcard(self, vcard_data: str) -> BatchImportResult:
|
|
119
|
+
"""Import contacts from a vCard 4.0 string."""
|
|
120
|
+
|
|
121
|
+
@abstractmethod
|
|
122
|
+
async def import_from_csv(self, csv_data: str, source: str = "csv") -> BatchImportResult:
|
|
123
|
+
"""Import contacts from a UTF-8 CSV string."""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ── SQLite-backed implementation ──────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
class SQLiteContactImporter(ContactImporter):
|
|
129
|
+
"""ContactImporter backed by SQLiteContactStore."""
|
|
130
|
+
|
|
131
|
+
def __init__(
|
|
132
|
+
self,
|
|
133
|
+
store: SQLiteContactStore,
|
|
134
|
+
config: Optional[ContactsConfig] = None,
|
|
135
|
+
bridge = None, # Optional[WorldModelContactBridge]
|
|
136
|
+
) -> None:
|
|
137
|
+
self._store = store
|
|
138
|
+
self._config = config or ContactsConfig()
|
|
139
|
+
self._bridge = bridge
|
|
140
|
+
|
|
141
|
+
async def import_raw(self, records: List[RawContactRecord], source: str) -> BatchImportResult:
|
|
142
|
+
result = BatchImportResult(source=source, total=len(records))
|
|
143
|
+
for raw in records:
|
|
144
|
+
display_name = raw.display_name
|
|
145
|
+
try:
|
|
146
|
+
# Normalize addresses
|
|
147
|
+
norm_phones = [_normalize_phone(p) for p in raw.phone_numbers if p]
|
|
148
|
+
norm_emails = [_normalize_email(e) for e in raw.email_addresses if e]
|
|
149
|
+
|
|
150
|
+
# Run dedup check
|
|
151
|
+
candidates = await self._store.find_dedup_candidates(
|
|
152
|
+
raw.given_name, raw.family_name, norm_phones, norm_emails
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
auto_threshold = self._config.auto_merge_confidence_threshold
|
|
156
|
+
proposal_threshold = self._config.merge_proposal_threshold
|
|
157
|
+
|
|
158
|
+
async def _attach(contact_id: str, kind: str, value: str, confidence: Optional[float]) -> None:
|
|
159
|
+
try:
|
|
160
|
+
kwargs = {"source": raw.source}
|
|
161
|
+
if confidence is not None:
|
|
162
|
+
kwargs["confidence"] = confidence
|
|
163
|
+
await self._store.add_handle(contact_id, kind, value, **kwargs)
|
|
164
|
+
except ValueError:
|
|
165
|
+
# Handle already owned by a different contact; store
|
|
166
|
+
# resolves ownership separately. Count the conflict
|
|
167
|
+
# so callers see lossy merges, and log a redacted
|
|
168
|
+
# correlation hash only — never raw PII.
|
|
169
|
+
result.handle_conflicts += 1
|
|
170
|
+
logger.debug(
|
|
171
|
+
"contact handle conflict: kind=%s value_hash=%s name_hash=%s",
|
|
172
|
+
kind, _pii_hash(value), _pii_hash(display_name),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
if candidates and candidates[0][0] >= auto_threshold:
|
|
176
|
+
# High-confidence match — merge handles into existing contact
|
|
177
|
+
conf, existing_id, reason = candidates[0]
|
|
178
|
+
for phone in norm_phones:
|
|
179
|
+
await _attach(existing_id, "imessage", phone, conf)
|
|
180
|
+
for email in norm_emails:
|
|
181
|
+
await _attach(existing_id, "email", email, conf)
|
|
182
|
+
result.merged += 1
|
|
183
|
+
result.records.append(ImportRecord(
|
|
184
|
+
raw_display_name=display_name,
|
|
185
|
+
outcome=ImportOutcome.MERGED,
|
|
186
|
+
contact_id=existing_id,
|
|
187
|
+
merged_into_id=existing_id,
|
|
188
|
+
))
|
|
189
|
+
# Absorb any discovered contacts with overlapping handles
|
|
190
|
+
if self._bridge:
|
|
191
|
+
await self._absorb_discovered_for(existing_id, norm_phones, norm_emails)
|
|
192
|
+
elif candidates and candidates[0][0] >= proposal_threshold:
|
|
193
|
+
# Below auto-merge but above proposal threshold — create and propose
|
|
194
|
+
contact = await self._store.create(
|
|
195
|
+
display_name=display_name,
|
|
196
|
+
given_name=raw.given_name,
|
|
197
|
+
family_name=raw.family_name,
|
|
198
|
+
organization=raw.organization,
|
|
199
|
+
import_source=raw.source,
|
|
200
|
+
)
|
|
201
|
+
for phone in norm_phones:
|
|
202
|
+
await _attach(contact.contact_id, "imessage", phone, None)
|
|
203
|
+
for email in norm_emails:
|
|
204
|
+
await _attach(contact.contact_id, "email", email, None)
|
|
205
|
+
# Queue merge proposal (handled by merger)
|
|
206
|
+
result.created += 1
|
|
207
|
+
result.records.append(ImportRecord(
|
|
208
|
+
raw_display_name=display_name,
|
|
209
|
+
outcome=ImportOutcome.CREATED,
|
|
210
|
+
contact_id=contact.contact_id,
|
|
211
|
+
))
|
|
212
|
+
if self._bridge:
|
|
213
|
+
await self._absorb_discovered_for(contact.contact_id, norm_phones, norm_emails)
|
|
214
|
+
else:
|
|
215
|
+
# No match — create new contact
|
|
216
|
+
contact = await self._store.create(
|
|
217
|
+
display_name=display_name,
|
|
218
|
+
given_name=raw.given_name,
|
|
219
|
+
family_name=raw.family_name,
|
|
220
|
+
organization=raw.organization,
|
|
221
|
+
import_source=raw.source,
|
|
222
|
+
)
|
|
223
|
+
for phone in norm_phones:
|
|
224
|
+
await _attach(contact.contact_id, "imessage", phone, None)
|
|
225
|
+
for email in norm_emails:
|
|
226
|
+
await _attach(contact.contact_id, "email", email, None)
|
|
227
|
+
result.created += 1
|
|
228
|
+
result.records.append(ImportRecord(
|
|
229
|
+
raw_display_name=display_name,
|
|
230
|
+
outcome=ImportOutcome.CREATED,
|
|
231
|
+
contact_id=contact.contact_id,
|
|
232
|
+
))
|
|
233
|
+
if self._bridge:
|
|
234
|
+
await self._absorb_discovered_for(contact.contact_id, norm_phones, norm_emails)
|
|
235
|
+
except Exception as exc:
|
|
236
|
+
logger.warning(
|
|
237
|
+
"Failed to import record (name_hash=%s): %s",
|
|
238
|
+
_pii_hash(display_name), type(exc).__name__,
|
|
239
|
+
)
|
|
240
|
+
result.failed += 1
|
|
241
|
+
result.records.append(ImportRecord(
|
|
242
|
+
raw_display_name=display_name,
|
|
243
|
+
outcome=ImportOutcome.FAILED,
|
|
244
|
+
error=type(exc).__name__,
|
|
245
|
+
))
|
|
246
|
+
return result
|
|
247
|
+
|
|
248
|
+
async def _absorb_discovered_for(
|
|
249
|
+
self,
|
|
250
|
+
curated_contact_id: str,
|
|
251
|
+
norm_phones: List[str],
|
|
252
|
+
norm_emails: List[str],
|
|
253
|
+
) -> None:
|
|
254
|
+
"""Find discovered contacts with overlapping handles and absorb them."""
|
|
255
|
+
if not self._bridge:
|
|
256
|
+
return
|
|
257
|
+
for phone in norm_phones:
|
|
258
|
+
discovered = await self._store.find_discovered_by_handle("imessage", phone)
|
|
259
|
+
if discovered:
|
|
260
|
+
await self._bridge.absorb_discovered_contact(curated_contact_id, discovered)
|
|
261
|
+
return
|
|
262
|
+
for email in norm_emails:
|
|
263
|
+
discovered = await self._store.find_discovered_by_handle("email", email)
|
|
264
|
+
if discovered:
|
|
265
|
+
await self._bridge.absorb_discovered_contact(curated_contact_id, discovered)
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
async def import_from_macos_contacts(self) -> BatchImportResult:
|
|
269
|
+
from .importers.macos_contacts import MacOSContactsImporter
|
|
270
|
+
importer = MacOSContactsImporter(normalize_phones=self._config.import_cfg.normalize_phones)
|
|
271
|
+
raw_records = importer.fetch_all()
|
|
272
|
+
return await self.import_raw(raw_records, "ios_contacts")
|
|
273
|
+
|
|
274
|
+
async def import_from_vcard(self, vcard_data: str) -> BatchImportResult:
|
|
275
|
+
records = _parse_vcard(vcard_data)
|
|
276
|
+
return await self.import_raw(records, "vcard")
|
|
277
|
+
|
|
278
|
+
async def import_from_csv(self, csv_data: str, source: str = "csv") -> BatchImportResult:
|
|
279
|
+
records = _parse_csv(csv_data, source)
|
|
280
|
+
return await self.import_raw(records, source)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Colony Contacts importers."""
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Batch import result types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ImportOutcome(str, Enum):
|
|
11
|
+
CREATED = "created"
|
|
12
|
+
MERGED = "merged"
|
|
13
|
+
SKIPPED = "skipped"
|
|
14
|
+
FAILED = "failed"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ImportRecord:
|
|
19
|
+
"""Result for a single record in a batch import."""
|
|
20
|
+
raw_display_name: Optional[str]
|
|
21
|
+
outcome: ImportOutcome
|
|
22
|
+
contact_id: Optional[str] = None # set on CREATED or MERGED
|
|
23
|
+
merged_into_id: Optional[str] = None # set on MERGED
|
|
24
|
+
error: Optional[str] = None # set on FAILED
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class BatchImportResult:
|
|
29
|
+
"""Summary of a completed batch import operation."""
|
|
30
|
+
source: str
|
|
31
|
+
total: int
|
|
32
|
+
created: int = 0
|
|
33
|
+
merged: int = 0
|
|
34
|
+
skipped: int = 0
|
|
35
|
+
failed: int = 0
|
|
36
|
+
handle_conflicts: int = 0
|
|
37
|
+
records: List[ImportRecord] = field(default_factory=list)
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def success_rate(self) -> float:
|
|
41
|
+
if self.total == 0:
|
|
42
|
+
return 1.0
|
|
43
|
+
return (self.created + self.merged + self.skipped) / self.total
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""macOS Contacts framework importer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger("colony.contacts.importers.macos_contacts")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class RawContactRecord:
|
|
14
|
+
"""A contact record extracted from the macOS Contacts framework.
|
|
15
|
+
|
|
16
|
+
Intermediate representation before deduplication and persistence.
|
|
17
|
+
"""
|
|
18
|
+
given_name: Optional[str]
|
|
19
|
+
family_name: Optional[str]
|
|
20
|
+
organization: Optional[str]
|
|
21
|
+
phone_numbers: List[str] = field(default_factory=list) # E.164-normalized where possible
|
|
22
|
+
email_addresses: List[str] = field(default_factory=list)
|
|
23
|
+
source: str = "ios_contacts"
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def display_name(self) -> Optional[str]:
|
|
27
|
+
parts = [self.given_name, self.family_name]
|
|
28
|
+
name = " ".join(p for p in parts if p)
|
|
29
|
+
return name or self.organization or None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MacOSContactsImporter:
|
|
33
|
+
"""Imports contacts from the macOS Contacts framework.
|
|
34
|
+
|
|
35
|
+
Requires pyobjc-framework-Contacts and macOS Contacts permission.
|
|
36
|
+
If permission is denied or the framework is unavailable, raises ImportError
|
|
37
|
+
with a descriptive message rather than silently returning an empty list.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
normalize_phones: If True, attempt E.164 normalization of phone numbers.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, normalize_phones: bool = True) -> None:
|
|
44
|
+
self._normalize = normalize_phones
|
|
45
|
+
self._cn = self._load_framework()
|
|
46
|
+
|
|
47
|
+
def _load_framework(self):
|
|
48
|
+
try:
|
|
49
|
+
import Contacts # type: ignore # pyobjc
|
|
50
|
+
return Contacts
|
|
51
|
+
except ImportError as exc:
|
|
52
|
+
raise ImportError(
|
|
53
|
+
"pyobjc-framework-Contacts is required for macOS Contacts import. "
|
|
54
|
+
"Install it with: pip install pyobjc-framework-Contacts"
|
|
55
|
+
) from exc
|
|
56
|
+
|
|
57
|
+
def fetch_all(self) -> List[RawContactRecord]:
|
|
58
|
+
"""Fetch all contacts from the macOS Contacts store.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
List of RawContactRecord objects.
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
PermissionError: If Contacts access has not been granted.
|
|
65
|
+
ImportError: If the Contacts framework is unavailable.
|
|
66
|
+
"""
|
|
67
|
+
CN = self._cn
|
|
68
|
+
store = CN.CNContactStore.alloc().init()
|
|
69
|
+
keys = [
|
|
70
|
+
CN.CNContactGivenNameKey,
|
|
71
|
+
CN.CNContactFamilyNameKey,
|
|
72
|
+
CN.CNContactOrganizationNameKey,
|
|
73
|
+
CN.CNContactPhoneNumbersKey,
|
|
74
|
+
CN.CNContactEmailAddressesKey,
|
|
75
|
+
]
|
|
76
|
+
fetch_request = CN.CNContactFetchRequest.alloc().initWithKeysToFetch_(keys)
|
|
77
|
+
records: List[RawContactRecord] = []
|
|
78
|
+
|
|
79
|
+
def handler(contact, _stop):
|
|
80
|
+
phones = [
|
|
81
|
+
v.value().stringValue()
|
|
82
|
+
for v in (contact.phoneNumbers() or [])
|
|
83
|
+
]
|
|
84
|
+
emails = [
|
|
85
|
+
str(v.value())
|
|
86
|
+
for v in (contact.emailAddresses() or [])
|
|
87
|
+
]
|
|
88
|
+
records.append(RawContactRecord(
|
|
89
|
+
given_name=str(contact.givenName()) or None,
|
|
90
|
+
family_name=str(contact.familyName()) or None,
|
|
91
|
+
organization=str(contact.organizationName()) or None,
|
|
92
|
+
phone_numbers=phones,
|
|
93
|
+
email_addresses=emails,
|
|
94
|
+
))
|
|
95
|
+
|
|
96
|
+
error = None
|
|
97
|
+
store.enumerateContactsWithFetchRequest_error_usingBlock_(
|
|
98
|
+
fetch_request, error, handler
|
|
99
|
+
)
|
|
100
|
+
logger.info("Fetched %d contacts from macOS Contacts", len(records))
|
|
101
|
+
return records
|