samagotchi 0.143.0 → 0.145.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +71 -1
  3. data/docs/bundle-authoring.md +1 -1
  4. data/docs/cli.md +21 -4
  5. data/docs/configuration.md +1 -1
  6. data/docs/guardrails.md +6 -3
  7. data/docs/hooks.md +5 -1
  8. data/docs/internals/bridge.md +2 -1
  9. data/docs/internals/bundles.md +1 -1
  10. data/docs/internals/engine.md +1 -1
  11. data/docs/internals/web-frontend.md +59 -2
  12. data/docs/plugins.md +4 -2
  13. data/docs/releasing.md +3 -2
  14. data/docs/testing.md +1 -1
  15. data/lib/samagotchi/bridge/sse_writer.rb +11 -1
  16. data/lib/samagotchi/bundle_command/check.rb +13 -2
  17. data/lib/samagotchi/bundle_settings_check.rb +34 -1
  18. data/lib/samagotchi/bundles/check-in/manifest.yml +4 -4
  19. data/lib/samagotchi/bundles/check-in/plugin.rb +13 -6
  20. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/cost-cap/manifest.yml +4 -4
  22. data/lib/samagotchi/bundles/cost-cap/plugin.rb +4 -1
  23. data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/github-pr/plugin.rb +4 -2
  25. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +4 -2
  26. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  28. data/lib/samagotchi/bundles/work-cap/manifest.yml +4 -4
  29. data/lib/samagotchi/bundles/work-cap/plugin.rb +17 -8
  30. data/lib/samagotchi/context_status.rb +1 -1
  31. data/lib/samagotchi/engine.rb +17 -7
  32. data/lib/samagotchi/kernel_loop.rb +7 -0
  33. data/lib/samagotchi/live_versions.rb +28 -2
  34. data/lib/samagotchi/llm/chat_loop.rb +5 -0
  35. data/lib/samagotchi/llm/utf8_scrub.rb +5 -3
  36. data/lib/samagotchi/plugin/api.rb +2 -2
  37. data/lib/samagotchi/plugin/context.rb +3 -2
  38. data/lib/samagotchi/plugin/peer.rb +1 -1
  39. data/lib/samagotchi/plugin_session_state.rb +6 -3
  40. data/lib/samagotchi/self_report.rb +15 -13
  41. data/lib/samagotchi/session_access/local.rb +7 -10
  42. data/lib/samagotchi/session_commands.rb +2 -1
  43. data/lib/samagotchi/system_one/answer.rb +8 -5
  44. data/lib/samagotchi/system_one/client.rb +17 -0
  45. data/lib/samagotchi/system_one/http_backend.rb +23 -10
  46. data/lib/samagotchi/testing/boundary.rb +76 -0
  47. data/lib/samagotchi/testing/env_not_shown.rb +17 -0
  48. data/lib/samagotchi/testing.rb +1 -0
  49. data/lib/samagotchi/update_command.rb +8 -7
  50. data/lib/samagotchi/version.rb +1 -1
  51. data/lib/samagotchi/web/public/annotate_bar.js +1 -1
  52. data/lib/samagotchi/web/public/app.js +15 -3
  53. data/lib/samagotchi/web/public/chip_popover.js +14 -4
  54. data/lib/samagotchi/web/public/composer_fold.js +5 -2
  55. data/lib/samagotchi/web/public/composer_grip.js +45 -2
  56. data/lib/samagotchi/web/public/copy.js +1 -1
  57. data/lib/samagotchi/web/public/index.html +79 -40
  58. data/lib/samagotchi/web/public/info_bar_model.js +6 -4
  59. data/lib/samagotchi/web/public/more_sheet.js +212 -0
  60. data/lib/samagotchi/web/public/question_cards.js +26 -3
  61. data/lib/samagotchi/web/public/ref_scan.js +4 -2
  62. data/lib/samagotchi/web/public/stage_frame.js +58 -13
  63. data/lib/samagotchi/web/public/stage_model.js +2 -1
  64. data/lib/samagotchi/web/public/stage_view.js +52 -20
  65. data/lib/samagotchi/web/public/turn_block.js +43 -9
  66. data/lib/samagotchi/worker.rb +21 -4
  67. metadata +3 -1
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: github-pr
3
- version: 0.6.1
3
+ version: 0.6.2
4
4
  scope: system
5
5
  description: Proposes the branch's open GitHub pull request to a session, attached once you mention it (#N, the branch or its URL), and attaches any PR URL with chi context add or the web's "+ URL"; the agent gets a note when reviews, comments, checks or its state change; in the web, a path:line in an answer links to that line of the PR; the auto_attach setting can attach the PR at once instead, or do nothing
6
6
  trust_level: reviewed
@@ -11,7 +11,7 @@ needs:
11
11
  hint: brew install gh, then gh auth login
12
12
  plugin:
