samagotchi 0.2.0 → 0.3.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 (96) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -1
  3. data/README.md +40 -4
  4. data/bin/chi +86 -19
  5. data/docs/cli.md +108 -5
  6. data/docs/configuration.md +229 -45
  7. data/docs/desktop.md +6 -0
  8. data/docs/hooks.md +126 -5
  9. data/docs/plugins.md +68 -2
  10. data/docs/releasing.md +18 -8
  11. data/docs/sessions.md +30 -4
  12. data/lib/samagotchi/answer_display.rb +95 -0
  13. data/lib/samagotchi/archive_store.rb +90 -0
  14. data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
  15. data/lib/samagotchi/bootstrap/probe.rb +262 -0
  16. data/lib/samagotchi/bootstrap_command.rb +347 -0
  17. data/lib/samagotchi/bridge/pending_card.rb +89 -0
  18. data/lib/samagotchi/bridge/turn_accumulator.rb +14 -3
  19. data/lib/samagotchi/bridge.rb +9 -0
  20. data/lib/samagotchi/bridge_client.rb +6 -2
  21. data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
  22. data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
  23. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +358 -0
  24. data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
  25. data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
  26. data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
  27. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  28. data/lib/samagotchi/bundles/system/self_map.md +8 -2
  29. data/lib/samagotchi/client.rb +72 -13
  30. data/lib/samagotchi/config.rb +196 -36
  31. data/lib/samagotchi/desktop/macos/ChiRunner.swift +13 -7
  32. data/lib/samagotchi/desktop/macos/Panel.swift +71 -19
  33. data/lib/samagotchi/empty_answer_retry.rb +43 -0
  34. data/lib/samagotchi/engine.rb +233 -36
  35. data/lib/samagotchi/guardrails/approval.rb +9 -0
  36. data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
  37. data/lib/samagotchi/guardrails.rb +1 -0
  38. data/lib/samagotchi/hooks/registry.rb +24 -5
  39. data/lib/samagotchi/host_registry.rb +4 -3
  40. data/lib/samagotchi/idle_recap.rb +5 -1
  41. data/lib/samagotchi/kernel_loop.rb +47 -15
  42. data/lib/samagotchi/llm/chat_loop.rb +59 -20
  43. data/lib/samagotchi/llm/errors.rb +21 -3
  44. data/lib/samagotchi/llm/http.rb +42 -13
  45. data/lib/samagotchi/llm/openai_chat.rb +12 -4
  46. data/lib/samagotchi/log_subscriber.rb +18 -3
  47. data/lib/samagotchi/model_profile.rb +1 -1
  48. data/lib/samagotchi/plugin/context.rb +22 -1
  49. data/lib/samagotchi/plugin/sessions.rb +3 -1
  50. data/lib/samagotchi/reply_wait.rb +126 -0
  51. data/lib/samagotchi/sampling_settings.rb +58 -0
  52. data/lib/samagotchi/self_report.rb +1 -0
  53. data/lib/samagotchi/send_command.rb +153 -7
  54. data/lib/samagotchi/session.rb +52 -11
  55. data/lib/samagotchi/session_archive_command.rb +107 -0
  56. data/lib/samagotchi/session_commands.rb +11 -2
  57. data/lib/samagotchi/session_manager.rb +114 -9
  58. data/lib/samagotchi/session_metrics.rb +222 -106
  59. data/lib/samagotchi/steer.rb +72 -0
  60. data/lib/samagotchi/terminal_ui/attached_loop.rb +39 -6
  61. data/lib/samagotchi/terminal_ui/event_renderer.rb +13 -8
  62. data/lib/samagotchi/terminal_ui/formatting.rb +31 -8
  63. data/lib/samagotchi/terminal_ui/input_support.rb +3 -0
  64. data/lib/samagotchi/terminal_ui.rb +77 -4
  65. data/lib/samagotchi/tool_activity.rb +3 -1
  66. data/lib/samagotchi/tools/builtins.rb +15 -4
  67. data/lib/samagotchi/tools/delegate_wait.rb +26 -69
  68. data/lib/samagotchi/tools/execute.rb +52 -14
  69. data/lib/samagotchi/tools/task_runtime.rb +19 -0
  70. data/lib/samagotchi/tools/task_wait.rb +27 -3
  71. data/lib/samagotchi/turn_note.rb +60 -6
  72. data/lib/samagotchi/version.rb +1 -1
  73. data/lib/samagotchi/vision_support.rb +2 -6
  74. data/lib/samagotchi/web/app.rb +88 -4
  75. data/lib/samagotchi/web/public/activity.js +10 -1
  76. data/lib/samagotchi/web/public/annotate_presets.js +26 -0
  77. data/lib/samagotchi/web/public/annotations.js +13 -0
  78. data/lib/samagotchi/web/public/app.js +437 -88
  79. data/lib/samagotchi/web/public/card.js +5 -3
  80. data/lib/samagotchi/web/public/chat_view.js +10 -1
  81. data/lib/samagotchi/web/public/copy.js +20 -4
  82. data/lib/samagotchi/web/public/ctx.js +15 -0
  83. data/lib/samagotchi/web/public/data.js +21 -6
  84. data/lib/samagotchi/web/public/format.js +9 -0
  85. data/lib/samagotchi/web/public/index.html +38 -2
  86. data/lib/samagotchi/web/public/notify.js +175 -0
  87. data/lib/samagotchi/web/public/question_card.js +2 -1
  88. data/lib/samagotchi/web/public/sessions_list.js +7 -0
  89. data/lib/samagotchi/web/public/timing.js +39 -14
  90. data/lib/samagotchi/web/public/turn_events.js +46 -0
  91. data/lib/samagotchi/web/public/turn_view.js +47 -7
  92. data/lib/samagotchi/web/server.rb +8 -4
  93. data/lib/samagotchi/web/session_hub.rb +2 -1
  94. data/lib/samagotchi/web/session_summary.rb +24 -1
  95. data/lib/samagotchi/worker.rb +11 -0
  96. metadata +20 -1
