samagotchi 0.140.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 (109) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -1
  3. data/README.md +1 -1
  4. data/bin/chi +2 -0
  5. data/docs/architecture.md +14 -285
  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 +68 -6
  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 +5 -2
  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 +162 -2
  23. data/docs/sessions.md +53 -28
  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/claude-peer/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +14 -0
  31. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  32. data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +15 -1
  33. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  34. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  35. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  36. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  37. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  38. data/lib/samagotchi/config.rb +74 -14
  39. data/lib/samagotchi/context_candidate_choice.rb +8 -0
  40. data/lib/samagotchi/context_candidates_log.rb +25 -1
  41. data/lib/samagotchi/context_command.rb +32 -9
  42. data/lib/samagotchi/context_judge.rb +6 -2
  43. data/lib/samagotchi/context_move.rb +31 -0
  44. data/lib/samagotchi/context_sources.rb +248 -10
  45. data/lib/samagotchi/engine.rb +68 -7
  46. data/lib/samagotchi/extension_load.rb +3 -2
  47. data/lib/samagotchi/guardrail_wiring.rb +36 -31
  48. data/lib/samagotchi/guardrails/chi_dirs.rb +7 -4
  49. data/lib/samagotchi/guardrails/path_resolve.rb +1 -1
  50. data/lib/samagotchi/guardrails/protected_paths.rb +7 -4
  51. data/lib/samagotchi/guardrails_command.rb +3 -3
  52. data/lib/samagotchi/judgments_command.rb +155 -0
  53. data/lib/samagotchi/kernel_loop.rb +13 -1
  54. data/lib/samagotchi/llm/chat_loop.rb +74 -10
  55. data/lib/samagotchi/llm/errors.rb +7 -5
  56. data/lib/samagotchi/llm/stray_tool_call.rb +61 -0
  57. data/lib/samagotchi/log_line.rb +1 -1
  58. data/lib/samagotchi/memory_bundle/entry_size.rb +100 -0
  59. data/lib/samagotchi/plugin/api.rb +33 -2
  60. data/lib/samagotchi/plugin/attached_context.rb +14 -2
  61. data/lib/samagotchi/plugin/context.rb +54 -1
  62. data/lib/samagotchi/plugin/judgments.rb +72 -0
  63. data/lib/samagotchi/plugin/loader.rb +3 -2
  64. data/lib/samagotchi/plugin/peer.rb +53 -0
  65. data/lib/samagotchi/plugin/sessions.rb +9 -2
  66. data/lib/samagotchi/run_options.rb +1 -0
  67. data/lib/samagotchi/self_report.rb +36 -0
  68. data/lib/samagotchi/send_command.rb +3 -1
  69. data/lib/samagotchi/session.rb +4 -0
  70. data/lib/samagotchi/session_access/contract.rb +3 -1
  71. data/lib/samagotchi/session_access/local.rb +24 -3
  72. data/lib/samagotchi/session_access/remote.rb +19 -8
  73. data/lib/samagotchi/session_access.rb +26 -6
  74. data/lib/samagotchi/session_commands.rb +3 -0
  75. data/lib/samagotchi/session_continue.rb +9 -2
  76. data/lib/samagotchi/session_manager.rb +13 -2
  77. data/lib/samagotchi/session_retention.rb +10 -0
  78. data/lib/samagotchi/side_judgments.rb +12 -1
  79. data/lib/samagotchi/system_one/answer.rb +63 -11
  80. data/lib/samagotchi/system_one/approval_label.rb +56 -0
  81. data/lib/samagotchi/system_one/approval_question.rb +2 -0
  82. data/lib/samagotchi/system_one/client.rb +43 -17
  83. data/lib/samagotchi/system_one/fake_backend.rb +1 -1
  84. data/lib/samagotchi/system_one/http_backend.rb +12 -4
  85. data/lib/samagotchi/system_one/judgment_settings.rb +61 -0
  86. data/lib/samagotchi/system_one/judgments.rb +260 -0
  87. data/lib/samagotchi/system_one/log.rb +4 -2
  88. data/lib/samagotchi/system_one/recording.rb +117 -0
  89. data/lib/samagotchi/system_one/runs.rb +179 -0
  90. data/lib/samagotchi/system_one/runs_store.rb +230 -0
  91. data/lib/samagotchi/system_one.rb +9 -0
  92. data/lib/samagotchi/testing/boundary.rb +5 -1
  93. data/lib/samagotchi/testing/bundle.rb +9 -2
  94. data/lib/samagotchi/testing/host.rb +2 -1
  95. data/lib/samagotchi/testing/judgments.rb +185 -0
  96. data/lib/samagotchi/testing/recorder.rb +5 -0
  97. data/lib/samagotchi/testing/recording_api.rb +1 -1
  98. data/lib/samagotchi/tool_output_cut.rb +26 -6
  99. data/lib/samagotchi/tool_response.rb +1 -1
  100. data/lib/samagotchi/tool_runner.rb +63 -4
  101. data/lib/samagotchi/toolkit_suggest.rb +11 -2
  102. data/lib/samagotchi/tools/memory.rb +28 -0
  103. data/lib/samagotchi/turn_note.rb +9 -0
  104. data/lib/samagotchi/version.rb +1 -1
  105. data/lib/samagotchi/web/app.rb +14 -8
  106. data/lib/samagotchi/web/public/chains.js +10 -2
  107. data/lib/samagotchi/web/session_access_routes.rb +7 -2
  108. data/lib/samagotchi/web/session_create.rb +1 -0
  109. metadata +17 -2