13
13
  file: plugin.rb
14
- sha256: sha256:558bd4191f28b7c54d4521f55e8514a63568d5e2df5a4937856821fd88de508f
14
+ sha256: sha256:114e999c49d4d427b7570e3f4bec8db5565ee0dd1a99f2f5ec3000748a1aec3a
15
15
  scripts:
16
16
  pr_context.rb: sha256:a883d2780e89c68c0d7b6d5e0fcb22c273f17bb167e79064e0e99db6bd2ee6f0
17
17
  context_providers:
@@ -501,8 +501,10 @@ class LineLinker
501
501
  QUICK = /\S:\d/
502
502
  URL_SPAN = %r{[a-z][a-z0-9+.-]*://\S+}i
503
503
  MARKDOWN_LINK = /\[[^\]]*\]\([^)]*\)/
504
- # A fenced code block's opening line (up to 3 spaces, then ``` or ~~~).
505
- FENCE_OPEN = /\A {0,3}(`{3,}|~{3,})/
504
+ # A fenced code block's opening line (up to 3 spaces, then ``` or ~~~). A
505
+ # backtick fence's info string carries no backtick: "```foo``` first" is
506
+ # inline code, and as a fence it would stay open over the rest of the text.
507
+ FENCE_OPEN = /\A {0,3}(`{3,}(?![^\n]*`)|~{3,})/
506
508
 
507
509
  # The content of the last model message, or nil (string or symbol keys).
508
510
  def self.last_model_text(messages)
@@ -86,8 +86,10 @@ class SourceLinks
86
86
  URL_SPAN = %r{[a-z][a-z0-9+.-]*://\S+}i
87
87
  TRAILING_URL_PUNCTUATION = /[.,;:!?'"]+\z/
88
88
  MARKDOWN_LINK = /\[([^\]]*)\]\(([^)]*)\)/
89
- # A fenced code block's opening line (up to 3 spaces, then ``` or ~~~).
90
- FENCE_OPEN = /\A {0,3}(`{3,}|~{3,})/
89
+ # A fenced code block's opening line (up to 3 spaces, then ``` or ~~~). A
90
+ # backtick fence's info string carries no backtick: "```foo``` first" is
91
+ # inline code, and as a fence it would stay open over the rest of the text.
92
+ FENCE_OPEN = /\A {0,3}(`{3,}(?![^\n]*`)|~{3,})/
91
93
  # A `{word}` or `{N}` placeholder in a `url:` template.
92
94
  PLACEHOLDER = /\{(\w+)\}/
93
95
  # Placeholders every pattern has: {match} (group 1, else the whole ref),
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: source-links
3
- version: 0.6.1
3
+ version: 0.6.2
4
4
  scope: system
5
5
  description: Links source refs (JIRA tickets, GitHub issues, …) in the model's answer — inline links in the web, refs in a waiting question's reply, and a one-line note after the turn (note false turns it off); sources are configured patterns
6
6
  trust_level: reviewed
@@ -9,7 +9,7 @@ files:
9
9
  source_links.md: sha256:ace01a0f7c308a0ae54ece714c31c0e6d7a49c372d98111030e6d268320bed3a
10
10
  hooks:
11
11
  source_links.rb:
12
- sha256: sha256:84c0bef064fb1ea76749a61e758eba612662aaf0f05f18a18ab4228497078fa2
12
+ sha256: sha256:7e3fafb843f1a9006b1a42f4ec647b41a358d814515cd0be73b60eb08b898992
13
13
  events: [after_turn, question_asked]
14
14
  on_error: log
15
15
  priority: 90
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.143.0
3
+ version: 0.145.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -1,15 +1,15 @@
1
1
  ---
2
2
  name: work-cap
3
- version: 0.1.4
3
+ version: 0.1.5
4
4
  scope: system
5
5
  description: "A per-session work cap (tool calls, active minutes) across turns: at a cap the turn pauses on Continue (another cap's worth) / Stop with the session's totals, a warning at 80% goes to the user only; /workcap shows or sets it"
6
6
  trust_level: reviewed
7
7
  plugin:
8
8
  file: plugin.rb
9
- sha256: sha256:d2c61b0aa26775c7b9783681fec4e5759766ad01e92f78ed88743a52ff5a1495
9
+ sha256: sha256:31cf59a518a7fe3f51efc72f3bb213a0f053d6577aa81a7f47eaac12c5caa07a
10
10
  # ask_user's effects: are in chi 0.92.0; ctx.usage is new in the next chi
11
- # ctx.session_dir is new in the next chi
12
- requires_chi: ">= 0.143.0"
11
+ # ctx.session_dir is new in 0.143.0
12
+ requires_chi: ">= 0.144.0"
13
13
  # chi --set work-cap.tool_calls=400 / work-cap.minutes=60: the session's own caps, a number or off
