samagotchi 0.38.0 → 0.40.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -1
  3. data/docs/cli.md +2 -1
  4. data/docs/configuration.md +58 -2
  5. data/docs/hooks.md +1 -0
  6. data/docs/internals/background-tasks.md +1 -1
  7. data/docs/internals/context-telemetry.md +16 -0
  8. data/docs/internals/llm-context-bench.md +133 -0
  9. data/docs/internals/llm-context-forget.md +45 -0
  10. data/docs/internals/prompt-caching.md +3 -2
  11. data/docs/plugins.md +4 -3
  12. data/docs/testing.md +8 -0
  13. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  14. data/lib/samagotchi/bundles/loop-guard/plugin.rb +1 -1
  15. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  16. data/lib/samagotchi/bundles/mcp/plugin.rb +10 -10
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/system/self_map.md +4 -0
  19. data/lib/samagotchi/config.rb +43 -4
  20. data/lib/samagotchi/context_note.rb +7 -3
  21. data/lib/samagotchi/context_status.rb +112 -11
  22. data/lib/samagotchi/engine.rb +36 -9
  23. data/lib/samagotchi/guardrails/targets.rb +15 -3
  24. data/lib/samagotchi/host_registry.rb +8 -3
  25. data/lib/samagotchi/kernel_loop.rb +142 -14
  26. data/lib/samagotchi/llm/chat_loop.rb +57 -18
  27. data/lib/samagotchi/llm/turn_settings.rb +5 -4
  28. data/lib/samagotchi/llm_context_apply.rb +165 -0
  29. data/lib/samagotchi/llm_context_edit.rb +102 -0
  30. data/lib/samagotchi/llm_context_forget.rb +258 -0
  31. data/lib/samagotchi/llm_context_stale.rb +271 -0
  32. data/lib/samagotchi/llm_context_strategy.rb +177 -0
  33. data/lib/samagotchi/llm_context_view.rb +201 -0
  34. data/lib/samagotchi/log_subscriber.rb +2 -1
  35. data/lib/samagotchi/prompt.rb +4 -11
  36. data/lib/samagotchi/session_manager.rb +9 -1
  37. data/lib/samagotchi/session_metrics.rb +18 -1
  38. data/lib/samagotchi/system_prompt.rb +20 -15
  39. data/lib/samagotchi/terminal_ui/formatting.rb +4 -2
  40. data/lib/samagotchi/token_usage.rb +3 -0
  41. data/lib/samagotchi/tool_activity.rb +18 -0
  42. data/lib/samagotchi/tool_declarations.rb +60 -4
  43. data/lib/samagotchi/tool_ids.rb +84 -0
  44. data/lib/samagotchi/tool_response.rb +67 -6
  45. data/lib/samagotchi/tools/builtin_calls.rb +3 -2
  46. data/lib/samagotchi/tools/builtins.rb +15 -5
  47. data/lib/samagotchi/tools/context_read.rb +27 -10
  48. data/lib/samagotchi/tools/forget_outputs.rb +62 -0
  49. data/lib/samagotchi/tools/registry.rb +25 -8
  50. data/lib/samagotchi/version.rb +1 -1
  51. data/lib/samagotchi/web/public/app.js +1 -1
  52. data/lib/samagotchi/worker.rb +4 -0
  53. metadata +11 -1
@@ -22,6 +22,7 @@ require_relative "tools/send_note"
22
22
  require_relative "tools/context_read"
23
23
  require_relative "tools/delegate"
24
24
  require_relative "tools/delegate_result"
25
+ require_relative "tools/forget_outputs"
25
26
 
26
27
  module Samagotchi
27
28
  # The one-line summary of a tool call that the UIs show ("reading file",
@@ -172,6 +173,7 @@ module Samagotchi
172
173
  when Tools::ContextRead::NAME then "reading attached context"
173
174
  when Tools::Delegate::NAME then "delegating"
174
175
  when Tools::DelegateResult::NAME then "waiting for a delegate"
176
+ when Tools::ForgetOutputs::NAME then forget_outputs_action(call)
175
177
  else registry_entry(registry, tool_name)&.label || "calling tool"
176
178
  end
177
179
  end
@@ -192,6 +194,14 @@ module Samagotchi
192
194
  "saving memory"
193
195
  end
194
196
 
197
+ # forget_outputs' label: a call with only restore brings outputs back.
198
+ def forget_outputs_action(call)
199
+ return "forgetting outputs" unless call.is_a?(Hash)
200
+
201
+ request = Tools::ForgetOutputs.parse(call)
202
+ request.ids.empty? && request.restore? ? "restoring outputs" : "forgetting outputs"
203
+ end
204
+
195
205
  def tool_activity_status(result, tool_name = nil)
196
206
  text = result.to_s
197
207
  return "stopped" if text == Tools::Execute::NOT_RUN_ON_STOP
