samagotchi 0.62.0 → 0.63.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -1
  3. data/README.md +4 -1
  4. data/bin/chi +3 -2
  5. data/docs/cli.md +4 -1
  6. data/docs/configuration.md +1 -1
  7. data/docs/context.md +13 -3
  8. data/docs/hooks.md +6 -3
  9. data/docs/memory.md +1 -1
  10. data/docs/plugins.md +89 -2
  11. data/docs/releasing.md +1 -1
  12. data/docs/sub-agent.md +6 -0
  13. data/docs/testing.md +7 -4
  14. data/lib/samagotchi/answer_refs.rb +18 -26
  15. data/lib/samagotchi/bridge.rb +23 -1
  16. data/lib/samagotchi/bundles/claude-peer/manifest.yml +22 -0
  17. data/lib/samagotchi/bundles/claude-peer/plugin.rb +406 -0
  18. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +104 -0
  19. data/lib/samagotchi/bundles/claude-peer/scripts/pair +66 -0
  20. data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +60 -0
  21. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  22. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/loop-guard/plugin.rb +17 -6
  24. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  26. data/lib/samagotchi/context_absorber.rb +25 -6
  27. data/lib/samagotchi/idle_recap.rb +2 -3
  28. data/lib/samagotchi/image_store.rb +8 -0
  29. data/lib/samagotchi/plugin/attached_context.rb +7 -4
  30. data/lib/samagotchi/recap_setup.rb +3 -3
  31. data/lib/samagotchi/row_refs.rb +3 -4
  32. data/lib/samagotchi/session.rb +12 -0
  33. data/lib/samagotchi/terminal_ui/attach_launcher.rb +6 -3
  34. data/lib/samagotchi/terminal_ui/attached_loop.rb +26 -9
  35. data/lib/samagotchi/terminal_ui.rb +31 -11
  36. data/lib/samagotchi/tools/execute.rb +22 -4
  37. data/lib/samagotchi/version.rb +1 -1
  38. data/lib/samagotchi/web/app.rb +6 -4
  39. data/lib/samagotchi/web/public/activity.js +2 -2
  40. data/lib/samagotchi/web/public/app.js +47 -91
  41. data/lib/samagotchi/web/public/card.js +73 -23
  42. data/lib/samagotchi/web/public/chains.js +6 -4
  43. data/lib/samagotchi/web/public/command_view.js +39 -35
  44. data/lib/samagotchi/web/public/copy.js +7 -15
  45. data/lib/samagotchi/web/public/diff_view.js +20 -23
  46. data/lib/samagotchi/web/public/format.js +0 -48
  47. data/lib/samagotchi/web/public/images.js +12 -16
  48. data/lib/samagotchi/web/public/question_cards.js +3 -2
  49. data/lib/samagotchi/web/public/read_view.js +10 -9
  50. data/lib/samagotchi/web/public/scope.js +23 -0
  51. data/lib/samagotchi/web/public/timing.js +0 -7
  52. data/lib/samagotchi/web/public/turn_html.js +119 -59
  53. data/lib/samagotchi/web/public/turn_view.js +73 -116
  54. data/lib/samagotchi/web/public/vendor/directive.js +8 -0
  55. data/lib/samagotchi/web/public/vendor/directives/unsafe-html.js +8 -0
  56. data/lib/samagotchi/worker.rb +46 -30
  57. data/lib/samagotchi/worker_inbound.rb +12 -11
  58. metadata +8 -1
@@ -297,7 +297,7 @@ class Plugin
297
297
  def register(chi)
298
298
  chi.on(:before_turn) { |_event| reset }
299
299
  chi.on(:before_tool_call) { |event, ctx| before(event, ctx) }
300
- chi.on(:after_tool_call) { |event| after(event) }
300
+ chi.on(:after_tool_call) { |event, ctx| after(event, ctx) }
301
301
  return unless @thinking["watch"]
302
302
 
303
303
  chi.on(:before_generation) do |_event|
@@ -377,6 +377,7 @@ class Plugin
377
377
  @warned = {} # key => true once notified this turn
378
378
  @denials = 0
379
379
  @pending = nil
380
+ @stop_card = nil # the stop's card, posted after the denied call
380
381
  @thinking_loops = 0
381
382
  @good_steps = 0
382
383
  @watch = nil
@@ -412,7 +413,8 @@ class Plugin
412
413
  # A call's result, paired with the before_tool_call that keyed it. A call
413
414
  # that was denied (by us or another voter) ran nothing: no result, or the
414
415
  # deny text would become the key's result and the next call would pass.
415
- def after(event)
416
+ def after(event, ctx)
417
+ post_stop_card(ctx)
416
418
  pending = @pending
417
419
  @pending = nil
418
420
  return unless pending && event[:tool].to_s == pending[:key].first
@@ -426,16 +428,25 @@ class Plugin
426
428
  @previews[key] = preview(output)
427
429
  end
428
430
 
431
+ # The turn stops now; its card waits for the denied call's
432
+ # after_tool_call (#post_stop_card), so a UI that prints events in order
433
+ # shows it under that call's row, not before it.
429
434
  def stop(ctx)
