samagotchi 0.102.0 → 0.103.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -1
  3. data/bin/chi +23 -191
  4. data/docs/architecture.md +54 -0
  5. data/docs/cli.md +114 -7
  6. data/docs/configuration.md +7 -1
  7. data/docs/guardrails.md +2 -1
  8. data/docs/internals/engine.md +1 -1
  9. data/docs/internals/llm-context-forget.md +11 -1
  10. data/docs/internals/remote-sessions.md +303 -0
  11. data/docs/internals/working-set.md +95 -11
  12. data/docs/plugins.md +26 -2
  13. data/docs/sessions.md +12 -5
  14. data/docs/sub-agent.md +8 -0
  15. data/lib/samagotchi/answer_command.rb +27 -25
  16. data/lib/samagotchi/bridge.rb +24 -4
  17. data/lib/samagotchi/bridge_client/event_stream.rb +81 -12
  18. data/lib/samagotchi/bridge_client/stream_request.rb +77 -0
  19. data/lib/samagotchi/bridge_client.rb +22 -38
  20. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/claude-peer/scripts/pair +30 -1
  22. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/codex-peer/scripts/pair +49 -4
  24. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +7 -4
  25. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  26. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  27. data/lib/samagotchi/chi_invocation.rb +43 -0
  28. data/lib/samagotchi/cli/command.rb +3 -2
  29. data/lib/samagotchi/cli/flags.rb +5 -0
  30. data/lib/samagotchi/cli/parent_wait.rb +22 -13
  31. data/lib/samagotchi/context_command.rb +80 -73
  32. data/lib/samagotchi/engine.rb +12 -8
  33. data/lib/samagotchi/idle_recap.rb +7 -1
  34. data/lib/samagotchi/kernel_loop.rb +13 -1
  35. data/lib/samagotchi/launch_mode.rb +22 -0
  36. data/lib/samagotchi/llm_context_forget.rb +13 -6
  37. data/lib/samagotchi/note_command.rb +8 -7
  38. data/lib/samagotchi/parent_report.rb +55 -43
  39. data/lib/samagotchi/remote_command.rb +170 -0
  40. data/lib/samagotchi/remotes.rb +130 -0
  41. data/lib/samagotchi/run_options.rb +233 -0
  42. data/lib/samagotchi/send_command/busy.rb +58 -0
  43. data/lib/samagotchi/send_command/parent_gate.rb +23 -10
  44. data/lib/samagotchi/send_command/wait_target.rb +16 -3
  45. data/lib/samagotchi/send_command.rb +173 -108
  46. data/lib/samagotchi/session.rb +30 -5
  47. data/lib/samagotchi/session_access/contract.rb +129 -0
  48. data/lib/samagotchi/session_access/dispatch.rb +133 -0
  49. data/lib/samagotchi/session_access/http_transport.rb +109 -0
  50. data/lib/samagotchi/session_access/local.rb +349 -0
  51. data/lib/samagotchi/session_access/remote.rb +638 -0
  52. data/lib/samagotchi/session_access/web_bridge_client.rb +181 -0
  53. data/lib/samagotchi/session_access.rb +141 -0
  54. data/lib/samagotchi/session_archive_command.rb +9 -5
  55. data/lib/samagotchi/session_commands.rb +20 -15
  56. data/lib/samagotchi/session_delete_command.rb +8 -4
  57. data/lib/samagotchi/session_manager.rb +7 -2
  58. data/lib/samagotchi/session_setup.rb +11 -6
  59. data/lib/samagotchi/sessions_command.rb +36 -14
  60. data/lib/samagotchi/terminal_ui/attach_launcher.rb +51 -15
  61. data/lib/samagotchi/terminal_ui/attach_link.rb +104 -0
  62. data/lib/samagotchi/terminal_ui/attached_loop.rb +80 -75
  63. data/lib/samagotchi/terminal_ui/local_link.rb +130 -0
  64. data/lib/samagotchi/terminal_ui/version_lines.rb +5 -3
  65. data/lib/samagotchi/terminal_ui.rb +2 -2
  66. data/lib/samagotchi/title_command.rb +9 -11
  67. data/lib/samagotchi/version.rb +1 -1
  68. data/lib/samagotchi/web/app.rb +360 -69
  69. data/lib/samagotchi/web/public/data.js +3 -2
  70. data/lib/samagotchi/web/public/index.html +20 -4
  71. data/lib/samagotchi/web/public/stage_frame.js +7 -2
  72. data/lib/samagotchi/web/public/stage_view.js +3 -2
  73. data/lib/samagotchi/web/public/stream_handlers.js +3 -1
  74. data/lib/samagotchi/web/public/working_set.js +159 -28
  75. data/lib/samagotchi/web/public/working_set_rail.js +39 -12
  76. data/lib/samagotchi/web/public/working_set_view.js +91 -33
  77. data/lib/samagotchi/web/session_access_routes.rb +343 -0
  78. data/lib/samagotchi/worker_sidecar.rb +12 -5
  79. data/lib/samagotchi/working_set.rb +88 -14
  80. data/lib/samagotchi/working_set_command.rb +133 -30
  81. data/lib/samagotchi/working_set_pin.rb +34 -0
  82. data/lib/samagotchi/working_set_steer.rb +191 -27
  83. metadata +19 -1