@@ -285,6 +295,14 @@ module Samagotchi
285
295
  parts.join(" ")
286
296
  when Tools::DelegateResult::NAME
287
297
  call[:session].to_s.strip.empty? ? nil : "session=#{preview_tool_param(call[:session])}"
298
+ when Tools::ForgetOutputs::NAME
299
+ request = Tools::ForgetOutputs.parse(call)
300
+ parts = []
301
+ parts << "ids=#{preview_tool_param(request.ids.join(","))}" unless request.ids.empty?
302
+ parts << "keep=#{preview_tool_param(request.keep.keys.join(","))}" unless request.keep.empty?
303
+ parts << "restore=#{preview_tool_param(request.restore.join(","))}" if request.restore?
304
+ parts << "note=#{preview_tool_param(request.note)}" unless request.note.empty?
305
+ parts.empty? ? nil : parts.join(" ")
288
306
  when Tools::AskUserQuestion::NAME
289
307
  parts = ["question=#{preview_tool_param(call[:question] || call[:content])}"]
290
308
  opts = call[:options]
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "json"
4
4
  require_relative "tools/args"
5
+ require_relative "llm_context_strategy"
5
6
 
6
7
  module Samagotchi
7
8
  # Tool-declaration constants, protocol constants, and guidance text.
@@ -437,6 +438,62 @@ module Samagotchi
437
438
  }
438
439
  ].freeze
439
440
 
441
+ # forget_outputs, the forget layer's tool (LLMContextForget): offered
442
+ # only in a turn whose LLM context strategy has forget
443
+ # (Tools::Registry's layer gate), so it is not in TOOL_SCHEMAS, which
444
+ # every turn declares. Its description carries llm_context.policy
445
+ # (#forget_outputs_schema); this one the default's.
446
+ FORGET_OUTPUTS_POLICY = LLMContextStrategy::DEFAULT_POLICY
447
+ FORGET_OUTPUTS_DESCRIPTION = "Forget tool outputs you no longer need, to free context. Every tool output starts " \
448
+ "with an id like [#t42]: list ids to forget, and each output is replaced in place by " \
449
+ "a stub with your note (the call stays). %<policy>s\n" \
450
+ "The note is required and is all that stays of them, so write it for your future " \
451
+ "self: the facts you carry forward, quoted (file:line; values marked VERIFIED or " \
452
+ "UNVERIFIED); what you ruled out, with the reason and \"do not retry\"; the exact " \
453
+ "commands you tried; a last line NEXT: what you do next. Never \"not needed\".\n" \
454
+ "Good candidates: settled exploration, dead ends, long logs and test runs whose " \
455
+ "result the note keeps, listings, reads of files you're done with. Keep what you'll " \
456
+ "still edit against (edit needs the exact text): leave it, or forget it but keep " \
457
+ "its lines with keep. Outputs from your last few steps can't be forgotten yet.\n" \
458
+ "Cost: a forget makes the server read again everything after the earliest output " \
459
+ "it touches, so forget in one batch at a natural break, prefer older outputs, and " \
460
+ "skip small ones. The result says whether the stubs go out at once or at the " \
461
+ "turn's end. restore brings a forgotten output back (for a read, read the file " \
462
+ "again)."
463
+
464
+ module_function
465
+
466
+ # The forget_outputs schema, its description with +policy+ (the
467
+ # llm_context.policy sentence; blank: none).
468
+ def forget_outputs_schema(policy: FORGET_OUTPUTS_POLICY)
469
+ policy = policy.to_s.strip
470
+ description = format(FORGET_OUTPUTS_DESCRIPTION, policy: policy.empty? ? "" : "Policy: #{policy}")
471
+ {
472
+ name: "forget_outputs",
473
+ description: description.gsub(/ +\n/, "\n"),
474
+ parameters: {
475
+ type: "object",
476
+ properties: {
477
+ ids: { type: "array", items: { type: "string" },
478
+ description: "Output ids to forget, e.g. [\"t41\", \"t42\"]" },
479
+ note: { type: "string",
480
+ description: "Required with ids: the facts carried forward (quoted; VERIFIED or UNVERIFIED), what " \
481
+ "is ruled out and why (do not retry), the commands tried, and NEXT: …" },
482
+ keep: { type: "array", items: { type: "string" },
483
+ description: "Lines to keep from an output you are forgetting: \"t42:12-40\" forgets t42 except " \
484
+ "those lines (a read's are the file's line numbers; other outputs count from 1)" },
485
+ restore: { type: "array", items: { type: "string" },
486
+ description: "Ids of forgotten outputs to bring back instead" }
487
+ }
488
+ }
489
+ }
490
+ end
491
+
492
+ FORGET_OUTPUTS_SCHEMA = forget_outputs_schema.freeze
493
+ # Every built-in's schema: the ones each turn declares, then the ones a
494
+ # layer adds (BuiltinCalls maps their calls).
495
+ BUILTIN_SCHEMAS = (TOOL_SCHEMAS + [FORGET_OUTPUTS_SCHEMA]).freeze
496
+
440
497
  # Where Gemma's declaration text says something the schema doesn't:
