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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -1
  3. data/docs/cli.md +8 -6
  4. data/docs/configuration.md +8 -0
  5. data/docs/context.md +46 -0
  6. data/docs/memory.md +18 -0
  7. data/docs/plugins.md +27 -5
  8. data/docs/sessions.md +5 -5
  9. data/docs/testing.md +16 -0
  10. data/lib/samagotchi/bundles/github-pr/manifest.yml +4 -4
  11. data/lib/samagotchi/bundles/github-pr/plugin.rb +177 -1
  12. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  13. data/lib/samagotchi/child_reports.rb +8 -4
  14. data/lib/samagotchi/context_command.rb +5 -2
  15. data/lib/samagotchi/context_sources.rb +40 -0
  16. data/lib/samagotchi/engine.rb +31 -1
  17. data/lib/samagotchi/model_notes.rb +7 -4
  18. data/lib/samagotchi/plugin/attached_context.rb +75 -11
  19. data/lib/samagotchi/prompt_note.rb +53 -0
  20. data/lib/samagotchi/self_report.rb +32 -0
  21. data/lib/samagotchi/session.rb +16 -1
  22. data/lib/samagotchi/session_commands.rb +24 -5
  23. data/lib/samagotchi/session_metrics.rb +21 -4
  24. data/lib/samagotchi/system_prompt.rb +14 -1
  25. data/lib/samagotchi/terminal_ui/attached_loop.rb +12 -0
  26. data/lib/samagotchi/terminal_ui/formatting.rb +4 -0
  27. data/lib/samagotchi/tool_declarations.rb +2 -1
  28. data/lib/samagotchi/turn_flow.rb +8 -0
  29. data/lib/samagotchi/version.rb +1 -1
  30. data/lib/samagotchi/web/app.rb +55 -8
  31. data/lib/samagotchi/web/public/app.js +73 -11
  32. data/lib/samagotchi/web/public/ctx.js +27 -2
  33. data/lib/samagotchi/web/public/data.js +6 -3
  34. data/lib/samagotchi/web/public/format.js +24 -0
  35. data/lib/samagotchi/web/public/index.html +8 -0
  36. data/lib/samagotchi/web/public/llm_context_chip.js +151 -21
  37. data/lib/samagotchi/web/public/model_pick.js +3 -1
  38. data/lib/samagotchi/web/public/model_picker.js +2 -0
  39. data/lib/samagotchi/web/public/session_state.js +5 -0
  40. data/lib/samagotchi/web/public/timing.js +3 -1
  41. data/lib/samagotchi/web/public/turn_view.js +15 -2
  42. data/lib/samagotchi/web/session_summary.rb +2 -0
  43. data/lib/samagotchi/worker.rb +16 -4
  44. data/lib/samagotchi/worker_wakes.rb +2 -1
  45. 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
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.41.0
3
+ version: 0.43.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:
@@ -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 it is kept (completed or canceled):
114
- # the cursors move on (DelegateCursors) and the rings go; #release when it
115
- # failed: the next turn reads the same rings again.
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 failed (rolled back): its rings stay for the next one.
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
- def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, state_dir: nil, env: ENV, cwd: Dir.pwd)
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 = (Time.now - Time.iso8601(iso)).to_i
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
 
@@ -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 = location or raise Error, "this session has no id yet"
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
- return nil if own.declined?(source.name) || (url && own.declined_hints.include?(source.hint))
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
- own.add(source.with(scope: own.scope))
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
- # Whether the user removed +name+ from this session (so #attach skips it).
46
- def declined?(name)
47
- own = location
48
- own ? own.declined?(name) : false
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
- false
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