@@ -47,18 +47,45 @@ module Samagotchi
47
47
  # collapsed); +state+ live|stale|forgotten|dropped; +tokens+ its content
48
48
  # outputs as sent; +ops+ read|edit for a file, read|write for a memory,
49
49
  # read for a URL, in first-seen order; +count+ the runs that touched
50
- # it; +first_id+ the first one's chi id (ToolIds).
51
- Item = Data.define(:kind, :key, :state, :tokens, :ops, :count, :first_id)
50
+ # it; +first_id+ the first one's chi id (ToolIds); +pinned+ whether
51
+ # the user pinned it (WorkingSetPin: the model's forget_outputs refuses
52
+ # its outputs), whatever the strategy; +has_drop+ whether any of its
53
+ # content outputs holds the user's applied drop (an undrop has
54
+ # something to take back: a forgotten item too, its other outputs
55
+ # stubbed by the model's layers).
56
+ Item = Data.define(:kind, :key, :state, :tokens, :ops, :count, :first_id, :pinned, :has_drop)
57
+
58
+ # An item the user added (/working-set add) that the next turn's note
59
+ # asks the model to read: its identity (+kind+, +key+, as an Item's)
60
+ # and how the note names it (+label+: a file relative to the session's
61
+ # folder, "memory NAME", a URL).
62
+ Add = Data.define(:kind, :key, :label)
52
63
 
53
64
  # A projection and the conversation's size (entries) it was built
54
65
  # from: the web keeps the newer of an event's and a GET's by it.
55
- Snapshot = Data.define(:items, :at) do
56
- def empty? = items.empty?
66
+ # +adds+: the pending note's adds (#pending_adds), not items yet.
67
+ Snapshot = Data.define(:items, :at, :adds) do
68
+ def initialize(items:, at:, adds: [])
69
+ super
70
+ end
71
+
72
+ def empty? = items.empty? && adds.empty?
57
73
 
58
- # The JSON the event and the route carry: {items: [...], at:}.
59
- def to_h = { items: items.map(&:to_h), at: at }
74
+ # The JSON the event and the route carry: {items: [...], at:}, and
75
+ # adds: [{kind, key, label}] while any is pending.
76
+ def to_h
77
+ hash = { items: items.map(&:to_h), at: at }
78
+ hash[:adds] = adds.map(&:to_h) unless adds.empty?
79
+ hash
80
+ end
60
81
  end
61
82
 
83
+ # The turn-start note (WorkingSetSteer#record!): a tail system message
84
+ # of this kind, its ops ([{"op", "kind", "key", "label"}]) in NOTE_OPS.
85
+ NOTE_KIND = "working_set"
86
+ NOTE_OPS = :working_set_ops
87
+ ADD = "add"
88
+
62
89
  FILE_OPS = { "read" => "read", "edit" => "edit", "write" => "edit" }.freeze
63
90
  CONTENT = %w[read memory_read web_fetch].freeze
64
91
  MEMORY_READ = Tools::MemoryRead::NAME
@@ -83,21 +110,52 @@ module Samagotchi
83
110
  # @param strategy [Symbol, Array<Symbol>] :none or the layers, as
84
111
  # LLMContextView takes it (the session's current one)
85
112
  # @param stat [#call] File.stat, or a stand-in (specs)
