samagotchi 0.19.0 → 0.21.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +130 -1
  3. data/docs/architecture.md +8 -1
  4. data/docs/cli.md +16 -4
  5. data/docs/configuration.md +4 -1
  6. data/docs/guardrails.md +92 -14
  7. data/docs/plugins.md +27 -8
  8. data/docs/sessions.md +3 -3
  9. data/lib/samagotchi/bootstrap/config_writer.rb +14 -3
  10. data/lib/samagotchi/bootstrap_command.rb +26 -5
  11. data/lib/samagotchi/bridge/sse_writer.rb +32 -0
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +10 -3
  13. data/lib/samagotchi/bridge.rb +16 -4
  14. data/lib/samagotchi/bridge_client.rb +33 -9
  15. data/lib/samagotchi/bundle_command.rb +28 -4
  16. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  17. data/lib/samagotchi/bundles/check-in/plugin.rb +14 -5
  18. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +24 -5
  19. data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
  20. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/loop-guard/plugin.rb +61 -5
  22. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/mcp/plugin.rb +18 -4
  24. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  26. data/lib/samagotchi/commands/registry.rb +4 -3
  27. data/lib/samagotchi/config.rb +3 -0
  28. data/lib/samagotchi/context_usage.rb +3 -3
  29. data/lib/samagotchi/engine.rb +31 -4
  30. data/lib/samagotchi/events.rb +1 -1
  31. data/lib/samagotchi/guardrail_wiring.rb +2 -1
  32. data/lib/samagotchi/guardrails/approval.rb +4 -3
  33. data/lib/samagotchi/guardrails/approvals.rb +11 -5
  34. data/lib/samagotchi/guardrails/context.rb +16 -9
  35. data/lib/samagotchi/guardrails/parent_approvals.rb +10 -4
  36. data/lib/samagotchi/guardrails/read_only_shell.rb +322 -0
  37. data/lib/samagotchi/guardrails/rm_targets.rb +86 -0
  38. data/lib/samagotchi/guardrails/rules.rb +69 -5
  39. data/lib/samagotchi/guardrails/shell_git_dirs.rb +5 -137
  40. data/lib/samagotchi/guardrails/shell_lex.rb +164 -0
  41. data/lib/samagotchi/guardrails/shell_paths.rb +145 -0
  42. data/lib/samagotchi/guardrails/targets.rb +65 -4
  43. data/lib/samagotchi/guardrails.rb +2 -0
  44. data/lib/samagotchi/hooks/registry.rb +6 -2
  45. data/lib/samagotchi/idle_recap.rb +7 -2
  46. data/lib/samagotchi/llm/chat_loop.rb +1 -1
  47. data/lib/samagotchi/llm/openai_chat.rb +19 -5
  48. data/lib/samagotchi/llm/usage.rb +1 -1
  49. data/lib/samagotchi/log_subscriber.rb +4 -2
  50. data/lib/samagotchi/memory_bundle/builder.rb +1 -5
  51. data/lib/samagotchi/memory_bundle/installer.rb +46 -13
  52. data/lib/samagotchi/memory_bundle/shipped_update.rb +3 -1
  53. data/lib/samagotchi/memory_bundle/status.rb +12 -4
  54. data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -5
  55. data/lib/samagotchi/memory_paths.rb +6 -0
  56. data/lib/samagotchi/self_report.rb +38 -2
  57. data/lib/samagotchi/session.rb +35 -6
  58. data/lib/samagotchi/session_commands.rb +13 -2
  59. data/lib/samagotchi/session_manager.rb +26 -9
  60. data/lib/samagotchi/session_metrics.rb +230 -23
  61. data/lib/samagotchi/steer.rb +1 -1
  62. data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -9
  63. data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -1
  64. data/lib/samagotchi/terminal_ui/formatting.rb +59 -3
  65. data/lib/samagotchi/terminal_ui/live_region.rb +41 -2
  66. data/lib/samagotchi/terminal_ui/reline_seam.rb +10 -0
  67. data/lib/samagotchi/token_usage.rb +57 -41
  68. data/lib/samagotchi/tool_runner.rb +21 -2
  69. data/lib/samagotchi/tools/execute.rb +9 -8
  70. data/lib/samagotchi/version.rb +1 -1
  71. data/lib/samagotchi/web/app.rb +19 -0
  72. data/lib/samagotchi/web/public/app.js +68 -22
  73. data/lib/samagotchi/web/public/card.js +34 -2
  74. data/lib/samagotchi/web/public/ctx.js +59 -0
  75. data/lib/samagotchi/web/public/format.js +11 -0
  76. data/lib/samagotchi/web/public/index.html +33 -2
  77. data/lib/samagotchi/web/public/model_pick.js +6 -2
  78. data/lib/samagotchi/web/public/model_picker.js +2 -1
  79. data/lib/samagotchi/web/public/notifications.js +8 -3
  80. data/lib/samagotchi/web/public/notify.js +49 -7
  81. data/lib/samagotchi/web/public/question_card.js +2 -1
  82. data/lib/samagotchi/web/public/timing.js +11 -5
  83. data/lib/samagotchi/web/public/turn_events.js +17 -1
  84. data/lib/samagotchi/web/session_summary.rb +6 -2
  85. metadata +5 -1
