samagotchi 0.139.1 → 0.141.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -1
  3. data/README.md +2 -1
  4. data/docs/architecture.md +8 -6
  5. data/docs/cli.md +3 -1
  6. data/docs/configuration.md +33 -1
  7. data/docs/context.md +249 -46
  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/internals/remote-sessions.md +4 -1
  12. data/docs/plugins.md +75 -18
  13. data/docs/sessions.md +3 -2
  14. data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
  15. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  16. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +14 -0
  17. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +15 -1
  19. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/github-pr/manifest.yml +4 -4
  21. data/lib/samagotchi/bundles/github-pr/plugin.rb +72 -176
  22. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  23. data/lib/samagotchi/config.rb +8 -0
  24. data/lib/samagotchi/context_candidate_choice.rb +154 -0
  25. data/lib/samagotchi/context_candidates.rb +259 -0
  26. data/lib/samagotchi/context_candidates_log.rb +90 -0
  27. data/lib/samagotchi/context_command.rb +65 -17
  28. data/lib/samagotchi/context_judge.rb +194 -0
  29. data/lib/samagotchi/context_move.rb +31 -0
  30. data/lib/samagotchi/context_report_command.rb +161 -0
  31. data/lib/samagotchi/context_sources.rb +311 -29
  32. data/lib/samagotchi/engine.rb +189 -32
  33. data/lib/samagotchi/owner_prompt.rb +39 -0
  34. data/lib/samagotchi/plugin/api.rb +1 -0
  35. data/lib/samagotchi/plugin/attached_context.rb +105 -13
  36. data/lib/samagotchi/plugin/context.rb +12 -1
  37. data/lib/samagotchi/plugin/peer.rb +53 -0
  38. data/lib/samagotchi/plugin/sessions.rb +9 -2
  39. data/lib/samagotchi/prompt_warmup.rb +9 -1
  40. data/lib/samagotchi/send_command.rb +3 -1
  41. data/lib/samagotchi/session.rb +4 -0
  42. data/lib/samagotchi/session_access/contract.rb +4 -0
  43. data/lib/samagotchi/session_access/local.rb +34 -3
  44. data/lib/samagotchi/session_access/remote.rb +33 -8
  45. data/lib/samagotchi/session_access.rb +31 -6
  46. data/lib/samagotchi/session_commands.rb +28 -5
  47. data/lib/samagotchi/session_continue.rb +9 -2
  48. data/lib/samagotchi/side_judgments.rb +84 -0
  49. data/lib/samagotchi/terminal_ui.rb +15 -0
  50. data/lib/samagotchi/testing/boundary.rb +5 -1
  51. data/lib/samagotchi/testing/host.rb +4 -1
  52. data/lib/samagotchi/toolkit_report_command.rb +2 -1
  53. data/lib/samagotchi/toolkit_suggest.rb +3 -18
  54. data/lib/samagotchi/version.rb +1 -1
  55. data/lib/samagotchi/web/app.rb +24 -11
  56. data/lib/samagotchi/web/auto_update.rb +8 -1
  57. data/lib/samagotchi/web/public/app.js +8 -2
  58. data/lib/samagotchi/web/public/chains.js +10 -2
  59. data/lib/samagotchi/web/public/update_notice.js +21 -8
  60. data/lib/samagotchi/web/session_access_routes.rb +8 -3
  61. data/lib/samagotchi/web/session_create.rb +1 -0
  62. data/lib/samagotchi/web/session_hub.rb +5 -2
  63. data/lib/samagotchi/web/update_check.rb +14 -8
  64. data/lib/samagotchi/worker.rb +5 -0
  65. metadata +9 -1