430
435
  ctx.stop_turn("the model kept repeating the same calls")
431
436
  repeated = @attempts.select { |key, n| n > 1 && @last_result[key] && @results[[key, @last_result[key]]] >= @deny_after }
432
437
  lines = repeated.map do |key, n|
433
438
  "- `#{short_key(key)}`: #{n} times, the same result each time (#{@previews[key]})"
434
439
  end
435
- ctx.card(title: "loop-guard stopped the turn",
436
- body: "The model kept repeating these calls:\n\n#{lines.join("\n")}\n\n" \
437
- "Tell it what to try instead, or where to look.",
438
- level: :warn)
440
+ @stop_card = { title: "loop-guard stopped the turn",
441
+ body: "The model kept repeating these calls:\n\n#{lines.join("\n")}\n\n" \
442
+ "Tell it what to try instead, or where to look.",
443
+ level: :warn }
444
+ end
445
+
446
+ def post_stop_card(ctx)
447
+ card = @stop_card
448
+ @stop_card = nil
449
+ ctx.card(**card) if card
439
450
  end
440
451
 
441
452
  def warn(ctx, key, what)
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.62.0
3
+ version: 0.63.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:
7
7
  identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
8
8
  self_map.md: sha256:dfc19266259a9af1e0d769b7e0f67996b740b0c9921fdf3dadad973f6e5c144a
9
9
  config_modification_protocol.md: sha256:16403da5dd97a335e19d9d15bb1ba4390237bf9f4869e35d63c172a8828b5ae0
10
- memory_guide.md: sha256:d16919387c42d229498f470fc4078245a15faee872480ffbeffe2e27897624e4
10
+ memory_guide.md: sha256:b0a1ba42c5334d4117b5733c8c26d7477364baca9169b0c0a711de6ac7fc7f3c
11
11
  delegated.md: sha256:aedaa1e756a244904c5560fff0424e06fb468bbdcb0358bccefe321d67cd2614
@@ -94,7 +94,7 @@ Bundles are versioned directories/zips/tar.gz/git URLs with a `manifest.yml` and
94
94
  - Integrity: each file's sha256 is recorded at install; a hook/plugin/rule file changed afterwards is not loaded (rules: every call denied) until reinstalled. It is an integrity check, not proof of authorship.
95
95
  - Installing = trusting its Ruby code (hooks, plugin), like a gem. Install only copies; the code runs at the next session start (`Engine.new`), so a running worker needs a restart to pick it up.
96
96
  - A bundle without `*.md` (btw, mcp, loop-guard) adds no line to the prompt's memory index.
97
- - Profiles are shipped bundles with only `includes:` (bundle names): `core` (loop-guard, check-in, guardrails, slow-commands; `chi bootstrap` installs it) and `dev` (known-names, mcp, btw, skills, source-links, github-pr, coordinator). `chi bundle install core` installs the ones not installed and records them; a bundle the user uninstalled stays out on a later install, upgrade or `chi update`, which install only bundles new to the profile. `chi bundle uninstall core` removes its recorded bundles, then core (`docs/memory.md` "Bundle profiles").
97
+ - Profiles are shipped bundles with only `includes:` (bundle names): `core` (loop-guard, check-in, guardrails, slow-commands; `chi bootstrap` installs it) and `dev` (known-names, mcp, btw, skills, source-links, github-pr, coordinator, claude-peer). `chi bundle install core` installs the ones not installed and records them; a bundle the user uninstalled stays out on a later install, upgrade or `chi update`, which install only bundles new to the profile. `chi bundle uninstall core` removes its recorded bundles, then core (`docs/memory.md` "Bundle profiles").
98
98
 
99
99
  ### CLI — `chi bundle`
100
100
 
@@ -16,8 +16,9 @@ module Samagotchi
16
16
  class ContextAbsorber
17
17
  # +note+: Engine#add_context_note's hash; +subscription+: the session's
18
18
  # Subscription after it (nil: dropped); +wake_note+: the same note with
19
- # the wake line, for an update whose source asked to wake (the worker
20
- # may start a turn with it), else nil.
19
+ # the wake line, for an update whose source asked to wake or a session
20
+ # push source's first text that asked (the worker may start a turn with
21
+ # it), else nil.
21
22
  Delivery = Data.define(:name, :note, :subscription, :wake_note) do
22
23
  def initialize(name:, note:, subscription:, wake_note: nil) = super
23
24
  end
@@ -93,9 +94,12 @@ module Samagotchi
93
94
  "(third-party text, not your user's words):\n> #{quoted}\n#{last_line}"
94
95
  end
95
96
 
96
- def self.attached_text(name:, hint:, why:, summary:)
97
+ # The attached note's text; a wake turn's (+wake+) ends with the wake
98
+ # line in place of the background line.
99
+ def self.attached_text(name:, hint:, why:, summary:, wake: false)
97
100
  why_part = why ? " Why: #{why}#{"." unless why.match?(/[.!?]\z/)}" : ""