@@ -18,16 +18,22 @@ module Samagotchi
18
18
  TIMEOUT_SEC = 30
19
19
  STOP_GRACE_SEC = 1.0
20
20
  STOP_POLL_INTERVAL_SEC = 0.05
21
+ WAIT_SLICE_SEC = 0.2
22
+ NOT_RUN_ON_STOP = "Error: not run, the user stopped the turn"
21
23
 
22
24
  def self.name = NAME
23
25
 
24
- def self.call(command, cwd: nil)
26
+ # @param cancelled [#call] true once the user stopped the turn: the
27
+ # command is killed (or not started) and its output so far returned
28
+ def self.call(command, cwd: nil, cancelled: -> { false })
29
+ return NOT_RUN_ON_STOP if cancelled.call
30
+
25
31
  command = command.strip
26
32
  resolved_cwd = resolve_cwd(cwd)
27
33
  return "Error: cwd not found: #{resolved_cwd}" unless resolved_cwd
28
34
 
29
35
  timeout_sec = timeout_seconds
30
- stdout, stderr, status = run_command(command, timeout_sec: timeout_sec, cwd: resolved_cwd)
36
+ stdout, stderr, status = run_command(command, timeout_sec: timeout_sec, cwd: resolved_cwd, cancelled: cancelled)
31
37
 
32
38
  stdout_block = output_block("stdout", stdout)
33
39
  stderr_block = output_block("stderr", stderr)
@@ -45,19 +51,29 @@ module Samagotchi
45
51
  rescue CommandTimedOut => e
46
52
  # Keep the "Error:" first line (callers classify on it) and return
47
53
  # whatever the command printed before it was killed.
48
- parts = ["Error: command timed out after #{timeout_sec}s"]
49
- parts << output_block("stdout", e.stdout)
50
- parts << output_block("stderr", e.stderr)
51
- parts.compact.join("\n")
54
+ stopped_result("Error: command timed out after #{timeout_sec}s", e)
55
+ rescue CommandCancelled => e
56
+ stopped_result("Error: command stopped by the user after #{e.elapsed.round}s (killed; rerun it if still needed)", e)
52
57
  rescue => e
53
58
  "Error: #{e.message}"
54
59
  end
55
60
 
56
- def self.run_command(command, timeout_sec:, cwd:)
61
+ def self.stopped_result(first_line, error)
62
+ parts = [first_line]
63
+ parts << output_block("stdout", error.stdout)
64
+ parts << output_block("stderr", error.stderr)
65
+ parts.compact.join("\n")
66
+ end
67
+ private_class_method :stopped_result
68
+
69
+ def self.run_command(command, timeout_sec:, cwd:, cancelled: -> { false })
57
70
  stdout_text = ""
58
71
  stderr_text = ""
59
72
  status = nil
60
73
  timed_out = false
74
+ was_cancelled = false
75
+ started = monotonic_time
76
+ deadline = started + timeout_sec
61
77
 
62
78
  Open3.popen3(command, chdir: cwd, pgroup: true) do |stdin, stdout, stderr, wait_thr|
63
79
  stdin.close
@@ -65,14 +81,24 @@ module Samagotchi
65
81
  stderr_reader = reader_thread_for(stderr)
66
82
 
67
83
  begin
68
- if wait_thr.join(timeout_sec)
69
- status = wait_thr.value
70
- else
71
- timed_out = true
72
- terminate_process_tree(wait_thr.pid)
73
- wait_thr.join
74
- status = wait_thr.value
84
+ until wait_thr.join(WAIT_SLICE_SEC)
85
+ if cancelled.call
86
+ was_cancelled = true
87
+ break
88
+ elsif monotonic_time > deadline
89
+ timed_out = true
90
+ break
91
+ end
75
92
  end
