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
@@ -26,6 +26,13 @@
26
26
  # when the session comes back (a worker's restart), before its first turn
27
27
  # or command. Another session starts from the settings.
28
28
  #
29
+ # Each check-in also asks System 1 `check-in.progress` (docs/system-one.md),
30
+ # off until the user sets a mode for it: the turn's prompt, the calls since
31
+ # the last check-in and the count and elapsed time go, on a side thread, to
32
+ # the cheap judgment model. Nothing is done with the answer: it is saved,
33
+ # and what the user then did with the card is saved as a label on that run.
34
+ # The card, the nudge and the notify are the same either way.
35
+ #
29
36
  # Settings (config.yml, bundles: check-in:):
30
37
  # after: 50 tool calls in one turn before the first check-in
31
38
  # every: 50 then again every this many more
@@ -43,6 +50,15 @@ class Plugin
43
50
  DEFAULT_IGNORE = %w[task_wait task_get delegate_result].freeze
44
51
  LAST_TOOLS = 5
45
52
  USAGE = "usage: /checkin [on|off|<calls>|mode ask|nudge|notify|nudge|later|stop]"
53
+ # The audit judgment (docs/plugins.md, Judgments): one noul question per
54
+ # check-in, answered on a side thread and never acted on.
55
+ PROGRESS = "progress"
56
+ PROGRESS_QUESTION = "Is this turn still making progress toward what the user asked?"
57
+ # The calls since the last check-in, each cut as it is captured and kept
58
+ # together under about 4 KB (the oldest go first).
59
+ CALL_CHARS = 120
60
+ RESULT_CHARS = 200
61
+ CALLS_TOTAL = 4096
46
62
 
47
63
  def initialize(settings = {})
48
64
  @after = positive(settings["after"]) || 50
@@ -59,7 +75,14 @@ class Plugin
59
75
  end
60
76
 
61
77
  def register(chi)
62
- chi.on(:before_turn) { |_event, ctx| before_turn(ctx) }
78
+ # An older chi has no judgments; the check-in itself is the same there.
79
+ @declared = chi.respond_to?(:judgment)
80
+ if @declared
81
+ chi.judgment PROGRESS, questions: { PROGRESS => { type: "noul", instructions: PROGRESS_QUESTION } },
82
+ shared_host: :skip
83
+ end
84
+
85
+ chi.on(:before_turn) { |event, ctx| before_turn(event, ctx) }
63
86
  chi.on(:after_tool_call) { |event, ctx| after_tool_call(event, ctx) }
64
87
  chi.on(:after_turn) { |event, ctx| after_turn(event, ctx) }
65
88
  chi.command "/checkin", "check on a long turn: status, on|off, <calls>, mode ask|nudge|notify, nudge, later, stop",
@@ -82,13 +105,21 @@ class Plugin
82
105
  @nudge = nil # the card's Nudge this turn: { text:, count: }
83
106
  @checked_in = nil # the count at this turn's latest check-in
84
107
  @nudged_for = nil # the check-in (its count, :none before one) nudged
108
+ @prompt = nil # the owner's prompt, for the judgment
109
+ @calls_since = [] # the calls since the last check-in, for the judgment
110
+ @calls_bytes = 0
111
+ @run = nil # the latest check-in's run, for its labels
85
112
  end
86
113
 
87
- def before_turn(ctx)
114
+ def before_turn(event, ctx)
88
115
  close_card(ctx, "The turn ended after #{calls} tool calls.")
89
116
  @mutex.synchronize do
90
117
  load_session(ctx)
91
118
  reset
119
+ # A continue, a wake or a reminder has no prompt of its own, and a
120
+ # participant's turn says nothing about the owner's: the judgment gets
121
+ # none then.
122
+ @prompt = ctx.turn_author ? nil : event[:prompt]
92
123
  end
93
124
  end
94
125
 
@@ -100,13 +131,17 @@ class Plugin
100
131
  load_session(ctx)
101
132
  @count += 1
102
133
  @tools = (@tools + [tool]).last(LAST_TOOLS)
