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
@@ -8,6 +8,7 @@ require "yaml"
8
8
 
9
9
  require_relative "config"
10
10
  require_relative "context_note"
11
+ require_relative "steer"
11
12
  require_relative "turn_note"
12
13
  require_relative "model_profile"
13
14
  require_relative "thought_stream_splitter"
@@ -15,14 +16,17 @@ require_relative "cancellation_controller"
15
16
  require_relative "context_window"
16
17
  require_relative "kernel_loop"
17
18
  require_relative "tools/builtins"
19
+ require_relative "tools/task_runtime"
18
20
  require_relative "session_commands"
19
21
  require_relative "plugin/loader"
20
22
  require_relative "log"
21
23
  require_relative "log_subscriber"
24
+ require_relative "client"
22
25
  require_relative "host_registry"
23
26
  require_relative "llm/backend"
24
27
  require_relative "llm/openai_chat"
25
28
  require_relative "session"
29
+ require_relative "archive_store"
26
30
  require_relative "session_observer"
27
31
  require_relative "tool_declarations"
28
32
  require_relative "session_metrics"
@@ -41,6 +45,8 @@ require_relative "served_model"
41
45
  require_relative "image_store"
42
46
  require_relative "vision_context"
43
47
  require_relative "vision_support"
48
+ require_relative "sampling_settings"
49
+ require_relative "answer_display"
44
50
 
45
51
  module Samagotchi
46
52
  # Engine owns the core agent logic: system prompt construction, tool
@@ -74,11 +80,16 @@ module Samagotchi
74
80
  # @param muted_memories [Array<String>] --mute list: memories hidden from this session (not in the
75
81
  # prompt's index, dropped from the preloads, refused by memory_read); a mute wins over a preload
76
82
  DEFAULT_SYSTEM_MEMORIES = %w[identity].freeze
83
+ # What memory_write answers in a scratch session.
84
+ SCRATCH_MEMORY_WRITE = "Error: scratch session: nothing is saved"
77
85
 
78
86
  # @param plugins [Boolean] false: load no bundle plugins (a throwaway Engine for a prompt)
87
+ # @param scratch [Boolean] a `chi scratch` session: memory writes are refused, and there is no
88
+ # delegate (a child would outlive it) nor plugin fork
79
89
  def initialize(mode: :assist, client: nil, host_registry: nil, profile: nil, session_id: nil, no_interrupt: false, model_name: nil, memories: [], muted_memories: [], kernel: nil, recap: nil, reminders: nil,
80
- plugins: true)
90
+ plugins: true, scratch: false)
81
91
  @mode = mode.to_sym
92
+ @scratch = scratch
82
93
  @chat_backend = nil
83
94
  @chat_backend_mutex = Mutex.new
84
95
  @default_model_name = ModelProfile.required_model_name(model_name)
@@ -113,6 +124,12 @@ module Samagotchi
113
124
  # The tools this session offers (the prompts' declarations and the
114
125
  # kernel's dispatch): the built-ins, per Engine.
115
126
  @tools = Tools::Builtins.registry
127
+ if @scratch
128
+ # A scratch session's children would outlive it; its memories
129
+ # would too (write and edit into the memories: ScratchWrites).
130
+ [Tools::Delegate::NAME, Tools::DelegateResult::NAME].each { |name| @tools.unregister(name) }
131
+ @tools[Tools::MemoryWrite::NAME].handler = ->(_call, _kctx) { SCRATCH_MEMORY_WRITE }
132
+ end
116
133
  # Likewise the slash commands its SessionCommands run.
117
134
  @command_registry = SessionCommands.register_builtins(Commands::Registry.new)
118
135
  # Installed bundles' plugins add commands, tools and hooks to these
@@ -220,6 +237,9 @@ module Samagotchi
220
237
  @activity_seq = 0
221
238
  @turn_running = false
222
239
  @active_cancel_controller = nil
240
+ # Plugin steers ({text:, source:}) waiting for the running turn's next
241
+ # boundary (#steer); guarded by @activity_mutex.
242
+ @steers = []
223
243
  # Reminder names the interactive REPL's IdleReminders callback marked due;
224
244
  # the REPL polls them to decide when to run a synthetic reminder turn.
225
245
  @due_reminder_names = []
@@ -302,6 +322,58 @@ module Samagotchi
302
322
  @activity_mutex.synchronize { @turn_running }
303
323
  end
304
324
 
