samagotchi 0.30.0 → 0.32.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 (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +87 -1
  3. data/README.md +13 -0
  4. data/bin/chi +3 -0
  5. data/docs/cli.md +1 -0
  6. data/docs/configuration.md +7 -1
  7. data/docs/context.md +218 -0
  8. data/docs/guardrails.md +12 -4
  9. data/docs/hooks.md +1 -0
  10. data/docs/internals/engine.md +1 -1
  11. data/docs/plugins.md +77 -1
  12. data/docs/releasing.md +1 -1
  13. data/docs/sessions.md +9 -2
  14. data/lib/samagotchi/bootstrap_command.rb +4 -1
  15. data/lib/samagotchi/bridge/card_store.rb +1 -1
  16. data/lib/samagotchi/bridge/turn_notice.rb +1 -1
  17. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  18. data/lib/samagotchi/bundles/github-pr/manifest.yml +22 -0
  19. data/lib/samagotchi/bundles/github-pr/plugin.rb +65 -0
  20. data/lib/samagotchi/bundles/github-pr/scripts/pr_context.rb +199 -0
  21. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +15 -2
  22. data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/loop-guard/plugin.rb +1 -1
  25. data/lib/samagotchi/bundles/system/delegated.md +1 -0
  26. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  27. data/lib/samagotchi/bundles/system/self_map.md +8 -1
  28. data/lib/samagotchi/child_reports.rb +212 -0
  29. data/lib/samagotchi/client.rb +35 -4
  30. data/lib/samagotchi/config.rb +11 -0
  31. data/lib/samagotchi/context_absorber.rb +179 -0
  32. data/lib/samagotchi/context_command.rb +473 -0
  33. data/lib/samagotchi/context_fetch.rb +226 -0
  34. data/lib/samagotchi/context_note.rb +11 -5
  35. data/lib/samagotchi/context_poller.rb +100 -0
  36. data/lib/samagotchi/context_providers.rb +159 -0
  37. data/lib/samagotchi/context_sources.rb +452 -0
  38. data/lib/samagotchi/engine.rb +12 -5
  39. data/lib/samagotchi/guardrail_wiring.rb +3 -1
  40. data/lib/samagotchi/guardrails/parent_approvals.rb +9 -7
  41. data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
  42. data/lib/samagotchi/guardrails/shell_lex.rb +0 -9
  43. data/lib/samagotchi/guardrails/verdict.rb +21 -3
  44. data/lib/samagotchi/idle_recap.rb +4 -0
  45. data/lib/samagotchi/kernel_loop.rb +44 -5
  46. data/lib/samagotchi/llm/chat_loop.rb +2 -1
  47. data/lib/samagotchi/llm/errors.rb +9 -0
  48. data/lib/samagotchi/log_line.rb +1 -1
  49. data/lib/samagotchi/log_subscriber.rb +1 -1
  50. data/lib/samagotchi/memory_bundle/installer.rb +43 -1
  51. data/lib/samagotchi/memory_bundle/manifest.rb +39 -3
  52. data/lib/samagotchi/memory_bundle/provenance.rb +17 -1
  53. data/lib/samagotchi/plugin/attached_context.rb +100 -0
  54. data/lib/samagotchi/plugin/context.rb +22 -0
  55. data/lib/samagotchi/prompt_warmup.rb +30 -5
  56. data/lib/samagotchi/question_desk.rb +0 -3
  57. data/lib/samagotchi/reply_wait.rb +39 -8
  58. data/lib/samagotchi/session.rb +30 -7
  59. data/lib/samagotchi/session_commands.rb +18 -1
  60. data/lib/samagotchi/session_inbox.rb +34 -1
  61. data/lib/samagotchi/session_manager.rb +37 -5
  62. data/lib/samagotchi/session_retention.rb +3 -0
  63. data/lib/samagotchi/steer.rb +48 -7
  64. data/lib/samagotchi/system_prompt.rb +1 -1
  65. data/lib/samagotchi/terminal_ui/attached_loop.rb +10 -4
  66. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
  67. data/lib/samagotchi/terminal_ui/formatting.rb +45 -7
  68. data/lib/samagotchi/terminal_ui.rb +9 -3
  69. data/lib/samagotchi/tool_activity.rb +8 -0
  70. data/lib/samagotchi/tool_call_parser.rb +40 -0
  71. data/lib/samagotchi/tool_declarations.rb +28 -7
  72. data/lib/samagotchi/tools/builtin_calls.rb +1 -2
  73. data/lib/samagotchi/tools/builtins.rb +5 -0
  74. data/lib/samagotchi/tools/context_read.rb +163 -0
  75. data/lib/samagotchi/tools/delegate.rb +13 -4
  76. data/lib/samagotchi/tools/delegate_cursor.rb +91 -0
  77. data/lib/samagotchi/tools/delegate_wait.rb +50 -31
  78. data/lib/samagotchi/tools/task_runtime.rb +21 -2
  79. data/lib/samagotchi/tools/task_wait.rb +2 -2
  80. data/lib/samagotchi/version.rb +1 -1
  81. data/lib/samagotchi/web/app.rb +117 -3
  82. data/lib/samagotchi/web/public/app.js +52 -14
  83. data/lib/samagotchi/web/public/composer_size.js +7 -6
  84. data/lib/samagotchi/web/public/context_chips.js +285 -0
  85. data/lib/samagotchi/web/public/data.js +21 -0
  86. data/lib/samagotchi/web/public/format.js +8 -1
  87. data/lib/samagotchi/web/public/index.html +49 -3
  88. data/lib/samagotchi/web/public/notify.js +6 -2
  89. data/lib/samagotchi/web/public/timing.js +7 -3
  90. data/lib/samagotchi/web/public/turn_events.js +34 -10
  91. data/lib/samagotchi/web/public/turn_view.js +26 -4
  92. data/lib/samagotchi/worker.rb +343 -8
  93. metadata +16 -1
@@ -6,18 +6,18 @@ require_relative "../parent_report"
6
6
  require_relative "output_guardrails"
7
7
  require_relative "peers"
8
8
  require_relative "delegate_relay"
9
+ require_relative "delegate_cursor"
9
10
 
10
11
  module Samagotchi
11
12
  module Tools
12
13
  # Waiting for a delegated session's next reply, shared by delegate and
13
14
  # delegate_result: ReplyWait in the tools' words, with the status words
14
- # `chi send --wait` and `chi answer` use (ParentReport.status), with a cursor per
15
- # child (the newest reply this parent was already given) and the child
16
- # as it was before the message went in (ReplyWait's baseline), so a turn
17
- # that ends before the wait's first look (a fast failure) still ends it.
18
- # Both live in this process (a worker runs one session), keyed by parent
19
- # and child; a worker respawn loses them, which only repeats the newest
20
- # reply once or waits for a later turn.
15
+ # `chi send --wait` and `chi answer` use (ParentReport.status), with a
16
+ # DelegateCursor per child: the newest reply this parent was already
17
+ # given, and the child as it was when the parent last heard from it or
18
+ # sent it a message (ReplyWait's baseline), so a turn that ends before
19
+ # the wait's first look (a fast failure) still ends it. The cursors are
20
+ # on disk (DelegateCursors), so a respawned worker doesn't repeat a reply.
21
21
  module DelegateWait
22
22
  TIMEOUT_DEFAULT = 600
23
23
  POLL_INTERVAL = 0.5
@@ -32,16 +32,6 @@ module Samagotchi
32
32
 
33
33
  module_function
34
34
 
35
- # @return [Hash{Array(String, String) => String}] [parent id, child id] → newest reply filename given
36
- def seen
37
- @seen ||= {}
38
- end
39
-
40
- # @return [Hash{Array(String, String) => Hash}] [parent id, child id] → ReplyWait baseline taken before sending
41
- def baselines
42
- @baselines ||= {}
43
- end
44
-
45
35
  # @return [Hash{Array(String, String) => Array<String>}] [parent id,
46
36
  # child id] → outcome lines of that child's approvals relayed while
47
37
  # the parent waited on another child, for its next result
@@ -69,18 +59,20 @@ module Samagotchi
69
59
  cancelled = -> { peers.cancelled? }
70
60
  relay = peers.respond_to?(:relay) ? peers.relay : nil
71
61
  others = relay && others_for(peers.session_id, child_id, relay, sd)
72
- baseline = baselines[key]
62
+ cursor = DelegateCursors.get(peers.session_id, child_id, state_dir: sd)
63
+ baseline = cursor.baseline
73
64
  left = timeout.to_i
74
65
  outcomes = relayed_outcomes.delete(key) || []
75
66
  loop do
76
67
  started = monotonic
77
- wait = ReplyWait.call(child_id, state_dir: sd, cursor: seen[key], timeout: [left, 0].max,
68
+ wait = ReplyWait.call(child_id, state_dir: sd, cursor: cursor.reply_file, timeout: [left, 0].max,
78
69
  poll_interval: poll_interval, cancelled: cancelled, baseline: baseline,
79
70
  owner_grace: owner_grace, interject: others && -> { others.poll })
80
71
  left -= monotonic - started
81
72
  unless wait.status == :waiting_for_answer && DelegateRelay.relayable?(relay, wait.question)
82
73
  outcomes.concat(relayed_outcomes.delete(key) || [])
83
- return with_outcomes(finish(wait, child_id, key: key, timeout: timeout), outcomes)
74
+ advance(peers.session_id, child_id, wait, state_dir: sd)
75
+ return with_outcomes(finish(wait, child_id, timeout: timeout), outcomes)
84
76
  end
85
77
 
86
78
  outcome = DelegateRelay.call(child_id, wait.question, relay: relay, state_dir: sd, more: others&.count.to_i)
@@ -118,24 +110,43 @@ module Samagotchi
118
110
 
119
111
  def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
120
112
 
113
+ REPORT_NEXT_REPLY = "chi brings the child's next reply here by itself; don't wait for it with delegate_result."
114
+
115
+ # Outcomes that tell the parent something about the child's state.
116
+ REPORTED = %i[done no_reply error stopped waiting_for_answer worker_gone].freeze
117
+
118
+ # Move the parent's cursor past what +wait+ reported: the reply it
119
+ # gave, and the child as that same look loaded it (a fresh load could
120
+ # swallow a turn that ended in between), so a later wait looks for a
121
+ # later turn and a question already reported isn't reported again.
122
+ # Nothing moves on a timeout or a cancel.
123
+ # @param wait [ReplyWait::Result]
124
+ # @return [DelegateCursor, nil] the new cursor
125
+ def advance(parent_id, child_id, wait, state_dir:)
126
+ return nil unless REPORTED.include?(wait.status) && wait.session
127
+
128
+ DelegateCursors.update(parent_id, child_id, state_dir: state_dir) do |cursor|
129
+ cursor = cursor.with(reply_file: wait.file) if wait.status == :done
130
+ cursor.with_baseline(ReplyWait.baseline_of(wait.session))
131
+ end
132
+ end
133
+
121
134
  # The tool result for how the wait ended.
122
135
  # @param wait [ReplyWait::Result]
123
- # @param key [Array(String, String)] [parent id, child id]
136
+ # @param report [Boolean] a delegate report's text (ChildReports), not
137
+ # the tool's: a question's last line says chi brings the next reply
124
138
  # @return [String]
125
- def finish(wait, child_id, key:, timeout:)
126
- # The turn sent to is handed over: a later wait looks for a later one.
127
- baselines.delete(key) if %i[done no_reply].include?(wait.status)
139
+ def finish(wait, child_id, timeout:, report: false)
128
140
  status = ParentReport.status(wait)
129
141
  case wait.status
130
142
  when :done
131
- seen[key] = wait.file
132
143
  reply_result(child_id, status, wait.text)
133
144
  when :error
134
145
  result(child_id, status, "the child's worker failed: #{wait.text}; its session shows what happened")
135
146
  when :stopped
136
147
  result(child_id, status, "the child was stopped (chi sessions stop); delegate with session: #{child_id} starts it again with a message")
137
148
  when :waiting_for_answer
138
- result(child_id, status, waiting_text(child_id, wait.question))
149
+ result(child_id, status, waiting_text(child_id, wait.question, report: report))
139
150
  when :canceled
140
151
  canceled_result(child_id)
141
152
  when :no_reply
@@ -153,14 +164,18 @@ module Samagotchi
153
164
  # child's baseline before the follow-up goes in.
154
165
  # @param child [Session] the child as loaded before sending
155
166
  def mark_seen(parent_id, child, state_dir:)
156
- seen[[parent_id, child.id]] = ReplyWait.newest_reply(child.id, state_dir: state_dir)
157
- baselines[[parent_id, child.id]] = ReplyWait.baseline_of(child)
167
+ newest = ReplyWait.newest_reply(child.id, state_dir: state_dir)
168
+ DelegateCursors.update(parent_id, child.id, state_dir: state_dir) do |cursor|
169
+ cursor.with(reply_file: newest).with_baseline(ReplyWait.baseline_of(child))
170
+ end
158
171
  end
159
172
 
160
173
  # A new child's baseline: as spawned, before its first turn ran.
161
174
  # @param child [Session] the session spawn_session returned
162
- def mark_started(parent_id, child)
163
- baselines[[parent_id, child.id]] = ReplyWait.baseline_of(child, question_id: nil)
175
+ def mark_started(parent_id, child, state_dir:)
176
+ DelegateCursors.update(parent_id, child.id, state_dir: state_dir) do |cursor|
177
+ cursor.with_baseline(ReplyWait.baseline_of(child, question_id: nil))
178
+ end
164
179
  end
165
180
 
166
181
  def reply_result(child_id, status, text)
@@ -188,13 +203,17 @@ module Samagotchi
188
203
  # decide (it isn't relayed: Continue grants no permission): continue
189
204
  # it with chi answer through execute, send a follow-up instead, or
190
205
  # report back.
191
- def waiting_text(child_id, pending)
206
+ # A report's last line differs (+report+): "waits again" made models
207
+ # call delegate_result right after answering Continue.
208
+ def waiting_text(child_id, pending, report: false)
192
209
  text = "Child #{child_id} is #{ParentReport.question_text(pending, session_id: child_id)}"
193
210
  if ParentReport.continue?(pending)
194
211
  text += "The child ran out of steps before it answered. Decide: continue it (run the chi answer command " \
195
212
  "above with execute) if it is getting somewhere; send it a narrower follow-up with delegate " \
196
213
  "session: #{child_id} (that drops the question); or stop it and report back to your user.\n"
197
214
  end
215
+ return "#{text}#{REPORT_NEXT_REPLY}" if report
216
+
198
217
  "#{text}delegate_result #{child_id} waits again once it is answered."
199
218
  end
200
219
 
@@ -154,7 +154,7 @@ module Samagotchi
154
154
 
155
155
  def get_record(task_id)
156
156
  record = load_record(task_id)
157
- return [nil, "Error: task not found: #{task_id}"] unless record
157
+ return [nil, not_found(task_id)] unless record
158
158
 
159
159
  [refresh_record(record), nil]
160
160
  rescue StandardError => e
@@ -180,7 +180,7 @@ module Samagotchi
180
180
  # @param by [String] "model" (task_stop) or "user" (the web's button)
181
181
  def stop_task(task_id, by:)
182
182
  record = load_record(task_id)
183
- return [nil, "Error: task not found: #{task_id}"] unless record
183
+ return [nil, not_found(task_id)] unless record
184
184
 
185
185
  refreshed = refresh_record(record)
186
186
  return [refreshed, nil] unless refreshed["status"] == "running"
@@ -223,7 +223,26 @@ module Samagotchi
223
223
  record
224
224
  end
225
225
 
226
+ # A session id (a uuid) is never a task's: task ids are a timestamp
227
+ # and hex (20261005093000-1a2b3c4d).
228
+ SESSION_ID_SHAPE = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
229
+
230
+ # A model mixing up ids (Qwen passes session ids to task_get and
231
+ # task_stop) is told which kind it passed and where task ids come from.
232
+ def not_found(task_id)
233
+ error = "Error: task not found: #{task_id}"
234
+ return error unless SESSION_ID_SHAPE.match?(task_id.to_s)
235
+
236
+ "#{error} (that is a session id; task ids come from task_create, and task_list lists them)"
237
+ end
238
+
239
+ # Exactly the shape generate_task_id makes. Anything else (../x, an
240
+ # absolute path) is no task, so a model's id never leaves tmp/tasks.
241
+ TASK_ID_SHAPE = /\A\d{14}-\h{8}\z/
242
+
226
243
  def load_record(task_id)
244
+ return nil unless TASK_ID_SHAPE.match?(task_id.to_s)
245
+
227
246
  path = metadata_path(task_id)
228
247
  return nil unless File.exist?(path)
229
248
 
@@ -34,7 +34,7 @@ module Samagotchi
34
34
 
35
35
  loop do
36
36
  record, error = TaskRuntime.get_record(task_id)
37
- return "Error: #{error}" if error
37
+ return error if error
38
38
 
39
39
  status = record.fetch("status")
40
40
  return finished_response(record, tail_lines) unless status == "running"
@@ -64,7 +64,7 @@ module Samagotchi
64
64
  # normal finished result. Only a task still running says so.
65
65
  def self.cancelled_response(task_id, tail_lines, started)
66
66
  record, error = TaskRuntime.get_record(task_id)
67
- return "Error: #{error}" if error
67
+ return error if error
68
68
  return finished_response(record, tail_lines) unless record.fetch("status") == "running"
69
69
 
70
70
  note = [
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.30.0"
4
+ VERSION = "0.32.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -29,6 +29,8 @@ require_relative "../config"
29
29
  require_relative "../output_formatter"
30
30
  require_relative "../image_store"
31
31
  require_relative "../context_note"
32
+ require_relative "../context_sources"
33
+ require_relative "../context_providers"
32
34
  require_relative "../recap_store"
33
35
  require_relative "markdown_renderer"
34
36
  require_relative "message_parts"
@@ -150,6 +152,10 @@ module Samagotchi
150
152
  ["POST", %r{\A/api/sessions/([^/]+)/command\z}, :handle_command],
151
153
  ["POST", %r{\A/api/sessions/([^/]+)/images\z}, :handle_image_upload],
152
154
  ["GET", %r{\A/api/sessions/([^/]+)/images/([^/]+)\z}, :handle_image],
155
+ ["GET", %r{\A/api/sessions/([^/]+)/context\z}, :handle_context_list],
156
+ ["GET", %r{\A/api/sessions/([^/]+)/context/([^/]+)\z}, :handle_context_show],
157
+ ["POST", %r{\A/api/sessions/([^/]+)/context\z}, :handle_context_add],
158
+ ["DELETE", %r{\A/api/sessions/([^/]+)/context/([^/]+)\z}, :handle_context_delete],
153
159
  ["GET", %r{\A/api/sessions/([^/]+)\z}, :handle_show],
154
160
  ["DELETE", %r{\A/api/sessions/([^/]+)\z}, :handle_delete]
155
161
  ].freeze
@@ -1117,6 +1123,101 @@ module Samagotchi
1117
1123
  error_response(404, "not_found", e.message)
1118
1124
  end
1119
1125
 
1126
+ # ── Attached context (ContextSources): the session bar's chips ───────
1127
+ # A source's command isn't shown (the page may be on a phone over the
1128
+ # LAN), and the web adds a URL only, through an installed bundle's
1129
+ # provider (ContextProviders): never a command.
1130
+
1131
+ # GET /api/sessions/:id/context: the sources this session sees, muted
1132
+ # ones marked, with what the chips show.
1133
+ def handle_context_list(_req, id)
1134
+ session = @session_class.load(id, state_dir: default_state_dir)
1135
+ own = ContextSources.session_location(id, state_dir: default_state_dir)
1136
+ subs = own.subscriptions
1137
+ rows = attached_context(session).map { |attached| context_row(attached, own, subs) }
1138
+ json_response(200, { session_id: id, sources: rows, can_add_url: ContextProviders.any? })
1139
+ rescue ArgumentError => e
1140
+ error_response(404, "not_found", e.message)
1141
+ end
1142
+
1143
+ # GET /api/sessions/:id/context/:name: one source with its text.
1144
+ def handle_context_show(_req, id, name)
1145
+ session = @session_class.load(id, state_dir: default_state_dir)
1146
+ attached = find_context(session, name) or return error_response(404, "not_found", "no source #{name[0, 40]}")
1147
+ own = ContextSources.session_location(id, state_dir: default_state_dir)
1148
+ json_response(200, context_row(attached, own, own.subscriptions).merge(text: attached.snapshot.text))
1149
+ rescue ArgumentError => e
1150
+ error_response(404, "not_found", e.message)
1151
+ end
1152
+
1153
+ # POST /api/sessions/:id/context {url, why}: the source an installed
1154
+ # bundle's provider makes of +url+, attached to this session.
1155
+ def handle_context_add(req, id)
1156
+ session = @session_class.load(id, state_dir: default_state_dir)
1157
+ body = parse_json(request_body(req))
1158
+ return error_response(400, "invalid_json", "give {url, why}") unless body.is_a?(Hash) && body["url"].is_a?(String)
1159
+
1160
+ url = body["url"].strip
1161
+ return error_response(422, "not_a_url", "give an http(s) URL") unless url.match?(%r{\Ahttps?://\S+\z})
1162
+
1163
+ resolved = ContextProviders.resolve(url)
1164
+ return error_response(422, "no_provider", "no installed bundle resolves #{url[0, 200]}") unless resolved
1165
+
1166
+ own = ContextSources.session_location(session.id, state_dir: default_state_dir)
1167
+ own.add(web_source(resolved, body["why"], own.scope))
1168
+ json_response(201, { status: "attached", name: resolved.name })
1169
+ rescue ContextProviders::Invalid => e
1170
+ error_response(422, "no_provider", e.message)
1171
+ rescue ContextSources::Invalid => e
1172
+ error_response(409, "exists", e.message)
1173
+ rescue ArgumentError => e
1174
+ error_response(404, "not_found", e.message)
1175
+ end
1176
+
1177
+ def web_source(resolved, why, scope)
1178
+ why = ContextSources.one_line(why.is_a?(String) ? why : nil, ContextSources::LINE_MAX_CHARS) || resolved.why
1179
+ ContextSources::Source.new(name: resolved.name, cmd: resolved.cmd, every_seconds: resolved.every_seconds, why: why,
1180
+ hint: resolved.hint, scope: scope, added_by: "web", created_at: Time.now.utc.iso8601,
1181
+ provider: resolved.bundle)
1182
+ end
1183
+
1184
+ # DELETE /api/sessions/:id/context/:name: the session's own source is
1185
+ # detached; a project's is muted for this session only.
1186
+ def handle_context_delete(_req, id, name)
1187
+ session = @session_class.load(id, state_dir: default_state_dir)
1188
+ attached = find_context(session, name) or return error_response(404, "not_found", "no source #{name[0, 40]}")
1189
+ if attached.location.session?
1190
+ attached.location.remove(attached.name)
1191
+ json_response(200, { status: "detached", name: attached.name })
1192
+ else
1193
+ ContextSources.session_location(id, state_dir: default_state_dir).mute(attached.name)
1194
+ json_response(200, { status: "muted", name: attached.name })
1195
+ end
1196
+ rescue ArgumentError => e
1197
+ error_response(404, "not_found", e.message)
1198
+ end
1199
+
1200
+ def attached_context(session)
1201
+ ContextSources.attached(session.id, project_root: session.project_root, state_dir: default_state_dir)
1202
+ end
1203
+
1204
+ # @return [ContextSources::Attached, nil]
1205
+ def find_context(session, name)
1206
+ ContextSources.check_name!(name)
1207
+ attached_context(session).find { |attached| attached.name == name }
1208
+ rescue ContextSources::Invalid
1209
+ nil
1210
+ end
1211
+
1212
+ def context_row(attached, own, subs)
1213
+ source = attached.source
1214
+ snapshot = attached.snapshot
1215
+ { name: source.name, scope: source.scope, kind: source.push? ? "push" : "cmd", every_seconds: source.every_seconds,
1216
+ why: source.why, hint: snapshot.hint || source.hint, summary: snapshot.summary, error: snapshot.error,
1217
+ fetched_at: snapshot.fetched_at, has_text: snapshot.text?, muted: own.muted?(source.name),
1218
+ unread: snapshot.text? && subs[source.name]&.read != snapshot.revision }
1219
+ end
1220
+
1120
1221
  def handle_stream(req, id)
1121
1222
  # Validate session exists
1122
1223
  begin
@@ -1491,7 +1592,14 @@ module Samagotchi
1491
1592
  role = (m[:role] || m["role"]).to_s
1492
1593
  content = (m[:content] || m["content"]).to_s
1493
1594
  if Samagotchi::ContextNote.note?(m)
1494
- filtered << { role: "note", content: Samagotchi::ContextNote.text_of(m), label: Samagotchi::ContextNote.label_of(m) }
1595
+ note = { role: "note", content: Samagotchi::ContextNote.text_of(m), label: Samagotchi::ContextNote.label_of(m) }
1596
+ # A context wake turn's note starts that turn (Steer.turn_prompt?):
1597
+ # the page opens the turn with it and pairs it with its record.
1598
+ if Samagotchi::Steer.turn_prompt?(m)
1599
+ note[:turn_start] = true
1600
+ note[:turn_id] = (m[:turn_id] || m["turn_id"]).to_s
1601
+ end
1602
+ filtered << note
1495
1603
  next
1496
1604
  end
1497
1605
  # A plugin's steer: a row of the step that answered it, which the
@@ -1522,8 +1630,14 @@ module Samagotchi
1522
1630
 
1523
1631
  message = { role: norm_role, content: stripped }
1524
1632
  # Merged into the running turn (Steer::INPUT_KIND): part of it,
1525
- # read by step +step+.
1526
- message.merge!(merged: true, step: steer_step(list, index)) if norm_role == "user" && Samagotchi::Steer.input?(m)
1633
+ # read by step +step+; a wake turn's report starts its turn
1634
+ # (Steer.turn_prompt?). Its sender (a delegate report, chi send):
1635
+ # the page's label.
1636
+ if norm_role == "user" && Samagotchi::Steer.input?(m)
1637
+ message.merge!(merged: true, step: steer_step(list, index)) unless Samagotchi::Steer.turn_prompt?(m)
1638
+ source = (m[:source] || m["source"]).to_s
1639
+ message[:source] = source unless source.empty?
1640
+ end
1527
1641
  # The turn it started (Engine): the page pairs it with that turn's
1528
1642
  # record by this, not by its place.
1529
1643
  turn_id = m[:turn_id] || m["turn_id"]
@@ -15,7 +15,12 @@ import {
15
15
  uploadImage,
16
16
  listModels,
17
17
  fetchHistory,
18
+ listContext,
19
+ getContext,
20
+ detachContext,
21
+ addContext,
18
22
  } from "./data.js";
23
+ import { createContextChips } from "./context_chips.js";
19
24
  import { MODEL_KEY } from "./model_pick.js";
20
25
  import { emptySessionState } from "./session_state.js";
21
26
  import { createModelPicker } from "./model_picker.js";
@@ -32,7 +37,7 @@ import { routeChunk } from "./chunk_router.js";
32
37
  import { extractCtxPct, savedCtxPct, cardCtxText, generationTokens, lastSpeedText, speedText, tokensTipText } from "./ctx.js";
33
38
  import { createQuestionCards } from "./question_cards.js";
34
39
  import { cardClass, cardInnerHtml, cardPlace, isCard, isNoticeCard, noticeInnerHtml, splitSnapshotCards, turnNoticePlace } from "./card.js";
35
- import { gripFloor, promptHeight } from "./composer_size.js";
40
+ import { PROMPT_LINE_PX, PROMPT_MIN_PX, gripFloor, promptHeight } from "./composer_size.js";
36
41
  import { createCommandList } from "./command_list.js";
37
42
  import { createPromptHistory } from "./prompt_history.js";
38
43
  import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
@@ -46,7 +51,7 @@ import {
46
51
  appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, mergeTiming, normalizeTiming, STOPPED_LINE_CLASS, turnTimingText,
47
52
  } from "./timing.js";
48
53
  import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
49
- import { clientLabel, commandView, displayWait, dropEarlyRestores, emptyAnswerLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, replaysDrawnTurn, retryStatusLine, snapshotEvents, steerCutLine, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
54
+ import { clientLabel, commandView, isReportClient, displayWait, dropEarlyRestores, emptyAnswerLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, replaysDrawnTurn, retryStatusLine, snapshotEvents, steerCutLine, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
50
55
 
51
56
  const $ = (s) => document.querySelector(s);
52
57
  const topStripEl = $("#topStrip");
@@ -57,6 +62,14 @@ const composerEl = $("#composer");
57
62
  const infoBarEl = $("#infoBar");
58
63
  const firstPeekEl = $("#firstPeek");
59
64
  const infoTextEl = $("#infoText");
65
+ // The open session's attached context, in its own row of the session bar
66
+ // (updateInfoBar rewrites #infoText every tick; the chips keep their popover).
67
+ const contextChips = createContextChips({
68
+ el: $("#contextChips"),
69
+ api: { list: listContext, show: getContext, detach: detachContext, add: addContext },
70
+ escapeHtml,
71
+ toast: (text) => showToast(text),
72
+ });
60
73
  const markdownWarningEl = $("#markdownWarning");
61
74
  const promptEl = $("#prompt");
62
75
  const actionBtn = $("#actionBtn");
@@ -142,8 +155,11 @@ let selectSeq = null;
142
155
  // new turn has started on it yet: see followWokenWorker.
143
156
  let streamFromGuess = false;
144
157
  // The last input_merged had an origin with no bubble: its merged text
145
- // (pending_input_merged) gets one.
158
+ // (pending_input_merged) gets one ("report": a delegate report's).
146
159
  let unmatchedMerge = false;
160
+ // The running turn is one chi ran for delegate reports and its report
161
+ // bubble isn't drawn yet: that bubble starts the turn.
162
+ let wakeStart = false;
147
163
  // The enqueued_ids this page sent (from the /turn acks): only these come back
148
164
  // into the composer when their turn fails.
149
165
  const sentIds = new Set();
@@ -549,18 +565,24 @@ function setPlaceholder(long, short) {
549
565
  promptEl.placeholder = narrowScreen.matches ? short : long;
550
566
  }
551
567
 
552
- // The key hint under the composer (shown while it has focus).
568
+ // The key hint under the composer (shown while it has focus; on the start
569
+ // page only: in a session the placeholder carries it, the hint row is gone).
553
570
  const SEND_HINT = "Enter to send · Shift+Enter for a newline · ↑ history";
554
571
 
572
+ let hadSession = false;
555
573
  // While a turn runs, Send still sends: the prompt merges into the turn at
556
574
  // its next step (steering). Cancel is its own button then.
557
575
  function updateComposerMode() {
558
- document.body.classList.toggle("has-session", !!selected);
576
+ // In a session the box is one line: its minimum follows the class.
577
+ if (document.body.classList.toggle("has-session", !!selected) !== hadSession) {
578
+ hadSession = !hadSession;
579
+ fitPrompt();
580
+ }
559
581
  if (selected) modelPicker.close();
560
582
  const running = turnRunning && !!selected;
561
583
  cancelBtn.style.display = running ? "" : "none";
562
584
  if (running) {
563
- setPlaceholder("Running… a message now steers this turn (Enter to send)", "Running… a message steers it");
585
+ setPlaceholder("Running… a message now steers this turn · Enter to send · Shift+Enter newline", "Steer the turn…");
564
586
  composeHintEl.textContent = SEND_HINT;
565
587
  actionBtn.textContent = "Send";
566
588
  emptyEl.classList.add("hidden");
@@ -575,13 +597,13 @@ function updateComposerMode() {
575
597
  historyEl.innerHTML = "";
576
598
  updateInfoBar();
577
599
  } else if (current.owner === "tui") {
578
- setPlaceholder("A terminal chi owns this session and doesn't share it — run it with chi --shared to send from here", "A terminal chi owns this session");
600
+ setPlaceholder("A terminal chi owns this session · run it with chi --shared to send from here", "A terminal chi owns this session");
579
601
  composeHintEl.textContent = "";
580
602
  actionBtn.textContent = "Send";
581
603
  actionBtn.disabled = true;
582
604
  emptyEl.classList.add("hidden");
583
605
  } else {
584
- setPlaceholder("Message chi…", "Message chi…");
606
+ setPlaceholder("Message chi… · Enter to send · Shift+Enter newline · ↑ history", "Message chi…");
585
607
  composeHintEl.textContent = SEND_HINT;
586
608
  actionBtn.textContent = "Send";
587
609
  actionBtn.disabled = false;
@@ -650,6 +672,7 @@ function clearSelection() {
650
672
  setSessionHash(null);
651
673
  document.querySelectorAll(".card").forEach((el) => el.classList.remove("active"));
652
674
  resetSessionState();
675
+ contextChips.clear();
653
676
  clearTimingInterval();
654
677
  liveTurnTimingEl = null;
655
678
  liveTurnId = null;
@@ -696,6 +719,7 @@ async function select(id) {
696
719
  resetTurnView();
697
720
  updateFirstPeek();
698
721
  updateInfoBar();
722
+ contextChips.load(id);
699
723
  updateComposerMode();
700
724
  await resync();
701
725
  promptEl.focus();
@@ -710,6 +734,7 @@ function resetTurnView() {
710
734
  questionCards.removeQuestion();
711
735
  questionCards.forgetContinue();
712
736
  unmatchedMerge = false;
737
+ wakeStart = false;
713
738
  setTurnRunning(false);
714
739
  releaseDisplayWait();
715
740
  }
@@ -1067,14 +1092,18 @@ function untaggedOwnBubble(prompt) {
1067
1092
  // Returns the bubble of the turn a turn_started starts (the stage takes it
1068
1093
  // into its prompt line), else null.
1069
1094
  function applyPromptOps(event) {
1070
- const ops = promptOps(event, { myId: clientId, known: (id) => !!bubbleById(id), unmatchedMerge });
1095
+ if (event.type === "turn_started") wakeStart = !!event.continue && isReportClient(event.origin?.client_id);
1096
+ const ops = promptOps(event, { myId: clientId, known: (id) => !!bubbleById(id), unmatchedMerge, wakeStart });
1071
1097
  if (event.type === "input_merged") unmatchedMerge = false;
1072
1098
  if (event.type === "pending_input_merged") unmatchedMerge = false;
1073
1099
  let started = null;
1074
1100
  for (const op of ops) {
1075
- if (op.op === "add") {
1101
+ if (op.op === "report") {
1102
+ unmatchedMerge = "report";
1103
+ } else if (op.op === "add") {
1076
1104
  const bubble = makeUserBubble(op.prompt, { state: op.state, label: op.label, enqueuedId: op.enqueuedId, images: op.images });
1077
1105
  if (op.state === "started") started = bubble;
1106
+ if (event.type === "pending_input_merged") wakeStart = false;
1078
1107
  } else if (op.op === "tag") {
1079
1108
  if (bubbleById(op.enqueuedId)) continue;
1080
1109
  const echo = untaggedOwnBubble(op.prompt);
@@ -1459,7 +1488,7 @@ function updateInfoBar() {
1459
1488
  const attachCmd = `chi --attach ${selected}`;
1460
1489
  const copied = Date.now() - attachCopiedAt < 2000;
1461
1490
  const copyHtml = `<button type="button" class="copy-attach" title="Copy &quot;${escapeHtml(attachCmd)}&quot; to attach a terminal">${copied ? "copied ✓" : "copy chi --attach"}</button>`;
1462
- const metaHtml = `<div class="meta"><span class="id" title="${escapeHtml(selected)}">${escapeHtml(idShort)}</span>${copyHtml}<span class="${statusClass}">${escapeHtml(current.status||"")}</span><span class="model${label.mismatch ? " served-mismatch" : ""}" title="${escapeHtml(label.title)}">${escapeHtml(label.text)}</span>${parentHtml}${current.usedMemories.length ? `<span class="model mem-chip" title="${escapeHtml(memChipTitle(current.usedMemories))}">${escapeHtml(memChipText(current.usedMemories))}</span>` : ""}<span class="dir" title="${escapeHtml(current.dir||"")}">${escapeHtml((current.dir||"").slice(0,48))}</span>${ctxText ? `<span class="model ctx" title="${escapeHtml(ctxTip)}">${escapeHtml(ctxText)}</span>` : ""}${speedTextNow ? `<span class="model speed" title="${escapeHtml(speedTip)}">${escapeHtml(speedTextNow)}</span>` : ""}${timingText ? `<span class="model">${escapeHtml(timingText)}</span>` : ""}${workerBadgeHtml()}</div>`;
1491
+ const metaHtml = `<div class="meta"><span class="id" title="${escapeHtml(selected)}">${escapeHtml(idShort)}</span>${copyHtml}<span class="${statusClass}">${escapeHtml(current.status||"")}</span><span class="model${label.mismatch ? " served-mismatch" : ""}" title="${escapeHtml(label.title)}">${escapeHtml(label.text)}</span>${parentHtml}${current.usedMemories.length ? `<span class="model mem-chip" title="${escapeHtml(memChipTitle(current.usedMemories))}">${escapeHtml(memChipText(current.usedMemories))}</span>` : ""}<span class="dir" title="${escapeHtml(current.dir||"")}"><bdi>${escapeHtml(current.dir||"")}</bdi></span>${ctxText ? `<span class="model ctx" title="${escapeHtml(ctxTip)}">${escapeHtml(ctxText)}</span>` : ""}${speedTextNow ? `<span class="model speed" title="${escapeHtml(speedTip)}">${escapeHtml(speedTextNow)}</span>` : ""}${timingText ? `<span class="model">${escapeHtml(timingText)}</span>` : ""}${workerBadgeHtml()}</div>`;
1463
1492
  const previewHtml = `<div class="preview-line"><span class="preview">${escapeHtml(current.firstPreview || "—")}</span> · <span class="mem" title="${escapeHtml(current.usedMemories.join(", "))}">mem: ${escapeHtml(memText)}</span></div>`;
1464
1493
  infoTextEl.innerHTML = metaHtml + previewHtml;
1465
1494
  infoTextEl.title = memoriesTooltip(current.usedMemories, current.preloadedMemories, current.mutedMemories);
@@ -1836,7 +1865,11 @@ const streamHandlers = {
1836
1865
  // Due reminders went into the turn (a reminder turn has no prompt bubble).
1837
1866
  reminder_injected: (data) => addCommandBubble({ label: null, line: reminderText(data), text: "" }),
1838
1867
  // A context note joined the conversation (between turns; no turn runs).
1839
- context_added: (data) => addNoteBubble({ label: data.label, content: data.text || "" }),
1868
+ context_added: (data) => {
1869
+ addNoteBubble({ label: data.label, content: data.text || "" });
1870
+ // An attached context source's note: its chip changed.
1871
+ if (data.context_source) contextChips.refresh();
1872
+ },
1840
1873
  continue_offered: (data) => questionCards.renderContinue(data),
1841
1874
  continue_resolved: (data) => questionCards.resolveContinue(data),
1842
1875
  tool_call_started: (data) => turnDom.toolStarted(data),
@@ -2230,10 +2263,15 @@ const GRIP_KEY = "chi_composer_floor";
2230
2263
  let promptFloor = null;
2231
2264
  try { promptFloor = Number(localStorage.getItem(GRIP_KEY)) || null; } catch (_) {}
2232
2265
 
2266
+ // The empty box: one line in a session, three on the start page.
2267
+ function promptMinPx() {
2268
+ return document.body.classList.contains("has-session") ? PROMPT_LINE_PX : PROMPT_MIN_PX;
2269
+ }
2270
+
2233
2271
  function fitPrompt() {
2234
2272
  const borders = promptEl.offsetHeight - promptEl.clientHeight;
2235
2273
  promptEl.style.height = "0px";
2236
- const { height, scrolls } = promptHeight({ contentPx: promptEl.scrollHeight + borders, floorPx: promptFloor, viewportPx: window.innerHeight });
2274
+ const { height, scrolls } = promptHeight({ contentPx: promptEl.scrollHeight + borders, floorPx: promptFloor, viewportPx: window.innerHeight, minPx: promptMinPx() });
2237
2275
  promptEl.style.height = `${height}px`;
2238
2276
  promptEl.style.overflowY = scrolls ? "auto" : "hidden";
2239
2277
  }
@@ -2246,7 +2284,7 @@ composeGrip.addEventListener("pointerdown", (e) => {
2246
2284
  const startY = e.clientY;
2247
2285
  const startH = promptEl.offsetHeight;
2248
2286
  const move = (ev) => {
2249
- promptFloor = gripFloor(startH + (startY - ev.clientY), window.innerHeight);
2287
+ promptFloor = gripFloor(startH + (startY - ev.clientY), window.innerHeight, promptMinPx());
2250
2288
  fitPrompt();
2251
2289
  };
2252
2290
  const up = () => {
@@ -1,19 +1,20 @@
1
1
  // The composer's height. It grows with its text up to a cap; the grip on
2
2
  // its top edge sets a taller floor (the cap then is at least that floor).
3
- export const PROMPT_MIN_PX = 72; // about three lines of the 15 px field
3
+ export const PROMPT_MIN_PX = 72; // about three lines of the 15 px field: the start page
4
+ export const PROMPT_LINE_PX = 26; // one line (15 px × 1.45 + 4 padding): in a session
4
5
  export const PROMPT_CAP_SHARE = 0.35; // of the window's height, for growing with text
5
6
  export const PROMPT_GRIP_SHARE = 0.5; // of the window's height, for the grip
6
7
 
7
8
  // contentPx: the text's height (scrollHeight plus borders); floorPx: the
8
- // grip's height, or null.
9
- export function promptHeight({ contentPx, floorPx = null, viewportPx }) {
10
- const floor = Math.max(PROMPT_MIN_PX, floorPx || 0);
9
+ // grip's height, or null; minPx: the empty box's height.
10
+ export function promptHeight({ contentPx, floorPx = null, viewportPx, minPx = PROMPT_MIN_PX }) {
11
+ const floor = Math.max(minPx, floorPx || 0);
11
12
  const cap = Math.max(floor, Math.round(viewportPx * PROMPT_CAP_SHARE));
12
13
  return { height: Math.min(Math.max(contentPx, floor), cap), scrolls: contentPx > cap };
13
14
  }
14
15
 
15
16
  // A grip drag: the floor it asks for, kept between the minimum and the
16
17
  // grip's share of the window.
17
- export function gripFloor(px, viewportPx) {
18
- return Math.round(Math.min(Math.max(px, PROMPT_MIN_PX), viewportPx * PROMPT_GRIP_SHARE));
18
+ export function gripFloor(px, viewportPx, minPx = PROMPT_MIN_PX) {
19
+ return Math.round(Math.min(Math.max(px, minPx), viewportPx * PROMPT_GRIP_SHARE));
19
20
  }