@@ -35,10 +35,12 @@ module Samagotchi
35
35
  # ([] when none), usage (never nil) and the finish reason.
36
36
  # +model+ is the model the server says answered (the body's `model`),
37
37
  # which can differ from the one asked for; nil when it says nothing.
38
+ # +provider+ is the provider that served it (the body's `provider`,
39
+ # OpenRouter's routing name); nil when the server says nothing.
38
40
  # +cut+ is set on the empty response the chat loop makes for a
39
41
  # generation a plugin cut (stop_generation): {by:, reason:}.
40
- ChatResponse = Data.define(:text, :reasoning, :tool_calls, :usage, :finish_reason, :model, :cut) do
41
- def initialize(model: nil, cut: nil, **fields) = super
42
+ ChatResponse = Data.define(:text, :reasoning, :tool_calls, :usage, :finish_reason, :model, :provider, :cut) do
43
+ def initialize(model: nil, provider: nil, cut: nil, **fields) = super
42
44
  end
43
45
 
44
46
  # The OpenAI Chat Completions API (llama.cpp's /v1, and any compatible
@@ -248,7 +250,8 @@ module Samagotchi
248
250
  ChatResponse.new(text: OpenAIChat.drop_leading_newlines(message["content"].to_s),
249
251
  reasoning: (message["reasoning_content"] || message["reasoning"]).to_s,
250
252
  tool_calls: calls, usage: Usage.from_payload(body) || Usage.none,
251
- finish_reason: body.dig("choices", 0, "finish_reason"), model: served_model(body))
253
+ finish_reason: body.dig("choices", 0, "finish_reason"), model: served_model(body),
254
+ provider: self.class.served_provider(body))
252
255
  end
253
256
 
254
257
  # Workaround for Splash 1.0.2/1.1.0, which keeps the "\n\n" the model
@@ -265,6 +268,15 @@ module Samagotchi
265
268
 
266
269
  def served_model(payload) = self.class.served_model(payload)
267
270
 
271
+ # OpenRouter names the provider that served a request (Baseten,
272
+ # Fireworks, …) in the body's `provider`; other servers send none.
273
+ def self.served_provider(payload)
274
+ provider = payload.is_a?(Hash) ? payload["provider"] : nil
275
+ provider.is_a?(String) && !provider.strip.empty? ? provider : nil
276
+ end
277
+
278
+ def served_provider(payload) = self.class.served_provider(payload)
279
+
268
280
  # The parsed payload of a `data:` line; nil for blank lines, comments
269
281
  # and [DONE]. Error events raise their ProviderError.
270
282
  # A 200 whose body is a plain JSON error instead of a stream (OpenRouter
@@ -352,7 +364,7 @@ module Samagotchi
352
364
  def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
353
365
 
354
366
  # Streamed deltas put together: text, reasoning, tool calls by index,
355
- # the finish reason, the usage chunk and the served model.
367
+ # the finish reason, the usage chunk, the served model and provider.
356
368
  class Assembly
357
369
  # The chunk carries part of a tool call.
358
370
  def self.tool_call_delta?(payload)
@@ -367,12 +379,14 @@ module Samagotchi
367
379
  @finish_reason = nil
368
380
  @usage = nil
369
381
  @model = nil
382
+ @provider = nil
370
383
  end
371
384
 
372
385
  # @return [Array(String, String)] this chunk's content and reasoning
373
386
  def add(payload)
374
387
  @usage = Usage.from_payload(payload) || @usage if payload.key?("usage")
375
388
  @model = OpenAIChat.served_model(payload) || @model
389
+ @provider = OpenAIChat.served_provider(payload) || @provider
376
390
  choice = payload["choices"].is_a?(Array) ? payload["choices"].first : nil
377
391
  return ["", ""] unless choice.is_a?(Hash)
378
392
 
@@ -392,7 +406,7 @@ module Samagotchi
392
406
  ToolCall.new(id: call[:id], name: call[:name].to_s, arguments: OpenAIChat.parse_arguments(call[:arguments]))
393
407
  end
