mimir-agent 0.1.0__tar.gz

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 (195) hide show
  1. mimir_agent-0.1.0/.gitignore +72 -0
  2. mimir_agent-0.1.0/LICENSE +26 -0
  3. mimir_agent-0.1.0/PKG-INFO +41 -0
  4. mimir_agent-0.1.0/mimir/__init__.py +3 -0
  5. mimir_agent-0.1.0/mimir/_context.py +206 -0
  6. mimir_agent-0.1.0/mimir/_jsonl_tail.py +134 -0
  7. mimir_agent-0.1.0/mimir/_langchain_claude_code_patches.py +500 -0
  8. mimir_agent-0.1.0/mimir/_paths.py +113 -0
  9. mimir_agent-0.1.0/mimir/_streaming_dispatch.py +424 -0
  10. mimir_agent-0.1.0/mimir/_tool_helpers.py +122 -0
  11. mimir_agent-0.1.0/mimir/agent.py +2155 -0
  12. mimir_agent-0.1.0/mimir/billing.py +689 -0
  13. mimir_agent-0.1.0/mimir/bridges/__init__.py +0 -0
  14. mimir_agent-0.1.0/mimir/bridges/_attachments.py +173 -0
  15. mimir_agent-0.1.0/mimir/bridges/_directives.py +222 -0
  16. mimir_agent-0.1.0/mimir/bridges/_emoji.py +106 -0
  17. mimir_agent-0.1.0/mimir/bridges/_history.py +36 -0
  18. mimir_agent-0.1.0/mimir/bridges/base.py +118 -0
  19. mimir_agent-0.1.0/mimir/bridges/bench.py +106 -0
  20. mimir_agent-0.1.0/mimir/bridges/discord.py +931 -0
  21. mimir_agent-0.1.0/mimir/bridges/slack.py +898 -0
  22. mimir_agent-0.1.0/mimir/bridges/web_chat.py +221 -0
  23. mimir_agent-0.1.0/mimir/budget.py +470 -0
  24. mimir_agent-0.1.0/mimir/channel_registry.py +102 -0
  25. mimir_agent-0.1.0/mimir/cli.py +2521 -0
  26. mimir_agent-0.1.0/mimir/codex_auth.py +143 -0
  27. mimir_agent-0.1.0/mimir/commitments/__init__.py +36 -0
  28. mimir_agent-0.1.0/mimir/commitments/cli.py +389 -0
  29. mimir_agent-0.1.0/mimir/commitments/extractor.py +381 -0
  30. mimir_agent-0.1.0/mimir/commitments/models.py +277 -0
  31. mimir_agent-0.1.0/mimir/commitments/poller.py +255 -0
  32. mimir_agent-0.1.0/mimir/commitments/render.py +156 -0
  33. mimir_agent-0.1.0/mimir/commitments/store.py +598 -0
  34. mimir_agent-0.1.0/mimir/config.py +854 -0
  35. mimir_agent-0.1.0/mimir/core_blocks.py +166 -0
  36. mimir_agent-0.1.0/mimir/cred_rotate.py +531 -0
  37. mimir_agent-0.1.0/mimir/cred_verify.py +477 -0
  38. mimir_agent-0.1.0/mimir/dispatcher.py +200 -0
  39. mimir_agent-0.1.0/mimir/event_logger.py +217 -0
  40. mimir_agent-0.1.0/mimir/feedback.py +2049 -0
  41. mimir_agent-0.1.0/mimir/git_bootstrap.py +822 -0
  42. mimir_agent-0.1.0/mimir/git_tracking.py +537 -0
  43. mimir_agent-0.1.0/mimir/health.py +137 -0
  44. mimir_agent-0.1.0/mimir/health_probe.py +589 -0
  45. mimir_agent-0.1.0/mimir/history.py +598 -0
  46. mimir_agent-0.1.0/mimir/identities.py +424 -0
  47. mimir_agent-0.1.0/mimir/identities_populator.py +639 -0
  48. mimir_agent-0.1.0/mimir/index.py +301 -0
  49. mimir_agent-0.1.0/mimir/index_integrity.py +385 -0
  50. mimir_agent-0.1.0/mimir/jsonl_snapshot.py +166 -0
  51. mimir_agent-0.1.0/mimir/loop_detector.py +95 -0
  52. mimir_agent-0.1.0/mimir/loop_inventory.py +133 -0
  53. mimir_agent-0.1.0/mimir/loops_cmd.py +244 -0
  54. mimir_agent-0.1.0/mimir/mcp_client.py +503 -0
  55. mimir_agent-0.1.0/mimir/minimax_usage_poller.py +340 -0
  56. mimir_agent-0.1.0/mimir/model_registry.py +326 -0
  57. mimir_agent-0.1.0/mimir/models.py +244 -0
  58. mimir_agent-0.1.0/mimir/ntfy.py +222 -0
  59. mimir_agent-0.1.0/mimir/oauth_usage_poller.py +1174 -0
  60. mimir_agent-0.1.0/mimir/ops_dashboard.py +1285 -0
  61. mimir_agent-0.1.0/mimir/pollers.py +878 -0
  62. mimir_agent-0.1.0/mimir/prompt_templates/__init__.py +73 -0
  63. mimir_agent-0.1.0/mimir/prompts.py +461 -0
  64. mimir_agent-0.1.0/mimir/quota_pause.py +295 -0
  65. mimir_agent-0.1.0/mimir/rate_limits.py +589 -0
  66. mimir_agent-0.1.0/mimir/reactions.py +131 -0
  67. mimir_agent-0.1.0/mimir/readonly_backend.py +463 -0
  68. mimir_agent-0.1.0/mimir/reflection/__init__.py +0 -0
  69. mimir_agent-0.1.0/mimir/reflection/applied_audit.py +639 -0
  70. mimir_agent-0.1.0/mimir/reflection/introspection_report.py +656 -0
  71. mimir_agent-0.1.0/mimir/reflection/most_retrieved.py +87 -0
  72. mimir_agent-0.1.0/mimir/reflection/proposed_changes_health.py +197 -0
  73. mimir_agent-0.1.0/mimir/reindex.py +486 -0
  74. mimir_agent-0.1.0/mimir/saga/__init__.py +178 -0
  75. mimir_agent-0.1.0/mimir/saga/_config_io.py +903 -0
  76. mimir_agent-0.1.0/mimir/saga/_llm.py +521 -0
  77. mimir_agent-0.1.0/mimir/saga/activation.py +349 -0
  78. mimir_agent-0.1.0/mimir/saga/async_pool.py +72 -0
  79. mimir_agent-0.1.0/mimir/saga/calibration.py +156 -0
  80. mimir_agent-0.1.0/mimir/saga/client.py +1956 -0
  81. mimir_agent-0.1.0/mimir/saga/cluster.py +151 -0
  82. mimir_agent-0.1.0/mimir/saga/config.py +210 -0
  83. mimir_agent-0.1.0/mimir/saga/consolidate.py +287 -0
  84. mimir_agent-0.1.0/mimir/saga/contributions.py +210 -0
  85. mimir_agent-0.1.0/mimir/saga/dedup.py +708 -0
  86. mimir_agent-0.1.0/mimir/saga/embeddings.py +467 -0
  87. mimir_agent-0.1.0/mimir/saga/forget.py +229 -0
  88. mimir_agent-0.1.0/mimir/saga/fts.py +240 -0
  89. mimir_agent-0.1.0/mimir/saga/mark_access.py +154 -0
  90. mimir_agent-0.1.0/mimir/saga/migrate.py +584 -0
  91. mimir_agent-0.1.0/mimir/saga/observations.py +311 -0
  92. mimir_agent-0.1.0/mimir/saga/query_rewrite.py +182 -0
  93. mimir_agent-0.1.0/mimir/saga/recall.py +614 -0
  94. mimir_agent-0.1.0/mimir/saga/reflect.py +369 -0
  95. mimir_agent-0.1.0/mimir/saga/retrieval_fusion.py +67 -0
  96. mimir_agent-0.1.0/mimir/saga/store.py +298 -0
  97. mimir_agent-0.1.0/mimir/saga/synthesize.py +701 -0
  98. mimir_agent-0.1.0/mimir/saga/triples.py +674 -0
  99. mimir_agent-0.1.0/mimir/saga/vector_index.py +304 -0
  100. mimir_agent-0.1.0/mimir/saga_client.py +832 -0
  101. mimir_agent-0.1.0/mimir/sagatools.py +177 -0
  102. mimir_agent-0.1.0/mimir/scaffold_docker.py +758 -0
  103. mimir_agent-0.1.0/mimir/scheduler.py +1822 -0
  104. mimir_agent-0.1.0/mimir/search.py +873 -0
  105. mimir_agent-0.1.0/mimir/server.py +1017 -0
  106. mimir_agent-0.1.0/mimir/session_boundary_log.py +330 -0
  107. mimir_agent-0.1.0/mimir/session_manager.py +333 -0
  108. mimir_agent-0.1.0/mimir/shell_jobs.py +504 -0
  109. mimir_agent-0.1.0/mimir/skill_catalog.py +234 -0
  110. mimir_agent-0.1.0/mimir/skill_defs.py +240 -0
  111. mimir_agent-0.1.0/mimir/skill_install.py +402 -0
  112. mimir_agent-0.1.0/mimir/skill_md.py +160 -0
  113. mimir_agent-0.1.0/mimir/skill_outcomes.py +718 -0
  114. mimir_agent-0.1.0/mimir/skill_resolver.py +155 -0
  115. mimir_agent-0.1.0/mimir/skills/__init__.py +0 -0
  116. mimir_agent-0.1.0/mimir/skills/alert/SKILL.md +70 -0
  117. mimir_agent-0.1.0/mimir/skills/async-tasks/SKILL.md +219 -0
  118. mimir_agent-0.1.0/mimir/skills/chainlink/SKILL.md +429 -0
  119. mimir_agent-0.1.0/mimir/skills/circuit-breaker/SKILL.md +133 -0
  120. mimir_agent-0.1.0/mimir/skills/commitments/SKILL.md +166 -0
  121. mimir_agent-0.1.0/mimir/skills/fallback-chains/SKILL.md +138 -0
  122. mimir_agent-0.1.0/mimir/skills/find-skills/SKILL.md +102 -0
  123. mimir_agent-0.1.0/mimir/skills/five-whys/CHAINLINK_SETUP.md +117 -0
  124. mimir_agent-0.1.0/mimir/skills/five-whys/CHAINLINK_USAGE.md +167 -0
  125. mimir_agent-0.1.0/mimir/skills/five-whys/SKILL.md +247 -0
  126. mimir_agent-0.1.0/mimir/skills/github/SKILL.md +101 -0
  127. mimir_agent-0.1.0/mimir/skills/identity-lookup/SKILL.md +210 -0
  128. mimir_agent-0.1.0/mimir/skills/introspection/SKILL.md +313 -0
  129. mimir_agent-0.1.0/mimir/skills/introspection/debugging-communication.md +222 -0
  130. mimir_agent-0.1.0/mimir/skills/introspection/debugging-drift.md +170 -0
  131. mimir_agent-0.1.0/mimir/skills/introspection/debugging-jobs.md +145 -0
  132. mimir_agent-0.1.0/mimir/skills/long-running-jobs/SKILL.md +281 -0
  133. mimir_agent-0.1.0/mimir/skills/memory/SKILL.md +281 -0
  134. mimir_agent-0.1.0/mimir/skills/memory/maintenance.md +46 -0
  135. mimir_agent-0.1.0/mimir/skills/mermaid-diagrams/SKILL.md +248 -0
  136. mimir_agent-0.1.0/mimir/skills/mermaid-diagrams/references/advanced-features.md +556 -0
  137. mimir_agent-0.1.0/mimir/skills/mermaid-diagrams/references/c4-diagrams.md +410 -0
  138. mimir_agent-0.1.0/mimir/skills/mermaid-diagrams/references/class-diagrams.md +361 -0
  139. mimir_agent-0.1.0/mimir/skills/mermaid-diagrams/references/erd-diagrams.md +510 -0
  140. mimir_agent-0.1.0/mimir/skills/mermaid-diagrams/references/flowcharts.md +450 -0
  141. mimir_agent-0.1.0/mimir/skills/mermaid-diagrams/references/sequence-diagrams.md +394 -0
  142. mimir_agent-0.1.0/mimir/skills/ntfy/SKILL.md +177 -0
  143. mimir_agent-0.1.0/mimir/skills/onboarding/SKILL.md +164 -0
  144. mimir_agent-0.1.0/mimir/skills/onboarding/establishing-goals.md +109 -0
  145. mimir_agent-0.1.0/mimir/skills/onboarding/establishing-identity.md +106 -0
  146. mimir_agent-0.1.0/mimir/skills/onboarding/establishing-schedules.md +262 -0
  147. mimir_agent-0.1.0/mimir/skills/onboarding/establishing-skills.md +76 -0
  148. mimir_agent-0.1.0/mimir/skills/pollers/SKILL.md +268 -0
  149. mimir_agent-0.1.0/mimir/skills/pollers/design-patterns.md +335 -0
  150. mimir_agent-0.1.0/mimir/skills/pollers/security.md +132 -0
  151. mimir_agent-0.1.0/mimir/skills/predictions/SKILL.md +175 -0
  152. mimir_agent-0.1.0/mimir/skills/predictions/script.py +611 -0
  153. mimir_agent-0.1.0/mimir/skills/review/SKILL.md +186 -0
  154. mimir_agent-0.1.0/mimir/skills/skill-acquisition/SKILL.md +234 -0
  155. mimir_agent-0.1.0/mimir/skills/skill-acquisition/clawhub-reference.md +198 -0
  156. mimir_agent-0.1.0/mimir/skills/skill-acquisition/skillflag-reference.md +217 -0
  157. mimir_agent-0.1.0/mimir/skills/skill-creator/SKILL.md +126 -0
  158. mimir_agent-0.1.0/mimir/skills/tmux/SKILL.md +85 -0
  159. mimir_agent-0.1.0/mimir/skills/try-harder/SKILL.md +212 -0
  160. mimir_agent-0.1.0/mimir/skills/view-attachment/SKILL.md +104 -0
  161. mimir_agent-0.1.0/mimir/skills/weather/SKILL.md +73 -0
  162. mimir_agent-0.1.0/mimir/skills/weather/get_weather.py +95 -0
  163. mimir_agent-0.1.0/mimir/skills/wiki/SKILL.md +260 -0
  164. mimir_agent-0.1.0/mimir/skills/world-scanning/SKILL.md +206 -0
  165. mimir_agent-0.1.0/mimir/stats_block.py +177 -0
  166. mimir_agent-0.1.0/mimir/subagent_defs.py +234 -0
  167. mimir_agent-0.1.0/mimir/subagent_inbox.py +106 -0
  168. mimir_agent-0.1.0/mimir/subagent_stats.py +264 -0
  169. mimir_agent-0.1.0/mimir/templates/git/gitignore +68 -0
  170. mimir_agent-0.1.0/mimir/templates/git/pre-commit +107 -0
  171. mimir_agent-0.1.0/mimir/templates.py +453 -0
  172. mimir_agent-0.1.0/mimir/token_usage_history.py +127 -0
  173. mimir_agent-0.1.0/mimir/tools/__init__.py +126 -0
  174. mimir_agent-0.1.0/mimir/tools/budget_gate.py +229 -0
  175. mimir_agent-0.1.0/mimir/tools/extra.py +329 -0
  176. mimir_agent-0.1.0/mimir/tools/mcp.py +38 -0
  177. mimir_agent-0.1.0/mimir/tools/memory.py +58 -0
  178. mimir_agent-0.1.0/mimir/tools/prohibited_action_guard.py +126 -0
  179. mimir_agent-0.1.0/mimir/tools/registry.py +1050 -0
  180. mimir_agent-0.1.0/mimir/tools/saga_ops.py +238 -0
  181. mimir_agent-0.1.0/mimir/tools/shell_async.py +220 -0
  182. mimir_agent-0.1.0/mimir/tools/store.py +83 -0
  183. mimir_agent-0.1.0/mimir/tools/web.py +518 -0
  184. mimir_agent-0.1.0/mimir/turn_hooks.py +309 -0
  185. mimir_agent-0.1.0/mimir/turn_logger.py +531 -0
  186. mimir_agent-0.1.0/mimir/turn_viewer.html +678 -0
  187. mimir_agent-0.1.0/mimir/upcoming.py +206 -0
  188. mimir_agent-0.1.0/mimir/update_on_start.py +454 -0
  189. mimir_agent-0.1.0/mimir/usage_history.py +249 -0
  190. mimir_agent-0.1.0/mimir/usage_stats.py +638 -0
  191. mimir_agent-0.1.0/mimir/version_check.py +306 -0
  192. mimir_agent-0.1.0/mimir/viability_metrics.py +623 -0
  193. mimir_agent-0.1.0/mimir/web_ui.py +173 -0
  194. mimir_agent-0.1.0/mimir/wiki_backlinks.py +490 -0
  195. mimir_agent-0.1.0/pyproject.toml +206 -0