113
+ # @param pins [Array<WorkingSetPin>] the session's pins
86
114
  # @return [Snapshot]
87
- def build(conversation, cwd:, strategy:, stat: File.method(:stat))
115
+ def build(conversation, cwd:, strategy:, stat: File.method(:stat), pins: [])
88
116
  conversation = Array(conversation)
89
117
  view = LLMContextView.new(strategy: strategy)
118
+ pinned = pins.to_set { |pin| [pin.kind, pin.key] }
90
119
  items = all_touches(conversation, cwd, view).group_by { |touch| [touch.kind, touch.key] }.map do |(kind, key), group|
91
- item(kind, key, group, conversation, view, stat)
120
+ item(kind, key, group, conversation, view, stat, pinned.include?([kind, key]))
121
+ end
122
+ Snapshot.new(items: items, at: conversation.size, adds: pending_adds(conversation))
123
+ end
124
+
125
+ # The index of the note the next turn will send (one after the last
126
+ # turn's entries: only system messages follow it), or nil.
127
+ def pending_note_index(messages)
128
+ (messages.size - 1).downto(0) do |index|
129
+ message = messages[index]
130
+ return nil unless message.is_a?(Hash) && message[:role].to_s == "system"
131
+ return index if message[:kind].to_s == NOTE_KIND
132
+ end
133
+ nil
134
+ end
135
+
136
+ # The ops the pending note lists ([{"op", "kind", "key", "label"}]).
137
+ def pending_ops(messages)
138
+ messages = Array(messages)
139
+ index = pending_note_index(messages)
140
+ index ? Array(messages[index][NOTE_OPS]).select { |op| op.is_a?(Hash) } : []
141
+ end
142
+
143
+ # The items the user added since the last turn, which the next turn's
144
+ # note asks the model to read.
145
+ # @return [Array<Add>]
146
+ def pending_adds(messages)
147
+ pending_ops(messages).select { |op| op["op"] == ADD }.map do |op|
148
+ Add.new(kind: op["kind"].to_s, key: op["key"].to_s, label: op["label"].to_s)
92
149
  end
93
- Snapshot.new(items: items, at: conversation.size)
94
150
  end
95
151
 
96
152
  # One content output (read, memory_read, web_fetch) of an item: its
97
153
  # chi +id+ (ToolIds), the +index+ of the tool_response entry holding
98
154
  # it, whether the id is +derived+ (a legacy entry's: nothing can name
99
155
  # it) and whether the model is still +sent+ it under the strategy.
100
- Output = Data.define(:id, :index, :derived, :sent)
156
+ # +shared_with+: the other names a memory_read of several names holds in the
157
+ # same output (an op on the output acts on them too), else empty.
158
+ Output = Data.define(:id, :index, :derived, :sent, :shared_with)
101
159
 
102
160
  # The content outputs of the item +kind+/+key+ (#build's identity: a
103
161
  # file's absolute path, a memory's name, a URL), in conversation
@@ -113,10 +171,23 @@ module Samagotchi
113
171
 
114
172
  ref = touch.run.ref
115
173
  Output.new(id: ref.id, index: touch.run.index, derived: ref.derived?,
116
- sent: still_sent?(touch, conversation, view))
174
+ sent: still_sent?(touch, conversation, view), shared_with: shared_with(touch, key))
117
175
  end
118
176
  end
119
177
 
178
+ # The ids of the content outputs of the items +pins+ name (the ones
179
+ # the model's forget_outputs refuses), by the same pairing as #build.
180
+ # @param pins [Array<WorkingSetPin>]
181
+ # @return [Set<String>]
182
+ def pinned_ids(conversation, pins:, cwd:)
183
+ return Set.new if pins.empty?
184
+
185
+ conversation = Array(conversation)
186
+ pinned = pins.to_set { |pin| [pin.kind, pin.key] }
187
+ touches = all_touches(conversation, cwd, LLMContextView.new(strategy: LLMContextView::NONE))
188
+ touches.select { |touch| touch.content && pinned.include?([touch.kind, touch.key]) }.to_set { |touch| touch.run.ref.id }
189
+ end
190
+
120
191
  # Every paired run's touches, with what +view+ sends of its output.
121
192
  def all_touches(conversation, cwd, view)
122
193
  stored = LLMContextStale.paired_runs(conversation)