325
+ # Put +text+ into the running turn, as a UI's steering does: it joins the
326
+ # conversation at the loop's next iteration boundary as its own user
327
+ # message (kind: "steer", source:). Callable from any thread; never
328
+ # blocks. With no turn running it does nothing (it never starts one).
329
+ # True means queued, not merged: at the after-answer boundary, and when
330
+ # the turn ends first, it is dropped (logged).
331
+ # @return [Boolean] whether it was queued
332
+ def steer(text, source:)
333
+ text = text.to_s.strip
334
+ return false if text.empty?
335
+
336
+ @activity_mutex.synchronize do
337
+ return false unless @turn_running
338
+
339
+ @steers << { text: text, source: source.to_s }
340
+ end
341
+ true
342
+ end
343
+
344
+ # The drain a turn's loop gets: the caller's lines (a UI's steering; nil
345
+ # in a --non-interactive run) and the plugin steers. At the after-answer
346
+ # boundary the steers are dropped: the model answered, and a nudge would
347
+ # only restart the turn. The steer part fails on its own, never taking
348
+ # the user's lines with it.
349
+ def turn_drain(pending_input)
350
+ lambda do |at_answer: false|
351
+ lines = pending_input ? Array(pending_input.call) : []
352
+ lines + take_steers(at_answer)
353
+ end
354
+ end
355
+ private :turn_drain
356
+
357
+ def take_steers(drop)
358
+ steers = @activity_mutex.synchronize do
359
+ taken = @steers
360
+ @steers = []
361
+ taken
362
+ end
363
+ return steers unless drop
364
+
365
+ log_dropped_steers(steers, "answered")
366
+ []
367
+ rescue StandardError
368
+ []
369
+ end
370
+ private :take_steers
371
+
372
+ def log_dropped_steers(steers, why)
373
+ steers.each { |steer| Log.info(:turn, "steer_dropped", source: steer[:source], why: why, chars: steer[:text].length) }
374
+ end
375
+ private :log_dropped_steers
376
+
305
377
  # @return [Array<String>] reminder names queued for a synthetic REPL turn
306
378
  def due_reminder_names
307
379
  @activity_mutex.synchronize { @due_reminder_names.dup }
@@ -341,7 +413,7 @@ module Samagotchi
341
413
  snapshot = @activity_mutex.synchronize { @session&.messages }
342
414
  return "[]" if snapshot.nil?
343
415
 
344
- JSON.generate(Array(snapshot).map(&:dup))
416
+ JSON.generate(AnswerDisplay.strip_all(snapshot).map(&:dup))
345
417
  end
346
418
 
347
419
  # Emit a :recap_ready event (additive slot) carrying the generated recap
@@ -892,9 +964,12 @@ module Samagotchi
892
964
  target = @host_registry.resolve(@effective_model_name)
893
965
  served, served_for = served_model_for(snapshot, target: target)
894
966
  snapshot = snapshot.merge(served_model: served, served_model_for: served_for)
895
- unless snapshot[:context_window_tokens]
967
+ unless snapshot.dig(:context, :window_tokens)
896
968
  window = current_context_window(target)
897
- snapshot = snapshot.merge(context_window_tokens: window.tokens, context_window_source: window.source) if window
969
+ if window
970
+ snapshot = snapshot.merge(context: snapshot[:context].merge(window_tokens: window.tokens,
971
+ window_source: window.source.to_s))
972
+ end
898
973
  end
899
974
  # The chat loop uses no prompt profile: drop one a native turn reported.
900
975
  return snapshot.except(:profile, :profile_source) if target.entry.chat?
@@ -906,10 +981,28 @@ module Samagotchi
906
981
  snapshot
907
982
  end
908
983
 
984
+ # The status line's ctx from the saved context, for a worker that has run
985
+ # no turn yet (it woke after an idle exit): nil when either count is
986
+ # unknown or context.status is off.
987
+ def saved_context_status(context)
988
+ return nil unless context && @kernel.respond_to?(:context_display)
989
+
990
+ @kernel.context_display(used_tokens: context[:used_tokens], window_tokens: context[:window_tokens])
991
+ end
992
+
909
993
  # The effective model is on a chat host (api: openai), whose loop uses
910
994
  # no prompt profile.
911
995
  def chat_model? = @host_registry.resolve(@effective_model_name).entry.chat?
912
996
 
997
+ # "temperature=0.6 (hosts.work)" for /model: the effective model's
998
+ # configured sampling, nil when none.
999
+ def sampling_summary
1000
+ target = @host_registry.resolve(@effective_model_name)
1001
+ SamplingSettings.summary(target, names: model_lookup_names(target))
1002
+ rescue StandardError
1003
+ nil
1004
+ end
1005
+
913
1006
  # The model the server serves for the current model, and the name asked
914
1007
  # for: what the last generation of that name reported, else llama.cpp's
915
1008
  # model_alias (/props, one short cached probe; not with probe: false),