134
+ note_call(event)
103
135
  next nil unless @enabled && @count >= @next_at
104
136
 
105
137
  @next_at = @count + @every
106
138
  @checked_in = @count
107
139
  [@count, @mode]
108
140
  end
109
- check_in(ctx, *due) if due
141
+ return unless due
142
+
143
+ check_in(ctx, *due)
144
+ ask_progress(ctx)
110
145
  end
111
146
 
112
147
  def check_in(ctx, count, mode)
@@ -128,7 +163,7 @@ class Plugin
128
163
  end
129
164
 
130
165
  def after_turn(event, ctx)
131
- return if close_card(ctx, "The turn ended after #{calls} tool calls.")
166
+ label(ctx, "turn_stopped", by: "turn", strength: nil) if close_card(ctx, "The turn ended after #{calls} tool calls.")
132
167
 
133
168
  nudge_not_sent(event, ctx)
134
169
  end
@@ -191,6 +226,63 @@ class Plugin
191
226
  true
192
227
  end
193
228
 
229
+ # --- the progress judgment (audit) ---------------------------------------
230
+
231
+ # Every counted call since the last check-in, in the words System 1 sees:
232
+ # the tool, what it was asked for, what it said. Cut here, so the state
233
+ # stays near 4 KB whatever a tool returned (docs/system-one.md: this is
234
+ # tool output reaching System 1, which approvals and context never send).
235
+ def note_call(event)
236
+ entry = "#{event[:tool]} #{cut(call_args(event[:call]), CALL_CHARS)} -> #{cut(event[:result] || event[:output], RESULT_CHARS)}"
237
+ @calls_since << entry
238
+ @calls_bytes += entry.bytesize
239
+ @calls_bytes -= @calls_since.shift.bytesize while @calls_bytes > CALLS_TOTAL && @calls_since.size > 1
240
+ end
241
+
242
+ def call_args(call)
243
+ args = call.is_a?(Hash) ? (call[:arguments] || call["arguments"] || call) : call
244
+ return args if args.is_a?(String)
245
+
246
+ # The tool's name is already in the entry and nil fields say nothing:
247
+ # the cut should keep what the call asked for.
248
+ args = args.compact.except(:name, "name") if args.is_a?(Hash)
249
+ JSON.generate(args)
250
+ rescue StandardError
251
+ args.to_s
252
+ end
253
+
254
+ def cut(text, limit)
255
+ text = text.to_s
256
+ text.length > limit ? "#{text[0, limit]}…" : text
257
+ end
258
+
259
+ # The ask, on its own thread. Nothing reads the answer: the turn and the
260
+ # card are what they are with the judgment off. `shared_host: :skip` (the
261
+ # declaration) saves the run with `asked: false` when a turn is running on
262
+ # the same local System 1 host, so the ask never evicts that turn's cache.
263
+ def ask_progress(ctx)
264
+ return unless @declared
265
+
266
+ state = @mutex.synchronize do
267
+ state = { prompt: @prompt, calls: @count, elapsed_s: (monotonic - @started).round, tools: @calls_since.dup }
268
+ @calls_since = []
269
+ @calls_bytes = 0
270
+ state
271
+ end
272
+ run = ctx.judge_later(PROGRESS, state: state)
273
+ @mutex.synchronize { @run = run }
274
+ end
275
+
276
+ # What the user did with the card, saved on this turn's latest check-in's
277
+ # run: raw words, mapped only at analysis, since a Nudge asks for a status
278
+ # rather than a verdict and Keep going may be habit. A run that was never
279
+ # saved (the judgment off, no host) labels nothing.
280
+ def label(ctx, value, by: "card", strength: "weak")
281
+ return false unless @declared
282
+
283
+ ctx.judgments.label(@mutex.synchronize { @run }, value, by: by, strength: strength)
284
+ end
285
+
194
286
  # --- /checkin --------------------------------------------------------------
195
287
 
196
288
  def command(args, ctx)
@@ -262,6 +354,7 @@ class Plugin
262
354
  end
263
355
 