394
408
  ChatResponse.new(text: @text, reasoning: @reasoning, tool_calls: calls, usage: @usage || Usage.none,
395
- finish_reason: @finish_reason, model: @model)
409
+ finish_reason: @finish_reason, model: @model, provider: @provider)
396
410
  end
397
411
 
398
412
  private
@@ -12,7 +12,7 @@ module Samagotchi
12
12
  counts = TokenUsage.from_payload(payload)
13
13
  return nil unless counts
14
14
 
15
- new(prompt_tokens: counts[:prompt_tokens].to_i, completion_tokens: counts[:completion_tokens].to_i,
15
+ new(prompt_tokens: counts.prompt_tokens.to_i, completion_tokens: counts.completion_tokens.to_i,
16
16
  source: :server)
17
17
  end
18
18
 
@@ -82,7 +82,8 @@ module Samagotchi
82
82
  end
83
83
 
84
84
  def on_turn_canceled(event)
85
- log(:info, :turn_canceled, ms: since(@turn_started_at), reason: event[:cancellation_reason], **origin(event))
85
+ log(:info, :turn_canceled, ms: since(@turn_started_at), reason: event[:cancellation_reason], by: event[:cancelled_by],
86
+ **origin(event))
86
87
  end
87
88
 
88
89
  def on_turn_failed(event)
@@ -100,7 +101,8 @@ module Samagotchi
100
101
  def on_generation_completed(event)
101
102
  ms = since(@generation_started_at.delete(event[:iteration]))
102
103
  log(:info, :generation_completed, iteration: event[:iteration], ms: ms,
103
- served_model: event[:served_model], requested_model: event[:requested_model],
104
+ served_model: event[:served_model], provider: event[:served_provider],
105
+ requested_model: event[:requested_model],
104
106
  content_length: event[:content_length],
105
107
  thinking_chars: event[:thinking_chars], finish_reason: event[:finish_reason],
106
108
  stopped_by: event[:stopped_by])
@@ -41,7 +41,7 @@ module Samagotchi
41
41
  end
42
42
 
43
43
  def run
44
- target_dir = resolve_target_dir(@scope)
44
+ target_dir = MemoryPaths.scope_dir(@scope) or raise BuildError, "invalid scope: #{@scope}"
45
45
  resolved_name = @name || default_name(@scope)
46
46
  resolved_version = @version || DEFAULT_VERSION
47
47
 
@@ -333,10 +333,6 @@ module Samagotchi
333
333
  base
334
334
  end
335
335
 
336
- def resolve_target_dir(scope)
337
- MemoryPaths.scope_dir(scope) or raise BuildError, "invalid scope: #{scope}"
338
- end
339
-
340
336
  def resolve_out_path(out_arg, default_name)
341
337
  if out_arg.nil? || out_arg.empty?
342
338
  # Default: <name>.zip in Dir.pwd
@@ -51,6 +51,22 @@ module Samagotchi
51
51
  # none moved).
52
52
  def trash_dir = @trash.dir
53
53
 
54
+ # The normalized source dir, kept alive past #run when an upgrade has
55
+ # conflicts (the agent step reads the incoming files); nil once cleaned.
56
+ attr_reader :source_dir
57
+
58
+ # Removes the normalized source dir an owned source (git/zip/tar) was
59
+ # extracted to. #run defers this when it found conflicts, so the
60
+ # conflict prompt and resolve_conflicts can still read the incoming
61
+ # files; the caller runs it once the agent step is done. Idempotent.
62
+ def cleanup_source!
63
+ return unless @defer_cleanup && @normalized_dir
64
+
65
+ SourceNormalizer.cleanup(@normalized_dir)
66
+ @normalized_dir = nil
67
+ @defer_cleanup = false
68
+ end
69
+
54
70
  def run
55
71
  normalized_dir = nil
56
72
  source_owned = false
@@ -59,6 +75,7 @@ module Samagotchi
59
75
  begin
60
76
  normalized_dir, source_owned, source_commit = SourceNormalizer.normalize(@source)
61
77
  @bundle_dir = normalized_dir
78
+ @normalized_dir = normalized_dir
62
79
  manifest = Manifest.read(dir: normalized_dir)
63
80
  rescue SourceNormalizer::UnknownSourceError => e
64
81
  raise InstallError, "Source normalization failed: #{e.message}"
@@ -73,7 +90,7 @@ module Samagotchi
73
90
  # Determine target scope (CLI wins over manifest).
74
91
  cli_scope = @scope.to_s.strip.empty? ? nil : @scope
75
92
  target_scope = cli_scope || manifest&.scope || "system"
76
- target_dir = resolve_target_dir(target_scope)
93
+ target_dir = MemoryPaths.scope_dir(target_scope) or raise InstallError, "invalid scope: #{target_scope}"
77
94
  FileUtils.mkdir_p(target_dir)
78
95
 
79
96
  # Copy .md files and update index for each.
@@ -199,12 +216,15 @@ module Samagotchi
199
216
  end
200
217
 
201
218
  FileUtils.mkdir_p(hooks_target)
219
+ unchanged = File.exist?(dest) && FileUtils.identical?(src, dest)
202
220
  FileUtils.cp(src, dest)
203
- if @upgrade && existing_provenance
204
- @results[basename] = { status: "updated" }
205
- else
206
- @results[basename] = { status: "installed" }
207
- end
221
+ @results[basename] = if unchanged
222
+ { status: "skipped", reason: "already up to date" }
223
+ elsif @upgrade && existing_provenance
224
+ { status: "updated" }
225
+ else
226
+ { status: "installed" }
227
+ end
208
228
  hooks_files_for_provenance[basename] = dest
209
229
  end
210
230
  else
@@ -406,7 +426,17 @@ module Samagotchi
406
426
 
407
427
  [normalized_dir, manifest]
408
428
  ensure
409
- SourceNormalizer.cleanup(normalized_dir) if source_owned
429
+ # An owned source (git/zip/tar) is cleaned up here, unless an upgrade
430
+ # kept conflicts: the agent step still needs the incoming files, so
431
+ # the caller runs #cleanup_source! when it is done.
432
+ if source_owned
433
+ if @conflicts.any? && !@dry_run
434
+ @defer_cleanup = true
435
+ else
436
+ SourceNormalizer.cleanup(normalized_dir)
437
+ @normalized_dir = nil
438
+ end
439
+ end
410
440
  end
411
441
 
412
442
  def summary
@@ -541,11 +571,18 @@ module Samagotchi
541
571
  return nil
542
572
  end
543
573
 
574
+ dest = File.join(provenance.plugin_dir, plugin[:file])
575
+ unchanged = File.file?(dest) && FileUtils.identical?(src, dest)
544
576
  FileUtils.rm_rf(provenance.plugin_dir)
545
577
  FileUtils.mkdir_p(provenance.plugin_dir)
546
- dest = File.join(provenance.plugin_dir, plugin[:file])
547
578
  FileUtils.cp(src, dest)
548
- @results[plugin[:file]] = { status: @upgrade && provenance.read ? "updated" : "installed" }
579
+ @results[plugin[:file]] = if unchanged
580
+ { status: "skipped", reason: "already up to date" }
581
+ elsif @upgrade && provenance.read
582
+ { status: "updated" }
583
+ else
584
+ { status: "installed" }
585
+ end
549
586
  expected = manifest.checksum_for_plugin
550
587
  actual = Digest::SHA256.hexdigest(File.binread(dest))
551
588
  if @strict && expected && actual != expected
@@ -598,10 +635,6 @@ module Samagotchi
598
635
  @warnings << "index.md: line for #{entry_name} not updated (#{e.message})"
599
636
  end
600
637
  end
601
-
602
- def resolve_target_dir(scope)
603
- MemoryPaths.scope_dir(scope) or raise InstallError, "invalid scope: #{scope}"
604
- end
605
638
  end
606
639
  end
607
640
  end
@@ -9,6 +9,7 @@ require_relative "provenance"
9
9
  require_relative "source"
10
10
  require_relative "status"
11
11
  require_relative "system_bundle"
12
+ require_relative "../memory_paths"
12
13
  require_relative "../version"
13
14
 
14
15
  module Samagotchi
@@ -112,6 +113,7 @@ module Samagotchi
112
113
  return skip(row, "needs chi #{manifest.requires_chi}", keep_to: true)
113
114
  end
114
115
  return skip(row, "project scope: chi bundle upgrade #{ship.source} in the project") if row.scope == "project"
116
+ return skip(row, "unknown scope #{row.scope}: upgrade chi or reinstall") unless MemoryPaths.scope_dir(row.scope)
115
117
 
116
118
  row.kept = conflicts(row, data)
117
119
  row.replaced = edited_executables(name, data)
@@ -147,7 +149,7 @@ module Samagotchi
147
149
  # version (a same-name file it didn't install is skipped, not merged).
148
150
  def conflicts(row, data)
149
151
  provenance = Provenance.new(name: row.name)
150
- target_dir = Status.resolve_target_dir(row.scope)
152
+ target_dir = MemoryPaths.scope_dir!(row.scope)
151
153
  return [] unless data[:files].is_a?(Hash)
152
154
 
153
155
  owned = data[:files].keys.map(&:to_s)
@@ -11,6 +11,9 @@ require_relative "../model_overlay"
11
11
  module Samagotchi
12
12
  module MemoryBundle
13
13
  module Status
14
+ # A scope this chi can't resolve (a newer chi's, a hand edit) isn't
15
+ # guessed: scope_error says so, target_dir is nil and each file is
16
+ # unchecked (no disk reads, nothing counted as missing).
14
17
  def self.bundle_status(name, scope_override: nil)
15
18
  provenance = Provenance.new(name: name)
16
19
  data = provenance.read
@@ -18,11 +21,13 @@ module Samagotchi
18
21
 
19
22
  raw_scope = scope_override || data[:scope]&.to_s
20
23
  scope = (raw_scope.nil? || raw_scope.strip.empty?) ? "system" : raw_scope
21
- target_dir = resolve_target_dir(scope)
24
+ target_dir = MemoryPaths.scope_dir(scope)
22
25
  files = data[:files] || {}
23
26
  details = {}
24
27
  files.each do |file_key, meta|
25
28
  file_key_str = file_key.to_s
29
+ next details[file_key_str] = unchecked_file(meta) unless target_dir
30
+
26
31
  target_path = File.join(target_dir, file_key_str)
27
32
  base_path = provenance.base_path(file_key_str)
28
33
  stored_checksum = meta[:checksum] || meta["checksum"]
@@ -46,7 +51,8 @@ module Samagotchi
46
51
  base_path: base_path
47
52
  }