@@ -941,12 +1034,13 @@ module Samagotchi
941
1034
  # :recap_min_user_turns and :recap_inactivity_seconds (nil when not)
942
1035
  def session_state_snapshot
943
1036
  served_pair = served_model(probe: false)
1037
+ metrics = @metrics.snapshot
944
1038
  {
945
1039
  status: @session&.status,
946
1040
  message_count: (@session&.messages || []).size,
947
1041
  last_prompt: @session&.last_prompt,
948
1042
  event_seq: @session_observer&.event_count,
949
- metrics: @metrics.snapshot,
1043
+ metrics: metrics,
950
1044
  pending_question: @question_mutex.synchronize { @pending_question&.dup },
951
1045
  used_memory_names: @used_memory_mutex.synchronize { @used_memory_names.dup },
952
1046
  preloaded_memory_names: preloaded_memory_names,
@@ -955,7 +1049,7 @@ module Samagotchi
955
1049
  model_name: @effective_model_name,
956
1050
  served_model: served_pair[0],
957
1051
  served_model_for: served_pair[1],
958
- context_status: @last_context_status&.dup,
1052
+ context_status: @last_context_status&.dup || saved_context_status(metrics[:context]),
959
1053
  recap_enabled: !@recap.nil?,
960
1054
  recap_min_user_turns: @recap&.min_user_turns,
961
1055
  recap_inactivity_seconds: @recap&.inactivity&.to_i
@@ -1069,6 +1163,14 @@ module Samagotchi
1069
1163
  # its own.
1070
1164
  attr_writer :session_state_dir
1071
1165
 
1166
+ # The metrics load the session's saved records from its state dir, once.
1167
+ def bind_metrics(session)
1168
+ return unless session.respond_to?(:id) && session.id
1169
+
1170
+ @metrics.state_dir = session_state_dir
1171
+ @metrics.session_id = session.id
1172
+ end
1173
+
1072
1174
  def session_state_dir = @session_state_dir || Session.default_state_dir
1073
1175
 
1074
1176
  # What a Bridge adds to a plugin's ctx.messages while a turn runs (the
@@ -1085,7 +1187,8 @@ module Samagotchi
1085
1187
  # The gate's core checks, in order.
1086
1188
  def guardrail_checks
1087
1189
  rules = guardrail_rules
1088
- [@guardrail_failures, rules.hook_asks, guardrail_protected_paths, rules]
1190
+ [@guardrail_failures, rules.hook_asks, guardrail_protected_paths, (@scratch_writes ||= Guardrails::ScratchWrites.new if @scratch),
1191
+ rules].compact
1089
1192
  end
1090
1193
 
1091
1194
  # The YAML rules: config.yml's `guardrails:` section (rules, disable) and
@@ -1231,10 +1334,44 @@ module Samagotchi
1231
1334
  # The conversation as a hook may read it: a frozen array of copied
1232
1335
  # messages, so a hook cannot change what the turn sends or stores.
1233
1336
  def hook_messages(messages)
1234
- Array(messages).map(&:dup).freeze
1337
+ AnswerDisplay.strip_all(messages).map(&:dup).freeze
1235
1338
  end
1236
1339
  private :hook_messages
1237
1340
 
1341
+ # How the turn ended, on the session (Session#last_turn): the caller's
1342
+ # save puts it in the file the session hub watches.
1343
+ def record_last_turn(session, outcome, seconds, origin)
1344
+ client_id = origin.is_a?(Hash) ? origin[:client_id].to_s : ""
1345
+ source = if client_id.start_with?("#{Tools::Delegate::CLIENT_PREFIX}:") then "delegate"
1346
+ elsif client_id == SessionManager::REMINDER_CLIENT_ID then "reminder"
1347
+ else "client"
1348
+ end
1349
+ session.last_turn = { "outcome" => outcome, "ended_at" => Time.now.iso8601(3),
1350
+ "seconds" => seconds.round(1), "origin" => source }
1351
+ end
1352
+ private :record_last_turn
1353
+
1354
+ # Keep what the after_turn hooks presented as the answer's `display`
1355
+ # and tell the observers (:answer_display), after the turn_completed
1356
+ # they already had: the web re-reads the answer then. A turn_completed
1357
+ # with display_pending (after_turn hooks were about to run) always gets
1358
+ # one, `display: nil` when nothing was presented, so the web holds the
1359
+ # answer's pop until then and never swaps it after. Terminals are not
1360
+ # sinks of it; they printed the answer already. The session is saved by
1361
+ # the caller, as after every turn.
1362
+ def store_answer_display(session, display, pending: false)
1363
+ synchronize_events do
1364
+ messages = Array(session.messages)
1365
+ if display.changed? && messages.last.equal?(display.target)
1366
+ replace_session_messages(session, messages[0...-1] + [display.target.merge(AnswerDisplay::KEY => display.text)])
1367
+ @session_observer.notify({ type: :answer_display, display: display.text })
1368
+ elsif pending
1369
+ @session_observer.notify({ type: :answer_display, display: nil })
1370
+ end
1371
+ end
1372
+ end
1373
+ private :store_answer_display
1374
+
1238
1375
  # ── The hook runtime (Hooks::Runtime) ─────────────────────────────────────
1239
1376
 
1240
1377
  # The three things a hook can do beyond reading its event. Each gets the
@@ -1243,11 +1380,19 @@ module Samagotchi
1243
1380
  Hooks::Runtime.new(
1244
1381
  notify: ->(text:, level:, hook:) { hook_notify(text, level, hook) },
1245
1382
  ask_user: ->(question:, options:, header:, allow_freeform:, hook:) { hook_ask_user(question, options, header, allow_freeform, hook) },
1246
- stop_turn: ->(reason:, hook:) { hook_stop_turn(reason, hook) }
1383
+ stop_turn: ->(reason:, hook:) { hook_stop_turn(reason, hook) },
1384
+ steer: ->(text:, hook:) { steer(text, source: hook_source(hook)) }
1247
1385
  )
1248
1386
  end
1249
1387
  private :hook_runtime
1250
1388
 
1389
+ # What a steer is attributed to: the bundle of a bundle hook's label
1390
+ # ("<file> (bundle <name>)"), the label of any other.
1391
+ def hook_source(hook)
1392
+ hook.to_s[/\(bundle (.+)\)\z/, 1] || hook.to_s
1393
+ end
1394
+ private :hook_source
1395
+
1251
1396
  # One line to the user (:hook_notice). During a turn it is a turn
1252
1397
  # event: the turn's sink (the REPL) and the observers (bridge, log).
1253
1398
  # Outside one (a plugin's command at the prompt) it is announced with
@@ -1379,7 +1524,7 @@ module Samagotchi
1379
1524
  # Persist to session file for WEB stub + resume (generic for all UIs)
1380
1525
  if @session
1381
1526
  @session.pending_question = pending.dup
1382
- begin; @session.save; rescue StandardError; nil; end
1527
+ begin; @session.save(state_dir: session_state_dir); rescue StandardError; nil; end
1383
1528
  end
1384
1529
  # Generic emit for all UIs (TUI, WEB, Bridge, future). Observers that
1385
1530
  # stash this event (e.g. TerminalUI handle_question_event) will
@@ -1402,7 +1547,7 @@ module Samagotchi
1402
1547
  @pending_question = nil
1403
1548
  if @session
1404
1549
  @session.pending_question = nil
1405
- begin; @session.save; rescue StandardError; nil; end
1550
+ begin; @session.save(state_dir: session_state_dir); rescue StandardError; nil; end
1406
1551
  end
1407
1552
  emit_event(nil, { type: :question_answered, id: id, answer: ans })
1408
1553
  return ans
@@ -1413,7 +1558,7 @@ module Samagotchi
1413
1558
  @pending_question = nil
1414
1559
  if @session
1415
1560
  @session.pending_question = nil
1416
- begin; @session.save; rescue StandardError; nil; end
1561
+ begin; @session.save(state_dir: session_state_dir); rescue StandardError; nil; end
1417
1562
  end
1418
1563
  emit_event(nil, { type: :question_answered, id: id, answer: sync_res })
1419
1564
  return sync_res
@@ -1431,7 +1576,7 @@ module Samagotchi
1431
1576
  @question_mutex.synchronize { @pending_question = nil }
1432
1577
  if @session
1433
1578
  @session.pending_question = nil
1434
- begin; @session.save; rescue StandardError; nil; end
1579
+ begin; @session.save(state_dir: session_state_dir); rescue StandardError; nil; end
1435
1580
  end
1436
1581
  return { error: "no answer", detail: "handler failed to capture selection", id: id }
1437
1582
  end
@@ -1453,7 +1598,7 @@ module Samagotchi
1453
1598
  @pending_question = nil
1454
1599
  if @session
1455
1600
  @session.pending_question = nil
1456
- begin; @session.save; rescue StandardError; nil; end
1601
+ begin; @session.save(state_dir: session_state_dir); rescue StandardError; nil; end
1457
1602
  end
1458
1603
  emit_event(nil, { type: :question_cancelled, id: id, reason: active_cancel_controller.reason.to_s })
1459
1604
  return { error: "cancelled", reason: active_cancel_controller.reason.to_s, id: id }
@@ -1464,7 +1609,7 @@ module Samagotchi
1464
1609
  @question_mutex.synchronize { @pending_question = nil }
1465
1610
  if @session
1466
1611
  @session.pending_question = nil
1467
- begin; @session.save; rescue StandardError; nil; end
1612
+ begin; @session.save(state_dir: session_state_dir); rescue StandardError; nil; end
1468
1613
  end
1469
1614
  if answer
1470
1615
  emit_event(nil, { type: :question_answered, id: id, answer: answer })
@@ -1512,6 +1657,9 @@ module Samagotchi
1512
1657
  @question_answer = answer
1513
1658
  @question_cv.broadcast
1514
1659
  answer
1660
+ end.tap do
1661
+ # A human answered: the session is back in the lists (ArchiveStore).
1662
+ ArchiveStore.user_input(@session&.id, state_dir: session_state_dir)
1515
1663
  end
1516
1664
  end
1517
1665
 
@@ -1586,6 +1734,8 @@ module Samagotchi
1586
1734
  @session = session
1587
1735
  # One session per REPL/worker process: its records carry this sid.
1588
1736
  Log.session_id = session.id if session.respond_to?(:id) && session.id
1737
+ # A woken worker's /stats and status line count the turns before it.
1738
+ bind_metrics(session)
1589
1739
  sync_used_memories_from_session(session)
1590
1740
  end
1591
1741
 
@@ -1616,7 +1766,7 @@ module Samagotchi
1616
1766
 
1617
1767
  messages = @activity_mutex.synchronize { @session&.messages } || []
1618
1768
  covered = state[:covered].to_i
1619
- turns_since = Array(messages).drop(covered).count { |m| m.is_a?(Hash) && (m["role"] || m[:role]).to_s == "user" }
1769
+ turns_since = Array(messages).drop(covered).count { |m| Steer.prompt?(m) }
1620
1770
  { text: state[:text], covered: covered, turns_since: turns_since, created_at: state[:created_at] }
1621
1771
  end
1622
1772
 
@@ -1710,9 +1860,12 @@ module Samagotchi
1710
1860
  # stay unchanged for callers that don't pass it.
1711
1861
  with_origin = origin ? ->(event) { event.merge(origin: origin) } : ->(event) { event }
1712
1862
  begin
1863
+ # A Stop cuts the /props probes this thread makes for the turn
1864
+ # (window, served model, vision) instead of waiting their timeout.
1865
+ probe_cancel_before = Client.swap_probe_cancel(effective_controller)
1713
1866
  image_refs, image_error = turn_image_refs(session, continue ? [] : images)
1714
1867
  # Emit turn_started event
1715
- @metrics.session_id = session.id
1868
+ bind_metrics(session)
1716
1869
  turn_started = { type: :turn_started, session_id: session.id, prompt: prompt }
1717
1870
  turn_started[:continue] = true if continue
1718
1871
  turn_started[:images] = image_refs unless image_refs.empty?
@@ -1722,6 +1875,7 @@ module Samagotchi
1722
1875
  # Before anything of the turn is kept or a reminder is used up.
1723
1876
  vision = turn_vision(session)
1724
1877
  @kernel.vision = vision if @kernel.respond_to?(:vision=)
1878
+ @kernel.sampling = turn_sampling if @kernel.respond_to?(:sampling=)
1725
1879
  refuse_images!(vision) unless image_refs.empty?
1726
1880
  announce_guardrail_failures(on_event)
1727
1881
  # Plugins' slow setup that brings tools (an MCP server's first
@@ -1785,7 +1939,7 @@ module Samagotchi
1785
1939
  cancel_controller: effective_controller,
1786
1940
  model_name: bare_for_backend,
1787
1941
  max_tool_output_chars: max_tool_output_chars,
1788
- pending_input: pending_input
1942
+ pending_input: turn_drain(pending_input)
1789
1943
  )
