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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -1
- data/README.md +2 -1
- data/docs/architecture.md +8 -6
- data/docs/cli.md +3 -1
- data/docs/configuration.md +33 -1
- data/docs/context.md +249 -46
- data/docs/guardrails.md +11 -0
- data/docs/internals/engine.md +7 -1
- data/docs/internals/prompt-caching.md +10 -1
- data/docs/internals/remote-sessions.md +4 -1
- data/docs/plugins.md +75 -18
- data/docs/sessions.md +3 -2
- data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +14 -0
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +15 -1
- 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/system/manifest.yml +1 -1
- 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 +65 -17
- data/lib/samagotchi/context_judge.rb +194 -0
- data/lib/samagotchi/context_move.rb +31 -0
- data/lib/samagotchi/context_report_command.rb +161 -0
- data/lib/samagotchi/context_sources.rb +311 -29
- data/lib/samagotchi/engine.rb +189 -32
- data/lib/samagotchi/owner_prompt.rb +39 -0
- data/lib/samagotchi/plugin/api.rb +1 -0
- data/lib/samagotchi/plugin/attached_context.rb +105 -13
- data/lib/samagotchi/plugin/context.rb +12 -1
- data/lib/samagotchi/plugin/peer.rb +53 -0
- data/lib/samagotchi/plugin/sessions.rb +9 -2
- data/lib/samagotchi/prompt_warmup.rb +9 -1
- data/lib/samagotchi/send_command.rb +3 -1
- data/lib/samagotchi/session.rb +4 -0
- data/lib/samagotchi/session_access/contract.rb +4 -0
- data/lib/samagotchi/session_access/local.rb +34 -3
- data/lib/samagotchi/session_access/remote.rb +33 -8
- data/lib/samagotchi/session_access.rb +31 -6
- data/lib/samagotchi/session_commands.rb +28 -5
- data/lib/samagotchi/session_continue.rb +9 -2
- data/lib/samagotchi/side_judgments.rb +84 -0
- data/lib/samagotchi/terminal_ui.rb +15 -0
- data/lib/samagotchi/testing/boundary.rb +5 -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/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +24 -11
- data/lib/samagotchi/web/auto_update.rb +8 -1
- data/lib/samagotchi/web/public/app.js +8 -2
- data/lib/samagotchi/web/public/chains.js +10 -2
- data/lib/samagotchi/web/public/update_notice.js +21 -8
- data/lib/samagotchi/web/session_access_routes.rb +8 -3
- data/lib/samagotchi/web/session_create.rb +1 -0
- 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
|
@@ -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
|
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -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
|