@@ -179,17 +179,21 @@ module Samagotchi
179
179
  # mode GuardrailWiring also reads rules, threshold and min_record for the auto-allow (docs/guardrails.md).
180
180
  # off (the default) | audit (judge every ask, log it, never decide) | on. An unknown value warns and is off.
181
181
  Entry.new(key: "guardrails.system_one.mode", yaml_path: %w[guardrails system_one mode], type: :enum, default: "off", expose: %i[config], enum_values: %w[off audit on], lifecycle: :next_tool_or_approval),
182
- # The hosts: entry serving /v1/systemone (Splash, or the hosted Jev API); unset: no judgment.
183
- Entry.new(key: "guardrails.system_one.host", yaml_path: %w[guardrails system_one host], type: :string, default: nil, expose: %i[config], lifecycle: :next_tool_or_approval),
184
- # The judgment model; unset: the host's first model, and no judgment if it names none.
185
- Entry.new(key: "guardrails.system_one.model", yaml_path: %w[guardrails system_one model], type: :string, default: nil, expose: %i[config], lifecycle: :next_tool_or_approval),
186
182
  # The rule ids System 1 may answer for ("a|b", or a YAML list, parsed like guardrails.small_models).
187
183
  Entry.new(key: "guardrails.system_one.rules", yaml_path: %w[guardrails system_one rules], type: :string, default: nil, expose: %i[config], lifecycle: :next_tool_or_approval),
188
184
  # The probability an auto-allow needs, and how many clean records a rule needs before it is trusted.
189
185
  Entry.new(key: "guardrails.system_one.threshold", yaml_path: %w[guardrails system_one threshold], type: :float, default: 0.8, expose: %i[config], lifecycle: :next_tool_or_approval),
190
186
  Entry.new(key: "guardrails.system_one.min_record", yaml_path: %w[guardrails system_one min_record], type: :integer, default: 5, expose: %i[config], lifecycle: :next_tool_or_approval),
191
- # How long one judgment may take, in milliseconds; a judgment that doesn't arrive asks the user.
192
- Entry.new(key: "guardrails.system_one.timeout_ms", yaml_path: %w[guardrails system_one timeout_ms], type: :integer, default: 2000, expose: %i[config], lifecycle: :next_tool_or_approval),
187
+
188
+ # What every use of System 1 shares: approvals, toolkit suggestions, context candidates and bundles' judgments.
189
+ # The hosts: entry serving /v1/systemone (Splash, or the hosted Jev API); unset: no judgment, so nothing auto-allows.
190
+ Entry.new(key: "system_one.host", yaml_path: %w[system_one host], type: :string, default: nil, expose: %i[config], lifecycle: :next_tool_or_approval),
191
+ # The judgment model; unset: the host's first model, and no judgment if it names none.
192
+ Entry.new(key: "system_one.model", yaml_path: %w[system_one model], type: :string, default: nil, expose: %i[config], lifecycle: :next_tool_or_approval),
193
+ # How long one judgment may take, in milliseconds; a judgment that doesn't arrive is no judgment.
194
+ Entry.new(key: "system_one.timeout_ms", yaml_path: %w[system_one timeout_ms], type: :integer, default: 2000, expose: %i[config], lifecycle: :next_tool_or_approval),
195
+ # Save each System 1 ask and what came of it (docs/system-one.md): on (the default) | off.
196
+ Entry.new(key: "system_one.runs", yaml_path: %w[system_one runs], type: :enum, default: "on", expose: %i[config], enum_values: %w[on off], lifecycle: :next_tool_or_approval),
193
197
 
194
198
  Entry.new(key: "log.file", yaml_path: %w[log file], type: :string, default: nil, expose: %i[env config cli], lifecycle: :process_start),