1790
1944
 
1791
1945
  # Persist deduped used memories onto the session for Web + reload.
@@ -1818,24 +1972,31 @@ module Samagotchi
1818
1972
  # placeholder text) is a turn the model should know ended that way.
1819
1973
  empty = !canceled && !resumable &&
1820
1974
  (response.strip.empty? || (result.respond_to?(:empty_answer?) && result.empty_answer?))
1975
+ # after_turn hooks run below and may present the answer (the web
1976
+ # holds its pop until it knows).
1977
+ display_pending = !canceled && @hooks.any?(:after_turn)
1821
1978
  synchronize_events do
1822
1979
  if empty
1823
1980
  # The placeholder is for the UIs (a new array: it must not leak
1824
1981
  # into the result); the note is for the model, so it goes on the
1825
1982
  # result's conversation too, which the REPL keeps as-is.
1983
+ # A retry's nudge at the tail goes: this note says it all.
1826
1984
  note = TurnNote.empty
1827
- saved = (conversation || session.messages).dup
1985
+ conversation&.replace(TurnNote.without_trailing(conversation))
1986
+ saved = TurnNote.without_trailing(conversation || session.messages)
1828
1987
  saved << { role: "model", content: "[No response]" } if response.strip.empty?
1829
1988
  replace_session_messages(session, saved + [note])