264
356
  @mutex.synchronize { @nudge = { text: text, count: count } }
357
+ label(ctx, "nudge") if card_open?
265
358
  close_card(ctx, "Nudged the model at #{count} tool calls.")
266
359
  nil
267
360
  end
@@ -270,12 +363,14 @@ class Plugin
270
363
  at = @mutex.synchronize { @next_at }
271
364
  return "check-in: no check-in open" unless close_card(ctx, "Kept going; the next check-in is at #{at} tool calls.")
272
365
 
366
+ label(ctx, "keep_going")
273
367
  nil
274
368
  end
275
369
 
276
370
  def stop(ctx)
277
371
  return "check-in: no turn running" unless ctx.stop_turn("stopped from check-in")
278
372
 
373
+ label(ctx, "stop") if card_open?
279
374
  close_card(ctx, "Stopped the turn at #{calls} tool calls.")
280
375
  nil
281
376
  end
@@ -326,6 +421,8 @@ class Plugin
326
421
 
327
422
  def calls = @mutex.synchronize { @count }
328
423
 
424
+ def card_open? = @mutex.synchronize { @card_open }
425
+
329
426
  def message(count) = @message.gsub("{calls}", count.to_s)
330
427
 
331
428
  def elapsed
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: core
3
- version: 0.4.0
3
+ version: 0.4.1
4
4
  scope: system
5
5
  description: "Recommended safety set"
6
6
  trust_level: reviewed
7
7
  # Its members' highest floor (work-cap's: ctx.usage, raised at the release)
8
- requires_chi: ">= 0.100.0"
8
+ requires_chi: ">= 0.142.0"
9
9
  includes:
10
10
  - loop-guard
11
11
  - check-in