98
- "Attached: #{name}#{" (#{hint})" if hint}.#{why_part}\nSummary: #{summary}\n#{BACKGROUND_LINE} #{read_line(name)}"
101
+ last_line = wake ? WAKE_LAST_LINE : "#{BACKGROUND_LINE} #{read_line(name)}"
102
+ "Attached: #{name}#{" (#{hint})" if hint}.#{why_part}\nSummary: #{summary}\n#{last_line}"
99
103
  end
100
104
 
101
105
  def self.read_line(name) = "Read it with context_read(name: \"#{name}\") when your user's request is about it."
@@ -105,6 +109,8 @@ module Samagotchi
105
109
  # A source the session sees: attached when it has text and was never
106
110
  # noted; updated for a new revision; an error once per run of failures
107
111
  # after a success. A source with no text yet waits (nothing to read).
112
+ # An update that asked to wake carries a wake note, and so does a first
113
+ # text that asked when #first_text_wakes?.
108
114
  def delivery_for(attached, sub, now)
109
115
  snapshot = attached.snapshot
110
116
  return nil unless snapshot.text?
@@ -112,8 +118,11 @@ module Samagotchi
112
118
  name = attached.name
113
119
  hint = snapshot.hint || attached.source.hint
114
120
  if sub.seen.nil?
115
- text = self.class.attached_text(name: name, hint: hint, why: attached.source.why, summary: snapshot.summary)
116
- return delivery(name, text_note_id(name, snapshot), text, now, seen(sub, snapshot))
121
+ args = { name: name, hint: hint, why: attached.source.why, summary: snapshot.summary }
122
+ found = delivery(name, text_note_id(name, snapshot), self.class.attached_text(**args), now, seen(sub, snapshot))
123
+ return found unless first_text_wakes?(attached, sub)
124
+
125
+ return found.with(wake_note: found.note.merge(text: self.class.attached_text(**args, wake: true)))
117
126
  end
118
127
  if sub.seen != snapshot.revision
119
128
  args = { name: name, hint: hint, summary: snapshot.summary, changes: snapshot.serial - sub.seen_serial }
@@ -131,6 +140,16 @@ module Samagotchi
131
140
  sub.with(error_seen: snapshot.error_since))
132
141
  end
133
142
 
143
+ # A first text may wake only for a session's pushed source (`chi context
144
+ # add NAME --push`, attached empty) that asked. Not for a command source
145
+ # (a URL/provider source has a cmd too): its first fetch runs as it is
146
+ # attached, while the user is likely looking at the session. Not for a
147
+ # project source: its first text may predate this session. A blank
148
+ # Subscription has seen_serial 0, so any revision that asked counts.
149
+ def first_text_wakes?(attached, sub)
150
+ attached.source.push? && attached.location.session? && attached.snapshot.wake_serial.to_i > sub.seen_serial
151
+ end
152
+
134
153
  # A subscription whose source is gone: detached when the session was
135
154
  # told of it, dropped quietly otherwise.
136
155
  def detached(sub, now)
@@ -7,6 +7,7 @@ require "time"
7
7
 
8
8
  require_relative "idle_client"
9
9
  require_relative "idle_target"
10
+ require_relative "image_store"
10
11
  require_relative "output_formatter"
11
12
  require_relative "steer"
12
13
  require_relative "recap_store"
@@ -53,8 +54,6 @@ module Samagotchi
53
54
  # separator, while the chat loop writes one message per call.
54
55
  TOOL_OUTPUT_SEPARATOR = "\n\n---\n\n"
55
56
  TOOL_OUTPUT_HEADER_RE = /\A\[([\w.:-]+)\]/
56
- # The text the web sends for a turn of images alone (images.js turnText).
57
- IMAGE_ONLY_RE = /\A\s*(?:\[image: [^\]\n]*\]\s*)+\z/
58
57
  DELEGATE_REPORT_LINE = "(a delegate session's report, delivered by chi; not the user's words)"
59
58
 
60
59
  module_function
@@ -76,7 +75,7 @@ module Samagotchi
76
75
  # A delegate child's report (ChildReports) is chi's news, not
77
76
  # what the user said.
78
77
  content = "#{DELEGATE_REPORT_LINE}\n#{content}" if message["source"].to_s == Steer::DELEGATE_REPORT
79
- images = IMAGE_ONLY_RE.match?(content) ? [] : image_lines(message["images"])
78
+ images = ImageRef.placeholder_only?(content) ? [] : image_lines(message["images"])
80
79
  [content, *images].reject(&:empty?).join("\n")
81
80
  when "model", "assistant"
82
81
  strip_thought(message["content"].to_s.gsub(TOOL_CALL_RE, ""))
@@ -159,6 +159,14 @@ module Samagotchi
159
159
  # One-line texts about an image ref ({file:, mime:, width:, height:,
160
160
  # bytes:, name:, source:}).
161
161
  module ImageRef
162
+ # The text the web sends for a turn of images alone (images.js
163
+ # turnText): "[image: name]" per image, nothing else.
164
+ PLACEHOLDER_ONLY_RE = /\A\s*(?:\[image: [^\]\n]*\]\s*)+\z/
165
+
166
+ # Whether +text+ is only that placeholder: the images' own lines would
167
+ # name them a second time.
168
+ def self.placeholder_only?(text) = PLACEHOLDER_ONLY_RE.match?(text.to_s)
169
+
162
170
  # Claude's rule of thumb (28×28 px per token). About 30% over on the