1830
1989
  conversation << note if conversation
1831
1990
  elsif canceled && conversation
1832
1991
  conversation << TurnNote.cancelled(result.cancellation_reason, seconds: turn_seconds.call,
1833
- shown: TurnNote.interrupted_tail?(conversation))
1992
+ shown: TurnNote.interrupted_tail?(conversation),
1993
+ running_tasks: Tools::TaskRuntime.running_created_in(conversation))
1834
1994
  replace_session_messages(session, conversation)
1835
1995
  elsif conversation
1836
1996
  replace_session_messages(session, conversation)
1837
1997
  end
1838
1998
  session.status = Session::STATUS_IDLE
1999
+ record_last_turn(session, canceled ? "canceled" : "completed", turn_seconds.call, origin)
1839
2000
  if canceled
1840
2001
  emit_event(on_event, with_origin.call({
1841
2002
  type: :turn_canceled,
@@ -1847,16 +2008,22 @@ module Samagotchi
1847
2008
  emit_event(on_event, with_origin.call({
1848
2009
  type: :turn_completed,
1849
2010
  result: result,
1850
- turn_summary: turn_summary(result)
2011
+ turn_summary: turn_summary(result),
2012
+ display_pending: display_pending
1851
2013
  }))
1852
2014
  end
1853
2015
  end
1854
- @metrics.persist
2016
+ @metrics.persist(state_dir: session_state_dir)
1855
2017
 
1856
2018
  # Fire :after_turn hook (runs even on cancel/success), with a read-only
1857
- # copy of the conversation the turn stored.
1858
- @hooks.fire(:after_turn, { type: :after_turn, status: canceled ? "canceled" : "completed",
1859
- messages: hook_messages(session.messages) })
2019
+ # copy of the conversation the turn stored, and event[:present] for
2020
+ # a display version of the answer (AnswerDisplay).
2021
+ display = AnswerDisplay.new(session.messages)
2022
+ after_turn = { type: :after_turn, status: canceled ? "canceled" : "completed",
2023
+ messages: hook_messages(session.messages) }
2024
+ after_turn[:present] = display.presenter(after_turn)
2025
+ @hooks.fire(:after_turn, after_turn)
2026
+ store_answer_display(session, display, pending: display_pending)
1860
2027
 
1861
2028
  # Fire :session_end after every turn (turn-level lifecycle)
1862
2029
  @hooks.fire(:session_end, { type: :session_end, session_id: session.id })
@@ -1865,11 +2032,18 @@ module Samagotchi
1865
2032
  rescue Interrupt
1866
2033
  effective_controller.cancel!(:ctrl_c)
1867
2034
  synchronize_events do
1868
- replace_session_messages(session, TurnNote.replace_trailing(messages, TurnNote.cancelled(:ctrl_c, seconds: turn_seconds.call))) if messages
2035
+ # Only the pre-turn messages survive here, so tasks this turn
2036
+ # started are missed (plan wait-stop §6).
2037
+ if messages
2038
+ note = TurnNote.cancelled(:ctrl_c, seconds: turn_seconds.call,
2039
+ running_tasks: Tools::TaskRuntime.running_created_in(messages))
2040
+ replace_session_messages(session, TurnNote.replace_trailing(messages, note))
2041
+ end
1869
2042
  session.status = Session::STATUS_IDLE
2043
+ record_last_turn(session, "canceled", turn_seconds.call, origin)
1870
2044
  emit_event(on_event, with_origin.call({ type: :turn_canceled, cancellation_reason: :ctrl_c }))
1871
2045
  end
1872
- @metrics.persist
2046
+ @metrics.persist(state_dir: session_state_dir)
1873
2047
  raise
1874
2048
  rescue StandardError => e
1875
2049
  # Keep what the turn got to (the prompt plus the loop's completed
@@ -1885,14 +2059,15 @@ module Samagotchi
1885
2059
  end
1886
2060
  replace_session_messages(session, kept) if kept
1887
2061
  session.status = Session::STATUS_IDLE
1888
- begin; session.save; rescue StandardError; nil; end
2062
+ record_last_turn(session, "failed", turn_seconds.call, origin)
2063
+ begin; session.save(state_dir: session_state_dir); rescue StandardError; nil; end
1889
2064
  failed = { type: :turn_failed, error_class: e.class.name, message: e.message }
1890
2065
  # A provider error says what kind it is, for one line per kind in the UIs.
1891
2066
  if e.is_a?(LLM::ProviderError)
1892
2067
  failed.merge!(error_kind: e.kind, retryable: e.retryable?, host: e.host, summary: e.summary)
1893
2068
  end
1894
2069
  emit_event(on_event, with_origin.call(failed))
1895
- @metrics.persist
2070
+ @metrics.persist(state_dir: session_state_dir)
1896
2071
  raise
1897
2072
  ensure
1898
2073
  # A completed turn is activity: release the turn flag and advance the
@@ -1900,10 +2075,13 @@ module Samagotchi
1900
2075
  # treats the just-finished turn as activity and re-arms its window.
1901
2076
  # Always runs, even if an exception occurred.
1902
2077
  set_turn_running(false)
1903
- @activity_mutex.synchronize do
2078
+ Client.swap_probe_cancel(probe_cancel_before)
2079
+ left = @activity_mutex.synchronize do
1904
2080
  @active_cancel_controller = nil
1905
2081
  @turn_event_sink = nil
2082
+ @steers.tap { @steers = [] }
1906
2083
  end
2084
+ log_dropped_steers(left, "turn_ended")
1907
2085
  record_activity
1908
2086
  # Clear hooks so they remain turn-scoped and never leak into the next turn.
1909
2087
  clear_hooks
@@ -1928,6 +2106,15 @@ module Samagotchi
1928
2106
  capability: -> { VisionSupport.for(target, profile: profile, adapter: vision_adapter(target)) })
1929
2107
  end
1930
2108
 
2109
+ # The effective model's request parameters (hosts: and models:
2110
+ # sampling), read per turn: /model can change the model between turns.
2111
+ def turn_sampling
2112
+ target = @host_registry.resolve(@effective_model_name)
2113
+ SamplingSettings.for(target, names: model_lookup_names(target))
2114
+ rescue StandardError
2115
+ SamplingSettings::EMPTY
2116
+ end
2117
+
1931
2118
  def vision_adapter(target)
1932
2119
  target.entry.chat? ? @host_registry.adapter_for(target.entry) : nil
1933
2120
  rescue StandardError
@@ -2161,11 +2348,14 @@ module Samagotchi
2161
2348
  (task = current_init_task) ? task.cancelled? : @activity_mutex && active_cancel_controller&.cancelled?
2162
2349
  },
2163
2350
  card: ->(**card) { show_card(**card) },
2351
+ steer: ->(text, source) { steer(text, source: source) },
2352
+ stop_turn: ->(reason, label) { hook_stop_turn(reason, label) },
2164
2353
  ask_model: lambda { |request, timeout:, max_tokens:, cancel_controller:|
2165
2354
  ask_side_model(request, timeout: timeout, max_tokens: max_tokens, cancel_controller: cancel_controller)
2166
2355
  },
2167
2356
  model_name: -> { @session&.model_name || @effective_model_name },
2168
- state_dir: -> { session_state_dir }
2357
+ state_dir: -> { session_state_dir },
2358
+ scratch: -> { @scratch }
2169
2359
  )
