samagotchi 0.59.0 → 0.60.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 +49 -1
- data/README.md +3 -2
- data/bin/chi +23 -2
- data/docs/architecture.md +4 -2
- data/docs/cli.md +9 -3
- data/docs/configuration.md +62 -12
- data/docs/hooks.md +10 -4
- data/docs/internals/engine.md +16 -8
- data/docs/internals/gemma4-contract.md +1 -0
- data/docs/internals/prompt-caching.md +3 -2
- data/docs/memory.md +1 -1
- data/docs/plugins.md +50 -1
- data/docs/sessions.md +1 -1
- data/docs/sub-agent.md +2 -2
- data/docs/testing.md +4 -2
- data/lib/samagotchi/bootstrap_command.rb +2 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
- data/lib/samagotchi/bundles/agents-md/manifest.yml +11 -0
- data/lib/samagotchi/bundles/agents-md/plugin.rb +198 -0
- data/lib/samagotchi/bundles/core/manifest.yml +2 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/config.rb +33 -5
- data/lib/samagotchi/continue_offer.rb +5 -2
- data/lib/samagotchi/engine.rb +63 -14
- data/lib/samagotchi/iteration_limit.rb +3 -2
- data/lib/samagotchi/kernel_loop.rb +35 -9
- data/lib/samagotchi/llm/backend.rb +6 -1
- data/lib/samagotchi/llm/chat_loop.rb +11 -6
- data/lib/samagotchi/llm/model_result.rb +11 -2
- data/lib/samagotchi/llm/native_backend.rb +3 -2
- data/lib/samagotchi/log_subscriber.rb +2 -1
- data/lib/samagotchi/plugin/context.rb +16 -3
- data/lib/samagotchi/prompt_files.rb +127 -0
- data/lib/samagotchi/ref.rb +124 -0
- data/lib/samagotchi/session.rb +15 -3
- data/lib/samagotchi/session_metrics.rb +8 -2
- data/lib/samagotchi/session_setup.rb +11 -5
- data/lib/samagotchi/system_prompt.rb +71 -15
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +18 -9
- data/lib/samagotchi/terminal_ui/formatting.rb +14 -0
- data/lib/samagotchi/terminal_ui.rb +17 -5
- data/lib/samagotchi/turn_flow.rb +22 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/favicon.svg +1 -0
- data/lib/samagotchi/web/public/index.html +1 -0
- data/lib/samagotchi/web/public/refs.js +107 -15
- data/lib/samagotchi/worker.rb +1 -0
- metadata +6 -1
|
@@ -204,12 +204,18 @@ module Samagotchi
|
|
|
204
204
|
# steering mid-stream); drained lines merge into ONE user message appended
|
|
205
205
|
# at the conversation tail (prefix KV cache preserved) and a
|
|
206
206
|
# :pending_input_merged stream event is emitted.
|
|
207
|
+
# @param reasoning_since [Integer, nil] a step-limit Continue's: where the
|
|
208
|
+
# run it resumes started in +messages+; that run's model messages keep
|
|
209
|
+
# their thought blocks (#prepare_conversation), as within one run. nil:
|
|
210
|
+
# every earlier thought is stripped.
|
|
207
211
|
# @return [LLM::ModelResult] final visible response with continuation metadata
|
|
208
|
-
def run(messages, max_iterations: IterationLimit::DEFAULT, on_stream_event: nil, cancel_controller: nil, model_name: nil, max_tool_output_chars: nil, pending_input: nil
|
|
212
|
+
def run(messages, max_iterations: IterationLimit::DEFAULT, on_stream_event: nil, cancel_controller: nil, model_name: nil, max_tool_output_chars: nil, pending_input: nil,
|
|
213
|
+
reasoning_since: nil)
|
|
209
214
|
@turn_settings = @turn_settings.with(model_name: completion_model_name(model_name))
|
|
210
215
|
@last_slot = nil
|
|
211
216
|
turn = start_turn(messages, on_stream_event: on_stream_event, cancel_controller: cancel_controller,
|
|
212
|
-
pending_input: pending_input, cap: resolve_output_char_cap(max_tool_output_chars)
|
|
217
|
+
pending_input: pending_input, cap: resolve_output_char_cap(max_tool_output_chars),
|
|
218
|
+
reasoning_since: reasoning_since)
|
|
213
219
|
|
|
214
220
|
max_iterations.times do |iteration_index|
|
|
215
221
|
turn.iteration = iteration_index + 1
|
|
@@ -284,8 +290,8 @@ module Samagotchi
|
|
|
284
290
|
:buffer_mark, keyword_init: true)
|
|
285
291
|
private_constant :Turn, :Request, :Generation
|
|
286
292
|
|
|
287
|
-
def start_turn(messages, on_stream_event:, cancel_controller:, pending_input:, cap:)
|
|
288
|
-
conversation = prepare_conversation(messages)
|
|
293
|
+
def start_turn(messages, on_stream_event:, cancel_controller:, pending_input:, cap:, reasoning_since: nil)
|
|
294
|
+
conversation = prepare_conversation(messages, keep_thought_from: thought_floor(messages, reasoning_since))
|
|
289
295
|
context = ContextStatus.new(conversation: conversation, llm_context: @turn_settings.llm_context)
|
|
290
296
|
emit = ->(event) { emit_stream_event(on_stream_event, event) }
|
|
291
297
|
@llm_context_turn = LLMContextForget::Turn.new(conversation: conversation, context: context, emit: emit)
|
|
@@ -992,9 +998,13 @@ module Samagotchi
|
|
|
992
998
|
@profile.uses_role_prefixes? ? @profile.user_prefix : "#{@profile.turn_start}user\n"
|
|
993
999
|
end
|
|
994
1000
|
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
1001
|
+
# +keep_thought_from+: model messages from this index on keep their
|
|
1002
|
+
# thought blocks (a resumed run's); nil strips every one.
|
|
1003
|
+
def sanitize_history(messages, keep_thought_from: nil)
|
|
1004
|
+
messages.each_with_index.map do |m, index|
|
|
1005
|
+
if m[:role] == "model" && keep_thought_from && index >= keep_thought_from
|
|
1006
|
+
{ role: m[:role], content: m[:content].to_s, display: m[:display] }.compact
|
|
1007
|
+
elsif m[:role] == "model"
|
|
998
1008
|
# `display` rides along so the stored conversation keeps it; the
|
|
999
1009
|
# prompt formatter never reads it (AnswerDisplay).
|
|
1000
1010
|
{ role: m[:role], content: strip_thought_blocks(m[:content].to_s), display: m[:display] }.compact
|
|
@@ -1004,8 +1014,24 @@ module Samagotchi
|
|
|
1004
1014
|
end
|
|
1005
1015
|
end
|
|
1006
1016
|
|
|
1007
|
-
# Standard multi-turn compliance: never pass prior raw thought blocks
|
|
1008
|
-
|
|
1017
|
+
# Standard multi-turn compliance: never pass prior raw thought blocks,
|
|
1018
|
+
# but a resumed run's (+keep_thought_from+, #thought_floor): the same
|
|
1019
|
+
# run going on after a step-limit Continue.
|
|
1020
|
+
def prepare_conversation(messages, keep_thought_from: nil) = sanitize_history(messages, keep_thought_from: keep_thought_from)
|
|
1021
|
+
|
|
1022
|
+
# Where a resumed run's thought blocks start: its start (+since+). Inside
|
|
1023
|
+
# one native run nothing re-sanitizes, so a user entry the run merged (a
|
|
1024
|
+
# steer, a delegate report, the Qwen recovery prompt) left the thoughts
|
|
1025
|
+
# before it in the prompt: they stay. Only a user or turn_start entry
|
|
1026
|
+
# added after the run ended (after its last model message: a !cmd's
|
|
1027
|
+
# output) starts them again past itself. nil without +since+.
|
|
1028
|
+
def thought_floor(messages, since)
|
|
1029
|
+
return nil if since.nil?
|
|
1030
|
+
|
|
1031
|
+
last_reply = messages.rindex { |m| m[:role].to_s == "model" } || -1
|
|
1032
|
+
query = messages.rindex { |m| m[:role].to_s == "user" || m[:turn_start] == true }
|
|
1033
|
+
query && query > last_reply ? [query + 1, since].max : since
|
|
1034
|
+
end
|
|
1009
1035
|
|
|
1010
1036
|
def duplicate_conversation(messages)
|
|
1011
1037
|
messages.map(&:dup)
|
|
@@ -14,9 +14,14 @@ module Samagotchi
|
|
|
14
14
|
# extra surface lives on backend-specific subclasses, never on this interface.
|
|
15
15
|
#
|
|
16
16
|
# The raw-prompt KernelLoop is NativeBackend; the chat loop is ChatLoop.
|
|
17
|
+
#
|
|
18
|
+
# +reasoning_since+: where the run a step-limit Continue resumes started
|
|
19
|
+
# in +messages+ (Engine#run_turn resume_from:); the replies from there on
|
|
20
|
+
# keep the model's reasoning (thought blocks), as within one run. nil:
|
|
21
|
+
# the run starts here, as every other turn's does.
|
|
17
22
|
class ModelBackend
|
|
18
23
|
def complete(messages:, max_iterations: IterationLimit::DEFAULT, on_stream_event: nil, cancel_controller: nil,
|
|
19
|
-
model_name: nil, max_tool_output_chars: nil, pending_input: nil)
|
|
24
|
+
model_name: nil, max_tool_output_chars: nil, pending_input: nil, reasoning_since: nil)
|
|
20
25
|
raise NotImplementedError, "#{self.class}#complete must be implemented"
|
|
21
26
|
end
|
|
22
27
|
end
|
|
@@ -68,12 +68,14 @@ module Samagotchi
|
|
|
68
68
|
|
|
69
69
|
def provider = :chat
|
|
70
70
|
|
|
71
|
+
# +reasoning_since+ (ModelBackend#complete): the resumed run's start,
|
|
72
|
+
# the Run's own start then (#reasoning_boundary).
|
|
71
73
|
def complete(messages:, max_iterations: IterationLimit::DEFAULT, on_stream_event: nil, cancel_controller: nil,
|
|
72
|
-
model_name: nil, max_tool_output_chars: nil, pending_input: nil)
|
|
74
|
+
model_name: nil, max_tool_output_chars: nil, pending_input: nil, reasoning_since: nil)
|
|
73
75
|
raise ArgumentError, "ChatLoop has no adapter" unless @adapter
|
|
74
76
|
|
|
75
77
|
conversation = Array(messages).map(&:dup)
|
|
76
|
-
Run.new(self, conversation, on_stream_event, cancel_controller, model_name, pending_input)
|
|
78
|
+
Run.new(self, conversation, on_stream_event, cancel_controller, model_name, pending_input, start: reasoning_since)
|
|
77
79
|
.call(max_iterations: max_iterations || 1, cap: KernelLoop.resolve_output_char_cap(max_tool_output_chars))
|
|
78
80
|
rescue StandardError => e
|
|
79
81
|
FailedTurn.attach(e, conversation && plain(conversation))
|
|
@@ -195,7 +197,8 @@ module Samagotchi
|
|
|
195
197
|
# user message: a prompt, a steer, merged input, the chat template's
|
|
196
198
|
# last query; or an entry that starts a turn, turn_start) and +since+
|
|
197
199
|
# (the Run's start, so a continue turn -- a reminder, a context wake,
|
|
198
|
-
# a
|
|
200
|
+
# a delegate report's wake -- sends none of the run before it; a
|
|
201
|
+
# step-limit Continue's Run starts where the run it resumes did).
|
|
199
202
|
# @return [Integer, nil]
|
|
200
203
|
def reasoning_boundary(conversation, since: nil, level: reasoning_history)
|
|
201
204
|
case level
|
|
@@ -402,7 +405,9 @@ module Samagotchi
|
|
|
402
405
|
# One turn's state: the conversation, the stream sink and tool
|
|
403
406
|
# activity.
|
|
404
407
|
class Run
|
|
405
|
-
|
|
408
|
+
# +start+: where the run a step-limit Continue resumes started (nil:
|
|
409
|
+
# this run starts at the conversation's end).
|
|
410
|
+
def initialize(loop, conversation, on_stream_event, cancel_controller, model_name, pending_input, start: nil)
|
|
406
411
|
@loop = loop
|
|
407
412
|
@conversation = conversation
|
|
408
413
|
@on_stream_event = on_stream_event
|
|
@@ -410,8 +415,8 @@ module Samagotchi
|
|
|
410
415
|
@model_name = model_name
|
|
411
416
|
@pending_input = pending_input
|
|
412
417
|
# Where this run's entries start: the reasoning it sends back
|
|
413
|
-
# (ChatLoop#reasoning_boundary) is its own.
|
|
414
|
-
@start = conversation.length
|
|
418
|
+
# (ChatLoop#reasoning_boundary) is its own, a resumed run's too.
|
|
419
|
+
@start = start || conversation.length
|
|
415
420
|
@tool_activity = []
|
|
416
421
|
@empty_retry = EmptyAnswerRetry.new
|
|
417
422
|
# The empty generations, never saved: the UIs draw the turn's
|
|
@@ -14,9 +14,15 @@ module Samagotchi
|
|
|
14
14
|
# generations as model messages, which the loops keep out of the
|
|
15
15
|
# conversation) and .empty_retries (the retries spent): the Engine saves
|
|
16
16
|
# them on the turn's note for the UIs (TurnNote.empty).
|
|
17
|
+
#
|
|
18
|
+
# .run_start is where the turn's run started in the conversation (the
|
|
19
|
+
# length of the messages the loop got), set by the Engine after the loop
|
|
20
|
+
# returns; nil from a loop called directly. A step-limit offer keeps it
|
|
21
|
+
# (TurnFlow), so a Continue resumes that run (Engine#run_turn resume_from:).
|
|
17
22
|
class ModelResult
|
|
18
23
|
attr_reader :text, :conversation, :canceled, :cancellation_reason, :exhausted,
|
|
19
24
|
:tool_activity, :context_status, :pending_tool_calls, :empty_steps, :empty_retries
|
|
25
|
+
attr_accessor :run_start
|
|
20
26
|
|
|
21
27
|
def initialize(text:, conversation: nil, canceled: false, cancellation_reason: nil, exhausted: false,
|
|
22
28
|
tool_activity: [], context_status: nil, pending_tool_calls: false, empty_steps: [], empty_retries: 0)
|
|
@@ -57,8 +63,11 @@ module Samagotchi
|
|
|
57
63
|
!!pending_tool_calls
|
|
58
64
|
end
|
|
59
65
|
|
|
60
|
-
#
|
|
61
|
-
#
|
|
66
|
+
# A run that ran out of iterations can be continued (the step-limit
|
|
67
|
+
# offer). KernelLoop marks one exhausted only when it stopped on pending
|
|
68
|
+
# tool calls; ChatLoop also leaves it exhausted when its last iteration
|
|
69
|
+
# ended in a retry (an empty answer's nudge, a cut) or merged input
|
|
70
|
+
# after an answer, which a Continue then picks up.
|
|
62
71
|
def resumable?
|
|
63
72
|
exhausted?
|
|
64
73
|
end
|
|
@@ -17,7 +17,7 @@ module Samagotchi
|
|
|
17
17
|
def provider = :native
|
|
18
18
|
|
|
19
19
|
def complete(messages:, max_iterations: IterationLimit::DEFAULT, on_stream_event: nil, cancel_controller: nil,
|
|
20
|
-
model_name: nil, max_tool_output_chars: nil, pending_input: nil)
|
|
20
|
+
model_name: nil, max_tool_output_chars: nil, pending_input: nil, reasoning_since: nil)
|
|
21
21
|
@kernel.run(
|
|
22
22
|
messages,
|
|
23
23
|
max_iterations: max_iterations,
|
|
@@ -25,7 +25,8 @@ module Samagotchi
|
|
|
25
25
|
cancel_controller: cancel_controller,
|
|
26
26
|
model_name: model_name,
|
|
27
27
|
max_tool_output_chars: max_tool_output_chars,
|
|
28
|
-
pending_input: pending_input
|
|
28
|
+
pending_input: pending_input,
|
|
29
|
+
reasoning_since: reasoning_since
|
|
29
30
|
)
|
|
30
31
|
end
|
|
31
32
|
end
|
|
@@ -63,7 +63,8 @@ module Samagotchi
|
|
|
63
63
|
@generation_started_at.clear
|
|
64
64
|
@tool_started_at.clear
|
|
65
65
|
log(:info, :turn_started, session: event[:session_id], prompt_chars: event[:prompt].to_s.length,
|
|
66
|
-
continue: event[:continue] || nil,
|
|
66
|
+
continue: event[:continue] || nil, resumed: event[:resumed] || nil, **items(event),
|
|
67
|
+
**origin(event))
|
|
67
68
|
end
|
|
68
69
|
|
|
69
70
|
def on_turn_completed(event)
|
|
@@ -19,7 +19,9 @@ module Samagotchi
|
|
|
19
19
|
# (chat messages, timeout:, max_tokens:, cancel_controller:) and returns
|
|
20
20
|
# the answer text.
|
|
21
21
|
# +messages_partial+ says whether +messages+ leaves out a running turn;
|
|
22
|
-
# +
|
|
22
|
+
# +prompt_files+ returns the files whose text the built system prompt
|
|
23
|
+
# holds (SystemPrompt#prompt_files, [] before a build); +model_name+
|
|
24
|
+
# (the session's resolved model ref, live after /model) and
|
|
23
25
|
# +state_dir+ are what ctx.sessions forks with, and +setup+ (the
|
|
24
26
|
# session's own SessionSetup), which a fork copies; +model_key+ is the
|
|
25
27
|
# model's memory overlay key (ctx.model, ctx.model_key).
|
|
@@ -28,8 +30,8 @@ module Samagotchi
|
|
|
28
30
|
# +frontend+ returns the Engine's interface (:repl, :worker,
|
|
29
31
|
# :non_interactive), which ctx.frontend names for plugins.
|
|
30
32
|
Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
|
|
31
|
-
:ask_model, :model_name, :model_key, :state_dir, :scratch, :setup, :
|
|
32
|
-
:frontend, keyword_init: true)
|
|
33
|
+
:ask_model, :model_name, :model_key, :state_dir, :scratch, :setup, :prompt_files,
|
|
34
|
+
:steer, :stop_turn, :stop_generation, :frontend, keyword_init: true)
|
|
33
35
|
|
|
34
36
|
# ctx.ask_model failed: the model couldn't be reached, timed out, or
|
|
35
37
|
# sent nothing usable. The message says why, for the user.
|
|
@@ -159,6 +161,17 @@ module Samagotchi
|
|
|
159
161
|
# a plugin can say what its answer is about.
|
|
160
162
|
def messages_partial? = !!@host.messages_partial&.call
|
|
161
163
|
|
|
164
|
+
# The files whose text the session's system prompt holds now, as
|
|
165
|
+
# absolute paths in prompt order (SystemPrompt#prompt_files): the
|
|
166
|
+
# AGENTS.md chain (the work tree top down to the cwd), then the
|
|
167
|
+
# appended files (append_system_prompt:, --append-system-prompt).
|
|
168
|
+
# [] before the first build and after one that drops them (a model
|
|
169
|
+
# or tools change). A Host without the member (a spec's) reads [].
|
|
170
|
+
# @return [Array<String>] frozen
|
|
171
|
+
def prompt_files
|
|
172
|
+
Array(@host.prompt_files&.call || []).freeze
|
|
173
|
+
end
|
|
174
|
+
|
|
162
175
|
# Run +block+ as this plugin's handler for +event+: until it returns,
|
|
163
176
|
# the helpers called on this thread act as the event's own. Nests (a
|
|
164
177
|
# fire inside a handler gets its own event; the outer one is back
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "memory_paths"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# The files whose text goes into the system prompt as they are
|
|
7
|
+
# (SystemPrompt): which AGENTS.md files a cwd loads, and how a file
|
|
8
|
+
# becomes a prompt section.
|
|
9
|
+
module PromptFiles
|
|
10
|
+
AGENTS_FILE = "AGENTS.md"
|
|
11
|
+
|
|
12
|
+
AGENTS_HEADER = "Project specific description:"
|
|
13
|
+
|
|
14
|
+
# A file's text as the prompt carries it: +path+ absolute, +text+ the
|
|
15
|
+
# section with its header.
|
|
16
|
+
Section = Data.define(:path, :text)
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# The AGENTS.md files a session in +cwd+ loads, as absolute paths,
|
|
21
|
+
# outermost first: inside a git work tree every one from its top (a
|
|
22
|
+
# linked worktree's own checkout) down to the cwd; outside a repository
|
|
23
|
+
# only the cwd's ([] with none).
|
|
24
|
+
# @param cwd [String] where the prompt is built (Dir.pwd, not the
|
|
25
|
+
# session's working directory: /tmp vs /private/tmp would change the
|
|
26
|
+
# prompt bytes)
|
|
27
|
+
# @return [Array<String>]
|
|
28
|
+
def agents_paths(cwd)
|
|
29
|
+
chain_dirs(cwd).map { |dir| File.join(dir, AGENTS_FILE) }.select { |path| File.file?(path) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The sections of the AGENTS.md chain (#agents_paths) with text: the
|
|
33
|
+
# first keeps the plain header (a one-file session's prompt is as it
|
|
34
|
+
# always was), each further one names its directory relative to the
|
|
35
|
+
# work tree top ("Project specific description for pkg/:").
|
|
36
|
+
# @return [Array<Section>]
|
|
37
|
+
def agents_sections(cwd)
|
|
38
|
+
top = MemoryPaths.work_tree_root(cwd)
|
|
39
|
+
sections = []
|
|
40
|
+
agents_paths(cwd).each do |path|
|
|
41
|
+
dir = File.dirname(path)
|
|
42
|
+
header = sections.empty? ? AGENTS_HEADER : "Project specific description for #{relative_dir(dir, top) || dir}/:"
|
|
43
|
+
text = section(path, header)
|
|
44
|
+
sections << Section.new(path: path, text: text) if text
|
|
45
|
+
end
|
|
46
|
+
sections
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The directories whose AGENTS.md a session in +cwd+ reads, outermost first.
|
|
50
|
+
def chain_dirs(cwd)
|
|
51
|
+
cwd = File.expand_path(cwd)
|
|
52
|
+
top = MemoryPaths.work_tree_root(cwd)
|
|
53
|
+
return [cwd] unless top
|
|
54
|
+
|
|
55
|
+
rel = relative_dir(cwd, top)
|
|
56
|
+
return [top] if rel == "."
|
|
57
|
+
# A cwd the walk up didn't reach from (not expected): only its own and the top's.
|
|
58
|
+
return [top, cwd] if rel.nil?
|
|
59
|
+
|
|
60
|
+
parts = rel.split("/")
|
|
61
|
+
[top] + parts.each_index.map { |i| File.join(top, *parts[0..i]) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# +dir+ relative to +top+ ("." for the top itself), nil when it isn't under it.
|
|
65
|
+
def relative_dir(dir, top)
|
|
66
|
+
return "." if dir == top
|
|
67
|
+
|
|
68
|
+
prefix = top.end_with?("/") ? top : "#{top}/"
|
|
69
|
+
dir.start_with?(prefix) ? dir.delete_prefix(prefix) : nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# How a prompt file is named in the prompt and /stats: relative to the
|
|
73
|
+
# work tree +top+ when inside it, else with ~ for the home directory.
|
|
74
|
+
# @return [String]
|
|
75
|
+
def display_path(path, top:)
|
|
76
|
+
rel = top && relative_dir(path, top)
|
|
77
|
+
return rel if rel && rel != "."
|
|
78
|
+
|
|
79
|
+
home = Dir.home
|
|
80
|
+
path.start_with?("#{home}/") ? "~#{path.delete_prefix(home)}" : path
|
|
81
|
+
rescue ArgumentError
|
|
82
|
+
path
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# A chi --append-system-prompt value: [absolute path, nil], or
|
|
86
|
+
# [absolute path, the reason it can't be used].
|
|
87
|
+
# @param cwd [String] the shell's cwd the value is relative to
|
|
88
|
+
# @return [Array(String, String)]
|
|
89
|
+
def check_cli_file(value, cwd: Dir.pwd)
|
|
90
|
+
begin
|
|
91
|
+
path = File.expand_path(value.to_s, cwd)
|
|
92
|
+
rescue ArgumentError
|
|
93
|
+
# A quoted "~nosuchuser/x" reaches chi unexpanded.
|
|
94
|
+
return [value.to_s, "no such user"]
|
|
95
|
+
end
|
|
96
|
+
reason = if !File.exist?(path) then "no such file"
|
|
97
|
+
elsif !File.file?(path) then "not a file"
|
|
98
|
+
elsif !File.readable?(path) then "can't be read"
|
|
99
|
+
end
|
|
100
|
+
[path, reason]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Text this long goes into every request: chi warns once, at start.
|
|
104
|
+
LARGE_CHARS = 32_000
|
|
105
|
+
|
|
106
|
+
# The number of chars of +path+ when above LARGE_CHARS, else nil.
|
|
107
|
+
# @return [Integer, nil]
|
|
108
|
+
def large_size(path)
|
|
109
|
+
chars = File.read(path, encoding: "UTF-8").length
|
|
110
|
+
chars > LARGE_CHARS ? chars : nil
|
|
111
|
+
rescue StandardError
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# +path+'s text under +header+ ("<header>\n<text>", stripped); nil when
|
|
116
|
+
# the file is empty or can't be read.
|
|
117
|
+
# @return [String, nil]
|
|
118
|
+
def section(path, header)
|
|
119
|
+
content = File.read(path, encoding: "UTF-8").strip
|
|
120
|
+
return nil if content.empty?
|
|
121
|
+
|
|
122
|
+
"#{header}\n#{content}"
|
|
123
|
+
rescue StandardError
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# One thing a click on a ref can do besides the kind's own (the web keeps
|
|
7
|
+
# those per kind, refs.js REF_ACTIONS): open +url+ in a new tab, shown as
|
|
8
|
+
# +label+ in the ref's menu. A plugin names it ("PR diff", "Open in Jira").
|
|
9
|
+
# The URL is http(s) only, checked here and again in the page
|
|
10
|
+
# (refs.js httpUrl): the page opens it as is, so a javascript:, data: or
|
|
11
|
+
# editor URL never gets there.
|
|
12
|
+
RefAction = Data.define(:id, :label, :url) do
|
|
13
|
+
# @raise [ArgumentError] an empty id or label, or a URL that isn't an
|
|
14
|
+
# http(s) one with a host, at most MAX_URL characters
|
|
15
|
+
def initialize(id:, label:, url:)
|
|
16
|
+
id = id.to_s.strip
|
|
17
|
+
label = label.to_s.strip
|
|
18
|
+
url = url.to_s.strip
|
|
19
|
+
raise ArgumentError, "a ref action needs an id" if id.empty?
|
|
20
|
+
raise ArgumentError, "a ref action needs a label" if label.empty?
|
|
21
|
+
raise ArgumentError, "a ref action's url must be http(s) with a host, at most #{RefAction::MAX_URL} characters" unless RefAction.http_url?(url)
|
|
22
|
+
|
|
23
|
+
super
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class RefAction
|
|
28
|
+
MAX_URL = 2_000
|
|
29
|
+
|
|
30
|
+
# @return [Boolean] +url+ is an http or https URL with a host
|
|
31
|
+
def self.http_url?(url)
|
|
32
|
+
return false if url.length > MAX_URL
|
|
33
|
+
|
|
34
|
+
uri = URI.parse(url)
|
|
35
|
+
uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
|
|
36
|
+
rescue URI::InvalidURIError
|
|
37
|
+
false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @param value [RefAction, Hash] a plugin's {id:, label:, url:} (String
|
|
41
|
+
# or Symbol keys)
|
|
42
|
+
# @raise [ArgumentError] anything else, or an invalid action
|
|
43
|
+
def self.from(value)
|
|
44
|
+
return value if value.is_a?(RefAction)
|
|
45
|
+
raise ArgumentError, "a ref action is a Hash {id:, label:, url:}" unless value.is_a?(Hash)
|
|
46
|
+
|
|
47
|
+
value = value.transform_keys(&:to_sym)
|
|
48
|
+
new(id: value[:id], label: value[:label], url: value[:url])
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# A ref as data: a kind, a target, an optional label and the plugins'
|
|
53
|
+
# extra actions (RefAction), for a UI to trace it. The kind's own actions
|
|
54
|
+
# are the UI's (a file ref keeps the editor, show in turn and copy), so
|
|
55
|
+
# +actions+ only adds to them.
|
|
56
|
+
# - +kind+ "file": +target+ {path:, line:, end_line:}, the lines only when
|
|
57
|
+
# positive, the end only past the start (FileRef's shape).
|
|
58
|
+
# - Any other kind ("issue", "commit", …): +target+ {id:}. The page shows
|
|
59
|
+
# its +label+ (or the text it matched) and runs its first action.
|
|
60
|
+
# +to_h+ is the shape the page reads (refs.js bindRef). FileRef stays the
|
|
61
|
+
# rows' own ref; this is the one a plugin gives.
|
|
62
|
+
Ref = Data.define(:kind, :target, :label, :actions) do
|
|
63
|
+
# @raise [ArgumentError] a kind that isn't a short lowercase word, a
|
|
64
|
+
# target without its kind's key, a non-list of actions or a bad action
|
|
65
|
+
def initialize(kind:, target:, label: nil, actions: [])
|
|
66
|
+
kind = kind.to_s
|
|
67
|
+
raise ArgumentError, "a ref's kind is a short lowercase word (#{kind.inspect})" unless Ref::KIND.match?(kind)
|
|
68
|
+
raise ArgumentError, "a ref's actions are a list" unless actions.nil? || actions.is_a?(Array)
|
|
69
|
+
|
|
70
|
+
label = label.to_s.strip
|
|
71
|
+
super(kind: kind, target: Ref.target_for(kind, target), label: label.empty? ? nil : label,
|
|
72
|
+
actions: Array(actions).map { |action| RefAction.from(action) }.freeze)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @return [Hash] {kind:, target:, label:, actions: [{id:, label:, url:}]},
|
|
76
|
+
# no label when there is none
|
|
77
|
+
def to_h = { kind: kind, target: target, label: label, actions: actions.map(&:to_h) }.compact
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
class Ref
|
|
81
|
+
KIND = /\A[a-z][a-z0-9_-]{0,31}\z/
|
|
82
|
+
# FileRef::KIND, not required here (it loads the file tools).
|
|
83
|
+
FILE = "file"
|
|
84
|
+
|
|
85
|
+
# A plugin's {kind:, target:, label:, actions:} (String or Symbol keys).
|
|
86
|
+
# @raise [ArgumentError] as +new+
|
|
87
|
+
def self.from(value)
|
|
88
|
+
raise ArgumentError, "a ref is a Hash {kind:, target:, label:, actions:}" unless value.is_a?(Hash)
|
|
89
|
+
|
|
90
|
+
value = value.transform_keys(&:to_sym)
|
|
91
|
+
new(kind: value[:kind], target: value[:target], label: value[:label], actions: value[:actions] || [])
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# +kind+'s target from +raw+: a file's path and lines, any other kind's id.
|
|
95
|
+
# @raise [ArgumentError] not a Hash, or without the kind's key
|
|
96
|
+
def self.target_for(kind, raw)
|
|
97
|
+
raise ArgumentError, "a ref's target is a Hash" unless raw.is_a?(Hash)
|
|
98
|
+
|
|
99
|
+
raw = raw.transform_keys(&:to_sym)
|
|
100
|
+
kind == FILE ? file_target(raw) : { id: required(raw[:id], :id) }.freeze
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def self.file_target(raw)
|
|
104
|
+
line = positive(raw[:line])
|
|
105
|
+
end_line = line && positive(raw[:end_line])
|
|
106
|
+
{ path: required(raw[:path], :path), line: line, end_line: end_line && end_line > line ? end_line : nil }.compact.freeze
|
|
107
|
+
end
|
|
108
|
+
private_class_method :file_target
|
|
109
|
+
|
|
110
|
+
def self.required(value, key)
|
|
111
|
+
value = value.to_s.strip
|
|
112
|
+
raise ArgumentError, "a ref's target needs #{key}" if value.empty?
|
|
113
|
+
|
|
114
|
+
value
|
|
115
|
+
end
|
|
116
|
+
private_class_method :required
|
|
117
|
+
|
|
118
|
+
def self.positive(value)
|
|
119
|
+
number = Integer(value.to_s.strip, exception: false)
|
|
120
|
+
number&.positive? ? number : nil
|
|
121
|
+
end
|
|
122
|
+
private_class_method :positive
|
|
123
|
+
end
|
|
124
|
+
end
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -53,6 +53,15 @@ module Samagotchi
|
|
|
53
53
|
# from it (--mute): the worker rebuilds the same prompt on a respawn.
|
|
54
54
|
# Names as given; the engine normalizes them.
|
|
55
55
|
attr_accessor :preloaded_memory_names, :muted_memory_names
|
|
56
|
+
# The files chi --append-system-prompt put into the session's system
|
|
57
|
+
# prompt (absolute paths; config.yml's append_system_prompt: entries
|
|
58
|
+
# aren't stored): kept on respawns, copied by a fork and a continue
|
|
59
|
+
# (SessionSetup).
|
|
60
|
+
attr_reader :appended_prompt_files
|
|
61
|
+
|
|
62
|
+
def appended_prompt_files=(paths)
|
|
63
|
+
@appended_prompt_files = Array(paths).map(&:to_s).reject(&:empty?).uniq
|
|
64
|
+
end
|
|
56
65
|
# The model notes the session's system prompt carried at its last build
|
|
57
66
|
# (Array<PromptNote>: name, scope, chars, digest; ModelNotes), set by the
|
|
58
67
|
# Engine at each build (a start, a resume's or a woken worker's first
|
|
@@ -182,7 +191,7 @@ module Samagotchi
|
|
|
182
191
|
used_memory_names: [], project_root: nil,
|
|
183
192
|
preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
|
|
184
193
|
last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [],
|
|
185
|
-
continues: nil, thinking: nil, reasoning_history: nil)
|
|
194
|
+
continues: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [])
|
|
186
195
|
@id = id
|
|
187
196
|
@metadata_version = metadata_version
|
|
188
197
|
@mode = mode
|
|
@@ -210,6 +219,7 @@ module Samagotchi
|
|
|
210
219
|
self.llm_context = llm_context
|
|
211
220
|
self.thinking = thinking
|
|
212
221
|
self.reasoning_history = reasoning_history
|
|
222
|
+
self.appended_prompt_files = appended_prompt_files
|
|
213
223
|
@archived = false
|
|
214
224
|
end
|
|
215
225
|
|
|
@@ -289,6 +299,7 @@ module Samagotchi
|
|
|
289
299
|
llm_context: setup.llm_context,
|
|
290
300
|
thinking: setup.thinking,
|
|
291
301
|
reasoning_history: setup.reasoning_history,
|
|
302
|
+
appended_prompt_files: setup.appended_prompt_files,
|
|
292
303
|
continues: continues
|
|
293
304
|
)
|
|
294
305
|
end
|
|
@@ -351,7 +362,8 @@ module Samagotchi
|
|
|
351
362
|
"llm_context" => nil,
|
|
352
363
|
"continues" => nil,
|
|
353
364
|
"thinking" => nil,
|
|
354
|
-
"reasoning_history" => nil
|
|
365
|
+
"reasoning_history" => nil,
|
|
366
|
+
"appended_prompt_files" => []
|
|
355
367
|
}.freeze
|
|
356
368
|
|
|
357
369
|
# A session from a parsed session file. +messages+ false leaves the
|
|
@@ -476,7 +488,7 @@ module Samagotchi
|
|
|
476
488
|
when "reasoning_history" then reasoning_history_file
|
|
477
489
|
when "prompt_notes" then @prompt_notes.map(&:to_file)
|
|
478
490
|
when "last_turn" then @last_turn&.to_file
|
|
479
|
-
when "used_memory_names", "preloaded_memory_names", "muted_memory_names"
|
|
491
|
+
when "used_memory_names", "preloaded_memory_names", "muted_memory_names", "appended_prompt_files"
|
|
480
492
|
Array(instance_variable_get(:"@#{key}"))
|
|
481
493
|
else instance_variable_get(:"@#{key}")
|
|
482
494
|
end
|
|
@@ -142,9 +142,13 @@ module Samagotchi
|
|
|
142
142
|
:cuts,
|
|
143
143
|
:capped,
|
|
144
144
|
:model,
|
|
145
|
-
#
|
|
146
|
-
# Continue
|
|
145
|
+
# A turn with no prompt of its own (:turn_started's +continue+): a
|
|
146
|
+
# step-limit Continue, a reminder's or a wake's turn; its record
|
|
147
|
+
# carries it.
|
|
147
148
|
:continue,
|
|
149
|
+
# The turn the user started by answering the step-limit question with
|
|
150
|
+
# Continue (:turn_started's +resumed+): it resumed the run before it.
|
|
151
|
+
:resumed,
|
|
148
152
|
:id,
|
|
149
153
|
:started_at,
|
|
150
154
|
:started_monotonic,
|
|
@@ -558,6 +562,7 @@ module Samagotchi
|
|
|
558
562
|
capped: 0,
|
|
559
563
|
model: nil,
|
|
560
564
|
continue: event[:continue] ? true : false,
|
|
565
|
+
resumed: event[:resumed] ? true : false,
|
|
561
566
|
id: event[:turn_id] || SecureRandom.uuid,
|
|
562
567
|
started_at: now.iso8601(3),
|
|
563
568
|
started_monotonic: monotonic_time,
|
|
@@ -597,6 +602,7 @@ module Samagotchi
|
|
|
597
602
|
# Every record says, so the report can tell a file with no Continue
|
|
598
603
|
# from one written before the mark.
|
|
599
604
|
record[:continue] = @turn.continue
|
|
605
|
+
record[:resumed] = @turn.resumed
|
|
600
606
|
record.merge!(turn_token_fields(@turn))
|
|
601
607
|
@turn_records << record
|
|
602
608
|
count_turn(record)
|
|
@@ -10,15 +10,20 @@ module Samagotchi
|
|
|
10
10
|
# (SessionManager.spawn_session, a continue, a plugin's fork, the web's
|
|
11
11
|
# create, chi send --new): +llm_context+ is an LLMContextOverride or nil,
|
|
12
12
|
# +thinking+ a level Symbol (Thinking.session_level) or nil,
|
|
13
|
-
# +reasoning_history+ a level Symbol (ReasoningHistory.session_level) or nil
|
|
13
|
+
# +reasoning_history+ a level Symbol (ReasoningHistory.session_level) or nil,
|
|
14
|
+
# +appended_prompt_files+ the absolute paths chi --append-system-prompt
|
|
15
|
+
# named (their text goes into the system prompt; [] with none).
|
|
14
16
|
#
|
|
15
17
|
# It doesn't save itself: the session keeps each setting in its own field
|
|
16
18
|
# and file key, and resolves it on its own. A delegate child starts
|
|
17
19
|
# without one; a continue and a fork copy their session's (.of).
|
|
18
|
-
SessionSetup = Data.define(:llm_context, :thinking, :reasoning_history) do
|
|
19
|
-
def initialize(llm_context: nil, thinking: nil, reasoning_history: nil
|
|
20
|
+
SessionSetup = Data.define(:llm_context, :thinking, :reasoning_history, :appended_prompt_files) do
|
|
21
|
+
def initialize(llm_context: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [])
|
|
22
|
+
super(llm_context: llm_context, thinking: thinking, reasoning_history: reasoning_history,
|
|
23
|
+
appended_prompt_files: Array(appended_prompt_files).map(&:to_s).freeze)
|
|
24
|
+
end
|
|
20
25
|
|
|
21
|
-
def empty? = llm_context.nil? && thinking.nil? && reasoning_history.nil?
|
|
26
|
+
def empty? = llm_context.nil? && thinking.nil? && reasoning_history.nil? && appended_prompt_files.empty?
|
|
22
27
|
|
|
23
28
|
# A session's own setup, for a session that starts from it.
|
|
24
29
|
# @param session [Session, nil]
|
|
@@ -26,7 +31,8 @@ module Samagotchi
|
|
|
26
31
|
def self.of(session)
|
|
27
32
|
return new if session.nil?
|
|
28
33
|
|
|
29
|
-
new(llm_context: session.llm_context, thinking: session.thinking, reasoning_history: session.reasoning_history
|
|
34
|
+
new(llm_context: session.llm_context, thinking: session.thinking, reasoning_history: session.reasoning_history,
|
|
35
|
+
appended_prompt_files: session.appended_prompt_files)
|
|
30
36
|
end
|
|
31
37
|
end
|
|
32
38
|
end
|