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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +49 -1
  3. data/README.md +3 -2
  4. data/bin/chi +23 -2
  5. data/docs/architecture.md +4 -2
  6. data/docs/cli.md +9 -3
  7. data/docs/configuration.md +62 -12
  8. data/docs/hooks.md +10 -4
  9. data/docs/internals/engine.md +16 -8
  10. data/docs/internals/gemma4-contract.md +1 -0
  11. data/docs/internals/prompt-caching.md +3 -2
  12. data/docs/memory.md +1 -1
  13. data/docs/plugins.md +50 -1
  14. data/docs/sessions.md +1 -1
  15. data/docs/sub-agent.md +2 -2
  16. data/docs/testing.md +4 -2
  17. data/lib/samagotchi/bootstrap_command.rb +2 -1
  18. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
  19. data/lib/samagotchi/bundles/agents-md/manifest.yml +11 -0
  20. data/lib/samagotchi/bundles/agents-md/plugin.rb +198 -0
  21. data/lib/samagotchi/bundles/core/manifest.yml +2 -1
  22. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  23. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  24. data/lib/samagotchi/config.rb +33 -5
  25. data/lib/samagotchi/continue_offer.rb +5 -2
  26. data/lib/samagotchi/engine.rb +63 -14
  27. data/lib/samagotchi/iteration_limit.rb +3 -2
  28. data/lib/samagotchi/kernel_loop.rb +35 -9
  29. data/lib/samagotchi/llm/backend.rb +6 -1
  30. data/lib/samagotchi/llm/chat_loop.rb +11 -6
  31. data/lib/samagotchi/llm/model_result.rb +11 -2
  32. data/lib/samagotchi/llm/native_backend.rb +3 -2
  33. data/lib/samagotchi/log_subscriber.rb +2 -1
  34. data/lib/samagotchi/plugin/context.rb +16 -3
  35. data/lib/samagotchi/prompt_files.rb +127 -0
  36. data/lib/samagotchi/ref.rb +124 -0
  37. data/lib/samagotchi/session.rb +15 -3
  38. data/lib/samagotchi/session_metrics.rb +8 -2
  39. data/lib/samagotchi/session_setup.rb +11 -5
  40. data/lib/samagotchi/system_prompt.rb +71 -15
  41. data/lib/samagotchi/terminal_ui/attach_launcher.rb +18 -9
  42. data/lib/samagotchi/terminal_ui/formatting.rb +14 -0
  43. data/lib/samagotchi/terminal_ui.rb +17 -5
  44. data/lib/samagotchi/turn_flow.rb +22 -3
  45. data/lib/samagotchi/version.rb +1 -1
  46. data/lib/samagotchi/web/public/favicon.svg +1 -0
  47. data/lib/samagotchi/web/public/index.html +1 -0
  48. data/lib/samagotchi/web/public/refs.js +107 -15
  49. data/lib/samagotchi/worker.rb +1 -0
  50. 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
- def sanitize_history(messages)
996
- messages.map do |m|
997
- if m[:role] == "model"
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
- def prepare_conversation(messages) = sanitize_history(messages)
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 continue after a stop -- sends none of the run before it).
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
- def initialize(loop, conversation, on_stream_event, cancel_controller, model_name, pending_input)
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
- # KernelLoop only marks a run exhausted when it stopped on pending tool
61
- # calls, so exhausted? alone already means "resumable".
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, **items(event), **origin(event))
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
- # +model_name+ (the session's resolved model ref, live after /model) and
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, :steer, :stop_turn, :stop_generation,
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
@@ -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
- # The turn the user started by answering the step-limit question with
146
- # Continue (:turn_started's +continue+); its record carries it.
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) = super
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