195
199
  Entry.new(key: "log.disable", yaml_path: %w[log disable], type: :bool, default: false, expose: %i[env config cli], lifecycle: :process_start),
@@ -204,6 +208,12 @@ module Samagotchi
204
208
  # The tokens one scope's memory index (sent with every prompt) may hold before a write that crosses it gets a note
205
209
  # (MemoryBundle::IndexSize); 0: no note. `memory:` is its own section, not the `memories:` preload list.
206
210
  Entry.new(key: "memory.index_warn_tokens", yaml_path: %w[memory index_warn_tokens], type: :integer, default: 2500, expose: %i[env config cli], lifecycle: :next_matching_turn_display_poll_write),
211
+ # The most characters one memory_read result holds (ToolOutputCut cuts it with a line naming the memory file); 0: no
212
+ # fixed cap. memory_read is not cut by max_tool_output_chars (ToolRunner).
213
+ Entry.new(key: "memory.read_max_chars", yaml_path: %w[memory read_max_chars], type: :integer, default: 0, expose: %i[env config cli], lifecycle: :next_matching_use),
214
+ # The tokens one memory may hold (each read of it pays them) before a write that crosses it gets a note
215
+ # (MemoryBundle::EntrySize); 0: no note.
216
+ Entry.new(key: "memory.entry_warn_tokens", yaml_path: %w[memory entry_warn_tokens], type: :integer, default: 5000, expose: %i[env config cli], lifecycle: :next_matching_turn_display_poll_write),
207
217
  Entry.new(key: "context.status_thresholds", yaml_path: %w[context status_thresholds], type: :string, default: "20,40,60,80", expose: %i[env config cli], lifecycle: :next_matching_turn_display_poll_write),
208
218
  # Attached context (ContextSources): how often a source's command runs when it names no --every (seconds; less
209
219
  # than 30 counts as 30).
@@ -219,7 +229,7 @@ module Samagotchi
219
229
  Entry.new(key: "context.peer_turns", yaml_path: %w[context peer_turns], type: :integer, default: 3, expose: %i[env config cli], lifecycle: :next_matching_turn_display_poll_write),
220
230
  # The candidate judge (ContextJudge): off (no conversation text goes to a System 1 host); audit (asks and logs
221
231
  # context_candidates.jsonl, attaches nothing); on (ContextCandidateChoice: p ≥ threshold attaches quietly,
222
- # offer_threshold ≤ p < threshold shows one card). Its host and model are guardrails.system_one.host/model.
232
+ # offer_threshold ≤ p < threshold shows one card). Its host and model are system_one.host and .model.
223
233
  # reask_after_turns: the owner prompts after a judged ask before its one re-ask.
224
234
  Entry.new(key: "context.candidates.mode", yaml_path: %w[context candidates mode], type: :enum, default: "off", expose: %i[config], enum_values: %w[off audit on], lifecycle: :next_matching_turn_display_poll_write),
225
235
  Entry.new(key: "context.candidates.threshold", yaml_path: %w[context candidates threshold], type: :float, default: 0.8, expose: %i[config], lifecycle: :next_matching_turn_display_poll_write),
@@ -260,7 +270,7 @@ module Samagotchi
260
270
  Entry.new(key: "toolkit.default", yaml_path: %w[toolkit default], type: :string, default: nil, expose: %i[env config], lifecycle: :session_creation),
261
271
  Entry.new(key: "toolkit.child", yaml_path: %w[toolkit child], type: :string, default: nil, expose: %i[env config], lifecycle: :session_creation),
262
272
  # Toolkit suggestion (ToolkitSuggest): off; audit (logs, never blocks or changes a session); on (the first eligible
263
- # turn waits up to guardrails.system_one.timeout_ms and may start on the suggested kit).
273
+ # turn waits up to system_one.timeout_ms and may start on the suggested kit).
264
274
  Entry.new(key: "toolkit.suggest.mode", yaml_path: %w[toolkit suggest mode], type: :enum, default: "off", expose: %i[config], enum_values: %w[off audit on], lifecycle: :worker_start),
265
275
  Entry.new(key: "toolkit.suggest.threshold", yaml_path: %w[toolkit suggest threshold], type: :float, default: 0.5, expose: %i[config], lifecycle: :worker_start),
266
276
  Entry.new(key: "toolkit.suggest.defer_turns", yaml_path: %w[toolkit suggest defer_turns], type: :integer, default: 3, expose: %i[config], lifecycle: :worker_start),
@@ -390,8 +400,18 @@ module Samagotchi
390
400
  # The keys a hosts.<name>.models entry may hold (HostModel.parse_map).
391
401
  HOST_MODEL_KEYS = %w[price served].freeze