@@ -159,6 +230,9 @@ module Samagotchi
159
230
  ToolResponse.interrupted_text?(text) || text.sub(ToolResponse::LEAD, "").lstrip.start_with?("Error")
160
231
  end
161
232
 
233
+ # The other names +touch+'s output holds (a memory_read of several).
234
+ def shared_with(touch, key) = touch.share > 1 ? found_names(touch.run) - [key] : []
235
+
162
236
  # A memory_read's names (Tools::MemoryRead.parse_names: none for the
163
237
  # index listing), less the ones its output says weren't found.
164
238
  def found_names(run)
@@ -168,7 +242,7 @@ module Samagotchi
168
242
  names - missing
169
243
  end
170
244
 
171
- def item(kind, key, group, conversation, view, stat)
245
+ def item(kind, key, group, conversation, view, stat, pinned)
172
246
  first = group.first
173
247
  contents = group.select(&:content)
174
248
  tokens = contents.sum { |touch| touch.sent.length / TokenUsage::CHARS_PER_TOKEN / touch.share }.ceil
@@ -180,7 +254,7 @@ module Samagotchi
180
254
  LIVE
181
255
  end
182
256
  Item.new(kind: kind, key: key, state: state, tokens: tokens, ops: group.map(&:op).uniq, count: group.size,
183
- first_id: first.run.ref.id)
257
+ first_id: first.run.ref.id, pinned: pinned, has_drop: contents.any? { |touch| dropped?(touch, conversation) })
184
258
  end
185
259
 
186
260
  # Whether the model is still sent +touch+'s output: whole (with the
@@ -233,6 +307,6 @@ module Samagotchi
233
307
 
234
308
  def field(hash, key) = hash.key?(key) ? hash[key] : hash[key.to_s]
235
309
 
236
- private_class_method :all_touches, :touches, :failed?, :found_names, :item, :still_sent?, :dropped?, :with_id, :changed?, :stamp_of, :field
310
+ private_class_method :all_touches, :touches, :failed?, :found_names, :shared_with, :item, :still_sent?, :dropped?, :with_id, :changed?, :stamp_of, :field
237
311
  end
238
312
  end
@@ -2,22 +2,35 @@
2
2
 
3
3
  require_relative "working_set"
4
4
  require_relative "working_set_steer"
5
+ require_relative "tools/memory"
5
6
 
6
7
  module Samagotchi
7
8
  # /working-set: with no arguments the session's working set (WorkingSet,
8
9
  # as the web's list shows it: each item, what was done to it, its state
9
- # and tokens, then the changes the next turn starts with); `drop KEY…`
10
- # and `undrop KEY…` steer it (WorkingSetSteer). KEY: a path (absolute or
11
- # the session's folder's), memory:NAME, a URL.
10
+ # and tokens, then the changes the next turn starts with); `drop KEY…`,
11
+ # `undrop KEY…`, `pin KEY…`, `unpin KEY…`, `add KEY…` and `unadd KEY…`
12
+ # steer it (WorkingSetSteer). An add asks the model, in the next turn's
13
+ # note, to read the item (a file that exists, a memory, an http(s) URL).
14
+ # KEY: a path (absolute or the session's folder's), memory:NAME, a URL;
15
+ # one with spaces quoted ("my dir/a.rb", WorkingSetSteer.words), as the
16
+ # listing shows it.
17
+ # A pin is saved on the session (Session#working_set_pins); the turn-start
18
+ # note names it only where the model has forget_outputs (the strategy's
19
+ # forget layer), or to cancel a pin or an unpin it named.
12
20
  #
13
21
  # An op runs between turns (mid_turn :queue: a line sent while a turn
14
22
  # runs waits for its end), on a copy of the session's messages that
15
23
  # replaces them, so the next turn's first request is the first with it.
16
24
  class WorkingSetCommand
17
25
  NAME = "/working-set"