441
498
  # extra description text.
442
499
  GEMMA_PARAM_OVERRIDES = {
@@ -456,8 +513,6 @@ module Samagotchi
456
513
 
457
514
  GEMMA_QUOTE = '<|"|>'
458
515
 
459
- module_function
460
-
461
516
  # +schema+ without execute's description property: what a session
462
517
  # with execute.description off declares (the parameter isn't offered
463
518
  # at all). Any other schema as given.
@@ -526,8 +581,9 @@ module Samagotchi
526
581
  # The schemas the native (Gemma, Qwen) prompts declare: the built-ins
527
582
  # as they are, and each plugin tool's flattened (.flat_schema).
528
583
  # @param registry [Tools::Registry]
529
- def native_schemas(registry)
530
- registry.entries.map { |entry| entry.core? ? entry.schema : flat_schema(entry.schema) }
584
+ # @param layers [Array<Symbol>] the turn's LLM context layers (Registry#entries)
585
+ def native_schemas(registry, layers: [])
586
+ registry.entries(layers: layers).map { |entry| entry.core? ? entry.schema : flat_schema(entry.schema) }
531
587
  end
532
588
 
533
589
  # A plugin tool's schema in the shape the built-ins have, which is all
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tool_response"
4
+ require_relative "context_note"
5
+
6
+ module Samagotchi
7
+ # chi's own ids for tool outputs, one per run ("t41"), saved on the
8
+ # tool_response entry as tool_ids (ToolResponse.joined: one per call of
9
+ # the batch; ToolResponse.single: its one call). A strategy's edits
10
+ # (LLMContextView) name the outputs they change by them; the prompt never
11
+ # shows them under none.
12
+ #
13
+ # The next id is one past the highest the conversation holds, read when
14
+ # the runs are added: the ids live on the entries, so they go wherever
15
+ # the entries go (a turn's result, a save and load, --resume, a plugin's
16
+ # sessions.fork) with no counter of their own to keep in step. A rollback
17
+ # drops the later entries and their ids with them; an id handed out
18
+ # again after it names an output nothing else refers to any more (an
19
+ # edit is saved on the entry it changes, so it went too).
20
+ #
21
+ # Copies of an entry share its tool_ids Array (a checkpoint, a fork and
22
+ # duplicate_conversation copy the top level only): set it, never change
23
+ # it in place.
24
+ #
25
+ # An entry saved before the ids has none: #refs derives them on read
26
+ # from its index and the run's position ("e12.1"), marked derived, so a
27
+ # later strategy can refuse to change what it can't name for sure.
28
+ # #refs_at counts that index as if the conversation had its system head:
29
+ # each request puts a head on the stored messages
30
+ # (ContextNote.with_system_head replaces a saved one, and adds one to a
31
+ # head-less fork or a session before its first save), so an entry's
32
+ # derived id is the same in the stored session, every request and the
33
+ # warm-up, and an edit saved under it still names its run.
34
+ module ToolIds
35
+ PATTERN = /\At(\d+)\z/
36
+ DERIVED_PREFIX = "e"
37
+
38
+ # One run's id in an entry: the id, the run's position (0-based) and
39
+ # whether it was derived.
40
+ Ref = Data.define(:id, :run, :derived) do
41
+ def derived? = derived
42
+ end
43
+
44
+ module_function
45
+
46
+ # +count+ ids for runs about to go on +conversation+.
47
+ # @return [Array<String>]
48
+ def next_ids(conversation, count)
49
+ first = highest(conversation) + 1
50
+ Array.new(count) { |offset| "t#{first + offset}" }
51
+ end
52
+
53
+ # The highest stored id's number in +conversation+, 0 for none.
54
+ def highest(conversation)
55
+ Array(conversation).reduce(0) do |max, entry|
56
+ next max unless entry.is_a?(Hash) && entry[:role].to_s == "tool_response"
57
+
58
+ Array(entry[:tool_ids]).reduce(max) do |acc, id|
59
+ (match = PATTERN.match(id.to_s)) ? [acc, match[1].to_i].max : acc
60
+ end
61
+ end
62
+ end
63
+
64
+ # The ids of +conversation+[+index+]'s runs: #refs, a derived id
65
+ # counted as if the conversation had its system head.
66
+ # @return [Array<Ref>]
67
+ def refs_at(conversation, index)
68
+ refs(conversation[index], ContextNote.system_head?(conversation.first) ? index : index + 1)
69
+ end
70
+
71
+ # The ids of +entry+'s runs (+index+ its place in the conversation):
72
+ # the stored tool_ids, else derived ones, one per run (a chat result
73
+ # paired by tool_call_id is one run; a native entry has a run per
74
+ # "[name]" part, ToolResponse.split).
75
+ # @return [Array<Ref>]
76
+ def refs(entry, index)
77
+ ids = Array(entry[:tool_ids])
78
+ return ids.each_with_index.map { |id, run| Ref.new(id: id.to_s, run: run, derived: false) } unless ids.empty?
79
+
80
+ count = entry[:tool_call_id] ? 1 : [ToolResponse.split(entry[:content]).size, 1].max
81
+ Array.new(count) { |run| Ref.new(id: "#{DERIVED_PREFIX}#{index}.#{run + 1}", run: run, derived: true) }
82
+ end
83
+ end
84
+ end
@@ -10,8 +10,19 @@ module Samagotchi
10
10
  module ToolResponse
11
11
  SEPARATOR = "\n\n---\n\n"
12
12
  # Saved with a result for the web's reload (a plugin tool's params line
13
- # and label, an edit/write's diff); the prompt never reads them.
14
- SAVED_KEYS = %i[tool_params tool_labels tool_diffs].freeze
13
+ # and label, an edit/write's diff) and for the LLMContextView (each
14
+ # run's chi-owned id, ToolIds, and a strategy's edits by id,
15
+ # LLMContextEdit); the prompt never renders them.
16
+ SAVED_KEYS = %i[tool_params tool_labels tool_diffs tool_ids edits].freeze
17
+ # A run's "[name]" lead: the name, then a newline, a space or the end.
18
+ LEAD = /\A\[([^\]\s]+)\](?:\n| |\z)/
19
+
20
+ # One run's text in a joined entry: its "[name]" lead (with what
21
+ # follows it) and the rest. A part without a lead has no name and an
22
+ # empty lead.
23
+ RunText = Data.define(:name, :lead, :body) do
24
+ def text = "#{lead}#{body}"
25
+ end
15
26
 
