samagotchi 0.139.0 → 0.140.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -1
  3. data/README.md +2 -1
  4. data/docs/architecture.md +6 -4
  5. data/docs/cli.md +4 -2
  6. data/docs/configuration.md +36 -2
  7. data/docs/context.md +185 -44
  8. data/docs/guardrails.md +11 -0
  9. data/docs/internals/background-tasks.md +1 -1
  10. data/docs/internals/engine.md +7 -1
  11. data/docs/internals/prompt-caching.md +10 -1
  12. data/docs/internals/tool-guardrails.md +1 -1
  13. data/docs/plugins.md +35 -17
  14. data/docs/sessions.md +3 -2
  15. data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
  16. data/lib/samagotchi/bundle_command.rb +21 -21
  17. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/github-pr/manifest.yml +4 -4
  19. data/lib/samagotchi/bundles/github-pr/plugin.rb +72 -176
  20. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/mcp/plugin.rb +8 -3
  22. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/system/self_map.md +2 -2
  24. data/lib/samagotchi/cancellation_controller.rb +4 -4
  25. data/lib/samagotchi/config.rb +8 -0
  26. data/lib/samagotchi/context_candidate_choice.rb +154 -0
  27. data/lib/samagotchi/context_candidates.rb +259 -0
  28. data/lib/samagotchi/context_candidates_log.rb +90 -0
  29. data/lib/samagotchi/context_command.rb +33 -8
  30. data/lib/samagotchi/context_fetch.rb +2 -2
  31. data/lib/samagotchi/context_judge.rb +194 -0
  32. data/lib/samagotchi/context_report_command.rb +161 -0
  33. data/lib/samagotchi/context_sources.rb +67 -23
  34. data/lib/samagotchi/cut.rb +21 -0
  35. data/lib/samagotchi/cut_policy.rb +6 -5
  36. data/lib/samagotchi/engine.rb +189 -32
  37. data/lib/samagotchi/hook_routes.rb +3 -2
  38. data/lib/samagotchi/kernel_loop.rb +8 -6
  39. data/lib/samagotchi/launch_mode.rb +5 -2
  40. data/lib/samagotchi/llm/chat_loop.rb +3 -2
  41. data/lib/samagotchi/llm/openai_chat.rb +1 -1
  42. data/lib/samagotchi/memory_bundle/status.rb +13 -3
  43. data/lib/samagotchi/owner_prompt.rb +39 -0
  44. data/lib/samagotchi/plugin/api.rb +1 -0
  45. data/lib/samagotchi/plugin/attached_context.rb +91 -11
  46. data/lib/samagotchi/plugin/context.rb +7 -1
  47. data/lib/samagotchi/project_env.rb +16 -2
  48. data/lib/samagotchi/prompt_warmup.rb +9 -1
  49. data/lib/samagotchi/session_access/contract.rb +2 -0
  50. data/lib/samagotchi/session_access/local.rb +10 -0
  51. data/lib/samagotchi/session_access/remote.rb +14 -0
  52. data/lib/samagotchi/session_access.rb +5 -0
  53. data/lib/samagotchi/session_commands.rb +28 -5
  54. data/lib/samagotchi/side_judgments.rb +84 -0
  55. data/lib/samagotchi/steer_cut.rb +2 -1
  56. data/lib/samagotchi/terminal_ui.rb +15 -0
  57. data/lib/samagotchi/testing/boundary.rb +3 -1
  58. data/lib/samagotchi/testing/host.rb +4 -1
  59. data/lib/samagotchi/toolkit_report_command.rb +2 -1
  60. data/lib/samagotchi/toolkit_suggest.rb +3 -18
  61. data/lib/samagotchi/turn_note.rb +6 -6
  62. data/lib/samagotchi/version.rb +1 -1
  63. data/lib/samagotchi/web/app.rb +10 -3
  64. data/lib/samagotchi/web/auto_update.rb +8 -1
  65. data/lib/samagotchi/web/public/app.js +11 -3
  66. data/lib/samagotchi/web/public/composer.css +1 -1
  67. data/lib/samagotchi/web/public/index.html +10 -9
  68. data/lib/samagotchi/web/public/notifications.js +10 -1
  69. data/lib/samagotchi/web/public/revise_stack.js +6 -5
  70. data/lib/samagotchi/web/public/update_notice.js +21 -8
  71. data/lib/samagotchi/web/session_access_routes.rb +1 -1
  72. data/lib/samagotchi/web/session_hub.rb +5 -2
  73. data/lib/samagotchi/web/update_check.rb +14 -8
  74. data/lib/samagotchi/worker.rb +5 -0
  75. metadata +9 -1
data/docs/plugins.md CHANGED
@@ -820,6 +820,7 @@ full label, `plugin.rb (bundle my-bundle)`.
820
820
  | `ctx.repo_root` | the git checkout holding `cwd`, or nil |
821
821
  | `ctx.scratch?` | whether the session is a `chi scratch` one (deleted when it ends) |
822
822
  | `ctx.delegate?` | whether the session is a delegate child: a task another session handed over |
823
+ | `ctx.review?` | whether the session is a review session (`chi review`): one its participants read, not the owner's working session |
823
824
  | `ctx.frontend` | what runs the session, read now: `:repl` (the REPL, `-p` without `--non-interactive`), `:one_shot` (`-p --non-interactive`: one turn, then exit) or `:worker` (a session worker: the web, an attached TUI). Only a worker's answers reach the web while it runs; a REPL's show there once the session is opened. Until the host sets it (while the plugins load, inside `Engine.new`) it reads `:one_shot`, so read it in hooks and init tasks, not in `register` |