@@ -0,0 +1,72 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ *.egg-info/
8
+ *.egg
9
+ build/
10
+ dist/
11
+ .eggs/
12
+
13
+ # Virtual envs
14
+ .venv/
15
+ venv/
16
+ env/
17
+
18
+ # Tools
19
+ .pytest_cache/
20
+ .mypy_cache/
21
+ .ruff_cache/
22
+ .tool-versions
23
+
24
+ # IDE
25
+ .vscode/
26
+ .idea/
27
+ *.iml
28
+ *.swp
29
+ *.swo
30
+
31
+ # Claude Code per-project settings (operator/host-specific permissions,
32
+ # allowlists, and hooks — not part of the package contract).
33
+ .claude/
34
+
35
+ # OS
36
+ .DS_Store
37
+ Thumbs.db
38
+
39
+ # Mimir runtime artifacts (only meaningful inside an agent home, never in the
40
+ # package source — but the smoke-test harness occasionally drops them here).
41
+ home/
42
+ *.db
43
+ *.db-shm
44
+ *.db-wal
45
+ *.tar.gz
46
+
47
+ # v0.5 §3: saga's bench data + LongMemEval source are symlinks/dirs
48
+ # pointing at large datasets that don't belong in git (msam2's .gitignore
49
+ # had the same exclusions; preserved post-merge).
50
+ /saga/data
51
+ # saga/external is now vendored (the LongMemEval eval harness lives under
52
+ # saga/external/longmemeval/ — see its PROVENANCE.md). Specific subdirs
53
+ # that are too big or transient stay ignored.
54
+ /saga/external/hindsight
55
+ /saga/results
56
+ # Integration bench output (per-run hypothesis JSONLs + scratch mimir homes).
57
+ /results/
58
+ /benchmarks/longmemeval_via_mimir/results/
59
+
60
+ # Agent home dotenv (in case a smoke run spits one out at workspace root).
61
+ .env
62
+
63
+ # Local scratch / spike / backtest artifacts. Keep WIP scripts and
64
+ # one-shot data dumps out of the repo (e.g. commitments-extraction
65
+ # backtest results, throwaway profiling output).
66
+ /scratch/
67
+
68
+ # Runtime agent state. ``state/`` is per-deployment and accumulates
69
+ # identity bindings, spec results, proposed-change drafts, wiki topics,
70
+ # etc. — none of which belongs in source control. A fresh agent home
71
+ # generates its own ``state/`` on first run.
72
+ /state/
@@ -0,0 +1,26 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jason Carreira
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ----
24
+
25
+ The saga/ subdirectory is independently MIT-licensed (combined copyright
26
+ Jaden Schwab + Jason Carreira). See saga/LICENSE.
@@ -0,0 +1,41 @@
1
+ Metadata-Version: 2.4
2
+ Name: mimir-agent
3
+ Version: 0.1.0
4
+ Summary: Memory-centric agent harness on deepagents / LangGraph
5
+ Author: Jason Carreira
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.11
9
+ Requires-Dist: aiohttp>=3.12
10
+ Requires-Dist: apscheduler>=3.11
11
+ Requires-Dist: deepagents>=0.6.1
12
+ Requires-Dist: fastembed>=0.4
13
+ Requires-Dist: langchain>=1.3
14
+ Requires-Dist: python-dotenv>=1.0
15
+ Requires-Dist: pyyaml>=6.0
16
+ Requires-Dist: requests>=2.33.1
17
+ Provides-Extra: anthropic
18
+ Requires-Dist: langchain-anthropic>=1.4; extra == 'anthropic'
19
+ Provides-Extra: bench
20
+ Requires-Dist: saga; extra == 'bench'
21
+ Provides-Extra: codex-plus
22
+ Requires-Dist: langchain-codex-plus<0.1,>=0.0.1; extra == 'codex-plus'
23
+ Provides-Extra: dev
24
+ Requires-Dist: discord-py>=2.6; extra == 'dev'
25
+ Requires-Dist: faiss-cpu>=1.13; extra == 'dev'
26
+ Requires-Dist: langchain-anthropic>=1.4; extra == 'dev'
27
+ Requires-Dist: langchain-codex-plus<0.1,>=0.0.1; extra == 'dev'
28
+ Requires-Dist: langchain-openai>=1.2.1; extra == 'dev'
29
+ Requires-Dist: mcp>=1.27; extra == 'dev'
30
+ Requires-Dist: pytest-aiohttp>=1.0; extra == 'dev'
31
+ Requires-Dist: pytest-asyncio>=1.3.0; extra == 'dev'
32
+ Requires-Dist: pytest>=9.0.3; extra == 'dev'
33
+ Requires-Dist: slack-bolt>=1.18; extra == 'dev'
34
+ Provides-Extra: discord
35
+ Requires-Dist: discord-py>=2.6; extra == 'discord'
36
+ Provides-Extra: mcp
37
+ Requires-Dist: mcp>=1.27; extra == 'mcp'
38
+ Provides-Extra: openai
39
+ Requires-Dist: langchain-openai>=1.2.1; extra == 'openai'
40
+ Provides-Extra: slack
41
+ Requires-Dist: slack-bolt>=1.18; extra == 'slack'
@@ -0,0 +1,3 @@
1
+ """Mimir — memory-centric agent harness."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,206 @@
1
+ """Per-turn ``TurnContext`` propagation via ``contextvars`` (SPEC §4.6, §9.3).
2
+
3
+ The SAGA ``saga_query`` tool needs to auto-append returned ``atom_id``s to the
4
+ parent's ``TurnContext.saga_atom_ids`` so the post-message hook can credit
5
+ mid-turn retrievals without the agent having to remember (SPEC §9.3 "mid-turn
6
+ ``saga_query`` tracking"). Tools registered with the SDK are plain functions —
7
+ they need a way to find the active turn.
8
+
9
+ ``contextvars`` are the right primitive for in-task lookups: each ``query()``
10
+ call runs in its own asyncio task, and we set the ContextVar before invoking
11
+ ``query()``. Subagent calls run in distinct tasks with distinct contexts, so a
12
+ subagent's ``saga_query`` does NOT mutate the parent's ``saga_atom_ids`` —
13
+ matching SPEC §9.3 "Subagents do not inherit the parent's ``saga_atom_ids``".
14
+
15
+ Hook callbacks (PreToolUse / PostToolUse) are dispatched on a different
16
+ task — the SDK's control-protocol task, forked at first ``client.connect()``.
17
+ That task captured the contextvar value at fork time (``None``) and never
18
+ sees subsequent ``set()`` calls in ``run_turn``, so contextvar lookups from
19
+ hooks return stale data. The ``_active_turns`` map fixes this: ``run_turn``
20
+ registers the turn under its ``turn_id``, hooks pass their incoming
21
+ ``session_id`` (which is ``ctx.turn_id`` since stage 2 of the ClaudeSDKClient
22
+ migration) to ``get_turn_by_session_id`` for a reliable lookup that doesn't
23
+ depend on task-fork inheritance.
24
+
25
+ **MCP tool dispatch hits the same pattern (chainlink #23).** Every MCP
26
+ ``tools/call`` control request lands in
27
+ ``Query._spawn_control_request_handler`` (SDK internals,
28
+ ``claude_agent_sdk/_internal/query.py:232``), which calls
29
+ ``spawn_detached`` to run the handler on a fresh asyncio task. That task
30
+ captures contextvars from the SDK's read-loop task — forked at connect
31
+ time, where ``_current_turn`` was ``None``. So the same staleness affects
32
+ MCP-dispatched tools (``saga_query``, ``saga_store``, ``saga_feedback``,
33
+ ``saga_end_session``) as PreToolUse / PostToolUse hooks.
34
+
35
+ The hook fix uses ``input_data["session_id"]`` which the SDK forwards on
36
+ every hook callback. The MCP path is **asymmetric** — the SDK only
37
+ forwards ``(server_name, mcp_message)`` to the MCP handler; per-call
38
+ session_id is dropped at the boundary. So MCP tools can't use the same
39
+ fix shape as hooks.
40
+
41
+ The two helpers below cover the lookups available to MCP tool handlers:
42
+
43
+ - ``get_turn_by_saga_session_id(saga_session_id)`` — for tools whose args
44
+ carry the saga_session_id (currently just ``saga_end_session``). Iterates
45
+ ``_active_turns`` matching ``ctx.saga_session_id``.
46
+ - ``get_only_active_turn()`` — best-effort heuristic for tools whose args
47
+ don't carry any per-turn key. Returns the single active turn if exactly
48
+ one is registered, else ``None``. Works in single-channel deployments;
49
+ multi-active cases must be surfaced via observability events rather than
50
+ silently picking one.
51
+
52
+ See ``state/spec/chainlink-23-saga-mcp-context-resolution.md`` for the
53
+ full design and the per-tool migration sequence.
54
+ """
55
+
56
+ from __future__ import annotations
57
+
58
+ from contextvars import ContextVar, Token
59
+ from typing import TYPE_CHECKING, Any
60
+
61
+ if TYPE_CHECKING:
62
+ from .models import TurnContext
63
+
64
+ _current_turn: ContextVar["TurnContext | None"] = ContextVar(
65
+ "mimir_current_turn", default=None
66
+ )
67
+
68
+ # Registry of active turns keyed by turn_id. Populated by ``run_turn``,
69
+ # read by hook callbacks that can't rely on contextvar inheritance.
70
+ _active_turns: dict[str, "TurnContext"] = {}
71
+
72
+
73
+ class _TurnCell:
74
+ """Per-client mutable holder for the currently-acquired turn id.
75
+
76
+ The SDK's hook control task is forked at first ``client.connect()``
77
+ and captures the surrounding ``ContextVar`` values at that instant.
78
+ A plain ``_current_turn`` value frozen at fork time is useless —
79
+ later turns can't update what the hook task sees.
80
+
81
+ A ``_TurnCell`` flips that property: the contextvar holds a *cell
82
+ reference*, which the hook task captures. The cell's ``turn_id``
83
+ attribute is mutable. Mimir stamps it on ``acquire`` and clears it
84
+ on ``release``; the hook reads ``cell.turn_id`` lazily and gets the
85
+ live value.
86
+
87
+ One cell per pooled client (created when the client is constructed),
88
+ so multi-channel concurrent turns each have their own cell — the
89
+ hook task on client A only sees writes from acquires of client A.
90
+ Cell reads/writes are bare attribute accesses (atomic under the GIL);
91
+ no lock needed because each cell has at most one acquire-stamping
92
+ task at a time (the pool serializes acquire of a given entry)."""
93
+
94
+ __slots__ = ("turn_id",)
95
+
96
+ def __init__(self) -> None:
97
+ self.turn_id: str | None = None
98
+
99
+
100
+ # ContextVar set by the pool before each new client's ``connect()`` so
101
+ # the SDK's forked hook task captures *this client's* cell. The cell
102
+ # stays None outside of a per-client connect.
103
+ _current_client_cell: ContextVar["_TurnCell | None"] = ContextVar(
104
+ "mimir_current_client_cell", default=None
105
+ )
106
+
107
+
108
+ def set_current_turn(ctx: "TurnContext") -> Token:
109
+ _active_turns[ctx.turn_id] = ctx
110
+ return _current_turn.set(ctx)
111
+
112
+
113
+ def reset_current_turn(token: Token) -> None:
114
+ ctx = _current_turn.get()
115
+ if ctx is not None:
116
+ _active_turns.pop(ctx.turn_id, None)
117
+ _current_turn.reset(token)
118
+
119
+
120
+ def get_current_turn() -> "TurnContext | None":
121
+ return _current_turn.get()
122
+
123
+
124
+ def get_turn_by_session_id(session_id: str | None) -> "TurnContext | None":
125
+ """Look up an active turn by its ``turn_id``. Used by hook callbacks
126
+ where contextvar inheritance is unreliable (the hook task forked at
127
+ first connect, captured contextvar=None, and never sees later sets).
128
+ Returns ``None`` if the session is unknown — caller should treat
129
+ that as "no active turn" and skip per-turn enforcement."""
130
+ if not session_id:
131
+ return None
132
+ return _active_turns.get(session_id)
133
+
134
+
135
+ def get_turn_by_saga_session_id(saga_session_id: str | None) -> "TurnContext | None":
136
+ """Look up an active turn by its ``saga_session_id``. Used by MCP
137
+ tool handlers whose args carry a ``saga_session_id`` (currently
138
+ ``saga_end_session``) where the SDK's task-fork dispatch breaks
139
+ contextvar inheritance (chainlink #23).
140
+
141
+ Iterates ``_active_turns.values()`` rather than maintaining a parallel
142
+ registry — active_turns is bounded by the dispatcher's per-channel
143
+ queue size (typically 1-3 in production), so the linear scan is cheap.
144
+
145
+ Returns ``None`` when ``saga_session_id`` is empty / None, or when no
146
+ active turn matches. Caller should fall back to ``get_current_turn``
147
+ (which works for direct-handler-call paths, e.g. unit tests) or
148
+ treat as "no active turn" and skip per-turn bookkeeping."""
149
+ if not saga_session_id:
150
+ return None
151
+ for ctx in _active_turns.values():
152
+ if ctx.saga_session_id == saga_session_id:
153
+ return ctx
154
+ return None
155
+
156
+
157
+ def get_only_active_turn() -> "TurnContext | None":
158
+ """Return the unique active turn if exactly one is registered, else
159
+ ``None``. Best-effort heuristic for MCP tool handlers whose args
160
+ don't carry any per-turn lookup key (``saga_query``, ``saga_store``,
161
+ ``saga_feedback``) — works in single-channel deployments where
162
+ concurrent turns are serialized by the dispatcher.
163
+
164
+ Multi-active cases (multiple channels with concurrent in-flight
165
+ turns) return ``None`` rather than guessing — callers should emit a
166
+ ``resolution_path`` observability event so the rate at which the
167
+ heuristic punts is visible. See chainlink #23 design doc."""
168
+ if len(_active_turns) == 1:
169
+ return next(iter(_active_turns.values()))
170
+ return None
171
+
172
+
173
+ def resolve_active_ctx(args: dict[str, Any]) -> tuple["TurnContext | None", str]:
174
+ """Standard three-level lookup chain for MCP tool handlers running
175
+ on a forked task that can't see ``_current_turn``.
176
+
177
+ Tries:
178
+
179
+ 1. ``args["session_id"]`` (model-passed via Option P) → match against
180
+ ``ctx.saga_session_id`` in ``_active_turns``. Multi-channel safe.
181
+ 2. ``get_only_active_turn()`` heuristic — the unique active turn if
182
+ exactly one is registered. Works in single-channel deployments;
183
+ returns None when 0 or >1 turns are active.
184
+ 3. ``get_current_turn()`` contextvar — works for the direct-handler-
185
+ call test path. Won't fire under SDK dispatch.
186
+
187
+ Returns ``(ctx, resolution_path)`` where resolution_path is one of
188
+ ``"saga_session_id" | "single_active" | "contextvar" | "missing"``.
189
+ The path is logged via per-tool ``<tool>_ctx_resolution`` events so
190
+ the rate of each path is visible in events.jsonl.
191
+
192
+ Mirrors the chainlink #23 sagatools resolution chain; lifted here
193
+ so any new MCP-dispatched tool (currently the bash_async family)
194
+ can use the same shape without duplicating the logic.
195
+ """
196
+ sid = args.get("session_id") if args else None
197
+ ctx = get_turn_by_saga_session_id(sid) if sid else None
198
+ if ctx is not None:
199
+ return ctx, "saga_session_id"
200
+ ctx = get_only_active_turn()
201
+ if ctx is not None:
202
+ return ctx, "single_active"
203
+ ctx = get_current_turn()
204
+ if ctx is not None:
205
+ return ctx, "contextvar"
206
+ return None, "missing"
@@ -0,0 +1,134 @@
1
+ """Streaming tail-reader for JSONL files.
2
+
3
+ Used by ``feedback.py`` (recent feedback signals from events.jsonl /
4
+ turns.jsonl) and ``session_boundary_log.py`` (local mirror tail). Both
5
+ read newest-first up to a small bound — typically ≤ 20 records — but
6
+ the underlying file can grow unbounded (events.jsonl is the firehose).
7
+ Loading the whole file into memory per turn is an O(file_size) memory
8
+ spike on every prompt assembly; this module reads from the tail in
9
+ chunks so memory use stays O(chunk_size) regardless of file length.
10
+
11
+ Usage::
12
+
13
+ for rec in tail_jsonl_records(path):
14
+ if too_old(rec):
15
+ break
16
+ consume(rec)
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import logging
23
+ from pathlib import Path
24
+ from typing import Iterator
25
+
26
+ log = logging.getLogger(__name__)
27
+
28
+ # 8 KiB per chunk. Typical JSONL records are 100 B–4 KB, so each chunk
29
+ # yields multiple records; a small bound (≤ ~20 records) almost always
30
+ # resolves in one chunk read. Bigger chunks waste memory on small files;
31
+ # smaller chunks waste seeks.
32
+ _CHUNK_BYTES = 8192
33
+
34
+
35
+ def tail_jsonl_records(path: Path) -> Iterator[dict]:
36
+ """Yield JSON-decoded records from ``path`` newest-first.
37
+
38
+ Streams chunks from the end of the file rather than reading the
39
+ whole file into memory. Skips lines that fail to JSON-decode (the
40
+ firehose may have torn lines from a crash). Yields nothing when the
41
+ file is missing or unreadable.
42
+ """
43
+ try:
44
+ for line in _tail_lines(path):
45
+ line = line.strip()
46
+ if not line:
47
+ continue
48
+ try:
49
+ yield json.loads(line)
50
+ except json.JSONDecodeError:
51
+ continue
52
+ except OSError:
53
+ return
54
+
55
+
56
+ def count_lines_chunked(path: Path, *, chunk_bytes: int = 65536) -> int:
57
+ """Count newline-terminated lines in ``path`` without reading the
58
+ whole file into memory.
59
+
60
+ Streams the file in 64 KiB chunks and counts ``\\n`` bytes. Used
61
+ by ``EventLogger`` and ``TurnLogger`` at startup to learn how many
62
+ records the existing log holds (so the trim hysteresis knows when
63
+ to fire) without a multi-hundred-MB memory spike on a hot log.
64
+
65
+ A trailing line without a final newline is counted as one more
66
+ record — that's the shape of a torn write that left the file
67
+ without a final ``\\n``. ``wc -l`` would *miss* this one; we don't.
68
+
69
+ **Behavior note (vs. previous splitlines+strip implementation).**
70
+ The old code dropped blank lines (``if line.strip()``); this
71
+ counter does not — every ``\\n`` is one record. On a clean
72
+ firehose those produce identical counts, but a torn write that
73
+ leaves a bare ``\\n`` in the middle, or an external process
74
+ appending an empty line, will over-count by one here. The trim
75
+ hysteresis absorbs over-counts (a one-line drift just makes the
76
+ next trim fire a hair sooner — not load-bearing).
77
+
78
+ Returns 0 for missing or unreadable files.
79
+ """
80
+ try:
81
+ with path.open("rb") as f:
82
+ count = 0
83
+ had_data = False
84
+ last_byte: bytes = b""
85
+ while True:
86
+ chunk = f.read(chunk_bytes)
87
+ if not chunk:
88
+ break
89
+ had_data = True
90
+ count += chunk.count(b"\n")
91
+ last_byte = chunk[-1:]
92
+ # File ended with content but no trailing newline → that's
93
+ # one more record (the pattern most JSONL appenders never
94
+ # produce, but a torn write can leave one behind).
95
+ if had_data and last_byte != b"\n":
96
+ count += 1
97
+ return count
98
+ except OSError:
99
+ return 0
100
+
101
+
102
+ def _tail_lines(path: Path) -> Iterator[str]:
103
+ """Yield lines from ``path`` newest-first, reading in fixed-size
104
+ chunks from the end. Lines that span chunk boundaries are stitched
105
+ correctly via a leading-fragment buffer.
106
+
107
+ OSError is propagated to the caller (``tail_jsonl_records`` swallows
108
+ it); we don't paper over a deleted file mid-iteration here.
109
+ """
110
+ with path.open("rb") as f:
111
+ f.seek(0, 2) # SEEK_END
112
+ pos = f.tell()
113
+ leading_fragment = b""
114
+ while pos > 0:
115
+ read_size = min(_CHUNK_BYTES, pos)
116
+ pos -= read_size
117
+ f.seek(pos)
118
+ chunk = f.read(read_size) + leading_fragment
119
+ lines = chunk.split(b"\n")
120
+ # If we haven't reached BOF, the first split is a partial
121
+ # line whose head lives in an earlier chunk — defer it.
122
+ if pos > 0:
123
+ leading_fragment = lines[0]
124
+ lines = lines[1:]
125
+ else:
126
+ leading_fragment = b""
127
+ # Yield in reverse so the caller sees newest-first.
128
+ for raw in reversed(lines):
129
+ if raw:
130
+ yield raw.decode("utf-8", errors="replace")
131
+ # Once we've reached BOF, the leading fragment is the very first
132
+ # line of the file — yield it last (it's the oldest record).
133
+ if leading_fragment:
134
+ yield leading_fragment.decode("utf-8", errors="replace")