93
+ terminate_process_tree(wait_thr.pid) if timed_out || was_cancelled
94
+ wait_thr.join
95
+ status = wait_thr.value
96
+ rescue Exception # rubocop:disable Lint/RescueException -- kill the child, then re-raise
97
+ # An Interrupt (or anything else) mid-wait: the child is in its own
98
+ # process group, so it never saw the SIGINT. Kill it, or the reads
99
+ # below block until it closes stdout.
100
+ terminate_process_tree(wait_thr.pid)
101
+ raise
76
102
  ensure
77
103
  # Read the buffered output before closing the pipes. For a process
78
104
  # that exits almost instantly (e.g. `echo hello`), the background
@@ -86,6 +112,7 @@ module Samagotchi
86
112
  end
87
113
 
88
114
  raise CommandTimedOut.new(stdout_text, stderr_text) if timed_out
115
+ raise CommandCancelled.new(stdout_text, stderr_text, monotonic_time - started) if was_cancelled
89
116
 
90
117
  [stdout_text, stderr_text, status]
91
118
  end
@@ -209,6 +236,17 @@ module Samagotchi
209
236
  super("command timed out")
210
237
  end
211
238
  end
239
+
240
+ class CommandCancelled < StandardError
241
+ attr_reader :stdout, :stderr, :elapsed
242
+
243
+ def initialize(stdout, stderr, elapsed)
244
+ @stdout = stdout
245
+ @stderr = stderr
246
+ @elapsed = elapsed
247
+ super("command stopped by the user")
248
+ end
249
+ end
212
250
  end
213
251
  end
214
252
  end
@@ -94,6 +94,25 @@ module Samagotchi
94
94
  end.compact.sort_by { |record| record["created_at"].to_s }.reverse
95
95
  end
96
96
 
97
+ # The tasks this conversation's task_create calls started that are
98
+ # still running. Only task_create results count: records are shared by
99
+ # every session in the worker's cwd, and task_list shows them all.
100
+ # A native tool_response joins its calls' results with "---".
101
+ # @return [Array<Hash>] {id:, command:}, oldest first
102
+ def running_created_in(messages)
103
+ ids = Array(messages).flat_map do |message|
104
+ next [] unless (message[:role] || message["role"]).to_s == "tool_response"
105
+
106
+ (message[:content] || message["content"]).to_s.split("\n\n---\n\n").filter_map do |block|
107
+ block[/\A\[task_create\]\ntask_id: (\S+)/, 1]
108
+ end
109
+ end
110
+ ids.uniq.filter_map do |id|
111
+ record, _error = get_record(id)
112
+ { id: id, command: record["command"].to_s } if record&.fetch("status") == "running"
113
+ end
114
+ end
115
+
97
116
  def get_record(task_id)
98
117
  record = load_record(task_id)
99
118
  return [nil, "Error: task not found: #{task_id}"] unless record
@@ -14,7 +14,10 @@ module Samagotchi
14
14
 
15
15
  def self.name = NAME
16
16
 
17
- def self.call(task_id, timeout: TIMEOUT_DEFAULT, tail_lines: TAIL_LINES_DEFAULT, done_pattern: nil)
17
+ # @param cancelled [#call] true once the user stopped the turn: the
18
+ # wait returns then, and the task keeps running
19
+ def self.call(task_id, timeout: TIMEOUT_DEFAULT, tail_lines: TAIL_LINES_DEFAULT, done_pattern: nil,
20
+ cancelled: -> { false })
18
21
  timeout = TIMEOUT_DEFAULT if blank?(timeout)
19
22
  tail_lines = TAIL_LINES_DEFAULT if blank?(tail_lines)
20
23
  timeout = timeout.to_i
@@ -27,7 +30,8 @@ module Samagotchi
27
30
  return completion_pattern if completion_pattern.is_a?(String)
28
31
 
29
32
  task_id = task_id.to_s.strip
30
- deadline = monotonic_time + timeout
33
+ started = monotonic_time
34
+ deadline = started + timeout
31
35
 
32
36
  loop do
33
37
  record, error = TaskRuntime.get_record(task_id)
@@ -41,6 +45,8 @@ module Samagotchi
41
45
  return format_response(record, wait_result: "pattern_matched", output_tail: output_tail) if completion_pattern.match?(output_tail)
42
46
  end
43
47
 
48
+ return cancelled_response(task_id, tail_lines, started) if cancelled.call
49
+
44
50
  break if monotonic_time > deadline
45
51
  sleep(POLL_INTERVAL)
46
52
  end
@@ -55,7 +61,24 @@ module Samagotchi
55
61
  "Error: #{e.message}"
56
62
  end
57
63
 