16
27
  module_function
17
28
 
@@ -39,8 +50,9 @@ module Samagotchi
39
50
 
40
51
  # The native loop's entry for a batch: the capped outputs joined, every image
41
52
  # in call order with how many each call returned (the web's reload puts
42
- # each on its own tool row), and the saved-only fields one per call.
43
- def joined(runs)
53
+ # each on its own tool row), and the saved-only fields one per call:
54
+ # +ids+ (ToolIds.next_ids) as tool_ids.
55
+ def joined(runs, ids:)
44
56
  images = runs.flat_map { |run| Array(run[:images]) }
45
57
  entry = { role: "tool_response", content: runs.map { |run| run[:capped_output] }.join(SEPARATOR) }
46
58
  unless images.empty?
@@ -50,18 +62,67 @@ module Samagotchi
50
62
  saved = { tool_params: runs.map { |run| run[:shown_params] }, tool_labels: runs.map { |run| run[:shown_label] },
51
63
  tool_diffs: runs.map { |run| run[:diff] } }
52
64
  saved.each { |key, values| entry[key] = values if values.any? }
65
+ entry[:tool_ids] = ids
53
66
  entry
54
67
  end
55
68
 
56
69
  # The chat loop's entry for one call: its capped output, paired with the
57
- # call by +tool_call_id+.
58
- def single(run, tool_call_id:)
70
+ # call by +tool_call_id+, and its chi-owned id (a one-id +ids+, as
71
+ # #joined's tool_ids).
72
+ def single(run, tool_call_id:, ids:)
59
73
  entry = { role: "tool_response", content: run[:capped_output], tool_call_id: tool_call_id }
60
74
  entry[:images] = run[:images] if run[:images]&.any?
61
75
  entry[:tool_params] = run[:shown_params] if run[:shown_params]
62
76
  entry[:tool_labels] = run[:shown_label] if run[:shown_label]
63
77
  entry[:tool_diffs] = run[:diff] if run[:diff]
78
+ entry[:tool_ids] = ids
64
79
  entry
65
80
  end