14
14
  session_settings: {tool_calls: number, minutes: number}
15
15
  # config.yml bundles: work-cap: (another key warns; an older chi ignores this)
@@ -142,7 +142,7 @@ class Plugin
142
142
  cap = meter_cap[:cap]
143
143
  next unless used[meter] >= share * cap && first_warning?(meter, [resolved[:session], cap])
144
144
 
145
- ctx.notify("#{count(used[meter])} of #{count(cap)} #{unit(meter)} (#{(used[meter] / cap * 100).floor}%)",
145
+ ctx.notify("#{count(used[meter])} of #{counted(cap, meter)} (#{(used[meter] / cap * 100).floor}%)",
146
146
  level: :warn)
147
147
  end
148
148
  end
@@ -196,7 +196,7 @@ class Plugin
196
196
  save_meter(ctx, meter, "cap" => cap, "step" => reached[meter][:step] || reached[meter][:cap])
197
197
  end
198
198
  ctx.notify("#{raised.size == 1 ? "cap" : "caps"} raised to " \
199
- "#{raised.map { |meter, cap| "#{count(cap)} #{unit(meter)}" }.join(" and ")}")
199
+ "#{raised.map { |meter, cap| counted(cap, meter) }.join(" and ")}")
200
200
  else
201
201
  stop(ctx, reached, usage, used)
202
202
  end
@@ -204,21 +204,21 @@ class Plugin
204
204
 
205
205
  # "412 tool calls of 400", and "61 active minutes of 60" for the other.
206
206
  def reached_text(reached, used)
207
- reached.map { |meter, meter_cap| "#{count(used[meter])} #{unit(meter)} of #{count(meter_cap[:cap])}" }.join(", ")
207
+ reached.map { |meter, meter_cap| "#{counted(used[meter], meter)} of #{count(meter_cap[:cap])}" }.join(", ")
208
208
  end
209
209
 
210
210
  # "it to 800", or "them to 800 tool calls and 120 active minutes".
211
211
  def raised_text(raised)
212
212
  return "it to #{count(raised.values.first)}" if raised.size == 1
213
213
 
214
- "them to #{raised.map { |meter, cap| "#{count(cap)} #{unit(meter)}" }.join(" and ")}"
214
+ "them to #{raised.map { |meter, cap| counted(cap, meter) }.join(" and ")}"
215
215
  end
216
216
 
217
217
  # The session's totals a reader decides on, the reached meters left out
218
218
  # (already named): turns, tool calls, active minutes and the spend.
219
219
  def totals(ctx, usage, used, skip = [])
220
220
  parts = ["#{usage.turns} #{usage.turns == 1 ? "turn" : "turns"}"]
221
- parts << "#{count(used["tool_calls"])} tool calls" unless skip.include?("tool_calls")
221
+ parts << counted(used["tool_calls"], "tool_calls") unless skip.include?("tool_calls")
222
222
  parts << "#{active_text(used["minutes"])} active" unless skip.include?("minutes")
223
223
  cost = ctx.cost
224
224
  parts << format("$%.2f", cost.total) if cost.known? && cost.total.positive?
@@ -268,7 +268,7 @@ class Plugin
268
268
  return USAGE unless value
269
269
 
270
270
  save_meter(ctx, meter, "cap" => value, "step" => value)
271
- "work-cap: #{unit(meter)} cap set to #{count(value)} (#{count(used(ctx, ctx.usage)[meter])} so far)"
271
+ "work-cap: cap set to #{counted(value, meter)} (#{count(used(ctx, ctx.usage)[meter])} so far)"
272
272
  rescue StandardError => e
273
273
  ctx.log.warn(:workcap_command_failed, error: "#{e.class}: #{e.message}")
274
274
  "work-cap: #{e.message}"
@@ -281,12 +281,18 @@ class Plugin
281
281
  return "work-cap: no cap (#{totals(ctx, usage, used)})" if meters.values.none? { |meter_cap| meter_cap[:cap] }
282
282
 
283
283
  capped = meters.select { |_meter, meter_cap| meter_cap[:cap] }
284
- caps = capped.map { |meter, meter_cap| "#{count(used[meter])} of #{count(meter_cap[:cap])} #{unit(meter)}" }
284
+ caps = capped.map { |meter, meter_cap| "#{count(used[meter])} of #{counted(meter_cap[:cap], meter)}" }
285
285
  "work-cap: #{caps.join(", ")} (#{totals(ctx, usage, used, capped.keys)})"
286
286
  end
287
287
 
288
288
  def unit(meter) = meter == "tool_calls" ? "tool calls" : "active minutes"
289
289
 
290
+ # "412 tool calls", "1 tool call".
291
+ def counted(value, meter)
292
+ number = count(value)
293
+ "#{number} #{number == "1" ? unit(meter).chomp("s") : unit(meter)}"
294
+ end
295
+
290
296
  # The session's caps now: per meter the state file's, else the session's
291
297
  # own value (--set), else child:'s for a delegate, else default:'s.
292
298
  # Cached per session id and settings (a config change resolves again).
@@ -310,7 +316,7 @@ class Plugin
310
316
  def state(ctx, session)
311
317
  return [{}, nil] unless session
312
318
 
313
- path = state_file(ctx)
319
+ path = saved_file(ctx)
314
320
  saved = JSON.parse(File.read(path))
315
321
  raise JSON::ParserError, "not a JSON object" unless saved.is_a?(Hash)
316
322
 
@@ -412,6 +418,9 @@ class Plugin
412
418
 
413
419
  def state_file(ctx) = File.join(ctx.session_dir, "state.json")
414
420
 
421
+ # For a read: a session with nothing saved keeps no folder.
422
+ def saved_file(ctx) = File.join(ctx.session_dir(create: false), "state.json")
423
+
415
424
  def count(value) = self.class.count(value)
416
425
 
417
426
  def first_warning?(meter, key)
@@ -299,7 +299,7 @@ module Samagotchi
299
299
 
300
300
  def bucket_rank(bucket) = self.class.bucket_rank(bucket, @thresholds)
301
301
 
302
- # A rise (never a fall or a cadence tick) into a bucket whose guidance
302
+ # A rise (never a fall) into a bucket whose guidance
303
303
  # asks for a change. With no previous bucket (a first turn, a resumed
304
304
  # session with no line yet), the first bucket counts as a rise from 0.
305
305
  def guidance_due?(previous, bucket)
@@ -1288,11 +1288,21 @@ module Samagotchi
1288
1288
  # Hooks are turn-scoped: they are automatically cleared after each
1289
1289
  # `run_turn` call so that a single turn's hooks do not leak into the next.
1290
1290
  #
1291
+ # A hook that raises tells the user once, as a bundle's does
1292
+ # (HookRoutes#plugin_hook_failed); the registry logs each failure and goes on.
1293
+ #
1291
1294
  # @param name [Symbol] one of the hook names (see Hooks module)
1292
1295
  # @param block [Proc] receives an event hash (may mutate in place)
1293
1296
  # @return [void]
1294
- def register_hook(name, &)
1295
- @hooks.register(name, &)
1297
+ def register_hook(name, &block)
1298
+ return @hooks.register(name) unless block
1299
+
1300
+ @hooks.register(name) do |event|
1301
+ yield(event)
1302
+ rescue StandardError => e
1303
+ @hook_routes.plugin_hook_failed("#{Hooks::Registry::TURN_HOOK_LABEL} (#{name})", "#{e.class}: #{e.message}")
1304
+ raise
1305
+ end
1296
1306
  end
1297
1307
 
1298
1308
  # Unregister a previously registered hook.
@@ -2945,12 +2955,12 @@ module Samagotchi
2945
2955
  end
2946
2956
 
2947
2957
  # config.yml bundles: names and keys that do nothing (BundleSettingsCheck),
2948
- # held like the judgments' line, for the same reason.
2958
+ # held like the judgments' line, for the same reason. Logged by every
2959
+ # Engine; shown by the first to find them (BundleSettingsCheck.unshown).
2949
2960
  def warn_bundle_settings
2950
- BundleSettingsCheck.warnings(@extension_load.bundle_settings).each do |line|
2951
- Log.warn(:config, "bundle_setting_unknown", msg: line)
2952
- hook_notify(line, :warn, "config")
2953
- end
2961
+ lines = BundleSettingsCheck.warnings(@extension_load.bundle_settings)
2962
+ lines.each { |line| Log.warn(:config, "bundle_setting_unknown", msg: line) }
2963
+ BundleSettingsCheck.unshown(lines).each { |line| hook_notify(line, :warn, "config") }
2954
2964
  end
2955
2965
  private :warn_bundle_settings
2956
2966
 
@@ -734,6 +734,13 @@ module Samagotchi
734
734
  response = request.prefill + generation.response.to_s
735
735
  answer = -> { PromptLiteralGuard.restore(strip_thought_blocks(response), profile: @profile) }
736
736
  empty = strip_thought_blocks(response.to_s).strip.empty?
737
+ # A Stop that lands on an empty reply is no empty answer: the empty
738
+ # generation goes, as it does below, and the turn ends cancelled.
739
+ if empty && turn.cancel_controller&.cancelled?
740
+ turn.empty_steps << turn.conversation.pop
741
+ raise Client::RequestCancelled, turn.cancel_controller.reason
742
+ end
743
+
737
744
  retry_empty = empty && turn.empty_retry.retry_empty?(iteration: turn.iteration,
738
745
  cancelled: turn.cancel_controller&.cancelled?,
739
746
  finish_reason: generation.finish_reason,
@@ -1,21 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "config"
3
4
  require_relative "session"
4
5
  require_relative "version"
5
6
  require_relative "worker_link"
6
7
  require_relative "web/info_probe"
8
+ require_relative "web/running"
7
9
 
8
10
  module Samagotchi
9
11
  # Which chi versions the running processes run, for `chi update`: session
10
12
  # workers (their bridge.json sidecar names the version since chi update exists; an
11
- # older one names none) and a `chi web` on its port (/api/info). Read-only:
12
- # a dead worker's sidecar is left for the next client to clean up.
13
+ # older one names none) and a `chi web` on its port (/api/info): also
14
+ # chi self's and chi share's lookup of that chi web. Read-only: a dead
15
+ # worker's sidecar is left for the next client to clean up.
13
16
  module LiveVersions
14
17
  WEB_TIMEOUT = 0.5
18
+ # chi web's port when config names none: Web::Server::DEFAULT_PORT,
19
+ # without loading chi web.
20
+ DEFAULT_WEB_PORT = 4567
15
21
 
16
22
  # version is nil for a sidecar written before sidecars carried one;
17
23
  # restart: the worker takes `chi sessions restart` (its "restart" feature).
18
24
  Worker = Struct.new(:session_id, :version, :restart, keyword_init: true)
25
+ # The port a chi web answers on and its /api/info.
26
+ WebAnswer = Data.define(:port, :info)
19
27
 
20
28
  module_function
21
29
 
@@ -41,5 +49,23 @@ module Samagotchi
41
49
  info = Web::InfoProbe.call(host, port, timeout: timeout)
42
50
  info.is_a?(Hash) ? info : nil
43
51
  end
52
+
53
+ # Config's web.port, or the default for none that is a port.
54
+ def configured_web_port
55
+ port = Config.get("web.port").to_s.to_i
56
+ port.positive? ? port : DEFAULT_WEB_PORT
57
+ end
58
+
59
+ # The chi web on this machine: on the port the running one recorded (a
60
+ # --port it was started with is not config's), else on +configured+.
61
+ # @param path [String] its record (Web::Running.path)
62
+ # @return [WebAnswer, nil] nil with none
63
+ def web_answering(host, configured, path:, timeout: WEB_TIMEOUT)
64
+ [Web::Running.port(path: path), configured].compact.uniq.each do |port|
65
+ info = web_info(host, port, timeout: timeout)
66
+ return WebAnswer.new(port: port, info: info) if info
67
+ end
68
+ nil
69
+ end
44
70
  end
45
71
  end
@@ -677,6 +677,11 @@ module Samagotchi
677
677
  "could not read, twice in a row; nothing was run", host: host)
678
678
  end
679
679
  end
680
+ # A Stop that lands on an empty reply is no empty answer either:
681
+ # nothing more is asked because the turn is stopping. (A refusal
682
+ # the Stop landed on stays what the provider said.)
683
+ return canceled(iteration, @cancel_controller.reason) if empty && !stopped && @cancel_controller&.cancelled?
684
+
680
685
  # Kept before a merge too: the model answers the merged line
681
686
  # knowing what it just said.
682
687
  @conversation << with_thinking({ role: "model", content: last_text }, response) unless empty || stray
@@ -8,8 +8,9 @@ module Samagotchi
8
8
  # `JSON#to_json` raises `JSON::GeneratorError` on such input, which would
9
9
  # abort the whole turn, so a request body is scrubbed first: only
10
10
  # offending bytes are replaced with "?", every valid UTF-8 string passes
11
- # through untouched. Non-UTF-8 encodings are left alone because
12
- # `#to_json` already handles them without raising.
11
+ # through untouched. A binary string is read as UTF-8 first (JSON raises
12
+ # on its high bytes too); other encodings are left alone because
13
+ # `#to_json` converts them without raising.
13
14
  module Utf8Scrub
14
15
  module_function
15
16
 
@@ -18,7 +19,8 @@ module Samagotchi
18
19
  def call(obj)
19
20
  case obj
20
21
  when String
21
- obj.encoding == Encoding::UTF_8 && !obj.valid_encoding? ? obj.scrub("?") : obj
22
+ text = obj.encoding == Encoding::BINARY ? obj.dup.force_encoding(Encoding::UTF_8) : obj
23
+ text.encoding == Encoding::UTF_8 && !text.valid_encoding? ? text.scrub("?") : text
22
24
  when Array
23
25
  obj.map { |element| call(element) }
24
26
  when Hash
@@ -54,8 +54,8 @@ module Samagotchi
54
54
  "ctx.context.decline" => "0.43.0", "ctx.context.declined(url:)" => "0.43.0", "ctx.context.attach(force:)" => "0.43.0",
55
55
  "ctx.ask_user(effects:)" => "0.86.0", "chi.toolkit(description:)" => "0.122.0", "ctx.review" => "0.140.0",
56
56
  "ctx.context.propose" => "0.140.0", "ctx.context.candidates" => "0.140.0", "ctx.context.withdraw" => "0.140.0",
57
- "ctx.continues" => "0.141.0", "ctx.context.left_behind" => "0.141.0", "ctx.context.attach(movable:)" => "0.141.0"
58
- # Unreleased (moved up, with its version, at the release: docs/releasing.md): "ctx.session_dir".
57
+ "ctx.continues" => "0.141.0", "ctx.context.left_behind" => "0.141.0", "ctx.context.attach(movable:)" => "0.141.0",
58
+ "ctx.session_dir" => "0.143.0", "ctx.session_dir(create:)" => "0.144.0"
59
59
  }.freeze