392
402
  # Section keys beyond the registry's: guardrails' YAML rules (Engine#guardrail_rules).
393
- # And toolkit's kits (Toolkit.kits).
394
- SECTION_EXTRA_KEYS = { "guardrails" => %w[rules disable].freeze, "toolkit" => %w[kits].freeze }.freeze
403
+ # And toolkit's kits (Toolkit.kits), and bundles' judgments' settings (SystemOne::JudgmentSettings).
404
+ SECTION_EXTRA_KEYS = { "guardrails" => %w[rules disable].freeze, "toolkit" => %w[kits].freeze, "system_one" => %w[judgments].freeze }.freeze
405
+
406
+ # guardrails.system_one's keys that moved to system_one (SystemOne::Client.from_config).
407
+ SYSTEM_ONE_MOVED_KEYS = %w[host model timeout_ms].freeze
408
+ # What a system_one.judgments entry may hold. Core's judgments take none (RESERVED_JUDGMENTS).
409
+ JUDGMENT_ENTRY_KEYS = %w[mode host model].freeze
410
+ JUDGMENT_MODES = %w[off audit on].freeze
411
+ # Names core keeps, as a judgment's first word: a bundle can't declare one and
412
+ # system_one.judgments can't set one (Record and Similar are model-blind, so a
413
+ # swapped model would inherit a clean record and could auto-allow).
414
+ RESERVED_JUDGMENTS = %w[approvals context guardrails toolkit].freeze
395
415
 
396
416
  # What a :bool value reads as (Config.coerce!), YAML 1.1's words and 1/0.