81
+
82
+ # An entry's +content+ as +count+ runs, the way the LLMContextView
83
+ # sends them: one run is the whole content (its lead when it opens
84
+ # with one), more are #split's. The count may not match (#runs_named
85
+ # checks the parts).
86
+ # @return [Array<RunText>]
87
+ def runs(content, count)
88
+ return split(content, -1) unless count == 1
89
+
90
+ run = split(content, -1).first
91
+ return [RunText.new(name: nil, lead: "", body: content.to_s)] unless run&.name
92
+
93
+ [RunText.new(name: run.name, lead: run.lead, body: content.to_s.delete_prefix(run.lead))]
94
+ end
95
+
96
+ # +content+'s runs (#runs) when they are a batch's whose calls are
97
+ # +names+, in order: as many parts as calls, each opening with its
98
+ # call's "[name]" lead; nil when not. The part count alone can be
99
+ # fooled: a "ran as:" line before a lead (ToolRunner, a corrected call)
100
+ # merges that run into the one before it, and an output holding
101
+ # SEPARATOR + "[x] " adds a part, and the two cancel out. So whatever
102
+ # changes a run of an entry (a strategy's edit, LLMContextView) checks
103
+ # the names first.
104
+ # @return [Array<RunText>, nil]
105
+ def runs_named(content, names)
106
+ parts = runs(content, names.size)
107
+ parts.map(&:name) == names.map(&:to_s) ? parts : nil
108
+ end
109
+
110
+ # A joined entry's +content+ split into its runs (RunText), by the
111
+ # SEPARATOR heuristic: a part that opens with a "[name]" lead starts a
112
+ # run, any other part is the previous run's own text (the first one a
113
+ # run without a name). +limit+ goes to String#split (-1 keeps trailing
114
+ # empty parts, so the texts join back to +content+).
115
+ # @return [Array<RunText>]
116
+ def split(content, limit = 0)
117
+ content.to_s.split(SEPARATOR, limit).each_with_object([]) do |part, runs|
118
+ if (match = part.match(LEAD))
119
+ runs << RunText.new(name: match[1], lead: match[0], body: match.post_match)
120
+ elsif runs.empty?
121
+ runs << RunText.new(name: nil, lead: "", body: part)
122
+ else
123
+ runs[-1] = runs.last.with(body: "#{runs.last.body}#{SEPARATOR}#{part}")
124
+ end
125
+ end
126
+ end
66
127
  end
67
128
  end
@@ -52,7 +52,8 @@ module Samagotchi
52
52
  "context_read" => { content: "name", fallback: :prefix_or_raw },
53
53
  "delegate" => { content: "task", fallback: :prefix_or_raw },
54
54
  "ask_user_question" => { content: "question", also: %w[question], verbatim: %w[options], options: true,
55
- fallback: :raw }
55
+ fallback: :raw },
56
+ "forget_outputs" => { content: "note", also: %w[note], verbatim: %w[ids keep restore] }
56
57
  }.freeze
57
58
 
58
59
  # One built-in's mapping, resolved from its schema and OVERRIDES.
@@ -73,7 +74,7 @@ module Samagotchi
73
74
 
74
75
  # @return [Hash{String => Row}]
75
76
  def rows
76
- @rows ||= ToolDeclarations::TOOL_SCHEMAS.to_h { |schema| [schema[:name], row_for(schema)] }.freeze
77
+ @rows ||= ToolDeclarations::BUILTIN_SCHEMAS.to_h { |schema| [schema[:name], row_for(schema)] }.freeze
77
78
  end
78
79
 
79
80
  def row_for(schema)
@@ -22,6 +22,7 @@ require_relative "context_read"
22
22
  require_relative "delegate"
23
23
  require_relative "delegate_result"
24
24
  require_relative "ask_user_question"
25
+ require_relative "forget_outputs"
25
26
  require_relative "../guardrails/parent_approvals"
26
27
  require_relative "../model_profile"
27
28
 
@@ -56,6 +57,8 @@ module Samagotchi
56
57
  DelegateResult,
57
58
  AskUserQuestion
58
59
  ].freeze
60
+ # The built-ins an LLM context layer adds (offered only under it).
61
+ LAYER_CLASSES = [ForgetOutputs].freeze
59
62
 
60
63
  # The handlers that pass more than the call's content.
61
64
  HANDLERS = {
@@ -104,7 +107,8 @@ module Samagotchi
104
107
  DelegateResult::NAME => lambda do |call, kctx|
105
108
  DelegateResult.call(call[:content], session: call[:session], timeout: call[:timeout], peers: kctx.peers)
106
109
  end,
107
- AskUserQuestion::NAME => ->(call, kctx) { kctx.ask_user_question(call) }
110
+ AskUserQuestion::NAME => ->(call, kctx) { kctx.ask_user_question(call) },
111
+ ForgetOutputs::NAME => ->(call, kctx) { kctx.forget_outputs(call) }
108
112
  }.freeze
109
113
 
110
114
  module_function
@@ -152,16 +156,22 @@ module Samagotchi
152
156
 
153
157
  # @param command_description [Boolean] false: execute declares no
154
158
  # description parameter (execute.description off)
159
+ # @param policy [String, nil] llm_context.policy, the sentence
160
+ # forget_outputs' description carries
155
161
  # @return [Registry] a new registry with the built-ins, in
156
- # TOOL_SCHEMAS order (an Engine's own, which bundles add to)
157
- def registry(command_description: true)
162
+ # TOOL_SCHEMAS order (an Engine's own, which bundles add to), then
163
+ # forget_outputs, offered only under the forget layer
164
+ def registry(command_description: true, policy: ToolDeclarations::FORGET_OUTPUTS_POLICY)
158
165
  by_name = CLASSES.to_h { |klass| [klass::NAME, klass] }
