samagotchi 0.41.0 → 0.43.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/docs/cli.md +8 -6
- data/docs/configuration.md +8 -0
- data/docs/context.md +46 -0
- data/docs/memory.md +18 -0
- data/docs/plugins.md +27 -5
- data/docs/sessions.md +5 -5
- data/docs/testing.md +16 -0
- data/lib/samagotchi/bundles/github-pr/manifest.yml +4 -4
- data/lib/samagotchi/bundles/github-pr/plugin.rb +177 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_reports.rb +8 -4
- data/lib/samagotchi/context_command.rb +5 -2
- data/lib/samagotchi/context_sources.rb +40 -0
- data/lib/samagotchi/engine.rb +31 -1
- data/lib/samagotchi/model_notes.rb +7 -4
- data/lib/samagotchi/plugin/attached_context.rb +75 -11
- data/lib/samagotchi/prompt_note.rb +53 -0
- data/lib/samagotchi/self_report.rb +32 -0
- data/lib/samagotchi/session.rb +16 -1
- data/lib/samagotchi/session_commands.rb +24 -5
- data/lib/samagotchi/session_metrics.rb +21 -4
- data/lib/samagotchi/system_prompt.rb +14 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +12 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +4 -0
- data/lib/samagotchi/tool_declarations.rb +2 -1
- data/lib/samagotchi/turn_flow.rb +8 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +55 -8
- data/lib/samagotchi/web/public/app.js +73 -11
- data/lib/samagotchi/web/public/ctx.js +27 -2
- data/lib/samagotchi/web/public/data.js +6 -3
- data/lib/samagotchi/web/public/format.js +24 -0
- data/lib/samagotchi/web/public/index.html +8 -0
- data/lib/samagotchi/web/public/llm_context_chip.js +151 -21
- data/lib/samagotchi/web/public/model_pick.js +3 -1
- data/lib/samagotchi/web/public/model_picker.js +2 -0
- data/lib/samagotchi/web/public/session_state.js +5 -0
- data/lib/samagotchi/web/public/timing.js +3 -1
- data/lib/samagotchi/web/public/turn_view.js +15 -2
- data/lib/samagotchi/web/session_summary.rb +2 -0
- data/lib/samagotchi/worker.rb +16 -4
- data/lib/samagotchi/worker_wakes.rb +2 -1
- metadata +2 -1
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "digest"
|
|
4
4
|
require "json"
|
|
5
5
|
require "open3"
|
|
6
|
+
require "time"
|
|
6
7
|
require "tmpdir"
|
|
7
8
|
|
|
8
9
|
# github-pr: a session on a branch with an open GitHub PR gets the PR
|
|
@@ -24,15 +25,31 @@ require "tmpdir"
|
|
|
24
25
|
# done); before_turn, before_generation and the init task fill the cache
|
|
25
26
|
# off the turn's thread. `bundles: github-pr: line_links: false` turns it
|
|
26
27
|
# off.
|
|
28
|
+
#
|
|
29
|
+
# `bundles: github-pr: auto_attach:` says what the init task does with the
|
|
30
|
+
# branch's open PR: `attach` (the default), `offer` (a card with Attach and
|
|
31
|
+
# Not here, which run /pr-attach N and /pr-decline N; nothing attached until
|
|
32
|
+
# a click; offered once per session) or `off` (nothing). In offer mode the
|
|
33
|
+
# offers, their outcomes and each session's first prompt go to
|
|
34
|
+
# <data_dir>/offers.ndjson (OffersLog), to find heuristics later.
|
|
27
35
|
class Plugin
|
|
28
36
|
# gh and git each get this long (a network hang must not keep a thread).
|
|
29
37
|
COMMAND_SECONDS = 20
|
|
30
38
|
# before_generation looks for a newly attached PR at most this often.
|
|
31
39
|
GENERATION_CHECK_SECONDS = 30
|
|
40
|
+
# auto_attach's values (YAML's bare true/false: attach/off).
|
|
41
|
+
AUTO_ATTACH_MODES = { "attach" => :attach, "offer" => :offer, "off" => :off, true => :attach, false => :off }.freeze
|
|
42
|
+
# /pr-attach and /pr-decline take a PR number (`42` or `#42`).
|
|
43
|
+
PR_NUMBER = /\A#?(\d+)\z/
|
|
44
|
+
# The offer card's line under the PR's title.
|
|
45
|
+
OFFER_LINE = "Not attached: the agent gets nothing until you attach it."
|
|
46
|
+
# first_prompt keeps this much of the prompt.
|
|
47
|
+
FIRST_PROMPT_CHARS = 160
|
|
32
48
|
|
|
33
49
|
def initialize
|
|
34
50
|
@cache = PrCache.new
|
|
35
51
|
@generation_checked_at = nil
|
|
52
|
+
@warned_mode = false
|
|
36
53
|
end
|
|
37
54
|
|
|
38
55
|
def register(chi)
|
|
@@ -48,6 +65,13 @@ class Plugin
|
|
|
48
65
|
# The prompt too: messages is the history before this turn, and a
|
|
49
66
|
# delegate child's task is its first prompt.
|
|
50
67
|
kick(ctx, attached_prs(ctx) + PrRef.from_messages(event[:messages], prompt: event[:prompt])) if line_links?(ctx)
|
|
68
|
+
log_first_prompt(event, ctx)
|
|
69
|
+
end
|
|
70
|
+
chi.command("/pr-attach", "Attach the PR this session was offered: /pr-attach 42", anytime: true) do |args, ctx|
|
|
71
|
+
offer_command("/pr-attach", args, ctx) { |number, offer| attach_offered(ctx, number, offer) }
|
|
72
|
+
end
|
|
73
|
+
chi.command("/pr-decline", "Don't attach the PR this session was offered: /pr-decline 42", anytime: true) do |args, ctx|
|
|
74
|
+
offer_command("/pr-decline", args, ctx) { |number, offer| decline_offered(ctx, number, offer) }
|
|
51
75
|
end
|
|
52
76
|
chi.on(:before_generation) { |_event, ctx| check_attached(ctx) }
|
|
53
77
|
# After source-links (90): its links are in the display this one gets.
|
|
@@ -60,15 +84,19 @@ class Plugin
|
|
|
60
84
|
return "skipped: a delegate child" if ctx.delegate?
|
|
61
85
|
return "no session yet" unless ctx.session_id
|
|
62
86
|
|
|
87
|
+
mode = auto_attach_mode(ctx)
|
|
88
|
+
return "auto_attach: off" if mode == :off
|
|
89
|
+
|
|
63
90
|
branch = run(ctx, "git", "branch", "--show-current").to_s.strip
|
|
64
91
|
return "not on a branch" if branch.empty?
|
|
65
92
|
|
|
66
|
-
json = run(ctx, "gh", "pr", "view", "--json", "number,url,state") or return "no pull request for #{branch}"
|
|
93
|
+
json = run(ctx, "gh", "pr", "view", "--json", "number,url,state,title") or return "no pull request for #{branch}"
|
|
67
94
|
pr = JSON.parse(json)
|
|
68
95
|
return "pull request ##{pr["number"]} isn't open" unless pr["state"] == "OPEN"
|
|
69
96
|
|
|
70
97
|
name = "pr-#{pr["number"]}"
|
|
71
98
|
return "#{name} is attached already" if ctx.context.list.any? { |source| source[:name] == name || source[:hint] == pr["url"] }
|
|
99
|
+
return offer_branch_pr(ctx, branch, pr, name) if mode == :offer
|
|
72
100
|
|
|
73
101
|
attached = ctx.context.attach(url: pr["url"], name: name, why: "branch #{branch} has open PR ##{pr["number"]}")
|
|
74
102
|
attached ? "attached #{name}" : "#{name} was removed from this session; not attached again"
|
|
@@ -77,6 +105,21 @@ class Plugin
|
|
|
77
105
|
"no pull request attached"
|
|
78
106
|
end
|
|
79
107
|
|
|
108
|
+
# `bundles: github-pr: auto_attach:`, read at each worker start.
|
|
109
|
+
# @return [Symbol] :attach, :offer or :off; an unknown value is :attach
|
|
110
|
+
# (logged once)
|
|
111
|
+
def auto_attach_mode(ctx)
|
|
112
|
+
value = ctx.settings["auto_attach"]
|
|
113
|
+
return :attach if value.nil?
|
|
114
|
+
|
|
115
|
+
key = value.is_a?(String) ? value.strip.downcase : value
|
|
116
|
+
AUTO_ATTACH_MODES.fetch(key) do
|
|
117
|
+
ctx.log.warn(:pr_auto_attach_unknown, value: value.to_s[0, 40]) unless @warned_mode
|
|
118
|
+
@warned_mode = true
|
|
119
|
+
:attach
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
80
123
|
# The after_turn handler: links the answer's `path:line` refs from the
|
|
81
124
|
# PRs' cached data. Never runs gh: nothing cached yet links nothing.
|
|
82
125
|
def link_answer(event, ctx)
|
|
@@ -109,6 +152,106 @@ class Plugin
|
|
|
109
152
|
|
|
110
153
|
def line_links?(ctx) = ctx.settings["line_links"] != false
|
|
111
154
|
|
|
155
|
+
def offer_card_id(number) = "github-pr-offer-#{number}"
|
|
156
|
+
|
|
157
|
+
# Offer mode: a card instead of the attach, once per session per PR (a
|
|
158
|
+
# worker restart finds the offered marker). The marker is written after
|
|
159
|
+
# the card, so a card that failed leaves none.
|
|
160
|
+
def offer_branch_pr(ctx, branch, pr, name)
|
|
161
|
+
return "#{name} was removed from this session; not offered" if ctx.context.declined?(name, url: pr["url"])
|
|
162
|
+
return "#{name} was offered already" if ctx.context.offered(name)
|
|
163
|
+
|
|
164
|
+
number = pr["number"]
|
|
165
|
+
body = [pr["title"].to_s.gsub(/\s+/, " ").strip, OFFER_LINE].reject(&:empty?).join("\n\n")
|
|
166
|
+
ctx.card(id: offer_card_id(number), title: "PR ##{number} for branch #{branch}", body: body, level: :info,
|
|
167
|
+
actions: [{ label: "Attach", command: "/pr-attach #{number}" },
|
|
168
|
+
{ label: "Not here", command: "/pr-decline #{number}" }])
|
|
169
|
+
ctx.context.mark_offered(name, pr["url"], why: "branch #{branch} has open PR ##{number}")
|
|
170
|
+
offers_log(ctx).write("offered", session: ctx.session_id, project_root: project_root(ctx), cwd: ctx.cwd,
|
|
171
|
+
branch: branch, pr: pr["url"], checkout: checkout_kind(ctx), model: ctx.model)
|
|
172
|
+
"offered #{name}"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# /pr-attach and /pr-decline: the PR number and its offer, else the
|
|
176
|
+
# answer (a usage line, no offer).
|
|
177
|
+
def offer_command(command, args, ctx)
|
|
178
|
+
number = args.to_s.strip[PR_NUMBER, 1] or return "usage: #{command} <PR number>, as the offer card names it"
|
|
179
|
+
number = number.to_i
|
|
180
|
+
offer = ctx.context.offered("pr-#{number}")
|
|
181
|
+
return "no offered PR ##{number} here; use `chi context add <PR URL>`" unless offer
|
|
182
|
+
|
|
183
|
+
yield number, offer
|
|
184
|
+
rescue Samagotchi::Plugin::AttachedContext::Error => e
|
|
185
|
+
"pr-#{number} not changed: #{e.message}"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# The user's click: attached as auto-attach would, past a decline, with
|
|
189
|
+
# the offer's why (no git or gh here).
|
|
190
|
+
def attach_offered(ctx, number, offer)
|
|
191
|
+
why = "#{offer.why || "open PR ##{number}"} (attached from the offer)"
|
|
192
|
+
attached = ctx.context.attach(url: offer.hint, name: offer.name, why: why, force: true)
|
|
193
|
+
return "#{offer.name} wasn't attached" unless attached
|
|
194
|
+
|
|
195
|
+
ctx.card(id: offer_card_id(number), title: "PR ##{number}", body: "Attached #{attached}")
|
|
196
|
+
offers_log(ctx).write("attached", session: ctx.session_id, pr: offer.hint, seconds: seconds_since(offer))
|
|
197
|
+
nil
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Not for a PR attached here already (a stale card): that is
|
|
201
|
+
# `chi context rm`'s, and a declined marker would leave it attached.
|
|
202
|
+
def decline_offered(ctx, number, offer)
|
|
203
|
+
attached = ctx.context.list.find { |source| source[:name] == offer.name || source[:hint] == offer.hint }
|
|
204
|
+
return "#{attached[:name]} is attached here; `chi context rm #{attached[:name]}` removes it" if attached
|
|
205
|
+
|
|
206
|
+
ctx.context.decline(url: offer.hint, name: offer.name)
|
|
207
|
+
ctx.card(id: offer_card_id(number), title: "PR ##{number}", body: "Not attached here; + URL attaches it")
|
|
208
|
+
offers_log(ctx).write("declined", session: ctx.session_id, pr: offer.hint, seconds: seconds_since(offer))
|
|
209
|
+
nil
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def seconds_since(offer)
|
|
213
|
+
(Time.now - Time.iso8601(offer.at.to_s)).round
|
|
214
|
+
rescue ArgumentError
|
|
215
|
+
nil
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Offer mode: every session's first prompt (offered or not; a web chat's
|
|
219
|
+
# first turn starts before the init task's gh returns), joined to the
|
|
220
|
+
# offers by session at analysis time. First = no user message yet (a
|
|
221
|
+
# context note may be in messages already).
|
|
222
|
+
def log_first_prompt(event, ctx)
|
|
223
|
+
return if event[:prompt].nil? || auto_attach_mode(ctx) != :offer
|
|
224
|
+
return if Array(event[:messages]).any? { |message| message_role(message) == "user" }
|
|
225
|
+
return if ctx.scratch? || ctx.delegate? || ctx.session_id.nil?
|
|
226
|
+
|
|
227
|
+
offers_log(ctx).write("first_prompt", session: ctx.session_id, prompt: event[:prompt].to_s[0, FIRST_PROMPT_CHARS])
|
|
228
|
+
rescue StandardError => e
|
|
229
|
+
ctx.log.debug(:pr_offers_log_failed, error: e.class.name)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def message_role(message)
|
|
233
|
+
return nil unless message.is_a?(Hash)
|
|
234
|
+
|
|
235
|
+
(message.key?(:role) ? message[:role] : message["role"]).to_s
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def offers_log(ctx) = OffersLog.new(File.join(ctx.data_dir, OffersLog::FILE), ctx.log)
|
|
239
|
+
|
|
240
|
+
def project_root(ctx)
|
|
241
|
+
ctx.repo_root
|
|
242
|
+
rescue StandardError
|
|
243
|
+
nil
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# "main" for the main checkout, "worktree" for a linked one (its git dir
|
|
247
|
+
# isn't the common one), nil outside a repo.
|
|
248
|
+
def checkout_kind(ctx)
|
|
249
|
+
dirs = run(ctx, "git", "rev-parse", "--path-format=absolute", "--git-dir", "--git-common-dir").to_s.split("\n")
|
|
250
|
+
return nil unless dirs.size == 2
|
|
251
|
+
|
|
252
|
+
dirs[0] == dirs[1] ? "main" : "worktree"
|
|
253
|
+
end
|
|
254
|
+
|
|
112
255
|
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
113
256
|
|
|
114
257
|
# #refresh on its own thread (a raw one, docs/plugins.md), when there is
|
|
@@ -191,6 +334,39 @@ class Plugin
|
|
|
191
334
|
end
|
|
192
335
|
end
|
|
193
336
|
|
|
337
|
+
# <data_dir>/offers.ndjson: one JSON object a line, {event:, ts:, …},
|
|
338
|
+
# private to the user (0600: it holds prompts). Every worker shares it, so
|
|
339
|
+
# each write holds offers.ndjson.lock (flock) around the size check, the
|
|
340
|
+
# rotation and the append; over MAX_BYTES the file becomes
|
|
341
|
+
# offers.ndjson.1 (one old file kept). A failure is logged at debug: the
|
|
342
|
+
# log never breaks an attach.
|
|
343
|
+
class OffersLog
|
|
344
|
+
FILE = "offers.ndjson"
|
|
345
|
+
MAX_BYTES = 1024 * 1024
|
|
346
|
+
LINE_MAX_BYTES = 4000
|
|
347
|
+
MODE = 0o600
|
|
348
|
+
|
|
349
|
+
def initialize(path, log)
|
|
350
|
+
@path = path
|
|
351
|
+
@log = log
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def write(event, **fields)
|
|
355
|
+
line = "#{JSON.generate({ event: event, ts: Time.now.utc.iso8601 }.merge(fields))}\n"
|
|
356
|
+
return @log.debug(:pr_offers_line_too_long, event: event) if line.bytesize > LINE_MAX_BYTES
|
|
357
|
+
|
|
358
|
+
File.open("#{@path}.lock", File::RDWR | File::CREAT, MODE) do |lock|
|
|
359
|
+
lock.flock(File::LOCK_EX)
|
|
360
|
+
File.rename(@path, "#{@path}.1") if File.size?(@path).to_i > MAX_BYTES
|
|
361
|
+
File.open(@path, File::WRONLY | File::APPEND | File::CREAT, MODE) { |file| file.write(line) }
|
|
362
|
+
end
|
|
363
|
+
nil
|
|
364
|
+
rescue StandardError => e
|
|
365
|
+
@log.debug(:pr_offers_log_failed, error: e.class.name)
|
|
366
|
+
nil
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
194
370
|
# A GitHub pull request, by its URL.
|
|
195
371
|
PrRef = Data.define(:owner, :repo, :number)
|
|
196
372
|
|
|
@@ -110,9 +110,13 @@ module Samagotchi
|
|
|
110
110
|
# thread, the drain inside them).
|
|
111
111
|
#
|
|
112
112
|
# A turn takes reports (#take, at each boundary) and the worker settles
|
|
113
|
-
# them as the turn ends: #commit when
|
|
114
|
-
#
|
|
115
|
-
#
|
|
113
|
+
# them as the turn ends: #commit when the turn stays in the conversation
|
|
114
|
+
# (completed, canceled, or failed but kept: its work stayed, or a reminder
|
|
115
|
+
# or continue turn's): the cursors move on (DelegateCursors) and the rings
|
|
116
|
+
# go; #release when its end rolled it back (a failed turn that got
|
|
117
|
+
# nowhere, a canceled continue turn): the next turn reads the same rings
|
|
118
|
+
# again. So the model gets each report once (a later !rollback that
|
|
119
|
+
# erases a kept turn drops what it read, as before).
|
|
116
120
|
class ChildReports
|
|
117
121
|
SOURCE = Steer::DELEGATE_REPORT
|
|
118
122
|
|
|
@@ -178,7 +182,7 @@ module Samagotchi
|
|
|
178
182
|
@taken = {}
|
|
179
183
|
end
|
|
180
184
|
|
|
181
|
-
# The turn
|
|
185
|
+
# The turn was rolled back: its rings stay for the next one.
|
|
182
186
|
def release
|
|
183
187
|
@taken = {}
|
|
184
188
|
end
|
|
@@ -75,7 +75,9 @@ module Samagotchi
|
|
|
75
75
|
end
|
|
76
76
|
|
|
77
77
|
# @param argv [Array<String>] the arguments after "context"
|
|
78
|
-
|
|
78
|
+
# @param clock [#call] now, for ls's ages ("4s ago")
|
|
79
|
+
def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, state_dir: nil, env: ENV, cwd: Dir.pwd,
|
|
80
|
+
clock: -> { Time.now })
|
|
79
81
|
@argv = argv.dup
|
|
80
82
|
@stdin = stdin
|
|
81
83
|
@stdout = stdout
|
|
@@ -83,6 +85,7 @@ module Samagotchi
|
|
|
83
85
|
@state_dir = state_dir || Session.default_state_dir
|
|
84
86
|
@env = env
|
|
85
87
|
@cwd = cwd
|
|
88
|
+
@clock = clock
|
|
86
89
|
end
|
|
87
90
|
|
|
88
91
|
# @return [Integer] exit status: 0 done, 1 refused or failed, 2 usage
|
|
@@ -437,7 +440,7 @@ module Samagotchi
|
|
|
437
440
|
def age(iso)
|
|
438
441
|
return "-" unless iso
|
|
439
442
|
|
|
440
|
-
seconds = (
|
|
443
|
+
seconds = (@clock.call - Time.iso8601(iso)).to_i
|
|
441
444
|
if seconds < 60 then "#{seconds}s ago"
|
|
442
445
|
elsif seconds < 3600 then "#{seconds / 60}m ago"
|
|
443
446
|
elsif seconds < 86_400 then "#{seconds / 3600}h ago"
|
|
@@ -22,6 +22,7 @@ module Samagotchi
|
|
|
22
22
|
# sessions/<id>/subscriptions.json {name => Subscription}; the worker writes it
|
|
23
23
|
# sessions/<id>/muted/<name> a marker: this session ignores <name>
|
|
24
24
|
# sessions/<id>/declined/<name> a marker (its hint inside): the user removed <name>
|
|
25
|
+
# sessions/<id>/offered/<name> a marker ({hint, why, at}): a plugin offered <name> (ctx.context.mark_offered)
|
|
25
26
|
# The guardrails protect the root (ProtectedPaths, CHI_TEXT): a source's
|
|
26
27
|
# command runs later, outside the gate.
|
|
27
28
|
module ContextSources
|
|
@@ -39,6 +40,7 @@ module Samagotchi
|
|
|
39
40
|
SUBSCRIPTIONS_FILE = "subscriptions.json"
|
|
40
41
|
MUTED_DIR = "muted"
|
|
41
42
|
DECLINED_DIR = "declined"
|
|
43
|
+
OFFERED_DIR = "offered"
|
|
42
44
|
|
|
43
45
|
# A name, a command, a value the store can't take.
|
|
44
46
|
class Invalid < ArgumentError; end
|
|
@@ -131,6 +133,11 @@ module Samagotchi
|
|
|
131
133
|
# What a command printed or a push sent (the contract, ContextSources.parse_output).
|
|
132
134
|
Fetched = Data.define(:text, :summary, :wake, :hint)
|
|
133
135
|
|
|
136
|
+
# A plugin's offer of a source to a session (ctx.context.mark_offered):
|
|
137
|
+
# +hint+ (often the URL), +why+ (the plugin's reason, for the attach
|
|
138
|
+
# that may follow; nil for none) and +at+, when it was offered (ISO 8601).
|
|
139
|
+
Offer = Data.define(:name, :hint, :why, :at)
|
|
140
|
+
|
|
134
141
|
# A source as one session sees it: the definition, where it lives, and
|
|
135
142
|
# +shadowed+ for a project source hidden by a session one of its name.
|
|
136
143
|
Attached = Data.define(:source, :location, :shadowed) do
|
|
@@ -273,6 +280,39 @@ module Samagotchi
|
|
|
273
280
|
AtomicFile.write(declined_path(name), "#{hint}\n")
|
|
274
281
|
end
|
|
275
282
|
|
|
283
|
+
# Clears the declined marker of +name+ and any whose hint is +hint+
|
|
284
|
+
# (the user's explicit attach: a URL declined under another name).
|
|
285
|
+
def undecline(name, hint: nil)
|
|
286
|
+
FileUtils.rm_f(declined_path(name))
|
|
287
|
+
folder = File.join(dir, DECLINED_DIR)
|
|
288
|
+
return if hint.to_s.empty? || !Dir.exist?(folder)
|
|
289
|
+
|
|
290
|
+
Dir.children(folder).each do |file|
|
|
291
|
+
path = File.join(folder, file)
|
|
292
|
+
FileUtils.rm_f(path) if File.read(path).strip == hint
|
|
293
|
+
rescue SystemCallError
|
|
294
|
+
nil
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def offered_path(name) = File.join(dir, OFFERED_DIR, ContextSources.check_name!(name))
|
|
299
|
+
|
|
300
|
+
# Session only: a plugin offered +name+ here (so it doesn't offer it
|
|
301
|
+
# again). Neither an add nor a decline clears it.
|
|
302
|
+
# @return [Offer]
|
|
303
|
+
def mark_offered(name, hint, why: nil, at: Time.now)
|
|
304
|
+
offer = Offer.new(name: ContextSources.check_name!(name), hint: hint, why: why, at: at.utc.iso8601)
|
|
305
|
+
FileUtils.mkdir_p(File.join(dir, OFFERED_DIR))
|
|
306
|
+
AtomicFile.write(offered_path(name), "#{JSON.generate({ "hint" => offer.hint, "why" => offer.why, "at" => offer.at }.compact)}\n")
|
|
307
|
+
offer
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# @return [Offer, nil]
|
|
311
|
+
def offered(name)
|
|
312
|
+
data = ContextSources.read_json(offered_path(name))
|
|
313
|
+
data.is_a?(Hash) ? Offer.new(name: name, hint: data["hint"], why: data["why"], at: data["at"]) : nil
|
|
314
|
+
end
|
|
315
|
+
|
|
276
316
|
# Session only: the markers and the worker's subscription file.
|
|
277
317
|
def muted?(name) = File.exist?(File.join(dir, MUTED_DIR, ContextSources.check_name!(name)))
|
|
278
318
|
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -54,6 +54,7 @@ require_relative "engine_peers"
|
|
|
54
54
|
require_relative "bundle_needs"
|
|
55
55
|
require_relative "model_overlay"
|
|
56
56
|
require_relative "model_notes"
|
|
57
|
+
require_relative "prompt_note"
|
|
57
58
|
require_relative "served_model"
|
|
58
59
|
require_relative "image_store"
|
|
59
60
|
require_relative "vision_context"
|
|
@@ -478,6 +479,26 @@ module Samagotchi
|
|
|
478
479
|
muted: @muted_memory_names)
|
|
479
480
|
end
|
|
480
481
|
|
|
482
|
+
# The model notes the session's prompt carried at its last build
|
|
483
|
+
# (Session#prompt_notes): a resumed session's or a woken worker's saved
|
|
484
|
+
# ones until this process builds its prompt; without a session, the
|
|
485
|
+
# notes the effective model's prompt would load.
|
|
486
|
+
# @return [Array<PromptNote>]
|
|
487
|
+
def prompt_notes
|
|
488
|
+
return @session.prompt_notes.dup if @session
|
|
489
|
+
|
|
490
|
+
model_notes.map { |note| PromptNote.from_note(note) }
|
|
491
|
+
rescue StandardError
|
|
492
|
+
[]
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
# Records +notes+ (ModelNotes::Note) on the session as the ones its
|
|
496
|
+
# prompt carries; saved with it.
|
|
497
|
+
def record_prompt_notes(notes)
|
|
498
|
+
@session.prompt_notes = Array(notes).map { |note| PromptNote.from_note(note) } if @session && notes
|
|
499
|
+
end
|
|
500
|
+
private :record_prompt_notes
|
|
501
|
+
|
|
481
502
|
# +name+'s resolved ref (ModelRef#ref).
|
|
482
503
|
def model_ref_for(name)
|
|
483
504
|
@host_registry.model_ref(name).ref
|
|
@@ -856,6 +877,9 @@ module Samagotchi
|
|
|
856
877
|
@profile_resolution = nil
|
|
857
878
|
sync_model_key!
|
|
858
879
|
@prompt_builder.reset!
|
|
880
|
+
# The notes the new model's prompt loads at its next build, so /model,
|
|
881
|
+
# /stats and the web name them before the next turn does.
|
|
882
|
+
record_prompt_notes(model_notes) if @session
|
|
859
883
|
sync_kernel_client!
|
|
860
884
|
@client.invalidate_context_window!
|
|
861
885
|
@metrics.forget_model_reports!
|
|
@@ -921,7 +945,8 @@ module Samagotchi
|
|
|
921
945
|
snapshot = @metrics.snapshot
|
|
922
946
|
target = @host_registry.resolve(@effective_model_name)
|
|
923
947
|
served, served_for = served_model_for(snapshot, target: target)
|
|
924
|
-
snapshot = snapshot.merge(served_model: served, served_model_for: served_for, llm_context: llm_context_summary
|
|
948
|
+
snapshot = snapshot.merge(served_model: served, served_model_for: served_for, llm_context: llm_context_summary,
|
|
949
|
+
prompt_notes: prompt_notes.map(&:to_h))
|
|
925
950
|
unless snapshot.dig(:context, :window_tokens)
|
|
926
951
|
window = current_context_window(target)
|
|
927
952
|
if window
|
|
@@ -1028,6 +1053,8 @@ module Samagotchi
|
|
|
1028
1053
|
# :metrics [Hash] @metrics.snapshot (per-session analytics)
|
|
1029
1054
|
# :pending_question [Hash, nil] current pending structured question
|
|
1030
1055
|
# :used_memory_names [Array<String>] deduped memory names active this session
|
|
1056
|
+
# :prompt_notes [Array<Hash>] the model notes the prompt carried
|
|
1057
|
+
# ({name:, scope:, chars:, digest:}; #prompt_notes)
|
|
1031
1058
|
# :parent_id [String, nil] the session that delegated this one
|
|
1032
1059
|
# :model_name [String] the model turns run on now (after /model)
|
|
1033
1060
|
# :served_model, :served_model_for [String, nil] what a generation of
|
|
@@ -1048,6 +1075,7 @@ module Samagotchi
|
|
|
1048
1075
|
used_memory_names: @used_memories.names,
|
|
1049
1076
|
preloaded_memory_names: preloaded_memory_names,
|
|
1050
1077
|
muted_memory_names: @muted_memory_names.dup,
|
|
1078
|
+
prompt_notes: prompt_notes.map(&:to_h),
|
|
1051
1079
|
parent_id: @session&.parent_id,
|
|
1052
1080
|
model_name: effective_model_ref,
|
|
1053
1081
|
served_model: served_pair[0],
|
|
@@ -1462,6 +1490,8 @@ module Samagotchi
|
|
|
1462
1490
|
prompt = @prompt_builder.build(chat: chat, thinking: level, layers: llm_context_layers(target))
|
|
1463
1491
|
# The memory indexes this prompt holds: /stats, the snapshot, analytics.json.
|
|
1464
1492
|
@metrics.memory_index = @prompt_builder.memory_index
|
|
1493
|
+
# The model notes it carries: the session records them (/stats, /model, the web).
|
|
1494
|
+
record_prompt_notes(@prompt_builder.prompt_notes)
|
|
1465
1495
|
prompt
|
|
1466
1496
|
end
|
|
1467
1497
|
|
|
@@ -49,21 +49,24 @@ module Samagotchi
|
|
|
49
49
|
# @param muted [Array<String>] the session's muted names (MutedMemories)
|
|
50
50
|
# @param small [#call, nil] → whether the model is a small one; nil:
|
|
51
51
|
# guardrails.small_models (Guardrails::ModelSize)
|
|
52
|
+
# @param env [Hash] the XDG env the memories dirs resolve from
|
|
53
|
+
# @param warn [Boolean] false: no size warnings (a read-only report such
|
|
54
|
+
# as chi self; the prompt's build gives them)
|
|
52
55
|
# @return [Array<Note>]
|
|
53
|
-
def for(name:, key:, fallback_key: nil, muted: [], small: nil)
|
|
56
|
+
def for(name:, key:, fallback_key: nil, muted: [], small: nil, env: ENV, warn: true)
|
|
54
57
|
return [] if name.nil? || name.to_s.strip.empty?
|
|
55
58
|
|
|
56
59
|
small ||= -> { Guardrails::ModelSize.small?(name, key) }
|
|
57
60
|
small_once = memo(small)
|
|
58
61
|
seen = Set.new
|
|
59
62
|
notes = SCOPES.flat_map do |scope|
|
|
60
|
-
files(scope).filter_map do |path|
|
|
63
|
+
files(scope, env).filter_map do |path|
|
|
61
64
|
next unless seen.add?(File.expand_path(path))
|
|
62
65
|
|
|
63
66
|
note_at(path, scope, name: name, key: key, fallback_key: fallback_key, muted: muted, small: small_once)
|
|
64
67
|
end
|
|
65
68
|
end
|
|
66
|
-
warn_sizes(notes)
|
|
69
|
+
warn_sizes(notes) if warn
|
|
67
70
|
notes
|
|
68
71
|
end
|
|
69
72
|
|
|
@@ -96,7 +99,7 @@ module Samagotchi
|
|
|
96
99
|
@warn_mutex.synchronize { @warned.clear }
|
|
97
100
|
end
|
|
98
101
|
|
|
99
|
-
def files(scope) = files_in(MemoryPaths.scope_dir(scope))
|
|
102
|
+
def files(scope, env = ENV) = files_in(MemoryPaths.scope_dir(scope, env: env))
|
|
100
103
|
|
|
101
104
|
# The `model_notes_*.md` files in +dir+, by their names on disk: the
|
|
102
105
|
# prefix exactly as written (a case-insensitive disk's glob matches
|
|
@@ -27,27 +27,71 @@ module Samagotchi
|
|
|
27
27
|
# +name+ and +cmd+ (+hint+, +every_seconds+). A source of that name
|
|
28
28
|
# already attached stays as it is. One the user removed from the
|
|
29
29
|
# session (its name, or its URL) isn't attached again (#declined?)
|
|
30
|
-
# until the user adds it again
|
|
30
|
+
# until the user adds it again, or +force+: the user's own explicit
|
|
31
|
+
# choice (a click), which clears the declined marker (by the name,
|
|
32
|
+
# and by the URL).
|
|
31
33
|
# @return [String, nil] the source's name; nil when declined
|
|
32
34
|
# @raise [Error]
|
|
33
|
-
def attach(url: nil, name: nil, cmd: nil, why: nil, hint: nil, every_seconds: nil)
|
|
34
|
-
own =
|
|
35
|
+
def attach(url: nil, name: nil, cmd: nil, why: nil, hint: nil, every_seconds: nil, force: false)
|
|
36
|
+
own = own_location!
|
|
35
37
|
source = url ? from_url(url, name: name, why: why) : from_cmd(name: name, cmd: cmd, why: why, hint: hint, every_seconds: every_seconds)
|
|
36
|
-
|
|
38
|
+
if force
|
|
39
|
+
own.undecline(source.name, hint: url && source.hint)
|
|
40
|
+
elsif own.declined?(source.name) || (url && own.declined_hints.include?(source.hint))
|
|
41
|
+
return nil
|
|
42
|
+
end
|
|
37
43
|
return source.name if own.source(source.name)
|
|
38
44
|
|
|
39
|
-
|
|
40
|
-
source.name
|
|
45
|
+
add(own, source)
|
|
41
46
|
rescue ContextSources::Invalid, ContextProviders::Invalid => e
|
|
42
47
|
raise Error, e.message
|
|
43
48
|
end
|
|
44
49
|
|
|
45
|
-
#
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
50
|
+
# Marks +name+ (or +url+'s source, through its provider) declined in
|
|
51
|
+
# this session, as the user's removal does: #attach skips it until the
|
|
52
|
+
# user adds it again. An attached source stays (chi context rm).
|
|
53
|
+
# @return [String] the name
|
|
54
|
+
# @raise [Error]
|
|
55
|
+
def decline(url: nil, name: nil)
|
|
56
|
+
own = own_location!
|
|
57
|
+
if url
|
|
58
|
+
resolved = ContextProviders.resolve(url) or raise Error, "no installed bundle resolves #{url}"
|
|
59
|
+
name = name ? ContextSources.check_name!(name) : resolved.name
|
|
60
|
+
hint = resolved.hint
|
|
61
|
+
else
|
|
62
|
+
raise Error, "give url: or name:" if name.to_s.empty?
|
|
63
|
+
|
|
64
|
+
name = ContextSources.check_name!(name)
|
|
65
|
+
end
|
|
66
|
+
own.decline(name, hint)
|
|
67
|
+
name
|
|
68
|
+
rescue ContextSources::Invalid, ContextProviders::Invalid => e
|
|
69
|
+
raise Error, e.message
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Whether the user removed +name+ (or +url+) from this session (so
|
|
73
|
+
# #attach skips it).
|
|
74
|
+
def declined?(name = nil, url: nil)
|
|
75
|
+
own = location or return false
|
|
76
|
+
name_declined?(own, name) || (!url.to_s.empty? && own.declined_hints.include?(url))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Records that this plugin offered +name+ (+hint+: its URL; +why+: the
|
|
80
|
+
# reason an attach from the offer gives) here, so a worker restart
|
|
81
|
+
# doesn't offer it again.
|
|
82
|
+
# @return [ContextSources::Offer]
|
|
83
|
+
# @raise [Error]
|
|
84
|
+
def mark_offered(name, hint, why: nil)
|
|
85
|
+
own_location!.mark_offered(name, hint, why: why)
|
|
86
|
+
rescue ContextSources::Invalid => e
|
|
87
|
+
raise Error, e.message
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# @return [ContextSources::Offer, nil] the offer #mark_offered recorded
|
|
91
|
+
def offered(name)
|
|
92
|
+
location&.offered(name)
|
|
49
93
|
rescue ContextSources::Invalid
|
|
50
|
-
|
|
94
|
+
nil
|
|
51
95
|
end
|
|
52
96
|
|
|
53
97
|
# This session's sources (its own, then its project's).
|
|
@@ -69,6 +113,26 @@ module Samagotchi
|
|
|
69
113
|
|
|
70
114
|
def state_dir = @host.state_dir&.call || Session.default_state_dir
|
|
71
115
|
|
|
116
|
+
# Two attaches at once (a double click): the one that lost the race
|
|
117
|
+
# finds the source there and returns its name too.
|
|
118
|
+
def add(own, source)
|
|
119
|
+
own.add(source.with(scope: own.scope))
|
|
120
|
+
source.name
|
|
121
|
+
rescue ContextSources::Invalid
|
|
122
|
+
raise unless own.source(source.name)
|
|
123
|
+
|
|
124
|
+
source.name
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# An invalid name isn't declined (and doesn't hide the URL's check).
|
|
128
|
+
def name_declined?(own, name)
|
|
129
|
+
!name.nil? && own.declined?(name)
|
|
130
|
+
rescue ContextSources::Invalid
|
|
131
|
+
false
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def own_location! = location || raise(Error, "this session has no id yet")
|
|
135
|
+
|
|
72
136
|
def location
|
|
73
137
|
id = @host.session_id.call
|
|
74
138
|
id && ContextSources.session_location(id, state_dir: state_dir)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# A model note a session's system prompt carried (ModelNotes::Note
|
|
5
|
+
# without its body): what the session file's `prompt_notes` records at
|
|
6
|
+
# each prompt build, so /stats, /model, the web and a later comparison
|
|
7
|
+
# read what the prompt really held. name: the memory name; scope:
|
|
8
|
+
# "system" or "project"; chars: the body's length in the prompt; digest:
|
|
9
|
+
# a short SHA-256 of that body (ModelNotes::Note#digest).
|
|
10
|
+
PromptNote = Data.define(:name, :scope, :chars, :digest) do
|
|
11
|
+
# @param note [ModelNotes::Note]
|
|
12
|
+
def self.from_note(note)
|
|
13
|
+
new(name: note.name, scope: note.scope, chars: note.chars, digest: note.digest)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# A saved or wire record (string or symbol keys); nil without a name.
|
|
17
|
+
def self.from_h(raw)
|
|
18
|
+
return raw if raw.is_a?(PromptNote)
|
|
19
|
+
return nil unless raw.is_a?(Hash)
|
|
20
|
+
|
|
21
|
+
get = ->(key) { raw.key?(key.to_s) ? raw[key.to_s] : raw[key] }
|
|
22
|
+
name = get.call(:name).to_s.strip
|
|
23
|
+
return nil if name.empty?
|
|
24
|
+
|
|
25
|
+
chars = get.call(:chars)
|
|
26
|
+
new(name: name, scope: get.call(:scope)&.to_s, chars: chars.is_a?(Numeric) ? chars.to_i : nil,
|
|
27
|
+
digest: get.call(:digest)&.to_s)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The records of a saved list (others dropped).
|
|
31
|
+
# @return [Array<PromptNote>]
|
|
32
|
+
def self.list(raw)
|
|
33
|
+
Array(raw).filter_map { |entry| from_h(entry) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# "model_notes_deepseek (system, 612 chars), model_notes_x (project,
|
|
37
|
+
# 80 chars)": the line /model, /stats and chi self show; "" without
|
|
38
|
+
# notes. As format.js promptNotesText (spec/shared/labels_matrix.json).
|
|
39
|
+
# @param notes [Array<PromptNote, Hash>]
|
|
40
|
+
def self.text(notes)
|
|
41
|
+
list(notes).map(&:label).join(", ")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# "model_notes_deepseek (system, 612 chars)".
|
|
45
|
+
def label
|
|
46
|
+
details = [scope, chars && "#{chars} chars"].compact.reject { |part| part.to_s.empty? }
|
|
47
|
+
details.empty? ? name : "#{name} (#{details.join(", ")})"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The session file's record (string keys).
|
|
51
|
+
def to_file = to_h.transform_keys(&:to_s)
|
|
52
|
+
end
|
|
53
|
+
end
|