18
- USAGE = "usage: #{NAME} [drop KEY… | undrop KEY…] (KEY: a path, memory:NAME or a URL)".freeze
19
- VERBS = [WorkingSetSteer::DROP, WorkingSetSteer::UNDROP].freeze
20
- OP_WORDS = { WorkingSetSteer::DROP => "dropped", WorkingSetSteer::UNDROP => "undropped" }.freeze
26
+ USAGE = "usage: #{NAME} [drop KEY… | undrop KEY… | pin KEY… | unpin KEY… | add KEY… | unadd KEY…] " \
27
+ "(KEY: a path, memory:NAME or a URL; \"quote\" one with spaces)".freeze
28
+ VERBS = [WorkingSetSteer::DROP, WorkingSetSteer::UNDROP, WorkingSetSteer::PIN, WorkingSetSteer::UNPIN,
29
+ WorkingSetSteer::ADD, WorkingSetSteer::UNADD].freeze
30
+ OP_WORDS = { WorkingSetSteer::DROP => "dropped", WorkingSetSteer::UNDROP => "undropped",
31
+ WorkingSetSteer::PIN => "pinned", WorkingSetSteer::UNPIN => "unpinned",
32
+ WorkingSetSteer::ADD => "added", WorkingSetSteer::UNADD => "unadded" }.freeze
33
+ PIN_OPS = [WorkingSetSteer::PIN, WorkingSetSteer::UNPIN].freeze
21
34
 
22
35
  # @param engine [Engine] #session, #working_set_strategy,
23
36
  # #clone_messages, #replace_messages
@@ -28,39 +41,39 @@ module Samagotchi
28
41
  end
29
42
 
30
43
  # @param args [String] what follows /working-set
31
- # @return [Array(String, Boolean, Symbol)] the reply, whether the
32
- # conversation changed, and :ok or :error (every key refused)
44
+ # @return [Array(String, Array<Symbol>, Symbol)] the reply, what
45
+ # changed (:messages, :working_set_pins; none when nothing did), and
46
+ # :ok or :error (every key refused)
33
47
  # @raise [ArgumentError] a line that isn't one (its message and USAGE)
34
48
  def run(args)
35
- verb, *words = args.to_s.split
36
- return [listing, false, :ok] if verb.nil?
49
+ verb, *words = WorkingSetSteer.words(args)
50
+ return [listing, [], :ok] if verb.nil?
37
51
  raise ArgumentError, USAGE unless VERBS.include?(verb) && !words.empty?
38
52
 
39
53
  session = @engine.session or raise ArgumentError, "no session yet"
40
54
  cwd = session.working_directory
41
55
  strategy = @engine.working_set_strategy
42
56
  messages = @engine.clone_messages(session.messages)
57
+ pins = session.working_set_pins
43
58
  lines = []
44
59
  done = 0
60
+ noted = false
45
61
  words.each do |word|
46
62
  target = WorkingSetSteer.target(word, cwd: cwd)
47
- result = if verb == WorkingSetSteer::DROP
48
- WorkingSetSteer.drop(messages, target, strategy: strategy, cwd: cwd)
49
- else
50
- sent = !WorkingSetSteer.pending?(messages, WorkingSetSteer::DROP, target)
51
- WorkingSetSteer.undrop(messages, target, strategy: strategy, cwd: cwd, sent: sent)
52
- end
53
- messages = WorkingSetSteer.record!(messages, result)
54
- lines << done_line(result)
63
+ result = op(verb, messages, pins, target, strategy, cwd)
64
+ [result.undone, result].compact.select { |each| noted?(each, messages, strategy) }.each do |each|
65
+ messages = WorkingSetSteer.record!(messages, each)
66
+ noted = true
67
+ end
68
+ pins = WorkingSetSteer.pinning(pins, result) if PIN_OPS.include?(result.op)
69
+ lines << done_line(result.undone) if result.undone
70
+ lines << done_line(result, strategy)
55
71
  done += 1
56
72
  rescue WorkingSetSteer::Refused => e
57
- lines << "#{verb} #{word}: #{e.message}"
73
+ lines << "#{verb} #{WorkingSetSteer.quote(word)}: #{e.message}"
58
74
  end
59
- if done.positive?
60
- @engine.replace_messages(messages)
61
- @save.call(session)
62
- end
63
- [lines.join("\n"), done.positive?, done.positive? ? :ok : :error]
75
+ changed = done.positive? ? save(session, noted && messages, pins) : []
76
+ [lines.join("\n"), changed, done.positive? ? :ok : :error]
64
77
  end
65
78
 
66
79
  # The set under the strategy the next turn runs, and the pending ops.
@@ -69,10 +82,11 @@ module Samagotchi
69
82
  return "no session yet" unless session
70
83
 
71
84
  messages = Array(session.messages)