60
60
 
61
61
  # @param bundle [String]
@@ -304,10 +304,11 @@ module Samagotchi
304
304
  # This session's own folder under #data_dir, created on use and read
305
305
  # now (a continue moves the id on). chi removes it with the session:
306
306
  # the layout is core's (PluginSessionState).
307
+ # @param create [Boolean] false for a read: the path, with no folder made
307
308
  # @return [String, nil] nil with no session yet
308
- def session_dir
309
+ def session_dir(create: true)
309
310
  id = session_id
310
- id && PluginSessionState.dir(data_dir, id)
311
+ id && PluginSessionState.dir(data_dir, id, create: create)
311
312
  end
312
313
 
313
314
  # The conversation so far, a frozen copy, without the system prompt.
@@ -313,7 +313,7 @@ module Samagotchi
313
313
  end
314
314
 
315
315
  def last_send(ctx)
316
- path = File.join(ctx.session_dir, LAST_SEND)
316
+ path = File.join(ctx.session_dir(create: false), LAST_SEND)
317
317
  File.file?(path) ? JSON.parse(File.read(path)) : nil
318
318
  rescue JSON::ParserError, SystemCallError
319
319
  nil
@@ -21,15 +21,18 @@ module Samagotchi
21
21
  # A session's folder under a bundle's +data_dir+, created. A
