samagotchi 0.141.0 → 0.142.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 (88) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -1
  3. data/README.md +1 -1
  4. data/bin/chi +2 -0
  5. data/docs/architecture.md +12 -283
  6. data/docs/bundle-authoring.md +1 -0
  7. data/docs/cli.md +15 -7
  8. data/docs/configuration.md +57 -18
  9. data/docs/context.md +2 -2
  10. data/docs/guardrails.md +20 -11
  11. data/docs/hooks.md +9 -7
  12. data/docs/internals/attached-context.md +226 -0
  13. data/docs/internals/background-tasks.md +1 -1
  14. data/docs/internals/bridge.md +380 -0
  15. data/docs/internals/bundles.md +1 -0
  16. data/docs/internals/context-telemetry.md +1 -1
  17. data/docs/internals/engine.md +24 -18
  18. data/docs/internals/participants.md +13 -12
  19. data/docs/internals/remote-sessions.md +1 -1
  20. data/docs/internals/{tool-guardrails.md → tool-output-limits.md} +21 -2
  21. data/docs/memory.md +31 -1
  22. data/docs/plugins.md +122 -1
  23. data/docs/sessions.md +50 -26
  24. data/docs/system-one.md +122 -0
  25. data/docs/testing.md +3 -1
  26. data/lib/samagotchi/bridge.rb +2 -0
  27. data/lib/samagotchi/bundles/check-in/manifest.yml +6 -3
  28. data/lib/samagotchi/bundles/check-in/plugin.rb +101 -4
  29. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  31. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  32. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  33. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  34. data/lib/samagotchi/config.rb +74 -14
  35. data/lib/samagotchi/context_candidate_choice.rb +8 -0
  36. data/lib/samagotchi/context_candidates_log.rb +25 -1
  37. data/lib/samagotchi/context_judge.rb +6 -2
  38. data/lib/samagotchi/engine.rb +68 -7
  39. data/lib/samagotchi/extension_load.rb +3 -2
  40. data/lib/samagotchi/guardrail_wiring.rb +36 -31
  41. data/lib/samagotchi/guardrails/chi_dirs.rb +7 -4
  42. data/lib/samagotchi/guardrails/path_resolve.rb +1 -1
  43. data/lib/samagotchi/guardrails/protected_paths.rb +7 -4
  44. data/lib/samagotchi/guardrails_command.rb +3 -3
  45. data/lib/samagotchi/judgments_command.rb +155 -0
  46. data/lib/samagotchi/kernel_loop.rb +13 -1
  47. data/lib/samagotchi/llm/chat_loop.rb +74 -10
  48. data/lib/samagotchi/llm/errors.rb +7 -5
  49. data/lib/samagotchi/llm/stray_tool_call.rb +61 -0
  50. data/lib/samagotchi/log_line.rb +1 -1
  51. data/lib/samagotchi/memory_bundle/entry_size.rb +100 -0
  52. data/lib/samagotchi/plugin/api.rb +33 -2
  53. data/lib/samagotchi/plugin/context.rb +49 -1
  54. data/lib/samagotchi/plugin/judgments.rb +72 -0
  55. data/lib/samagotchi/plugin/loader.rb +3 -2
  56. data/lib/samagotchi/run_options.rb +1 -0
  57. data/lib/samagotchi/self_report.rb +36 -0
  58. data/lib/samagotchi/session_access/contract.rb +1 -1
  59. data/lib/samagotchi/session_commands.rb +3 -0
  60. data/lib/samagotchi/session_manager.rb +13 -2
  61. data/lib/samagotchi/session_retention.rb +10 -0
  62. data/lib/samagotchi/side_judgments.rb +12 -1
  63. data/lib/samagotchi/system_one/answer.rb +63 -11
  64. data/lib/samagotchi/system_one/approval_label.rb +56 -0
  65. data/lib/samagotchi/system_one/approval_question.rb +2 -0
  66. data/lib/samagotchi/system_one/client.rb +43 -17
  67. data/lib/samagotchi/system_one/fake_backend.rb +1 -1
  68. data/lib/samagotchi/system_one/http_backend.rb +12 -4
  69. data/lib/samagotchi/system_one/judgment_settings.rb +61 -0
  70. data/lib/samagotchi/system_one/judgments.rb +260 -0
  71. data/lib/samagotchi/system_one/log.rb +4 -2
  72. data/lib/samagotchi/system_one/recording.rb +117 -0
  73. data/lib/samagotchi/system_one/runs.rb +179 -0
  74. data/lib/samagotchi/system_one/runs_store.rb +230 -0
  75. data/lib/samagotchi/system_one.rb +9 -0
  76. data/lib/samagotchi/testing/bundle.rb +9 -2
  77. data/lib/samagotchi/testing/host.rb +2 -1
  78. data/lib/samagotchi/testing/judgments.rb +185 -0
  79. data/lib/samagotchi/testing/recorder.rb +5 -0
  80. data/lib/samagotchi/testing/recording_api.rb +1 -1
  81. data/lib/samagotchi/tool_output_cut.rb +26 -6
  82. data/lib/samagotchi/tool_response.rb +1 -1
  83. data/lib/samagotchi/tool_runner.rb +63 -4
  84. data/lib/samagotchi/toolkit_suggest.rb +11 -2
  85. data/lib/samagotchi/tools/memory.rb +28 -0
  86. data/lib/samagotchi/turn_note.rb +9 -0
  87. data/lib/samagotchi/version.rb +1 -1
  88. metadata +16 -2