163
171
  # local Qwen, close enough for a label and the context estimate.
164
172
  def self.estimated_tokens(ref)
@@ -94,18 +94,21 @@ module Samagotchi
94
94
  nil
95
95
  end
96
96
 
97
- # This session's sources (its own, then its project's).
98
- # @return [Array<Hash>] {name:, scope:, why:, hint:, fetched_at:, error:, provider:}
97
+ # This session's sources (its own, then its project's); none before
98
+ # the session has an id or a saved file.
99
+ # +summary+ is the last text's summary (nil before any text).
100
+ # @return [Array<Hash>] {name:, scope:, why:, hint:, fetched_at:, error:, provider:, summary:}
99
101
  def list
100
102
  id = @host.session_id.call
101
- return [] unless id
103
+ # No id, or no saved file yet (a command before the first turn).
104
+ return [] unless id && Session.exist?(id, state_dir: state_dir)
102
105
 
103
106
  session = Session.load(id, state_dir: state_dir)
104
107
  ContextSources.attached(id, project_root: session.project_root, state_dir: state_dir).map do |attached|
105
108
  snapshot = attached.snapshot
106
109
  { name: attached.name, scope: attached.location.scope, why: attached.source.why,
107
110
  hint: snapshot.hint || attached.source.hint, fetched_at: snapshot.fetched_at, error: snapshot.error,
108
- provider: attached.source.provider }
111
+ provider: attached.source.provider, summary: snapshot.summary }
109
112
  end
110
113
  end
111
114
 
@@ -31,12 +31,12 @@ module Samagotchi
31
31
  # @return [IdleRecap, nil]
32
32
  def self.build(recap, engine:, host_registry:, session_target:, session_id:, state_dir:)
33
33
  return nil if recap == false
34
- # The TUI passes the config file's section; a worker passes nothing, so
35
- # read it here too (a scalar `recap: false` is only seen this way).
34
+ # The REPL and a worker pass nothing: the config is read here alone
35
+ # (a scalar `recap: false` is only seen this way).
36
36
  return nil if recap.nil? && ConfigFile.recap_config == false
37
37
  return nil if Samagotchi::Config.get("recap.enabled") == false
38
38
 
39
- # Normalize kwarg (TerminalUI passes recap: recap_config hash or nil)
39
+ # An explicit kwarg (a spec, an embedder) over the registry.
40
40
  kwarg_config = recap.is_a?(Hash) ? recap : {}
41
41
 
42
42
  base_url = string_config(kwarg_config, :base_url) || registry_string("recap.base_url")
@@ -88,7 +88,7 @@ module Samagotchi
88
88
  # +cwd+ as [its spelling, its real path] (one when they are the same,
89
89
  # none without a +cwd+). absolute_path, not expand_path: a leading ~ is
90
90
  # a name, never a home. One realpath per row: the lambda's refs are
91
- # checked together.
91
+ # checked together. The answer's refs (AnswerRef) check against it too.
92
92
  def roots(cwd)
93
93
  return [] if cwd.to_s.empty?
94
94
 
@@ -100,10 +100,10 @@ module Samagotchi
100
100
  end
101
101
  [given, real].uniq
102
102
  end
103
- private_class_method :roots
104
103
 
105
104
  # +ref+ with a file's path absolute, resolved against the folder
106
- # (+roots+, #roots), under its real spelling.
105
+ # (+roots+, #roots), under its real spelling: plan D7, for a plugin
106
+ # row's refs and an answer's (AnswerRef) alike.
107
107
  # @raise [ArgumentError] a relative path with no folder (or not
108
108
  # +relative+), or a path outside the folder
109
109
  def inside(ref, roots, relative)
@@ -121,6 +121,5 @@ module Samagotchi
121
121
 
122
122
  ref.with(target: ref.target.merge(path: "#{roots.last}#{path.delete_prefix(root)}").freeze)
123
123
  end
124
- private_class_method :inside
125
124
  end
126
125
  end
@@ -275,6 +275,18 @@ module Samagotchi
275
275
  { id: pending[:id], kind: %w[approval hook continue].include?(kind) ? kind : "question", relayed_to: relayed_to }.compact
276
276
  end
277
277
 
278
+ # A question saved by a process that no longer runs this session (the
279
+ # one that loads it now owns it): nobody waits on its answer, so it is
280
+ # dropped. Unsaved; the caller saves.
281
+ # @return [String, nil] the dropped question's id, nil with none
282
+ def drop_dead_question
283
+ pending = pending_question
284
+ return nil unless pending
285
+
286
+ @pending_question = nil
287
+ pending[:id].to_s
288
+ end
289
+
278
290
  # The stored project root, else (a file from before the field) the
279
291
  # project of the working directory now: nil when that is in no repo or
280
292
  # gone. +cache+ is ProjectScope.root_for's, shared across one listing.
@@ -46,12 +46,15 @@ module Samagotchi
46
46
  # @param thinking [String, nil] --thinking as typed: a new session
