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,863 @@
1
+ """Durable append-only event journal for Colony.
2
+
3
+ Every externally visible host event is persisted before it is offered to the
4
+ live WebSocket stream. Events remain file-per-record for compatibility with
5
+ existing operators and recovery tooling. A small cursor plus a per-sequence
6
+ prune index make the steady-state append path constant-time; a directory scan
7
+ is only needed when migrating or repairing missing cursor metadata.
8
+
9
+ Sequence allocation is protected by both an in-process lock and ``flock`` so
10
+ threads and sidecar processes sharing a state directory cannot allocate the
11
+ same sequence. The cursor is advanced before the event file is written. A
12
+ crash may therefore leave a sequence gap, but can never cause a sequence to be
13
+ reused or a live event to precede its durable record.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import json
20
+ import logging
21
+ import os
22
+ import re
23
+ import threading
24
+ import uuid
25
+ from contextlib import contextmanager
26
+ from datetime import datetime, timezone
27
+ from pathlib import Path
28
+ from typing import Any, Dict, Iterator, List, Optional
29
+
30
+ try: # Colony is deployed on Linux; retain a safe process-local fallback.
31
+ import fcntl
32
+ except ImportError: # pragma: no cover - exercised only on non-POSIX hosts
33
+ fcntl = None # type: ignore[assignment]
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ _CURSOR_FILENAME = ".cursor"
38
+ _LOCK_FILENAME = ".lock"
39
+ _INDEX_DIRNAME = ".sequence-index"
40
+ _EVENT_KEY_DIRNAME = ".event-keys"
41
+ _PROCESS_LOCK = threading.RLock()
42
+
43
+
44
+ def _state_dir() -> Path:
45
+ return Path(os.environ.get("COLONY_STATE_DIR", ".")).resolve()
46
+
47
+
48
+ def _journal_dir() -> Path:
49
+ configured = os.environ.get("COLONY_EVENT_JOURNAL_DIR", "").strip()
50
+ return Path(configured).resolve() if configured else _state_dir() / "events"
51
+
52
+
53
+ def _retention() -> int:
54
+ try:
55
+ return max(1, int(os.environ.get("COLONY_EVENT_JOURNAL_RETENTION", "500")))
56
+ except ValueError:
57
+ return 500
58
+
59
+
60
+ def _format_seq(seq: int) -> str:
61
+ return str(seq).zfill(6)
62
+
63
+
64
+ def _estimate_tokens(text: str) -> int:
65
+ """Rough token estimate retained for callers of the legacy helper."""
66
+ return len(text.split())
67
+
68
+
69
+ def _fsync_directory(directory: Path) -> None:
70
+ """Best-effort directory fsync so a rename survives a host crash."""
71
+ try:
72
+ fd = os.open(str(directory), os.O_RDONLY)
73
+ except OSError:
74
+ return
75
+ try:
76
+ os.fsync(fd)
77
+ except OSError:
78
+ pass
79
+ finally:
80
+ os.close(fd)
81
+
82
+
83
+ def _atomic_write(path: Path, content: str) -> None:
84
+ """Durably replace ``path`` without exposing a partial record.
85
+
86
+ The temporary filename is unique. The previous implementation used one
87
+ shared ``.tmp`` name, which allowed concurrent appenders to overwrite one
88
+ another before rename.
89
+ """
90
+ path.parent.mkdir(parents=True, exist_ok=True)
91
+ tmp = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
92
+ fd: Optional[int] = None
93
+ try:
94
+ fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
95
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
96
+ fd = None # fdopen owns it now
97
+ handle.write(content)
98
+ handle.flush()
99
+ os.fsync(handle.fileno())
100
+ os.replace(tmp, path)
101
+ _fsync_directory(path.parent)
102
+ except Exception:
103
+ if fd is not None:
104
+ try:
105
+ os.close(fd)
106
+ except OSError:
107
+ pass
108
+ try:
109
+ tmp.unlink()
110
+ except OSError:
111
+ pass
112
+ raise
113
+
114
+
115
+ def _checksum(payload: str) -> str:
116
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
117
+
118
+
119
+ def _valid_record_checksum(raw: Dict[str, Any]) -> bool:
120
+ """Verify new canonical and legacy journal checksums.
121
+
122
+ Very early/manual journal files had no checksum, so absence remains a
123
+ compatibility case. A present but incorrect checksum is corruption.
124
+ """
125
+ expected = raw.get("checksum")
126
+ if not expected:
127
+ return True
128
+ unsigned = {key: value for key, value in raw.items() if key != "checksum"}
129
+ canonical = json.dumps(
130
+ unsigned,
131
+ ensure_ascii=False,
132
+ separators=(",", ":"),
133
+ sort_keys=True,
134
+ ) + "\n"
135
+ if _checksum(canonical) == expected:
136
+ return True
137
+ legacy = json.dumps(unsigned, ensure_ascii=False) + "\n"
138
+ return _checksum(legacy) == expected
139
+
140
+
141
+ @contextmanager
142
+ def _journal_lock(journal_dir: Path) -> Iterator[None]:
143
+ """Serialize cursor allocation and journal mutation across processes."""
144
+ journal_dir.mkdir(parents=True, exist_ok=True)
145
+ with _PROCESS_LOCK:
146
+ with (journal_dir / _LOCK_FILENAME).open("a+b") as lock_file:
147
+ if fcntl is not None:
148
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
149
+ try:
150
+ yield
151
+ finally:
152
+ if fcntl is not None:
153
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
154
+
155
+
156
+ def _event_file_sequence(path: Path) -> Optional[int]:
157
+ prefix = path.name.split(".", 1)[0]
158
+ return int(prefix) if prefix.isdigit() else None
159
+
160
+
161
+ def _event_files(journal_dir: Path) -> list[tuple[int, Path]]:
162
+ records: list[tuple[int, Path]] = []
163
+ for path in journal_dir.glob("*.json"):
164
+ seq = _event_file_sequence(path)
165
+ if seq is not None:
166
+ records.append((seq, path))
167
+ records.sort(key=lambda item: (item[0], item[1].name))
168
+ return records
169
+
170
+
171
+ def _cursor_path(journal_dir: Path) -> Path:
172
+ return journal_dir / _CURSOR_FILENAME
173
+
174
+
175
+ def _index_dir(journal_dir: Path) -> Path:
176
+ return journal_dir / _INDEX_DIRNAME
177
+
178
+
179
+ def _event_key_path(journal_dir: Path, event_key: str) -> Path:
180
+ return journal_dir / _EVENT_KEY_DIRNAME / _event_key_digest(event_key)
181
+
182
+
183
+ def _event_key_request_digest(
184
+ event_type: str,
185
+ data: Dict[str, Any],
186
+ occurred_at: Optional[str],
187
+ ) -> str:
188
+ return _checksum(json.dumps(
189
+ {
190
+ "type": event_type,
191
+ "data": data,
192
+ "occurredAt": occurred_at or "",
193
+ },
194
+ ensure_ascii=False,
195
+ separators=(",", ":"),
196
+ sort_keys=True,
197
+ ))
198
+
199
+
200
+ def event_record_request_digest(
201
+ event_type: str,
202
+ data: Dict[str, Any],
203
+ occurred_at: Optional[str],
204
+ ) -> str:
205
+ """Return the immutable keyed-append request identity for a caller."""
206
+
207
+ return _event_key_request_digest(event_type, data, occurred_at)
208
+
209
+
210
+ def _event_key_digest(event_key: str) -> str:
211
+ return hashlib.sha256(event_key.encode("utf-8")).hexdigest()
212
+
213
+
214
+ def _write_cursor(journal_dir: Path, last_seq: int, pruned_through: int) -> None:
215
+ payload = json.dumps(
216
+ {"lastSeq": last_seq, "prunedThrough": pruned_through},
217
+ separators=(",", ":"),
218
+ sort_keys=True,
219
+ ) + "\n"
220
+ _atomic_write(_cursor_path(journal_dir), payload)
221
+
222
+
223
+ def _write_index_entry(journal_dir: Path, seq: int, filenames: list[str]) -> None:
224
+ payload = json.dumps(filenames, separators=(",", ":")) + "\n"
225
+ _atomic_write(_index_dir(journal_dir) / _format_seq(seq), payload)
226
+
227
+
228
+ def _rebuild_cursor(journal_dir: Path) -> tuple[int, int]:
229
+ """One-time migration/repair scan for legacy file-only journals."""
230
+ files = _event_files(journal_dir)
231
+ by_seq: dict[int, list[str]] = {}
232
+ for seq, path in files:
233
+ by_seq.setdefault(seq, []).append(path.name)
234
+ try:
235
+ raw = json.loads(path.read_text(encoding="utf-8"))
236
+ marker_digest = str(raw.get("eventKeyDigest") or "")
237
+ if re.fullmatch(r"[a-f0-9]{64}", marker_digest):
238
+ by_seq[seq].append(f"@event-key:{marker_digest}")
239
+ except (OSError, ValueError, TypeError, json.JSONDecodeError):
240
+ pass
241
+ for seq, filenames in by_seq.items():
242
+ _write_index_entry(journal_dir, seq, filenames)
243
+
244
+ last_seq = files[-1][0] if files else 0
245
+ pruned_through = (files[0][0] - 1) if files else 0
246
+ _write_cursor(journal_dir, last_seq, pruned_through)
247
+ return last_seq, pruned_through
248
+
249
+
250
+ def _load_cursor(journal_dir: Path) -> tuple[int, int]:
251
+ try:
252
+ raw = json.loads(_cursor_path(journal_dir).read_text(encoding="utf-8"))
253
+ last_seq = int(raw["lastSeq"])
254
+ pruned_through = int(raw.get("prunedThrough", 0))
255
+ # Legacy test/development journals may contain sequence zero, whose
256
+ # natural predecessor is -1. Newly allocated production sequences
257
+ # still begin at one.
258
+ if last_seq < 0 or pruned_through < -1 or pruned_through > last_seq:
259
+ raise ValueError("invalid journal cursor")
260
+ return last_seq, pruned_through
261
+ except (OSError, ValueError, TypeError, KeyError, json.JSONDecodeError):
262
+ logger.info("Rebuilding event-journal cursor from existing records")
263
+ return _rebuild_cursor(journal_dir)
264
+
265
+
266
+ def _read_index_entry(path: Path) -> list[str]:
267
+ try:
268
+ value = json.loads(path.read_text(encoding="utf-8"))
269
+ if isinstance(value, list):
270
+ return [str(item) for item in value]
271
+ except (OSError, ValueError, TypeError, json.JSONDecodeError):
272
+ pass
273
+ return []
274
+
275
+
276
+ def _prune_indexed_events(
277
+ journal_dir: Path,
278
+ current_seq: int,
279
+ keep: int,
280
+ pruned_through: int,
281
+ ) -> int:
282
+ """Prune expired sequences without scanning the journal in steady state."""
283
+ cutoff = current_seq - max(1, keep)
284
+ if cutoff <= pruned_through:
285
+ return pruned_through
286
+
287
+ index_dir = _index_dir(journal_dir)
288
+ for seq in range(pruned_through + 1, cutoff + 1):
289
+ index_path = index_dir / _format_seq(seq)
290
+ filenames = _read_index_entry(index_path)
291
+ prune_ok = True
292
+ needs_fallback = not filenames
293
+ if filenames:
294
+ for filename in filenames:
295
+ if filename.startswith("@event-key:"):
296
+ marker_digest = filename.removeprefix("@event-key:")
297
+ if not re.fullmatch(r"[a-f0-9]{64}", marker_digest):
298
+ logger.warning(
299
+ "Ignoring unsafe event-key index entry %r", filename,
300
+ )
301
+ continue
302
+ if not prune_ok:
303
+ # The event file remains. Its marker was changed to
304
+ # pruning, so keyed replay cannot duplicate it while a
305
+ # later retention pass retries deletion.
306
+ continue
307
+ marker_path = journal_dir / _EVENT_KEY_DIRNAME / marker_digest
308
+ try:
309
+ marker = json.loads(
310
+ marker_path.read_text(encoding="utf-8")
311
+ )
312
+ except FileNotFoundError:
313
+ continue
314
+ except (OSError, ValueError, TypeError, json.JSONDecodeError):
315
+ logger.warning(
316
+ "Failed to read journal event-key marker %s",
317
+ marker_path, exc_info=True,
318
+ )
319
+ prune_ok = False
320
+ continue
321
+ # The inbox receipt is the only authority allowed to
322
+ # acknowledge this key. Retention removes event content,
323
+ # but preserves fixed-size projection metadata so an
324
+ # interrupted inbox finalization cannot resurrect it.
325
+ marker["state"] = "pruned"
326
+ marker.pop("filename", None)
327
+ try:
328
+ _atomic_write(
329
+ marker_path,
330
+ json.dumps(
331
+ marker,
332
+ ensure_ascii=False,
333
+ separators=(",", ":"),
334
+ sort_keys=True,
335
+ ) + "\n",
336
+ )
337
+ except OSError:
338
+ logger.warning(
339
+ "Failed to retain journal event-key tombstone %s",
340
+ marker_path, exc_info=True,
341
+ )
342
+ prune_ok = False
343
+ continue
344
+ # Index contents are internal, but still constrain deletion to
345
+ # a basename beneath the journal directory.
346
+ if Path(filename).name != filename:
347
+ logger.warning("Ignoring unsafe journal index entry %r", filename)
348
+ needs_fallback = True
349
+ continue
350
+ marker_path: Optional[Path] = None
351
+ event_path = journal_dir / filename
352
+ try:
353
+ raw = json.loads(event_path.read_text(encoding="utf-8"))
354
+ marker_digest = str(raw.get("eventKeyDigest") or "")
355
+ if re.fullmatch(r"[a-f0-9]{64}", marker_digest):
356
+ marker_path = (
357
+ journal_dir / _EVENT_KEY_DIRNAME / marker_digest
358
+ )
359
+ if marker_path.exists():
360
+ marker = json.loads(
361
+ marker_path.read_text(encoding="utf-8")
362
+ )
363
+ marker["state"] = "pruning"
364
+ _atomic_write(
365
+ marker_path,
366
+ json.dumps(
367
+ marker,
368
+ ensure_ascii=False,
369
+ separators=(",", ":"),
370
+ sort_keys=True,
371
+ ) + "\n",
372
+ )
373
+ except (OSError, ValueError, TypeError, json.JSONDecodeError):
374
+ marker_path = None
375
+ try:
376
+ event_path.unlink()
377
+ except FileNotFoundError:
378
+ pass
379
+ except OSError:
380
+ logger.warning("Failed to prune journal record %s", filename,
381
+ exc_info=True)
382
+ prune_ok = False
383
+ if needs_fallback:
384
+ # Exceptional repair path for an interrupted legacy migration.
385
+ for path in journal_dir.glob(f"{_format_seq(seq)}.*.json"):
386
+ try:
387
+ path.unlink()
388
+ except OSError:
389
+ logger.warning("Failed to prune journal record %s", path,
390
+ exc_info=True)
391
+ prune_ok = False
392
+ if not prune_ok:
393
+ # Keep the cursor immediately before the failed sequence so a
394
+ # later append retries instead of silently abandoning retention.
395
+ return seq - 1
396
+ try:
397
+ index_path.unlink()
398
+ except FileNotFoundError:
399
+ pass
400
+ except OSError:
401
+ logger.warning("Failed to remove journal index %s", index_path,
402
+ exc_info=True)
403
+ return seq - 1
404
+ return cutoff
405
+
406
+
407
+ def append_event_record(
408
+ event_type: str,
409
+ data: Dict[str, Any],
410
+ *,
411
+ occurred_at: Optional[str] = None,
412
+ event_key: Optional[str] = None,
413
+ ) -> Optional[Dict[str, Any]]:
414
+ """Durably append an event and return its canonical journal record.
415
+
416
+ ``event_key`` enables a crash-recoverable exactly-once append. Its marker
417
+ is written after sequence reservation but before the event file, so a
418
+ retry either returns or finishes the same canonical record.
419
+
420
+ ``None`` means the event was not made durable and therefore must not be
421
+ published to the live stream. Sequence gaps are intentionally permitted
422
+ after an interrupted or failed write; sequence reuse is not.
423
+ """
424
+ journal_dir = _journal_dir()
425
+ try:
426
+ with _journal_lock(journal_dir):
427
+ normalized_key = str(event_key or "").strip()
428
+ if len(normalized_key) > 256:
429
+ raise ValueError("event_key exceeds 256 characters")
430
+ request_digest = (
431
+ _event_key_request_digest(event_type, data, occurred_at)
432
+ if normalized_key else ""
433
+ )
434
+ marker_path = (
435
+ _event_key_path(journal_dir, normalized_key)
436
+ if normalized_key else None
437
+ )
438
+ last_seq, pruned_through = _load_cursor(journal_dir)
439
+ if marker_path is not None and marker_path.exists():
440
+ marker = json.loads(marker_path.read_text(encoding="utf-8"))
441
+ key_digest = _event_key_digest(normalized_key)
442
+ if (
443
+ marker.get("eventKeyDigest") != key_digest
444
+ or marker.get("requestDigest") != request_digest
445
+ ):
446
+ raise ValueError("immutable event_key replay mismatch")
447
+ seq = int(marker.get("seq") or 0)
448
+ recorded_at = str(marker.get("recordedAt") or "")
449
+ event_id = str(marker.get("ulid") or "")
450
+ state = str(marker.get("state") or "")
451
+ if not seq or not recorded_at or not event_id:
452
+ raise ValueError("event_key marker metadata is invalid")
453
+ if state in {"pruning", "pruned"} or seq <= pruned_through:
454
+ # Never reconstruct event content after retention has
455
+ # started. The request digest above proves this is the same
456
+ # immutable request; only its original durable metadata is
457
+ # needed to truthfully finish the inbox transaction.
458
+ if state != "pruned":
459
+ marker["state"] = "pruned"
460
+ marker.pop("filename", None)
461
+ _atomic_write(
462
+ marker_path,
463
+ json.dumps(
464
+ marker,
465
+ ensure_ascii=False,
466
+ separators=(",", ":"),
467
+ sort_keys=True,
468
+ ) + "\n",
469
+ )
470
+ return {
471
+ "seq": seq,
472
+ "ulid": event_id,
473
+ "recordedAt": recorded_at,
474
+ "retained": False,
475
+ }
476
+ if state != "active":
477
+ raise ValueError("event_key marker state is invalid")
478
+ filename = str(marker.get("filename") or "")
479
+ if (
480
+ Path(filename).name != filename
481
+ or not filename or not recorded_at or not event_id
482
+ ):
483
+ raise ValueError("event_key marker filename is invalid")
484
+ event_payload = {
485
+ "seq": seq,
486
+ "ulid": event_id,
487
+ "type": event_type,
488
+ "recordedAt": recorded_at,
489
+ "occurredAt": occurred_at or recorded_at,
490
+ "data": data,
491
+ "eventKeyDigest": key_digest,
492
+ }
493
+ unsigned = json.dumps(
494
+ event_payload,
495
+ ensure_ascii=False,
496
+ separators=(",", ":"),
497
+ sort_keys=True,
498
+ ) + "\n"
499
+ payload_with_checksum = json.dumps(
500
+ {**event_payload, "checksum": _checksum(unsigned)},
501
+ ensure_ascii=False,
502
+ separators=(",", ":"),
503
+ sort_keys=True,
504
+ ) + "\n"
505
+ event_path = journal_dir / filename
506
+ if event_path.exists():
507
+ persisted = json.loads(event_path.read_text(encoding="utf-8"))
508
+ if (
509
+ not _valid_record_checksum(persisted)
510
+ or {
511
+ key: value for key, value in persisted.items()
512
+ if key != "checksum"
513
+ } != event_payload
514
+ ):
515
+ raise ValueError("event_key record is corrupt")
516
+ else:
517
+ _write_index_entry(
518
+ journal_dir,
519
+ int(event_payload["seq"]),
520
+ [filename, f"@event-key:{key_digest}"],
521
+ )
522
+ _atomic_write(event_path, payload_with_checksum)
523
+ return event_payload
524
+
525
+ seq = last_seq + 1
526
+
527
+ # Reserve the sequence before writing the record. A crash can burn
528
+ # a number, but cannot allow a later event to reuse it.
529
+ _write_cursor(journal_dir, seq, pruned_through)
530
+
531
+ event_id = (
532
+ hashlib.sha256(normalized_key.encode("utf-8")).hexdigest()[:26]
533
+ if normalized_key else uuid.uuid4().hex[:26]
534
+ )
535
+ recorded_at = datetime.now(timezone.utc).isoformat()
536
+ event_payload: Dict[str, Any] = {
537
+ "seq": seq,
538
+ "ulid": event_id,
539
+ "type": event_type,
540
+ "recordedAt": recorded_at,
541
+ "occurredAt": occurred_at or recorded_at,
542
+ "data": data,
543
+ }
544
+ if normalized_key:
545
+ event_payload["eventKeyDigest"] = _event_key_digest(
546
+ normalized_key
547
+ )
548
+ unsigned = json.dumps(
549
+ event_payload,
550
+ ensure_ascii=False,
551
+ separators=(",", ":"),
552
+ sort_keys=True,
553
+ ) + "\n"
554
+ payload_with_checksum = json.dumps(
555
+ {**event_payload, "checksum": _checksum(unsigned)},
556
+ ensure_ascii=False,
557
+ separators=(",", ":"),
558
+ sort_keys=True,
559
+ ) + "\n"
560
+
561
+ filename = f"{_format_seq(seq)}.{event_id}.json"
562
+ if marker_path is not None:
563
+ _atomic_write(
564
+ marker_path,
565
+ json.dumps(
566
+ {
567
+ "state": "active",
568
+ "eventKeyDigest": _event_key_digest(normalized_key),
569
+ "requestDigest": request_digest,
570
+ "filename": filename,
571
+ "seq": seq,
572
+ "ulid": event_id,
573
+ "recordedAt": recorded_at,
574
+ },
575
+ ensure_ascii=False,
576
+ separators=(",", ":"),
577
+ sort_keys=True,
578
+ ) + "\n",
579
+ )
580
+ # Write the prune pointer first. If the process dies before the
581
+ # record write, a later prune sees a harmless pointer to no file.
582
+ index_filenames = [filename]
583
+ if normalized_key:
584
+ index_filenames.append(
585
+ f"@event-key:{_event_key_digest(normalized_key)}"
586
+ )
587
+ _write_index_entry(journal_dir, seq, index_filenames)
588
+ _atomic_write(journal_dir / filename, payload_with_checksum)
589
+
590
+ new_pruned_through = _prune_indexed_events(
591
+ journal_dir, seq, _retention(), pruned_through
592
+ )
593
+ if new_pruned_through != pruned_through:
594
+ _write_cursor(journal_dir, seq, new_pruned_through)
595
+ return event_payload
596
+ except Exception:
597
+ logger.error("Failed to append event %s to journal", event_type, exc_info=True)
598
+ return None
599
+
600
+
601
+ def acknowledge_event_record(
602
+ event_key: str,
603
+ *,
604
+ expected_seq: Optional[int] = None,
605
+ expected_event_id: Optional[str] = None,
606
+ expected_recorded_at: Optional[str] = None,
607
+ expected_request_digest: Optional[str] = None,
608
+ ) -> bool:
609
+ """Release a keyed projection marker after its owning receipt commits.
610
+
611
+ The key itself is never persisted. A missing marker is already
612
+ acknowledged, making this safe to retry after a process restart.
613
+ """
614
+ journal_dir = _journal_dir()
615
+ try:
616
+ normalized_key = str(event_key or "").strip()
617
+ if not normalized_key or len(normalized_key) > 256:
618
+ raise ValueError("event_key is invalid")
619
+ expected_values = (
620
+ expected_seq,
621
+ expected_event_id,
622
+ expected_recorded_at,
623
+ expected_request_digest,
624
+ )
625
+ if any(value is not None for value in expected_values) and not all(
626
+ value is not None for value in expected_values
627
+ ):
628
+ raise ValueError("expected journal projection receipt is incomplete")
629
+ if expected_seq is not None and (
630
+ type(expected_seq) is not int or expected_seq < 1
631
+ ):
632
+ raise ValueError("expected journal sequence is invalid")
633
+ if expected_event_id is not None and (
634
+ type(expected_event_id) is not str
635
+ or not expected_event_id
636
+ or expected_event_id != expected_event_id.strip()
637
+ ):
638
+ raise ValueError("expected journal event ID is invalid")
639
+ if expected_recorded_at is not None and (
640
+ type(expected_recorded_at) is not str
641
+ or not expected_recorded_at
642
+ or expected_recorded_at != expected_recorded_at.strip()
643
+ ):
644
+ raise ValueError("expected journal recorded time is invalid")
645
+ if expected_request_digest is not None and not re.fullmatch(
646
+ r"[a-f0-9]{64}", expected_request_digest,
647
+ ):
648
+ raise ValueError("expected journal request digest is invalid")
649
+ with _journal_lock(journal_dir):
650
+ marker_path = _event_key_path(journal_dir, normalized_key)
651
+ try:
652
+ marker = json.loads(marker_path.read_text(encoding="utf-8"))
653
+ except FileNotFoundError:
654
+ return True
655
+ if marker.get("eventKeyDigest") != _event_key_digest(normalized_key):
656
+ raise ValueError("event_key marker digest mismatch")
657
+ if expected_seq is not None and (
658
+ type(marker.get("seq")) is not int
659
+ or marker.get("seq") != expected_seq
660
+ or marker.get("ulid") != expected_event_id
661
+ or marker.get("recordedAt") != expected_recorded_at
662
+ or marker.get("requestDigest") != expected_request_digest
663
+ ):
664
+ raise ValueError("event_key marker projection receipt mismatch")
665
+ marker_path.unlink()
666
+ _fsync_directory(marker_path.parent)
667
+ return True
668
+ except Exception:
669
+ logger.error("Failed to acknowledge journal event key", exc_info=True)
670
+ return False
671
+
672
+
673
+ def append_event(event_type: str, data: Dict[str, Any]) -> int:
674
+ """Compatibility wrapper returning the assigned sequence or ``-1``."""
675
+ record = append_event_record(event_type, data)
676
+ return int(record["seq"]) if record is not None else -1
677
+
678
+
679
+ def current_sequence() -> int:
680
+ """Return the journal's durable high-water sequence."""
681
+ journal_dir = _journal_dir()
682
+ try:
683
+ with _journal_lock(journal_dir):
684
+ last_seq, _ = _load_cursor(journal_dir)
685
+ return last_seq
686
+ except Exception:
687
+ logger.error("Failed to read event-journal cursor", exc_info=True)
688
+ return 0
689
+
690
+
691
+ def _prune_events(current_seq: int, keep: int) -> None:
692
+ """Compatibility helper used by older maintenance code and tests."""
693
+ journal_dir = _journal_dir()
694
+ try:
695
+ with _journal_lock(journal_dir):
696
+ last_seq, pruned_through = _load_cursor(journal_dir)
697
+ new_pruned = _prune_indexed_events(
698
+ journal_dir, current_seq, keep, pruned_through
699
+ )
700
+ if new_pruned != pruned_through:
701
+ _write_cursor(journal_dir, max(last_seq, current_seq), new_pruned)
702
+ except Exception:
703
+ logger.debug("Prune failed", exc_info=True)
704
+
705
+
706
+ def _parse_timestamp(value: str) -> Optional[datetime]:
707
+ if not value or value == "0":
708
+ return None
709
+ try:
710
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
711
+ if parsed.tzinfo is None:
712
+ parsed = parsed.replace(tzinfo=timezone.utc)
713
+ return parsed.astimezone(timezone.utc)
714
+ except (TypeError, ValueError):
715
+ return None
716
+
717
+
718
+ def replay_events(
719
+ since: str = "",
720
+ limit: int = 500,
721
+ types: Optional[List[str]] = None,
722
+ newest_first: bool = False,
723
+ *,
724
+ after_seq: Optional[int] = None,
725
+ until_seq: Optional[int] = None,
726
+ ) -> Dict[str, Any]:
727
+ """Replay a consistent journal snapshot by sequence and/or timestamp.
728
+
729
+ ``after_seq`` is the authoritative reconnect cursor when supplied. The
730
+ timestamp remains supported for older clients. ``until_seq`` lets a
731
+ WebSocket handshake replay to a captured high-water mark while live events
732
+ accumulate in its bounded subscriber buffer.
733
+ """
734
+ journal_dir = _journal_dir()
735
+ if not journal_dir.exists():
736
+ return {
737
+ "events": [], "lastSeq": 0, "hasMore": False,
738
+ "firstAvailableSeq": 0, "journalLastSeq": 0,
739
+ }
740
+
741
+ since_dt = _parse_timestamp(since)
742
+ limit = max(1, int(limit))
743
+ event_types = set(types) if types else None
744
+
745
+ try:
746
+ with _journal_lock(journal_dir):
747
+ journal_last_seq, _ = _load_cursor(journal_dir)
748
+ files = _event_files(journal_dir)
749
+ first_available = files[0][0] if files else 0
750
+
751
+ # Validate the complete retained generation before applying query
752
+ # filters or pagination. A page boundary must never hide a second
753
+ # file for an already-returned sequence (or a file whose embedded
754
+ # identity differs from its filename): cursor consumers would skip
755
+ # that record forever on their next ``after_seq`` replay.
756
+ sequence_counts: Dict[int, int] = {}
757
+ for seq, _path in files:
758
+ sequence_counts[seq] = sequence_counts.get(seq, 0) + 1
759
+ duplicate_sequences = {
760
+ seq for seq, count in sequence_counts.items() if count > 1
761
+ }
762
+ corrupt_count = sum(
763
+ count - 1 for count in sequence_counts.values() if count > 1
764
+ )
765
+ validated: list[tuple[int, Path, Dict[str, Any]]] = []
766
+ for seq, path in files:
767
+ if seq in duplicate_sequences:
768
+ # Neither file is authoritative when one sequence has more
769
+ # than one durable identity. Report the collision and
770
+ # withhold the ambiguous records from every page.
771
+ continue
772
+ try:
773
+ raw = json.loads(path.read_text(encoding="utf-8"))
774
+ except (json.JSONDecodeError, OSError):
775
+ corrupt_count += 1
776
+ continue
777
+ if not isinstance(raw, dict) or not _valid_record_checksum(raw):
778
+ corrupt_count += 1
779
+ continue
780
+ raw_sequence = raw.get("seq")
781
+ if raw_sequence is not None and (
782
+ type(raw_sequence) is not int or raw_sequence != seq
783
+ ):
784
+ corrupt_count += 1
785
+ continue
786
+ parts = path.name.split(".")
787
+ filename_event_id = parts[1] if len(parts) > 2 else ""
788
+ raw_event_id = raw.get("ulid")
789
+ if raw_event_id not in (None, "") and (
790
+ type(raw_event_id) is not str
791
+ or not filename_event_id
792
+ or raw_event_id != filename_event_id
793
+ ):
794
+ corrupt_count += 1
795
+ continue
796
+ validated.append((seq, path, raw))
797
+
798
+ ordered = (
799
+ list(reversed(validated)) if newest_first else validated
800
+ )
801
+ events: List[Dict[str, Any]] = []
802
+ has_more = False
803
+ for seq, path, raw in ordered:
804
+ if after_seq is not None and seq <= after_seq:
805
+ continue
806
+ if until_seq is not None and seq > until_seq:
807
+ continue
808
+
809
+ recorded_at = str(raw.get("recordedAt", ""))
810
+ if after_seq is None and since_dt is not None:
811
+ recorded_dt = _parse_timestamp(recorded_at)
812
+ if recorded_dt is None or recorded_dt <= since_dt:
813
+ continue
814
+ event_type = str(raw.get("type", "unknown"))
815
+ if event_types and event_type not in event_types:
816
+ continue
817
+
818
+ if len(events) >= limit:
819
+ has_more = True
820
+ break
821
+ parts = path.name.split(".")
822
+ event_id = str(raw.get("ulid") or (parts[1] if len(parts) > 2 else ""))
823
+ events.append({
824
+ "seq": seq,
825
+ "ulid": event_id,
826
+ "type": event_type,
827
+ "recordedAt": recorded_at,
828
+ "occurredAt": raw.get("occurredAt", recorded_at),
829
+ "data": raw.get("data", {}),
830
+ })
831
+
832
+ return {
833
+ "events": events,
834
+ "lastSeq": events[-1]["seq"] if events else 0,
835
+ "hasMore": has_more,
836
+ "firstAvailableSeq": first_available,
837
+ "journalLastSeq": journal_last_seq,
838
+ "corruptCount": corrupt_count,
839
+ }
840
+ except OSError:
841
+ logger.error("Failed to replay event journal", exc_info=True)
842
+ return {
843
+ "events": [], "lastSeq": 0, "hasMore": False,
844
+ "firstAvailableSeq": 0, "journalLastSeq": 0,
845
+ "replayError": "journal_unavailable",
846
+ }
847
+
848
+
849
+ def _file_is_after(
850
+ filepath: Path,
851
+ since: str,
852
+ types: Optional[List[str]] = None,
853
+ ) -> bool:
854
+ """Compatibility predicate for older callers and downstream extensions."""
855
+ try:
856
+ raw = json.loads(filepath.read_text(encoding="utf-8"))
857
+ since_dt = _parse_timestamp(since)
858
+ recorded_dt = _parse_timestamp(str(raw.get("recordedAt", "")))
859
+ if since_dt is not None and (recorded_dt is None or recorded_dt <= since_dt):
860
+ return False
861
+ return not types or raw.get("type", "unknown") in types
862
+ except Exception:
863
+ return False