48
53
  end
49
- { provenance: data, scope: scope, target_dir: target_dir, files: details, plugin: plugin_status(provenance, data),
54
+ { provenance: data, scope: scope, scope_error: target_dir ? nil : "unknown scope: #{scope}",
55
+ target_dir: target_dir, files: details, plugin: plugin_status(provenance, data),
50
56
  hooks_requires_failure: hooks_requires_failure(data), needs: needs_status(data) }
51
57
  end
52
58
 
@@ -91,9 +97,11 @@ module Samagotchi
91
97
  requires_failure: Manifest.requires_chi_failure(data[:requires_chi], Samagotchi::VERSION) }
92
98
  end
93
99
 
94
- def self.resolve_target_dir(scope)
95
- MemoryPaths.scope_dir(scope) || MemoryPaths.system_dir
100
+ def self.unchecked_file(meta)
101
+ { conflict: meta.is_a?(Hash) && meta[:conflict] == true, stored_checksum: meta.is_a?(Hash) ? meta[:checksum] || meta["checksum"] : nil,
102
+ unchecked: true, modified: false, missing: false, index_present: nil, overlay: false }
96
103
  end
104
+ private_class_method :unchecked_file
97
105
 
98
106
  # Index lines name the entry without ".md" (Installer#update_target_index,