47
47
  # starts with it as its own level; a resumed or attached one's
48
48
  # worker gets a /thinking, after those
49
+ # @param state_dir [String, nil] where the sessions are (the default
50
+ # state dir by default): the worker's, for the attached loop too
49
51
  # @return [Symbol] :detached, :closed when the worker went away,
50
52
  # :failed when the --model switch didn't go through, or (input from a
51
53
  # pipe) :turn_failed / :empty_answer / :unanswered (AttachedLoop#run)
52
54
  def run(attach: nil, shared: false, resume: nil, prompt: nil, model: nil, no_interrupt: false, default_input: true,
53
- memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [])
54
- client = connect(attach: attach, shared: shared, resume: resume, model: model,
55
+ memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [], state_dir: nil)
56
+ state_dir ||= Session.default_state_dir
57
+ client = connect(attach: attach, shared: shared, resume: resume, model: model, state_dir: state_dir,
55
58
  memories: memories, muted_memories: muted_memories, llm_context: llm_context, thinking: thinking,
56
59
  appended_prompt_files: appended_prompt_files)
57
60
  commands = []
@@ -65,7 +68,7 @@ module Samagotchi
65
68
  first_command: first_command, no_interrupt: no_interrupt,
66
69
  default_input: default_input && !prompt && !attach && !resume,
67
70
  wait_at_eof: !$stdin.tty?,
68
- parent_answers: Guardrails::ParentApprovals.parent_process?)
71
+ parent_answers: Guardrails::ParentApprovals.parent_process?, state_dir: state_dir)
69
72
  ended = begin
70
73
  attached.run
71
74
  ensure
@@ -100,14 +100,18 @@ module Samagotchi
100
100
  # (the -p one too) have had their turns; if one failed, #run says so
101
101
  # @param children_counts [#call] session id -> its delegates'
102
102
  # ChildrenStatus::Counts, the status row's children segment
103
+ # @param state_dir [String] the state dir the worker was found in: its
104
+ # session folder (@path images, saved records) is under it
103
105
  def initialize(client:, screen:, client_id:, first_prompt: nil, first_command: nil, no_interrupt: false,
104
106
  default_input: false, wait_at_eof: false, parent_answers: false,
105
107
  clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
106
108
  delete_session: ->(id) { SessionManager.delete_session(id, stop: true, wait: DELETE_WAIT) },
107
109
  archive_session: ->(id) { SessionManager.archive_session(id, wait: DELETE_WAIT) },
108
110
  installed_version: -> { InstalledVersions.new.newest },
109
- children_counts: ->(id) { ChildrenStatus.counts(id, state_dir: Session.default_state_dir) })
111
+ state_dir: Session.default_state_dir,
112
+ children_counts: ->(id) { ChildrenStatus.counts(id, state_dir: state_dir) })
110
113
  @client = client
114
+ @state_dir = state_dir
111
115
  @installed_version = installed_version
112
116
  @delete_session = delete_session
113
117
  @archive_session = archive_session
@@ -342,7 +346,7 @@ module Samagotchi
342
346
  id = @client.session_id
343
347
  return :gone if Session.stopped_marker?(id)
344
348
 
345
- BridgeClient.discover(id, session_dir: Session.session_dir(id), host: @client.host)
349
+ BridgeClient.discover(id, session_dir: Session.session_dir(id, state_dir: @state_dir), host: @client.host)
346
350
  end
347
351
 
348
352
  # The stream moved to a new worker (a restart): requests go there too.
@@ -683,7 +687,11 @@ module Samagotchi
683
687
  end
684
688
  @continue_answer = nil
685
689
  @screen.commit(QuestionSlot.continue_summary(outcome, paint: method(:paint))) if outcome && !asked
686
- sync_prompt
690
+ # Back to the > prompt: with the text a question set aside meanwhile
691
+ # (#close_question), else what the ? read holds.
692
+ set_aside = @set_aside
693
+ @set_aside = nil
694
+ set_aside ? sync_prompt(keep_text: false, prefill: set_aside) : sync_prompt
687
695
  end
688
696
 
689
697
  # The notes slot shows the offer's choices while it waits (a question
@@ -802,7 +810,7 @@ module Samagotchi
802
810
  paths = ImageInput.extract(text)
803
811
  return [] if paths.empty?
804
812
 
805
- session_dir = Session.session_dir(@client.session_id)
813
+ session_dir = Session.session_dir(@client.session_id, state_dir: @state_dir)
806
814
  paths.map do |image|
807
815
  ref = ImageStore.ingest(session_dir, path: image[:path])
808
816
  { file: ref[:file], name: ref[:name] }
@@ -865,7 +873,9 @@ module Samagotchi
865
873
 
866
874
  @view.finish_thinking_spinner
867
875
  # What was typed at the prompt waits for the question to close.
868
- @set_aside = @reader&.typed_text unless @question
876
+ # Not over text already set aside (a question that closed while a
877
+ # continue offer waits): the ? read holds an answer then, if anything.
878
+ @set_aside = @reader&.typed_text.to_s.then { |typed| typed.empty? ? nil : typed } unless @question || @set_aside
869
879
  @question = QuestionPrompt.new(pending)