159
- ToolDeclarations::TOOL_SCHEMAS.each_with_object(Registry.new) do |schema, registry|
166
+ registry = ToolDeclarations::TOOL_SCHEMAS.each_with_object(Registry.new) do |schema, built|
160
167
  schema = ToolDeclarations.without_command_description(schema) unless command_description
161
168
  klass = by_name.fetch(schema[:name])
162
169
  handler = HANDLERS.fetch(klass::NAME) { ->(call, _kctx) { klass.call(call[:content]) } }
163
- registry.register(klass::NAME, schema: schema, handler: handler)
170
+ built.register(klass::NAME, schema: schema, handler: handler)
164
171
  end
172
+ registry.register(ForgetOutputs::NAME, schema: ToolDeclarations.forget_outputs_schema(policy: policy),
173
+ handler: HANDLERS.fetch(ForgetOutputs::NAME), layer: :forget)
174
+ registry
165
175
  end
166
176
 
167
177
  # @return [Registry] the built-ins alone, frozen: a KernelLoop or a
@@ -15,8 +15,17 @@ module Samagotchi
15
15
  class ContextRead
16
16
  NAME = "context_read"
17
17
  DEFAULT_MAX_CHARS = 10_000
18
- # Room for the header and the paging line inside the cap.
19
- HEADER_ROOM = 1_000
18
+ # The smallest cap it pages to (a smaller one is taken as this).
19
+ MIN_CHARS = 2_000
20
+ # What the kernel puts before the output ("[context_read]\n"), and
21
+ # ToolRunner counts in the same cap: a page that fits never gets the
22
+ # runner's cut on top of its own paging.
23
+ PREFIX_ROOM = "[#{NAME}]\n".length
24
+ # Between the header and the page.
25
+ SEPARATOR = "\n---\n"
26
+ # A page's text at least, whatever the header takes (a header over
27
+ # the cap is the runner's to cut).
28
+ MIN_PAGE_CHARS = 200
20
29
 
21
30
  def self.name = NAME
22
31
 
@@ -44,7 +53,7 @@ module Samagotchi
44
53
  # (specs, a bare kernel) has only its folder.
45
54
  root = peers.respond_to?(:project_root) ? peers.project_root : ProjectScope.root_for(peers.cwd)
46
55
  @attached = ContextSources.attached(peers.session_id, project_root: root, state_dir: @state_dir)
47
- @max_chars = [max_chars.to_i, HEADER_ROOM * 2].max
56
+ @max_chars = [max_chars.to_i, MIN_CHARS].max
48
57
  @now = now
49
58
  end
50
59
 
@@ -97,7 +106,7 @@ module Samagotchi
97
106
 
98
107
  page = lines[(first - 1)..] || []
99
108
  page = page.first(positive(limit)) if positive(limit)
100
- page = fit(page)
109
+ page = fit(page, room: room(header, lines.size))
101
110
  last = first + page.size - 1
102
111
  @own.update_subscription(attached.name) { |sub| sub.with(read: snapshot.revision) }
103
112
 
@@ -105,7 +114,7 @@ module Samagotchi
105
114
  more = last < lines.size ? " Pass offset: #{last + 1} for more." : ""
106
115
  header << "Lines #{first}-#{last} of #{lines.size}.#{more}"
107
116
  end
108
- "#{header.join("\n")}\n---\n#{page.join}"
117
+ "#{header.join("\n")}#{SEPARATOR}#{page.join}"
109
118
  end
110
119
 
111
120
  def header_lines(attached, snapshot)
@@ -119,16 +128,24 @@ module Samagotchi
119
128
  header
120
129
  end
121
130
 
122
- # The lines that fit in the cap (at least one, cut when that one is
123
- # longer than the cap).
124
- def fit(lines)
125
- budget = @max_chars - HEADER_ROOM
131
+ # What the output takes besides the page: the kernel's prefix, the
132
+ # header, its paging line at its longest (every number +count+) and
133
+ # the separator.
134
+ def room(header, count)
135
+ paging = "\nLines #{count}-#{count} of #{count}. Pass offset: #{count} for more."
136
+ PREFIX_ROOM + header.join("\n").length + paging.length + SEPARATOR.length
137
+ end
138
+
139
+ # The lines that fit in the cap beside +room+ (at least one, cut,
140
+ # its "…\n" included, when that one is longer).
141
+ def fit(lines, room:)
142
+ budget = [@max_chars - room, MIN_PAGE_CHARS].max
126
143
  taken = []
127
144
  used = 0
128
145
  lines.each do |line|
129
146
  break if used + line.length > budget && taken.any?
130
147
 
131
- taken << (line.length > budget ? "#{line[0, budget]}…\n" : line)
148
+ taken << (line.length > budget ? "#{line[0, budget - 2]}…\n" : line)
132
149
  used += line.length
133
150
  end
