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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +62 -1
- data/README.md +2 -1
- data/docs/architecture.md +6 -4
- data/docs/cli.md +4 -2
- data/docs/configuration.md +36 -2
- data/docs/context.md +185 -44
- data/docs/guardrails.md +11 -0
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/engine.md +7 -1
- data/docs/internals/prompt-caching.md +10 -1
- data/docs/internals/tool-guardrails.md +1 -1
- data/docs/plugins.md +35 -17
- data/docs/sessions.md +3 -2
- data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
- data/lib/samagotchi/bundle_command.rb +21 -21
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
- data/lib/samagotchi/bundles/github-pr/manifest.yml +4 -4
- data/lib/samagotchi/bundles/github-pr/plugin.rb +72 -176
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +8 -3
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/self_map.md +2 -2
- data/lib/samagotchi/cancellation_controller.rb +4 -4
- data/lib/samagotchi/config.rb +8 -0
- data/lib/samagotchi/context_candidate_choice.rb +154 -0
- data/lib/samagotchi/context_candidates.rb +259 -0
- data/lib/samagotchi/context_candidates_log.rb +90 -0
- data/lib/samagotchi/context_command.rb +33 -8
- data/lib/samagotchi/context_fetch.rb +2 -2
- data/lib/samagotchi/context_judge.rb +194 -0
- data/lib/samagotchi/context_report_command.rb +161 -0
- data/lib/samagotchi/context_sources.rb +67 -23
- data/lib/samagotchi/cut.rb +21 -0
- data/lib/samagotchi/cut_policy.rb +6 -5
- data/lib/samagotchi/engine.rb +189 -32
- data/lib/samagotchi/hook_routes.rb +3 -2
- data/lib/samagotchi/kernel_loop.rb +8 -6
- data/lib/samagotchi/launch_mode.rb +5 -2
- data/lib/samagotchi/llm/chat_loop.rb +3 -2
- data/lib/samagotchi/llm/openai_chat.rb +1 -1
- data/lib/samagotchi/memory_bundle/status.rb +13 -3
- data/lib/samagotchi/owner_prompt.rb +39 -0
- data/lib/samagotchi/plugin/api.rb +1 -0
- data/lib/samagotchi/plugin/attached_context.rb +91 -11
- data/lib/samagotchi/plugin/context.rb +7 -1
- data/lib/samagotchi/project_env.rb +16 -2
- data/lib/samagotchi/prompt_warmup.rb +9 -1
- data/lib/samagotchi/session_access/contract.rb +2 -0
- data/lib/samagotchi/session_access/local.rb +10 -0
- data/lib/samagotchi/session_access/remote.rb +14 -0
- data/lib/samagotchi/session_access.rb +5 -0
- data/lib/samagotchi/session_commands.rb +28 -5
- data/lib/samagotchi/side_judgments.rb +84 -0
- data/lib/samagotchi/steer_cut.rb +2 -1
- data/lib/samagotchi/terminal_ui.rb +15 -0
- data/lib/samagotchi/testing/boundary.rb +3 -1
- data/lib/samagotchi/testing/host.rb +4 -1
- data/lib/samagotchi/toolkit_report_command.rb +2 -1
- data/lib/samagotchi/toolkit_suggest.rb +3 -18
- data/lib/samagotchi/turn_note.rb +6 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +10 -3
- data/lib/samagotchi/web/auto_update.rb +8 -1
- data/lib/samagotchi/web/public/app.js +11 -3
- data/lib/samagotchi/web/public/composer.css +1 -1
- data/lib/samagotchi/web/public/index.html +10 -9
- data/lib/samagotchi/web/public/notifications.js +10 -1
- data/lib/samagotchi/web/public/revise_stack.js +6 -5
- data/lib/samagotchi/web/public/update_notice.js +21 -8
- data/lib/samagotchi/web/session_access_routes.rb +1 -1
- data/lib/samagotchi/web/session_hub.rb +5 -2
- data/lib/samagotchi/web/update_check.rb +14 -8
- data/lib/samagotchi/worker.rb +5 -0
- 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.
|
|
1137
|
-
ctx.context.
|
|
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
|
-
- `
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
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 `
|
|
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)
|
|
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
|
-
|
|
2130
|
-
its worker starts (not in a scratch session
|
|
2131
|
-
|
|
2132
|
-
|
|
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
|
-
|
|
2140
|
-
|
|
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)
|
|
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
|
|
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
|
|
451
|
-
if st
|
|
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
|
|
454
|
+
@stdout.puts "Target: #{st.target_dir}"
|
|
455
455
|
end
|
|
456
|
-
st
|
|
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
|
|
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
|
|
470
|
-
trust = st
|
|
471
|
-
commit = st
|
|
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
|
|
482
|
-
@stdout.puts " not loaded: #{st
|
|
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
|
|
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
|
|
491
|
-
(st
|
|
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
|
|
503
|
-
hooks_count = st
|
|
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
|
|
505
|
+
plugin = st.plugin
|
|
506
506
|
mods += 1 if plugin && (plugin[:state] != "ok" || plugin[:requires_failure])
|
|
507
|
-
mods += 1 if st
|
|
508
|
-
mods += 1 if st
|
|
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
|
|
510
|
+
includes = st.provenance.includes
|
|
511
511
|
includes_info = includes ? " includes=#{includes.join(",")}" : ""
|
|
512
|
-
@stdout.puts " #{bname} v#{st
|
|
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
|
|
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,17 +1,17 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: github-pr
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.6.0
|
|
4
4
|
scope: system
|
|
5
|
-
description:
|
|
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.
|
|
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:
|
|
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
|
-
#
|
|
11
|
-
# init task asks git for the branch and gh for its PR, and
|
|
12
|
-
# through this bundle's provider (manifest context_providers,
|
|
13
|
-
# serves `chi context add <PR URL>` and the web's "+ URL")
|
|
14
|
-
#
|
|
15
|
-
#
|
|
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: `
|
|
36
|
-
#
|
|
37
|
-
#
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
#
|
|
52
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
122
|
-
# @return [Symbol] :
|
|
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 :
|
|
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
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
238
|
-
ctx.
|
|
239
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
+
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:
|
|
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"
|