samagotchi 0.141.0 → 0.143.0

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 (165) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +111 -7
  3. data/README.md +2 -1
  4. data/bin/chi +2 -0
  5. data/docs/architecture.md +25 -289
  6. data/docs/bundle-authoring.md +7 -6
  7. data/docs/bundles.md +1232 -0
  8. data/docs/cli.md +51 -13
  9. data/docs/configuration.md +68 -30
  10. data/docs/context.md +12 -12
  11. data/docs/guardrails.md +27 -20
  12. data/docs/hooks.md +28 -9
  13. data/docs/internals/attached-context.md +225 -0
  14. data/docs/internals/background-tasks.md +1 -1
  15. data/docs/internals/bridge.md +380 -0
  16. data/docs/internals/bundles.md +6 -3
  17. data/docs/internals/context-telemetry.md +1 -1
  18. data/docs/internals/engine.md +31 -19
  19. data/docs/internals/participants.md +13 -12
  20. data/docs/internals/prompt-caching.md +12 -12
  21. data/docs/internals/remote-sessions.md +1 -1
  22. data/docs/internals/{tool-guardrails.md → tool-output-limits.md} +21 -2
  23. data/docs/internals/web-push.md +1 -1
  24. data/docs/memory.md +33 -3
  25. data/docs/plugins.md +127 -1231
  26. data/docs/sessions.md +51 -27
  27. data/docs/sub-agent.md +6 -6
  28. data/docs/system-one.md +122 -0
  29. data/docs/testing.md +3 -1
  30. data/lib/samagotchi/bridge.rb +2 -0
  31. data/lib/samagotchi/bundle_command/api_calls.rb +132 -0
  32. data/lib/samagotchi/bundle_command/check.rb +50 -34
  33. data/lib/samagotchi/bundle_command/installed_check.rb +86 -0
  34. data/lib/samagotchi/bundle_command/scaffold.rb +1 -0
  35. data/lib/samagotchi/bundle_settings_check.rb +58 -0
  36. data/lib/samagotchi/bundles/agents-md/manifest.yml +4 -2
  37. data/lib/samagotchi/bundles/agents-md/plugin.rb +1 -1
  38. data/lib/samagotchi/bundles/btw/manifest.yml +4 -2
  39. data/lib/samagotchi/bundles/btw/plugin.rb +1 -1
  40. data/lib/samagotchi/bundles/check-in/manifest.yml +7 -3
  41. data/lib/samagotchi/bundles/check-in/plugin.rb +101 -11
  42. data/lib/samagotchi/bundles/claude-peer/manifest.yml +3 -3
  43. data/lib/samagotchi/bundles/claude-peer/plugin.rb +1 -1
  44. data/lib/samagotchi/bundles/claude-peer/scripts/pair +1 -1
  45. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  46. data/lib/samagotchi/bundles/codex-peer/plugin.rb +1 -1
  47. data/lib/samagotchi/bundles/coordinator/manifest.yml +2 -2
  48. data/lib/samagotchi/bundles/coordinator/plugin.rb +1 -1
  49. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  50. data/lib/samagotchi/bundles/cost-cap/manifest.yml +6 -3
  51. data/lib/samagotchi/bundles/cost-cap/plugin.rb +5 -7
  52. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -1
  53. data/lib/samagotchi/bundles/known-names/manifest.yml +3 -1
  54. data/lib/samagotchi/bundles/loop-guard/manifest.yml +4 -2
  55. data/lib/samagotchi/bundles/loop-guard/plugin.rb +1 -1
  56. data/lib/samagotchi/bundles/mcp/manifest.yml +4 -2
  57. data/lib/samagotchi/bundles/mcp/plugin.rb +2 -2
  58. data/lib/samagotchi/bundles/ruby/manifest.yml +2 -2
  59. data/lib/samagotchi/bundles/ruby/plugin.rb +1 -1
  60. data/lib/samagotchi/bundles/skills/manifest.yml +4 -2
  61. data/lib/samagotchi/bundles/skills/plugin.rb +1 -1
  62. data/lib/samagotchi/bundles/slow-commands/manifest.yml +4 -2
  63. data/lib/samagotchi/bundles/slow-commands/plugin.rb +1 -1
  64. data/lib/samagotchi/bundles/source-links/manifest.yml +3 -1
  65. data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -3
  66. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  67. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  68. data/lib/samagotchi/bundles/system/self_map.md +3 -3
  69. data/lib/samagotchi/bundles/work-cap/manifest.yml +6 -3
  70. data/lib/samagotchi/bundles/work-cap/plugin.rb +11 -13
  71. data/lib/samagotchi/config.rb +74 -14
  72. data/lib/samagotchi/context_candidate_choice.rb +20 -3
  73. data/lib/samagotchi/context_candidates.rb +4 -1
  74. data/lib/samagotchi/context_candidates_log.rb +36 -1
  75. data/lib/samagotchi/context_judge.rb +6 -2
  76. data/lib/samagotchi/context_status.rb +1 -2
  77. data/lib/samagotchi/engine.rb +96 -70
  78. data/lib/samagotchi/extension_load.rb +11 -4
  79. data/lib/samagotchi/guardrail_wiring.rb +36 -31
  80. data/lib/samagotchi/guardrails/chi_dirs.rb +7 -4
  81. data/lib/samagotchi/guardrails/path_resolve.rb +1 -1
  82. data/lib/samagotchi/guardrails/protected_paths.rb +7 -4
  83. data/lib/samagotchi/guardrails_command.rb +3 -3
  84. data/lib/samagotchi/hook_routes.rb +37 -2
  85. data/lib/samagotchi/hooks/bundle_loader.rb +20 -4
  86. data/lib/samagotchi/hooks/registry.rb +23 -5
  87. data/lib/samagotchi/host_model.rb +3 -3
  88. data/lib/samagotchi/host_registry.rb +14 -7
  89. data/lib/samagotchi/judgments_command.rb +155 -0
  90. data/lib/samagotchi/kernel_loop.rb +20 -3
  91. data/lib/samagotchi/llm/chat_loop.rb +79 -10
  92. data/lib/samagotchi/llm/errors.rb +7 -5
  93. data/lib/samagotchi/llm/stray_tool_call.rb +68 -0
  94. data/lib/samagotchi/log_line.rb +1 -1
  95. data/lib/samagotchi/memory_bundle/builder.rb +4 -2
  96. data/lib/samagotchi/memory_bundle/entry_size.rb +100 -0
  97. data/lib/samagotchi/memory_bundle/installed_bundle.rb +9 -2
  98. data/lib/samagotchi/memory_bundle/installed_copy.rb +92 -0
  99. data/lib/samagotchi/memory_bundle/installer.rb +2 -0
  100. data/lib/samagotchi/memory_bundle/manifest.rb +23 -13
  101. data/lib/samagotchi/memory_bundle/provenance.rb +4 -1
  102. data/lib/samagotchi/memory_bundle/shipped_update.rb +13 -1
  103. data/lib/samagotchi/model_catalog.rb +3 -2
  104. data/lib/samagotchi/models_command.rb +1 -1
  105. data/lib/samagotchi/plugin/api.rb +41 -7
  106. data/lib/samagotchi/plugin/context.rb +59 -1
  107. data/lib/samagotchi/plugin/judgments.rb +72 -0
  108. data/lib/samagotchi/plugin/loader.rb +6 -3
  109. data/lib/samagotchi/plugin/peer.rb +17 -22
  110. data/lib/samagotchi/plugin_session_state.rb +32 -5
  111. data/lib/samagotchi/prompt_warmup.rb +1 -9
  112. data/lib/samagotchi/run_options.rb +1 -0
  113. data/lib/samagotchi/self_report.rb +36 -0
  114. data/lib/samagotchi/session_access/contract.rb +1 -1
  115. data/lib/samagotchi/session_access/local.rb +1 -1
  116. data/lib/samagotchi/session_access/remote.rb +3 -3
  117. data/lib/samagotchi/session_archive_command.rb +3 -3
  118. data/lib/samagotchi/session_commands.rb +7 -4
  119. data/lib/samagotchi/session_config.rb +9 -3
  120. data/lib/samagotchi/session_continue.rb +1 -1
  121. data/lib/samagotchi/session_manager.rb +25 -5
  122. data/lib/samagotchi/session_retention.rb +10 -0
  123. data/lib/samagotchi/side_judgments.rb +12 -1
  124. data/lib/samagotchi/system_one/answer.rb +63 -11
  125. data/lib/samagotchi/system_one/approval_label.rb +56 -0
  126. data/lib/samagotchi/system_one/approval_question.rb +2 -0
  127. data/lib/samagotchi/system_one/client.rb +43 -17
  128. data/lib/samagotchi/system_one/fake_backend.rb +1 -1
  129. data/lib/samagotchi/system_one/http_backend.rb +12 -4
  130. data/lib/samagotchi/system_one/judgment_settings.rb +61 -0
  131. data/lib/samagotchi/system_one/judgments.rb +243 -0
  132. data/lib/samagotchi/system_one/log.rb +25 -2
  133. data/lib/samagotchi/system_one/recording.rb +117 -0
  134. data/lib/samagotchi/system_one/runs.rb +179 -0
  135. data/lib/samagotchi/system_one/runs_store.rb +230 -0
  136. data/lib/samagotchi/system_one.rb +9 -0
  137. data/lib/samagotchi/terminal_ui/attach_link.rb +2 -2
  138. data/lib/samagotchi/terminal_ui/attached_loop/exit_commands.rb +1 -1
  139. data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -1
  140. data/lib/samagotchi/terminal_ui/local_link.rb +1 -1
  141. data/lib/samagotchi/terminal_ui/remote_link.rb +1 -2
  142. data/lib/samagotchi/testing/bundle.rb +9 -2
  143. data/lib/samagotchi/testing/host.rb +2 -1
  144. data/lib/samagotchi/testing/judgments.rb +176 -0
  145. data/lib/samagotchi/testing/recorder.rb +5 -0
  146. data/lib/samagotchi/testing/recording_api.rb +1 -1
  147. data/lib/samagotchi/tool_output_cut.rb +26 -6
  148. data/lib/samagotchi/tool_response.rb +1 -1
  149. data/lib/samagotchi/tool_runner.rb +63 -4
  150. data/lib/samagotchi/toolkit_suggest.rb +11 -2
  151. data/lib/samagotchi/tools/memory.rb +28 -0
  152. data/lib/samagotchi/turn_note.rb +9 -0
  153. data/lib/samagotchi/update_command.rb +49 -16
  154. data/lib/samagotchi/version.rb +1 -1
  155. data/lib/samagotchi/web/app.rb +1 -0
  156. data/lib/samagotchi/web/auto_update.rb +79 -49
  157. data/lib/samagotchi/web/auto_update_record.rb +59 -0
  158. data/lib/samagotchi/web/public/app.js +8 -4
  159. data/lib/samagotchi/web/public/hash_intent.js +24 -0
  160. data/lib/samagotchi/web/public/index.html +3 -1
  161. data/lib/samagotchi/web/public/revise_stack.js +14 -5
  162. data/lib/samagotchi/web/session_control.rb +3 -3
  163. data/lib/samagotchi/web/update_check.rb +7 -6
  164. data/lib/samagotchi/web/update_ops.rb +80 -25
  165. metadata +23 -2
