samagotchi 0.139.1 → 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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -1
  3. data/README.md +2 -1
  4. data/docs/architecture.md +6 -4
  5. data/docs/cli.md +3 -1
  6. data/docs/configuration.md +33 -1
  7. data/docs/context.md +185 -44
  8. data/docs/guardrails.md +11 -0
  9. data/docs/internals/engine.md +7 -1
  10. data/docs/internals/prompt-caching.md +10 -1
  11. data/docs/plugins.md +35 -17
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
  13. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  14. data/lib/samagotchi/bundles/github-pr/manifest.yml +4 -4
  15. data/lib/samagotchi/bundles/github-pr/plugin.rb +72 -176
  16. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  17. data/lib/samagotchi/config.rb +8 -0
  18. data/lib/samagotchi/context_candidate_choice.rb +154 -0
  19. data/lib/samagotchi/context_candidates.rb +259 -0
  20. data/lib/samagotchi/context_candidates_log.rb +90 -0
  21. data/lib/samagotchi/context_command.rb +33 -8
  22. data/lib/samagotchi/context_judge.rb +194 -0
  23. data/lib/samagotchi/context_report_command.rb +161 -0
  24. data/lib/samagotchi/context_sources.rb +67 -23
  25. data/lib/samagotchi/engine.rb +189 -32
  26. data/lib/samagotchi/owner_prompt.rb +39 -0
  27. data/lib/samagotchi/plugin/api.rb +1 -0
  28. data/lib/samagotchi/plugin/attached_context.rb +91 -11
  29. data/lib/samagotchi/plugin/context.rb +7 -1
  30. data/lib/samagotchi/prompt_warmup.rb +9 -1
  31. data/lib/samagotchi/session_access/contract.rb +2 -0
  32. data/lib/samagotchi/session_access/local.rb +10 -0
  33. data/lib/samagotchi/session_access/remote.rb +14 -0
  34. data/lib/samagotchi/session_access.rb +5 -0
  35. data/lib/samagotchi/session_commands.rb +28 -5
  36. data/lib/samagotchi/side_judgments.rb +84 -0
  37. data/lib/samagotchi/terminal_ui.rb +15 -0
  38. data/lib/samagotchi/testing/host.rb +4 -1
  39. data/lib/samagotchi/toolkit_report_command.rb +2 -1
  40. data/lib/samagotchi/toolkit_suggest.rb +3 -18
  41. data/lib/samagotchi/version.rb +1 -1
  42. data/lib/samagotchi/web/app.rb +10 -3
  43. data/lib/samagotchi/web/auto_update.rb +8 -1
  44. data/lib/samagotchi/web/public/app.js +8 -2
  45. data/lib/samagotchi/web/public/update_notice.js +21 -8
  46. data/lib/samagotchi/web/session_access_routes.rb +1 -1
  47. data/lib/samagotchi/web/session_hub.rb +5 -2
  48. data/lib/samagotchi/web/update_check.rb +14 -8
  49. data/lib/samagotchi/worker.rb +5 -0
  50. metadata +8 -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
@@ -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
@@ -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,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.139.1
3
+ version: 0.140.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -217,6 +217,14 @@ module Samagotchi
217
217
  # counted here); then they wait as notes. 0: a peer source
218
218
  # wakes like any other (once per 10 minutes, the guard line).
219
219
  Entry.new(key: "context.peer_turns", yaml_path: %w[context peer_turns], type: :integer, default: 3, expose: %i[env config cli], lifecycle: :next_matching_turn_display_poll_write),
220
+ # The candidate judge (ContextJudge): off (no conversation text goes to a System 1 host); audit (asks and logs
221
+ # context_candidates.jsonl, attaches nothing); on (ContextCandidateChoice: p ≥ threshold attaches quietly,
222
+ # offer_threshold ≤ p < threshold shows one card). Its host and model are guardrails.system_one.host/model.
223
+ # reask_after_turns: the owner prompts after a judged ask before its one re-ask.
224
+ Entry.new(key: "context.candidates.mode", yaml_path: %w[context candidates mode], type: :enum, default: "off", expose: %i[config], enum_values: %w[off audit on], lifecycle: :next_matching_turn_display_poll_write),
225
+ Entry.new(key: "context.candidates.threshold", yaml_path: %w[context candidates threshold], type: :float, default: 0.8, expose: %i[config], lifecycle: :next_matching_turn_display_poll_write),
226
+ Entry.new(key: "context.candidates.offer_threshold", yaml_path: %w[context candidates offer_threshold], type: :float, default: 0.6, expose: %i[config], lifecycle: :next_matching_turn_display_poll_write),
227
+ Entry.new(key: "context.candidates.reask_after_turns", yaml_path: %w[context candidates reask_after_turns], type: :integer, default: 3, expose: %i[config], lifecycle: :next_matching_turn_display_poll_write),
220
228
 
221
229
  # What the model is sent of the conversation (LLMContextStrategy, LLMContextView): none, or a list of layers
222
230
  # (stale, forget; "|"-separated or a YAML list); forget (the model's forget_outputs) is experimental.