2170
2360
  end
2171
2361
 
@@ -2175,7 +2365,7 @@ module Samagotchi
2175
2365
  # with the event log held, so a turn is in exactly one of the two.
2176
2366
  def plugin_messages
2177
2367
  synchronize_events do
2178
- messages = messages_checkpoint || []
2368
+ messages = AnswerDisplay.strip_all(messages_checkpoint)
2179
2369
  first = messages.first
2180
2370
  messages = messages.drop(1) if first && first[:role].to_s == "system" && first[:kind].to_s.empty?
2181
2371
  running = turn_running? && @running_turn_messages ? Array(@running_turn_messages.call) : []
@@ -2462,10 +2652,14 @@ module Samagotchi
2462
2652
  end
2463
2653
 
2464
2654
  target = @host_registry.resolve(@effective_model_name)
2465
- # As typed (maybe an alias), the part after a host prefix, alias-resolved, bare.
2655
+ ModelProfile.resolve(names: model_lookup_names(target), entry: target.entry, client: target.client, bare_model: target.bare_model)
2656
+ end
2657
+
2658
+ # The names a models: entry may be under: as typed (maybe an alias), the
2659
+ # part after a host prefix, alias-resolved, bare.
2660
+ def model_lookup_names(target)
2466
2661
  typed = @model_lookup_names.first