397
417
  BOOL_WORDS = { "1" => true, "true" => true, "yes" => true, "on" => true,
@@ -464,6 +484,14 @@ module Samagotchi
464
484
  word && entry.enum_values.include?(word) ? word : nil
465
485
  end
466
486
 
487
+ # A system_one.judgments entry's mode as written, nil for one it can't
488
+ # be: an unquoted on/off is a boolean there too.
489
+ # @return [String, nil]
490
+ def judgment_mode(raw)
491
+ word = BOOL_ENUM_WORDS.fetch(raw) { raw.to_s.strip.downcase }
492
+ word if JUDGMENT_MODES.include?(word)
493
+ end
494
+
467
495
  # What +entry+'s type takes, as a refusal names it.
468
496
  def takes(entry)
469
497
  case entry.type
@@ -692,6 +720,8 @@ module Samagotchi
692
720
  # A section's leaf: a key the registry knows, or a mapping of a
693
721
  # third level (web.participants.cap) whose keys it knows.
694
722
  def leaf_problems(path, value)
723
+ return judgment_problems(value) if path == "system_one.judgments"
724
+
695
725
  if value.is_a?(Hash) && ENTRIES.any? { |e| e.key.start_with?("#{path}.") }
696
726
  value.keys.filter_map { |leaf| key_problem("#{path}.#{leaf}") }
697
727
  else
@@ -699,6 +729,29 @@ module Samagotchi
699
729
  end
700
730
  end
701
731
 
732
+ # system_one.judgments' entries: a map entry bypasses the registry, so
733
+ # its keys and mode are checked here (an unquoted on/off is a boolean).
734
+ # An undeclared name can only be told once the plugins have loaded
735
+ # (SystemOne::Judgments#warn_undeclared).
736
+ def judgment_problems(value)
737
+ return value.nil? ? [] : ["config: 'system_one.judgments' must be a mapping of judgments; ignored"] unless value.is_a?(Hash)
738
+
739
+ value.flat_map do |name, entry|
740
+ where = "system_one.judgments.#{name}"
741
+ if RESERVED_JUDGMENTS.include?(name.to_s.split(".").first)
742
+ next ["config: #{where}: core's judgments take no settings of their own; ignored (see docs/system-one.md)"]
743
+ end
744
+ next ["config: #{where} must be a mapping of #{JUDGMENT_ENTRY_KEYS.join(", ")}; ignored"] unless entry.is_a?(Hash)
745
+
746
+ unknown = (entry.keys.map(&:to_s) - JUDGMENT_ENTRY_KEYS).map do |k|
747
+ unknown_key_message("#{where}.#{k}", k, JUDGMENT_ENTRY_KEYS, prefix: "#{where}.")
748
+ end
749
+ mode = entry.key?("mode") && judgment_mode(entry["mode"]).nil? ? ["config: #{where}.mode must be off, audit or on; it is off"] : []
750
+ text = %w[host model].reject { |k| entry[k].nil? || entry[k].is_a?(String) }.map { |k| "config: #{where}.#{k} must be text" }
751
+ unknown + mode + text
752
+ end
753
+ end
754
+
702
755
  # What a model ref in config.yml won't do as written: an alias whose
703
756
  # target is another alias (aliases apply once, ModelRef), a ref that
704
757
  # starts with "<host>/" (only ':' names a host).
@@ -752,10 +805,17 @@ module Samagotchi
752
805
  @known_sections ||= known_config_keys.filter_map { |k| k.split(".").first if k.include?(".") }.to_set.freeze
753
806
  end
754
807
 
755
- # Keys replaced by another: the warning names the new one (no alias).
808
+ # Keys replaced by another, and what the warning adds: it names the new
809
+ # one (no alias).
810
+ PARENT_ANSWERS_HINT = "hard, standard or soft; see docs/guardrails.md"
811
+ SYSTEM_ONE_MOVED_HINT = "it has moved: until all of host, model and timeout_ms have, nothing is judged or " \
812
+ "auto-allowed, and every ask goes to you; see docs/configuration.md"
756
813
  REPLACED_KEYS = {
757
- "guardrails.parent_approvals" => "guardrails.parent_answers",
758
- "turn.parent_continue" => "guardrails.parent_answers"
814
+ "guardrails.parent_approvals" => ["guardrails.parent_answers", PARENT_ANSWERS_HINT],
815
+ "turn.parent_continue" => ["guardrails.parent_answers", PARENT_ANSWERS_HINT],
816
+ "guardrails.system_one.host" => ["system_one.host", SYSTEM_ONE_MOVED_HINT],
817
+ "guardrails.system_one.model" => ["system_one.model", SYSTEM_ONE_MOVED_HINT],
818
+ "guardrails.system_one.timeout_ms" => ["system_one.timeout_ms", SYSTEM_ONE_MOVED_HINT]
759
819
  }.freeze
760
820
 
761
821
  # nil when +dotted+ is a key the file may hold.
@@ -763,7 +823,7 @@ module Samagotchi
763
823
  return nil if known_config_keys.include?(dotted)
764
824
  return "config: #{ConfigFile.removed_transport_problem("'#{dotted}'")}" if dotted == "server.transport"
765
825
  if (replaced = REPLACED_KEYS[dotted])
766
- return "config: unknown key '#{dotted}': replaced by '#{replaced}' (hard, standard or soft; see docs/guardrails.md)"
826
+ return "config: unknown key '#{dotted}': replaced by '#{replaced.first}' (#{replaced.last})"
767
827
  end
768
828
 
769
829
  entry = find_by_key(dotted)
@@ -133,12 +133,19 @@ module Samagotchi
133
133
  own.add(ContextCandidates.source_for(candidate, why: CHOSEN_WHY))
134
134
  Log.info(:context, "candidate_attached", name: candidate.name, trigger: "command")
135
135
  ContextCandidatesLog.event("attached", session_id: session.id, name: candidate.name, state_dir: state_dir, by: "command")
136
+ label_run(session, candidate, "attached", state_dir)
136
137
  show_card.call(**closed_card(candidate, "Attached."))
137
138
  [:ok, "attached #{candidate.name}: its text comes with your next message"]
138
139
  rescue ContextSources::Invalid, ContextProviders::Invalid => e
139
140
  [:error, "#{candidate.name} not attached: #{e.message}"]
140
141
  end
141
142
 
143
+ # /context attach|decline (the card's buttons), on the latest ask's run.
144
+ def label_run(session, candidate, value, state_dir)
145
+ records = ContextCandidatesLog.records_for(ContextCandidatesLog.log_for(state_dir), session.id)
146
+ ContextCandidatesLog.label_run(records, candidate.name, value, by: "command", sessions_dir: state_dir)
147
+ end
148
+
142
149
  def decline(candidate, status, session, state_dir, show_card)
143
150
  if status == "attached"
144
151
  return [:error, "#{candidate.name} is attached; chi context rm #{candidate.name} removes it"]
@@ -147,6 +154,7 @@ module Samagotchi
147
154
 
148
155
  ContextSources.session_location(session.id, state_dir: state_dir).decline(candidate.name, candidate.url.to_s)
149
156
  ContextCandidatesLog.event("declined", session_id: session.id, name: candidate.name, state_dir: state_dir, by: "command")
157
+ label_run(session, candidate, "declined", state_dir)
150
158
  show_card.call(**closed_card(candidate, "Not attached here."))
151
159
  [:ok, "declined #{candidate.name}: not proposed again in this session"]
152
160
  end
@@ -3,6 +3,7 @@
3
3
  require_relative "log"
4
4
  require_relative "session"
5
5
  require_relative "side_judgments"
6
+ require_relative "system_one/runs"
6
7
 
7
8
  module Samagotchi
8
9
  # The candidates' record, `context_candidates.jsonl` in the state dir
@@ -15,7 +16,8 @@ module Samagotchi
15
16
  # Kinds (every line has session_id, name and at):
16
17
  # - proposed: a plugin's ctx.context.propose recorded it (title, by)
17
18
  # - mentioned: an owner prompt named it and it was attached (mention)
18
- # - judged: System 1's ask (ContextJudge.ask: p, task_p, stated, would, …)
19
+ # - judged: System 1's ask (ContextJudge.ask: run_id, p, task_p, stated,
20
+ # would, …)
19
21
  # - judge_attached: on mode attached it (p ≥ threshold; p)
20
22
  # - carded: on mode showed its card (the middle band; p): once per
21
23
  # candidate, read back by ContextCandidateChoice.carded?
@@ -34,6 +36,10 @@ module Samagotchi
34
36
  STATUS_OF = { "proposed" => "proposed", "mentioned" => "attached", "judge_attached" => "attached", "attached" => "attached",
35
37
  "declined" => "declined", "detached" => "proposed", "withdrawn" => nil }.freeze
36
38
 
39
+ # The kinds #look sees that are a removal: a chip's ×, chi context rm. One
40
+ # removed after the session's last turn is never seen.
41
+ REMOVED = %w[declined detached].freeze
42
+
37
43
  module_function
38
44
 
39
45
  # @param state_dir [String, nil] the sessions folder (Session.default_state_dir)
@@ -77,10 +83,28 @@ module Samagotchi
77
83
 
78
84
  kind = status == "proposed" ? "detached" : status
79
85
  event(kind, session_id: session_id, name: candidate.name, log: log, by: "user")
86
+ label_run(records, candidate.name, kind, by: "user", root: log.dir) if REMOVED.include?(kind)
80
87
  kind
81
88
  end
82
89
  end
83
90
 
91
+ # The user's say on a candidate, as a label on the latest ask about it
92
+ # (the judged line's run, question "about"). No ask with a run: nothing.
93
+ # Never raises.
94
+ # @param records [Array<Hash>] the session's lines (#records_for)
95
+ # @param root [String, nil] the state dir; nil: from +sessions_dir+
96
+ # @return [Boolean] whether a label was written
97
+ def label_run(records, name, value, by:, sessions_dir: nil, root: nil)
98
+ judged = records.reverse_each.find { |line| line["kind"] == "judged" && line["name"] == name && line["run_id"] }
99
+ return false if judged.nil?
100
+
101
+ runs = SystemOne::Runs.in(root || File.dirname(sessions_dir || Session.default_state_dir))
102
+ runs.label(judged["run_id"], value, by: by, question: "about")
103
+ rescue StandardError => e
104
+ Log.warn(:context, "context_candidates_log_failed", error: e.class.name, msg: e.message.to_s[0, 200])
105
+ false
106
+ end
107
+
84
108
  # +text+ as a log line keeps it: one line, at most PROMPT_MAX chars.
85
109
  def short(text)
86
110
  line = text.to_s.gsub(/\s+/, " ").strip
@@ -25,12 +25,14 @@ module Samagotchi
25
25
 
26
26
  USAGE = <<~TEXT
27
27
  Usage: chi context <add|push|ls|show|refresh|rm|mute|unmute|report> [options] [TARGET]
28
- add NAME (--cmd CMD | --push) [--every SECONDS] [--why TEXT] [--hint TEXT] TARGET
29
- add URL [--why TEXT] [--hint TEXT] TARGET a URL an installed bundle's provider knows (github-pr: a PR)
28
+ add NAME (--cmd CMD | --push) [--every SECONDS] [--why TEXT] [--hint TEXT] [--[no-]movable] TARGET
29
+ add URL [--why TEXT] [--hint TEXT] [--[no-]movable] TARGET a URL an installed bundle's provider knows (github-pr: a PR)
30
30
  a source: CMD prints its text (plain, or JSON {"text", "summary", "wake", "hint"});
31
31
  --push: text comes from chi context push. --every: how often CMD runs
32
32
  (seconds, at least 30; default context.every_seconds). --why: why it's attached;
33
33
  --hint: one line, often its URL. NAME: a-z, 0-9 and -, up to 40.
34
+ --movable / --no-movable: whether a continue or a fork takes it to the new
35
+ session (default: a --cmd or URL source moves, a --push one stays).
34
36
  push NAME [-m TEXT] [TARGET] new text for a --push source (stdin without -m; text or JSON)
35
37
  ls [TARGET] [--format json] the sources, their age and state; a session's candidates
36
38
  show NAME [--json] [TARGET] the text (--json: the source and its snapshot)
@@ -52,6 +54,8 @@ module Samagotchi
52
54
  f.value "--every"
53
55
  f.value "--why"
54
56
  f.value "--hint"
57
+ f.switch "--movable"
58
+ f.switch "--no-movable", key: :movable, set: false
55
59
  f.switch "--project"
56
60
  end,
57
61
  "push" => CLI::Flags.new(help: HELP_WORDS) do |f|
@@ -142,15 +146,27 @@ module Samagotchi
142
146
  name: name, cmd: options[:cmd], every_seconds: ContextSources.check_every!(options[:every]),
143
147
  why: ContextSources.one_line(options[:why], ContextSources::LINE_MAX_CHARS),
144
148
  hint: ContextSources.one_line(options[:hint], ContextSources::LINE_MAX_CHARS),
145
- scope: nil, added_by: added_by, created_at: Time.now.utc.iso8601
149
+ scope: nil, added_by: added_by, created_at: Time.now.utc.iso8601, movable: options[:movable]
146
150
  )