99
107
  # memory_write); a legacy "name.md" line counts too.
@@ -39,7 +39,7 @@ module Samagotchi
39
39
  cli_scope = @scope.to_s.strip.empty? ? nil : @scope
40
40
  raw = cli_scope || data[:scope]&.to_s
41
41
  target_scope = (raw.nil? || raw.strip.empty?) ? "system" : raw
42
- target_dir = resolve_target_dir(target_scope)
42
+ target_dir = MemoryPaths.scope_dir(target_scope) or raise UninstallError, "invalid scope: #{target_scope}"
43
43
 
44
44
  files = data[:files] || {}
45
45
  hooks = data[:hooks] || {}
@@ -129,10 +129,6 @@ module Samagotchi
129
129
  rescue StandardError => e
130
130
  @warnings << "index.md: line for #{file_key.delete_suffix(".md")} not updated (#{e.message})"
131
131
  end
132
-
133
- def resolve_target_dir(scope)
134
- MemoryPaths.scope_dir(scope) or raise UninstallError, "invalid scope: #{scope}"
135
- end
136
132
  end
137
133
  end
138
134
  end
@@ -112,6 +112,12 @@ module Samagotchi
112
112
  end
113
113
  end
114
114
 
115
+ # scope_dir, or ArgumentError ("invalid scope: team") for a scope it
116
+ # doesn't know: for a caller that must not guess a folder.
117
+ def scope_dir!(scope, env: ENV, cwd: Dir.pwd)
118
+ scope_dir(scope, env: env, cwd: cwd) or raise ArgumentError, "invalid scope: #{scope}"
119
+ end
120
+
115
121
  def bundles_dir(env: ENV)
116
122
  File.join(system_dir(env: env), ".bundles")
117
123
  end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "net/http"
5
+ require "uri"
4
6
  require_relative "version"
5
7
  require_relative "config"