58
- def self.format_response(record, wait_result: nil, output_tail: nil)
64
+ # The task may have finished between the poll and the Stop: then the
65
+ # normal finished result. Only a task still running says so.
66
+ def self.cancelled_response(task_id, tail_lines, started)
67
+ record, error = TaskRuntime.get_record(task_id)
68
+ return "Error: #{error}" if error
69
+ return format_response(record) unless record.fetch("status") == "running"
70
+
71
+ output_tail = TaskRuntime.output_tail_lines(record.fetch("output_path"), tail_lines)
72
+ note = [
73
+ "note: the user stopped the turn while waiting; the task is STILL RUNNING.",
74
+ " To keep waiting: task_wait #{task_id}. If it is stuck or no longer needed: task_stop #{task_id}.",
75
+ "waited: #{(monotonic_time - started).round}s"
76
+ ]
77
+ format_response(record, wait_result: "canceled", output_tail: output_tail, extra: note)
78
+ end
79
+ private_class_method :cancelled_response
80
+
81
+ def self.format_response(record, wait_result: nil, output_tail: nil, extra: [])
59
82
  lines = [
60
83
  "task_id: #{record.fetch("id")}",
61
84
  "status: #{record.fetch("status")}",
@@ -66,6 +89,7 @@ module Samagotchi
66
89
  return lines.join("\n") unless wait_result
67
90
 
68
91
  lines << "wait_result: #{wait_result}"
92
+ lines.concat(extra)
69
93
  lines << "output_tail:"
70
94
  lines << output_tail.rstrip unless output_tail.empty?
71
95
  lines.join("\n")
@@ -13,6 +13,10 @@ module Samagotchi
13
13
  KIND = "turn_note"
14
14
  OPEN = "[SYSTEM: "
15
15
  CLOSE = "]"
16
+ FAILED = "the previous turn failed before any answer: "
17
+ RESTORED = "The message went back to the user, who may send it again."
18
+ TASKS_LISTED = 5
19
+ TASK_COMMAND_CHARS = 60
16
20
 
17
21
  module_function
18
22
 
@@ -21,28 +25,48 @@ module Samagotchi
21
25
  # failed user message is not in the conversation any more)
22
26
  # @param continued [Boolean] it was a continue turn (no user message)
23
27
  def failed(summary, restored: false, continued: false)
24
- tail = if restored then "The message went back to the user, who may send it again."
28
+ tail = if restored then RESTORED
25
29
  elsif continued then "The continued turn stopped there."
26
30
  else "The user's last message was not answered."
27
31
  end
28
- message("the previous turn failed before any answer: #{one_line(summary)}. #{tail}")
32
+ message("#{FAILED}#{one_line(summary)}. #{tail}")
29
33
  end
30
34
 
31
35
  # @param reason [Symbol, String, nil] the cancel reason (:ctrl_c, :user…)
32
36
  # @param seconds [Numeric, nil] how long the turn had run
33
37
  # @param shown [Boolean] visible text had streamed (an `[interrupted]`
34
38
  # model message precedes this note)
35
- def cancelled(reason, seconds: nil, shown: false)
39
+ # @param running_tasks [Array<Hash>] {id:, command:} of this session's
40
+ # background tasks still running: a Stop doesn't end them
41
+ def cancelled(reason, seconds: nil, shown: false, running_tasks: [])
36
42
  why = reason.to_s.empty? ? "" : " (#{reason.to_s.tr("_", "-")})"
37
43
  after = seconds ? " after #{seconds.round}s" : ""
38
44
  what = shown ? "the answer above ends where it was cut off." : "no answer had been shown."
39
- message("the previous turn was cancelled#{why}#{after}; #{what}")
45
+ message("the previous turn was cancelled#{why}#{after}; #{what}#{still_running(running_tasks)}")
46
+ end
47
+
48
+ def still_running(tasks)
49
+ return "" if tasks.empty?
50
+
51
+ listed = tasks.first(TASKS_LISTED).map do |task|
52
+ command = one_line(task[:command])
53
+ command = "#{command[0, TASK_COMMAND_CHARS - 1]}…" if command.length > TASK_COMMAND_CHARS
54
+ "task #{task[:id]} (#{command})"
55
+ end
56
+ listed << "#{tasks.size - TASKS_LISTED} more (task_list)" if tasks.size > TASKS_LISTED
57
+ " Still running: #{listed.join(", ")}. Continue with task_wait <id> or stop with task_stop <id>."
40
58
  end
41
59
 
42
60
  def empty
43
61
  message("the previous turn ended with no visible answer (thinking only, or nothing). The user's last message is still unanswered.")
44
62
  end
45
63
 
64
+ # The hidden nudge before a retry of an empty answer (EmptyAnswerRetry):
65
+ # the model never sees its empty generation, so it is told what happened.
66
+ def empty_retry
67
+ message("your last reply had no visible answer. Answer the user's last message now, briefly.")
68
+ end
69
+
46
70
  def note?(entry)
47
71
  return false unless entry.respond_to?(:[])
48
72
 
@@ -56,11 +80,41 @@ module Samagotchi
56
80
  # +messages+ plus +note+, in place of a note already at the tail (behind
57
81
  # context notes at most): failed retries leave one note, not a pile.
58
82
  def replace_trailing(messages, note)
83
+ without_trailing(messages) << note
84
+ end
85
+
86
+ # A copy of +messages+ without the note at its tail (behind context
87
+ # notes at most).
88
+ def without_trailing(messages)
59
89
  list = Array(messages).dup
90
+ index = trailing_index(list)
91
+ list.delete_at(index) if index
92
+ list
93
+ end
94
+
95
+ # The index of the note at the tail of +list+ (behind context notes at
96
+ # most), or nil.
97
+ def trailing_index(list)
60
98
  index = list.length - 1
61
99
  index -= 1 while index >= 0 && (list[index][:role] || list[index]["role"]).to_s == "system" && !note?(list[index])
62
- list.delete_at(index) if index >= 0 && note?(list[index])
63
- list << note
100
+ index >= 0 && note?(list[index]) ? index : nil
101
+ end
102
+
103
+ # The failure summary of the note at the tail of +messages+ (behind
104
+ # context notes at most) when it says a failed turn's prompt went back
105
+ # to the user, else nil: the prompt is not in the conversation any more
106
+ # (the session's last_prompt holds it), so a UI shows it from here.
107
+ def restored_failure(messages)
108
+ list = Array(messages)
109
+ index = trailing_index(list)
110
+ return nil unless index
111
+
112
+ text = (list[index][:content] || list[index]["content"]).to_s
113
+ prefix = "#{OPEN}#{FAILED}"
114
+ suffix = ". #{RESTORED}#{CLOSE}"
115
+ return nil unless text.start_with?(prefix) && text.end_with?(suffix)
116
+
117
+ text[prefix.length...-suffix.length]
64
118
  end
65
119
 
66
120
  # The last message is a reply cut short (`[interrupted]`).
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -50,12 +50,8 @@ module Samagotchi
50
50
  rescue StandardError
51
51
  {}
52
52
  end
53
- [target.model, target.bare_model].map { |name| name.to_s.strip.downcase }.uniq.each do |key|
54
- value = models.dig(key, :vision)
55
- next if value.nil?
56
-
57
- return Answer.new(value: value, reason: value ? nil : "models: #{key} sets vision: false")
58
- end
53
+ key, value = ConfigFile.model_setting([target.model, target.bare_model], :vision, models: models)
54
+ return Answer.new(value: value, reason: value ? nil : "models: #{key} sets vision: false") if key
59
55
  return nil if entry.vision.nil?
60
56
 
61
57
  Answer.new(value: entry.vision, reason: entry.vision ? nil : "hosts.#{entry.name} sets vision: false")
@@ -13,10 +13,12 @@ require_relative "../bridge/bounded_queue"
13
13
  require_relative "../session"
14
14
  require_relative "../session_manager"
15
15
  require_relative "../session_commands"
16
+ require_relative "../steer"
16
17
  require_relative "../host_registry"
17
18
  require_relative "../model_profile"
18
19
  require_relative "../project_scope"
19
20
  require_relative "../version"
21
+ require_relative "../config"
20
22
  require_relative "../output_formatter"
21
23
  require_relative "../image_store"
22
24
  require_relative "../context_note"
@@ -71,6 +73,9 @@ module Samagotchi
71
73
  # @param turn_view [Boolean] the page's per-turn view (web.turn_view,
72
74
  # the default); false is the classic row of bubbles; ?view=turn|chat
73
75
  # overrides it for one page load
76
+ # @param annotate_presets [String, Array] the quick replies next to
77
+ # Annotate (web.annotate_presets, "|"-separated); "" shows none. The
78
+ # page parses them (annotate_presets.js).
74
79
  # @param hub [SessionHub, nil] the session projection GET /api/events
75
80
  # streams from; without one the route answers 503
76
81
  # @param registry [HostRegistry, nil] the hosts GET /api/models lists
@@ -79,6 +84,7 @@ module Samagotchi
79
84
  # waits for the hosts' lists
80
85
  def initialize(manager: nil, session_class: nil, state_dir: nil, public_dir: nil,
81
86
  bridge_wait_timeout: BRIDGE_WAIT_TIMEOUT, markdown: false, turn_view: true, hub: nil,
87
+ annotate_presets: Config::BY_KEY["web.annotate_presets"].default,
82
88
  events_heartbeat: EVENTS_HEARTBEAT, events_queue: EVENTS_QUEUE,
83
89
  registry: nil, models_wait_timeout: MODELS_WAIT_TIMEOUT)
84
90
  @manager = manager || SessionManager
@@ -92,6 +98,7 @@ module Samagotchi
92
98
  @bridge_wait_timeout = bridge_wait_timeout
93
99
  @markdown_renderer = MarkdownRenderer.new(enabled: markdown)
94
100
  @turn_view = turn_view
101
+ @annotate_presets = annotate_presets.is_a?(Array) ? annotate_presets.join("|") : annotate_presets.to_s
95
102
  @hub = hub
96
103
  @events_heartbeat = events_heartbeat
97
104
  @events_queue = events_queue
@@ -138,6 +145,9 @@ module Samagotchi
138
145
  if (m = %r{\A/api/sessions/([^/]+)/stop\z}.match(req.path_info)) && req.post?
139
146
  return handle_stop(req, m[1])
140
147
  end
148
+ if (m = %r{\A/api/sessions/([^/]+)/(archive|unarchive)\z}.match(req.path_info)) && req.post?
149
+ return m[2] == "archive" ? handle_archive(req, m[1]) : handle_unarchive(req, m[1])
150
+ end
141
151
  if (m = %r{\A/api/sessions/([^/]+)/turn\z}.match(req.path_info)) && req.post?
142
152
  return handle_turn(req, m[1])
143
153
  end
@@ -224,6 +234,8 @@ module Samagotchi
224
234
  offset = sanitize_offset(req.params["offset"])
225
235
  return list_from_hub(root, sort: sort, order: order, limit: limit, offset: offset) if @hub
226
236
 
237
+ # Archived ones too, as the hub's: the page filters them at render.
238
+ scope[:include_archived] = true
227
239
  sessions = if @state_dir
228
240
  @manager.list_sessions(state_dir: @state_dir, sort: sort, order: order, limit: limit, offset: offset,
229
241
  **scope)
@@ -396,6 +408,11 @@ module Samagotchi
396
408
  # spelled as GET /api/models lists it); blank means the default.
397
409
  model = (body["model"] || body["model_name"]).to_s.strip
398
410
  folder[:model_name] = model unless model.empty?
411
+ # preview: an idle session's first message, sent as its first turn
412
+ # next; the list and the notifications name the session by it
413
+ # until that turn is saved.
414
+ preview = idle ? body["preview"].to_s.strip : ""
415
+ folder[:title] = preview unless preview.empty?
399
416
  begin
400
417
  session = @manager.spawn_session(prompt: idle ? nil : prompt.to_s, state_dir: @state_dir, **folder)
401
418
  rescue ArgumentError => e
@@ -464,6 +481,7 @@ module Samagotchi
464
481
  session: session_json,
465
482
  history: read_history(id),
466
483
  messages: messages_for_display(raw_messages, parts: req.params["parts"] == "1"),
484
+ failed_turn: failed_turn_for(session, raw_messages),
467
485
  current_turn: current_turn,
468
486
  queued: turn_snapshot ? Array(turn_snapshot["queued"]) : [],
469
487
  recap: turn_snapshot && turn_snapshot["recap"],
@@ -597,7 +615,8 @@ module Samagotchi
597
615
  client = live_bridge_client(id)
598
616
  return error_response(503, "not_live", "no live bridge for session #{id}") unless client
599
617
 
600
- reply = client.post_command(line: line, client_id: body["client_id"])
618
+ # card: a card's action; its command events say so (no echo).
619
+ reply = client.post_command(line: line, client_id: body["client_id"], card: body["card"] == true)
601
620
  case reply.status
602
621
  when 202 then json_response(202, reply.json || { status: "accepted" })
603
622
  when 400 then error_response(400, reply.json&.dig("error") || "unknown_command", reply.json&.dig("detail") || "not a session command")
@@ -805,6 +824,32 @@ module Samagotchi
805
824
  error_response(404, "not_found", e.message)
806
825
  end
807
826
 
827
+ # POST /api/sessions/:id/archive: hide it (and its delegates) from the
828
+ # lists, keep it for good. A live idle worker is stopped first; a turn
829
+ # running in it or a delegate, a chi REPL's session and a scratch one
830
+ # are refused (409).
831
+ def handle_archive(_req, id)
832
+ result = @manager.archive_session(id, state_dir: @state_dir, wait: STOP_WAIT_SECONDS)
833
+ (result[:archived] + result[:discarded]).each { |sid| @hub&.touch(sid) }
834
+ json_response(200, { status: "archived", session_id: result[:id], archived: result[:archived],
835
+ stopped: result[:stopped], discarded: result[:discarded] })
836
+ rescue SessionManager::OwnedByTUI
837
+ error_response(409, "owned_by_tui", "session #{id} is open in a chi REPL; close it there first")
838
+ rescue SessionManager::ArchiveRefused => e
839
+ error_response(409, e.reason == :scratch ? "scratch" : "busy", e.message)
840
+ rescue ArgumentError => e
841
+ error_response(404, "not_found", e.message)
842
+ end
843
+
844
+ # POST /api/sessions/:id/unarchive: back to the lists, delegates too.
845
+ def handle_unarchive(_req, id)
846
+ result = @manager.unarchive_session(id, state_dir: @state_dir)
847
+ result[:unarchived].each { |sid| @hub&.touch(sid) }
848
+ json_response(200, { status: "unarchived", session_id: result[:id], unarchived: result[:unarchived] })
849
+ rescue ArgumentError => e
850
+ error_response(404, "not_found", e.message)
851
+ end
852
+
808
853
  # DELETE /api/sessions/:id: the session for good. A live worker is
809
854
  # stopped first (the page's confirm says so); a chi REPL's session is
810
855
  # refused.
@@ -1038,7 +1083,11 @@ module Samagotchi
1038
1083
  turn_records: Array(source["turn_records"]),
1039
1084
  tool_records: Array(source["tool_records"]),
1040
1085
  active_turn: source["active_turn"],
1041
- active_tools: Array(source["active_tools"])
1086
+ active_tools: Array(source["active_tools"]),
1087
+ # The context last seen and the token sums, for the ctx meter
1088
+ # before the next turn streams (live over saved).
1089
+ context: source["context"],
1090
+ tokens: source["tokens"]
1042
1091
  }