147
151
  each_target(options, args) do |target|
148
- @access.context_add(target, source)
152
+ add_to(target, source)
149
153
  @stdout.puts("#{target.label} attached #{name}")
150
154
  true
151
155
  end
152
156
  end
153
157
 
158
+ # Attaches +source+ to +target+; a movable flag an older chi web
159
+ # dropped (its answer has no movable) is a warning: the source is
160
+ # attached with the default.
161
+ def add_to(target, source)
162
+ warn_movable_dropped(target, source.name) if @access.context_add(target, source).movable_dropped
163
+ end
164
+
165
+ def warn_movable_dropped(target, name)
166
+ error_line("#{command_name}: warning: the remote's chi web is too old for --movable/--no-movable: " \
167
+ "#{name} on #{target.label} has the default (update chi there)")
168
+ end
169
+
154
170
  # A URL through the installed bundles' providers (ContextProviders):
155
171
  # the provider gives the name, command, hint and interval. Not gated
156
172
  # like --cmd (chi-context-cmd): the command is the bundle's.
@@ -167,10 +183,11 @@ module Samagotchi
167
183
  name: resolved.name, cmd: resolved.cmd, every_seconds: resolved.every_seconds,
168
184
  why: ContextSources.one_line(options[:why] || resolved.why, ContextSources::LINE_MAX_CHARS),