@@ -169,6 +169,7 @@ module Samagotchi
169
169
  @chat_backend = nil
170
170
  @chat_backend_mutex = Mutex.new
171
171
  @side_judgments = SideJudgments.new
172
+ @judgments = build_judgments
172
173
  @context_judge_after_turn = nil
173
174
  # On mode's cards an answer brought while a turn ran: shown once it
174
175
  # has ended (#show_context_cards_after_turn).
@@ -431,7 +432,11 @@ module Samagotchi
431
432
  # System 1 (experimental, audit phase): the judgment client is read
432
433
  # from config on every ask, and the running turn's cancel controller
433
434
  # closes a judgment that is no longer wanted.
434
- system_one: -> { SystemOne::Client.from_config(host_registry: @host_registry) },
435
+ system_one: lambda {
436
+ system_one_recording(SystemOne::Client.from_config(host_registry: @host_registry, mode: Config.get("guardrails.system_one.mode")),
437
+ SystemOne::ApprovalQuestion::JUDGMENT, @session)
438
+ },
439
+ system_one_runs: -> { system_one_runs },
435
440
  cancel_controller: -> { active_cancel_controller },
436
441
  # A participant's turn: their name on the owner's card, their
437
442
  # grant's mode for the turn's (GuardrailWiring#turn_mode).
@@ -2924,13 +2929,24 @@ module Samagotchi
2924
2929
  tool_index: @tool_index,
2925
2930
  peer_sources: @peer_sources,
2926
2931
  prompt_sections: @prompt_sections,
2927
- prompt_changed: -> { prompt_changed! }
2932
+ prompt_changed: -> { prompt_changed! },
2933
+ judgments: @judgments
2928
2934
  )
2929
2935
  # What plugins show while they load (an MCP server that didn't
2930
2936
  # start) waits for the first turn, beside the load warnings: no UI
2931
2937
  # is there yet, and the Engine isn't built.
2932
- @extension_load.load_plugins(registries, failures: @plugin_failures)
2938
+ @extension_load.load_plugins(registries, failures: @plugin_failures) { warn_undeclared_judgments }
2939
+ end
2940
+
2941
+ # Held with the plugins' own notices, so every UI gets it: a worker's
2942
+ # stderr goes nowhere.
2943
+ def warn_undeclared_judgments
2944
+ @judgments.undeclared.each do |name|
2945
+ Log.warn(:config, "judgment_undeclared", judgment: name)
2946
+ hook_notify("system_one.judgments.#{name}: no loaded bundle declares it, so it does nothing", :warn, "config")
2947
+ end
2933
2948
  end
2949
+ private :warn_undeclared_judgments
2934
2950
 
2935
2951
  # Keep a plugin's new tool set (chi.replace_tools, from any thread)
2936
2952
  # for the turn thread (PluginTasks#stage_tools).
@@ -3055,7 +3071,8 @@ module Samagotchi
3055
3071
  return
3056
3072
  end
3057
3073
 
3058
- client = SystemOne::Client.from_config(host_registry: @host_registry, mode: "on", tag: :toolkit)
3074
+ client = system_one_recording(SystemOne::Client.from_config(host_registry: @host_registry, mode: "on", tag: :toolkit),
3075
+ ToolkitSuggest::JUDGMENT, session, mode: Config.get("toolkit.suggest.mode").to_s)
3059
3076
  return unless client
3060
3077
 
3061
3078
  # D7: on a shared single-slot server audit's free ask would compete
@@ -3091,7 +3108,9 @@ module Samagotchi
3091
3108
  state: { project: state[:project].dup.freeze, prompt: state[:prompt].dup.freeze }.freeze
3092
3109
  )
3093
3110
  threshold = Config.get("toolkit.suggest.threshold")
3111
+ runs = session.scratch ? nil : system_one_runs
3094
3112
  pending = ToolkitSuggest::Pending.new(log: log, joined: joined,
3113
+ acted: runs && ->(judged, did) { runs.acted(judged.fields[:run_id], did) },
3095
3114
  timeout_seconds: SystemOne::Client.configured_timeout_ms / 1000.0)