1043
1092
  end
1044
1093
 
@@ -1084,12 +1133,19 @@ module Samagotchi
1084
1133
  return nil unless saved
1085
1134
 
1086
1135
  messages = Array(session.messages)
1087
- since = messages.drop(saved[:covered].to_i).count { |m| (m[:role] || m["role"]).to_s == "user" }
1136
+ since = messages.drop(saved[:covered].to_i).count { |m| Samagotchi::Steer.prompt?(m) }
1088
1137
  { text: saved[:text], turns_since: since }
1089
1138
  rescue StandardError
1090
1139
  nil
1091
1140
  end
1092
1141
 
1142
+ # The step (iteration) that answered the steer at +index+: one past
1143
+ # the model messages between it and the prompt before it.
1144
+ def steer_step(list, index)
1145
+ list[0...index].reverse_each.take_while { |m| !Samagotchi::Steer.prompt?(m) }
1146
+ .count { |m| %w[model assistant].include?((m[:role] || m["role"]).to_s) } + 1
1147
+ end
1148
+
1093
1149
  def read_history(id)
1094
1150
  raw = @manager.read_responses(id, since_time: nil, state_dir: @state_dir)
1095
1151
  raw.map { |chunk| OutputFormatter.strip(chunk) }.reject(&:empty?)