824
825
  | `ctx.fork?` | whether the session is a fork (`ctx.sessions.fork`): it has a parent but isn't a delegate child, and started from a conversation, so its first prompt isn't its first user message |
825
826
  | `ctx.model` | the model the session runs on now: its resolved ref (`host:id`), right after `/model` too |
@@ -1133,8 +1134,9 @@ ctx.context.attach(url: "https://github.com/acme/app/pull/42", why: "branch feat
1133
1134
  ctx.context.attach(name: "ci", cmd: "bin/ci-status", why: "this branch's CI", every_seconds: 120)
1134
1135
  ctx.context.list # => [{name:, scope:, why:, hint:, fetched_at:, error:, provider:, summary:}]
1135
1136
  ctx.context.decline(url: "https://github.com/acme/app/pull/42") # => "pr-42"
1136
- ctx.context.mark_offered("pr-42", "https://github.com/acme/app/pull/42", why: "branch feat/x has open PR #42")
1137
- ctx.context.offered("pr-42") # => #<data Offer name="pr-42", hint="https://…", why="branch…", at="2026-10-08T…Z">, or nil
1137
+ ctx.context.propose(url: "https://github.com/acme/app/pull/42", title: "#42 Make the thing", mentions: ["#42", "feat/x"])
1138
+ ctx.context.candidates # => [#<data Candidate name="pr-42", url="https://…", title="#42 Make the thing", …>]
1139
+ ctx.context.withdraw("pr-42") # => true
1138
1140
  ```
1139
1141
 
1140
1142
  [Attached context](context.md) for the session the plugin runs in: chi runs
@@ -1156,12 +1158,27 @@ gets a note when its text changes.
1156
1158
  `url:`, the name and hint come from the bundle's provider (`name:`
1157
1159
  replaces the name). It returns the name. A source attached already stays
1158
1160
  attached (`chi context rm` removes it).
1159
- - `mark_offered(name, hint, why: nil)` records that the plugin offered a
1160
- source here (the hint is usually its URL; `why:` is kept for the attach
1161
- that may follow); `offered(name)` returns that record
1162
- (`ContextSources::Offer`: `name`, `hint`, `why`, `at`) or nil. A plugin that asks
1163
- before it attaches uses it so a worker restart doesn't ask again. Neither
1164
- an attach nor a decline clears it; deleting the session does.
1161
+ - `propose(url:, title:, name: nil, describe: nil, mentions: [])` or
1162
+ `propose(name:, cmd:, title:, describe: nil, mentions: [])` records a
1163
+ [candidate](context.md#candidates): a source proposed for this session,
1164
+ not attached. chi attaches it when the session's owner mentions one of its
1165
+ `mentions` in a prompt (the matching rules are in context.md), with the
1166
+ why "you mentioned …". `title` is what a person reads (no probabilities or
1167
+ place facts: "#42 Make the thing"), `describe` more of it. A candidate of
1168
+ that name stays as first proposed (a worker restart proposing again
1169
+ changes nothing). Nothing is proposed (nil) in a review session, for a
1170
+ source attached already (its name or its URL, at any scope), or for one
1171
+ the user removed from the session. A new candidate that an owner prompt
1172
+ already in the session mentions is attached at once: an init task's
1173
+ proposal usually lands while the first turn runs, and that turn's prompt
1174
+ still counts (its note comes with the next turn).
1175
+ - `candidates` lists them (`ContextSources::Candidate`: `name`, `url`,
1176
+ `cmd`, `title`, `describe`, `mentions`, `proposed_by`, `at`), by name.
1177
+ Whether one is attached or declined is never stored on it: `chi context
1178
+ ls` and `/context` derive it from the session's sources and declined
1179
+ markers. `withdraw(name)` takes a candidate back (true when there was
1180
+ one); a source attached from it stays attached. Neither an attach nor a
1181
+ decline removes a candidate; deleting the session does.
1165
1182
  - The source is the session's (not the project's), marked
1166
1183
  `added_by: plugin:<bundle>`. It is plugin code, so it isn't asked about as
1167
1184
  the agent's `chi context add --cmd` is.
@@ -1171,11 +1188,11 @@ gets a note when its text changes.
1171
1188
  (nil before any text; chi 0.63.0).
1172
1189
  - Two attaches of one source at once (a double click) both return its
1173
1190
  name.
1174
- - `attach`, `decline` and `mark_offered` raise
1191
+ - `attach`, `decline` and `propose` raise
1175
1192
  `Samagotchi::Plugin::AttachedContext::Error` with the reason (no session
1176
1193
  yet, no provider for the URL, a bad name).
1177
1194
 
1178
- The [github-pr bundle](#the-github-pr-bundle) attaches the branch's PR from
1195
+ The [github-pr bundle](#the-github-pr-bundle) proposes the branch's PR from
1179
1196
  a quiet `chi.init` task.
1180
1197
 
1181
1198
  ### Context providers in the manifest
@@ -2126,18 +2143,19 @@ bundles:
2126
2143
  chi bundle install github-pr # in the dev profile; needs gh, logged in
2127
2144
  ```
2128
2145
 
2129
- Attaches the branch's open GitHub pull request to a session as `pr-<n>` when
2130
- its worker starts (not in a scratch session or a delegate child; quietly
2131
- nothing without `gh`, a repository or an open PR), and resolves PR URLs for
2132
- `chi context add`. Its script prints the PR as text
2146
+ Proposes the branch's open GitHub pull request to a session as a candidate
2147
+ `pr-<n>` when its worker starts (not in a scratch session; quietly nothing
2148
+ without `gh`, a repository or an open PR): it is attached once you mention it
2149
+ (`#N`, the branch or its URL; [Candidates](context.md#candidates)). It resolves
2150
+ PR URLs for `chi context add`. Its script prints the PR as text
2133
2151
  and a summary of what changed in counts, authors and states only; it wakes
2134
2152
  the session for a review requesting changes, checks turning red, or the PR
2135
2153
  merged or closed. In the web, a `path:line` in a worker's answer links to
2136
2154
  that line of the session's PR ([Line links](context.md#line-links); `line_links:
2137
2155
  always` for the REPL and `chi -p` too), and its ref menu offers the file
2138
- here and a "PR diff" action (`event[:refs]`). `auto_attach:
2139
- offer` asks with a card first (`/pr-attach N`, `/pr-decline N`), `off`
2140
- attaches nothing ([Attach, offer or off](context.md#attach-offer-or-off)).
2156
+ here and a "PR diff" action (`event[:refs]`). `auto_attach: attach` attaches
2157
+ it at once (the old default), `off` does nothing ([Propose, attach or
2158
+ off](context.md#propose-attach-or-off)).
2141
2159
  See [Attached context](context.md#github-prs-the-github-pr-bundle).
2142
2160
 
2143
2161
  ## The coordinator bundle
data/docs/sessions.md CHANGED
@@ -386,7 +386,8 @@ mise.toml pins Ruby 3.4 gets that `ruby`, `gem` and `bundle` in its tools while
386
386
  belong to the starter's Ruby), unless mise sets its own; kept when the Ruby stays the same.
387
387
  - **Never what a `chi` in a tool reads.** The tools' env never takes `HOME`, any `XDG_*`, any `SAMAGOTCHI_*`,
388
388
  `CHI_REMOTE`, `RACK_ENV` or `CI` from the project (a `chi` run there would read another config or state dir, or
389
- send its session commands to another machine): they are dropped
389
+ send its session commands to another machine), nor `TMPDIR` (the guardrails judge a tool's `$TMPDIR/x` and its
390
+ tmp dirs by the worker's own `TMPDIR`, so the tools run on that one): they are dropped
390
391
  and the debug log names them once (`project_env_reserved`). `mise env` runs without `MISE_YES` (it would trust an
391
392
  untrusted mise.toml for you) and `CI`; your `MISE_TRUSTED_CONFIG_PATHS` stays.
392
393
  - **Only this session.** A worker started from this one (a restart, a delegate child) starts on the worker's own
@@ -403,7 +404,7 @@ mise.toml pins Ruby 3.4 gets that `ruby`, `gem` and `bundle` in its tools while
403
404
  included, asks you first; a read-only mention (`find . -name mise.toml`) doesn't. `.envrc` and mise's local files
404
405
  (`mise.local.toml`, `mise.<env>.local.toml`, dotted or not, and `config.local.toml` / `config.<env>.local.toml` in
405
406
  `.config/mise/`, `mise/`, `.mise/`) are secrets to them, as `.env` is.
406
- - **What tools still drop**: every `execute`, task and `!cmd` takes no `RUBYOPT`, `RUBYLIB`, `BUNDLE_GEMFILE`,
407
+ - **What tools still drop**: every `execute`, task, `!cmd`, context source and MCP server takes no `RUBYOPT`, `RUBYLIB`, `BUNDLE_GEMFILE`,
407
408
  `BUNDLE_BIN_PATH` or `BUNDLER_VERSION` from the project's mise env: it runs without `RUBYLIB` and Bundler's
408
409
  variables, and with the worker's own `RUBYOPT`, Bundler's part removed (`TaskRuntime`).
409
410
  - **`chi` in a tool** stays the home's: with a chi home the tools' PATH starts with `$XDG_STATE_HOME/samagotchi/bin`,
@@ -77,7 +77,9 @@ module Samagotchi
77
77
  # The turn in progress as conversation messages, for a plugin's
78
78
  # ctx.messages mid-turn (plan O1): its prompt (with its images), the
79
79
  # model's text so far, and the lines merged into it, in order. Tool
80
- # calls and thinking are left out.
80
+ # calls and thinking are left out. The prompt carries the turn's id,
81
+ # as the saved one will (OwnerPrompt.message?: a late candidate
82
+ # proposal matches the running turn's prompt too).
81
83
  # @return [Array<Hash>] [] with no turn running
82
84
  def current_messages
83
85
  @mutex.synchronize { @turn ? self.class.messages_of(@turn) : [] }
@@ -94,6 +96,7 @@ module Samagotchi
94
96
  end
95
97
  unless turn[:prompt].nil?
96
98
  prompt = { role: "user", content: turn[:prompt].to_s, **author_of(turn) }
99
+ prompt[:turn_id] = turn[:turn_id] if turn[:turn_id]
97
100
  prompt[:images] = turn[:images] if turn[:images]
98
101
  messages << prompt
99
102
  end
@@ -447,17 +447,17 @@ module Samagotchi
447
447
  @stdout.puts "Bundle '#{name}' not installed."
448
448
  return 0
449
449
  end
450
- @stdout.puts "Bundle: #{name} v#{st[:provenance].version}#{dev_label(st[:provenance])} scope=#{status_scope(st)} installed=#{st[:provenance].installed_at}"
451
- if st[:scope_error]
450
+ @stdout.puts "Bundle: #{name} v#{st.provenance.version}#{dev_label(st.provenance)} scope=#{status_scope(st)} installed=#{st.provenance.installed_at}"
451
+ if st.scope_error
452
452
  @stdout.puts "Target: (unknown scope: this chi can't resolve it; upgrade chi or reinstall the bundle)"
453
453
  else
454
- @stdout.puts "Target: #{st[:target_dir]}"
454
+ @stdout.puts "Target: #{st.target_dir}"
455
455
  end
456
- st[:files].each do |k, info|
456
+ st.files.each do |k, info|
457
457
  next @stdout.puts(" #{k}: unchecked") if info[:unchecked]
458
458
 
459
459
  mods = []
460
- mods << "conflict (kept your edits over v#{st[:provenance].version}: chi bundle diff #{name} #{k})" if info[:conflict]
460
+ mods << "conflict (kept your edits over v#{st.provenance.version}: chi bundle diff #{name} #{k})" if info[:conflict]
461
461
  mods << "modified" if info[:modified]
462
462
  mods << "missing" if info[:missing]
463
463
  mods << "no-index" unless info[:index_present] || info[:overlay]
@@ -466,9 +466,9 @@ module Samagotchi
466
466
  @stdout.puts " #{k}: #{label}"
467
467
  end
468
468
  # Hooks
469
- hooks = st[:provenance].hooks
470
- trust = st[:provenance].trust_level || "experimental"
471
- commit = st[:provenance].source_commit
469
+ hooks = st.provenance.hooks
470
+ trust = st.provenance.trust_level || "experimental"
471
+ commit = st.provenance.source_commit
472
472
  @stdout.puts " trust_level: #{trust}" if hooks.any? || trust
473
473
  @stdout.puts " source_commit: #{commit}" if commit
474
474
  if hooks.any?
@@ -478,17 +478,17 @@ module Samagotchi
478
478
  exists = File.exist?(hook_path) ? "ok" : "missing"
479
479
  @stdout.puts " #{hk}: #{hook.events_field} on_error=#{hook.on_error} priority=#{hook.priority} [#{exists}]"
480
480
  end
481
- @stdout.puts " requires_chi: #{st[:provenance].requires_chi}" if st[:hooks_requires_failure]
482
- @stdout.puts " not loaded: #{st[:hooks_requires_failure]}" if st[:hooks_requires_failure]
481
+ @stdout.puts " requires_chi: #{st.provenance.requires_chi}" if st.hooks_requires_failure
482
+ @stdout.puts " not loaded: #{st.hooks_requires_failure}" if st.hooks_requires_failure
483
483
  end
484
- if (plugin = st[:plugin])
484
+ if (plugin = st.plugin)
485
485
  note = plugin[:state] == "modified" ? " (edited after install: not loaded; reinstall the bundle)" : ""
486
486
  @stdout.puts " Plugin: #{plugin[:file]} [#{plugin[:state]}]#{note}"
487
487
  @stdout.puts " requires_chi: #{plugin[:requires_chi]}" if plugin[:requires_chi]
488
488
  @stdout.puts " not loaded: #{plugin[:requires_failure]}" if plugin[:requires_failure]
489
489
  end
490
- st[:needs].each { |need| @stdout.puts " #{Samagotchi::MemoryBundle::Status.need_line(need)}" }
491
- (st[:provenance].includes || []).each do |member|
490
+ st.needs.each { |need| @stdout.puts " #{Samagotchi::MemoryBundle::Status.need_line(need)}" }
491
+ (st.provenance.includes || []).each do |member|
492
492
  state = Samagotchi::MemoryBundle::Provenance.new(name: member).installed? ? "installed" : "not installed: chi bundle install #{member}"
493
493
  @stdout.puts " includes #{member} [#{state}]"
494
494
  end
@@ -499,17 +499,17 @@ module Samagotchi
499
499
  next @stdout.puts(" #{bname} (manifest.json unreadable)") if record.error?
500
500
 
501
501
  st = Samagotchi::MemoryBundle::Status.bundle_status(bname)
502
- mods = st[:files].values.count { |v| v[:modified] || v[:missing] }
503
- hooks_count = st[:provenance].hooks.size
502
+ mods = st.files.values.count { |v| v[:modified] || v[:missing] }
503
+ hooks_count = st.provenance.hooks.size
504
504
  hook_info = hooks_count > 0 ? " hooks=#{hooks_count}" : ""
505
- plugin = st[:plugin]
505
+ plugin = st.plugin
506
506
  mods += 1 if plugin && (plugin[:state] != "ok" || plugin[:requires_failure])
507
- mods += 1 if st[:hooks_requires_failure]
508
- mods += 1 if st[:scope_error]
507
+ mods += 1 if st.hooks_requires_failure
508
+ mods += 1 if st.scope_error
509
509
  plugin_info = plugin ? " plugin=#{plugin[:file]}" : ""
510
- includes = st[:provenance].includes
510
+ includes = st.provenance.includes
511
511
  includes_info = includes ? " includes=#{includes.join(",")}" : ""
512
- @stdout.puts " #{bname} v#{st[:provenance].version}#{dev_label(st[:provenance])} scope=#{status_scope(st)} files=#{st[:files].size}#{hook_info}#{plugin_info}#{includes_info} issues=#{mods}"
512
+ @stdout.puts " #{bname} v#{st.provenance.version}#{dev_label(st.provenance)} scope=#{status_scope(st)} files=#{st.files.size}#{hook_info}#{plugin_info}#{includes_info} issues=#{mods}"
513
513
  end
514
514
  end
515
515
  0
@@ -523,7 +523,7 @@ module Samagotchi
523
523
  end
524
524
 
525
525
  # "team (unknown)" for a scope this chi can't resolve (Status#bundle_status).
526
- def status_scope(st) = st[:scope_error] ? "#{st[:scope]} (unknown)" : st[:scope]
526
+ def status_scope(st) = st.scope_error ? "#{st.scope} (unknown)" : st.scope
527
527
 
528
528
  def diff(rest)
529
529
  args = rest.reject { |a| a.start_with?("--") }
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: dev
3
- version: 0.7.3
3
+ version: 0.7.4
4
4
  scope: system
5
5
  description: "Developer tools"
6
6
  trust_level: reviewed
7
- requires_chi: ">= 0.121.0"
7
+ requires_chi: ">= 0.140.0"
8
8
  includes:
9
9
  - known-names
10
10
  - mcp
@@ -1,17 +1,17 @@
1
1
  ---
2
2
  name: github-pr
3
- version: 0.5.1
3
+ version: 0.6.0
4
4
  scope: system
5
- description: Attaches the branch's open GitHub pull request to a session as context (and any PR URL with chi context add or the web's "+ URL"); the agent gets a note when reviews, comments, checks or its state change; in the web, a path:line in an answer links to that line of the PR; the auto_attach setting can offer the PR with a card first, or attach nothing
5
+ description: Proposes the branch's open GitHub pull request to a session, attached once you mention it (#N, the branch or its URL), and attaches any PR URL with chi context add or the web's "+ URL"; the agent gets a note when reviews, comments, checks or its state change; in the web, a path:line in an answer links to that line of the PR; the auto_attach setting can attach the PR at once instead, or do nothing
6
6
  trust_level: reviewed
7
- requires_chi: ">= 0.61.0"
7
+ requires_chi: ">= 0.140.0"
8
8
  needs:
9
9
  - command: gh
10
10
  why: reads the pull request
11
11
  hint: brew install gh, then gh auth login
12
12
  plugin:
13
13
  file: plugin.rb
14
- sha256: sha256:cfadb7668528302b3631681e570899230b6878c80ccbc87dfb16c80d43dead7b
14
+ sha256: sha256:558bd4191f28b7c54d4521f55e8514a63568d5e2df5a4937856821fd88de508f
15
15
  scripts:
16
16
  pr_context.rb: sha256:a883d2780e89c68c0d7b6d5e0fcb22c273f17bb167e79064e0e99db6bd2ee6f0
17
17
  context_providers:
@@ -7,12 +7,15 @@ require "time"
7
7
  require "tmpdir"
8
8
 
9
9
  # github-pr: a session on a branch with an open GitHub PR gets the PR
10
- # attached as context (docs/context.md) when its worker starts: a quiet
11
- # init task asks git for the branch and gh for its PR, and attaches it
12
- # through this bundle's provider (manifest context_providers, which also
13
- # serves `chi context add <PR URL>` and the web's "+ URL"). Nothing for a
14
- # scratch session or a delegate child (D12), and nothing, quietly, without
15
- # gh, its login, a repo or an open PR.
10
+ # proposed as a candidate (docs/context.md, Candidates) when its worker
11
+ # starts: a quiet init task asks git for the branch and gh for its PR, and
12
+ # proposes it through this bundle's provider (manifest context_providers,
13
+ # which also serves `chi context add <PR URL>` and the web's "+ URL"),
14
+ # titled "#N <title>", mentioned by "#N", the branch and the URL. chi
15
+ # attaches it once the owner mentions one of them. Nothing for a scratch
16
+ # session, and nothing, quietly, without gh, its login, a repo or an open
17
+ # PR. At a turn's start, more than RELOOK_SECONDS after the last look, it
18
+ # looks again on a thread of its own (a branch switched mid-session).
16
19
  #
17
20
  # Line links: in the web, a `lib/foo.rb:28` (or `lib/foo.rb:28-34`, in
18
21
  # inline code too) in the model's answer links to that line of the PR the
@@ -32,11 +35,9 @@ require "tmpdir"
32
35
  # the REPL and -p too (still not for a scratch session).
33
36
  #
34
37
  # `bundles: github-pr: auto_attach:` says what the init task does with the
35
- # branch's open PR: `attach` (the default), `offer` (a card with Attach and
36
- # Not here, which run /pr-attach N and /pr-decline N; nothing attached until
37
- # a click; offered once per session) or `off` (nothing). In offer mode the
38
- # offers, their outcomes and each session's first prompt go to
39
- # <data_dir>/offers.ndjson (OffersLog), to find heuristics later.
38
+ # branch's open PR: `propose` (the default, above), `attach` (attached at
39
+ # once, the old default: not for a delegate child, D12) or `off`
40
+ # (nothing). `offer` (the card it once posted) is read as `propose`.
40
41
  class Plugin
41
42
  # gh and git each get this long (a network hang must not keep a thread).
42
43
  COMMAND_SECONDS = 20
@@ -44,14 +45,12 @@ class Plugin
44
45
  GENERATION_CHECK_SECONDS = 30
45
46
  # line_links' values: off, a worker's only (the default), every frontend.
46
47
  LINE_LINKS_MODES = { false => :off, true => :worker, "always" => :always }.freeze
47
- # auto_attach's values (YAML's bare true/false: attach/off).
48
- AUTO_ATTACH_MODES = { "attach" => :attach, "offer" => :offer, "off" => :off, true => :attach, false => :off }.freeze
49
- # /pr-attach and /pr-decline take a PR number (`42` or `#42`).
50
- PR_NUMBER = /\A#?(\d+)\z/
51
- # The offer card's line under the PR's title.
52
- OFFER_LINE = "Not attached: the agent gets nothing until you attach it."
53
- # first_prompt keeps this much of the prompt.
54
- FIRST_PROMPT_CHARS = 160
48
+ # auto_attach's values (YAML's bare true/false: attach/off). `offer`, the
49
+ # card it once posted, reads as propose (warned once).
50
+ AUTO_ATTACH_MODES = { "propose" => :propose, "attach" => :attach, "offer" => :propose, "off" => :off, true => :attach,
51
+ false => :off }.freeze
52
+ # A turn's start looks for the branch's PR again after this long.
53
+ RELOOK_SECONDS = 300
55
54
  # A git remote URL's GitHub owner and repo (https, ssh or scp-like).
56
55
  REMOTE_REPO = %r{github\.com[:/]([^/\s]+)/([^/\s]+?)(?:\.git)?/?\z}i
57
56
 
@@ -59,6 +58,9 @@ class Plugin
59
58
  @cache = PrCache.new
60
59
  @generation_checked_at = nil
61
60
  @warned_mode = false
61
+ @looked_at = nil
62
+ @look_mutex = Mutex.new
63
+ @looking = false
62
64
  @warned_line_links = false
63
65
  # cwd => the "owner/repo"s (downcased) its git remotes name on
64
66
  # github.com, for the worker's life.
@@ -67,7 +69,7 @@ class Plugin
67
69
 
68
70
  def register(chi)
69
71
  chi.init("Looking for this branch's pull request", quiet: true) do |ctx|
70
- summary = attach_branch_pr(ctx)
72
+ summary = look(ctx)
71
73
  # Inline (an init task has its own thread), whatever the attach
72
74
  # returned (attached already, a child, a scratch session), and after
73
75
  # it, so a PR it just attached is warm for the first answer.
@@ -78,13 +80,7 @@ class Plugin
78
80
  # The prompt too: messages is the history before this turn, and a
79
81
  # delegate child's task is its first prompt.
80
82
  kick(ctx, attached_prs(ctx) + PrRef.from_messages(event[:messages], prompt: event[:prompt])) if prefetch?(ctx)
81
- log_first_prompt(event, ctx)
82
- end
83
- chi.command("/pr-attach", "Attach the PR this session was offered: /pr-attach 42", anytime: true) do |args, ctx|
84
- offer_command("/pr-attach", args, ctx) { |number, offer| attach_offered(ctx, number, offer) }
85
- end
86
- chi.command("/pr-decline", "Don't attach the PR this session was offered: /pr-decline 42", anytime: true) do |args, ctx|
87
- offer_command("/pr-decline", args, ctx) { |number, offer| decline_offered(ctx, number, offer) }
83
+ relook(ctx)
88
84
  end
89
85
  chi.on(:before_generation) { |_event, ctx| check_attached(ctx) }
90
86
  # After source-links (90): its links are in the display this one gets.
@@ -94,11 +90,12 @@ class Plugin
94
90
  # @return [String] what it did (the init task's summary)
95
91
  def attach_branch_pr(ctx)
96
92
  return "skipped: a scratch session" if ctx.scratch?
97
- return "skipped: a delegate child" if ctx.delegate?
98
93
  return "no session yet" unless ctx.session_id
99
94
 
100
95
  mode = auto_attach_mode(ctx)
101
96
  return "auto_attach: off" if mode == :off
97
+ return "skipped: a delegate child" if mode == :attach && ctx.delegate?
98
+ return "skipped: a review session" if mode == :propose && ctx.review?
102
99
 
103
100
  branch = run(ctx, "git", "branch", "--show-current").to_s.strip
104
101
  return "not on a branch" if branch.empty?
@@ -109,7 +106,7 @@ class Plugin
109
106
 
110
107
  name = "pr-#{pr["number"]}"
111
108
  return "#{name} is attached already" if ctx.context.list.any? { |source| source[:name] == name || source[:hint] == pr["url"] }
112
- return offer_branch_pr(ctx, branch, pr, name) if mode == :offer
109
+ return propose_branch_pr(ctx, branch, pr, name) if mode == :propose
113
110
 
114
111
  attached = ctx.context.attach(url: pr["url"], name: name, why: "branch #{branch} has open PR ##{pr["number"]}")
115
112
  attached ? "attached #{name}" : "#{name} was removed from this session; not attached again"
@@ -118,19 +115,49 @@ class Plugin
118
115
  "no pull request attached"
119
116
  end
120
117
 
121
- # `bundles: github-pr: auto_attach:`, read at each worker start.
122
- # @return [Symbol] :attach, :offer or :off; an unknown value is :attach
123
- # (logged once)
118
+ # `bundles: github-pr: auto_attach:`, read at each look.
119
+ # @return [Symbol] :propose, :attach or :off; `offer` and an unknown
120
+ # value are :propose (logged once)
124
121
  def auto_attach_mode(ctx)
125
122
  value = ctx.settings["auto_attach"]
126
- return :attach if value.nil?
123
+ return :propose if value.nil?
127
124
 
128
125
  key = value.is_a?(String) ? value.strip.downcase : value
126
+ warn_mode(ctx, :pr_auto_attach_offer_is_propose, value) if key == "offer"
129
127
  AUTO_ATTACH_MODES.fetch(key) do
130
- ctx.log.warn(:pr_auto_attach_unknown, value: value.to_s[0, 40]) unless @warned_mode
131
- @warned_mode = true
132
- :attach
128
+ warn_mode(ctx, :pr_auto_attach_unknown, value)
129
+ :propose
130
+ end
131
+ end
132
+
133
+ # #attach_branch_pr, remembering when (a turn's start looks again after
134
+ # RELOOK_SECONDS).
135
+ def look(ctx)
136
+ @look_mutex.synchronize { @looked_at = now }
137
+ attach_branch_pr(ctx)
138
+ end
139
+
140
+ # A turn's start: the branch's PR looked for again, on a thread of its
141
+ # own (git and gh never hold the turn), when the last look is more than
142
+ # RELOOK_SECONDS old and none runs. Propose mode only: attach mode
143
+ # attaches at the worker's start alone, as it always did.
144
+ def relook(ctx)
145
+ return if ctx.scratch? || auto_attach_mode(ctx) != :propose
146
+
147
+ claimed = @look_mutex.synchronize do
148
+ next false if @looking || @looked_at.nil? || now - @looked_at < RELOOK_SECONDS
149
+
150
+ @looking = true
151
+ end
152
+ return unless claimed
153
+
154
+ thread = Thread.new do
155
+ ctx.log.debug(:pr_relooked, summary: look(ctx))
156
+ ensure
157
+ @look_mutex.synchronize { @looking = false }
133
158
  end
159
+ thread.report_on_exception = false
160
+ thread
134
161
  end
135
162
 
136
163
  # The after_turn handler: links the answer's `path:line` refs from the
@@ -224,113 +251,26 @@ class Plugin
224
251
  end
225
252
  end
226
253
 
227
- def offer_card_id(number) = "github-pr-offer-#{number}"
228
-
229
- # Offer mode: a card instead of the attach, once per session per PR (a
230
- # worker restart finds the offered marker). The marker is written after
231
- # the card, so a card that failed leaves none.
232
- def offer_branch_pr(ctx, branch, pr, name)
233
- return "#{name} was removed from this session; not offered" if ctx.context.declined?(name, url: pr["url"])
234
- return "#{name} was offered already" if ctx.context.offered(name)
235
-
254
+ # Propose mode: the PR as a candidate; an owner prompt already in the
255
+ # session that mentions it attaches it at once (ctx.context.propose).
256
+ def propose_branch_pr(ctx, branch, pr, name)
236
257
  number = pr["number"]
237
- body = [pr["title"].to_s.gsub(/\s+/, " ").strip, OFFER_LINE].reject(&:empty?).join("\n\n")
238
- ctx.card(id: offer_card_id(number), title: "PR ##{number} for branch #{branch}", body: body, level: :info,
239
- actions: [{ label: "Attach", command: "/pr-attach #{number}" },
240
- { label: "Not here", command: "/pr-decline #{number}" }])
241
- ctx.context.mark_offered(name, pr["url"], why: "branch #{branch} has open PR ##{number}")
242
- offers_log(ctx).write("offered", session: ctx.session_id, project_root: project_root(ctx), cwd: ctx.cwd,
243
- branch: branch, pr: pr["url"], checkout: checkout_kind(ctx), model: ctx.model)
244
- "offered #{name}"
245
- end
246
-
247
- # /pr-attach and /pr-decline: the PR number and its offer, else the
248
- # answer (a usage line, no offer).
249
- def offer_command(command, args, ctx)
250
- number = args.to_s.strip[PR_NUMBER, 1] or return "usage: #{command} <PR number>, as the offer card names it"
251
- number = number.to_i
252
- offer = ctx.context.offered("pr-#{number}")
253
- return "no offered PR ##{number} here; use `chi context add <PR URL>`" unless offer
254
-
255
- yield number, offer
256
- rescue Samagotchi::Plugin::AttachedContext::Error => e
257
- "pr-#{number} not changed: #{e.message}"
258
- end
259
-
260
- # The user's click: attached as auto-attach would, past a decline, with
261
- # the offer's why (no git or gh here).
262
- def attach_offered(ctx, number, offer)
263
- why = "#{offer.why || "open PR ##{number}"} (attached from the offer)"
264
- attached = ctx.context.attach(url: offer.hint, name: offer.name, why: why, force: true)
265
- return "#{offer.name} wasn't attached" unless attached
266
-
267
- ctx.card(id: offer_card_id(number), title: "PR ##{number}", body: "Attached #{attached}")
268
- offers_log(ctx).write("attached", session: ctx.session_id, pr: offer.hint, seconds: seconds_since(offer))
269
- nil
270
- end
271
-
272
- # Not for a PR attached here already (a stale card): that is
273
- # `chi context rm`'s, and a declined marker would leave it attached.
274
- def decline_offered(ctx, number, offer)
275
- attached = ctx.context.list.find { |source| source[:name] == offer.name || source[:hint] == offer.hint }
276
- return "#{attached[:name]} is attached here; `chi context rm #{attached[:name]}` removes it" if attached
277
-
278
- ctx.context.decline(url: offer.hint, name: offer.name)
279
- ctx.card(id: offer_card_id(number), title: "PR ##{number}", body: "Not attached here; + URL attaches it")
280
- offers_log(ctx).write("declined", session: ctx.session_id, pr: offer.hint, seconds: seconds_since(offer))
281
- nil
282
- end
283
-
284
- def seconds_since(offer)
285
- (Time.now - Time.iso8601(offer.at.to_s)).round
286
- rescue ArgumentError
287
- nil
258
+ title = "##{number} #{pr["title"].to_s.gsub(/\s+/, " ").strip}".strip
259
+ proposed = ctx.context.propose(url: pr["url"], name: name, title: title, mentions: ["##{number}", branch, pr["url"]])
260
+ proposed ? "proposed #{name}" : "#{name} was removed from this session; not proposed"
288
261
  end
289
262
 
290
- # Offer mode: every session's first prompt (offered or not; a web chat's
291
- # first turn starts before the init task's gh returns), joined to the
292
- # offers by session at analysis time. First = no user message yet (a
293
- # context note may be in messages already); a fork starts from its
294
- # parent's conversation, user messages and all, so its first is the one
295
- # the log has no first_prompt row for yet.
296
- def log_first_prompt(event, ctx)
297
- return if event[:prompt].nil? || auto_attach_mode(ctx) != :offer
298
- return if ctx.scratch? || ctx.session_id.nil?
299
-
300
- log = offers_log(ctx)
301
- if Array(event[:messages]).any? { |message| message_role(message) == "user" }
302
- return unless ctx.fork? && !log.first_prompt?(ctx.session_id)
303
- elsif ctx.delegate?
304
- return
305
- end
306
- log.write("first_prompt", session: ctx.session_id, prompt: event[:prompt].to_s[0, FIRST_PROMPT_CHARS])
307
- rescue StandardError => e
308
- ctx.log.debug(:pr_offers_log_failed, error: e.class.name)
263
+ def warn_mode(ctx, event, value)
264
+ ctx.log.warn(event, value: value.to_s[0, 40]) unless @warned_mode
265
+ @warned_mode = true
309
266
  end
310
267
 
311
- def message_role(message)
312
- return nil unless message.is_a?(Hash)
313
-
314
- (message.key?(:role) ? message[:role] : message["role"]).to_s
315
- end
316
-
317
- def offers_log(ctx) = OffersLog.new(File.join(ctx.data_dir, OffersLog::FILE), ctx.log)
318
-
319
268
  def project_root(ctx)
320
269
  ctx.repo_root
321
270
  rescue StandardError
322
271
  nil
323
272
  end
324
273
 
325
- # "main" for the main checkout, "worktree" for a linked one (its git dir
326
- # isn't the common one), nil outside a repo.
327
- def checkout_kind(ctx)
328
- dirs = run(ctx, "git", "rev-parse", "--path-format=absolute", "--git-dir", "--git-common-dir").to_s.split("\n")
329
- return nil unless dirs.size == 2
330
-
331
- dirs[0] == dirs[1] ? "main" : "worktree"
332
- end
333
-
334
274
  def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
335
275
 
336
276
  # #refresh on its own thread (a raw one, docs/plugins.md), when there is
@@ -413,50 +353,6 @@ class Plugin
413
353
  end
414
354
  end
415
355
 
416
- # <data_dir>/offers.ndjson: one JSON object a line, {event:, ts:, …},
417
- # private to the user (0600: it holds prompts). Every worker shares it, so
418
- # each write holds offers.ndjson.lock (flock) around the size check, the
419
- # rotation and the append; over MAX_BYTES the file becomes
420
- # offers.ndjson.1 (one old file kept). A failure is logged at debug: the
421
- # log never breaks an attach.
422
- class OffersLog
423
- FILE = "offers.ndjson"
424
- MAX_BYTES = 1024 * 1024
425
- LINE_MAX_BYTES = 4000
426
- MODE = 0o600
427
-
428
- def initialize(path, log)
429
- @path = path
430
- @log = log
431
- end
432
-
433
- def write(event, **fields)
434
- line = "#{JSON.generate({ event: event, ts: Time.now.utc.iso8601 }.merge(fields))}\n"
435
- return @log.debug(:pr_offers_line_too_long, event: event) if line.bytesize > LINE_MAX_BYTES
436
-
437
- File.open("#{@path}.lock", File::RDWR | File::CREAT, MODE) do |lock|
438
- lock.flock(File::LOCK_EX)
439
- File.rename(@path, "#{@path}.1") if File.size?(@path).to_i > MAX_BYTES
440
- File.open(@path, File::WRONLY | File::APPEND | File::CREAT, MODE) { |file| file.write(line) }
441
- end
442
- nil
443
- rescue StandardError => e
444
- @log.debug(:pr_offers_log_failed, error: e.class.name)
445
- nil
446
- end
447
-
448
- # Whether the log (or its old file) has session +id+'s first_prompt row.
449
- def first_prompt?(id)
450
- [@path, "#{@path}.1"].any? do |path|
451
- File.exist?(path) && File.foreach(path).any? do |line|
452
- line.include?(%("session":#{JSON.generate(id)})) && JSON.parse(line)["event"] == "first_prompt"
453
- rescue JSON::ParserError
454
- false
455
- end
456
- end
457
- end
458
- end
459
-
460
356
  # A GitHub pull request, by its URL.
461
357
  PrRef = Data.define(:owner, :repo, :number)
462
358
 
@@ -1,12 +1,12 @@
1
1
  ---
2
2
  name: mcp
3
- version: 0.7.3
3
+ version: 0.7.4
4
4
  scope: system
5
5
  description: "Tools from MCP servers (stdio): each server in config.yml bundles: mcp: servers: runs for the session; their tools go into chi's tool index: the model searches them with find_tools and calls one with call_tool (<server>/<tool>); /mcp lists them"
6
6
  trust_level: reviewed
7
7
  files: {}
8
8
  plugin:
9
9
  file: plugin.rb
10
- sha256: sha256:65532b75b33fa7a58b4a35f9ed32e2705f4e3d323c6f770e71448da5cc9314d0
10
+ sha256: sha256:b892de35b1fc16dd22152cd95a6c90447a2be45f4daff04a4648ab2653463a20
11
11
  # 0.67.0 is the release with chi.tool_source (the core tool index the bundle publishes into).
12
12
  requires_chi: ">= 0.121.0"