3096
3115
  # On mode: cut by a Stop while the turn waits (Pending#wait), not
3097
3116
  # by one after it went on; audit's ask is never cut.
@@ -3124,7 +3143,7 @@ module Samagotchi
3124
3143
  # slow System 1 host (a local one) is the usual cause.
3125
3144
  timeout_ms = SystemOne::Client.configured_timeout_ms
3126
3145
  Log.warn(:toolkit, "toolkit_suggest_timeout", session_id: turn.session.id, timeout_ms: timeout_ms,
3127
- reason: "System 1 gave no answer within guardrails.system_one.timeout_ms")
3146
+ reason: "System 1 gave no answer within system_one.timeout_ms")
3128
3147
  @toolkit_binding.fell_back(turn.session, "System 1 took over #{timeout_ms} ms")
3129
3148
  return
3130
3149
  end
@@ -3210,6 +3229,46 @@ module Samagotchi
3210
3229
  end
3211
3230
  private :toolkit_suggestion_log
3212
3231
 
3232
+ # +client+ (nil for none) saving its asks as +judgment+'s runs. Anything
3233
+ # going wrong here leaves the client as it was: a run is never worth a
3234
+ # judgment.
3235
+ #
3236
+ # A scratch session's asks aren't saved; a participant's turn is never
3237
+ # judged, so it never comes here.
3238
+ # @param mode [String, nil] the use's mode, for its runs (Recording.wrap)
3239
+ def system_one_recording(client, judgment, session, mode: nil)
3240
+ return client if client.nil? || session&.scratch
3241
+
3242
+ context = SystemOne::Recording::Context.of(session, session_model: session && effective_model_name)
3243
+ SystemOne::Recording.wrap(client, judgment: judgment, runs: system_one_runs, context: context, recorded_mode: mode)
3244
+ rescue StandardError => e
3245
+ Log.warn(:system_one, "system_one_recording_failed", error: e.class.name, msg: e.message.to_s[0, 200])
3246
+ client
3247
+ end
3248
+ private :system_one_recording
3249
+
3250
+ # Bundles' judgments (chi.judgment), on this Engine's Runs and side asks.
3251
+ # A judgment's own host and model are for bundles only: core's clients
3252
+ # above never pass them (D2, JudgmentSettings).
3253
+ def build_judgments
3254
+ SystemOne::Judgments.new(
3255
+ client_for: lambda { |_name, settings|
3256
+ SystemOne::Client.from_config(host_registry: @host_registry, mode: settings.mode, host: settings.host,
3257
+ model: settings.client_model, tag: :plugins)
3258
+ },
3259
+ runs: -> { @session&.scratch ? SystemOne::Runs.new : system_one_runs },
3260
+ where: -> { @session && SystemOne::Recording::Context.of(@session, session_model: effective_model_name) },
3261
+ side: @side_judgments,
3262
+ shared_host_busy: ->(host) { turn_running? && system_one_shares_local_host?(host) },
3263
+ participant: -> { !turn_author.nil? }
3264
+ )
3265
+ end
3266
+ private :build_judgments
3267
+
3268
+ # Where this Engine's runs are saved; read each time, as the settings are.
3269
+ def system_one_runs = SystemOne::Runs.for_sessions_dir(session_state_dir)
3270
+ private :system_one_runs
3271
+
3213
3272
  # D7: System 1's host is the session's model server, and a local one
3214
3273
  # (a single-slot Splash or llama.cpp): the same name, or two hosts:
3215
3274
  # entries for one server root (scheme, host, port).
@@ -3270,7 +3329,8 @@ module Samagotchi
3270
3329
  parent_attached: context_parent_attached(session))
3271
3330
  return if jobs.empty?
3272
3331
 
3273
- client = SystemOne::Client.from_config(host_registry: @host_registry, mode: "on", tag: :context)
3332
+ client = system_one_recording(SystemOne::Client.from_config(host_registry: @host_registry, mode: "on", tag: :context),
3333
+ ContextJudge::JUDGMENT, session, mode: settings.mode)
3274
3334
  return unless client
3275
3335
 
3276
3336
  choice = context_choice(session, log, settings)
@@ -3451,7 +3511,8 @@ module Samagotchi
3451
3511
  turn_author: -> { turn_author },
3452
3512
  # A plugin's prompt section is in the stable part: a full re-read.
3453
3513
  cache_confirm: ->(line, changed) { CacheConfirm.ask(line: line, cost: :full, changed: changed, engine: self) },
3454
- candidate_proposed: ->(_candidate) { judge_late_candidates }
3514
+ candidate_proposed: ->(_candidate) { judge_late_candidates },
3515
+ judgments: -> { @judgments }
3455
3516
  )
3456
3517
  end
3457
3518
 
@@ -43,11 +43,12 @@ module Samagotchi
43
43
  end