@@ -1113,6 +1169,12 @@ module Samagotchi
1113
1169
  filtered << { role: "note", content: Samagotchi::ContextNote.text_of(m), label: Samagotchi::ContextNote.label_of(m) }
1114
1170
  next
1115
1171
  end
1172
+ # A plugin's steer: a row of the step that answered it, which the
1173
+ # page finds by `step`.
1174
+ if Samagotchi::Steer.steer?(m)
1175
+ filtered << { role: "steer", content: content, source: (m[:source] || m["source"]).to_s, step: steer_step(list, index) }
1176
+ next
1177
+ end
1116
1178
  next if role == "system"
1117
1179
  next if role == "tool_response"
1118
1180
 
@@ -1129,7 +1191,16 @@ module Samagotchi
1129
1191
  message = { role: norm_role, content: stripped }
1130
1192
  images = m[:images] || m["images"]
1131
1193
  message[:images] = Array(images).map { |ref| ImageStore.symbolize(ref).slice(:file, :name, :width, :height) } if norm_role == "user" && images.is_a?(Array) && !images.empty?
1132
- message[:html] = @markdown_renderer.render(stripped) if norm_role == "assistant" && !stripped.empty? && @markdown_renderer.available?
1194
+ if norm_role == "assistant"
1195
+ # What an after_turn hook presented (AnswerDisplay): rendered and
1196
+ # copied in place of the answer, sanitised the same way; content
1197
+ # stays the model's text, which the page finds the bubble by.
1198
+ display = m[:display] || m["display"]
1199
+ shown = display.is_a?(String) ? Samagotchi::OutputFormatter.strip_markup(display) : ""
1200
+ message[:display] = shown unless shown.strip.empty?
1201
+ rendered = message[:display] || stripped
1202
+ message[:html] = @markdown_renderer.render(rendered) if !rendered.empty? && @markdown_renderer.available?
1203
+ end
1133
1204
  message[:parts] = did if did