134
151
  taken
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Samagotchi
6
+ module Tools
7
+ # forget_outputs, the forget layer's tool (LLMContextStrategy :forget):
8
+ # the model forgets its own tool outputs by id, with a note that keeps
9
+ # what it learned, or restores ones it forgot. Its schema is
10
+ # ToolDeclarations.forget_outputs_schema; the work is LLMContextForget's,
11
+ # on the running turn's conversation (KernelLoop#forget_outputs, through
12
+ # the handler's kctx). This reads the call: models write lists as JSON,
13
+ # as a JSON string, or as words, so every id form is taken.
14
+ class ForgetOutputs
15
+ NAME = "forget_outputs"
16
+
17
+ def self.name = NAME
18
+
19
+ # A call, read: the ids to forget, the note, the lines to keep
20
+ # ({id => [[first, last], …]}), and the ids to restore.
21
+ Request = Data.define(:ids, :note, :keep, :restore) do
22
+ def restore? = !restore.empty?
23
+ end
24
+
25
+ ID = /#?\b(t\d+)\b/
26
+ RANGE = /#?\b(t\d+)\s*[:=]\s*(?:lines?\s*)?(\d+)\s*(?:[-–]\s*(\d+))?/
27
+
28
+ # @param call [Hash] the internal call (BuiltinCalls: ids:, note: as
29
+ # content:, keep:, restore:)
30
+ # @return [Request]
31
+ def self.parse(call)
32
+ keep = ranges(call[:keep])
33
+ Request.new(ids: (ids(call[:ids]) + keep.keys).uniq, note: (call[:note] || call[:content]).to_s.strip,
34
+ keep: keep, restore: ids(call[:restore]))
35
+ end
36
+
37
+ # The ids in +raw+: "t41", "#t41", a list, a JSON list, or words.
38
+ def self.ids(raw)
39
+ text(raw).scan(ID).flatten.uniq
40
+ end
41
+
42
+ # "t42:12-40" (or "t42: lines 12-40", "t42=7") entries, by id.
43
+ def self.ranges(raw)
44
+ text(raw).scan(RANGE).each_with_object({}) do |(id, first, last), keep|
45
+ first = first.to_i
46
+ last = last ? last.to_i : first
47
+ (keep[id] ||= []) << [first, last] if first.positive? && last >= first
48
+ end
49
+ end
50
+
51
+ def self.text(raw)
52
+ case raw
53
+ when nil then ""
54
+ when Array then raw.map { |item| text(item) }.join(" ")
55
+ when Hash then raw.map { |key, value| "#{key}:#{text(value)}" }.join(" ")
56
+ else raw.to_s
57
+ end
58
+ end
59
+ private_class_method :text
60
+ end
61
+ end
62
+ end
@@ -21,8 +21,14 @@ module Samagotchi
21
21
  # @!attribute preview [#call, nil] call → the activity line's params
22
22
  # @!attribute targets [#call, nil] call → what guardrail rules match
23
23
  # @!attribute source [String] "core", or the bundle that added it
24
- Entry = Struct.new(:name, :schema, :handler, :label, :preview, :targets, :source, keyword_init: true) do
24
+ # @!attribute layer [Symbol, nil] the LLM context layer the tool
25
+ # belongs to (LLMContextStrategy): offered only in a turn running
26
+ # under it (forget_outputs, :forget); nil: always
27
+ Entry = Struct.new(:name, :schema, :handler, :label, :preview, :targets, :source, :layer, keyword_init: true) do
25
28
  def core? = source == "core"
29
+
30
+ # Offered in a turn under +layers+ (the turn's LLM context layers).
31
+ def offered?(layers) = layer.nil? || Array(layers).include?(layer)
26
32
  end
27
33
 
28
34
  def initialize
@@ -30,33 +36,44 @@ module Samagotchi
30
36
  end
31
37
 
32
38
  # @return [Entry]
33
- def register(name, schema:, handler:, label: nil, preview: nil, targets: nil, source: "core")
39
+ def register(name, schema:, handler:, label: nil, preview: nil, targets: nil, source: "core", layer: nil)
34
40
  raise ArgumentError, "tool #{name} is already registered" if @entries.key?(name)
35
41
 
36
42
  @entries[name] = Entry.new(name: name, schema: schema, handler: handler, label: label, preview: preview,
37
- targets: targets, source: source)
43
+ targets: targets, source: source, layer: layer)
38
44
  end
39
45
 
40
46
  # Remove a tool (a plugin's tool set changed after load).
41
47
  # @return [Entry, nil] the removed entry
42
48
  def unregister(name) = @entries.delete(name.to_s)
43
49
 
44
- # @return [Entry, nil]
50
+ # @return [Entry, nil] whatever the turn offers
45
51
  def [](name) = @entries[name.to_s]
46
52
 
47
53
  def key?(name) = @entries.key?(name.to_s)
48
54
 
