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
apsimo/backup.py ADDED
@@ -0,0 +1,962 @@
1
+ """Full-state backup and restore for Colony.
2
+
3
+ Creates a compressed, optionally encrypted archive of all Colony state:
4
+ - All SQLite databases in COLONY_STATE_DIR (auto-discovered)
5
+ - Original source images referenced by the canonical ledger snapshot
6
+ - Identity files (colony-id, keys, genesis)
7
+ - Config files (scrubbed of secrets)
8
+ - LanceDB vector store
9
+ - Neo4j graph export (Cypher, best-effort)
10
+
11
+ Usage::
12
+
13
+ # Backup
14
+ colony backup --full --output ~/backups/
15
+
16
+ # Restore
17
+ colony restore --full colony-backup-20260627T120000.tar.gz
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from contextlib import closing
23
+ import hashlib
24
+ import hmac
25
+ import json
26
+ import logging
27
+ import os
28
+ import re
29
+ import shutil
30
+ import sqlite3
31
+ import stat
32
+ import tarfile
33
+ import tempfile
34
+ import time
35
+ from datetime import datetime, timezone
36
+ from io import BytesIO
37
+ from pathlib import Path
38
+ from typing import Any, Optional
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+ BACKUP_VERSION = 2
43
+ _GOVERNED_ACTION_LEDGER = Path("governed-actions/ledger.db")
44
+
45
+ _SECRET_KEY_PATTERN = re.compile(
46
+ r"(_KEY|_SECRET|_PASSWORD|_TOKEN|_PASSPHRASE)$", re.IGNORECASE
47
+ )
48
+
49
+
50
+ def _attest_private_governed_ledger(
51
+ path: Path, *, normalize_archive_modes: bool = False
52
+ ) -> os.stat_result:
53
+ """Require the same stable owner-private posture as the live ledger."""
54
+
55
+ try:
56
+ parent = path.parent.lstat()
57
+ leaf = path.lstat()
58
+ euid = os.geteuid()
59
+ except (AttributeError, OSError):
60
+ raise ValueError("private governed action ledger posture is invalid") from None
61
+ if (
62
+ not stat.S_ISDIR(parent.st_mode)
63
+ or parent.st_uid != euid
64
+ or not stat.S_ISREG(leaf.st_mode)
65
+ or leaf.st_uid != euid
66
+ or leaf.st_nlink != 1
67
+ ):
68
+ raise ValueError("private governed action ledger posture is invalid")
69
+ if normalize_archive_modes:
70
+ try:
71
+ os.chmod(path.parent, 0o700, follow_symlinks=False)
72
+ os.chmod(path, 0o600, follow_symlinks=False)
73
+ parent = path.parent.lstat()
74
+ leaf = path.lstat()
75
+ except OSError:
76
+ raise ValueError(
77
+ "private governed action ledger posture is invalid"
78
+ ) from None
79
+ if (
80
+ stat.S_IMODE(parent.st_mode) != 0o700
81
+ or stat.S_IMODE(leaf.st_mode) != 0o600
82
+ ):
83
+ raise ValueError("private governed action ledger posture is invalid")
84
+ return leaf
85
+
86
+
87
+ def _restore_private_governed_ledger(source: Path, state_dir: Path) -> None:
88
+ """Restore the evidence ledger atomically without following aliases."""
89
+
90
+ source_stat = _attest_private_governed_ledger(
91
+ source, normalize_archive_modes=True
92
+ )
93
+ _preflight_private_governed_destination(state_dir)
94
+ destination = state_dir / _GOVERNED_ACTION_LEDGER
95
+ parent = destination.parent
96
+ try:
97
+ parent_stat = parent.lstat()
98
+ except FileNotFoundError:
99
+ try:
100
+ parent.mkdir(mode=0o700, parents=True)
101
+ parent_stat = parent.lstat()
102
+ except OSError:
103
+ raise ValueError(
104
+ "private governed action ledger destination is invalid"
105
+ ) from None
106
+ except OSError:
107
+ raise ValueError(
108
+ "private governed action ledger destination is invalid"
109
+ ) from None
110
+
111
+ euid = os.geteuid()
112
+ if not stat.S_ISDIR(parent_stat.st_mode) or parent_stat.st_uid != euid:
113
+ raise ValueError("private governed action ledger destination is invalid")
114
+ try:
115
+ os.chmod(parent, 0o700, follow_symlinks=False)
116
+ parent_stat = parent.lstat()
117
+ except OSError:
118
+ raise ValueError(
119
+ "private governed action ledger destination is invalid"
120
+ ) from None
121
+ if stat.S_IMODE(parent_stat.st_mode) != 0o700:
122
+ raise ValueError("private governed action ledger destination is invalid")
123
+
124
+ try:
125
+ destination.lstat()
126
+ except FileNotFoundError:
127
+ pass
128
+ except OSError:
129
+ raise ValueError(
130
+ "private governed action ledger destination is invalid"
131
+ ) from None
132
+ else:
133
+ raise ValueError("private governed action ledger already exists")
134
+
135
+ source_fd: int | None = None
136
+ destination_fd: int | None = None
137
+ temporary_name: str | None = None
138
+ try:
139
+ source_fd = os.open(
140
+ source,
141
+ os.O_RDONLY | os.O_NOFOLLOW | getattr(os, "O_CLOEXEC", 0),
142
+ )
143
+ opened_source = os.fstat(source_fd)
144
+ if (
145
+ (opened_source.st_dev, opened_source.st_ino)
146
+ != (source_stat.st_dev, source_stat.st_ino)
147
+ ):
148
+ raise ValueError("private governed action ledger source changed")
149
+ destination_fd, temporary_name = tempfile.mkstemp(
150
+ prefix=".ledger-restore-", dir=str(parent)
151
+ )
152
+ os.fchmod(destination_fd, 0o600)
153
+ with os.fdopen(source_fd, "rb") as source_file:
154
+ source_fd = None
155
+ with os.fdopen(destination_fd, "wb") as destination_file:
156
+ destination_fd = None
157
+ shutil.copyfileobj(source_file, destination_file)
158
+ destination_file.flush()
159
+ os.fsync(destination_file.fileno())
160
+ try:
161
+ os.link(temporary_name, destination, follow_symlinks=False)
162
+ except FileExistsError:
163
+ raise ValueError(
164
+ "private governed action ledger already exists"
165
+ ) from None
166
+ os.unlink(temporary_name)
167
+ temporary_name = None
168
+ os.chmod(destination, 0o600, follow_symlinks=False)
169
+ _attest_private_governed_ledger(destination)
170
+ directory_fd = os.open(
171
+ parent,
172
+ os.O_RDONLY
173
+ | os.O_DIRECTORY
174
+ | os.O_NOFOLLOW
175
+ | getattr(os, "O_CLOEXEC", 0),
176
+ )
177
+ try:
178
+ os.fsync(directory_fd)
179
+ finally:
180
+ os.close(directory_fd)
181
+ except ValueError:
182
+ raise
183
+ except OSError:
184
+ raise ValueError("private governed action ledger restore failed") from None
185
+ finally:
186
+ if source_fd is not None:
187
+ os.close(source_fd)
188
+ if destination_fd is not None:
189
+ os.close(destination_fd)
190
+ if temporary_name is not None:
191
+ try:
192
+ Path(temporary_name).unlink(missing_ok=True)
193
+ except OSError:
194
+ pass
195
+
196
+
197
+ def _preflight_private_governed_destination(state_dir: Path) -> None:
198
+ """Refuse an evidence rewind before any full-restore state is written."""
199
+
200
+ destination = state_dir / _GOVERNED_ACTION_LEDGER
201
+ try:
202
+ parent = destination.parent.lstat()
203
+ except FileNotFoundError:
204
+ return
205
+ except OSError:
206
+ raise ValueError(
207
+ "private governed action ledger destination is invalid"
208
+ ) from None
209
+ if not stat.S_ISDIR(parent.st_mode) or parent.st_uid != os.geteuid():
210
+ raise ValueError("private governed action ledger destination is invalid")
211
+ try:
212
+ destination.lstat()
213
+ except FileNotFoundError:
214
+ return
215
+ except OSError:
216
+ raise ValueError(
217
+ "private governed action ledger destination is invalid"
218
+ ) from None
219
+ raise ValueError("private governed action ledger already exists")
220
+
221
+
222
+ # ── Backup ───────────────────────────────────────────────────────────────
223
+
224
+
225
+ def create_full_backup(
226
+ state_dir: str | Path,
227
+ output_dir: str | Path,
228
+ *,
229
+ passphrase: Optional[bytes] = None,
230
+ include_graph: bool = True,
231
+ include_vectors: bool = True,
232
+ include_host_paths: Optional[list[str]] = None,
233
+ ) -> Path:
234
+ """Create a full backup archive of Colony state.
235
+
236
+ Returns the path to the created archive.
237
+ """
238
+ state_dir = Path(state_dir)
239
+ output_dir = Path(output_dir)
240
+ output_dir.mkdir(parents=True, exist_ok=True)
241
+
242
+ colony_id = _read_colony_id(state_dir)
243
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
244
+ base_name = f"colony-backup-{timestamp}"
245
+
246
+ with tempfile.TemporaryDirectory(prefix="colony-backup-") as tmp:
247
+ staging = Path(tmp) / base_name
248
+ staging.mkdir()
249
+
250
+ db_manifest = _snapshot_databases(state_dir, staging / "databases")
251
+ source_images = _snapshot_source_images(state_dir, staging)
252
+ _snapshot_identity(state_dir, staging / "identity")
253
+ _snapshot_config(state_dir, staging / "config")
254
+
255
+ if include_vectors:
256
+ _snapshot_vectors(state_dir, staging / "vector")
257
+
258
+ graph_status = "skipped"
259
+ if include_graph:
260
+ graph_status = _snapshot_graph(state_dir, staging / "graph")
261
+
262
+ if include_host_paths:
263
+ _snapshot_host_state(include_host_paths, staging / "host")
264
+
265
+ meta = {
266
+ "backup_version": BACKUP_VERSION,
267
+ "colony_version": _get_colony_version(),
268
+ "timestamp": datetime.now(timezone.utc).isoformat(),
269
+ "colony_id": colony_id,
270
+ "colony_id_hmac": _compute_identity_hmac(colony_id),
271
+ "database_manifest": db_manifest,
272
+ "source_images": source_images,
273
+ "graph_status": graph_status,
274
+ "encrypted": passphrase is not None,
275
+ }
276
+ (staging / "meta.json").write_text(
277
+ json.dumps(meta, indent=2) + "\n"
278
+ )
279
+
280
+ archive_path = output_dir / f"{base_name}.tar.gz"
281
+ _create_archive(staging, archive_path)
282
+
283
+ if passphrase is not None:
284
+ encrypted_path = output_dir / f"{base_name}.tar.gz.enc"
285
+ _encrypt_file(archive_path, encrypted_path, passphrase)
286
+ archive_path.unlink()
287
+ archive_path = encrypted_path
288
+
289
+ logger.info("Full backup created: %s", archive_path)
290
+ return archive_path
291
+
292
+
293
+ # ── Restore ──────────────────────────────────────────────────────────────
294
+
295
+
296
+ def restore_full_backup(
297
+ archive_path: str | Path,
298
+ state_dir: str | Path,
299
+ *,
300
+ passphrase: Optional[bytes] = None,
301
+ force_identity: bool = False,
302
+ ) -> dict[str, Any]:
303
+ """Restore Colony state from a full backup archive.
304
+
305
+ Returns a summary dict of what was restored.
306
+ """
307
+ archive_path = Path(archive_path)
308
+ state_dir = Path(state_dir)
309
+ state_dir.mkdir(parents=True, exist_ok=True)
310
+
311
+ with tempfile.TemporaryDirectory(prefix="colony-restore-") as tmp:
312
+ staging = Path(tmp) / "restore"
313
+ staging.mkdir()
314
+
315
+ actual_archive = archive_path
316
+ if archive_path.suffix == ".enc":
317
+ if passphrase is None:
318
+ raise ValueError("Archive is encrypted; passphrase required")
319
+ actual_archive = Path(tmp) / "decrypted.tar.gz"
320
+ _decrypt_file(archive_path, actual_archive, passphrase)
321
+
322
+ _extract_archive(actual_archive, staging)
323
+
324
+ root = _find_backup_root(staging)
325
+ meta = json.loads((root / "meta.json").read_text())
326
+
327
+ if meta.get("backup_version", 0) > BACKUP_VERSION:
328
+ raise ValueError(
329
+ f"Backup version {meta['backup_version']} is newer than "
330
+ f"this Colony supports (max {BACKUP_VERSION})"
331
+ )
332
+
333
+ existing_id = _read_colony_id(state_dir)
334
+ backup_id = meta.get("colony_id", "")
335
+
336
+ if existing_id and existing_id != backup_id and not force_identity:
337
+ raise ValueError(
338
+ f"Backup is from colony {backup_id} but this instance is "
339
+ f"{existing_id}. Use --force-identity to override."
340
+ )
341
+
342
+ # Validate every referenced original before writing any restored state.
343
+ # A caption or an embedding cannot reconstruct the original evidence.
344
+ source_images = _verified_source_images(root)
345
+ governed_restore = root / "databases" / _GOVERNED_ACTION_LEDGER
346
+ try:
347
+ governed_restore.lstat()
348
+ except FileNotFoundError:
349
+ pass
350
+ else:
351
+ _attest_private_governed_ledger(
352
+ governed_restore, normalize_archive_modes=True
353
+ )
354
+ _preflight_private_governed_destination(state_dir)
355
+
356
+ summary: dict[str, Any] = {
357
+ "colony_id": backup_id, "databases": [], "errors": [],
358
+ "serving_admitted": False,
359
+ }
360
+
361
+ identity_dir = root / "identity"
362
+ if identity_dir.is_dir():
363
+ _restore_directory(identity_dir, state_dir)
364
+ summary["identity"] = True
365
+
366
+ db_dir = root / "databases"
367
+ if db_dir.is_dir():
368
+ db_files = list(sorted(db_dir.glob("*.db")))
369
+ governed_ledger = db_dir / _GOVERNED_ACTION_LEDGER
370
+ try:
371
+ governed_ledger.lstat()
372
+ except FileNotFoundError:
373
+ pass
374
+ else:
375
+ _attest_private_governed_ledger(
376
+ governed_ledger, normalize_archive_modes=True
377
+ )
378
+ db_files.append(governed_ledger)
379
+ for db_file in db_files:
380
+ relative = db_file.relative_to(db_dir)
381
+ dest = state_dir / relative
382
+ if relative == _GOVERNED_ACTION_LEDGER:
383
+ _restore_private_governed_ledger(db_file, state_dir)
384
+ else:
385
+ dest.parent.mkdir(parents=True, exist_ok=True)
386
+ # A crashed destination can still have committed WAL.
387
+ # SQLite replaces its logical database consistently;
388
+ # copying the main file alone can replay the old WAL over it.
389
+ with closing(sqlite3.connect(db_file.resolve().as_uri() + '?mode=ro', uri=True)) as source:
390
+ with closing(sqlite3.connect(dest)) as target:
391
+ source.backup(target)
392
+ relative_name = relative.as_posix()
393
+ summary["databases"].append(relative_name)
394
+ logger.info("Restored database: %s", relative_name)
395
+
396
+ if source_images:
397
+ from apsimo.vector.image_store import LocalImageStore
398
+ images = LocalImageStore(str(state_dir), source_evidence=True)
399
+ images._ensure_dirs()
400
+ for record, source in source_images:
401
+ destination = images._original_path(record['asset_hash'], record['mime_type'])
402
+ shutil.copyfile(source, destination)
403
+ destination.chmod(0o600)
404
+ summary['source_images'] = len(source_images)
405
+
406
+ config_dir = root / "config"
407
+ if config_dir.is_dir():
408
+ _restore_directory(config_dir, state_dir)
409
+ summary["config"] = True
410
+
411
+ vector_dir = root / "vector"
412
+ if vector_dir.is_dir():
413
+ dest = state_dir / "lancedb"
414
+ if dest.exists():
415
+ shutil.rmtree(dest)
416
+ shutil.copytree(vector_dir / "lancedb", dest, dirs_exist_ok=True)
417
+ summary["vectors"] = True
418
+
419
+ graph_dir = root / "graph"
420
+ if graph_dir.is_dir():
421
+ marker = graph_dir / "SKIPPED.txt"
422
+ if not marker.exists():
423
+ summary["graph_export"] = str(graph_dir / "neo4j-dump.cypher")
424
+
425
+ logger.info("Restore complete: %s", summary)
426
+ return summary
427
+
428
+
429
+ def restore_source_memory(
430
+ archive_path: str | Path,
431
+ destination: str | Path,
432
+ *,
433
+ current_state: str | Path,
434
+ passphrase: Optional[bytes] = None,
435
+ ) -> dict[str, Any]:
436
+ """Recover current canonical memory, without restoring runtime authority.
437
+
438
+ The caller must select a surviving authoritative source ledger. Its current
439
+ membership, revisions, scopes and erasure history replace the old archive's
440
+ memory metadata. The archive supplies only still-owned original image bytes
441
+ missing from that surviving state. No old grants, effects, identity, config,
442
+ contact databases, tasks, graph or native host state are installed.
443
+ """
444
+ archive_path = Path(archive_path).resolve(strict=True)
445
+ current_state = Path(current_state).resolve()
446
+ destination = Path(destination).absolute()
447
+ resolved_destination = destination.resolve()
448
+ if (destination.exists() or destination.is_symlink()
449
+ or current_state == resolved_destination
450
+ or current_state in resolved_destination.parents
451
+ or resolved_destination in current_state.parents):
452
+ raise ValueError("Memory recovery requires a fresh destination outside current state")
453
+ current_id = _read_colony_id(current_state)
454
+ ledger = current_state / "turn-idempotency.db"
455
+ if not current_id or not ledger.is_file():
456
+ raise ValueError("Memory recovery requires the surviving colony identity and source ledger")
457
+
458
+ from apsimo.vector.image_store import LocalImageStore
459
+
460
+ destination.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
461
+ with tempfile.TemporaryDirectory(prefix="colony-memory-recovery-", dir=destination.parent) as temporary:
462
+ staging = Path(temporary)
463
+ actual_archive = archive_path
464
+ if archive_path.suffix == ".enc":
465
+ if passphrase is None:
466
+ raise ValueError("Archive is encrypted; passphrase required")
467
+ actual_archive = staging / "decrypted.tar.gz"
468
+ _decrypt_file(archive_path, actual_archive, passphrase)
469
+ unpacked = staging / "archive"
470
+ unpacked.mkdir()
471
+ _extract_archive(actual_archive, unpacked)
472
+ archived = _find_backup_root(unpacked)
473
+ meta = json.loads((archived / "meta.json").read_text())
474
+ if meta.get("backup_version", 0) > BACKUP_VERSION:
475
+ raise ValueError("Memory archive version is newer than this Colony supports")
476
+ if meta.get("colony_id") != current_id:
477
+ raise ValueError("Memory archive and surviving state have different colony identities")
478
+
479
+ selected = staging / "selected"
480
+ (selected / "databases").mkdir(parents=True)
481
+ snapshot = selected / "databases" / "turn-idempotency.db"
482
+ try:
483
+ with closing(sqlite3.connect(ledger.resolve().as_uri() + "?mode=ro", uri=True)) as source:
484
+ with closing(sqlite3.connect(snapshot)) as target:
485
+ source.backup(target)
486
+ if target.execute("PRAGMA integrity_check").fetchone()[0] != "ok":
487
+ raise ValueError("Surviving source ledger failed its integrity check")
488
+ snapshot.chmod(0o600)
489
+ with closing(sqlite3.connect(snapshot.as_uri() + "?mode=ro", uri=True)) as current:
490
+ source_count = current.execute("SELECT count(*) FROM turn_sources").fetchone()[0]
491
+ heads = current.execute(
492
+ "SELECT contact_id,max(sequence) FROM source_erasures GROUP BY contact_id"
493
+ ).fetchall()
494
+ _check_erasure_ancestry(archived / "databases" / "turn-idempotency.db", current)
495
+ except sqlite3.Error as error:
496
+ raise ValueError("Surviving canonical source history is unavailable") from error
497
+ if _read_colony_id(current_state) != current_id:
498
+ raise ValueError("Surviving colony identity changed during memory recovery")
499
+
500
+ bundle = staging / "bundle"
501
+ bundle.mkdir(mode=0o700)
502
+ shutil.copyfile(snapshot, bundle / "turn-idempotency.db")
503
+ (bundle / "turn-idempotency.db").chmod(0o600)
504
+ output_images = LocalImageStore(str(bundle), source_evidence=True)
505
+ live_images = LocalImageStore(str(current_state), source_evidence=True)
506
+ old_images = LocalImageStore(str(archived), source_evidence=True)
507
+ recovered_from_archive = 0
508
+ records = _source_image_records(selected)
509
+ if records:
510
+ output_images._ensure_dirs()
511
+ for record in records:
512
+ chosen = None
513
+ for source_images, from_archive in ((live_images, False), (old_images, True)):
514
+ candidate = source_images._original_path(record["asset_hash"], record["mime_type"])
515
+ try:
516
+ data = candidate.read_bytes()
517
+ except FileNotFoundError:
518
+ continue
519
+ if (len(data) != record["size_bytes"]
520
+ or hashlib.sha256(data).hexdigest() != record["asset_hash"]):
521
+ continue
522
+ chosen = data
523
+ recovered_from_archive += int(from_archive)
524
+ break
525
+ if chosen is None:
526
+ raise ValueError("Current source image has no matching original in surviving state or archive")
527
+ target = output_images._original_path(record["asset_hash"], record["mime_type"])
528
+ target.write_bytes(chosen)
529
+ target.chmod(0o600)
530
+
531
+ files = []
532
+ for path in sorted(bundle.rglob("*")):
533
+ if path.is_file():
534
+ files.append({"path": path.relative_to(bundle).as_posix(),
535
+ "sha256": _file_sha256(path), "bytes": path.stat().st_size})
536
+ summary = {
537
+ "recovery_mode": "source_memory_only",
538
+ "colony_id": current_id,
539
+ "source_count": source_count,
540
+ "source_images": len(records),
541
+ "images_recovered_from_archive": recovered_from_archive,
542
+ "erasure_head": max((row[1] for row in heads), default=0),
543
+ "erasure_contacts": len(heads),
544
+ "current_source_ledger_sha256": _file_sha256(snapshot),
545
+ "archive_sha256": _file_sha256(archive_path),
546
+ "files": files,
547
+ "runtime_authority_restored": False,
548
+ "serving_admitted": False,
549
+ }
550
+ receipt = bundle / "source-memory-recovery.json"
551
+ receipt.write_text(json.dumps(summary, indent=2) + "\n")
552
+ receipt.chmod(0o600)
553
+ # Reserve the destination only after all source and image checks pass.
554
+ # A publication failure leaves an incomplete bundle, never a success.
555
+ destination.mkdir(mode=0o700)
556
+ _restore_directory(bundle, destination)
557
+ return summary
558
+
559
+
560
+ def _file_sha256(path: Path) -> str:
561
+ value = hashlib.sha256()
562
+ with path.open("rb") as stream:
563
+ for block in iter(lambda: stream.read(1024 * 1024), b""):
564
+ value.update(block)
565
+ return value.hexdigest()
566
+
567
+
568
+ def _check_erasure_ancestry(archived_ledger: Path, current: sqlite3.Connection) -> None:
569
+ """A selected surviving history cannot rewind or replace captured erasures."""
570
+ if not archived_ledger.is_file():
571
+ return
572
+ with closing(sqlite3.connect(archived_ledger.resolve().as_uri() + "?mode=ro", uri=True)) as old:
573
+ for table in ("source_erasures", "source_erasure_revisions"):
574
+ if not old.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)).fetchone():
575
+ continue
576
+ columns = [row[1] for row in old.execute(f"PRAGMA table_info({table})")]
577
+ selected = ",".join('"' + column.replace('"', '""') + '"' for column in columns)
578
+ for row in old.execute(f"SELECT {selected} FROM {table}"):
579
+ sequence = row[columns.index("sequence")]
580
+ current_row = current.execute(
581
+ f"SELECT {selected} FROM {table} WHERE sequence=?", (sequence,)
582
+ ).fetchone()
583
+ if current_row != row:
584
+ raise ValueError("Surviving source erasure history does not extend the archive")
585
+
586
+
587
+ # ── Database snapshot ────────────────────────────────────────────────────
588
+
589
+
590
+ def _snapshot_databases(
591
+ state_dir: Path, dest: Path,
592
+ ) -> list[dict[str, str]]:
593
+ """Snapshot top-level databases and the private action ledger."""
594
+ dest.mkdir(parents=True, exist_ok=True)
595
+ manifest: list[dict[str, str]] = []
596
+
597
+ db_paths = list(sorted(state_dir.glob("*.db")))
598
+ governed_ledger = state_dir / _GOVERNED_ACTION_LEDGER
599
+ try:
600
+ governed_ledger.lstat()
601
+ except FileNotFoundError:
602
+ pass
603
+ else:
604
+ _attest_private_governed_ledger(governed_ledger)
605
+ db_paths.append(governed_ledger)
606
+
607
+ for db_path in db_paths:
608
+ relative = db_path.relative_to(state_dir)
609
+ relative_name = relative.as_posix()
610
+ snap_path = dest / relative
611
+ snap_path.parent.mkdir(parents=True, exist_ok=True)
612
+ try:
613
+ conn = sqlite3.connect(str(db_path))
614
+ try:
615
+ escaped_snap_path = str(snap_path).replace("'", "''")
616
+ conn.execute(f"VACUUM INTO '{escaped_snap_path}'")
617
+ finally:
618
+ conn.close()
619
+ if relative == _GOVERNED_ACTION_LEDGER:
620
+ snap_path.parent.chmod(0o700)
621
+ snap_path.chmod(0o600)
622
+ manifest.append({
623
+ "filename": relative_name,
624
+ "size_bytes": str(snap_path.stat().st_size),
625
+ })
626
+ logger.info("Snapshotted database: %s", relative_name)
627
+ except Exception as exc:
628
+ if relative == _GOVERNED_ACTION_LEDGER:
629
+ try:
630
+ snap_path.unlink(missing_ok=True)
631
+ except OSError:
632
+ logger.error(
633
+ "Failed to remove incomplete governed action ledger snapshot",
634
+ exc_info=True,
635
+ )
636
+ raise RuntimeError(
637
+ "governed action ledger could not be snapshotted consistently"
638
+ ) from exc
639
+ logger.warning("VACUUM snapshot failed for %s; trying SQLite backup", relative_name)
640
+ try:
641
+ # Copying only the main file can lose committed WAL records.
642
+ snap_path.unlink(missing_ok=True)
643
+ with closing(sqlite3.connect(db_path.resolve().as_uri() + '?mode=ro', uri=True)) as source:
644
+ with closing(sqlite3.connect(snap_path)) as target:
645
+ source.backup(target)
646
+ manifest.append({
647
+ "filename": relative_name,
648
+ "size_bytes": str(snap_path.stat().st_size),
649
+ "method": "sqlite_backup",
650
+ })
651
+ except Exception as exc2:
652
+ raise RuntimeError(f"Database could not be snapshotted consistently: {relative_name}") from exc2
653
+
654
+ return manifest
655
+
656
+
657
+ def _source_image_records(root: Path) -> list[dict]:
658
+ """Read asset ownership from the captured database, never a later live view."""
659
+ ledger = root / 'databases' / 'turn-idempotency.db'
660
+ if not ledger.is_file():
661
+ return []
662
+ conn = sqlite3.connect(ledger.resolve().as_uri() + '?mode=ro', uri=True)
663
+ try:
664
+ conn.row_factory = sqlite3.Row
665
+ if not conn.execute("SELECT 1 FROM sqlite_master WHERE name='source_media'").fetchone():
666
+ return []
667
+ rows = conn.execute('''SELECT DISTINCT m.asset_hash,m.mime_type,m.size_bytes
668
+ FROM source_media m JOIN source_media_links l ON l.asset_hash=m.asset_hash
669
+ JOIN turn_sources s ON s.turn_id=l.turn_id WHERE m.status != 'orphan' ''').fetchall()
670
+ records = [dict(row) for row in rows]
671
+ for row in records:
672
+ if not re.fullmatch('[0-9a-f]{64}', row['asset_hash']):
673
+ raise ValueError('Invalid original source image identifier')
674
+ return records
675
+ finally:
676
+ conn.close()
677
+
678
+
679
+ def _verified_source_images(root: Path) -> list[tuple[dict, Path]]:
680
+ from apsimo.vector.image_store import LocalImageStore
681
+ images = LocalImageStore(str(root), source_evidence=True)
682
+ result = []
683
+ for record in _source_image_records(root):
684
+ path = images._original_path(record['asset_hash'], record['mime_type'])
685
+ try:
686
+ data = path.read_bytes()
687
+ except OSError as exc:
688
+ raise ValueError('Backup is missing an original source image') from exc
689
+ if len(data) != record['size_bytes'] or hashlib.sha256(data).hexdigest() != record['asset_hash']:
690
+ raise ValueError('Backup original source image does not match its evidence hash')
691
+ result.append((record, path))
692
+ return result
693
+
694
+
695
+ def _snapshot_source_images(state_dir: Path, staging: Path) -> int:
696
+ from apsimo.vector.image_store import LocalImageStore
697
+ source = LocalImageStore(str(state_dir), source_evidence=True)
698
+ target = LocalImageStore(str(staging), source_evidence=True)
699
+ records = _source_image_records(staging)
700
+ if records:
701
+ target._ensure_dirs()
702
+ for record in records:
703
+ try:
704
+ shutil.copyfile(source._original_path(record['asset_hash'], record['mime_type']),
705
+ target._original_path(record['asset_hash'], record['mime_type']))
706
+ except OSError as exc:
707
+ # Concurrent erasure can remove an original after the DB snapshot.
708
+ # Retry the backup later instead of publishing a broken archive.
709
+ raise RuntimeError('Original source image changed during backup; retry') from exc
710
+ return len(_verified_source_images(staging))
711
+
712
+
713
+ # ── Identity snapshot ────────────────────────────────────────────────────
714
+
715
+
716
+ def _snapshot_identity(state_dir: Path, dest: Path) -> None:
717
+ dest.mkdir(parents=True, exist_ok=True)
718
+ identity_files = ["colony-id", "genesis.json"]
719
+ keys_dir = state_dir / "colony-keys"
720
+
721
+ for f in identity_files:
722
+ src = state_dir / f
723
+ if src.exists():
724
+ shutil.copy2(src, dest / f)
725
+
726
+ if keys_dir.is_dir():
727
+ dest_keys = dest / "colony-keys"
728
+ dest_keys.mkdir(exist_ok=True)
729
+ for key_file in keys_dir.iterdir():
730
+ shutil.copy2(key_file, dest_keys / key_file.name)
731
+
732
+
733
+ # ── Config snapshot (with secret scrubbing) ──────────────────────────────
734
+
735
+
736
+ def _snapshot_config(state_dir: Path, dest: Path) -> None:
737
+ dest.mkdir(parents=True, exist_ok=True)
738
+ config_files = [
739
+ ".env", "channels.json", ".colony-llm-config.json",
740
+ "standing_approvals.json",
741
+ ]
742
+
743
+ for name in config_files:
744
+ src = state_dir / name
745
+ if not src.exists():
746
+ src = state_dir.parent / name
747
+ if src.exists():
748
+ if name == ".env":
749
+ scrubbed = _scrub_env_file(src.read_text())
750
+ (dest / name).write_text(scrubbed)
751
+ else:
752
+ shutil.copy2(src, dest / name)
753
+
754
+
755
+ def _scrub_env_file(content: str) -> str:
756
+ lines = []
757
+ for line in content.splitlines():
758
+ stripped = line.strip()
759
+ if not stripped or stripped.startswith("#"):
760
+ lines.append(line)
761
+ continue
762
+ if "=" in stripped:
763
+ key, _, _ = stripped.partition("=")
764
+ if _SECRET_KEY_PATTERN.search(key.strip()):
765
+ lines.append(f"{key.strip()}=<REDACTED>")
766
+ continue
767
+ lines.append(line)
768
+ return "\n".join(lines) + "\n"
769
+
770
+
771
+ # ── Vector store snapshot ────────────────────────────────────────────────
772
+
773
+
774
+ def _snapshot_vectors(state_dir: Path, dest: Path) -> None:
775
+ lance_dir = state_dir / "lancedb"
776
+ if not lance_dir.is_dir():
777
+ return
778
+ dest_lance = dest / "lancedb"
779
+ shutil.copytree(lance_dir, dest_lance)
780
+ logger.info("Snapshotted LanceDB directory")
781
+
782
+
783
+ # ── Graph export ─────────────────────────────────────────────────────────
784
+
785
+
786
+ def _snapshot_graph(state_dir: Path, dest: Path) -> str:
787
+ """Export Neo4j graph as Cypher. Returns status string."""
788
+ dest.mkdir(parents=True, exist_ok=True)
789
+
790
+ neo4j_uri = os.environ.get("NEO4J_URI", "")
791
+ if not neo4j_uri:
792
+ (dest / "SKIPPED.txt").write_text(
793
+ "Neo4j not configured (NEO4J_URI not set)\n"
794
+ )
795
+ return "skipped_not_configured"
796
+
797
+ try:
798
+ from neo4j import GraphDatabase
799
+
800
+ neo4j_user = os.environ.get("NEO4J_USER", "neo4j")
801
+ neo4j_pass = os.environ.get("NEO4J_PASSWORD", "")
802
+
803
+ driver = GraphDatabase.driver(
804
+ neo4j_uri, auth=(neo4j_user, neo4j_pass)
805
+ )
806
+
807
+ cypher_stmts: list[str] = []
808
+ with driver.session() as session:
809
+ nodes = session.run("MATCH (n) RETURN n")
810
+ for record in nodes:
811
+ node = record["n"]
812
+ labels = ":".join(node.labels)
813
+ props = json.dumps(dict(node), default=str)
814
+ cypher_stmts.append(
815
+ f"CREATE (:{labels} {props});"
816
+ )
817
+
818
+ rels = session.run(
819
+ "MATCH (a)-[r]->(b) "
820
+ "RETURN id(a) as a_id, type(r) as rel_type, "
821
+ "properties(r) as props, id(b) as b_id"
822
+ )
823
+ for record in rels:
824
+ props = json.dumps(record["props"], default=str)
825
+ cypher_stmts.append(
826
+ f"// REL: ({record['a_id']})-[:{record['rel_type']} {props}]->({record['b_id']})"
827
+ )
828
+
829
+ driver.close()
830
+
831
+ output = dest / "neo4j-dump.cypher"
832
+ output.write_text("\n".join(cypher_stmts) + "\n")
833
+ logger.info("Exported %d graph statements", len(cypher_stmts))
834
+ return "exported"
835
+
836
+ except ImportError:
837
+ (dest / "SKIPPED.txt").write_text("neo4j driver not installed\n")
838
+ return "skipped_no_driver"
839
+ except Exception as exc:
840
+ logger.warning("Neo4j export failed: %s", exc)
841
+ (dest / "SKIPPED.txt").write_text(f"Export failed: {exc}\n")
842
+ return "skipped_error"
843
+
844
+
845
+ # ── Host state ───────────────────────────────────────────────────────────
846
+
847
+
848
+ def _snapshot_host_state(paths: list[str], dest: Path) -> None:
849
+ dest.mkdir(parents=True, exist_ok=True)
850
+ for p in paths:
851
+ src = Path(p).expanduser()
852
+ if src.is_file():
853
+ shutil.copy2(src, dest / src.name)
854
+ elif src.is_dir():
855
+ shutil.copytree(src, dest / src.name, dirs_exist_ok=True)
856
+
857
+
858
+ # ── Archive creation / extraction ────────────────────────────────────────
859
+
860
+
861
+ def _create_archive(source_dir: Path, archive_path: Path) -> None:
862
+ with tarfile.open(archive_path, "w:gz") as tar:
863
+ tar.add(source_dir, arcname=source_dir.name)
864
+
865
+
866
+ def _extract_archive(archive_path: Path, dest: Path) -> None:
867
+ with tarfile.open(archive_path, "r:gz") as tar:
868
+ tar.extractall(dest, filter="data")
869
+
870
+
871
+ def _find_backup_root(staging: Path) -> Path:
872
+ """Find the root directory inside the extracted archive."""
873
+ entries = list(staging.iterdir())
874
+ if len(entries) == 1 and entries[0].is_dir():
875
+ return entries[0]
876
+ return staging
877
+
878
+
879
+ # ── Encryption (AES-256-GCM) ────────────────────────────────────────────
880
+
881
+
882
+ def _encrypt_file(
883
+ src: Path, dest: Path, passphrase: bytes,
884
+ ) -> None:
885
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
886
+ from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
887
+
888
+ salt = os.urandom(16)
889
+ kdf = Scrypt(salt=salt, length=32, n=2**17, r=8, p=1)
890
+ key = kdf.derive(passphrase)
891
+
892
+ nonce = os.urandom(12)
893
+ aesgcm = AESGCM(key)
894
+ plaintext = src.read_bytes()
895
+ ciphertext = aesgcm.encrypt(nonce, plaintext, None)
896
+
897
+ with dest.open("wb") as f:
898
+ f.write(b"COLONY_ENC_V1\n")
899
+ f.write(salt)
900
+ f.write(nonce)
901
+ f.write(ciphertext)
902
+
903
+
904
+ def _decrypt_file(
905
+ src: Path, dest: Path, passphrase: bytes,
906
+ ) -> None:
907
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
908
+ from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
909
+
910
+ data = src.read_bytes()
911
+ header = b"COLONY_ENC_V1\n"
912
+ if not data.startswith(header):
913
+ raise ValueError("Not a Colony encrypted backup (invalid header)")
914
+
915
+ offset = len(header)
916
+ salt = data[offset:offset + 16]
917
+ nonce = data[offset + 16:offset + 28]
918
+ ciphertext = data[offset + 28:]
919
+
920
+ kdf = Scrypt(salt=salt, length=32, n=2**17, r=8, p=1)
921
+ key = kdf.derive(passphrase)
922
+
923
+ aesgcm = AESGCM(key)
924
+ try:
925
+ plaintext = aesgcm.decrypt(nonce, ciphertext, None)
926
+ except Exception:
927
+ raise ValueError("Decryption failed -- wrong passphrase?")
928
+
929
+ dest.write_bytes(plaintext)
930
+
931
+
932
+ # ── Helpers ──────────────────────────────────────────────────────────────
933
+
934
+
935
+ def _read_colony_id(state_dir: Path) -> str:
936
+ id_path = state_dir / "colony-id"
937
+ if id_path.exists():
938
+ return id_path.read_text().strip()
939
+ return ""
940
+
941
+
942
+ def _compute_identity_hmac(colony_id: str) -> str:
943
+ return hmac.new(
944
+ colony_id.encode(), b"colony-backup-binding", hashlib.sha256
945
+ ).hexdigest()
946
+
947
+
948
+ def _get_colony_version() -> str:
949
+ try:
950
+ from importlib.metadata import version
951
+ return version("colonyai")
952
+ except Exception:
953
+ return "unknown"
954
+
955
+
956
+ def _restore_directory(src: Path, dest: Path) -> None:
957
+ for item in src.rglob("*"):
958
+ if item.is_file():
959
+ rel = item.relative_to(src)
960
+ target = dest / rel
961
+ target.parent.mkdir(parents=True, exist_ok=True)
962
+ shutil.copy2(item, target)