870
880
  @question_pending = pending
871
881
  # The step-limit question stands for the continue offer's own slot
@@ -987,10 +997,14 @@ module Samagotchi
987
997
  @screen.clear_slot(:notes)
988
998
  @screen.commit(@question.summary(outcome, paint: method(:paint))) if outcome
989
999
  @question = nil
1000
+ # A continue offer still waiting keeps the ? prompt (the step-limit
1001
+ # question closes before its offer resolves): the text set aside
1002
+ # waits for the > prompt (#continue_resolved).
1003
+ return sync_continue_slot if @continue_offer
1004
+
990
1005
  set_aside = @set_aside
991
1006
  @set_aside = nil
992
1007
  sync_prompt(keep_text: false, prefill: set_aside)
993
- sync_continue_slot if @continue_offer
994
1008
  end
995
1009
 
996
1010
  # Restart the open read when its prompt no longer fits (a question or a
@@ -1195,7 +1209,10 @@ module Samagotchi
1195
1209
  return unless last_user
1196
1210
 
1197
1211
  @screen.commit(join_prompt_line(exchange[last_user]))
1198
- Array(exchange[last_user][:images]).each { |ref| @screen.commit(format_image_line(ref)) }
1212
+ # An image-only turn's text names its images already.
1213
+ unless ImageRef.placeholder_only?(exchange[last_user][:content])
1214
+ Array(exchange[last_user][:images]).each { |ref| @screen.commit(format_image_line(ref)) }
1215
+ end
1199
1216
  from = messages.index { |m| m.equal?(exchange[last_user]) }
1200
1217
  render_join_steps(messages, from, durations: join_tool_records(exchange[last_user]))
1201
1218
  failed = join_failed_record(exchange[last_user])
@@ -1244,7 +1261,7 @@ module Samagotchi
1244
1261
  # machine's state dir, as #attach_images): none for a prompt with no
1245
1262
  # turn id (an older session) or a session with no analytics.
1246
1263
  def join_tool_records(prompt)
1247
- SessionMetrics.saved_tool_records(Session.session_dir(@client.session_id), prompt[:turn_id] || prompt["turn_id"])
1264
+ SessionMetrics.saved_tool_records(Session.session_dir(@client.session_id, state_dir: @state_dir), prompt[:turn_id] || prompt["turn_id"])
1248
1265
  rescue Session::InvalidId
1249
1266
  []
1250
1267
  end
@@ -1254,7 +1271,7 @@ module Samagotchi
1254
1271
  # canceled or older turn — or one saved before the failure field —
1255
1272
  # draws its answer as before.
1256
1273
  def join_failed_record(prompt)
