samagotchi 0.61.0 → 0.63.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 +91 -1
- data/README.md +4 -1
- data/bin/chi +3 -2
- data/docs/architecture.md +3 -0
- data/docs/cli.md +26 -9
- data/docs/configuration.md +33 -20
- data/docs/context.md +21 -3
- data/docs/guardrails.md +2 -1
- data/docs/hooks.md +14 -3
- data/docs/internals/engine.md +24 -11
- data/docs/internals/prompt-caching.md +6 -1
- data/docs/memory.md +1 -1
- data/docs/plugins.md +131 -5
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +4 -4
- data/docs/sub-agent.md +6 -0
- data/docs/testing.md +10 -4
- data/lib/samagotchi/answer_refs.rb +18 -26
- data/lib/samagotchi/bridge.rb +23 -1
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +22 -0
- data/lib/samagotchi/bundles/claude-peer/plugin.rb +406 -0
- data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +104 -0
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +66 -0
- data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +60 -0
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
- data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
- data/lib/samagotchi/bundles/github-pr/plugin.rb +70 -14
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +17 -6
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -1
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +58 -3
- data/lib/samagotchi/bundles/source-links/manifest.yml +4 -4
- data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/context_absorber.rb +25 -6
- data/lib/samagotchi/engine.rb +9 -0
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/idle_recap.rb +2 -3
- data/lib/samagotchi/image_store.rb +8 -0
- data/lib/samagotchi/kernel_loop.rb +7 -16
- data/lib/samagotchi/llm/chat_loop.rb +23 -15
- data/lib/samagotchi/plugin/api.rb +36 -18
- data/lib/samagotchi/plugin/attached_context.rb +7 -4
- data/lib/samagotchi/reasoning_history.rb +22 -4
- data/lib/samagotchi/recap_setup.rb +3 -3
- data/lib/samagotchi/row_refs.rb +125 -0
- data/lib/samagotchi/session.rb +12 -0
- data/lib/samagotchi/session_metrics.rb +24 -2
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +6 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +26 -9
- data/lib/samagotchi/terminal_ui/formatting.rb +1 -1
- data/lib/samagotchi/terminal_ui.rb +57 -14
- data/lib/samagotchi/tool_activity.rb +8 -4
- data/lib/samagotchi/tool_response.rb +5 -4
- data/lib/samagotchi/tool_row_fields.rb +4 -2
- data/lib/samagotchi/tool_runner.rb +16 -3
- data/lib/samagotchi/tools/builtins.rb +9 -2
- data/lib/samagotchi/tools/execute.rb +37 -7
- data/lib/samagotchi/tools/peers.rb +3 -2
- data/lib/samagotchi/tools/registry.rb +5 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +20 -5
- data/lib/samagotchi/web/message_parts.rb +17 -9
- data/lib/samagotchi/web/public/activity.js +6 -3
- data/lib/samagotchi/web/public/app.js +106 -106
- data/lib/samagotchi/web/public/card.js +73 -23
- data/lib/samagotchi/web/public/chains.js +24 -24
- data/lib/samagotchi/web/public/command_view.js +39 -35
- data/lib/samagotchi/web/public/context_chips.js +92 -64
- data/lib/samagotchi/web/public/copy.js +7 -15
- data/lib/samagotchi/web/public/diff_view.js +20 -23
- data/lib/samagotchi/web/public/families_view.js +51 -39
- data/lib/samagotchi/web/public/format.js +0 -48
- data/lib/samagotchi/web/public/images.js +12 -16
- data/lib/samagotchi/web/public/index.html +61 -10
- data/lib/samagotchi/web/public/keep_focus.js +24 -0
- data/lib/samagotchi/web/public/lists_view.js +13 -13
- data/lib/samagotchi/web/public/llm_context_chip.js +55 -34
- data/lib/samagotchi/web/public/question_card.js +11 -0
- data/lib/samagotchi/web/public/question_cards.js +63 -7
- data/lib/samagotchi/web/public/read_view.js +10 -9
- data/lib/samagotchi/web/public/ref_menu.js +18 -5
- data/lib/samagotchi/web/public/refs.js +113 -15
- data/lib/samagotchi/web/public/scope.js +23 -0
- data/lib/samagotchi/web/public/scroll.js +42 -0
- data/lib/samagotchi/web/public/stage_model.js +22 -3
- data/lib/samagotchi/web/public/stage_view.js +299 -70
- data/lib/samagotchi/web/public/timing.js +1 -8
- data/lib/samagotchi/web/public/turn_html.js +122 -61
- data/lib/samagotchi/web/public/turn_model.js +49 -0
- data/lib/samagotchi/web/public/turn_view.js +167 -116
- data/lib/samagotchi/web/public/vendor/directive.js +8 -0
- data/lib/samagotchi/web/public/vendor/directives/unsafe-html.js +8 -0
- data/lib/samagotchi/worker.rb +49 -32
- data/lib/samagotchi/worker_inbound.rb +17 -14
- metadata +10 -1
|
@@ -18,6 +18,7 @@ require_relative "thinking"
|
|
|
18
18
|
require_relative "hooks"
|
|
19
19
|
require_relative "pending_input_queue"
|
|
20
20
|
require_relative "steer"
|
|
21
|
+
require_relative "reasoning_history"
|
|
21
22
|
require_relative "thought_stream_splitter"
|
|
22
23
|
require_relative "tools/builtins"
|
|
23
24
|
require_relative "muted_memories"
|
|
@@ -172,6 +173,10 @@ module Samagotchi
|
|
|
172
173
|
# too); the thinking level is its own field, since the native path
|
|
173
174
|
# sends the sampling as is.
|
|
174
175
|
attr_accessor :turn_settings
|
|
176
|
+
# The session's working directory (a callable → String or nil), which
|
|
177
|
+
# a plugin tool's row refs resolve their files against (ToolRunner,
|
|
178
|
+
# RowRefs); the Engine sets it. nil: the process's own (Dir.pwd).
|
|
179
|
+
attr_accessor :session_cwd
|
|
175
180
|
# Tools::Peers (or the Engine's live view of it): the session
|
|
176
181
|
# list_sessions and send_note speak for; nil outside a session.
|
|
177
182
|
attr_accessor :peers
|
|
@@ -291,7 +296,7 @@ module Samagotchi
|
|
|
291
296
|
private_constant :Turn, :Request, :Generation
|
|
292
297
|
|
|
293
298
|
def start_turn(messages, on_stream_event:, cancel_controller:, pending_input:, cap:, reasoning_since: nil)
|
|
294
|
-
conversation = prepare_conversation(messages, keep_thought_from:
|
|
299
|
+
conversation = prepare_conversation(messages, keep_thought_from: ReasoningHistory.run_start(messages, reasoning_since))
|
|
295
300
|
context = ContextStatus.new(conversation: conversation, llm_context: @turn_settings.llm_context)
|
|
296
301
|
emit = ->(event) { emit_stream_event(on_stream_event, event) }
|
|
297
302
|
@llm_context_turn = LLMContextForget::Turn.new(conversation: conversation, context: context, emit: emit)
|
|
@@ -1017,24 +1022,10 @@ module Samagotchi
|
|
|
1017
1022
|
end
|
|
1018
1023
|
|
|
1019
1024
|
# Standard multi-turn compliance: never pass prior raw thought blocks,
|
|
1020
|
-
# but a resumed run's (+keep_thought_from+,
|
|
1025
|
+
# but a resumed run's (+keep_thought_from+, ReasoningHistory.run_start): the same
|
|
1021
1026
|
# run going on after a step-limit Continue.
|
|
1022
1027
|
def prepare_conversation(messages, keep_thought_from: nil) = sanitize_history(messages, keep_thought_from: keep_thought_from)
|
|
1023
1028
|
|
|
1024
|
-
# Where a resumed run's thought blocks start: its start (+since+). Inside
|
|
1025
|
-
# one native run nothing re-sanitizes, so a user entry the run merged (a
|
|
1026
|
-
# steer, a delegate report, the Qwen recovery prompt) left the thoughts
|
|
1027
|
-
# before it in the prompt: they stay. Only a user or turn_start entry
|
|
1028
|
-
# added after the run ended (after its last model message: a !cmd's
|
|
1029
|
-
# output) starts them again past itself. nil without +since+.
|
|
1030
|
-
def thought_floor(messages, since)
|
|
1031
|
-
return nil if since.nil?
|
|
1032
|
-
|
|
1033
|
-
last_reply = messages.rindex { |m| m[:role].to_s == "model" } || -1
|
|
1034
|
-
query = messages.rindex { |m| m[:role].to_s == "user" || m[:turn_start] == true }
|
|
1035
|
-
query && query > last_reply ? [query + 1, since].max : since
|
|
1036
|
-
end
|
|
1037
|
-
|
|
1038
1029
|
def duplicate_conversation(messages)
|
|
1039
1030
|
messages.map(&:dup)
|
|
1040
1031
|
end
|
|
@@ -24,6 +24,7 @@ require_relative "../vision_context"
|
|
|
24
24
|
require_relative "../log"
|
|
25
25
|
require_relative "../empty_answer_retry"
|
|
26
26
|
require_relative "../steer"
|
|
27
|
+
require_relative "../reasoning_history"
|
|
27
28
|
require_relative "../turn_note"
|
|
28
29
|
require_relative "../thinking"
|
|
29
30
|
|
|
@@ -70,7 +71,7 @@ module Samagotchi
|
|
|
70
71
|
def provider = :chat
|
|
71
72
|
|
|
72
73
|
# +reasoning_since+ (ModelBackend#complete): the resumed run's start,
|
|
73
|
-
# the Run's own start then (#reasoning_boundary).
|
|
74
|
+
# the Run's own start then (Run#initialize, #reasoning_boundary).
|
|
74
75
|
def complete(messages:, max_iterations: IterationLimit::DEFAULT, on_stream_event: nil, cancel_controller: nil,
|
|
75
76
|
model_name: nil, max_tool_output_chars: nil, pending_input: nil, reasoning_since: nil)
|
|
76
77
|
raise ArgumentError, "ChatLoop has no adapter" unless @adapter
|
|
@@ -137,7 +138,8 @@ module Samagotchi
|
|
|
137
138
|
# A model turn carries its saved reasoning as reasoning_content when
|
|
138
139
|
# +reasoning+ (a ReasoningHistory level; the turn's by default) says
|
|
139
140
|
# so: under turn the running turn's only (#reasoning_boundary, with
|
|
140
|
-
# +since+ the Run's
|
|
141
|
+
# +since+ where the Run's reasoning starts; a steer it merged doesn't
|
|
142
|
+
# move that), under all every one.
|
|
141
143
|
#
|
|
142
144
|
# The conversation goes through the kernel's LLMContextView first,
|
|
143
145
|
# as the native prompt does (it keeps every entry in place).
|
|
@@ -194,19 +196,24 @@ module Samagotchi
|
|
|
194
196
|
TOOL_IMAGES_TEXT = "[images from tool results]"
|
|
195
197
|
|
|
196
198
|
# The first index whose model entries carry their reasoning (nil:
|
|
197
|
-
# none do) under +level+. Under turn:
|
|
198
|
-
#
|
|
199
|
-
#
|
|
200
|
-
#
|
|
201
|
-
#
|
|
202
|
-
#
|
|
199
|
+
# none do) under +level+. Under turn: +since+ when given, where a
|
|
200
|
+
# Run's reasoning starts (ReasoningHistory.run_start, the native
|
|
201
|
+
# loop's rule: an entry the run merged -- a person's line, a plugin's
|
|
202
|
+
# steer, a Continue's text -- keeps the reasoning before it, so the
|
|
203
|
+
# request after it extends the one before; a continue turn -- a
|
|
204
|
+
# reminder, a context wake, a delegate report's wake -- starts a Run
|
|
205
|
+
# of its own and sends none of the run before it). Without +since+,
|
|
206
|
+
# past the last query (a user message or an entry that starts a turn,
|
|
207
|
+
# turn_start).
|
|
203
208
|
# @return [Integer, nil]
|
|
204
209
|
def reasoning_boundary(conversation, since: nil, level: reasoning_history)
|
|
205
210
|
case level
|
|
206
211
|
when :all then 0
|
|
207
212
|
when :turn
|
|
213
|
+
return since if since
|
|
214
|
+
|
|
208
215
|
query = conversation.rindex { |entry| entry[:role].to_s == "user" || entry[:turn_start] == true }
|
|
209
|
-
|
|
216
|
+
query ? query + 1 : 0
|
|
210
217
|
end
|
|
211
218
|
end
|
|
212
219
|
|
|
@@ -417,9 +424,10 @@ module Samagotchi
|
|
|
417
424
|
@cancel_controller = cancel_controller
|
|
418
425
|
@model_name = model_name
|
|
419
426
|
@pending_input = pending_input
|
|
420
|
-
# Where
|
|
421
|
-
# (ChatLoop#reasoning_boundary)
|
|
422
|
-
|
|
427
|
+
# Where the reasoning this run sends back starts
|
|
428
|
+
# (ChatLoop#reasoning_boundary): its own replies', a resumed run's
|
|
429
|
+
# too, fixed for the run; a steer it merges doesn't move it.
|
|
430
|
+
@reasoning_start = ReasoningHistory.run_start(conversation, start || conversation.length)
|
|
423
431
|
@tool_activity = []
|
|
424
432
|
@empty_retry = EmptyAnswerRetry.new
|
|
425
433
|
# The empty generations, never saved: the UIs draw the turn's
|
|
@@ -596,7 +604,7 @@ module Samagotchi
|
|
|
596
604
|
|
|
597
605
|
def request(iteration, retry_generation, streamed, thought, generation_controller, streamed_any = nil)
|
|
598
606
|
@loop.adapter.chat(
|
|
599
|
-
messages: @loop.wire_messages(@conversation, since: @
|
|
607
|
+
messages: @loop.wire_messages(@conversation, since: @reasoning_start, reasoning: reasoning_level), tools: @loop.tool_definitions, model: @model_name,
|
|
600
608
|
cancel_controller: generation_controller || @cancel_controller, session_id: @loop.session_id,
|
|
601
609
|
options: @loop.request_options(retry_generation: retry_generation, model: @model_name),
|
|
602
610
|
on_delta: lambda { |content:, reasoning:, payload:|
|
|
@@ -670,7 +678,7 @@ module Samagotchi
|
|
|
670
678
|
|
|
671
679
|
# Whether this step's request sends reasoning back.
|
|
672
680
|
def reasoning_sent?
|
|
673
|
-
!@without_reasoning && @loop.reasoning_chars(@conversation, since: @
|
|
681
|
+
!@without_reasoning && @loop.reasoning_chars(@conversation, since: @reasoning_start, reasoning: reasoning_level).positive?
|
|
674
682
|
end
|
|
675
683
|
|
|
676
684
|
# The level this step's request sends reasoning back under: none
|
|
@@ -733,7 +741,7 @@ module Samagotchi
|
|
|
733
741
|
# names and arguments, and the reasoning sent back.
|
|
734
742
|
def prompt_chars
|
|
735
743
|
LLMContextView.chars(@loop.llm_context_view.messages(@conversation)) +
|
|
736
|
-
@loop.reasoning_chars(@conversation, since: @
|
|
744
|
+
@loop.reasoning_chars(@conversation, since: @reasoning_start, reasoning: reasoning_level)
|
|
737
745
|
end
|
|
738
746
|
|
|
739
747
|
# The server's counts for this request (prompt + answer): the status
|
|
@@ -79,9 +79,11 @@ module Samagotchi
|
|
|
79
79
|
# @param targets [#call, nil] args → what guardrail rules match: a Hash
|
|
80
80
|
# with paths: (absolute or relative to the cwd), command: (a shell
|
|
81
81
|
# command) and cwd:, each optional
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
82
|
+
# @param refs [#call, nil] args → the call's row's refs, display only:
|
|
83
|
+
# a list of {kind:, target:, label:, actions:} (RowRefs, Ref). Run
|
|
84
|
+
# once per call on the tool path, so it must be cheap (no I/O)
|
|
85
|
+
def tool(name, description, **, &)
|
|
86
|
+
spec = Api.tool_spec(name, description, **, &)
|
|
85
87
|
if (taken = @registries.tools[spec[:name]])
|
|
86
88
|
raise ArgumentError, "tool #{spec[:name]} is already registered (#{taken.source})"
|
|
87
89
|
end
|
|
@@ -125,9 +127,8 @@ module Samagotchi
|
|
|
125
127
|
end
|
|
126
128
|
|
|
127
129
|
# As Api#tool.
|
|
128
|
-
def tool(name, description,
|
|
129
|
-
spec = Api.tool_spec(name, description,
|
|
130
|
-
targets: targets, &)
|
|
130
|
+
def tool(name, description, **, &)
|
|
131
|
+
spec = Api.tool_spec(name, description, **, &)
|
|
131
132
|
raise ArgumentError, "tool #{spec[:name]} is registered twice" if @specs.any? { |s| s[:name] == spec[:name] }
|
|
132
133
|
|
|
133
134
|
@specs << spec
|
|
@@ -249,34 +250,44 @@ module Samagotchi
|
|
|
249
250
|
def counts = { commands: @commands.size, tools: @tools.size, hooks: @hooks.size, services: @services.size,
|
|
250
251
|
inits: @inits.size }
|
|
251
252
|
|
|
252
|
-
# A checked tool declaration (#tool's arguments
|
|
253
|
-
#
|
|
254
|
-
|
|
253
|
+
# A checked tool declaration (#tool's arguments: Api#tool and
|
|
254
|
+
# ToolSet#tool pass theirs on, so a new keyword is added here).
|
|
255
|
+
# @return [Hash] {name:, schema:, label:, preview:, targets:, refs:, block:}
|
|
256
|
+
def self.tool_spec(name, description, params: {}, schema: nil, label: nil, preview: nil, targets: nil, refs: nil,
|
|
257
|
+
&block)
|
|
255
258
|
raise ArgumentError, "tool #{name.inspect} needs a block" unless block
|
|
256
259
|
|
|
257
260
|
name = name.to_s
|
|
258
261
|
raise ArgumentError, "tool name #{name.inspect} must be a-z, 0-9 and _ (at most 48)" unless name.match?(TOOL_NAME)
|
|
259
262
|
raise ArgumentError, "tool #{name}: preview must respond to #call" if preview && !preview.respond_to?(:call)
|
|
260
263
|
raise ArgumentError, "tool #{name}: targets must respond to #call" if targets && !targets.respond_to?(:call)
|
|
264
|
+
raise ArgumentError, "tool #{name}: refs must respond to #call" if refs && !refs.respond_to?(:call)
|
|
261
265
|
|
|
262
266
|
parameters = schema ? schema_parameters(name, schema) : params_schema(name, params)
|
|
263
267
|
{ name: name, schema: { name: name, description: description.to_s, parameters: parameters },
|
|
264
|
-
label: label&.to_s, preview: preview, targets: targets, block: block }
|
|
268
|
+
label: label&.to_s, preview: preview, targets: targets, refs: refs, block: block }
|
|
265
269
|
end
|
|
266
270
|
|
|
267
271
|
# Make +bundle+'s tools in +registry+ the +specs+: its tools not in
|
|
268
272
|
# them go; a new one, or one whose schema or label changed, is
|
|
269
273
|
# registered (a changed one again, in its place: Tools::Registry
|
|
270
274
|
# orders bundle tools by bundle and name); an unchanged one is kept
|
|
271
|
-
# as it is
|
|
272
|
-
# the
|
|
275
|
+
# as it is, but for its block, preview:, targets: and refs:, swapped
|
|
276
|
+
# in place: none of them reaches the prompt, so not a change (that
|
|
277
|
+
# would build the prompts again). A name
|
|
278
|
+
# another source has is left out. Called on the turn thread
|
|
279
|
+
# (Engine#apply_staged_tools!).
|
|
273
280
|
# @return [Hash] {changed: Boolean, skipped: [String] (why)}
|
|
274
281
|
def self.apply_tools(registry, bundle, specs, context)
|
|
275
282
|
wanted = specs.to_h { |spec| [spec[:name], spec] }
|
|
276
283
|
changed = false
|
|
277
284
|
registry.entries.each do |entry|
|
|
278
285
|
next unless entry.source == bundle
|
|
279
|
-
|
|
286
|
+
|
|
287
|
+
if (spec = wanted[entry.name]) && spec[:schema] == entry.schema && spec[:label] == entry.label
|
|
288
|
+
entry_fields(spec, context).slice(:handler, :preview, :targets, :refs).each { |key, value| entry[key] = value }
|
|
289
|
+
next
|
|
290
|
+
end
|
|
280
291
|
|
|
281
292
|
registry.unregister(entry.name)
|
|
282
293
|
changed = true
|
|
@@ -295,23 +306,30 @@ module Samagotchi
|
|
|
295
306
|
end
|
|
296
307
|
|
|
297
308
|
# Registry#register's keywords for a tool declaration: its block
|
|
298
|
-
# wrapped as a handler (typed args, the Context), preview, targets.
|
|
309
|
+
# wrapped as a handler (typed args, the Context), preview, targets, refs.
|
|
299
310
|
def self.entry_fields(spec, context)
|
|
300
311
|
block = spec[:block]
|
|
301
|
-
preview = spec[:preview]
|
|
302
|
-
targets = spec[:targets]
|
|
303
312
|
parameters = spec[:schema][:parameters]
|
|
313
|
+
preview = per_call(spec[:preview], parameters)
|
|
304
314
|
{
|
|
305
315
|
schema: spec[:schema], label: spec[:label],
|
|
306
316
|
handler: lambda { |call, _kctx|
|
|
307
317
|
result = block.call(Api.args_of(call, parameters), context)
|
|
308
318
|
Api.result_text(result)
|
|
309
319
|
},
|
|
310
|
-
preview: preview && ->(call) { preview.call(
|
|
311
|
-
targets:
|
|
320
|
+
preview: preview && ->(call) { preview.call(call)&.to_s },
|
|
321
|
+
targets: per_call(spec[:targets], parameters),
|
|
322
|
+
refs: per_call(spec[:refs], parameters)
|
|
312
323
|
}
|
|
313
324
|
end
|
|
314
325
|
|
|
326
|
+
# A plugin's args → value lambda (+fn+) as call → value: it gets the
|
|
327
|
+
# call's typed args (Api.args_of), as the tool's block does. nil
|
|
328
|
+
# without one.
|
|
329
|
+
def self.per_call(fn, parameters)
|
|
330
|
+
fn && ->(call) { fn.call(Api.args_of(call, parameters)) }
|
|
331
|
+
end
|
|
332
|
+
|
|
315
333
|
# A tool's result as the model reads it: a String (a ToolResult too,
|
|
316
334
|
# with its images) as it is, a Hash or Array as JSON (its #to_s is
|
|
317
335
|
# Hash#inspect, which changes with the Ruby version), else #to_s.
|
|
@@ -94,18 +94,21 @@ module Samagotchi
|
|
|
94
94
|
nil
|
|
95
95
|
end
|
|
96
96
|
|
|
97
|
-
# This session's sources (its own, then its project's)
|
|
98
|
-
#
|
|
97
|
+
# This session's sources (its own, then its project's); none before
|
|
98
|
+
# the session has an id or a saved file.
|
|
99
|
+
# +summary+ is the last text's summary (nil before any text).
|
|
100
|
+
# @return [Array<Hash>] {name:, scope:, why:, hint:, fetched_at:, error:, provider:, summary:}
|
|
99
101
|
def list
|
|
100
102
|
id = @host.session_id.call
|
|
101
|
-
|
|
103
|
+
# No id, or no saved file yet (a command before the first turn).
|
|
104
|
+
return [] unless id && Session.exist?(id, state_dir: state_dir)
|
|
102
105
|
|
|
103
106
|
session = Session.load(id, state_dir: state_dir)
|
|
104
107
|
ContextSources.attached(id, project_root: session.project_root, state_dir: state_dir).map do |attached|
|
|
105
108
|
snapshot = attached.snapshot
|
|
106
109
|
{ name: attached.name, scope: attached.location.scope, why: attached.source.why,
|
|
107
110
|
hint: snapshot.hint || attached.source.hint, fetched_at: snapshot.fetched_at, error: snapshot.error,
|
|
108
|
-
provider: attached.source.provider }
|
|
111
|
+
provider: attached.source.provider, summary: snapshot.summary }
|
|
109
112
|
end
|
|
110
113
|
end
|
|
111
114
|
|
|
@@ -7,9 +7,9 @@ module Samagotchi
|
|
|
7
7
|
# Which of its own reasoning a chat host's model gets back
|
|
8
8
|
# (LLM::ChatLoop sends it as reasoning_content on its earlier replies):
|
|
9
9
|
#
|
|
10
|
-
# turn the running turn's replies only (the
|
|
11
|
-
#
|
|
12
|
-
#
|
|
10
|
+
# turn the running turn's replies only (since the turn's start; a
|
|
11
|
+
# steer, a plugin's nudge or a Continue's text merged into the
|
|
12
|
+
# turn doesn't reset it, as on a native host: .run_start)
|
|
13
13
|
# drop none
|
|
14
14
|
# all every reply that has reasoning, earlier turns too (a direct
|
|
15
15
|
# DeepSeek API with tools wants this)
|
|
@@ -17,7 +17,8 @@ module Samagotchi
|
|
|
17
17
|
# Order (#resolve): the session's own level (/reasoning-history), then
|
|
18
18
|
# SAMAGOTCHI_REASONING_HISTORY, models.<key>.reasoning_history,
|
|
19
19
|
# hosts.<name>.reasoning_history, then turn. Native hosts ignore it:
|
|
20
|
-
# KernelLoop keeps the running turn's reasoning on its own
|
|
20
|
+
# KernelLoop keeps the running turn's reasoning on its own, from the
|
|
21
|
+
# run's start (.run_start).
|
|
21
22
|
module ReasoningHistory
|
|
22
23
|
LEVELS = %i[drop turn all].freeze
|
|
23
24
|
DEFAULT = :turn
|
|
@@ -118,6 +119,23 @@ module Samagotchi
|
|
|
118
119
|
end
|
|
119
120
|
end
|
|
120
121
|
|
|
122
|
+
# Where a run's own reasoning starts in +messages+: its start (+since+;
|
|
123
|
+
# nil without one). Inside one run nothing re-sanitizes, so an entry the
|
|
124
|
+
# run merged (a steer, a delegate report, the Qwen recovery prompt) left
|
|
125
|
+
# the reasoning before it in the prompt: it stays. Only a user or
|
|
126
|
+
# turn_start entry added after the run ended (after its last model
|
|
127
|
+
# message: a !cmd's output) starts it again past itself.
|
|
128
|
+
# @param messages [Array<Hash>] the conversation
|
|
129
|
+
# @param since [Integer, nil] the run's start
|
|
130
|
+
# @return [Integer, nil]
|
|
131
|
+
def run_start(messages, since)
|
|
132
|
+
return nil if since.nil?
|
|
133
|
+
|
|
134
|
+
last_reply = messages.rindex { |m| m[:role].to_s == "model" } || -1
|
|
135
|
+
query = messages.rindex { |m| m[:role].to_s == "user" || m[:turn_start] == true }
|
|
136
|
+
query && query > last_reply ? [query + 1, since].max : since
|
|
137
|
+
end
|
|
138
|
+
|
|
121
139
|
# "drop (models: qwen)", "turn (default)".
|
|
122
140
|
def label(level, source) = "#{level} (#{source || "default"})"
|
|
123
141
|
|
|
@@ -31,12 +31,12 @@ module Samagotchi
|
|
|
31
31
|
# @return [IdleRecap, nil]
|
|
32
32
|
def self.build(recap, engine:, host_registry:, session_target:, session_id:, state_dir:)
|
|
33
33
|
return nil if recap == false
|
|
34
|
-
# The
|
|
35
|
-
#
|
|
34
|
+
# The REPL and a worker pass nothing: the config is read here alone
|
|
35
|
+
# (a scalar `recap: false` is only seen this way).
|
|
36
36
|
return nil if recap.nil? && ConfigFile.recap_config == false
|
|
37
37
|
return nil if Samagotchi::Config.get("recap.enabled") == false
|
|
38
38
|
|
|
39
|
-
#
|
|
39
|
+
# An explicit kwarg (a spec, an embedder) over the registry.
|
|
40
40
|
kwarg_config = recap.is_a?(Hash) ? recap : {}
|
|
41
41
|
|
|
42
42
|
base_url = string_config(kwarg_config, :base_url) || registry_string("recap.base_url")
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "log"
|
|
5
|
+
require_relative "ref"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
# The refs a plugin tool gives its row (chi.tool's refs: lambda, traceable
|
|
9
|
+
# refs P5d): what the call traces to (a ticket, a PR, a file), each a Ref
|
|
10
|
+
# whose +to_h+ the row carries as +refs+ on tool_call_started and
|
|
11
|
+
# tool_call_completed (ToolRowFields::EVENT_KEYS), saved with the result
|
|
12
|
+
# as tool_refs (ToolResponse::SAVED_KEYS) for a reload, which has no
|
|
13
|
+
# plugins (Web::MessageParts). Display only: the model never sees them.
|
|
14
|
+
#
|
|
15
|
+
# A file ref is any file under the session's folder (plan D7): a relative
|
|
16
|
+
# path is resolved against it, a path outside it is dropped. The folder
|
|
17
|
+
# counts under its own spelling and its real path (macOS /tmp is
|
|
18
|
+
# /private/tmp), and a kept path is stored under the real one, so a
|
|
19
|
+
# reload under either spelling keeps it. The page
|
|
20
|
+
# shows the first ref on the row's title and the rest in its menu.
|
|
21
|
+
module RowRefs
|
|
22
|
+
# Per row, so a plugin can't bloat the events or session.json.
|
|
23
|
+
MAX_REFS = 20
|
|
24
|
+
# The row's refs' JSON; a ref that would cross it is left out.
|
|
25
|
+
MAX_BYTES = 64 * 1024
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# Runs +fn+ (the tool's refs: lambda, as Api.per_call wraps it: call →
|
|
30
|
+
# a list of {kind:, target:, label:, actions:}) on +call+. Never
|
|
31
|
+
# raises: a lambda that raises or returns something other than a list
|
|
32
|
+
# gives none, and a bad ref is dropped; both are logged (warn, not
|
|
33
|
+
# echoed: the turn's live region is on screen).
|
|
34
|
+
# @param cwd [String, nil] the session's folder
|
|
35
|
+
# @return [Array<Hash>] Ref#to_h each, deduped, at most MAX_REFS
|
|
36
|
+
def for(fn, call, cwd:)
|
|
37
|
+
return [] unless fn
|
|
38
|
+
|
|
39
|
+
tool = call[:name].to_s
|
|
40
|
+
raw = fn.call(call)
|
|
41
|
+
return [] if raw.nil?
|
|
42
|
+
raise ArgumentError, "refs: returned #{raw.class}, not a list" unless raw.is_a?(Array)
|
|
43
|
+
|
|
44
|
+
build(raw, cwd) { |why| Log.warn(:plugins, "tool_refs_rejected", tool: tool, reason: why) }
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
Log.warn(:plugins, "tool_refs_failed", tool: tool, error: "#{e.class}: #{e.message}")
|
|
47
|
+
[]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Stored refs (+raw+, a saved tool_refs entry), each rebuilt and
|
|
51
|
+
# checked again as #for checks a lambda's: a stored file is never
|
|
52
|
+
# trusted. What isn't valid any more is dropped, a relative path too
|
|
53
|
+
# (it was stored absolute). Never raises: a reload must show the
|
|
54
|
+
# conversation.
|
|
55
|
+
# @return [Array<Hash>]
|
|
56
|
+
def for_display(raw, cwd:)
|
|
57
|
+
return [] unless raw.is_a?(Array)
|
|
58
|
+
|
|
59
|
+
build(raw, cwd, relative: false) { |_why| nil }
|
|
60
|
+
rescue StandardError
|
|
61
|
+
[]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# +raw+'s valid refs as Ref#to_h; +rejected+ gets why each other one
|
|
65
|
+
# was dropped. +relative+: a relative file path is resolved against
|
|
66
|
+
# +cwd+ (else dropped).
|
|
67
|
+
def build(raw, cwd, relative: true, &)
|
|
68
|
+
roots = roots(cwd)
|
|
69
|
+
bytes = 2 # the list's []
|
|
70
|
+
raw.each_with_object([]) do |value, refs|
|
|
71
|
+
break refs if refs.length >= MAX_REFS
|
|
72
|
+
|
|
73
|
+
hash = inside(Ref.from(value), roots, relative).to_h
|
|
74
|
+
next if refs.include?(hash)
|
|
75
|
+
|
|
76
|
+
size = JSON.generate(hash).bytesize + (refs.empty? ? 0 : 1)
|
|
77
|
+
break refs if bytes + size > MAX_BYTES
|
|
78
|
+
|
|
79
|
+
bytes += size
|
|
80
|
+
refs << hash
|
|
81
|
+
# One junk ref ({1 => "x"}, a nil target) goes alone.
|
|
82
|
+
rescue ArgumentError, TypeError, NoMethodError => e
|
|
83
|
+
yield(e.message)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
private_class_method :build
|
|
87
|
+
|
|
88
|
+
# +cwd+ as [its spelling, its real path] (one when they are the same,
|
|
89
|
+
# none without a +cwd+). absolute_path, not expand_path: a leading ~ is
|
|
90
|
+
# a name, never a home. One realpath per row: the lambda's refs are
|
|
91
|
+
# checked together. The answer's refs (AnswerRef) check against it too.
|
|
92
|
+
def roots(cwd)
|
|
93
|
+
return [] if cwd.to_s.empty?
|
|
94
|
+
|
|
95
|
+
given = File.absolute_path(cwd.to_s)
|
|
96
|
+
real = begin
|
|
97
|
+
File.realpath(given)
|
|
98
|
+
rescue SystemCallError
|
|
99
|
+
given
|
|
100
|
+
end
|
|
101
|
+
[given, real].uniq
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# +ref+ with a file's path absolute, resolved against the folder
|
|
105
|
+
# (+roots+, #roots), under its real spelling: plan D7, for a plugin
|
|
106
|
+
# row's refs and an answer's (AnswerRef) alike.
|
|
107
|
+
# @raise [ArgumentError] a relative path with no folder (or not
|
|
108
|
+
# +relative+), or a path outside the folder
|
|
109
|
+
def inside(ref, roots, relative)
|
|
110
|
+
return ref unless ref.kind == Ref::FILE
|
|
111
|
+
|
|
112
|
+
path = ref.target[:path]
|
|
113
|
+
unless File.absolute_path?(path) || (relative && roots.any?)
|
|
114
|
+
raise ArgumentError, "a file ref's relative path needs the session folder"
|
|
115
|
+
end
|
|
116
|
+
return ref if roots.empty?
|
|
117
|
+
|
|
118
|
+
path = File.absolute_path(path, roots.first)
|
|
119
|
+
root = roots.find { |dir| path == dir || path.start_with?(dir.end_with?("/") ? dir : "#{dir}/") }
|
|
120
|
+
raise ArgumentError, "a file ref outside the session folder (#{path})" unless root
|
|
121
|
+
|
|
122
|
+
ref.with(target: ref.target.merge(path: "#{roots.last}#{path.delete_prefix(root)}").freeze)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -275,6 +275,18 @@ module Samagotchi
|
|
|
275
275
|
{ id: pending[:id], kind: %w[approval hook continue].include?(kind) ? kind : "question", relayed_to: relayed_to }.compact
|
|
276
276
|
end
|
|
277
277
|
|
|
278
|
+
# A question saved by a process that no longer runs this session (the
|
|
279
|
+
# one that loads it now owns it): nobody waits on its answer, so it is
|
|
280
|
+
# dropped. Unsaved; the caller saves.
|
|
281
|
+
# @return [String, nil] the dropped question's id, nil with none
|
|
282
|
+
def drop_dead_question
|
|
283
|
+
pending = pending_question
|
|
284
|
+
return nil unless pending
|
|
285
|
+
|
|
286
|
+
@pending_question = nil
|
|
287
|
+
pending[:id].to_s
|
|
288
|
+
end
|
|
289
|
+
|
|
278
290
|
# The stored project root, else (a file from before the field) the
|
|
279
291
|
# project of the working directory now: nil when that is in no repo or
|
|
280
292
|
# gone. +cache+ is ProjectScope.root_for's, shared across one listing.
|
|
@@ -416,10 +416,20 @@ module Samagotchi
|
|
|
416
416
|
# analytics.json in the session directory. Atomic write; failures are
|
|
417
417
|
# swallowed (analytics must never break the running session).
|
|
418
418
|
# @param state_dir [String, nil]
|
|
419
|
+
# @param running_turn [Boolean] also save the running turn's record so
|
|
420
|
+
# far (a worker's turn checkpoint): as the turn a worker killed now
|
|
421
|
+
# would leave, canceled with reason worker_stopped (TurnNote's), up to
|
|
422
|
+
# its completed_at. The next collector loads it with the others; the
|
|
423
|
+
# turn's own end writes over it. Only the file has it.
|
|
419
424
|
# @return [Boolean] true on success
|
|
420
|
-
def persist(state_dir: nil)
|
|
425
|
+
def persist(state_dir: nil, running_turn: false)
|
|
421
426
|
sid, data, turn_count = @mutex.synchronize do
|
|
422
|
-
|
|
427
|
+
data = build_snapshot(:all).merge(active_turn: nil, active_tools: [])
|
|
428
|
+
if running_turn && @turn
|
|
429
|
+
data[:turn_records] << running_turn_record
|
|
430
|
+
data[:cancellations] += 1
|
|
431
|
+
end
|
|
432
|
+
[@session_id, data, @turn_records.size]
|
|
423
433
|
end
|
|
424
434
|
return false if sid.nil? || sid.to_s.empty?
|
|
425
435
|
|
|
@@ -611,6 +621,18 @@ module Samagotchi
|
|
|
611
621
|
end
|
|
612
622
|
end
|
|
613
623
|
|
|
624
|
+
# The running turn's record so far, as end_turn would make it for a
|
|
625
|
+
# turn a worker died in (#persist's running_turn). Its open generation
|
|
626
|
+
# (none at an iteration boundary) and running tool calls are left out.
|
|
627
|
+
# Caller holds the mutex.
|
|
628
|
+
def running_turn_record
|
|
629
|
+
{
|
|
630
|
+
id: @turn.id, status: "canceled", started_at: @turn.started_at, completed_at: now.iso8601(3),
|
|
631
|
+
duration_ms: elapsed_ms(@turn.started_monotonic), cancellation_reason: "worker_stopped",
|
|
632
|
+
continue: @turn.continue, resumed: @turn.resumed
|
|
633
|
+
}.merge(turn_token_fields(@turn))
|
|
634
|
+
end
|
|
635
|
+
|
|
614
636
|
# What turn_failed's line is made of (format.js failedTurnText): a
|
|
615
637
|
# provider error's summary, else the message (capped: a server's error
|
|
616
638
|
# page can be long) and class, and the steps that stayed (kept_steps).
|
|
@@ -46,12 +46,15 @@ module Samagotchi
|
|
|
46
46
|
# @param thinking [String, nil] --thinking as typed: a new session
|
|
47
47
|
# starts with it as its own level; a resumed or attached one's
|
|
48
48
|
# worker gets a /thinking, after those
|
|
49
|
+
# @param state_dir [String, nil] where the sessions are (the default
|
|
50
|
+
# state dir by default): the worker's, for the attached loop too
|
|
49
51
|
# @return [Symbol] :detached, :closed when the worker went away,
|
|
50
52
|
# :failed when the --model switch didn't go through, or (input from a
|
|
51
53
|
# pipe) :turn_failed / :empty_answer / :unanswered (AttachedLoop#run)
|
|
52
54
|
def run(attach: nil, shared: false, resume: nil, prompt: nil, model: nil, no_interrupt: false, default_input: true,
|
|
53
|
-
memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [])
|
|
54
|
-
|
|
55
|
+
memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [], state_dir: nil)
|
|
56
|
+
state_dir ||= Session.default_state_dir
|
|
57
|
+
client = connect(attach: attach, shared: shared, resume: resume, model: model, state_dir: state_dir,
|
|
55
58
|
memories: memories, muted_memories: muted_memories, llm_context: llm_context, thinking: thinking,
|
|
56
59
|
appended_prompt_files: appended_prompt_files)
|
|
57
60
|
commands = []
|
|
@@ -65,7 +68,7 @@ module Samagotchi
|
|
|
65
68
|
first_command: first_command, no_interrupt: no_interrupt,
|
|
66
69
|
default_input: default_input && !prompt && !attach && !resume,
|
|
67
70
|
wait_at_eof: !$stdin.tty?,
|
|
68
|
-
parent_answers: Guardrails::ParentApprovals.parent_process
|
|
71
|
+
parent_answers: Guardrails::ParentApprovals.parent_process?, state_dir: state_dir)
|
|
69
72
|
ended = begin
|
|
70
73
|
attached.run
|
|
71
74
|
ensure
|