22
22
  # sessions/<id>.json there moves in as STATE_FILE, so a running session
23
23
  # keeps it when its bundle moves to ctx.session_dir.
24
+ # @param create [Boolean] false for a read: only names the folder, so a
25
+ # session with nothing saved leaves none (the older file still moves in)
24
26
  # @return [String, nil] nil for an id that isn't a plain one (it could
25
27
  # name a path outside the dir)
26
- def dir(data_dir, session_id)
28
+ def dir(data_dir, session_id, create: true)
27
29
  id = session_id.to_s
28
30
  return nil unless Session.valid_id?(id)
29
31
 
30
32
  path = File.join(data_dir, "sessions", id)
31
- FileUtils.mkdir_p(path)
32
- adopt("#{path}.json", File.join(path, STATE_FILE))
33
+ older = "#{path}.json"
34
+ FileUtils.mkdir_p(path) if create || File.file?(older)
35
+ adopt(older, File.join(path, STATE_FILE))
33
36
  path
34
37
  end
35
38
 
@@ -25,6 +25,7 @@ require_relative "memory_bundle/entry_size"
25
25
  require_relative "desktop"
26
26
  require_relative "live_versions"
27
27
  require_relative "web/lan"
28
+ require_relative "web/running"
28
29
  require_relative "chi_home"