55
+ # The entry +name+ names when a turn under +layers+ offers it (the
56
+ # one a call dispatches to).
57
+ # @return [Entry, nil]
58
+ def offered(name, layers: [])
59
+ entry = self[name]
60
+ entry if entry&.offered?(layers)
61
+ end
62
+
63
+ # The tools a turn offers is per turn: +layers+, the turn's LLM
64
+ # context layers (LLMContextStrategy), adds the tools gated by them.
65
+ # Without layers (none, the default) the list is what it always was.
49
66
  # @return [Array<String>] in declaration order
50
- def names = entries.map(&:name)
67
+ def names(layers: []) = entries(layers: layers).map(&:name)
51
68
 
52
69
  # @return [Array<Entry>] in declaration order
53
- def entries
54
- core, added = @entries.values.partition(&:core?)
70
+ def entries(layers: [])
71
+ core, added = @entries.values.select { |entry| entry.offered?(layers) }.partition(&:core?)
55
72
  core + added.sort_by { |entry| [entry.source.to_s, entry.name] }
56
73
  end
57
74
 
58
75
  # @return [Array<Hash>] the schemas, in declaration order
59
- def schemas = entries.map(&:schema)
76
+ def schemas(layers: []) = entries(layers: layers).map(&:schema)
60
77
 
61
78
  def freeze
62
79
  @entries.freeze
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.38.0"
4
+ VERSION = "0.40.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -367,7 +367,7 @@ function cardHtml(s, { deletable = false, archivable = false, selectable = false
367
367
  const kidsHtml = fam ? familyChipHtml(fam, { open }) : "";
368
368
  const rowsHtml = fam && rows && open ? familyRowsHtml(fam, { activeId: selected, matchIds, archivable: !selectable }) : "";
369
369
  const membersAttr = fam ? ` data-members="${escapeHtml(fam.members.map((m) => m.session.id).join(" "))}"` : "";
370
- return `<div class="card${s.archived ? " archived" : ""}${waiting || familyWaiting ? " waiting" : ""}${selectable && picked ? " picked" : ""}${fam && rows && open ? " family-open" : ""}" data-id="${s.id}"${membersAttr} title=""${escapeHtml(tip)}${memTitle ? ` · ${escapeHtml(memTitle)}` : ""}"><div class="preview">${preview}</div>${s.recap ? `<div class="recap" title="${escapeHtml(s.recap)}">${escapeHtml(s.recap)}</div>` : ""}<div class="meta"><span class="status dot ${escapeHtml(s.status)}${waiting ? " waiting" : ""}" title="${escapeHtml(dotWord)}" aria-label="${escapeHtml(dotWord)}"></span>${waitingHtml}${stoppedHtml}${timeHtml}${ctxHtml}${folderHtml}${parentChipHtml(s)}${kidsHtml}${ownerBadgeHtml(s)}${archivedHtml}${s.used_memory_names && s.used_memory_names.length ? `<span title="${escapeHtml(s.used_memory_names.join(", "))}">mem ${s.used_memory_names.length}</span>` : ""}</div>${rowsHtml}${skipHtml}${pickHtml}${selectable ? "" : cardToolsHtml(s, { deletable, archivable })}</div>`;
370
+ return `<div class="card${s.archived ? " archived" : ""}${waiting || familyWaiting ? " waiting" : ""}${selectable && picked ? " picked" : ""}${fam && rows && open ? " family-open" : ""}" data-id="${s.id}"${membersAttr} title="${escapeHtml(tip)}${memTitle ? ` · ${escapeHtml(memTitle)}` : ""}"><div class="preview">${preview}</div>${s.recap ? `<div class="recap" title="${escapeHtml(s.recap)}">${escapeHtml(s.recap)}</div>` : ""}<div class="meta"><span class="status dot ${escapeHtml(s.status)}${waiting ? " waiting" : ""}" title="${escapeHtml(dotWord)}" aria-label="${escapeHtml(dotWord)}"></span>${waitingHtml}${stoppedHtml}${timeHtml}${ctxHtml}${folderHtml}${parentChipHtml(s)}${kidsHtml}${ownerBadgeHtml(s)}${archivedHtml}</div>${rowsHtml}${skipHtml}${pickHtml}${selectable ? "" : cardToolsHtml(s, { deletable, archivable })}</div>`;
371
371
  }
372
372
 
373
373
  function cardToolsHtml(s, { deletable, archivable }) {
@@ -279,6 +279,10 @@ module Samagotchi
279
279
 
280
280
  input_files = SessionInbox.find_new_input_files(@session_dir)
281
281
  return idle_pass if input_files.empty?
282
+ # A command queued since the look above, before these prompts were
283
+ # written (an answer's /continue yes, then a prompt), still runs
284
+ # first; the next pass takes the prompts.
285
+ return if run_queued_commands
282
286
 
283
287
  run_input_files(input_files)
284
288
  end