2467
- names = @model_lookup_names + [@host_registry.parse_qualified_model(typed).last, target.bare_model]
2468
- ModelProfile.resolve(names: names.compact, entry: target.entry, client: target.client, bare_model: target.bare_model)
2662
+ (@model_lookup_names + [@host_registry.parse_qualified_model(typed).last, target.bare_model]).compact
2469
2663
  end
2470
2664
 
2471
2665
  # Everything that holds a profile follows the resolution: the kernel's
@@ -2661,6 +2855,8 @@ module Samagotchi
2661
2855
  end
2662
2856
 
2663
2857
  merged = Array(baseline).dup
2858
+ # For the warning when one can't be loaded: it names where it came from.
2859
+ @config_memories = merged.dup
2664
2860
  Array(cli_memories).each do |raw|
2665
2861
  raw.to_s.split(",").map(&:strip).reject(&:empty?).each do |name|
2666
2862
  merged << name unless merged.include?(name)
@@ -2693,7 +2889,8 @@ module Samagotchi
2693
2889
  scope, actual_name = split_memory_scope(name)
2694
2890
  body = Tools::MemoryRead.call(actual_name, scope: scope)
2695
2891
  if body.start_with?("Error:")
2696
- Log.warn(:memory, "preload_failed", echo: "Warning: --memory '#{name}' could not be loaded (#{body})", memory: name)
2892
+ source = Array(@config_memories).include?(raw) ? "memory '#{name}' (from config memories:)" : "--memory '#{name}'"
2893
+ Log.warn(:memory, "preload_failed", echo: "Warning: #{source} could not be loaded (#{body})", memory: name)
2697
2894
  next