29
30
  require_relative "home_notice"
30
31
  require_relative "project_env"
@@ -33,8 +34,8 @@ module Samagotchi
33
34
  # `chi self`: where this chi lives and what it is configured to use.
34
35
  #
35
36
  # Read-only and nearly offline (one short /props GET for the served model,
36
- # nothing else asks a model server; one /api/info GET on this machine's
37
- # chi web port), so the agent can run it via `execute`
37
+ # nothing else asks a model server; /api/info GETs for this machine's
38
+ # chi web), so the agent can run it via `execute`
38
39
  # to orient itself — source dir to rg, config path, memory dirs, sessions,
39
40
  # model/host, bundle versions — without guessing from $PATH.
40
41
  module SelfReport
@@ -77,22 +78,23 @@ module Samagotchi
77
78
  ["context window", context_window(env, model)],
78
79
  ["bundles", bundles_summary],
79
80
  ["desktop", desktop_summary(env)],
80
- ["chi web", web_summary]
81
+ ["chi web", web_summary(env)]
81
82
  ]
82
83
  end
83
84
 
84
85
  WEB_TIMEOUT = 0.3
85
86
 
86
- # Whether a chi web answers on this machine's web port, and whether a
87
- # phone can reach it (LAN mode: /api/info's lan).
88
- def web_summary
89
- port = Config.get("web.port").to_i
90
- port = 4567 unless port.positive?
91
- info = LiveVersions.web_info(Web::Lan.local_host(Config.get("web.host")), port, timeout: WEB_TIMEOUT)
92
- return "not running on port #{port}" unless info
93
- return "LAN on #{info["lan"]}:#{port} (and 127.0.0.1)" if info["lan"]
94
-
95
- "on 127.0.0.1:#{port} (this machine only)"
87
+ # Whether a chi web answers on this machine (LiveVersions.web_answering:
88
+ # the port it recorded, else config's), and whether a phone can reach it
89
+ # (LAN mode: /api/info's lan).
90
+ def web_summary(env = ENV)
91
+ configured = LiveVersions.configured_web_port
92
+ answer = LiveVersions.web_answering(Web::Lan.local_host(Config.get("web.host")), configured,
93
+ path: Web::Running.path(env: env), timeout: WEB_TIMEOUT)
94
+ return "not running on port #{configured}" unless answer
95
+ return "LAN on #{answer.info["lan"]}:#{answer.port} (and 127.0.0.1)" if answer.info["lan"]
96
+
97
+ "on 127.0.0.1:#{answer.port} (this machine only)"
96
98
  end