@@ -16,9 +16,9 @@ Single registry `Samagotchi::Config` (`Config::ENTRIES` in `lib/samagotchi/confi
16
16
  - **YAML**: `nested: { param: value }` → dotted `nested.param` (lower `snake_case`, leaf keeps `_`; kebab alias `base-url` also accepted and normalized)
17
17
  - **CLI**: `--nested-param` (kebab, `_` → `-` for both section+leaf; `--nested_param` is rejected as unknown by `chi` with a "did you mean" hint)
18
18
 
19
- Sections forbid `_`/`-` (`SECTION_RE` `/\A[a-z0-9]+\z/`); leaves keep `snake_case` in YAML (`base_url`) and become kebab in CLI (`base-url`) via registry derivation — no generic string split, registry lookup avoids flat vs nested collision.
19
+ Sections forbid `_`/`-` (`SECTION_RE` `/\A[a-z0-9]+\z/`), since an env or CLI name is derived from them; a config-only section (`system_one`: `expose: [:config]`, no derived name) may carry `_`. Leaves keep `snake_case` in YAML (`base_url`) and become kebab in CLI (`base-url`) via registry derivation — no generic string split, registry lookup avoids flat vs nested collision.
20
20
 
21
- **Universal entries** (`expose: [:env,:config,:cli]`): `default.model`, `server.host/port/open_timeout/read_timeout`, `server.first_token_timeout` (env and config only), `recap.model/base_url/host_ref/inactivity/timeout/min_user_turns/sentences`, `session.retention_days/max_count/keep_status/sweep_interval_hours/idle_exit_minutes`, `session.shared/keep_empty/max_children` (env and config only), `log.file/disable`, `status.line`, `context.status/window_tokens/chars_per_token/status_thresholds`, `memory.index_warn_tokens`, `default.max_tokens`, `max_tool_output_chars`, `retry.max/base_delay/max_delay`, `read.*`, `execute.*`, `web.port/host`, `no_interrupt`, `no_default_input` etc. (`Config::ENTRIES`; `expose` says which of env/config/cli each takes). Precedence is `CLI > ENV > file > default`.
21
+ **Universal entries** (`expose: [:env,:config,:cli]`): `default.model`, `server.host/port/open_timeout/read_timeout`, `server.first_token_timeout` (env and config only), `recap.model/base_url/host_ref/inactivity/timeout/min_user_turns/sentences`, `session.retention_days/max_count/keep_status/sweep_interval_hours/idle_exit_minutes`, `session.shared/keep_empty/max_children` (env and config only), `log.file/disable`, `status.line`, `context.status/window_tokens/chars_per_token/status_thresholds`, `memory.index_warn_tokens/entry_warn_tokens/read_max_chars`, `default.max_tokens`, `max_tool_output_chars`, `retry.max/base_delay/max_delay`, `read.*`, `execute.*`, `web.port/host`, `no_interrupt`, `no_default_input` etc. (`Config::ENTRIES`; `expose` says which of env/config/cli each takes). Precedence is `CLI > ENV > file > default`.
22
22
 
23
23
  Example `config.yml` (new nested form, preferred):
24
24
 
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.141.0
3
+ version: 0.142.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:
7
7
  identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
8
- self_map.md: sha256:3253cb9ae7f09821d8fe1fed5154dd2b5e33fe0e9f096eee2af981d7d7d66f16
9
- config_modification_protocol.md: sha256:ff4efccf2bdde453fc4e6741df8c18f78da78e353f6b1a5a64d0e7a631aea258
10
- memory_guide.md: sha256:3b9ff779ee962f1307d56937c826d5e581923114f3f39455b779ca618b363cd7
8
+ self_map.md: sha256:b97087aff3621127e8519a22987a625798f88308d71c6184b68409b7f6f20570
9
+ config_modification_protocol.md: sha256:e6b55d8703ccce866047b412837c33ee23f34f894cd7a5b65421eee93ee844aa
10
+ memory_guide.md: sha256:64c3fa0703a3bdd8ae33b9e7cb341caa88a205c289d767f4877b38dfa6629fda
11
11
  delegated.md: sha256:a0d6ddcd4a8be06526d964898611775fd251cfd9d145a9d16afaccaa503597bd
@@ -41,7 +41,7 @@ This memory teaches you (the agent) how to use Samagotchi memories — persisten
41
41
 
42
42
  **When to write:**
43
43
  - After learning a durable preference (e.g. commit style, test command, coding guideline) that the user confirmed or you observed repeatedly — ask before overwriting existing entries where appropriate.
44
- - Keep entries small and focused (one topic per file). Use clear filenames: `commit_preferences`, `testing_guide`, `project_conventions`.
44
+ - Keep entries compact: `memory_read` returns a memory whole and every read pays its size. A write that takes one over `memory.entry_warn_tokens` (5000 tokens) ends with a note: tighten it when you next have a moment, but never remove or merge memories unless the user asks. Keep entries small and focused (one topic per file). Use clear filenames: `commit_preferences`, `testing_guide`, `project_conventions`.
45
45
  - Never store secrets, tokens, or transient state. Memories are shared via bundles. One exception: a skill may keep a working handoff memory (its steps say so) whose status is its description, removed when the work is done.
46
46
 
47
47
  **Placeholders:**
@@ -53,7 +53,7 @@
53
53
  approvals (I see only how each was answered); see `docs/sessions.md` "Delegating".
54
54
  - Bundles extend me: memories, `hooks/*.rb`, `guardrails/*.yml`, and a `plugin.rb` that adds slash
55
55
  commands (`anytime:` ones run mid-turn), model tools (they can return images I see), hooks,
56
- cards, side answers (`ask_model`), HTTP calls (`ctx.http`),
56
+ cards, side answers (`ask_model`), System 1 judgments (`chi.judgment`, `ctx.judge`), HTTP calls (`ctx.http`),
57
57
  child sessions and services (e.g. MCP server processes). Shipped: `btw` (`/btw` side question),
58
58
  `mcp` (MCP server tools, searched with find_tools and called with call_tool; a screenshot comes as a picture; a toolkit's undeclared built-ins too, as chi/<tool>), `guardrails` (rules), `known-names` (typo guard), `source-links`
59
59
  (turns source refs like JIRA-123 in an answer into links in the web, and a one-line note), `loop-guard`
@@ -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
@@ -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,