6
8
  require_relative "context_window"
@@ -11,6 +13,8 @@ require_relative "model_overlay"
11
13
  require_relative "thinking"
12
14
  require_relative "served_model"
13
15
  require_relative "host_registry"
16
+ require_relative "llm/http"
17
+ require_relative "llm/api_key"
14
18
  require_relative "tools/memory"
15
19
  require_relative "hooks/loader"
16
20
  require_relative "memory_bundle/provenance"
@@ -194,7 +198,9 @@ module Samagotchi
194
198
 
195
199
  # chi self's one server call: a single-model llama.cpp answers any name
196
200
  # with the model it loaded, and its /props names it (model_alias). One
197
- # GET with the probe's short timeouts; a remote host isn't asked.
201
+ # GET with the probe's short timeouts; a remote host isn't asked. A local
202
+ # chat host (api: openai) has no /props, so it gets a GET <base>/models
203
+ # with the same timeouts: up/down and the ids it serves.
198
204
  def served_model_for(model, env)
199
205
  registry = HostRegistry.new(env: env)
200
206
  target = registry.resolve(model)
@@ -202,7 +208,7 @@ module Samagotchi
202
208
  bare = target.bare_model
203
209
  return "-" unless entry
204
210
  return "reported per turn (remote host)" if entry.remote?
205
- return NO_PROPS if entry.chat?
211
+ return chat_served_model_for(entry, bare, env) if entry.chat?
206
212
 
207
213
  client = registry.client_for(entry)
208
214
  props = client.server_props(model: bare)
@@ -217,6 +223,36 @@ module Samagotchi
217
223
  "unknown (#{e.class})"
218
224
  end
219
225
 
226
+ # A local chat host's reachability: one GET <base>/models with the
227
+ # native probe's short timeouts (Client's /props probe), no retry. Up
228
+ # with the ids it serves, down when it doesn't answer.
229
+ def chat_served_model_for(entry, bare, env)
230
+ uri = URI("#{entry.openai_base_url}/models")
231
+ request = Net::HTTP::Get.new(uri)
232
+ http = LLM::HTTP.new(label: entry.name, open_timeout: Client::CONTEXT_WINDOW_PROBE_OPEN_TIMEOUT,
233
+ read_timeout: Client::CONTEXT_WINDOW_PROBE_READ_TIMEOUT,
234
+ retry_policy: LLM::HTTP::RetryPolicy.none,
235
+ api_key: LLM::ApiKey.for(entry.api_key_env, host: entry.host, env: env))
236
+ response = http.fetch(uri, request, retries: false, check_status: false, log_fields: { purpose: "models" })
237
+ return "down (HTTP #{response.code})" unless response.code.to_s == "200"
238
+
239
+ ids = chat_model_ids(response.body)
240
+ return "up (no models listed)" if ids.empty?
241
+
242
+ note = ids.any? { |id| id.casecmp?(bare.to_s) } ? "" : " (not #{bare})"
243
+ "up: #{ids.join(", ")}#{note}"
244
+ rescue StandardError
245
+ "down (the server didn't answer; is it running?)"
246
+ end
247
+
248
+ def chat_model_ids(body)
249
+ parsed = JSON.parse(body.to_s)
250
+ data = parsed.is_a?(Hash) ? parsed["data"] : parsed
251
+ Array(data).filter_map { |raw| raw.is_a?(Hash) ? (raw["id"] || raw[:id]).to_s : raw.to_s }.reject(&:empty?)
252
+ rescue JSON::ParserError
253
+ []
254
+ end
255
+
220
256
  # Offline, so no /props probe: where nothing is configured, a native
221
257
  # llama.cpp host's chat template decides at runtime.
222
258
  def profile_for(model, env)
@@ -5,6 +5,7 @@ require "json"
5
5
  require "securerandom"
6
6
  require "time"
7
7
  require_relative "atomic_file"
8
+ require_relative "context_note"
8
9
 
9
10
  require_relative "paths"
10
11
  require_relative "project_scope"
@@ -413,27 +414,55 @@ module Samagotchi
413
414
 
414
415
  # Derive and cache the first user-message preview in the session record.
415
416
  # Returns true if the cached value was set or updated.
417
+ # A session with only context notes so far previews by its first note
418
+ # ("note: …"), until a message is typed.
416
419
  def compute_first_preview!
417
- return false if @first_preview && !@first_preview.empty?
420
+ from_note = note_preview
421
+ return false if @first_preview && !@first_preview.empty? && @first_preview != from_note
418
422
 
419
423
  first_user = @messages.find { |m| m[:role].to_s == "user" || m["role"].to_s == "user" }