169
185
  hint: ContextSources.one_line(options[:hint] || resolved.hint, ContextSources::LINE_MAX_CHARS),
170
- scope: nil, added_by: added_by, created_at: Time.now.utc.iso8601, provider: resolved.bundle
186
+ scope: nil, added_by: added_by, created_at: Time.now.utc.iso8601, provider: resolved.bundle,
187
+ movable: options[:movable]
171
188
  )
172
189
  each_target(options, args) do |target|
173
- @access.context_add(target, source)
190
+ add_to(target, source)
174
191
  @stdout.puts("#{target.label} attached #{source.name}")
175
192
  true
176
193
  end
@@ -183,8 +200,9 @@ module Samagotchi
183
200
  why = ContextSources.one_line(options[:why], ContextSources::LINE_MAX_CHARS)
184
201
  hint = ContextSources.one_line(options[:hint], ContextSources::LINE_MAX_CHARS)
185
202
  each_target(options, args) do |target|
186
- name = @access.context_add_url(target, url, why: why, hint: hint, added_by: added_by)
187
- @stdout.puts("#{target.label} attached #{name}")
203
+ added = @access.context_add_url(target, url, why: why, hint: hint, added_by: added_by, movable: options[:movable])
204
+ warn_movable_dropped(target, added.name) if added.movable_dropped
205
+ @stdout.puts("#{target.label} attached #{added.name}")
188
206
  true
189
207
  end
190
208
  rescue ContextProviders::Invalid => e
@@ -210,6 +228,9 @@ module Samagotchi
210
228
  # two fetches would read as a change and then vanish.
211
229
  error_line("#{command_name}: #{SessionAccess::ContextPush.not_push_message(name)}")
212
230
  next false
231
+ when :continued
232
+ error_line("#{command_name}: #{SessionAccess::ContextPush.continued_message(name, target.session_id, pushed.next_id)}")
233
+ next false
213
234
  end
214
235
 