72
- items = WorkingSet.build(messages, cwd: session.working_directory, strategy: @engine.working_set_strategy).items
85
+ items = WorkingSet.build(messages, cwd: session.working_directory, strategy: @engine.working_set_strategy,
86
+ pins: session.working_set_pins).items
73
87
  return "working set: empty (the files, memories and URLs the turns read show here)\n#{USAGE}" if items.empty?
74
88
 
75
- rows = items.map { |item| [name(item, session.working_directory), words(item)] }
89
+ rows = items.map { |item| [WorkingSetSteer.quote(name(item, session.working_directory)), words(item)] }
76
90
  width = rows.map { |row, _| row.length }.max
77
91
  lines = ["working set (#{items.size} item#{"s" unless items.size == 1}):"]
78
92
  lines.concat(rows.map { |row, text| " #{row.ljust(width)} #{text}" })
@@ -86,6 +100,65 @@ module Samagotchi
86
100
 
87
101
  private
88
102
 
103
+ def op(verb, messages, pins, target, strategy, cwd)
104
+ case verb
105
+ when WorkingSetSteer::DROP then WorkingSetSteer.drop(messages, target, strategy: strategy, cwd: cwd, pins: pins)
106
+ when WorkingSetSteer::UNDROP
107
+ sent = !WorkingSetSteer.pending?(messages, WorkingSetSteer::DROP, target)
108
+ WorkingSetSteer.undrop(messages, target, strategy: strategy, cwd: cwd, sent: sent)
109
+ when WorkingSetSteer::PIN then WorkingSetSteer.pin(messages, pins, target, strategy: strategy, cwd: cwd)
110
+ when WorkingSetSteer::UNPIN then WorkingSetSteer.unpin(pins, target)
111
+ when WorkingSetSteer::ADD
112
+ WorkingSetSteer.add(messages, target, strategy: strategy, cwd: cwd, found: method(:missing))
113
+ else WorkingSetSteer.unadd(messages, target)
114
+ end
115
+ end
116
+
117
+ # Why an added +target+ can't be read (a Refused reason), nil when it
118
+ # can: a file that is there, a memory the project or system scope
119
+ # holds (memory_read's look). A URL is fetched as it is.
120
+ def missing(target)
121
+ case target.kind
122
+ when WorkingSet::FILE
123
+ return :directory if File.directory?(target.key)
124
+
125
+ File.file?(target.key) ? nil : :no_file
126
+ when WorkingSet::MEMORY
127
+ return :bad_memory if target.key.empty? || Tools::MemoryRead.invalid_name?(target.key)
128
+
129
+ found = %w[project system].any? { |scope| File.file?(Tools::MemoryRead.memory_path(target.key, scope)) }
130
+ found ? nil : :no_memory
131
+ end
132
+ end
133
+
134
+ # Whether the turn-start note lists +done+: a drop or an undrop always;
135
+ # a pin or an unpin where the model has forget_outputs, or when it
136
+ # cancels one the note lists.
137
+ def noted?(done, messages, strategy)
138
+ return true unless PIN_OPS.include?(done.op)
139
+
140
+ forget?(strategy) || WorkingSetSteer.pending?(messages, WorkingSetSteer::INVERSE.fetch(done.op), done.target)
141
+ end
142
+
143
+ def forget?(strategy) = Array(strategy).include?(:forget)
144
+
145
+ # Puts the ops' messages (nil: unchanged) and pins in place and saves
146
+ # the session.
147
+ # @return [Array<Symbol>] what changed
148
+ def save(session, messages, pins)
149
+ changed = []
150
+ if messages
151
+ @engine.replace_messages(messages)
152
+ changed << :messages
153
+ end
154
+ unless pins == session.working_set_pins
155
+ session.working_set_pins = pins
156
+ changed << :working_set_pins
157
+ end
158
+ @save.call(session) unless changed.empty?
159
+ changed
160
+ end
161
+
89
162
  def name(item, cwd)
90
163
  case item.kind
91
164
  when WorkingSet::FILE then WorkingSetSteer.target(item.key, cwd: cwd).label
@@ -98,17 +171,47 @@ module Samagotchi
98
171
  parts = [item.ops.join(", ")]
99
172
  parts << "×#{item.count}" if item.count > 1
100
173
  parts << item.state unless item.state == WorkingSet::LIVE