420
- return false unless first_user
421
-
422
- preview = self.class.preview_of(first_user[:content] || first_user["content"])
423
- return false if preview.empty?
424
+ preview = first_user ? self.class.preview_of(first_user[:content] || first_user["content"]) : from_note.to_s
425
+ return false if preview.empty? || preview == @first_preview
424
426
 
425
427
  @first_preview = preview
426
428
  true
427
429
  end
428
430
 
431
+ # The web's annotation labels (annotations.js sourceLabel), each on its
432
+ # own line above a quote.
433
+ QUOTE_LABEL_RE = /\AFrom (?:your thinking|your earlier step|the .{1,80} call|my earlier message):\z/
434
+
429
435
  # A prompt as a one-line preview: whitespace collapsed, cut at 80 chars.
436
+ # A quoted or annotated message previews by what was typed under the
437
+ # quote; a quote alone by its words, without the "> " markers or label.
430
438
  def self.preview_of(text)
431
- norm = text.to_s.gsub(/\s+/, " ").strip
439
+ norm = preview_words(text.to_s).gsub(/\s+/, " ").strip
432
440
  norm.length > 80 ? "#{norm[0, 80]}…" : norm
433
441
  end
434
442
 
443
+ def self.preview_words(text)
444
+ lines = text.lines.map(&:strip)
445
+ quoted = lines.select { |line| line.start_with?(">") }
446
+ return text if quoted.empty?
447
+
448
+ own = lines.reject { |line| line.start_with?(">") || QUOTE_LABEL_RE.match?(line) }.join("\n")
449
+ return own unless own.strip.empty?
450
+
451
+ quoted.map { |line| line.sub(/\A(?:>[ \t]?)+/, "") }.join("\n")
452
+ end
453
+ private_class_method :preview_words
454
+
435
455
  private
436
456
 
457
+ # @return [String, nil] the first context note as a preview
458
+ def note_preview
459
+ note = @messages.find { |m| ContextNote.note?(m) }
460
+ return nil unless note
461
+
462
+ text = self.class.preview_of(ContextNote.text_of(note))
463
+ text.empty? ? nil : self.class.preview_of("note: #{text}")
464
+ end
465
+
437
466
  def stringify_message_keys(hash)
438
467
  hash.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
439
468
  end
@@ -234,6 +234,9 @@ module Samagotchi
234
234
  rules = @engine.guardrail_rules
235
235
  approvals = @engine.guardrail_approvals.entries
236
236
  lines = ["guardrails: #{rules.enabled? ? "on" : "off (guardrails.enabled: false; denies still apply)"}"]
237
+ lines << if rules.mode == "strict" then "mode: strict (every rule votes)"
238
+ else "mode: auto (rules marked strict only don't vote; guardrails.mode: strict in config.yml for those)"
239
+ end
237
240
  name = @engine.guardrail_model_name
238
241
  key = @engine.model_key
239
242
  lines << "model: #{name || "none"} — #{Guardrails::ModelSize.describe(name, key)}"
@@ -253,9 +256,14 @@ module Samagotchi
253
256
  ("command /#{shorten_pattern(rule.command.source)}/" if rule.command),
254
257
  ("path #{rule.path}" if rule.path),
255
258
  ("git #{rule.git}" if rule.git),
256
- ("models #{rule.models.join(",")}" if rule.models)
259
+ ("touches #{rule.touches}" if rule.touches),
260
+ ("rm #{rule.rm}" if rule.rm),
261
+ ("models #{rule.models.join(",")}" if rule.models),
262
+ ("modes #{rule.modes.join(",")}" if rule.modes),
263
+ ("skip_read_only" if rule.skip_read_only)
257
264
  ].compact.join(", ")
258
265
  off = "disabled (guardrails.disable) — " if rules.disabled?(rule)
266
+ off ||= "(#{rule.modes.join(", ")} only) — " unless rule.for_mode?(rules.mode)
259
267
  off ||= "off for this model — " unless rule.for_model?(name, key)
260
268
  lines << " #{idx + 1}. #{rule.id}: #{off}#{rule.verdict} (#{match}) — #{rule.reason} [#{rule.source}]"
261
269
  end
@@ -277,7 +285,10 @@ module Samagotchi
277
285
 
278
286
  def approval_line(entry)
279
287
  what = entry["key"] ? entry["key"].tr("\n", " ") : "any call rule #{entry["rule"]} asks about"