44
44
 
45
45
  # Load the installed bundles' plugins into +registries+; one that fails
46
- # goes to +failures+, and the rest still load.
46
+ # goes to +failures+, and the rest still load. The block runs after them,
47
+ # still loading: what it shows is held with theirs.
47
48
  def load_plugins(registries, failures:)
48
49
  @held_events = []
49
50
  @loading = true
50
- Plugin::Loader.load_installed(registries, failures: failures, settings: bundle_settings)
51
+ Plugin::Loader.load_installed(registries, failures: failures, settings: bundle_settings).tap { yield if block_given? }
51
52
  ensure
52
53
  @loading = false
53
54
  end
@@ -44,12 +44,15 @@ module Samagotchi
44
44
  # (a bundle's rules kept because it needs a newer chi)
45
45
  # @param system_one [#call] → SystemOne::Client or nil: the judgment client
46
46
  # (nil while `guardrails.system_one.mode` is off or no host is set)
47
+ # @param system_one_runs [#call] → SystemOne::Runs or nil: where labels
48
+ # and what an ask led to are saved
47
49
  # @param cancel_controller [#call] → the running turn's CancellationController, nil
48
50
  # @param participant [#call] (author) → the participant's
49
51
  # Participants::Entry (participants.json: their name, their grant's
50
52
  # mode), nil for none
51
53
  def initialize(scratch:, hooks:, tools:, session:, model_key:, cancelled:, ask:, model_name: -> {},
52
- notify: ->(_message) {}, system_one: -> {}, cancel_controller: -> {}, participant: ->(_author) {})
54
+ notify: ->(_message) {}, system_one: -> {}, system_one_runs: -> {}, cancel_controller: -> {},
55
+ participant: ->(_author) {})
53
56
  @scratch = scratch
54
57
  @hooks_lookup = hooks
55
58
  @tools_lookup = tools
@@ -60,6 +63,7 @@ module Samagotchi
60
63
  @ask = ask
61
64
  @notify = notify
62
65
  @system_one = system_one
66
+ @system_one_runs = system_one_runs
63
67
  @cancel_controller = cancel_controller
64
68
  @participant = participant
65
69
  @turn_mode = nil
@@ -273,6 +277,7 @@ module Samagotchi
273
277
  config_path: Samagotchi::ConfigFile.global_path,
274
278
  hooks_dir: Hooks::Loader.hooks_dir,
275
279
  context_dir: ContextSources.root,
280
+ runs_dir: File.join(File.dirname(@approvals.path, 2), "system_one"),
276
281
  home_path: ChiHome.path,
277
282
  shim_path: ChiShim.path
278
283
  )
@@ -375,11 +380,13 @@ module Samagotchi
375
380
  # judged is written after it, so the examples are always earlier asks.
376
381
  entries = log.entries
377
382
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
383
+ # A scratch session saves no run, so nothing hangs a later line on one.
384
+ run_id = SystemOne::Runs.new_id(SystemOne::ApprovalQuestion::JUDGMENT) unless @session_lookup.call&.scratch
378
385
  answers = begin
379
386
  client.ask(state: SystemOne::ApprovalQuestion.state(approval, session_title: @session_lookup.call&.title,
380
387
  entries: entries),
381
388
  questions: { SystemOne::ApprovalQuestion::ID => SystemOne::ApprovalQuestion.question },
382
- cancel_controller: @cancel_controller.call)
389
+ cancel_controller: @cancel_controller.call, run_id: run_id)
383
390
  rescue LLM::RequestCancelled
384
391
  raise
385
392
  rescue StandardError => e
@@ -393,13 +400,14 @@ module Samagotchi
393
400
  auto = system_one_auto_allow?(client, approval, p, entries, targets: targets)
394
401
  decision = system_one_decision(client, auto)
395
402
 