@@ -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.141.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.
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "context_candidates"
4
+ require_relative "context_candidates_log"
5
+ require_relative "context_providers"
6
+ require_relative "context_sources"
7
+ require_relative "log"
8
+
9
+ module Samagotchi
10
+ # What becomes of a candidate after the judge (context.candidates.mode
11
+ # on) or the owner chose (docs/context.md, "On mode"):
12
+ #
13
+ # - a judged p ≥ threshold attaches it quietly (#after_judged): the
14
+ # session's own folder, its why naming the judge without a number; the
15
+ # worker's next between-turn delivery brings its note, never mid-turn;
16
+ # - offer_threshold ≤ p < threshold shows one card (Attach / Not here),
17
+ # once per candidate: a `carded` line in ContextCandidatesLog says it
18
+ # was shown, so a worker restart doesn't show it again;
19
+ # - below, it stays proposed.
20
+ #
21
+ # The card's actions are /context attach NAME and /context decline NAME
22
+ # (#choose), the owner's only: a participant can't send a / line, and a
23
+ # review session refuses them.
24
+ module ContextCandidateChoice
25
+ CARD_SOURCE = "context"
26
+ # The judge's attach, as the source's why (no probability: it is
27
+ # model-visible in the attach note).
28
+ JUDGE_WHY = "it looks related to your task"
29
+ # The owner's attach from the card (or /context attach).
30
+ CHOSEN_WHY = "you attached it"
31
+ OFFER_BODY = "It may be related to your task. Not attached: the agent gets nothing until you attach it."
32
+ USAGE = "usage: /context [attach NAME | decline NAME]"
33
+
34
+ module_function
35
+
36
+ def card_id(name) = "context-candidate-#{name}"
37
+
38
+ # The middle band's card for +candidate+.
39
+ # @return [Hash] Engine#show_card's keywords
40
+ def offer_card(candidate)
41
+ { source: CARD_SOURCE, id: card_id(candidate.name), title: candidate.title.to_s.strip.empty? ? candidate.name : candidate.title,
42
+ body: OFFER_BODY, level: :info,
43
+ actions: [{ label: "Attach", command: "/context attach #{candidate.name}" },
44
+ { label: "Not here", command: "/context decline #{candidate.name}" }] }
45
+ end
46
+
47
+ # The card shown again under its id, with no actions: what became of it.
48
+ def closed_card(candidate, body)
49
+ { source: CARD_SOURCE, id: card_id(candidate.name), title: candidate.title.to_s.strip.empty? ? candidate.name : candidate.title,
50
+ body: body, level: :info, actions: [] }
51
+ end
52
+
53
+ # On mode, after a judged ask (ContextJudge.ask's +fields+; nil: no
54
+ # answer): attach, card or nothing. The candidate must still be
55
+ # proposed (the user may have chosen while it was asked).
56
+ # @param show_card [#call] (**card) → shows it; false/nil: it wasn't
57
+ # shown (the UI is on another session), so no carded line
58
+ # @return [Symbol, nil] :attached, :carded or nil
59
+ def after_judged(job:, fields:, settings:, project_root:, state_dir:, log:, show_card:)
60
+ return nil unless settings.mode == "on" && fields && fields["stated"]
61
+ return nil unless %w[attach offer].include?(fields["would"])
62
+
63
+ candidate = proposed(job.session_id, job.name, project_root: project_root, state_dir: state_dir) or return nil
64
+ if fields["would"] == "attach"
65
+ judge_attach(candidate, job: job, p: fields["p"], state_dir: state_dir, log: log, show_card: show_card)
66
+ else
67
+ offer(candidate, job: job, p: fields["p"], log: log, show_card: show_card)
68
+ end
69
+ rescue StandardError => e
70
+ Log.warn(:context, "candidate_choice_failed", name: job.name, error: e.class.name, msg: e.message.to_s[0, 200])
71
+ nil
72
+ end
73
+
74
+ # The candidate +name+ when it is still proposed in the session.
75
+ def proposed(session_id, name, project_root:, state_dir:)
76
+ rows = ContextCandidates.with_status(session_id, project_root: project_root, state_dir: state_dir)
77
+ rows.find { |candidate, status| candidate.name == name && status == "proposed" }&.first
78
+ end
79
+
80
+ # Whether +name+'s card was shown in the session (a carded line).
81
+ def carded?(log, session_id, name)
82
+ ContextCandidatesLog.records_for(log, session_id).any? { |line| line["kind"] == "carded" && line["name"] == name }
83
+ end
84
+
85
+ def judge_attach(candidate, job:, p:, state_dir:, log:, show_card:)
86
+ own = ContextSources.session_location(job.session_id, state_dir: state_dir)
87
+ own.add(ContextCandidates.source_for(candidate, why: JUDGE_WHY))
88
+ Log.info(:context, "candidate_attached", name: candidate.name, trigger: "judge")
89
+ ContextCandidatesLog.event("judge_attached", session_id: job.session_id, name: candidate.name, log: log, p: p)
90
+ # A card from an earlier ask loses its buttons.
91
+ show_card.call(**closed_card(candidate, "Attached: #{JUDGE_WHY}.")) if carded?(log, job.session_id, candidate.name)
92
+ :attached
93
+ rescue ContextSources::Invalid, ContextProviders::Invalid => e
94
+ Log.warn(:context, "candidate_not_attached", name: candidate.name, error: e.message.to_s[0, 200])
95
+ nil
96
+ end
97
+
98
+ def offer(candidate, job:, p:, log:, show_card:)
99
+ return nil if carded?(log, job.session_id, candidate.name)
100
+ return nil unless show_card.call(**offer_card(candidate))
101
+
102
+ ContextCandidatesLog.event("carded", session_id: job.session_id, name: candidate.name, log: log, p: p)
103
+ :carded
104
+ end
105
+
106
+ # /context attach NAME and /context decline NAME (+action+ :attach or
107
+ # :decline) in +session+.
108
+ # @param show_card [#call] (**card) → replaces the card
109
+ # @return [Array(Symbol, String)] :ok or :error, and the answer
110
+ def choose(action, name, session:, state_dir:, show_card:)
111
+ return [:error, "no session yet"] unless session&.id
112
+ return [:error, "a review session takes no context candidates"] if session.review?
113
+ return [:error, USAGE] if name.to_s.empty?
114
+
115
+ candidate, status = find(session, name, state_dir: state_dir)
116
+ return [:error, "no candidate #{name} in this session (see /context)"] unless candidate
117
+
118
+ action == :attach ? attach(candidate, status, session, state_dir, show_card) : decline(candidate, status, session, state_dir, show_card)
119
+ end
120
+
121
+ def find(session, name, state_dir:)
122
+ ContextCandidates.with_status(session.id, project_root: session.project_root, state_dir: state_dir)
123
+ .find { |candidate, _status| candidate.name == name }
124
+ end
125
+
126
+ def attach(candidate, status, session, state_dir, show_card)
127
+ return [:error, "#{candidate.name} is attached already"] if status == "attached"
128
+ if status == "declined"
129
+ return [:error, "#{candidate.name} was declined in this session; chi context add attaches a source by hand"]
130
+ end
131
+
132
+ own = ContextSources.session_location(session.id, state_dir: state_dir)
133
+ own.add(ContextCandidates.source_for(candidate, why: CHOSEN_WHY))
134
+ Log.info(:context, "candidate_attached", name: candidate.name, trigger: "command")
135
+ ContextCandidatesLog.event("attached", session_id: session.id, name: candidate.name, state_dir: state_dir, by: "command")
136
+ show_card.call(**closed_card(candidate, "Attached."))
137
+ [:ok, "attached #{candidate.name}: its text comes with your next message"]
138
+ rescue ContextSources::Invalid, ContextProviders::Invalid => e
139
+ [:error, "#{candidate.name} not attached: #{e.message}"]
140
+ end
141
+
142
+ def decline(candidate, status, session, state_dir, show_card)
143
+ if status == "attached"
144
+ return [:error, "#{candidate.name} is attached; chi context rm #{candidate.name} removes it"]
145
+ end
146
+ return [:ok, "#{candidate.name} is declined already"] if status == "declined"
147
+
148
+ ContextSources.session_location(session.id, state_dir: state_dir).decline(candidate.name, candidate.url.to_s)
149
+ ContextCandidatesLog.event("declined", session_id: session.id, name: candidate.name, state_dir: state_dir, by: "command")
150
+ show_card.call(**closed_card(candidate, "Not attached here."))
151
+ [:ok, "declined #{candidate.name}: not proposed again in this session"]
152
+ end
153
+ end
154
+ end