280
- where = entry["scope"] == "session" ? "session #{entry["session_id"].to_s[0, 8]}" : "in #{entry["repo_root"]}"
288
+ where = if entry["scope"] == "session" then "session #{entry["session_id"].to_s[0, 8]}"
289
+ elsif entry["repo"] then "in repo #{entry["repo"]}"
290
+ else "in #{entry["repo_root"]}"
291
+ end
281
292
  rule = entry["rule"] ? " (rule #{entry["rule"]}, #{entry["source"]})" : ""
282
293
  "#{entry["scope"]}: #{what} — #{where}#{rule}, #{entry["created_at"]}"
283
294
  end
@@ -24,6 +24,7 @@ require_relative "installed_gem"
24
24
  require_relative "installed_versions"
25
25
  require_relative "recap_store"
26
26
  require_relative "archive_store"
27
+ require_relative "continue_offer"
27
28
  require_relative "image_store"
28
29
  require_relative "plugin_session_state"
29
30
  require_relative "terminal_ui"
@@ -105,6 +106,10 @@ module Samagotchi
105
106
  case @reason
106
107
  when :scratch then "a scratch session is deleted when you leave; nothing to archive"
107
108
  when :busy_child then "its delegate #{@busy_id[0, 8]} is running a turn; wait for it or stop it first"
109
+ when :queued then "a prompt is queued, which a resume would run; let it run first"
110
+ when :queued_child then "its delegate #{@busy_id[0, 8]} has a prompt queued, which a resume would run; let it run first"
111
+ when :offer then "the step-limit question waits for an answer; answer Continue or Stop first"
112
+ when :offer_child then "its delegate #{@busy_id[0, 8]} waits for an answer to its step-limit question; answer it first"
108
113
  else "a turn is running; wait for it or cancel it first"
109
114
  end
110
115
  end
@@ -309,8 +314,9 @@ module Samagotchi
309
314
  # discarded: empty sessions their stopping worker deleted
310
315
  # @raise [ArgumentError] unknown id (Session::AmbiguousId for a prefix of several)
311
316
  # @raise [OwnedByTUI] a chi REPL owns it or one of its children
312
- # @raise [ArchiveRefused] a turn runs in it or in a child, or it is a
313
- # scratch session
317
+ # @raise [ArchiveRefused] a turn runs in it or in a child, a prompt is
318
+ # queued in one (a later resume would run it), a live one offers to
319
+ # continue past the step limit, or it is a scratch session
314
320
  def self.archive_session(id_or_prefix, state_dir: nil, wait: 5)
315
321
  sd = state_dir || Session.default_state_dir
316
322
  id = archive_target(id_or_prefix, sd)
@@ -319,13 +325,8 @@ module Samagotchi
319
325
  tree = [id, *descendant_ids(id, sd)]
320
326
  owners = tree.to_h { |sid| [sid, session_owner(sid, state_dir: sd)] }
321
327
  tree.each do |sid|
322
- owner = owners[sid]
323
- next unless owner
324
-
325
- raise OwnedByTUI, sid if owner.tui?
326
- next unless Session.load(sid, state_dir: sd).status == Session::STATUS_RUNNING
327
-
328
- raise ArchiveRefused.new(id, sid == id ? :busy : :busy_child, busy_id: sid)
328
+ refusal = archive_refusal(sid, owners[sid], sd)
329
+ raise ArchiveRefused.new(id, sid == id ? refusal : :"#{refusal}_child", busy_id: sid) if refusal
329
330
  end
330
331
 
331
332
  stopped = tree.select { |sid| owners[sid] }
@@ -334,6 +335,22 @@ module Samagotchi
334
335
  { id: id, archived: archived, stopped: stopped, discarded: discarded }
335
336
  end
336
337
 
338
+ # What keeps +sid+ (in an archive's tree) from being archived: :busy (a
339
+ # turn runs), :queued (input/ holds a prompt, which outlives the stop and
340
+ # runs on a resume, live worker or not), :offer (a live worker's continue
341
+ # offer, which the stop would drop), or nil.
342
+ # @raise [OwnedByTUI] a chi REPL owns it
343
+ def self.archive_refusal(sid, owner, state_dir)
344
+ raise OwnedByTUI, sid if owner&.tui?
345
+
346
+ session = owner && Session.load(sid, state_dir: state_dir)
347
+ return :busy if session&.status == Session::STATUS_RUNNING
348
+ return :queued unless SessionInbox.find_new_input_files(Session.session_dir(sid, state_dir: state_dir)).empty?
349
+
350
+ :offer if session&.waiting_question(live: true)&.dig(:kind) == ContinueOffer::KIND
351
+ end
352
+ private_class_method :archive_refusal
353
+
337
354
  # Unarchive a session and its delegated children.
338
355
  # @return [Hash] {id:, unarchived: [ids that were archived]}
339
356
  # @raise [ArgumentError] unknown id