97
99
 
98
100
  # "git checkout" when running from a repo (bin/chi), "installed gem" when
@@ -22,6 +22,7 @@ require_relative "../review_start"
22
22
  require_relative "../launch_mode"
23
23
  require_relative "../participants"
24
24
  require_relative "../config"
25
+ require_relative "../live_versions"
25
26
  require_relative "../web/grants"
26
27
  require_relative "../web/info_probe"
27
28
  require_relative "../web/public_url"
@@ -453,8 +454,6 @@ module Samagotchi
453
454
 
454
455
  def grants = @grants ||= Web::Grants.new(Web::Grants.path(state_dir: state_dir!))
455
456
 
456
- # Web::Server::DEFAULT_PORT, without loading chi web.
457
- WEB_PORT = 4567
458
457
  LOCAL_PROBE_TIMEOUT = 1.0
459
458
 
460
459
  # Where this machine's chi web answers (GET /api/info): its LAN ip
@@ -491,14 +490,12 @@ module Samagotchi
491
490
  end
492
491
 
493
492
  def local_web
494
- configured = Config.get("web.port").to_s.to_i
495
- configured = WEB_PORT unless configured.positive?
496
- recorded = Web::Running.port(path: Web::Running.path(state_dir: state_dir!))
497
- [recorded, configured].compact.uniq.each do |port|
498
- info = Web::InfoProbe.call("127.0.0.1", port, timeout: LOCAL_PROBE_TIMEOUT)
499
- return LocalWeb.new(ip: own_address(info["lan"]), port: port) if info.is_a?(Hash)
500
- end
501
- LocalWeb.new(ip: nil, port: configured)
493
+ configured = LiveVersions.configured_web_port
494
+ answer = LiveVersions.web_answering("127.0.0.1", configured, path: Web::Running.path(state_dir: state_dir!),
495
+ timeout: LOCAL_PROBE_TIMEOUT)
496
+ return LocalWeb.new(ip: nil, port: configured) unless answer
497
+
498
+ LocalWeb.new(ip: own_address(answer.info["lan"]), port: answer.port)
502
499
  end
503
500
 
504
501
  def dir = @state_dir ? { state_dir: @state_dir } : {}
@@ -512,7 +512,8 @@ module Samagotchi
512
512
  mode = Config.get("guardrails.system_one.mode").to_s
513
513
  return if mode == "off" && entries.empty?
514
514
 
515
- lines << "System 1 (#{mode}):"
515
+ no_judgment = SystemOne::Client.no_judgment_reason(host_registry: @engine.host_registry, mode: mode)
516
+ lines << (no_judgment ? "System 1 (#{mode}, but nothing is judged: #{no_judgment}):" : "System 1 (#{mode}):")
516
517
  auto_allows = system_one_auto_allows(entries)
517
518
  lines << " auto-allows (this session, newest first; /guardrails wrong N):"
518
519
  if auto_allows.empty?
@@ -20,7 +20,9 @@ module Samagotchi
20
20
  # not usable: no type, a type the contract doesn't have, a noul outside
21
21
  # 0..1, a score that isn't a number or past the last level, … A nil means
22
22
  # "no judgment for this question", never a guess.
23
- def self.from_wire(id, raw)
23
+ # @param levels [Integer, nil] how many levels the question declared: a
24
+ # bare score, with no legend or probabilities, is checked against it
25
+ def self.from_wire(id, raw, levels: nil)
24
26
  return nil unless raw.is_a?(Hash)
25
27
 
26
28
  type = raw["type"].to_s.strip.downcase.to_sym
@@ -46,7 +48,7 @@ module Samagotchi
46
48
  legend = levels(raw["legend"])
47
49
  return nil if bad_field(raw, "legend", legend)
48
50
 