1134
1205
  filtered << message
1135
1206
  end
@@ -1138,6 +1209,17 @@ module Samagotchi
1138
1209
  []
1139
1210
  end
1140
1211
 
1212
+ # The last turn failed and its prompt went back to the user: the
1213
+ # prompt (session.last_prompt) and why, for the page to show after the
1214
+ # conversation as it did live. nil otherwise.
1215
+ def failed_turn_for(session, msgs)
1216
+ summary = Samagotchi::TurnNote.restored_failure(msgs)
1217
+ prompt = session.last_prompt.to_s
1218
+ return nil if summary.nil? || prompt.strip.empty?
1219
+
1220
+ { prompt: prompt, summary: summary }
1221
+ end
1222
+
1141
1223
  # The parts of the assistant message at +index+, with the tool_response
1142
1224
  # messages right after it.
1143
1225
  def message_parts(list, index)
@@ -1199,6 +1281,8 @@ module Samagotchi
1199
1281
  def index_data_attributes(req)
1200
1282
  attrs = { "sessions-dir" => sessions_dir_label, "server-dir" => home_label(Dir.pwd) }
1201
1283
  attrs["turn-view"] = "1" if turn_view?(req.params["view"])
1284
+ # Always there: an empty one means no presets, not the default.
1285
+ attrs["annotate-presets"] = @annotate_presets
1202
1286
  dir, = scope_dir(req.params["dir"])
