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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +64 -1
- data/docs/cli.md +2 -1
- data/docs/configuration.md +58 -2
- data/docs/hooks.md +1 -0
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/context-telemetry.md +16 -0
- data/docs/internals/llm-context-bench.md +133 -0
- data/docs/internals/llm-context-forget.md +45 -0
- data/docs/internals/prompt-caching.md +3 -2
- data/docs/plugins.md +4 -3
- data/docs/testing.md +8 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +1 -1
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +10 -10
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/self_map.md +4 -0
- data/lib/samagotchi/config.rb +43 -4
- data/lib/samagotchi/context_note.rb +7 -3
- data/lib/samagotchi/context_status.rb +112 -11
- data/lib/samagotchi/engine.rb +36 -9
- data/lib/samagotchi/guardrails/targets.rb +15 -3
- data/lib/samagotchi/host_registry.rb +8 -3
- data/lib/samagotchi/kernel_loop.rb +142 -14
- data/lib/samagotchi/llm/chat_loop.rb +57 -18
- data/lib/samagotchi/llm/turn_settings.rb +5 -4
- data/lib/samagotchi/llm_context_apply.rb +165 -0
- data/lib/samagotchi/llm_context_edit.rb +102 -0
- data/lib/samagotchi/llm_context_forget.rb +258 -0
- data/lib/samagotchi/llm_context_stale.rb +271 -0
- data/lib/samagotchi/llm_context_strategy.rb +177 -0
- data/lib/samagotchi/llm_context_view.rb +201 -0
- data/lib/samagotchi/log_subscriber.rb +2 -1
- data/lib/samagotchi/prompt.rb +4 -11
- data/lib/samagotchi/session_manager.rb +9 -1
- data/lib/samagotchi/session_metrics.rb +18 -1
- data/lib/samagotchi/system_prompt.rb +20 -15
- data/lib/samagotchi/terminal_ui/formatting.rb +4 -2
- data/lib/samagotchi/token_usage.rb +3 -0
- data/lib/samagotchi/tool_activity.rb +18 -0
- data/lib/samagotchi/tool_declarations.rb +60 -4
- data/lib/samagotchi/tool_ids.rb +84 -0
- data/lib/samagotchi/tool_response.rb +67 -6
- data/lib/samagotchi/tools/builtin_calls.rb +3 -2
- data/lib/samagotchi/tools/builtins.rb +15 -5
- data/lib/samagotchi/tools/context_read.rb +27 -10
- data/lib/samagotchi/tools/forget_outputs.rb +62 -0
- data/lib/samagotchi/tools/registry.rb +25 -8
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/app.js +1 -1
- data/lib/samagotchi/worker.rb +4 -0
- 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
|
-
|
|
530
|
-
|
|
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)
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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::
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
#
|
|
19
|
-
|
|
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,
|
|
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")}
|
|
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
|
-
#
|
|
123
|
-
#
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
data/lib/samagotchi/version.rb
CHANGED
|
@@ -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="
|
|
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 }) {
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -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
|