396
- id = log.judgment(session_id: @session_lookup.call&.id,
403
+ id = log.judgment(id: run_id, session_id: @session_lookup.call&.id,
397
404
  rule: approval.rule, source: approval.source, tool: approval.tool,
398
405
  **SystemOne::ApprovalQuestion.call_fields(approval),
399
406
  repo_name: approval.repo_name,
400
407
  p: p, threshold: Config.get("guardrails.system_one.threshold"), model: client.model,
401
408
  latency_ms: latency_ms, decision: decision, origin: "live")
402
409
  # A judgment the log cannot hold is no judgment: the user is asked.
410
+ system_one_run_facts(run_id, approval, targets, id.nil? && auto ? "asked" : decision)
403
411
  { id: id, p: p, auto: auto && !id.nil? }
404
412
  rescue LLM::RequestCancelled
405
413
  raise
@@ -409,6 +417,22 @@ module Samagotchi
409
417
  nil
410
418
  end
411
419
 
420
+ # The saved run's later lines: what the ask led to, and what the gates
421
+ # say of it now, which can't be recomputed later (a rule list changes):
422
+ # a later comparison must tell a sweep of gated asks from ungated ones.
423
+ def system_one_run_facts(run_id, approval, targets, what)
424
+ runs = @system_one_runs.call
425
+ return unless run_id && runs&.on?
426
+
427
+ rule = approval.rule.to_s
428
+ runs.gates(run_id, rule: rule, rule_listed: system_one_rules.include?(rule),
429
+ self_protect_refused: SystemOne::SelfProtect.refuse?(approval, targets: targets),
430
+ threshold: Config.get("guardrails.system_one.threshold"))
431
+ runs.acted(run_id, what)
432
+ rescue StandardError => e
433
+ Samagotchi::Log.warn(:guardrails, "system_one_run_facts_failed", error: "#{e.class}: #{e.message}")
434
+ end
435
+
412
436
  # What the judgment line says it did: "auto" (System 1 answered for the
413
437
  # user), "asked" (on mode, the user decides), "audit" (nothing decided).
414
438
  def system_one_decision(client, auto)
@@ -450,43 +474,24 @@ module Samagotchi
450
474
  []
451
475
  end
452
476
 
453
- # The user's answer to that ask, as a label on the judgment: the scope
454
- # they took, a denial, or none for an ask that was cancelled. A parent
455
- # agent's answer is not the user's: its allow labels nothing, and its
456
- # deny is a label only where the parent could have allowed — a parent
457
- # that was not allowed to permit says nothing about what the user would
458
- # have done (docs/guardrails.md, "System 1").
477
+ # The answer to that ask, as a label on the judgment and on its saved run
478
+ # (SystemOne::ApprovalLabel: which answers are labels, how strong).
459
479
  def system_one_label(judgment, answer, payload)
460
480
  id = judgment && judgment[:id]
461
481
  return nil if id.nil?
462
482
 
463
- scopes = Array(payload[:approval].scopes)
464
- if !answer.is_a?(Samagotchi::QuestionAnswer)
465
- label = "none"
466
- elsif answer.parent_agent?
467
- return nil if parent_allowed?(answer, scopes) || !parent_may_permit?(payload)
468
-
469
- label = "deny"
470
- else
471
- index = answer.selected_indices.first
472
- label = if index && index < scopes.size
473
- scopes[index] == "once" ? "allow_once" : "allow_wide"
474
- else
475
- "deny"
476
- end
477
- end
478
- system_one_log.label(id, label, question_id: (answer.id if answer.is_a?(Samagotchi::QuestionAnswer)))
483
+ label = SystemOne::ApprovalLabel.of(answer, scopes: Array(payload[:approval].scopes),
484
+ parent_may_permit: -> { parent_may_permit?(payload) })
485
+ return nil if label.nil?
486
+
487
+ written = system_one_log.label(id, label.value, question_id: (answer.id if answer.is_a?(Samagotchi::QuestionAnswer)))
488
+ SystemOne::ApprovalLabel.save(@system_one_runs.call, id, label)
489
+ written
479
490
  rescue StandardError => e
480
491
  Samagotchi::Log.warn(:guardrails, "system_one_label_failed", error: "#{e.class}: #{e.message}")
481
492
  nil
482
493
  end
483
494
 
484
- # Whether a parent's answer picked one of the offered scopes.
485
- def parent_allowed?(answer, scopes)
486
- index = answer.selected_indices.first
487
- !index.nil? && index < scopes.size
488
- end
489
-
490
495
  # Whether this session's parent level lets a parent allow a call at all,
491
496
  # and the ask is not one only the user may answer.
492
497
  def parent_may_permit?(payload)
@@ -12,8 +12,9 @@ module Samagotchi
12
12
  # words against, as this process's environment and config.yml name them
13
13
  # (read each call). Each surface keeps its set:
14
14
  # .protected the config dir, the hooks dir, the approval store's
15
- # dir, the installed bundles' dir, attached context: an
16
- # ask only the user may answer (ParentApprovals.chi_files?)
15
+ # dir, the installed bundles' dir, attached context,
16
+ # System 1's saved runs: an ask only the user may answer
17
+ # (ParentApprovals.chi_files?)
17
18
  # .touches .protected and the session repo's git hooks dir: a
18
19
  # rule's touches: chi_dirs (Targets#touches_chi?)
19
20
  # .self_protect .protected and the whole state dir, resolved: what
@@ -27,11 +28,13 @@ module Samagotchi
27
28
  module_function
28
29
 
29
30
  # chi's config dir, the hooks dir, the approval store's dir, the
30
- # installed bundles' dir and attached context.
31
+ # installed bundles' dir, attached context and System 1's saved runs
32
+ # (labels there may train a model: no agent writes them).
31
33
  def protected
32
34
  hooks = begin Hooks::Loader.hooks_dir rescue nil end
33
35
  bundles = begin MemoryPaths.bundles_dir rescue nil end
34
- [ConfigFile.config_dir, hooks, File.join(Paths.state_dir, "guardrails"), bundles, File.join(Paths.state_dir, "context")]
36
+ [ConfigFile.config_dir, hooks, File.join(Paths.state_dir, "guardrails"), bundles, File.join(Paths.state_dir, "context"),
37
+ File.join(Paths.state_dir, "system_one")]
35
38
  .compact.map { |dir| dir.to_s.chomp("/") }.reject(&:empty?).uniq
36
39
  end
37
40
 
@@ -9,7 +9,7 @@ module Samagotchi
9
9
  # Outside, ShellPaths, RmTargets, ReviewPaths, PairCommand, Targets,
10
10
  # ParentApprovals, System 1's SelfProtect). Each surface keeps its own
11
11
  # predicate; this only says where a path lands and whether it is in a
12
- # dir. docs/internals/tool-guardrails.md maps which surface uses which.
12
+ # dir. docs/internals/tool-output-limits.md maps which surface uses which.
13
13
  module PathResolve
14
14
  # How many symlinks .real follows on a path that doesn't exist
15
15
  # (about the kernel's own ELOOP limit).
@@ -11,9 +11,10 @@ module Samagotchi
11
11
  # lexical answer and where the write lands, a vote when either is
12
12
  # protected).
13
13
  # deny: the approval store's dir, installed bundles (.bundles/:
14
- # hooks, rules, manifests) and attached context (a source's
14
+ # hooks, rules, manifests), attached context (a source's
15
15
  # command runs later, outside the gate; chi context writes
16
- # it); nothing legitimate writes there through the file tools.
16
+ # it) and System 1's saved runs (a label there may train a
17
+ # model); nothing legitimate writes there through the file tools.
17
18
  # ask (once / session): config.yml, home.json (the chi home: the
18
19
  # Ruby every chi launch boots), the chi shim (ChiShim: the
19
20
  # `chi` every tool runs) and the plain hooks dir. The
@@ -31,13 +32,15 @@ module Samagotchi
31
32
  # @param config_path [String] config.yml
32
33
  # @param hooks_dir [String] the plain hooks dir
33
34
  # @param context_dir [String, nil] <state dir>/context (ContextSources.root)
35
+ # @param runs_dir [String, nil] <state dir>/system_one (SystemOne::Runs)
34
36
  # @param home_path [String, nil] home.json (ChiHome.path)
35
37
  # @param shim_path [String, nil] the chi shim (ChiShim.path)
36
- def initialize(store_dir:, bundles_dir:, config_path:, hooks_dir:, context_dir: nil, home_path: nil, shim_path: nil)
38
+ def initialize(store_dir:, bundles_dir:, config_path:, hooks_dir:, context_dir: nil, runs_dir: nil, home_path: nil, shim_path: nil)
37
39
  @deny = [
38
40
  ["guardrail-store", store_dir, "the approval store belongs to the user"],
39
41
  ["installed-bundles", bundles_dir, "installed bundles (hooks, rules, manifests) are changed by chi bundle install"],
40
- ["chi-context", context_dir, "attached context sources run commands; change them with chi context"]
42
+ ["chi-context", context_dir, "attached context sources run commands; change them with chi context"],
43
+ ["system-one-runs", runs_dir, "System 1's saved runs are chi's own record"]
41
44
  ]
42
45
  @ask = [
43
46
  ["chi-config", config_path, "changes chi's config.yml"],
@@ -39,7 +39,7 @@ module Samagotchi
39
39
  user-only). --threshold overrides guardrails.system_one.threshold
40
40
  for the table; --model overrides the configured model for this run.
41
41
  --dry-run judges and prints the table without writing a line. Needs
42
- guardrails.system_one.mode audit or on and a guardrails.system_one.host
42
+ guardrails.system_one.mode audit or on and a system_one.host
43
43
  (exit 2 without one).
44
44
  TEXT
45
45
 
@@ -146,10 +146,10 @@ module Samagotchi
146
146
  threshold = threshold(options[:threshold])
147
147
  return usage_error(threshold) if threshold.is_a?(String)
148
148
 
149
- client = @client || SystemOne::Client.from_config(host_registry: HostRegistry.new, model: options[:model])
149
+ client = @client || SystemOne::Client.from_config(host_registry: HostRegistry.new, mode: Config.get("guardrails.system_one.mode"), model: options[:model])
150
150
  if client.nil?
151
151
  error_line("chi guardrails replay: no System 1 client — set guardrails.system_one.mode to audit or on " \
152
- "and guardrails.system_one.host in config.yml")
152
+ "and system_one.host in config.yml")
153
153
  return CLI::Exit::USAGE