174
+ # A drop on some of its reads (the rest forgotten or still sent): undrop takes it back.
175
+ parts << "part dropped" if item.has_drop && item.state != WorkingSet::DROPPED
176
+ parts << "pinned" if item.pinned
101
177
  parts << "~#{item.tokens} tokens" if item.tokens.positive?
102
178
  parts.join(" · ")
103
179
  end
104
180
 
105
- def done_line(done)
106
- what = "#{OP_WORDS[done.op]} #{done.target.label} (#{done.ids.join(", ")})"
107
- if done.op == WorkingSetSteer::DROP
181
+ def done_line(done, strategy = nil)
182
+ ids = done.ids.empty? ? "" : " (#{done.ids.join(", ")})"
183
+ "#{op_line(done, "#{OP_WORDS[done.op]} #{done.target.label}#{ids}", strategy)}#{with_line(done)}"
184
+ end
185
+
186
+ # A memory_read of several names is one output: the op acted on the
187
+ # others' read too. Says which names went with it.
188
+ def with_line(done)
189
+ return "" if done.shared_with.empty?
190
+
191
+ names = done.shared_with.map { |name| "memory #{name}" }.join(", ")
192
+ "; the same memory_read holds #{names}: #{OP_WORDS[done.op]} with it (that read only)"
193
+ end
194
+
195
+ def op_line(done, what, strategy)
196
+ case done.op
197
+ when WorkingSetSteer::PIN
198
+ off = forget?(strategy) ? "" : " (the strategy has no forget layer now: the pin holds when it has)"
199
+ "#{what}: forget_outputs refuses its outputs, its later reads too#{off}"
200
+ when WorkingSetSteer::UNPIN then "#{what}: the model may forget it again"
201
+ when WorkingSetSteer::ADD
202
+ "#{what}: the next turn's note asks the model to read it first (one tool call)"
203
+ when WorkingSetSteer::UNADD then "#{what}: its add was never sent; nothing changes"
204
+ when WorkingSetSteer::DROP
108
205
  frees = done.freed_chars.positive? ? "frees #{WorkingSetSteer.k(done.freed_chars)}" : "frees nothing, the stubs are as long"
109
206
  "#{what}: the next turn's first request sends the stubs (#{frees}; the server reads the " \
110
207
  "#{WorkingSetSteer.k(done.tail_chars)} after them again)"
111
- elsif done.free
208
+ else
209
+ undrop_line(what, done)
210
+ end
211
+ end
212
+
213
+ def undrop_line(what, done)
214
+ if done.free
112
215
  "#{what}: its drop was never sent; nothing changes"
113
216
  else
114
217
  "#{what}: the next request sends it whole again (the server reads the #{WorkingSetSteer.k(done.tail_chars)} " \
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # One item of the working set the user pinned (/working-set pin): its
5
+ # identity as WorkingSet names an item, +kind+ file|memory|url and +key+
6
+ # (a file's absolute path, a memory's name, a URL). The model's
7
+ # forget_outputs refuses its outputs, the ones it has and the ones it
8
+ # reads later (WorkingSetSteer). Saved as the session's
9
+ # working_set_pins ([{"kind", "key"}]); a respawn, a continue and a fork
10
+ # keep them (SessionSetup).
11
+ WorkingSetPin = Data.define(:kind, :key) do
12
+ def initialize(kind:, key:)
13
+ super(kind: kind.to_s, key: key.to_s)
14
+ end
15
+
16
+ # The session file's form.
17
+ def to_file = { "kind" => kind, "key" => key }
18
+
19
+ # +value+'s pins, in order, no repeats: pins, or a file's Hashes
20
+ # (string or symbol keys); anything else (a hand-edited entry without
21
+ # a kind or key) is left out.
22
+ # @return [Array<WorkingSetPin>] frozen
23
+ def self.list(value)
24
+ Array(value).filter_map do |entry|
25
+ next entry if entry.is_a?(WorkingSetPin)
26
+ next unless entry.is_a?(Hash)
27
+
28
+ kind = entry["kind"] || entry[:kind]
29
+ key = entry["key"] || entry[:key]
30
+ new(kind: kind, key: key) unless kind.to_s.empty? || key.to_s.empty?
31
+ end.uniq.freeze
32
+ end
33
+ end
34
+ end