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.
Files changed (614) hide show
  1. apsimo/__init__.py +38 -0
  2. apsimo/__main__.py +6 -0
  3. apsimo/agent/__init__.py +6 -0
  4. apsimo/agent/client.py +276 -0
  5. apsimo/agent/models.py +46 -0
  6. apsimo/agents/__init__.py +20 -0
  7. apsimo/agents/models.py +264 -0
  8. apsimo/agents/store.py +861 -0
  9. apsimo/agents/websocket.py +522 -0
  10. apsimo/api/__init__.py +1 -0
  11. apsimo/api/auth_telemetry.py +287 -0
  12. apsimo/api/authority.py +1203 -0
  13. apsimo/api/contact_grants.py +347 -0
  14. apsimo/api/middleware.py +483 -0
  15. apsimo/api/routers/__init__.py +1 -0
  16. apsimo/api/routers/commitment_work.py +265 -0
  17. apsimo/api/routers/context_gate.py +123 -0
  18. apsimo/api/routers/executions.py +140 -0
  19. apsimo/api/routers/followup_plans.py +147 -0
  20. apsimo/api/routers/governed_actions.py +162 -0
  21. apsimo/api/routers/host.py +14473 -0
  22. apsimo/api/routers/initiative_work.py +115 -0
  23. apsimo/api/routers/mining.py +104 -0
  24. apsimo/api/routers/observations.py +110 -0
  25. apsimo/api/routers/social_state.py +225 -0
  26. apsimo/api/routers/task_queue.py +2715 -0
  27. apsimo/api/routers/temporal_followups.py +251 -0
  28. apsimo/api/routers/transport.py +110 -0
  29. apsimo/api/routers/transport_ingress_api.py +240 -0
  30. apsimo/api/schemas/__init__.py +1 -0
  31. apsimo/api/schemas/host.py +1949 -0
  32. apsimo/autonomy/cli.py +110 -0
  33. apsimo/autonomy/condition_worker.py +437 -0
  34. apsimo/autonomy/config.py +424 -0
  35. apsimo/autonomy/loop.py +4316 -0
  36. apsimo/autonomy/registry.py +339 -0
  37. apsimo/autonomy/scheduler.py +1822 -0
  38. apsimo/autonomy/synthesis.py +449 -0
  39. apsimo/backup.py +962 -0
  40. apsimo/beliefs/__init__.py +23 -0
  41. apsimo/beliefs/contradictions.py +109 -0
  42. apsimo/beliefs/decay.py +61 -0
  43. apsimo/beliefs/engine.py +479 -0
  44. apsimo/beliefs/models.py +67 -0
  45. apsimo/beliefs/promotion.py +41 -0
  46. apsimo/beliefs/resolve.py +58 -0
  47. apsimo/beliefs/source_claims.py +690 -0
  48. apsimo/beliefs/source_projection.py +883 -0
  49. apsimo/beliefs/source_time.py +208 -0
  50. apsimo/beliefs/store.py +133 -0
  51. apsimo/briefings/aggregators.py +824 -0
  52. apsimo/briefings/composer.py +420 -0
  53. apsimo/briefings/config.py +55 -0
  54. apsimo/briefings/delivery.py +439 -0
  55. apsimo/briefings/engagement.py +97 -0
  56. apsimo/briefings/engine.py +274 -0
  57. apsimo/briefings/enhancer.py +99 -0
  58. apsimo/briefings/models.py +183 -0
  59. apsimo/briefings/scheduler.py +382 -0
  60. apsimo/briefings/store.py +435 -0
  61. apsimo/chain/__init__.py +48 -0
  62. apsimo/chain/block.py +100 -0
  63. apsimo/chain/cli.py +704 -0
  64. apsimo/chain/genesis.py +443 -0
  65. apsimo/chain/identity.py +416 -0
  66. apsimo/chain/keys.py +1025 -0
  67. apsimo/chain/local_keys.py +187 -0
  68. apsimo/chain/manager.py +290 -0
  69. apsimo/chain/node.py +163 -0
  70. apsimo/chain/plugin_transactions.py +371 -0
  71. apsimo/chain/protocol.py +220 -0
  72. apsimo/chain/state_machine.py +676 -0
  73. apsimo/chain/storage.py +503 -0
  74. apsimo/chain/transactions.py +250 -0
  75. apsimo/chain/validation.py +397 -0
  76. apsimo/channels/__init__.py +1 -0
  77. apsimo/channels/manifest.py +31 -0
  78. apsimo/channels/migrations/001_channels_schema.sql +12 -0
  79. apsimo/channels/phone_gateways.py +42 -0
  80. apsimo/channels/presence.py +188 -0
  81. apsimo/channels/router.py +235 -0
  82. apsimo/channels/store.py +231 -0
  83. apsimo/cli.py +2688 -0
  84. apsimo/cognition/__init__.py +11 -0
  85. apsimo/cognition/charter.py +398 -0
  86. apsimo/cognition/drive_governance.py +3530 -0
  87. apsimo/cognition/evidence_pipeline.py +1627 -0
  88. apsimo/cognition/external_events.py +932 -0
  89. apsimo/cognition/goal_spine.py +3488 -0
  90. apsimo/cognition/introspection.py +214 -0
  91. apsimo/cognition/prompt.py +150 -0
  92. apsimo/cognition/runtime.py +108 -0
  93. apsimo/cognition/trigger.py +154 -0
  94. apsimo/commitments/__init__.py +18 -0
  95. apsimo/commitments/local_work.py +355 -0
  96. apsimo/commitments/store.py +1052 -0
  97. apsimo/commitments/work.py +91 -0
  98. apsimo/compat.py +53 -0
  99. apsimo/compression/__init__.py +467 -0
  100. apsimo/connectors/__init__.py +21 -0
  101. apsimo/connectors/base.py +152 -0
  102. apsimo/connectors/caldav_calendar.py +125 -0
  103. apsimo/connectors/fs_documents.py +85 -0
  104. apsimo/connectors/imap_email.py +138 -0
  105. apsimo/connectors/manager.py +218 -0
  106. apsimo/connectors/webhook_pull.py +88 -0
  107. apsimo/contacts/__init__.py +33 -0
  108. apsimo/contacts/comms.py +357 -0
  109. apsimo/contacts/config.py +79 -0
  110. apsimo/contacts/exporters/__init__.py +1 -0
  111. apsimo/contacts/exporters/vcard.py +71 -0
  112. apsimo/contacts/identity_links.py +251 -0
  113. apsimo/contacts/importer.py +280 -0
  114. apsimo/contacts/importers/__init__.py +1 -0
  115. apsimo/contacts/importers/batch.py +43 -0
  116. apsimo/contacts/importers/macos_contacts.py +101 -0
  117. apsimo/contacts/migrations/001_contacts_schema.sql +141 -0
  118. apsimo/contacts/migrations/002_trust_scopes.sql +36 -0
  119. apsimo/contacts/migrations/003_open_gateway_enum.sql +32 -0
  120. apsimo/contacts/migrations/004_contact_provision_operations.sql +18 -0
  121. apsimo/contacts/migrations/005_identity_links.sql +27 -0
  122. apsimo/contacts/models.py +308 -0
  123. apsimo/contacts/scoring.py +16 -0
  124. apsimo/contacts/store.py +1623 -0
  125. apsimo/contacts/transport_ingress.py +252 -0
  126. apsimo/contacts/world_bridge.py +314 -0
  127. apsimo/contextgate/__init__.py +69 -0
  128. apsimo/contextgate/chunker.py +169 -0
  129. apsimo/contextgate/estimate.py +54 -0
  130. apsimo/contextgate/gate.py +313 -0
  131. apsimo/contextgate/retrieve.py +115 -0
  132. apsimo/delivery/__init__.py +16 -0
  133. apsimo/delivery/bridge.py +1260 -0
  134. apsimo/delivery/channels.py +526 -0
  135. apsimo/delivery/classification.py +50 -0
  136. apsimo/delivery/rate_limiter.py +268 -0
  137. apsimo/delivery/reachout_policy.py +206 -0
  138. apsimo/directed/__init__.py +22 -0
  139. apsimo/directed/audit.py +167 -0
  140. apsimo/directed/intake.py +95 -0
  141. apsimo/directed/models.py +191 -0
  142. apsimo/directed/service.py +509 -0
  143. apsimo/directives/__init__.py +25 -0
  144. apsimo/directives/evidence.py +87 -0
  145. apsimo/directives/extractor.py +188 -0
  146. apsimo/directives/guard.py +364 -0
  147. apsimo/directives/models.py +206 -0
  148. apsimo/directives/service.py +372 -0
  149. apsimo/directives/store.py +167 -0
  150. apsimo/doctor.py +2173 -0
  151. apsimo/environment.py +43 -0
  152. apsimo/events/__init__.py +33 -0
  153. apsimo/events/broadcaster.py +98 -0
  154. apsimo/events/bus.py +217 -0
  155. apsimo/events/journal.py +863 -0
  156. apsimo/events/stream.py +131 -0
  157. apsimo/events/types.py +150 -0
  158. apsimo/execution_results.py +357 -0
  159. apsimo/feedback/__init__.py +5 -0
  160. apsimo/feedback/store.py +76 -0
  161. apsimo/feeds/__init__.py +19 -0
  162. apsimo/feeds/cli.py +84 -0
  163. apsimo/feeds/engine.py +437 -0
  164. apsimo/feeds/example-feed.yaml +77 -0
  165. apsimo/feeds/hermes_cron.py +126 -0
  166. apsimo/feeds/manager.py +235 -0
  167. apsimo/feeds/spec.py +250 -0
  168. apsimo/feeds/template.py +202 -0
  169. apsimo/gate/__init__.py +18 -0
  170. apsimo/gate/audit.py +61 -0
  171. apsimo/gate/communication_policy.py +166 -0
  172. apsimo/gate/config.py +72 -0
  173. apsimo/gate/context_provenance.py +170 -0
  174. apsimo/gate/env_risk.py +226 -0
  175. apsimo/gate/guard_audit.py +353 -0
  176. apsimo/gate/layers/__init__.py +1 -0
  177. apsimo/gate/layers/base.py +15 -0
  178. apsimo/gate/layers/l1_recipient.py +66 -0
  179. apsimo/gate/layers/l2_pii.py +134 -0
  180. apsimo/gate/layers/l3_cross_context.py +50 -0
  181. apsimo/gate/layers/l4_trust_tier.py +78 -0
  182. apsimo/gate/layers/l5_injection.py +199 -0
  183. apsimo/gate/layers/l6_review.py +86 -0
  184. apsimo/gate/layers/l7_delay.py +100 -0
  185. apsimo/gate/layers/tom2_epistemic.py +185 -0
  186. apsimo/gate/models.py +64 -0
  187. apsimo/gate/pending_dispatch.py +5 -0
  188. apsimo/gate/pipeline.py +206 -0
  189. apsimo/gate/rejection.py +259 -0
  190. apsimo/gate/response_guard.py +700 -0
  191. apsimo/gate/rulesets/injection_v1.yaml +51 -0
  192. apsimo/gate/surface_policy.py +189 -0
  193. apsimo/gate/taint.py +226 -0
  194. apsimo/genesis.json +9 -0
  195. apsimo/goals/__init__.py +100 -0
  196. apsimo/goals/config.py +38 -0
  197. apsimo/goals/decomposer.py +421 -0
  198. apsimo/goals/engine.py +617 -0
  199. apsimo/goals/inference.py +354 -0
  200. apsimo/goals/models.py +302 -0
  201. apsimo/goals/priority.py +270 -0
  202. apsimo/goals/queue_bridge.py +149 -0
  203. apsimo/goals/replan.py +450 -0
  204. apsimo/goals/schema.sql +89 -0
  205. apsimo/goals/store.py +692 -0
  206. apsimo/governed_actions.py +1708 -0
  207. apsimo/harness_integration/__init__.py +45 -0
  208. apsimo/harness_integration/context.py +41 -0
  209. apsimo/harness_integration/skills.py +231 -0
  210. apsimo/identity/__init__.py +26 -0
  211. apsimo/identity/participants.py +181 -0
  212. apsimo/identity/resolver.py +329 -0
  213. apsimo/identity_bootstrap/__init__.py +5 -0
  214. apsimo/identity_bootstrap/builder.py +208 -0
  215. apsimo/identity_bootstrap/corpus.py +443 -0
  216. apsimo/identity_bootstrap/models.py +54 -0
  217. apsimo/identity_bootstrap/runner.py +353 -0
  218. apsimo/identity_bootstrap/seeders/__init__.py +25 -0
  219. apsimo/identity_bootstrap/seeders/briefings.py +109 -0
  220. apsimo/identity_bootstrap/seeders/chain.py +57 -0
  221. apsimo/identity_bootstrap/seeders/goals.py +128 -0
  222. apsimo/identity_bootstrap/seeders/memory.py +191 -0
  223. apsimo/identity_bootstrap/seeders/neo4j_cognition.py +79 -0
  224. apsimo/identity_bootstrap/seeders/relationship.py +152 -0
  225. apsimo/identity_bootstrap/seeders/sessions.py +67 -0
  226. apsimo/identity_bootstrap/seeders/skills.py +92 -0
  227. apsimo/identity_bootstrap/seeders/task_queue.py +72 -0
  228. apsimo/identity_bootstrap/seeders/world_model.py +143 -0
  229. apsimo/identity_bootstrap/self_query.py +92 -0
  230. apsimo/identity_bootstrap/self_reflection.py +155 -0
  231. apsimo/identity_bootstrap/skill.py +37 -0
  232. apsimo/identity_bootstrap/verifier.py +436 -0
  233. apsimo/initiatives/__init__.py +20 -0
  234. apsimo/initiatives/action_registry.py +454 -0
  235. apsimo/initiatives/approval_authority.py +2105 -0
  236. apsimo/initiatives/approval_policy.py +123 -0
  237. apsimo/initiatives/assignment.py +263 -0
  238. apsimo/initiatives/backup_evidence.py +100 -0
  239. apsimo/initiatives/context_freshness.py +103 -0
  240. apsimo/initiatives/models.py +318 -0
  241. apsimo/initiatives/native_work.py +270 -0
  242. apsimo/initiatives/standing_approvals.py +232 -0
  243. apsimo/initiatives/store.py +1081 -0
  244. apsimo/initiatives/temporal_followup.py +410 -0
  245. apsimo/intelligence/__init__.py +1 -0
  246. apsimo/intelligence/cognition/__init__.py +24 -0
  247. apsimo/intelligence/cognition/gap_detector.py +148 -0
  248. apsimo/intelligence/cognition/metalearner.py +547 -0
  249. apsimo/intelligence/cognition/metrics_collector.py +217 -0
  250. apsimo/intelligence/cognition/performance_index.py +299 -0
  251. apsimo/intelligence/cognition/registry.py +192 -0
  252. apsimo/intelligence/cognition/strategy_adjuster.py +222 -0
  253. apsimo/intelligence/cognition/types.py +16 -0
  254. apsimo/intelligence/components/__init__.py +66 -0
  255. apsimo/intelligence/components/anomaly_detector.py +413 -0
  256. apsimo/intelligence/components/initiative_engine.py +2643 -0
  257. apsimo/intelligence/components/preference_learner.py +521 -0
  258. apsimo/intelligence/components/research_orchestrator.py +358 -0
  259. apsimo/intelligence/components/self_directed_thinker.py +221 -0
  260. apsimo/intelligence/components/self_reflector.py +252 -0
  261. apsimo/intelligence/components/session_continuity.py +154 -0
  262. apsimo/intelligence/components/task_planner.py +320 -0
  263. apsimo/intelligence/components/tool_learner.py +217 -0
  264. apsimo/intelligence/graph/__init__.py +79 -0
  265. apsimo/intelligence/graph/client.py +2483 -0
  266. apsimo/intelligence/graph/consolidator.py +405 -0
  267. apsimo/intelligence/graph/distiller.py +312 -0
  268. apsimo/intelligence/graph/migrations.py +129 -0
  269. apsimo/intelligence/graph/queries.py +248 -0
  270. apsimo/intelligence/graph/recall.py +281 -0
  271. apsimo/intelligence/graph/reconciler.py +144 -0
  272. apsimo/intelligence/graph/schema.py +337 -0
  273. apsimo/intelligence/graph/selection.py +252 -0
  274. apsimo/intelligence/learning/__init__.py +17 -0
  275. apsimo/intelligence/learning/continuous_learner.py +245 -0
  276. apsimo/intelligence/learning/feedback_store.py +321 -0
  277. apsimo/intelligence/mind_model/__init__.py +1 -0
  278. apsimo/intelligence/mind_model/graph_baseline.py +136 -0
  279. apsimo/intelligence/mind_model/signal_collector.py +361 -0
  280. apsimo/intelligence/relationships/__init__.py +11 -0
  281. apsimo/intelligence/relationships/profiler.py +389 -0
  282. apsimo/intelligence/relationships/scorer.py +560 -0
  283. apsimo/intelligence/relationships/signal_floor.py +66 -0
  284. apsimo/intelligence/relationships/trust_tiers.py +300 -0
  285. apsimo/intelligence/synthesis/__init__.py +40 -0
  286. apsimo/intelligence/synthesis/connection_discoverer.py +379 -0
  287. apsimo/intelligence/synthesis/cross_domain_analyzer.py +287 -0
  288. apsimo/intelligence/synthesis/insight_deliverer.py +171 -0
  289. apsimo/intelligence/synthesis/insight_store.py +79 -0
  290. apsimo/intelligence/synthesis/insight_validator.py +183 -0
  291. apsimo/intelligence/synthesis/novelty_scorer.py +267 -0
  292. apsimo/intelligence/turn_middleware/__init__.py +15 -0
  293. apsimo/intelligence/turn_middleware/memory_sync.py +119 -0
  294. apsimo/mcp/__init__.py +41 -0
  295. apsimo/mcp/__main__.py +6 -0
  296. apsimo/mcp/config.py +287 -0
  297. apsimo/mcp/server.py +501 -0
  298. apsimo/migrations.py +187 -0
  299. apsimo/mining/__init__.py +27 -0
  300. apsimo/mining/corpus.py +239 -0
  301. apsimo/mining/escalations.py +289 -0
  302. apsimo/mining/models.py +169 -0
  303. apsimo/mining/store.py +210 -0
  304. apsimo/models/__init__.py +30 -0
  305. apsimo/models/memory.py +80 -0
  306. apsimo/models/mesh.py +72 -0
  307. apsimo/models/person.py +104 -0
  308. apsimo/models/signal.py +108 -0
  309. apsimo/observations/__init__.py +15 -0
  310. apsimo/observations/store.py +277 -0
  311. apsimo/patterns/__init__.py +6 -0
  312. apsimo/patterns/extract.py +187 -0
  313. apsimo/patterns/store.py +227 -0
  314. apsimo/persona/__init__.py +1 -0
  315. apsimo/persona/engine.py +611 -0
  316. apsimo/persona/manifest.py +140 -0
  317. apsimo/projects/__init__.py +28 -0
  318. apsimo/projects/engine.py +1681 -0
  319. apsimo/projects/event_outbox.py +188 -0
  320. apsimo/projects/models.py +216 -0
  321. apsimo/projects/planner.py +181 -0
  322. apsimo/projects/store.py +1446 -0
  323. apsimo/proposals/__init__.py +12 -0
  324. apsimo/proposals/engine.py +114 -0
  325. apsimo/proposals/models.py +207 -0
  326. apsimo/qualification/__init__.py +1 -0
  327. apsimo/qualification/cases.py +75 -0
  328. apsimo/qualification/cli.py +51 -0
  329. apsimo/qualification/memory_cases.py +209 -0
  330. apsimo/qualification/records.py +92 -0
  331. apsimo/qualification/report.py +87 -0
  332. apsimo/qualification/runner.py +311 -0
  333. apsimo/qualification/structured_cases.py +131 -0
  334. apsimo/reasoning/__init__.py +13 -0
  335. apsimo/reasoning/executor.py +506 -0
  336. apsimo/reasoning/loop.py +373 -0
  337. apsimo/reasoning/native_tools/__init__.py +16 -0
  338. apsimo/reasoning/native_tools/calculate.py +141 -0
  339. apsimo/reasoning/native_tools/file_ops.py +150 -0
  340. apsimo/reasoning/native_tools/web_search.py +49 -0
  341. apsimo/reasoning/tool_policy.py +182 -0
  342. apsimo/redact/__init__.py +176 -0
  343. apsimo/repos/__init__.py +5 -0
  344. apsimo/repos/mirrors.py +204 -0
  345. apsimo/research/__init__.py +41 -0
  346. apsimo/research/artifact.py +482 -0
  347. apsimo/research/gatherer.py +387 -0
  348. apsimo/research/pipeline.py +513 -0
  349. apsimo/research/search/__init__.py +7 -0
  350. apsimo/research/search/base.py +41 -0
  351. apsimo/research/search/brave.py +59 -0
  352. apsimo/research/search/cache.py +51 -0
  353. apsimo/research/search/duckduckgo.py +103 -0
  354. apsimo/research/search/orchestrator.py +119 -0
  355. apsimo/research/search/serpapi.py +59 -0
  356. apsimo/research/search/tavily.py +59 -0
  357. apsimo/research/synthesizer.py +309 -0
  358. apsimo/router/__init__.py +30 -0
  359. apsimo/router/complexity_scorer.py +148 -0
  360. apsimo/router/endpoints.py +153 -0
  361. apsimo/router/fallback.py +58 -0
  362. apsimo/router/functions.py +243 -0
  363. apsimo/router/native_policy.py +52 -0
  364. apsimo/router/router.py +762 -0
  365. apsimo/router/self_learning.py +174 -0
  366. apsimo/router/tiers.py +677 -0
  367. apsimo/sandbox/__init__.py +21 -0
  368. apsimo/sandbox/backend.py +195 -0
  369. apsimo/sandbox/manager.py +173 -0
  370. apsimo/scope_bounds.py +7 -0
  371. apsimo/secrets/__init__.py +6 -0
  372. apsimo/secrets/backends/__init__.py +8 -0
  373. apsimo/secrets/backends/base.py +42 -0
  374. apsimo/secrets/backends/env.py +110 -0
  375. apsimo/secrets/backends/keyring.py +72 -0
  376. apsimo/secrets/backends/onepassword.py +232 -0
  377. apsimo/secrets/cli.py +191 -0
  378. apsimo/secrets/manager.py +160 -0
  379. apsimo/secrets/migration.py +101 -0
  380. apsimo/secrets/types.py +98 -0
  381. apsimo/seed.py +41 -0
  382. apsimo/self_model/__init__.py +37 -0
  383. apsimo/self_model/appraisals.py +673 -0
  384. apsimo/self_model/benchmark.py +1314 -0
  385. apsimo/self_model/brief.py +40 -0
  386. apsimo/self_model/event_concerns.py +1128 -0
  387. apsimo/self_model/execution_forecasts.py +353 -0
  388. apsimo/self_model/expectations.py +1595 -0
  389. apsimo/self_model/experiments.py +1150 -0
  390. apsimo/self_model/journal.py +148 -0
  391. apsimo/self_model/judgments.py +705 -0
  392. apsimo/self_model/native_outcomes.py +55 -0
  393. apsimo/self_model/params.py +220 -0
  394. apsimo/self_model/perspective.py +246 -0
  395. apsimo/self_model/reconcile.py +183 -0
  396. apsimo/self_model/reply_forecasts.py +381 -0
  397. apsimo/self_model/runtime_forecasts.py +296 -0
  398. apsimo/self_model/runtime_models.py +67 -0
  399. apsimo/self_model/settlement.py +207 -0
  400. apsimo/self_model/situation.py +1731 -0
  401. apsimo/self_model/store.py +883 -0
  402. apsimo/self_model/supervised.py +137 -0
  403. apsimo/self_model/thinker.py +99 -0
  404. apsimo/self_model/trust.py +388 -0
  405. apsimo/self_model/workspace.py +2388 -0
  406. apsimo/server.py +4197 -0
  407. apsimo/services/__init__.py +1 -0
  408. apsimo/services/agent_bridge.py +474 -0
  409. apsimo/services/initiative_executor.py +914 -0
  410. apsimo/services/instance.py +297 -0
  411. apsimo/sessions/__init__.py +22 -0
  412. apsimo/sessions/config.py +13 -0
  413. apsimo/sessions/context_loader.py +88 -0
  414. apsimo/sessions/federation_session.py +75 -0
  415. apsimo/sessions/isolated_session.py +98 -0
  416. apsimo/sessions/reports.py +84 -0
  417. apsimo/sessions/store.py +148 -0
  418. apsimo/setup.py +2818 -0
  419. apsimo/setup_hermes.py +879 -0
  420. apsimo/setup_local_work.py +218 -0
  421. apsimo/setup_native_goals.py +134 -0
  422. apsimo/setup_native_reviews.py +115 -0
  423. apsimo/skills/__init__.py +10 -0
  424. apsimo/skills/base.py +108 -0
  425. apsimo/skills/budget.py +28 -0
  426. apsimo/skills/executor.py +493 -0
  427. apsimo/skills/executors/__init__.py +1 -0
  428. apsimo/skills/executors/behavioral_correction.py +75 -0
  429. apsimo/skills/executors/capability_gap.py +38 -0
  430. apsimo/skills/executors/data_quality.py +163 -0
  431. apsimo/skills/executors/knowledge_acquisition.py +41 -0
  432. apsimo/skills/executors/operational_hygiene.py +185 -0
  433. apsimo/skills/executors/subsystem_health.py +169 -0
  434. apsimo/skills/hermes_export.py +431 -0
  435. apsimo/skills/index.py +123 -0
  436. apsimo/skills/learning/__init__.py +21 -0
  437. apsimo/skills/learning/novelty_detector.py +206 -0
  438. apsimo/skills/learning/pattern_extractor.py +199 -0
  439. apsimo/skills/learning/triggers.py +159 -0
  440. apsimo/skills/loader.py +246 -0
  441. apsimo/skills/migrations/002_progressive_loading.sql +6 -0
  442. apsimo/skills/migrations/backfill_triggers.py +20 -0
  443. apsimo/skills/models.py +202 -0
  444. apsimo/skills/packager.py +128 -0
  445. apsimo/skills/protocols.py +70 -0
  446. apsimo/skills/registry.py +191 -0
  447. apsimo/skills/runtime.py +58 -0
  448. apsimo/skills/sandbox_runner.py +229 -0
  449. apsimo/skills/scheduler.py +129 -0
  450. apsimo/skills/schema.py +79 -0
  451. apsimo/skills/security/__init__.py +12 -0
  452. apsimo/skills/security/guards.py +53 -0
  453. apsimo/skills/security/scanner.py +223 -0
  454. apsimo/skills_memory/__init__.py +26 -0
  455. apsimo/skills_memory/distill.py +159 -0
  456. apsimo/skills_memory/models.py +85 -0
  457. apsimo/skills_memory/retrieve.py +62 -0
  458. apsimo/skills_memory/store.py +172 -0
  459. apsimo/surprise/__init__.py +6 -0
  460. apsimo/surprise/accumulation.py +57 -0
  461. apsimo/surprise/scorer.py +102 -0
  462. apsimo/surprise/store.py +203 -0
  463. apsimo/task_queue/__init__.py +69 -0
  464. apsimo/task_queue/action_receipts.py +148 -0
  465. apsimo/task_queue/approval_relay_canary.py +108 -0
  466. apsimo/task_queue/config.py +85 -0
  467. apsimo/task_queue/contract.py +361 -0
  468. apsimo/task_queue/events.py +130 -0
  469. apsimo/task_queue/governor.py +1031 -0
  470. apsimo/task_queue/handlers/__init__.py +16 -0
  471. apsimo/task_queue/handlers/base.py +37 -0
  472. apsimo/task_queue/handlers/inference.py +640 -0
  473. apsimo/task_queue/handlers/monitoring.py +116 -0
  474. apsimo/task_queue/handlers/registry.py +75 -0
  475. apsimo/task_queue/handlers/subtask_handler.py +173 -0
  476. apsimo/task_queue/handlers/system_maintenance.py +147 -0
  477. apsimo/task_queue/mesh_integration.py +111 -0
  478. apsimo/task_queue/models.py +317 -0
  479. apsimo/task_queue/queue_manager.py +8286 -0
  480. apsimo/task_queue/routing.py +287 -0
  481. apsimo/task_queue/scheduler.py +252 -0
  482. apsimo/task_queue/schema.sql +197 -0
  483. apsimo/task_queue/work_control.py +342 -0
  484. apsimo/task_queue/worker.py +993 -0
  485. apsimo/telemetry.py +145 -0
  486. apsimo/tom/__init__.py +6 -0
  487. apsimo/tom/affect.py +387 -0
  488. apsimo/tom/approvals.py +171 -0
  489. apsimo/tom/arcs.py +896 -0
  490. apsimo/tom/asymmetry.py +131 -0
  491. apsimo/tom/eligibility.py +248 -0
  492. apsimo/tom/engagement.py +214 -0
  493. apsimo/tom/exposure.py +214 -0
  494. apsimo/tom/extractor.py +306 -0
  495. apsimo/tom/fact_adapters.py +144 -0
  496. apsimo/tom/facts.py +326 -0
  497. apsimo/tom/integration.py +592 -0
  498. apsimo/tom/leveled.py +118 -0
  499. apsimo/tom/levels.py +247 -0
  500. apsimo/tom/recipient_audit.py +995 -0
  501. apsimo/tom/recipient_simulator.py +593 -0
  502. apsimo/tom/source_lineage.py +93 -0
  503. apsimo/tom/tom2.py +277 -0
  504. apsimo/tom/visibility.py +559 -0
  505. apsimo/tom/visibility_store.py +414 -0
  506. apsimo/tools/__init__.py +0 -0
  507. apsimo/tools/definitions.py +740 -0
  508. apsimo/tools/handlers.py +943 -0
  509. apsimo/toolsmith/__init__.py +26 -0
  510. apsimo/toolsmith/authority.py +166 -0
  511. apsimo/toolsmith/engine.py +559 -0
  512. apsimo/toolsmith/integrity.py +100 -0
  513. apsimo/toolsmith/miner.py +145 -0
  514. apsimo/toolsmith/policy.py +110 -0
  515. apsimo/toolsmith/registry.py +635 -0
  516. apsimo/turns/__init__.py +17 -0
  517. apsimo/turns/audio.py +134 -0
  518. apsimo/turns/documents.py +235 -0
  519. apsimo/turns/executions.py +486 -0
  520. apsimo/turns/hermes_history.py +245 -0
  521. apsimo/turns/hermes_kanban.py +268 -0
  522. apsimo/turns/hermes_work.py +96 -0
  523. apsimo/turns/idempotency.py +752 -0
  524. apsimo/turns/local_work.py +115 -0
  525. apsimo/turns/media.py +581 -0
  526. apsimo/turns/reported_workers.py +196 -0
  527. apsimo/turns/source_annotations.py +283 -0
  528. apsimo/turns/source_attribution.py +154 -0
  529. apsimo/turns/source_read.py +351 -0
  530. apsimo/turns/source_vectors.py +263 -0
  531. apsimo/turns/video.py +210 -0
  532. apsimo/util/autonomy_preset.py +220 -0
  533. apsimo/util/instance.py +92 -0
  534. apsimo/util/model_output.py +25 -0
  535. apsimo/util/quiet_hours.py +27 -0
  536. apsimo/util/session_safety.py +37 -0
  537. apsimo/util/temporal.py +343 -0
  538. apsimo/vector/__init__.py +75 -0
  539. apsimo/vector/backfill.py +171 -0
  540. apsimo/vector/caption.py +114 -0
  541. apsimo/vector/collections.py +51 -0
  542. apsimo/vector/config.py +102 -0
  543. apsimo/vector/embedder.py +670 -0
  544. apsimo/vector/image_preprocess.py +406 -0
  545. apsimo/vector/image_store.py +296 -0
  546. apsimo/vector/indexes.py +162 -0
  547. apsimo/vector/migrate.py +334 -0
  548. apsimo/vector/multimodal_provider.py +417 -0
  549. apsimo/vector/multimodal_types.py +87 -0
  550. apsimo/vector/openai_provider.py +119 -0
  551. apsimo/vector/query.py +49 -0
  552. apsimo/vector/reranker.py +565 -0
  553. apsimo/vector/safety_image.py +159 -0
  554. apsimo/vector/scanner.py +197 -0
  555. apsimo/vector/setup.py +289 -0
  556. apsimo/vector/store.py +533 -0
  557. apsimo/vector/tiers.py +263 -0
  558. apsimo/work_orders.py +925 -0
  559. apsimo/workers/__init__.py +21 -0
  560. apsimo/workers/agent_bridge.py +640 -0
  561. apsimo/workers/colony_worker.py +382 -0
  562. apsimo/workers/queue_worker.py +441 -0
  563. apsimo/workers/skills_sync.py +152 -0
  564. apsimo/world_model/__init__.py +71 -0
  565. apsimo/world_model/causal_maintenance.py +131 -0
  566. apsimo/world_model/causal_policy.py +43 -0
  567. apsimo/world_model/causal_query.py +125 -0
  568. apsimo/world_model/confidence.py +54 -0
  569. apsimo/world_model/config.py +64 -0
  570. apsimo/world_model/constants.py +97 -0
  571. apsimo/world_model/entities.py +145 -0
  572. apsimo/world_model/expectation_resolvers.py +177 -0
  573. apsimo/world_model/extraction/__init__.py +7 -0
  574. apsimo/world_model/extraction/base.py +62 -0
  575. apsimo/world_model/extraction/conversation_extractor.py +262 -0
  576. apsimo/world_model/extraction/detector.py +74 -0
  577. apsimo/world_model/extraction/document_extractor.py +78 -0
  578. apsimo/world_model/extraction/formats/__init__.py +24 -0
  579. apsimo/world_model/extraction/formats/csv_fmt.py +68 -0
  580. apsimo/world_model/extraction/formats/html_fmt.py +72 -0
  581. apsimo/world_model/extraction/formats/json_fmt.py +68 -0
  582. apsimo/world_model/extraction/formats/pdf.py +43 -0
  583. apsimo/world_model/extraction/formats/text.py +27 -0
  584. apsimo/world_model/extraction/llm_extractor.py +164 -0
  585. apsimo/world_model/extraction/pipeline.py +73 -0
  586. apsimo/world_model/integrations/__init__.py +5 -0
  587. apsimo/world_model/integrations/mind_model_bridge.py +115 -0
  588. apsimo/world_model/integrations/social_intel_bridge.py +120 -0
  589. apsimo/world_model/jobs/__init__.py +4 -0
  590. apsimo/world_model/jobs/extraction_job.py +168 -0
  591. apsimo/world_model/llm_extract.py +572 -0
  592. apsimo/world_model/neo4j/__init__.py +5 -0
  593. apsimo/world_model/neo4j/backend.py +654 -0
  594. apsimo/world_model/observations.py +155 -0
  595. apsimo/world_model/populator.py +307 -0
  596. apsimo/world_model/postgres/__init__.py +1 -0
  597. apsimo/world_model/postgres/backend.py +683 -0
  598. apsimo/world_model/relationships.py +25 -0
  599. apsimo/world_model/resolution/__init__.py +13 -0
  600. apsimo/world_model/resolution/entity_resolver.py +232 -0
  601. apsimo/world_model/resolution/merge_audit.py +16 -0
  602. apsimo/world_model/resolution/merge_workflow.py +117 -0
  603. apsimo/world_model/source_reports.py +121 -0
  604. apsimo/world_model/sqlite/__init__.py +4 -0
  605. apsimo/world_model/sqlite/backend.py +855 -0
  606. apsimo/world_model/sqlite/schema.sql +132 -0
  607. apsimo/world_model/store.py +545 -0
  608. apsimo-1.3.0.dist-info/METADATA +78 -0
  609. apsimo-1.3.0.dist-info/RECORD +614 -0
  610. apsimo-1.3.0.dist-info/WHEEL +5 -0
  611. apsimo-1.3.0.dist-info/entry_points.txt +11 -0
  612. apsimo-1.3.0.dist-info/licenses/LICENSE +21 -0
  613. apsimo-1.3.0.dist-info/top_level.txt +2 -0
  614. 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)