data/docs/hooks.md CHANGED
@@ -62,7 +62,7 @@ The plugin class must respond to `#call(event)` — duck-typed, no base class re
62
62
  | `:generation_progress` | While the response streams, in batches (see [Watching the stream](#watching-the-stream)) | `{ type: :generation_progress, iteration: N, thinking: "..." (new since the last fire), text: "..." (new visible text), thinking_chars: N, text_chars: N (this generation so far), elapsed_ms: N, restarted: true (only on the first fire after a dropped stream was asked again) }` |
63
63
  | `:before_tool_call` | Before tool dispatch (and before `tool_call_started`) | `{ type: :before_tool_call, iteration: N, call: {...}, params: "...", guardrail: Verdict, context: {...}, targets: {...}, blocked: false, block_reason: nil }` |
64
64
  | `:question_asked` | A question of the model's own (`ask_user_question`) or an approval of its tool call is about to open, on the turn's thread (not a hook's or plugin's `ask_user`, a relayed delegate approval, the step-limit question, a `--non-interactive` run or a stored approval: none of those fire it) | `{ type: :question_asked, kind: "question" \| "approval", text: "..." (the reply that asks, as the asking slot shows it: no thoughts, no tool-call markup), question: {...} (the question's fields, frozen: question/options/header…, an approval's approval: facts too; no id yet), refs: (see [Refs on a question](#refs-on-a-question-display-only)) }` |
65
- | `:after_tool_call` | After tool execution | `{ type: :after_tool_call, iteration: N, tool: "read", output: "..." (the text the model gets: capped at max_tool_output_chars; a cut one has a [cut: …] line, at its end, or for an execute result between its head and its tail, the whole output saved in the session's tool_output/ folder), status: "ok" \| "error" \| "blocked" \| "stopped", call: {...} (the call that ran, as the gate left it), elapsed_ms: N (the tool's own run, without an approval wait; nil for a denied call), result: "..." (the tool's own text: no "[read]" lead, no "ran as:" line, never cut) }`; `stopped` is a `task_wait` the turn's Stop ended (the task runs on) or whose task was stopped. Match on `result:`, not `output:`: it is what the tool said (`Error: command timed out after 120s` for an execute killed at its limit in kill mode, `Samagotchi::Tools::Execute::TIMEOUT_PREFIX`; `Still running at the 120s limit: …` for one handed over as a task, `Samagotchi::Tools::Execute::HANDED_OVER`) |
65
+ | `:after_tool_call` | After tool execution | `{ type: :after_tool_call, iteration: N, tool: "read", output: "..." (the text the model gets: capped at max_tool_output_chars (a memory_read's cap is memory.read_max_chars, 0: none); a cut one has a [cut: …] line, at its end, or for an execute result between its head and its tail, the whole output saved in the session's tool_output/ folder), status: "ok" \| "error" \| "blocked" \| "stopped", call: {...} (the call that ran, as the gate left it), elapsed_ms: N (the tool's own run, without an approval wait; nil for a denied call), result: "..." (the tool's own text: no "[read]" lead, no "ran as:" line, never cut) }`; `stopped` is a `task_wait` the turn's Stop ended (the task runs on) or whose task was stopped. Match on `result:`, not `output:`: it is what the tool said (`Error: command timed out after 120s` for an execute killed at its limit in kill mode, `Samagotchi::Tools::Execute::TIMEOUT_PREFIX`; `Still running at the 120s limit: …` for one handed over as a task, `Samagotchi::Tools::Execute::HANDED_OVER`) |
66
66
  | `:session_end` | After each turn `:after_turn` fires for, after it (turn-level lifecycle) | `{ type: :session_end, session_id: "..." }` |
67
67
 
68
68
  Every event also carries the hook runtime (next section): `hook:` (the label
@@ -76,12 +76,14 @@ cheap).
76
76
 
77
77
  ## Participants' turns
78
78
 
79
- A [participant's](internals/participants.md#turns) review turn fires the same hooks, and every event of it carries
80
- `author: "p:<id>"` (the owner's carry no `author:` key, as before); a plugin reads the same as `ctx.turn_author`
81
- (nil for the owner's turns and between turns). **A hook runs with the owner's power whatever the author**: it may
82
- read, write and run what the owner's chi may, and `notify`, `ask_user` and `steer` reach the owner. A bundle that
83
- acts on a turn's text (a prompt rewrite, a fetch of what it names) should check `author:` first: the text may be a
84
- participant's. The review boundary checks a tool call after the hooks, on the call they leave.
79
+ A [participant's](internals/participants.md#turns) turn — a review turn or a pair turn — fires the same hooks, and
80
+ every event of it carries `author: "p:<id>"` (the owner's carry no `author:` key, as before); a plugin reads the
81
+ same as `ctx.turn_author` (nil for the owner's turns and between turns). The mode does not change that: `author:`
82
+ comes from who wrote the turn, not from `ParticipantMode`. **A hook runs with the owner's power whatever the
83
+ author**: it may read, write and run what the owner's chi may, and `notify`, `ask_user` and `steer` reach the
84
+ owner. A bundle that acts on a turn's text (a prompt rewrite, a fetch of what it names) should check `author:`
85
+ first: the text may be a participant's. The participant's boundary (the review or pair one) checks a tool call
86
+ after the hooks, on the call they leave.
85
87
 
86
88
  ## What a hook can do: the runtime
87
89
 
@@ -226,7 +228,7 @@ turn. Don't ask questions or call the model from it: `event[:ask_user]`
226
228
  returns nil at once here, and `ctx.ask_model` would hold the stream for its
227
229
  whole answer. A hook that never returns hangs the turn, as any hook does. A
228
230
  plugin's block that raises is logged (`plugin_hook_failed`) at most once a
229
- minute.
231
+ minute, and its first failure in a worker shows once in every UI.
230
232
 
231
233
  Two ways to act:
232
234
 
@@ -470,6 +472,23 @@ once in the UI.
470
472
  Hook failures never break the engine loop — each hook is wrapped in its own
471
473
  try/catch.
472
474
 
475
+ A bundle's `hooks/*.rb` file that **raises** tells you once per hook in a
476
+ worker, on the plugins' surface, whatever its `on_error` (the same notice a
477
+ `chi.on` handler gets):
478
+
479
+ ```
480
+ plugins> guardrail guardrails.rb (bundle known-names) before_tool_call hook failed (RuntimeError: boom):
481
+ it denies every tool call it guards, and its next failures are in each denial's reason
482
+ ```
483
+
484
+ The label names the file, the bundle and the event, so one line per hook: a
485
+ hook on a stream can't nag. `on_error` still decides what happens at every
486
+ firing — `skip` logs nothing, `log` warns each time (a stream hook at most
487
+ once a minute), a fail_closed `before_tool_call` hook denies the call — the
488
+ notice is only added on top. A config hook (config.yml's own files) keeps its
489
+ behaviour: no notice, just the log. A UI that joins later reads the same lines
490
+ from `Engine#plugin_warning`.
491
+
473
492
  ## Runtime Hook Registration
474
493
 
475
494
  You can also register hooks programmatically during a turn (they are cleared
@@ -686,7 +705,7 @@ Notes:
686
705
  - Ordering: bundle hooks fire by `(priority, bundle_name, hook_name)` (lower priority first), then plain `config.yml` hooks in registration order.
687
706
  - Settings: a hook class with `initialize(settings = {})` gets the bundle's section of `config.yml` `bundles:` (see [Settings](#settings)).
688
707
  - A bundle can also ship a `plugin.rb` whose `chi.on(event)` blocks are bundle hooks too, next to commands and tools; see [Plugins](plugins.md). There `ctx.notify`, `ctx.steer` and the other `ctx` helpers act as this page's `event[:notify]`… do for the event the block runs for.
689
- - Shipped bundles (each in a profile: `core` or `dev`, see [Bundle profiles](memory.md#bundle-profiles-core-and-dev)): `chi bundle install guardrails` (rules, see [Guardrails](guardrails.md#the-guardrails-bundle)) and `chi bundle install known-names` (a hook, see [Guardrails](guardrails.md#the-known-names-bundle)), `chi bundle install source-links` (a hook: announces source refs, see [The source-links bundle](#the-source-links-bundle)), `chi bundle install btw` (a plugin: `/btw`, see [Plugins](plugins.md#the-btw-bundle)), `chi bundle install mcp` (a plugin: tools from MCP servers, see [Plugins](plugins.md#the-mcp-bundle)) `chi bundle install loop-guard` (a plugin: breaks tool-call loops, see [Plugins](plugins.md#the-loop-guard-bundle)), `chi bundle install check-in` (a plugin: checks on a long turn, see [Plugins](plugins.md#the-check-in-bundle)), `chi bundle install slow-commands` (a plugin: commands that ran past the execute limit are run as tasks next time, see [Plugins](plugins.md#the-slow-commands-bundle)), `chi bundle install agents-md` (a plugin: a nested AGENTS.md is steered into the conversation on the first touch under its directory, see [Plugins](plugins.md#the-agents-md-bundle)), `chi bundle install cost-cap` (a plugin: a per-session $ cap, `/cap`, see [Plugins](plugins.md#the-cost-cap-bundle)), `chi bundle install work-cap` (a plugin: a per-session cap on tool calls and active minutes, `/workcap`, see [Plugins](plugins.md#the-work-cap-bundle)), `chi bundle install ruby` (a plugin: an RSpec run with no examples at all, a hint, see [Plugins](plugins.md#the-ruby-bundle)) `chi bundle install skills` (a plugin: `/skill`, versions of skills, see [Plugins](plugins.md#the-skills-bundle)), `chi bundle install github-pr` (attached context: the branch's open PR, see [Plugins](plugins.md#the-github-pr-bundle)), `chi bundle install coordinator` (a plugin: parallel work in worktrees and child sessions, see [Plugins](plugins.md#the-coordinator-bundle)), `chi bundle install claude-peer` / `codex-peer` (a plugin: talk to a Claude Code or Codex session, see [Plugins](plugins.md#the-claude-peer-bundle)) and `chi bundle install model-notes` (memories: reviewed model notes, see [Plugins](plugins.md#the-model-notes-bundle)).
708
+ - Shipped bundles (each in a profile: `core` or `dev`, see [Bundle profiles](memory.md#bundle-profiles-core-and-dev)): `chi bundle install guardrails` (rules, see [Guardrails](guardrails.md#the-guardrails-bundle)) and `chi bundle install known-names` (a hook, see [Guardrails](guardrails.md#the-known-names-bundle)), `chi bundle install source-links` (a hook: announces source refs, see [The source-links bundle](#the-source-links-bundle)), `chi bundle install btw` (a plugin: `/btw`, see [Bundles](bundles.md#the-btw-bundle)), `chi bundle install mcp` (a plugin: tools from MCP servers, see [Bundles](bundles.md#the-mcp-bundle)) `chi bundle install loop-guard` (a plugin: breaks tool-call loops, see [Bundles](bundles.md#the-loop-guard-bundle)), `chi bundle install check-in` (a plugin: checks on a long turn, see [Bundles](bundles.md#the-check-in-bundle)), `chi bundle install slow-commands` (a plugin: commands that ran past the execute limit are run as tasks next time, see [Bundles](bundles.md#the-slow-commands-bundle)), `chi bundle install agents-md` (a plugin: a nested AGENTS.md is steered into the conversation on the first touch under its directory, see [Bundles](bundles.md#the-agents-md-bundle)), `chi bundle install cost-cap` (a plugin: a per-session $ cap, `/cap`, see [Bundles](bundles.md#the-cost-cap-bundle)), `chi bundle install work-cap` (a plugin: a per-session cap on tool calls and active minutes, `/workcap`, see [Bundles](bundles.md#the-work-cap-bundle)), `chi bundle install ruby` (a plugin: an RSpec run with no examples at all, a hint, see [Bundles](bundles.md#the-ruby-bundle)) `chi bundle install skills` (a plugin: `/skill`, versions of skills, see [Bundles](bundles.md#the-skills-bundle)), `chi bundle install github-pr` (attached context: the branch's open PR, see [Bundles](bundles.md#the-github-pr-bundle)), `chi bundle install coordinator` (a plugin: parallel work in worktrees and child sessions, see [Bundles](bundles.md#the-coordinator-bundle)), `chi bundle install claude-peer` / `codex-peer` (a plugin: talk to a Claude Code or Codex session, see [Bundles](bundles.md#the-claude-peer-bundle)) and `chi bundle install model-notes` (memories: reviewed model notes, see [Bundles](bundles.md#the-model-notes-bundle)).
690
709
  - Installing a bundle executes its hook code at `Engine` startup. Only install bundles you trust, as you would a gem. Hooks are **not** executed at install time (copy-only); they are `module_eval`'d at `Engine.new` inside per-bundle `Samagotchi::Bundles::<name>` namespaces (no top-level `require` collisions). Keep hook files side-effect-free at load time; do work in `#call` — top-level side effects (require, IO, `at_exit`, global assignment) run once per `Engine.new` (class redefinition is idempotent).
691
710
 
692
711
  Lifecycle:
@@ -0,0 +1,225 @@
1
+ # Attached context: the mechanism
2
+
3
+ For an agent changing the layer. What a user sees and configures (the commands, the note wording, the wake rules,
4
+ the judge's thresholds, the log and the report) is in [../context.md](../context.md); this page is how the code
5
+ gets a source's text into the prompt, and what each class owns. Not to be confused with `ContextStatus` /
6
+ `ContextUsage` / `ContextWindow` ([context-telemetry.md](context-telemetry.md): token counts) or the LLM context
7
+ strategies ([llm-context-forget.md](llm-context-forget.md)).
8
+
9
+ ## From a source to the prompt
10
+
11
+ ```
12
+ source (command | push | URL provider) ContextSources: <name>.json + <name>.snapshot.json
13
+ │ ContextPoller (worker thread) runs a command source via ContextFetch;
14
+ │ chi context push / refresh write the snapshot from another process
15
+ ▼
16
+ snapshot (text, revision, serial, summary, wake_serial, hint)
17
+ │ Worker#pass, between turns: WorkerInbound#absorb_context
18
+ ▼
19
+ ContextAbsorber#pending → Batch of Deliveries (one note per source that is new / changed / failing / gone)
20
+ │ Engine#add_context_note: a tail system message (kind "note", ContextNote), announced as :context_added
21
+ │ session saved, then ContextAbsorber#commit writes subscriptions.json
22
+ ▼
23
+ the next request: the note sits after the history; the model reads the text with the context_read tool
24
+ ```
25
+
26
+ Three rules hold the shape:
27
+
28
+ - **Never the stable prefix.** A note is a tail system message, appended between turns (never mid-turn, never
29
+ before a session's first turn: `conversation_started?`, or `before_turn:` when a prompt is about to run). The
30
+ system prompt carries only the standing line that explains `[CONTEXT NOTE …]`. History stays append-only
31
+ ([prompt-caching.md](prompt-caching.md)). Only the pointer (name, why, hint, summary) is in the note; the text is
32
+ read on demand by `Tools::ContextRead`, which reads snapshots only and never runs a command.
33
+ - **Deliver at least once, record after save.** `absorb_context` adds the notes, saves the session, and only then
34
+ `commit`s the subscriptions. A crash in between re-delivers, and `Engine#add_context_note` skips a `note_id` the
35
+ conversation already holds. Ids are deterministic: `ctx-<name>-<serial>-<revision[0,12]>` (the serial makes A, B, A
36
+ three notes), `ctx-<name>-error-<error_since digits>`, `ctx-<name>-detached-<seen[0,12]>`.
37
+ - **Polling is not activity.** The poller and the absorb never hold a worker up (`WorkerIdleExit`).
38
+
39
+ ## ContextSources
40
+
41
+ The store (`context_sources.rb`), one root `<state dir>/context/`, apart from the session folders; its header
42
+ comment is the file map and [../context.md](../context.md#storage) lists it for users.
43
+
44
+ - **Records** (`Data`/value objects): `Source` (definition; `cmd` nil = pushed; `movable` nil derives from
45
+ `push?`), `Snapshot` (last good text, `revision` = SHA-256 of the text, `serial` counting revisions, `error` and
46
+ `error_since`, `wake_serial` = the newest revision that asked to wake, `fetched_at` last success, `checked_at` last
47
+ try), `Subscription` (what one session has had: `seen`/`seen_serial`, `read`, `error_seen`, `wakes_at`),
48
+ `Candidate`, `Left`, `Moved`/`Stayed`.
49
+ - **Locations.** A `Location` is a folder of sources: a project's (`projects/<project_key>/`, shared by the
50
+ project's workers) or a session's (`sessions/<id>/`, which also holds `subscriptions.json`, the `muted/` and
51
+ `declined/` markers, `candidates/`, `left.json`). `ContextSources.attached(session_id, project_root:)` is what a
52
+ session sees: its own, then the project's it doesn't shadow. Names are `NAME_RE`; `subscriptions` and `left` are
53
+ reserved (`RESERVED_NAMES`) because their files share the folder.
54
+ - **Writes are file replaces** (`AtomicFile`). `Location#locked(name)` takes `<name>.lock`, the lock a fetch holds
55
+ for its whole command; `record_text` and `record_error` run under it, and `remove` leaves the lock file so a fetch
56
+ still running finds its source gone (`ContextFetch` then writes nothing). Removing a *session* source leaves a
57
+ `declined/<name>` marker (hint inside), which is what stops an auto-attach from bringing it back.
58
+ - **The output contract** is `ContextSources.parse_output` (JSON object with a string `text` is
59
+ `{text, summary, wake, hint}`; anything else is all text; empty or over `TEXT_MAX_BYTES` is `Invalid`), the same for
60
+ a command's stdout and a push. `ContextProviders` resolves a URL to a command source from an installed bundle's
61
+ manifest `context_providers:` (read from its provenance, so the CLI and chi web resolve without loading plugins);
62
+ `{url}` is filled at attach, `{bundle_dir}` and `{ruby}` when the command runs.
63
+ - **Protected.** The root is a `ProtectedPaths` entry: the file tools cannot write it, because a source's command
64
+ runs later outside the guardrails gate with the worker's environment ([../context.md](../context.md#safety)).
65
+
66
+ ## ContextPoller and ContextFetch
67
+
68
+ `ContextPoller` is one thread in a worker (`Worker` starts it, `on_change: -> { @waker.wake }`). Every
69
+ `TICK_SECONDS` (5) it walks `ContextSources.attached`, skips pushed and muted sources, and runs each command source
70
+ whose last try (`checked_at`, so a failing source isn't re-run every tick) is older than its interval (`--every`,
71
+ else `context.every_seconds`, floored at `MIN_EVERY_SECONDS` 30). Its first pass uses the floor as the interval, so a
72
+ woken worker's first absorb says what changed while it was away. It calls `on_change` only for `:new` and `:error`.
73
+
74
+ `ContextFetch.fetch` runs one source: `sh -c` in its own process group (a timeout, the output cap or the caller's
75
+ cancel kills the group), cwd the session's folder (a project source: the project root, `project_cwd`), stdin closed,
76
+ `SAMAGOTCHI_CONTEXT_NAME` and `SAMAGOTCHI_CONTEXT_PREVIOUS` in the env. A non-blocking `flock` on `<name>.lock` is
77
+ the arbiter between workers and the CLI: the loser gets `:busy`, and `fresh_within` is checked again under the lock,
78
+ so a project source shared by several workers is fetched once per interval. The `Outcome` status says what happened
79
+ (`:new :same :error :busy :fresh :cancelled :gone`); a cancelled fetch records nothing (a leaving worker isn't the
80
+ source failing). `chi context refresh` and the candidates' first fetch use the same entry.
81
+
82
+ A pushed source has no poller. `chi context push` writes the snapshot from its own process; the worker finds it
83
+ through `ContextAbsorber#store_signature` (names and mtimes of the files in the two folders, `subscriptions.json`
84
+ left out so the worker's own write is not a change), compared on each pass.
85
+
86
+ ## ContextAbsorber
87
+
88
+ Pure decision, no I/O of its own beyond the store. `pending` returns `nil` when the signature hasn't moved since the
89
+ last `commit`, else a `Batch` of `Delivery` values (`name`, `note`, `subscription`, `wake_note`, `peer`, `agreed`):
90
+
91
+ | State of source vs the session's `Subscription` | Delivery |
92
+ |---|---|
93
+ | has text, `seen` nil | *Attached* note |
94
+ | `seen != revision` | *Updated* note, `changes = serial - seen_serial` |
95
+ | error newer than `error_seen` | *Couldn't refresh*, once per run of failures |
96
+ | subscription with no source left | *Detached* if the session was told of it, else dropped quietly |
97
+ | no text yet | nothing |
98
+
99
+ A muted source is skipped. Notes name the source as third-party text; the exact wording lives in the class constants
100
+ (`WAKE_LAST_LINE`, `PEER_WAKE_LINE`, `PEER_AGREED_LINE` were tuned in spikes: change one only with an eval).
101
+
102
+ **Wake notes.** A delivery also carries a `wake_note` (the same note with the wake last line) when its source asked
103
+ (`snapshot.wake_serial > sub.seen_serial`, so an earlier asking revision still counts) or, for a first text, only a
104
+ session's own pushed source (`first_text_wakes?`). Whether it *runs* is the worker's, not the absorber's:
105
+ `WorkerInbound#context_wake_for` applies `context.wake`, `WorkerWakes#context_open?` (no continue offer, not paused,
106
+ under `session.max_wakes`, past the start grace), and the per-source `CONTEXT_WAKE_WINDOW` (600 s, from
107
+ `Subscription#wakes_at`); `Worker#pass` calls it only with nothing queued and no exit requested. A failed or
108
+ stopped wake turn is rolled back (`unmark_wake`) to a plain note.
109
+
110
+ **Peers.** `PeerSources` (the peer bundles' in-process declaration, `chi.peer_source`) marks a pushed session source
111
+ as a peer's answers; `peer_label` then needs `context.peer_turns` > 0. A peer delivery has no 10-minute window; its
112
+ budget is `WorkerWakes#peer_open?` (reset only by typed input), and an answer whose summary ends in `VERDICT OK`
113
+ (`peer_agrees?`) wakes once with the closing line, counted toward `session.max_wakes` only.
114
+
115
+ ## Candidates
116
+
117
+ `ContextCandidates` holds a session's proposed sources (`Candidate`, from `ctx.context.propose`, stored under
118
+ `candidates/<name>.json` and written once). **Status is derived, never stored**: *attached* while a source of its
119
+ name (or whose hint is its URL) is attached, *declined* while the declined marker names it, else *proposed*.
120
+
121
+ - **Mention.** `mention_in` / `pattern_for` implement the user-facing rules (whole tokens, `#N` ≥ 10 or after
122
+ "PR", branch names ≥ 6 chars with `/ - _`, exact URLs, fenced code blanked). Only `OwnerPrompt` texts count
123
+ (`owner_texts`), so a participant's message, a steer, a note or a reminder never attaches anything; a review
124
+ session skips all of it.
125
+ - **Where it runs.** `before_turn` (from `Worker#run_prompt` before the absorb, and the REPL's turn start): attach
126
+ each proposed candidate the owner's prompts so far mention (`attach` writes a session `Source` through
127
+ `source_for`: a URL resolves through `ContextProviders`, else the candidate's own `cmd`), then `first_fetch` them
128
+ together within `FIRST_FETCH_SECONDS` (10) so the note lands in this turn; cut short, the fetch records nothing and
129
+ the poller tries soon. `after_propose` does the same for a late proposal. The REPL has no absorber running, so
130
+ `deliver_notes` runs one.
131
+ - Forks and continues take no candidates.
132
+
133
+ ## ContextJudge and ContextCandidateChoice
134
+
135
+ `ContextJudge` is System 1's "is this session's work about the candidate?" ([../guardrails.md](../guardrails.md#system-1-experimental)).
136
+ It builds a small neutral state per ask (`state(job)`: the candidate's title and describe, `conversation` = the first
137
+ substantive owner prompt and the latest, each bounded at `PROMPT_MAX`, and for a delegate child the parent's attached
138
+ titles), two constant `noul` questions (`about`, `task`: constant so the host's prefix cache holds and nothing a
139
+ plugin wrote is read as instructions), and `ask` writes a `judged` line. Never the branch, the checkout or the
140
+ source's why. `task_p < TASK_MIN` (0.5) leaves the candidate unjudged; `due?` decides asks from the log's `judged`
141
+ lines (never twice at one owner-prompt count; one re-ask after `reask_after_turns`; then done). No answer writes
142
+ nothing, so the next owner prompt asks again.
143
+
144
+ The driver is the Engine, not the judge: `Engine#start_context_judge` (an owner turn's start; skipped for participants,
145
+ review sessions and a Continue) and `judge_late_candidates` (a proposal after the prompts) call `context_judge_pass`.
146
+ It first logs status changes (`ContextCandidatesLog.look`), returns at `mode: off`, builds the due `Job`s, and hands
147
+ them to `SideJudgments` (`:context` kind: one thread, one batch per session at a time), asked at once on any host,
148
+ the model's own server too.
149
+
150
+ `ContextCandidateChoice.after_judged` acts on `fields["would"]` in `on` mode only: *attach* → `judge_attach` (a
151
+ session source with `JUDGE_WHY`, no probability in the model-visible why), *offer* → one card (`offer_card`, buttons
152
+ `/context attach NAME` and `/context decline NAME`, guarded by a `carded` log line). It re-checks that the candidate
153
+ is still proposed, since the user may have chosen while it was asked. Cards are shown through the Engine's
154
+ `context_choice` lambda, which holds them while a turn runs (`@context_cards_after_turn`). `choose` is the
155
+ `/context attach|decline` command's body (`SessionCommands`), shared by the card's buttons; it refuses in a review
156
+ session. An attach only writes the session folder: its note comes with the next between-turn delivery, never into a
157
+ running turn.
158
+
159
+ `ContextCandidatesLog` is `context_candidates.jsonl` beside the sessions folder (a `SideJudgments.log`: 0600, one
160
+ line under a lock, the oldest half dropped past 5 MB; written in every mode). `statuses` replays the lines;
161
+ `look` appends `by: "user"` lines for changes it sees (the user's own `chi context add`, a chip's ×); the kinds and the
162
+ report are in [../context.md](../context.md#the-log-and-the-report). `ContextReportCommand` is its only reader besides
163
+ `due?` and `carded?`.
164
+
165
+ ## ContextMove and left.json
166
+
167
+ Movable context is the only code path that copies sources between sessions. `ContextMove.call` runs from
168
+ `SessionManager.spawn_session`'s `before_spawn` (a continue: `SessionContinue`; a plugin fork:
169
+ `Plugin::Sessions#fork` with `seed:` = the fork's messages), after the new session and its first note are saved and
170
+ **before its worker starts**, so the first absorb sees the sources. It calls `ContextSources.move`, then writes a
171
+ second note into the new session (`NOTE_SOURCE` "attached context", named by time so it sorts after the first). Best
172
+ effort: a failure is logged (`context_move_failed`) and the session starts without.
173
+
174
+ `ContextSources.move(from:, to:, mode:, seed:)` copies, by plain read (never `Location#locked`: a fetch holds it for
175
+ its whole command), each source for which `Source#movable?` is true: the source file, its snapshot, and a
176
+ `Subscription`; plus every `declined/` and `muted/` marker (the user's choices). Never `candidates/`, lock files, or a
177
+ source the new session already has. The subscription's content is what makes the first note right: a continue's says
178
+ nothing was seen (so the *Attached* note comes) at the snapshot's serial (so an old wake doesn't wake); a fork's is
179
+ what its seed last had, found from the newest note about the source (`seed_note`, via the `note_id` shape above), so
180
+ a source that changed since gets an *Updated* note.
181
+
182
+ `Moved` carries `moved` names and `stayed` (`Stayed`: name, `why` `:pushed` or `:not_movable`). Its `note_text` is the
183
+ second note; `words` is the few words `chi send --new --continues` prints; `to_h` / `from_h` are chi web's
184
+ `moved_context` answer (an older chi web sends none: `from_h(nil)` is nil).
185
+
186
+ **left.json.** For a *continue* the stayed sources are also written to the new link's `sessions/<id>/left.json`
187
+ (`Left`: `name`, `hint`, `from`; the hint is the snapshot's, since a pushed source's hint is where a peer's re-pair
188
+ pushes). `ctx.context.left_behind` reads it (`Plugin::AttachedContext#left_behind`) and `ctx.continues` names the
189
+ previous link. `Plugin::Peer#pair_again` uses both: from its quiet init task it posts one `moved` message to the
190
+ inbox left behind and records a marker under the bundle's `sessions/<id>/`, so it runs once. `Left#peer` /
191
+ `PEER_NAME` exist only to word the second note (a peer's inbox and outbox are one group: "chi asks Claude to pair
192
+ again" vs "Codex pairs again when it next answers"); `PeerSources` decides what a peer is. A fork writes no
193
+ `left.json`.
194
+
195
+ The refusal side: a push into a session that has been continued is refused with the next link's id
196
+ (`context_continued`, chi web 410, `chi context push` exit 1), from `SessionAccess` (`session_access.rb`) and chi web's push route (`web/session_access_routes.rb`).
197
+
198
+ ## Gates and config
199
+
200
+ | Where | Gate |
201
+ |---|---|
202
+ | Absorb at all | the conversation has started (`conversation_started?`), or `before_turn: true`; the session isn't stopped on disk |
203
+ | A wake turn | `context.wake`; `WorkerWakes#context_open?`; `CONTEXT_WAKE_WINDOW`; nothing queued and no exit requested; peer: `context.peer_turns`, `peer_open?` |
204
+ | Mention attach | `OwnerPrompt.turn?` (owner author, not a review session) |
205
+ | Judge | `context.candidates.mode` `audit`/`on` (default `off`); a System 1 client configured; not a participant's turn, review session, scratch session or Continue |
206
+ | Command source defined by the agent | `chi-context-cmd` guardrails rule asks, always; a parent agent can't approve it for a child |
207
+ | Writes to the store | `ProtectedPaths` denies the file tools |
208
+
209
+ Keys, all in [../configuration.md](../configuration.md): `context.every_seconds`, `context.wake`,
210
+ `context.peer_turns`, `session.max_wakes`, `context.candidates.{mode,threshold,offer_threshold,reask_after_turns}`.
211
+
212
+ ## Data files
213
+
214
+ | File | Written by | Read by |
215
+ |---|---|---|
216
+ | `context/{projects/<key>,sessions/<id>}/<name>.json` | `chi context add`, a provider, a plugin (`ctx.context.attach`), `ContextCandidates.attach`, `ContextMove` | poller, absorber, `context_read`, chi web |
217
+ | `<name>.snapshot.json` | `ContextFetch`, `chi context push`, `ContextMove` | absorber, `context_read`, chi web chips |
218
+ | `<name>.lock` | `ContextFetch` / `Location#locked` | same |
219
+ | `sessions/<id>/subscriptions.json` | the worker (`commit`, `context_read`) only | absorber, chi web (unread dot) |
220
+ | `sessions/<id>/{muted,declined}/<name>` | `chi context mute`, a removal, `ContextMove` | poller, absorber, candidates |
221
+ | `sessions/<id>/candidates/<name>.json` | `ctx.context.propose` | candidates, judge, `/context` |
222
+ | `sessions/<id>/left.json` | `ContextMove` (continue) | `ctx.context.left_behind` |
223
+ | `context_candidates.jsonl` (beside `sessions/`) | `ContextCandidatesLog` | judge (`due?`), choice (`carded?`), `chi context report` |
224
+
225
+ Specs: `spec/context_*_spec.rb`; a store under test uses an isolated `XDG_STATE_HOME`.
@@ -26,7 +26,7 @@ Behavior:
26
26
  - The exit code: the command runs after an EXIT trap that writes the shell's status to the `exit_code` file (`TaskRuntime.wrapped_shell_command`, shared with `task_create`), so `exit N`, a `set -e` failure and a trailing `\` or `|` in the command all record what the shell exited with. The trap runs wherever the shell is when it exits (a `task_create` `cwd`, or a `cd` in the command), so `task_create`'s `exit_code_path` is absolute; its `output_path` stays relative (`tmp/tasks/<id>/output.log`, opened by chi in the worker's cwd before the spawn). A command that sets its own EXIT trap replaces it, and a shell killed by a signal skips it (zsh's sh mode, on TERM, INT and KILL): `execute` still reports the shell's status, and a `task_create` task or a handed-over execute gets its code from chi's waiter while the chi that started it runs (`TaskRuntime.record_exit_status`): the exit status, or `128 + the signal` (TERM → 143), with no new `stop_reason`. A record a refresh already finished without a code in between (`failed`, `process_ended_without_exit_code` or the not-chi's-process reason) is corrected, its `finished_at` kept; a stopped task (its stop flag on disk) is never touched. `process_ended_without_exit_code` is left only when the shell wrote nothing and the chi that started it was gone too.
27
27
  - A stop of an `execute` (the user's Stop, the limit in kill mode) is done once the shell is reaped and nothing holds the logs: a child that ignores TERM after its shell died gets the KILL.
28
28
  - A command still running at `execute.timeout_sec` is handed over as a task (`execute.on_timeout: task`, the default; `kill` stops it instead, and the `!cmd` bang always kills): the result starts `Still running at the 120s limit: moved to a background task, not killed.`, then `task_id: …` and the tails of both logs so far. A command is also handed over early when a person's message waits for the turn's next step (the `message_waiting:` probe, the same `Engine#input_waiting?` as `task_wait`'s): after `Execute::EARLY_HAND_OVER_FLOOR_SEC` (3 s: a short command ends first and its result is the better answer), in the next 0.2 s slice. The first line then reads `Still running at the 4s mark: a message from the user is waiting, so the command moved to a background task, not killed.` (the seconds it had run), and the line after `task_id:` starts `Read the message first.`; the rest is the same hand-over, and the task stays watched. Never in `kill` mode (`execute.on_timeout: kill`, a participant's pair turn, the `!cmd` bang): there the probe is not even read and the command runs to its limit and is killed. A command that can't be adopted at that moment (a record that can't be written) isn't killed early: it runs on to its limit. Everything that matches the first line builds on `Execute::HANDED_OVER` (`Still running at the`) and `Execute::HANDED_OVER_AFTER` (`120s limit:` or `4s mark:`): `TaskRuntime.handed_over_pattern`, `ToolActivity::HANDED_OVER_START`, `TaskWatch` (`limit_sec` for the limit's form, `early_sec` for the mark's, which the finish note words as `handed over by execute after 4s: a message from the user was waiting`) and the web's `task_stop.js`; the slow-commands bundle reads only the limit's form, which is the only one that is a timeout. A timeout during the 1.5 s background grace is a kill (the shell is gone, nothing to hand over). If the record can't be written, the command is killed. `TaskRuntime.adopt` writes a record for the process execute already started, with `handed_over_by: "execute"`. Its logs stay where execute wrote them, so the record's paths are absolute: `output_path` (stdout), `stderr_path` (stderr, a log of its own) and `exit_code_path` (a `task_create` record's `exit_code_path` is absolute too; its `output_path` is relative). `started_at` (and `created_at`) is the spawn time, not the hand-over's; `pid_started` is read at the hand-over, while execute's detach waiter is still running. From there it is an ordinary task: `task_wait`, `task_get`, `task_list` and `task_stop` treat it as any other. Its dir gets a `task` back-pointer file holding the record's absolute path. Once per process, the first `execute` sweeps `…/execute/`: a dir whose logs no process holds is removed when it has no back-pointer and is a day old (chi died mid-command), when its record is gone, or when it is 14 days old. The records live in each project's `tmp/tasks`, which the state dir can't list: hence the back-pointer.
29
- - `task_wait` and `task_get` show `stderr_path` when the record has one; `task_wait`'s output tail is followed by a `stderr_tail:` of at most 10 lines when that log isn't empty. A `task_create` task has one log (stdout and stderr together) and no `stderr_path`. A `task_wait` result longer than `max_tool_output_chars` keeps its header and the tail's end, with a gap line naming `output_path`, where the whole log already is (and `stderr_path`, when the record has one) (`ToolOutputCut`'s `:head_tail_ref`; nothing is copied; see [Tool output guardrails](tool-guardrails.md#the-runners-cut-max_tool_output_chars)).
29
+ - `task_wait` and `task_get` show `stderr_path` when the record has one; `task_wait`'s output tail is followed by a `stderr_tail:` of at most 10 lines when that log isn't empty. A `task_create` task has one log (stdout and stderr together) and no `stderr_path`. A `task_wait` result longer than `max_tool_output_chars` keeps its header and the tail's end, with a gap line naming `output_path`, where the whole log already is (and `stderr_path`, when the record has one) (`ToolOutputCut`'s `:head_tail_ref`; nothing is copied; see [Tool output guardrails](tool-output-limits.md#the-runners-cut-max_tool_output_chars)).
30
30
  - Which tasks are a session's own (a cancelled turn's note, a worker's restart hold, the finish wake below, the web's stop-task button): the ids its `task_create` results and its handed-over executes name (`TaskRuntime.created_ids_in`, `created_id`). An execute's id is read only after its first line (`Execute::HANDED_OVER`, through `TaskRuntime.handed_over_id`; the web has a JS twin in `task_stop.js`), not from a `task_id:` line inside a run, which the command can print. (A command that prints the `---` separator chi joins a turn's results with, then a hand-over line, still fakes one: a known gap.) Either result may start with the `ran as: …` line of a gate-corrected call.
31
31
  - `task_get` returns metadata and `output_path`; use `read` for output contents.
32
32
  - `task_wait` accepts `timeout`, `tail_lines` (maximum 100), and `done_pattern` (a regular expression string).