215
236
  @stdout.puts("#{target.label} #{name}: #{pushed.revision == pushed.before ? "unchanged" : "new text"} " \
@@ -452,7 +473,8 @@ module Samagotchi
452
473
  "cmd" => source.cmd, "every_seconds" => source.every_seconds, "why" => source.why, "hint" => source.hint,
453
474
  "added_by" => source.added_by, "created_at" => source.created_at, "fetched_at" => snapshot.fetched_at,
454
475
  "revision" => snapshot.revision, "summary" => snapshot.summary, "error" => snapshot.error,
455
- "muted" => entry.muted, "shadowed" => entry.shadowed, "unread" => entry.unread }.compact
476
+ "muted" => entry.muted, "shadowed" => entry.shadowed, "unread" => entry.unread,
477
+ "movable" => (source.movable? if source.scope == "session") }.compact
456
478
  end
457
479
 
458
480
  def print_rows(rows, labels:)
@@ -465,6 +487,7 @@ module Samagotchi
465
487
  else
466
488
  "ok"
467
489
  end
490
+ state = "#{state} · stays" if r["movable"] == false
468
491
  [*(labels ? [r["target"]] : []), r["name"], r["scope"], kind, age(r["fetched_at"]), state, r["why"].to_s]
469
492
  end
470
493
  widths = lines.transpose.map { |column| column.map(&:length).max }
@@ -4,6 +4,7 @@ require_relative "config"
4
4
  require_relative "context_candidates_log"
5
5
  require_relative "log"
6
6
  require_relative "system_one/question"
7
+ require_relative "system_one/runs"
7
8
 
8
9
  module Samagotchi
9
10
  # System 1's judgment of a session's candidate context
@@ -26,6 +27,8 @@ module Samagotchi
26
27
  # (attach, card, or nothing); audit only logs.
27
28
  module ContextJudge
28
29
  MODES = %w[off audit on].freeze
30
+ # Its saved asks' name (SystemOne::Runs).
31
+ JUDGMENT = "context.candidate"
29
32
  ABOUT_ID = "about"
30
33
  TASK_ID = "task"
31
34
  # The bound on each prompt in the state.
@@ -168,14 +171,15 @@ module Samagotchi
168
171
  # candidate is asked again at the next owner prompt.
169
172
  # @return [Hash, nil] the line's fields
170
173
  def ask(client:, job:, log:, settings:, cancel_controller: nil)
174
+ run_id = SystemOne::Runs.new_id(JUDGMENT)
171
175
  answers = client.ask(state: state(job), questions: questions(child: !job.parent_attached.nil?),
172
- cancel_controller: cancel_controller)
176
+ cancel_controller: cancel_controller, run_id: run_id)
173
177
  about = answers&.dig(ABOUT_ID)&.noul
174
178
  task = answers&.dig(TASK_ID)&.noul
175
179
  return nil if about.nil? || task.nil?
176
180
 
177
181
  stated = task >= TASK_MIN
178
- fields = { "title" => job.title, "phase" => job.phase, "owner_prompts" => job.owner_prompts,
182
+ fields = { "run_id" => run_id, "title" => job.title, "phase" => job.phase, "owner_prompts" => job.owner_prompts,
179
183
  "p" => about.round(4), "task_p" => task.round(4), "stated" => stated,
180
184
  "would" => stated ? would(about, settings) : nil, "mode" => settings.mode,
181
185
  "threshold" => settings.threshold, "offer_threshold" => settings.offer_threshold,
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "context_sources"
4
+ require_relative "session_inbox"
5
+ require_relative "log"
6
+
7
+ module Samagotchi
8
+ # Movable context on its two paths, a continue (SessionContinue) and a
9
+ # plugin fork (ctx.sessions.fork): from SessionManager.spawn_session's
10
+ # before_spawn, once the new session and its first note are saved and
11
+ # before its worker starts, so its first absorb sees the sources.
12
+ module ContextMove
13
+ # What the new session's note says it is from.
14
+ NOTE_SOURCE = "attached context"
15
+
16
+ # ContextSources.move, then a second note in the new session saying what
17
+ # moved and what stayed (named by time, it sorts after the first). Best
18
+ # effort: a failure is logged and the session starts without (its start
19
+ # must not be undone for it).
20
+ # @return [ContextSources::Moved]
21
+ def self.call(from:, to:, mode:, state_dir:, seed: nil)
22
+ moved = ContextSources.move(from: from, to: to, mode: mode, seed: seed, state_dir: state_dir)
23
+ text = moved.note_text(from)
24
+ SessionInbox.write_note(to, text: text, source: NOTE_SOURCE, state_dir: state_dir) if text
25
+ moved
26
+ rescue StandardError => e
27
+ Log.warn(:context, "context_move_failed", sid: to, from: from, error: e.class.name, msg: e.message.to_s[0, 200])
28
+ ContextSources::Moved.none
29
+ end
30
+ end
31
+ end