2698
2895
  end
2699
2896
  # Record activated names so the UI can echo them in the sticky
@@ -36,6 +36,8 @@ module Samagotchi
36
36
  label: label,
37
37
  command: targets&.command,
38
38
  paths: targets && !targets.paths.empty? ? targets.paths : nil,
39
+ # No command or path (an MCP tool): its arguments, as the question.
40
+ args: args_for_card(targets),
39
41
  cwd: targets&.cwd,
40
42
  repo_root: targets&.repo_root,
41
43
  branch: verdict.context&.branch(targets&.cwd || verdict.context.cwd),
@@ -47,6 +49,13 @@ module Samagotchi
47
49
  }
48
50
  end
49
51
 
52
+ def args_for_card(targets)
53
+ return nil if targets.nil? || targets.command || !targets.paths.empty?
54
+
55
+ text = Approval.args_text(targets.args, limit: ARGS_CHARS)
56
+ text.empty? ? nil : text
57
+ end
58
+
50
59
  # Settle +verdict+ from open_question's result.
51
60
  # @param answer [Hash, String] {selected_indices:, freeform:} or {error:}
52
61
  # @param scopes [Array<String>] the offered scopes, in option order
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "protected_paths"
4
+ require_relative "../memory_paths"
5
+
6
+ module Samagotchi
7
+ module Guardrails
8
+ # A `chi scratch` session saves nothing: write and edit on a file under
9
+ # the memories folder (system and project memories, overlays, index.md)
10
+ # are denied. (memory_write itself refuses in a scratch session, with a
11
+ # shorter answer: Engine.) Reading them is fine. execute can still
12
+ # write there (as with ProtectedPaths).
13
+ class ScratchWrites
14
+ REASON = "scratch session: nothing is saved"
15
+ RULE = "scratch-session"
16
+
17
+ # @param memories_dir [#call] the memories folder (MemoryPaths.system_dir),
18
+ # read per call like the memory tools do
19
+ def initialize(memories_dir: -> { MemoryPaths.system_dir })
20
+ @memories_dir = memories_dir
21
+ end
22
+
23
+ # Vote on +verdict+.
24
+ def check(verdict)
25
+ return verdict unless writes_memory?(verdict)
26
+
27
+ verdict.deny!(REASON, rule: RULE, source: ProtectedPaths::SOURCE, decided_by: "core")
28
+ end
29
+
30
+ private
31
+
32
+ def writes_memory?(verdict)
33
+ targets = verdict.targets
34
+ return false unless targets && %w[write edit].include?(targets.tool)
35
+
36
+ targets.paths.any? { |path| ProtectedPaths.within?(ProtectedPaths.real(path), @memories_dir.call) }
37
+ end
38
+ end
39
+ end
40
+ end
@@ -6,6 +6,7 @@ require_relative "guardrails/targets"
6
6
  require_relative "guardrails/approval"
7
7
  require_relative "guardrails/approvals"
8
8
  require_relative "guardrails/protected_paths"
9
+ require_relative "guardrails/scratch_writes"
9
10
  require_relative "guardrails/load_failures"
10
11
  require_relative "guardrails/rules"
11
12
  require_relative "guardrails/gate"