1203
1287
  root = dir && ProjectScope.root_for(dir)
1204
1288
  if root
@@ -10,6 +10,8 @@ export const ROW_STATUS = Object.freeze({
10
10
  RUNNING: "running",
11
11
  OK: "ok",
12
12
  ERROR: "error",
13
+ // A task_wait the user's Stop ended (the task itself runs on).
14
+ STOPPED: "stopped",
13
15
  });
14
16
 
15
17
  // What a row shows for its tool: a plugin tool's label ("chrome:
@@ -79,7 +81,7 @@ export function addCompleted(model, event = {}) {
79
81
  });
80
82
  row = synthesized.row;
81
83
  }
82
- row.status = event.activity?.status === "error" ? ROW_STATUS.ERROR : ROW_STATUS.OK;
84
+ row.status = completedStatus(event.activity?.status);
83
85
  row.output = event.output || "";
84
86
  row.output_truncated = !!event.output_truncated;
85
87
  if (event.activity?.params) row.params = event.activity.params;
@@ -89,6 +91,13 @@ export function addCompleted(model, event = {}) {
89
91
  return row;
90
92
  }
91
93
 
94
+ // A completed call's row status from the server's: error, stopped, else ok.
95
+ export function completedStatus(status) {
96
+ if (status === "error") return ROW_STATUS.ERROR;
97
+ if (status === "stopped") return ROW_STATUS.STOPPED;
98
+ return ROW_STATUS.OK;
99
+ }
100
+
92
101
  // Mark a turn's activity model as finalized (rows stay; rendering collapses).
93
102
  export function finalizeActivity(model) {
94
103
  if (model) model.finalized = true;
@@ -0,0 +1,26 @@
1
+ // Annotate presets: quick replies next to "Annotate" in the selection bubble
2
+ // (web.annotate_presets, "Agreed|Could you please elaborate?"). A preset
3
+ // fills the composer with the quote and its text as the note; it never
4
+ // sends. Its own module, not more exports on annotations.js: static files
5
+ // are cached at unversioned URLs, and a fresh app.js importing a missing
6
+ // export from a cached annotations.js would fail to link.
7
+
8
+ export const MAX_PRESETS = 5;
9
+
10
+ // The presets in +raw+ ("a|b|c"): trimmed, blanks and repeats dropped, at
11
+ // most MAX_PRESETS. A preset can't hold "|".
12
+ export function parsePresets(raw) {
13
+ const out = [];
14
+ for (const part of String(raw ?? "").split("|")) {
15
+ const text = part.trim();
16
+ if (text && !out.includes(text)) out.push(text);
17
+ if (out.length === MAX_PRESETS) break;
18
+ }
19
+ return out;
20
+ }
21
+
22
+ // The quote +block+ (from quoteBlock, ending in a blank line) with +text+
23
+ // as its note.
24
+ export function presetNote(block, text) {
25
+ return `${block}${text}`;
26
+ }
@@ -65,3 +65,16 @@ export function appendQuote(value, block) {
65
65
  const before = String(value ?? "").replace(/\s+$/, "");
66
66
  return before ? `${before}\n\n${block}` : block;
67
67
  }
68
+
69
+ // The Annotate bar's left edge (fixed position, px) under the selection's
70
+ // last line (+rect+: its {left, right}): right-aligned to where the
71
+ // selection ends; where that would clamp to the page gutter (a selection
72
+ // ending near the bubble's left edge), left-aligned to where the selection
73
+ // starts on that line instead, so the bar stays by the text.
74
+ // @param width the bar's width; @param viewport the window's inner width
75
+ export function annotateBarLeft(rect, width, viewport, gutter = 8) {
76
+ const max = viewport - width - gutter;
77
+ const right = rect.right - width;
78
+ const left = right < gutter ? rect.left : right;
79
+ return Math.max(Math.min(left, max), gutter);
80
+ }