1257
- record = SessionMetrics.saved_turn_record(Session.session_dir(@client.session_id),
1274
+ record = SessionMetrics.saved_turn_record(Session.session_dir(@client.session_id, state_dir: @state_dir),
1258
1275
  prompt[:turn_id] || prompt["turn_id"])
1259
1276
  return nil unless record && record["status"] == "failed" && record["failure"].is_a?(Hash)
1260
1277
 
@@ -121,6 +121,7 @@ module Samagotchi
121
121
 
122
122
  claim_session!(session_id) if session_id
123
123
  @resume_session = session_id ? Session.load(session_id) : nil
124
+ drop_dead_question(@resume_session) if @resume_session
124
125
  if @resume_session
125
126
  # --model overrides resumed session's model (runtime only, default unchanged)
126
127
  if flag_model_name
@@ -186,7 +187,6 @@ module Samagotchi
186
187
  memories: @requested_memories,
187
188
  muted_memories: @muted_memory_names,
188
189
  appended_prompt_files: @appended_prompt_files,
189
- recap: scratch ? nil : recap_config,
190
190
  scratch: scratch,
191
191
  reminders: {
192
192
  callback: lambda { |due_names|
@@ -249,7 +249,8 @@ module Samagotchi
249
249
  # post-turn conversation.
250
250
  # @return [Symbol, nil] :turn_failed when lines came from a pipe and a
251
251
  # turn failed, :empty_answer when a -p --non-interactive turn ended
252
- # with no answer (bin/chi exits 1 on both), :interrupted when Ctrl-C
252
+ # with no answer, :iteration_limit when the iteration limit cut that
253
+ # turn short (bin/chi exits 1 on all three), :interrupted when Ctrl-C
253
254
  # cancelled that turn (bin/chi exits 130)
254
255
  def run
255
256
  # --non-interactive with no --prompt is a harmless no-op exit: build
@@ -309,12 +310,13 @@ module Samagotchi
309
310
  # Retry lines and hook notices on stderr; stdout gets the answer below.
310
311
  sink = OneShotSink.new
311
312
  save_before_first_turn(session)
313
+ max_iterations = IterationLimit.for(no_interrupt: true)
312
314
  begin
313
315
  result = @engine.run_turn(
314
316
  session,
315
317
  one_shot_prompt,
316
318
  on_event: sink,
317
- max_iterations: IterationLimit.for(no_interrupt: true),
319
+ max_iterations: max_iterations,
318
320
  cancel_controller: nil,
319
321
  images: ImageInput.extract(one_shot_prompt)
320
322
  )
@@ -337,6 +339,11 @@ module Samagotchi
337
339
  session.save
338
340
  # The after_turn hooks' notices, after the answer as the REPL shows them.
339
341
  sink.flush
342
+ # The limit cut the turn short: what came so far is no answer, so say
343
+ # so and fail (bin/chi exits 1), as the REPL's "iteration limit
344
+ # reached" line does there.
345
+ return one_shot_exhausted(session, max_iterations) if result.resumable?
346
+
340
347
  return
341
348
  end
342
349
 
@@ -390,6 +397,16 @@ module Samagotchi
390
397
  :turn_failed
391
398
  end
392
399
 
400
+ # A -p --non-interactive turn the iteration limit cut short: a line on
401
+ # stderr, with how to go on unless the session goes with this run.
402
+ # @return [Symbol] :iteration_limit
403
+ def one_shot_exhausted(session, max_iterations)
404
+ line = "chi: the iteration limit (#{max_iterations}) was reached before the turn finished"
405
+ line += "; continue it with: chi --resume #{session.id}" unless @scratch
406
+ warn line
407
+ :iteration_limit
408
+ end
409
+
393
410
  # `chi -p "/models" --non-interactive`: the command's output; a refused
394
411
  # one fails the run (bin/chi exits 1). The session is saved only when
395
412
  # the command changed it. Nobody is there to answer a confirm: a
@@ -518,6 +535,17 @@ module Samagotchi
518
535
  @pending_card_handle = @engine.subscribe(observer: pending)
519
536
  end
520
537
 
538
+ # A question the session's last owner saved and died with (a worker's,
539
+ # or another REPL's): this TUI owns the session now, so nobody waits on
540
+ # it. Dropped and saved, as the worker does (Worker#drop_dead_question).
541
+ def drop_dead_question(session)
542
+ id = session.drop_dead_question
543
+ return unless id
544
+
545
+ Log.info(:repl, "dead_question_dropped", id: id)
546
+ session.save
547
+ end
548
+
521
549
  # Take the session's OwnerLock for this process's lifetime: the TUI runs
522
550
  # its own Engine, so no worker may run the session meanwhile.
523
551
  # @raise [SessionBusy] when a worker or another TUI owns it
@@ -1359,14 +1387,6 @@ module Samagotchi
1359
1387
  Log.debug(:repl, "children_counts_failed", error: e.class.name)
1360
1388
  end
1361
1389
 
1362
- # Resolve the recap config (on by default). Returns false when explicitly
1363
- # disabled, nil when nothing is configured, or a Hash when enabled so the
1364
- # Engine can build the detector. Single precedence path via the Config
1365
- # registry: CLI > ENV (SAMAGOTCHI_RECAP_*) > file (recap:) > default.
1366
- def recap_config
1367
- ConfigFile.recap_config
1368
- end
1369
-
1370
1390
  # Reset the Engine's shared inactivity clock when a prompt opens (a
1371
1391
  # Reline.pre_input_hook) and on each key typed (RelineSeam.key_handler). This is the
1372
1392
  # single seam the Engine-owned idle recap detector reads, so the idle clock
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "fileutils"
4
4
  require_relative "../config"
5
+ require_relative "../log"
5
6
  require_relative "../paths"
6
7
  require_relative "../process_group"
7
8
  require_relative "output_guardrails"
@@ -169,10 +170,14 @@ module Samagotchi
169
170
  exit_code_path = File.join(dir, TaskRuntime::EXIT_CODE_FILENAME)
170
171
  begin
171
172
  pid = spawn_logged(command, env: env, cwd: cwd, logs: logs, exit_code_path: exit_code_path)
172
- rescue StandardError
173
+ rescue StandardError => e
174
+ Log.info(:turn, "execute_spawn_failed", id: id, error: e.class.name)
173
175
  FileUtils.rm_rf(dir)
174
176
  raise
175
177
  end
178
+ # Never the command line or its output: a command may carry a
179
+ # secret, and its output is the tool result's.
180
+ Log.debug(:turn, "execute_started", id: id, pid: pid, timeout_sec: timeout_sec, on_timeout: on_timeout)
176
181
  wait_thr = Process.detach(pid)
177
182
  # A stop is done once the shell is reaped and nothing holds the logs:
178
183
  # a child that ignores TERM keeps them, so the stop goes on to KILL.
@@ -195,6 +200,7 @@ module Samagotchi
195
200
  timeout_sec: timeout_sec, dir: dir, logs: logs, exit_code_path: exit_code_path)
196
201
  if text
197
202
  handed_over = true
203
+ Log.info(:turn, "execute_handed_over", id: id, after_sec: timeout_sec)
198
204
  raise HandedOver, text
199
205
  end
200
206
  # Not adopted because it ended meanwhile: finished too.
@@ -205,10 +211,11 @@ module Samagotchi
205
211
  status = wait_thr.value
206
212
  rescue HandedOver
207
213
  raise
208
- rescue Exception # rubocop:disable Lint/RescueException -- kill the child, then re-raise
214
+ rescue Exception => e # rubocop:disable Lint/RescueException -- kill the child, then re-raise
209
215
  # An Interrupt (or anything else) mid-wait: the child is in its own
210
216
  # process group, so it never saw the SIGINT. Kill it.
211
217
  aborted = true
218
+ Log.info(:turn, "execute_aborted", id: id, error: e.class.name)
212
219
  terminate_process_tree(pid, stopped: stopped) if wait_thr.alive?
213
220
  raise
214
221
  ensure
@@ -230,9 +237,18 @@ module Samagotchi
230
237
  end
231
238
  end
232
239
 
233
- raise CommandTimedOut.new(stdout_text, stderr_text) if timed_out
234
- raise CommandCancelled.new(stdout_text, stderr_text, monotonic_time - started) if was_cancelled
240
+ elapsed_ms = ((monotonic_time - started) * 1000).round
241
+ if timed_out
242
+ Log.info(:turn, "execute_timed_out", id: id, after_sec: timeout_sec)
243
+ raise CommandTimedOut.new(stdout_text, stderr_text)
244
+ end
245
+ if was_cancelled
246
+ Log.info(:turn, "execute_stopped", id: id, ms: elapsed_ms)
247
+ raise CommandCancelled.new(stdout_text, stderr_text, monotonic_time - started)
248
+ end
235
249
 
250
+ Log.debug(:turn, "execute_finished", id: id, exit: status&.exitstatus, ms: elapsed_ms,
251
+ background: held_open || nil)
236
252
  [stdout_text, stderr_text, status, held_open]
237
253
  end
238
254
  private_class_method :run_command
@@ -348,6 +364,8 @@ module Samagotchi
348
364
  Thread.current.report_on_exception = false
349
365
  code = waiter.value&.exitstatus
350
366
  File.write(path, code.to_s) if code && !File.exist?(path) && Dir.exist?(File.dirname(path))
367
+ rescue StandardError => e
368
+ Log.debug(:turn, "execute_exit_code_failed", error: e.class.name, msg: e.message)
351
369
  end
352
370
  end
353
371
  private_class_method :write_missing_exit_code
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.62.0"
4
+ VERSION = "0.63.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -921,7 +921,7 @@ module Samagotchi
921
921
  state = nil
922
922
  session_json = { id: id, status: displayed_status(session), used_memory_names: Array(session.used_memory_names),
923
923
  prompt_notes: session.prompt_notes.map(&:to_h) }
924
- messages = last_assistant_for_display(session.messages, turn_id: turn_id)
924
+ messages = last_assistant_for_display(session.messages, turn_id: turn_id, cwd: session.working_directory)
925
925
  end
926
926
  live_metrics = state && state["metrics"]
927
927
  # Without recent=1: a tab opened before this release (it replaces its
@@ -1923,7 +1923,7 @@ module Samagotchi
1923
1923
  message[:html] = @markdown_renderer.render(rendered) if !rendered.empty? && @markdown_renderer.available?
1924
1924
  # The refs after_turn hooks attached (AnswerRefs), checked again:
1925
1925
  # a stored file is never trusted. Never raises.
1926
- refs = Samagotchi::AnswerRefs.for_display(m[:display_refs] || m["display_refs"])
1926
+ refs = Samagotchi::AnswerRefs.for_display(m[:display_refs] || m["display_refs"], cwd: cwd)
1927
1927
  message[:display_refs] = refs unless refs.empty?
1928
1928
  end
1929
1929
  message[:parts] = did if did
@@ -1968,8 +1968,10 @@ module Samagotchi
1968
1968
 
1969
1969
  # The last message messages_for_display shows as an answer
1970
1970
  # (AnswerTail), as a list of at most one: only that one is rendered.
1971
- def last_assistant_for_display(msgs, turn_id: nil)
1972
- messages_for_display([AnswerTail.find(msgs, turn_id: turn_id)].compact)
1971
+ # +cwd+: the session's folder, for its file refs (nil from a live
1972
+ # worker's tail: its refs were held to the folder when they were added).
1973
+ def last_assistant_for_display(msgs, turn_id: nil, cwd: nil)
1974
+ messages_for_display([AnswerTail.find(msgs, turn_id: turn_id)].compact, cwd: cwd)
1973
1975
  end
1974
1976
 
1975
1977
  def parse_json(str)
@@ -140,10 +140,10 @@ export function addCompleted(model, event = {}, now = null) {
140
140
  // A grep that found nothing (status ok): the row says "no match".
141
141
  if (event.activity?.no_match) row.no_match = true;
142
142
  // The images the tool returned (a reload in the turn view draws them from
143
- // the saved parts, turn_html.js toolRowHtml).
143
+ // the saved parts, turn_html.js toolRowTemplate).
144
144
  if (event.images?.length) row.images = event.images;
145
145
  // What an edit/write changed in its file (a reload draws it from the
146
- // saved parts, turn_html.js toolRowHtml).
146
+ // saved parts, turn_html.js toolRowTemplate).
147
147
  if (event.diff) row.diff = event.diff;
148
148
  const duration = callDuration(row, event, now);
149
149
  if (duration !== null) row.duration_ms = duration;