154
154
  end
155
155
 
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cli/command"
4
+ require_relative "cli/exit"
5
+ require_relative "cli/flags"
6
+ require_relative "session"
7
+ require_relative "system_one/runs"
8
+
9
+ module Samagotchi
10
+ # `chi judgments`: what System 1's saved runs hold (docs/system-one.md).
11
+ # Reads only: it never asks a model or changes a run.
12
+ class JudgmentsCommand
13
+ include CLI::Command
14
+
15
+ USAGE = <<~TEXT
16
+ Usage: chi judgments ls
17
+ chi judgments report NAME [--days N]
18
+ System 1's saved asks on this machine. ls: each judgment's digests,
19
+ runs, labels, failures, models and size. report: one judgment's counts,
20
+ p histogram and labels by value, over N days (default: all that's kept).
21
+ TEXT
22
+
23
+ SUBCOMMANDS = %w[ls report].freeze
24
+ FLAGS = { "ls" => CLI::Flags.new(args: false), "report" => CLI::Flags.new { |flags| flags.value "--days" } }.freeze
25
+ BINS = 10
26
+
27
+ # @param argv [Array<String>] the arguments after "judgments"
28
+ # @param sessions_dir [String, nil] the sessions folder (a spec seam)
29
+ # @param now [Time] the report's clock (a spec seam)
30
+ def initialize(argv, stdout: $stdout, stderr: $stderr, sessions_dir: nil, now: Time.now)
31
+ @argv = argv.dup
32
+ @stdout = stdout
33
+ @stderr = stderr
34
+ @store = SystemOne::RunsStore.new(File.dirname(sessions_dir || Session.default_state_dir))
35
+ @now = now
36
+ end
37
+
38
+ def run
39
+ @sub = @argv.first
40
+ if @sub.nil? || HELP_WORDS.include?(@sub)
41
+ @stdout.puts(USAGE)
42
+ return CLI::Exit::OK
43
+ end
44
+ return usage_error("unknown subcommand #{@sub}") unless SUBCOMMANDS.include?(@sub)
45
+
46
+ parsed = parse_flags(FLAGS.fetch(@sub), @argv[1..])
47
+ return parsed if parsed.is_a?(Integer)
48
+
49
+ @sub == "ls" ? ls : report(parsed)
50
+ end
51
+
52
+ private
53
+
54
+ def command_name = @sub ? "chi judgments #{@sub}" : "chi judgments"
55
+
56
+ def ls
57
+ names = @store.names
58
+ if names.empty?
59
+ @stdout.puts("No saved runs.")
60
+ return CLI::Exit::OK
61
+ end
62
+
63
+ names.each { |name| @stdout.puts(ls_line(name)) }
64
+ CLI::Exit::OK
65
+ end
66
+
67
+ def ls_line(name)
68
+ runs, later = @store.lines(name).partition { |line| line["kind"] == "run" }
69
+ models = runs.filter_map { |run| run["served_model"] || run["model"] }.uniq
70
+ "#{name} digests #{@store.versions(name).size} runs #{runs.size} " \
71
+ "labels #{last_labels(runs, later).size} failures #{runs.count { |run| run["failure"] }} " \
72
+ "models #{models.empty? ? "none" : models.join(", ")} #{size_text(@store.size(name))}"
73
+ end
74
+
75
+ def size_text(bytes)
76
+ return "#{bytes} B" if bytes < 1024
77
+ return format("%.1f KB", bytes / 1024.0) if bytes < 1024 * 1024
78
+
79
+ format("%.1f MB", bytes / (1024.0 * 1024))
80
+ end
81
+
82
+ def report(parsed)
83
+ name = parsed.args.first
84
+ return usage_error("report takes one judgment's name (see chi judgments ls)") if parsed.args.size != 1
85
+ return usage_error("not a judgment's name: #{name}") unless name.match?(SystemOne::RunsStore::NAME)
86
+
87
+ days = parsed.options[:days]
88
+ return usage_error("--days takes a positive whole number") if days && !days.to_s.match?(/\A[1-9]\d*\z/)
89
+
90
+ since = days && (@now.utc - (Integer(days, 10) * 86_400)).strftime("%F")
91
+ runs, later = @store.lines(name).partition { |line| line["kind"] == "run" }
92
+ runs = runs.select { |run| since.nil? || run["at"].to_s[0, 10] >= since }
93
+ if runs.empty?
94
+ @stdout.puts("No runs of #{name}#{" in the last #{days} days" if days}.")
95
+ return CLI::Exit::OK
96
+ end
97
+
98
+ print_report(name, days, runs, later)
99
+ CLI::Exit::OK
100
+ end
101
+
102
+ def print_report(name, days, runs, later)
103
+ @stdout.puts("#{name} · #{days ? "last #{days} days" : "all kept"}")
104
+ asked = runs.count { |run| run["asked"] != false }
105
+ @stdout.puts("runs #{runs.size} asked #{asked} skipped #{runs.size - asked} failures #{runs.count { |run| run["failure"] }} " \
106
+ "truncated #{runs.count { |run| run["truncated"] }} test runs #{runs.count { |run| run["test_run"] }}")
107
+ failures = tally(runs.filter_map { |run| run["failure"] })
108
+ @stdout.puts("failures by class: #{failures}") unless failures.empty?
109
+ print_answers(runs)
110
+ last = last_labels(runs, later)
111
+ ids = runs.to_h { |run| [run["id"], true] }
112
+ @stdout.puts("labels: #{last.empty? ? "none" : tally(last.map { |line| "#{line["value"]} (#{line["by"]})" })}")
113
+ acted = later.select { |line| line["kind"] == "acted" && ids[line["run_id"]] }
114
+ @stdout.puts("acted: #{tally(acted.map { |line| line["what"] })}") unless acted.empty?
115
+ end
116
+
117
+ # One label per run and question: the last said. One whose run is gone
118
+ # or out of the window doesn't count.
119
+ def last_labels(runs, later)
120
+ ids = runs.to_h { |run| [run["id"], true] }
121
+ labels = later.select { |line| line["kind"] == "label" && ids[line["run_id"]] }
122
+ labels.to_h { |line| [[line["run_id"], line["question"]], line] }.values
123
+ end
124
+
125
+ def print_answers(runs)
126
+ answers = runs.flat_map { |run| (run["answers"].is_a?(Hash) ? run["answers"] : {}).to_a }
127
+ answers.group_by(&:first).sort.each do |question, pairs|
128
+ values = pairs.map(&:last).grep(Hash)
129
+ probabilities = values.filter_map { |answer| answer["noul"] || answer["confidence"] }.grep(Numeric).select { |p| p.between?(0, 1) }
130
+ kind = values.first&.fetch("type", nil)
131
+ @stdout.puts("#{question} (#{kind}): #{probabilities.size} with a p#{", picks #{picks(values)}" if picks(values)}")
132
+ histogram(probabilities).each { |line| @stdout.puts(" #{line}") }
133
+ end
134
+ end
135
+
136
+ def picks(values)
137
+ chosen = values.filter_map { |answer| answer["choice"] }
138
+ chosen.empty? ? nil : tally(chosen)
139
+ end
140
+
141
+ def histogram(values)
142
+ return [] if values.empty?
143
+
144
+ counts = Array.new(BINS, 0)
145
+ values.each { |value| counts[[(value.to_f * BINS).floor, BINS - 1].min] += 1 }
146
+ counts.each_with_index.map do |count, bin|
147
+ format("%.1f–%.1f %5d", bin.to_f / BINS, (bin + 1).to_f / BINS, count)
148
+ end
149
+ end
150
+
151
+ def tally(values)
152
+ values.tally.sort_by { |value, count| [-count, value.to_s] }.map { |value, count| "#{value} #{count}" }.join(", ")
153
+ end
154
+ end
155
+ end
@@ -193,6 +193,16 @@ module Samagotchi
193
193
  attr_accessor :generation_guard
194
194
 
195
195
  # The generation guard, if any; one that raises never breaks the turn.
196
+ # The model's context window as this loop knows it (ContextWindow.resolve
197
+ # on the client and the turn's setting): ToolRunner halves it for
198
+ # memory_read's overflow guard. nil when it can't be read.
199
+ # @return [ContextWindow::Resolved, nil]
200
+ def tool_output_window
201
+ ContextWindow.resolve(client: @client, model: @turn_settings&.model_name, setting: @turn_settings&.window_setting)
202
+ rescue StandardError
203
+ nil
204
+ end
205
+
196
206
  def guard_generation
197
207
  @generation_guard&.call
198
208
  rescue StandardError => e
@@ -306,7 +316,9 @@ module Samagotchi
306
316
  # Precedence: an explicit override wins, then max_tool_output_chars
307
317
  # (Config). A non-positive value falls back to
308
318
  # DEFAULT_MAX_TOOL_OUTPUT_CHARS (there is intentionally no "unlimited" — live UIs get a
309
- # bounded `output:` plus a truthful `output_truncated:` flag).
319
+ # bounded `output:` plus a truthful `output_truncated:` flag). The one
320
+ # exception is memory_read, which ToolRunner leaves out of this cap
321
+ # (memory.read_max_chars, 0: no fixed cap; a memory is read whole).
310
322
  # Class-level so the chat loop resolves it the same way.
311
323
  def self.resolve_output_char_cap(override)
312
324
  parsed = (override || Samagotchi::Config.get("max_tool_output_chars")).to_i