49
- score = level_score(raw["score"], legend, odds)
51
+ score = level_score(raw["score"], legend, odds, levels)
50
52
  score.nil? ? nil : new(**answer, score: score, level: top_level(legend, odds))
51
53
  end
52
54
  end
@@ -100,14 +102,15 @@ module Samagotchi
100
102
  end
101
103
 
102
104
  # The score as sent: the weighted level index, a whole number kept whole.
103
- # Not a number, or past the last level the response names, is no answer.
104
- private_class_method def self.level_score(value, legend, odds)
105
+ # Not a number, or past the last level the response names (or else the
106
+ # question declared), is no answer.
107
+ private_class_method def self.level_score(value, legend, odds, levels)
105
108
  return nil unless value.is_a?(Numeric)
106
109
 
107
110
  score = value.to_f
108
111
  return nil if score.nan? || score.infinite? || score.negative?
109
112
 
110
- count = legend&.size || (odds.is_a?(Array) ? odds.size : odds&.size)
113
+ count = legend&.size || (odds.is_a?(Array) ? odds.size : odds&.size) || levels
111
114
  # A hair past the last level is the probabilities' own rounding, not a
112
115
  # level out of range.
113
116
  return nil if count && score > count - 1 + 1e-9
@@ -60,6 +60,23 @@ module Samagotchi
60
60
  mode: mode, host: host, model: model.empty? ? nil : model, tag: tag)
61
61
  end
62
62
 
63
+ # Why an ask under +mode+ would get no judgment whatever the host does,
64
+ # from the settings alone (no request); nil when one would be asked for,
65
+ # and under off, which promises none (`/guardrails`).
66
+ # @return [String, nil]
67
+ def self.no_judgment_reason(host_registry:, mode:)
68
+ return nil if mode.to_s.strip.downcase == "off"
69
+ return "guardrails.system_one still sets a key that moved to system_one" if moved_keys_left?
70
+
71
+ client = from_config(host_registry: host_registry, mode: mode)
72
+ return "system_one.host is not set" unless client
73
+
74
+ client.backend.target
75
+ nil
76
+ rescue UnsupportedHost => e
77
+ e.message
78
+ end
79
+
63
80
  # system_one.timeout_ms, else the default: the whole
64
81
  # request's limit, and how long a turn waits for an answer (toolkit
65
82
  # suggestion's on mode).
@@ -47,15 +47,7 @@ module Samagotchi
47
47
  # @raise [BackendError] the request failed, the status isn't 200, or
48
48
  # the body isn't a usable response
49
49
  def ask(state:, questions:, cancel_controller: nil, observer: nil)
50
- entry = @host_registry.find_entry(@host)
51
- raise UnsupportedHost, "host #{@host.inspect} is not in hosts:" unless entry
52
- unless entry.api&.to_sym == :openai
53
- raise UnsupportedHost, "host #{@host} has api: #{entry.api || "llama_cpp"}; System One speaks the OpenAI-compatible API"
54
- end
55
-
56
- model = @model || host_model(entry)
57
- raise UnsupportedHost, "host #{@host} names no model for System One" if model.nil? || model.empty?
58
-
50
+ entry, model = target
59
51
  observer&.call(model: model)
60
52
  url = "#{entry.openai_base_url.to_s.chomp("/")}/systemone"
61
53
  questions = SystemOne.normalize_questions(questions)
@@ -72,7 +64,28 @@ module Samagotchi
72
64
  data = reply_of(response.body)
73
65
  answers = data["answers"]
74
66
  observer&.call(served_model: data["model"], raw: answers, usage: data["usage"])
75
- questions.to_h { |question| [question.id, Answer.from_wire(question.id, answers[question.id])] }
67
+ questions.to_h do |question|
68
+ levels = question.criteria.size if question.type == :score
69
+ [question.id, Answer.from_wire(question.id, answers[question.id], levels: levels)]
70
+ end
71
+ end
72
+
73
+ # The hosts: entry and model an ask would go to, without asking: all
74
+ # read from the settings, no request.
75
+ # @return [Array(HostRegistry::Entry, String)]
76
+ # @raise [UnsupportedHost] the host is unknown, not an openai host, or
77
+ # names no model
78
+ def target
79
+ entry = @host_registry.find_entry(@host)
80
+ raise UnsupportedHost, "host #{@host.inspect} is not in hosts:" unless entry
81
+ unless entry.api&.to_sym == :openai
82
+ raise UnsupportedHost, "host #{@host} has api: #{entry.api || "llama_cpp"}; System One speaks the OpenAI-compatible API"
83
+ end
84
+
85
+ model = @model || host_model(entry)
86
+ raise UnsupportedHost, "host #{@host} names no model for System One" if model.nil? || model.empty?
87
+
88
+ [entry, model]
76
89
  end
77
90
 
78
91
  private