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
|
@@ -11,6 +11,7 @@ require_relative "model_notes"
|
|
|
11
11
|
require_relative "bundle_needs"
|
|
12
12
|
require_relative "memory_bundle/index_size"
|
|
13
13
|
require_relative "thinking"
|
|
14
|
+
require_relative "prompt_files"
|
|
14
15
|
|
|
15
16
|
module Samagotchi
|
|
16
17
|
# The system prompt an Engine gives its model: the base prompt (tool
|
|
@@ -24,8 +25,6 @@ module Samagotchi
|
|
|
24
25
|
# tools a plugin replaces, the attached session, the thinking level) is
|
|
25
26
|
# read at build time through the lookups; the memory lists are fixed.
|
|
26
27
|
class SystemPrompt
|
|
27
|
-
AGENT_DESCRIPTION_FILE = "AGENTS.md"
|
|
28
|
-
|
|
29
28
|
# The model a session runs on, as the prompt names it: the resolved
|
|
30
29
|
# ref, its host ("name, host:port" or url), the memory overlay key, and
|
|
31
30
|
# what a llama.cpp server says it serves when that differs (else nil).
|
|
@@ -51,8 +50,10 @@ module Samagotchi
|
|
|
51
50
|
# model's (none: the section is left out)
|
|
52
51
|
# @param memories [Array<String>] the --memory list
|
|
53
52
|
# @param muted_memory_names [Array<String>] normalized (MutedMemories)
|
|
53
|
+
# @param appended_files [Array<String>] chi --append-system-prompt's
|
|
54
|
+
# files (absolute paths), after config.yml's append_system_prompt:
|
|
54
55
|
def initialize(profile:, tools:, session:, thinking:, model: -> {}, model_notes: -> { [] }, memories: [],
|
|
55
|
-
muted_memory_names: [])
|
|
56
|
+
muted_memory_names: [], appended_files: [])
|
|
56
57
|
@profile_lookup = profile
|
|
57
58
|
@model_lookup = model
|
|
58
59
|
@model_notes_lookup = model_notes
|
|
@@ -61,6 +62,14 @@ module Samagotchi
|
|
|
61
62
|
@thinking_lookup = thinking
|
|
62
63
|
@muted_memory_names = muted_memory_names
|
|
63
64
|
@requested_memories = effective_preload_list(preload_memory_list(memories))
|
|
65
|
+
# Read once, as the memories: list is: a config edit is picked up by
|
|
66
|
+
# a new process. The files' text is read at each build.
|
|
67
|
+
@config_appended = begin
|
|
68
|
+
ConfigFile.appended_prompt_files
|
|
69
|
+
rescue StandardError
|
|
70
|
+
[]
|
|
71
|
+
end
|
|
72
|
+
@cli_appended = Array(appended_files).map { |path| File.expand_path(path.to_s) }
|
|
64
73
|
end
|
|
65
74
|
|
|
66
75
|
# @return [Array<String>] the names the session preloads, known before
|
|
@@ -81,16 +90,20 @@ module Samagotchi
|
|
|
81
90
|
@built ||= {}
|
|
82
91
|
@index_sizes ||= {}
|
|
83
92
|
@notes_loaded ||= {}
|
|
93
|
+
@files_loaded ||= {}
|
|
84
94
|
unless @built.key?(key)
|
|
85
95
|
@section_notes = nil
|
|
96
|
+
@section_files = []
|
|
86
97
|
@built[key] = system_prompt_with_index(
|
|
87
98
|
assist_system_prompt(chat: chat, thinking: thinking, layers: layers), chat: chat, thinking: thinking, layers: layers
|
|
88
99
|
)
|
|
89
100
|
@index_sizes[key] = @measured_index
|
|
90
101
|
@notes_loaded[key] = @section_notes || []
|
|
102
|
+
@files_loaded[key] = @section_files.freeze
|
|
91
103
|
end
|
|
92
104
|
@memory_index = @index_sizes[key]
|
|
93
105
|
@prompt_notes = @notes_loaded[key]
|
|
106
|
+
@prompt_files = @files_loaded[key]
|
|
94
107
|
@built[key]
|
|
95
108
|
end
|
|
96
109
|
|
|
@@ -108,6 +121,14 @@ module Samagotchi
|
|
|
108
121
|
# @return [Array<ModelNotes::Note>, nil]
|
|
109
122
|
attr_reader :prompt_notes
|
|
110
123
|
|
|
124
|
+
# The files whose text the prompt #build returned last holds, as
|
|
125
|
+
# absolute paths in prompt order: the AGENTS.md chain, then the
|
|
126
|
+
# appended files (append_system_prompt:, --append-system-prompt). [] before a
|
|
127
|
+
# build and after #reset!. Read when the prompt is built, as the
|
|
128
|
+
# sections are.
|
|
129
|
+
# @return [Array<String>]
|
|
130
|
+
def prompt_files = @prompt_files || []
|
|
131
|
+
|
|
111
132
|
# Drops the built prompts: the next #build reads the profile, tools,
|
|
112
133
|
# indexes and memories again (a model or profile switch, changed tools).
|
|
113
134
|
def reset!
|
|
@@ -115,6 +136,8 @@ module Samagotchi
|
|
|
115
136
|
@index_sizes = nil
|
|
116
137
|
@notes_loaded = nil
|
|
117
138
|
@prompt_notes = nil
|
|
139
|
+
@files_loaded = nil
|
|
140
|
+
@prompt_files = nil
|
|
118
141
|
@stable_lengths = nil
|
|
119
142
|
end
|
|
120
143
|
|
|
@@ -295,7 +318,7 @@ module Samagotchi
|
|
|
295
318
|
"System memories:\n#{indexes[:system]}"
|
|
296
319
|
].join("\n\n")
|
|
297
320
|
stable = [thinking_token + base, rg_guidance, system_identity_section, model_notes_section, explicit_memory_section,
|
|
298
|
-
project_specific_description, memory_sections]
|
|
321
|
+
project_specific_description, appended_section, memory_sections]
|
|
299
322
|
stable_text = stable.compact.join("\n")
|
|
300
323
|
volatile = [current_model, project_location, current_session].compact
|
|
301
324
|
return with_tail_declarations(stable_text, chat: chat, layers: layers) if volatile.empty?
|
|
@@ -464,26 +487,59 @@ module Samagotchi
|
|
|
464
487
|
|
|
465
488
|
# ── Project / rg helpers ───────────────────────────────────────────────────
|
|
466
489
|
|
|
467
|
-
# AGENTS.md
|
|
490
|
+
# The AGENTS.md chain from the top of the git work tree down to the cwd
|
|
491
|
+
# (PromptFiles.agents_sections), one section per file with text.
|
|
468
492
|
def project_specific_description
|
|
469
493
|
return nil if skip_agent_description?
|
|
470
494
|
|
|
471
|
-
|
|
472
|
-
return nil
|
|
473
|
-
|
|
474
|
-
content = File.read(path, encoding: "UTF-8").strip
|
|
475
|
-
return nil if content.empty?
|
|
495
|
+
sections = PromptFiles.agents_sections(Dir.pwd)
|
|
496
|
+
return nil if sections.empty?
|
|
476
497
|
|
|
477
|
-
|
|
498
|
+
@section_files&.concat(sections.map(&:path))
|
|
499
|
+
sections.map(&:text).join("\n\n")
|
|
478
500
|
rescue StandardError
|
|
479
501
|
nil
|
|
480
502
|
end
|
|
481
503
|
|
|
482
|
-
|
|
504
|
+
# The appended files' text, right after AGENTS.md (the stable, cached
|
|
505
|
+
# part): config.yml's append_system_prompt: entries first (a relative
|
|
506
|
+
# one against the cwd, as AGENTS.md is found; a missing one is skipped),
|
|
507
|
+
# then --append-system-prompt's. A file the AGENTS.md chain holds, a
|
|
508
|
+
# repeat and an empty or vanished file are left out.
|
|
509
|
+
def appended_section
|
|
483
510
|
cwd = Dir.pwd
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
511
|
+
top = MemoryPaths.work_tree_root(cwd)
|
|
512
|
+
seen = Set.new((skip_agent_description? ? [] : PromptFiles.agents_paths(cwd)).map { |path| canonical_path(path) })
|
|
513
|
+
sections = []
|
|
514
|
+
appended_candidates(cwd).each do |path, source|
|
|
515
|
+
next unless seen.add?(canonical_path(path))
|
|
516
|
+
|
|
517
|
+
unless File.file?(path)
|
|
518
|
+
Log.debug(:config, "appended_prompt_file_missing", path: path, source: source)
|
|
519
|
+
next
|
|
520
|
+
end
|
|
521
|
+
text = PromptFiles.section(path, "Appended from #{PromptFiles.display_path(path, top: top)}:")
|
|
522
|
+
next unless text
|
|
523
|
+
|
|
524
|
+
@section_files&.push(path)
|
|
525
|
+
sections << text
|
|
526
|
+
end
|
|
527
|
+
sections.empty? ? nil : sections.join("\n\n")
|
|
528
|
+
rescue StandardError
|
|
529
|
+
nil
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
# [absolute path, "config" | "cli"], in prompt order.
|
|
533
|
+
def appended_candidates(cwd)
|
|
534
|
+
@config_appended.map { |entry| [File.expand_path(entry, cwd), "config"] } +
|
|
535
|
+
@cli_appended.map { |path| [path, "cli"] }
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
# The same file however it's spelled (/tmp vs /private/tmp on macOS).
|
|
539
|
+
def canonical_path(path)
|
|
540
|
+
File.realpath(path)
|
|
541
|
+
rescue SystemCallError
|
|
542
|
+
File.expand_path(path)
|
|
487
543
|
end
|
|
488
544
|
|
|
489
545
|
# Where the session runs and which project memory folder it uses. Only
|
|
@@ -37,6 +37,8 @@ module Samagotchi
|
|
|
37
37
|
# @param memories [Array<String>] --memory: a new session's worker
|
|
38
38
|
# preloads them (an existing session keeps its own list)
|
|
39
39
|
# @param muted_memories [Array<String>] --mute: hidden from a new session
|
|
40
|
+
# @param appended_prompt_files [Array<String>] --append-system-prompt's
|
|
41
|
+
# files (absolute): a new session's setup carries them
|
|
40
42
|
# @param llm_context [Hash{Symbol => String}] --llm-context* as typed
|
|
41
43
|
# (LLMContextOverride.update): a new session starts with them; a
|
|
42
44
|
# resumed or attached one's worker gets a /llm-context, after the
|
|
@@ -48,9 +50,10 @@ module Samagotchi
|
|
|
48
50
|
# :failed when the --model switch didn't go through, or (input from a
|
|
49
51
|
# pipe) :turn_failed / :empty_answer / :unanswered (AttachedLoop#run)
|
|
50
52
|
def run(attach: nil, shared: false, resume: nil, prompt: nil, model: nil, no_interrupt: false, default_input: true,
|
|
51
|
-
memories: [], muted_memories: [], llm_context: {}, thinking: nil)
|
|
53
|
+
memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [])
|
|
52
54
|
client = connect(attach: attach, shared: shared, resume: resume, model: model,
|
|
53
|
-
memories: memories, muted_memories: muted_memories, llm_context: llm_context, thinking: thinking
|
|
55
|
+
memories: memories, muted_memories: muted_memories, llm_context: llm_context, thinking: thinking,
|
|
56
|
+
appended_prompt_files: appended_prompt_files)
|
|
54
57
|
commands = []
|
|
55
58
|
commands << "/model #{model}" if model && (attach || resume)
|
|
56
59
|
commands << LLMContextOverride.command_line(llm_context) if (attach || resume) && !llm_context.empty?
|
|
@@ -117,9 +120,11 @@ module Samagotchi
|
|
|
117
120
|
# @return [BridgeClient] a client for the live Bridge of the session
|
|
118
121
|
# @raise [Error]
|
|
119
122
|
def connect(attach: nil, shared: false, resume: nil, model: nil, state_dir: nil, wait: BRIDGE_WAIT,
|
|
120
|
-
memories: [], muted_memories: [], llm_context: {}, thinking: nil, err: $stderr)
|
|
123
|
+
memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [], err: $stderr)
|
|
121
124
|
sd = state_dir || Session.default_state_dir
|
|
122
|
-
|
|
125
|
+
if attach || resume
|
|
126
|
+
warn_memory_flags_ignored(attach || resume, memories, muted_memories, err, appended_prompt_files: appended_prompt_files)
|
|
127
|
+
end
|
|
123
128
|
if attach
|
|
124
129
|
live = connect_existing(attach, sd)
|
|
125
130
|
return live if live
|
|
@@ -134,7 +139,8 @@ module Samagotchi
|
|
|
134
139
|
SessionManager.spawn_session(prompt: nil, model_name: model && model_ref(model), state_dir: sd,
|
|
135
140
|
memories: memories, muted_memories: muted_memories,
|
|
136
141
|
setup: SessionSetup.new(llm_context: llm_context.empty? ? nil : LLMContextOverride.update(nil, llm_context),
|
|
137
|
-
thinking: Thinking.session_level(thinking)
|
|
142
|
+
thinking: Thinking.session_level(thinking),
|
|
143
|
+
appended_prompt_files: appended_prompt_files))
|
|
138
144
|
end
|
|
139
145
|
client = BridgeClient.wait_for(session.id, session_dir: Session.session_dir(session.id, state_dir: sd), timeout: wait)
|
|
140
146
|
client || raise(Error, "the worker for session #{session.id} did not start its Bridge in time")
|
|
@@ -150,16 +156,19 @@ module Samagotchi
|
|
|
150
156
|
end
|
|
151
157
|
|
|
152
158
|
# An existing session's prompt is built from its own session fields:
|
|
153
|
-
# --memory/--mute given with --attach or
|
|
154
|
-
# line saying so (printed before the live
|
|
155
|
-
|
|
159
|
+
# --memory/--mute/--append-system-prompt given with --attach or
|
|
160
|
+
# --resume are ignored, with a line saying so (printed before the live
|
|
161
|
+
# region opens).
|
|
162
|
+
def warn_memory_flags_ignored(session_id, memories, muted_memories, err, appended_prompt_files: [])
|
|
156
163
|
flags = []
|
|
157
164
|
flags << "--memory" unless Array(memories).empty?
|
|
158
165
|
flags << "--mute" unless Array(muted_memories).empty?
|
|
166
|
+
flags << "--append-system-prompt" unless Array(appended_prompt_files).empty?
|
|
159
167
|
return if flags.empty?
|
|
160
168
|
|
|
161
169
|
verb = flags.size == 1 ? "applies" : "apply"
|
|
162
|
-
|
|
170
|
+
names = flags.size > 2 ? "#{flags[0..-2].join(", ")} and #{flags.last}" : flags.join(" and ")
|
|
171
|
+
err.puts "(#{names} #{verb} to a new session; #{session_id}'s prompt is already built)"
|
|
163
172
|
end
|
|
164
173
|
|
|
165
174
|
# @return [BridgeClient, nil] the running worker's, or nil when none runs
|
|
@@ -8,6 +8,7 @@ require_relative "../steer"
|
|
|
8
8
|
require_relative "../steer_cut"
|
|
9
9
|
require_relative "../memory_bundle/index_size"
|
|
10
10
|
require_relative "../prompt_note"
|
|
11
|
+
require_relative "../prompt_files"
|
|
11
12
|
|
|
12
13
|
module Samagotchi
|
|
13
14
|
class TerminalUI
|
|
@@ -404,6 +405,16 @@ module Samagotchi
|
|
|
404
405
|
"#{"~" if source.to_s == "estimate"}#{number} tok/s"
|
|
405
406
|
end
|
|
406
407
|
|
|
408
|
+
# The prompt files (absolute paths) named as the prompt names them:
|
|
409
|
+
# relative to this checkout's top, else with ~; nil with none.
|
|
410
|
+
def prompt_files_text(paths)
|
|
411
|
+
list = Array(paths).map(&:to_s).reject(&:empty?)
|
|
412
|
+
return nil if list.empty?
|
|
413
|
+
|
|
414
|
+
top = MemoryPaths.work_tree_root(Dir.pwd)
|
|
415
|
+
list.map { |path| PromptFiles.display_path(path, top: top) }.join(", ")
|
|
416
|
+
end
|
|
417
|
+
|
|
407
418
|
# "~3.4k tokens in this session's prompt (system 2.0k, project 1.4k)":
|
|
408
419
|
# the memory indexes the session's prompt holds (the snapshot's
|
|
409
420
|
# memory_index, symbol or string keys); "" without one. As ctx.js
|
|
@@ -600,6 +611,9 @@ module Samagotchi
|
|
|
600
611
|
# The model notes the session's prompt carried (Engine#prompt_notes).
|
|
601
612
|
notes = PromptNote.text(snapshot[:prompt_notes])
|
|
602
613
|
lines << "model notes: #{notes}" unless notes.empty?
|
|
614
|
+
# The files whose text is in the prompt (SystemPrompt#prompt_files).
|
|
615
|
+
files = prompt_files_text(snapshot[:prompt_files])
|
|
616
|
+
lines << "prompt files: #{files}" if files
|
|
603
617
|
llm_context = llm_context_stats_text(snapshot[:llm_context])
|
|
604
618
|
lines << "llm context: #{llm_context}" if llm_context
|
|
605
619
|
thinking = setting_stats_text(snapshot[:thinking])
|
|
@@ -92,8 +92,12 @@ module Samagotchi
|
|
|
92
92
|
# (LLMContextOverride.update): set on the new or resumed session
|
|
93
93
|
# @param thinking [String, nil] --thinking as typed: the new or resumed
|
|
94
94
|
# session's own level (default unsets it)
|
|
95
|
+
# @param appended_prompt_files [Array<String>] --append-system-prompt's
|
|
96
|
+
# files (absolute): a new session saves them; a resumed one's own come
|
|
97
|
+
# first, and the merge goes only to the Engine (as --memory's does)
|
|
95
98
|
def initialize(prompt: nil, client: nil, host_registry: nil, profile: nil, session_id: nil, no_interrupt: false, no_default_input: false, model_name: nil, memories: [], muted_memories: [], non_interactive: false, surface: nil,
|
|
96
|
-
spinner_tick_interval: AttachedView::TICK_INTERVAL, spinner_clock: nil, scratch: false, llm_context: {}, thinking: nil
|
|
99
|
+
spinner_tick_interval: AttachedView::TICK_INTERVAL, spinner_clock: nil, scratch: false, llm_context: {}, thinking: nil,
|
|
100
|
+
appended_prompt_files: [])
|
|
97
101
|
@scratch = scratch
|
|
98
102
|
@thinking_word = thinking
|
|
99
103
|
@prompt = prompt
|
|
@@ -142,9 +146,11 @@ module Samagotchi
|
|
|
142
146
|
# session started attached with mutes keeps them here.
|
|
143
147
|
@requested_memories = Array(memories)
|
|
144
148
|
@muted_memory_names = Array(muted_memories)
|
|
149
|
+
@appended_prompt_files = Array(appended_prompt_files)
|
|
145
150
|
if @resume_session
|
|
146
151
|
@requested_memories = (@resume_session.preloaded_memory_names + @requested_memories).uniq
|
|
147
152
|
@muted_memory_names = (@resume_session.muted_memory_names + @muted_memory_names).uniq
|
|
153
|
+
@appended_prompt_files = (@resume_session.appended_prompt_files + @appended_prompt_files).uniq
|
|
148
154
|
end
|
|
149
155
|
# Every terminal write goes through the surface. The REPL swaps in a
|
|
150
156
|
# live region when the terminal can show one (#assist_loop), unless it
|
|
@@ -178,6 +184,7 @@ module Samagotchi
|
|
|
178
184
|
model_typed: no_default ? @resumed_model_typed : nil,
|
|
179
185
|
memories: @requested_memories,
|
|
180
186
|
muted_memories: @muted_memory_names,
|
|
187
|
+
appended_prompt_files: @appended_prompt_files,
|
|
181
188
|
recap: scratch ? nil : recap_config,
|
|
182
189
|
scratch: scratch,
|
|
183
190
|
reminders: {
|
|
@@ -258,7 +265,8 @@ module Samagotchi
|
|
|
258
265
|
working_directory: Dir.pwd,
|
|
259
266
|
preloaded_memory_names: @requested_memories,
|
|
260
267
|
muted_memory_names: @muted_memory_names,
|
|
261
|
-
scratch: @scratch
|
|
268
|
+
scratch: @scratch,
|
|
269
|
+
setup: SessionSetup.new(appended_prompt_files: @appended_prompt_files)
|
|
262
270
|
))
|
|
263
271
|
# --llm-context*: the session's own values, over a resumed one's.
|
|
264
272
|
# Saved at once on a resumed one: leaving before a turn keeps them.
|
|
@@ -628,7 +636,7 @@ module Samagotchi
|
|
|
628
636
|
# Run one REPL turn (a prompt, or a continue/reminder turn with nil) through
|
|
629
637
|
# Engine#run_turn, rendering via @renderer.
|
|
630
638
|
# @param images [Array<Hash>] the prompt's `@path` images ({path:})
|
|
631
|
-
def run_engine_turn(session, prompt, continue: false, max_iterations: IterationLimit.for, images: [])
|
|
639
|
+
def run_engine_turn(session, prompt, continue: false, max_iterations: IterationLimit.for, images: [], resumed: false, resume_from: nil)
|
|
632
640
|
cancellation_controller = CancellationController.new
|
|
633
641
|
@active_cancel_controller = cancellation_controller
|
|
634
642
|
@renderer.begin_turn
|
|
@@ -646,7 +654,9 @@ module Samagotchi
|
|
|
646
654
|
cancel_controller: cancellation_controller,
|
|
647
655
|
pending_input: method(:drain_steering),
|
|
648
656
|
continue: continue,
|
|
649
|
-
images: images
|
|
657
|
+
images: images,
|
|
658
|
+
resumed: resumed,
|
|
659
|
+
resume_from: resume_from
|
|
650
660
|
)
|
|
651
661
|
end
|
|
652
662
|
rescue Interrupt
|
|
@@ -774,9 +784,11 @@ module Samagotchi
|
|
|
774
784
|
end
|
|
775
785
|
return show_command_result(answer) unless answer.resume
|
|
776
786
|
|
|
787
|
+
# The run the offer's turn started: the Continue keeps its reasoning.
|
|
788
|
+
resume_from = @turn_flow.resume_from
|
|
777
789
|
@turn_flow.before_continue_turn
|
|
778
790
|
begin
|
|
779
|
-
result = run_engine_turn(session, nil, continue: true)
|
|
791
|
+
result = run_engine_turn(session, nil, continue: true, resumed: true, resume_from: resume_from)
|
|
780
792
|
rescue LLM::ProviderError
|
|
781
793
|
# The renderer showed the failure (:turn_failed).
|
|
782
794
|
@surface.commit(turn_end_hint("continue prompt preserved"))
|
data/lib/samagotchi/turn_flow.rb
CHANGED
|
@@ -42,8 +42,9 @@ module Samagotchi
|
|
|
42
42
|
[:invalid, nil]
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
-
# @return [Hash, nil] the pending continue offer: {context:, no_interrupt
|
|
46
|
-
# (context summarizes the interrupted turn for a
|
|
45
|
+
# @return [Hash, nil] the pending continue offer: {context:, no_interrupt:,
|
|
46
|
+
# run_start:, model:} (context summarizes the interrupted turn for a
|
|
47
|
+
# "no, <reason>"; run_start and model are #resume_from's)
|
|
47
48
|
attr_reader :offer
|
|
48
49
|
|
|
49
50
|
# How many times the conversation went back to a checkpoint (a failed
|
|
@@ -94,7 +95,11 @@ module Samagotchi
|
|
|
94
95
|
end
|
|
95
96
|
|
|
96
97
|
if result.resumable?
|
|
97
|
-
|
|
98
|
+
# A Continue that ran out again goes on from the run the chain
|
|
99
|
+
# started; any other turn's offer (a prompt's, a wake's) from its own.
|
|
100
|
+
run_start = (continue && resume_from) || result.run_start
|
|
101
|
+
@offer = { context: interrupted_turn_context(result), no_interrupt: no_interrupt, run_start: run_start,
|
|
102
|
+
model: @engine.effective_model_name }
|
|
98
103
|
:continue_offered
|
|
99
104
|
else
|
|
100
105
|
@offer = nil
|
|
@@ -143,6 +148,20 @@ module Samagotchi
|
|
|
143
148
|
:restored
|
|
144
149
|
end
|
|
145
150
|
|
|
151
|
+
# Where a Continue of the pending offer resumes: the run its turn (or
|
|
152
|
+
# the first turn of a chain of Continues) started (ModelResult#run_start),
|
|
153
|
+
# so the Continue keeps that run's reasoning (Engine#run_turn
|
|
154
|
+
# resume_from:). nil with no offer, and after a model change while the
|
|
155
|
+
# offer stands: another model's reasoning isn't replayed, the Continue
|
|
156
|
+
# starts cold. A cancelled or failed Continue keeps the offer, and so
|
|
157
|
+
# this; every way the offer goes takes it along.
|
|
158
|
+
# @return [Integer, nil]
|
|
159
|
+
def resume_from
|
|
160
|
+
return nil unless @offer && @offer[:model] == @engine.effective_model_name
|
|
161
|
+
|
|
162
|
+
@offer[:run_start]
|
|
163
|
+
end
|
|
164
|
+
|
|
146
165
|
# Answer the offer with no (Stop): the interrupted turn stays, as
|
|
147
166
|
# after a Ctrl-C or a new prompt (!rollback still erases it), and the
|
|
148
167
|
# model reads that it wasn't continued: a note, or the user's reason
|
data/lib/samagotchi/version.rb
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#7aa5ff"/><stop offset="1" stop-color="#8957e5"/></linearGradient></defs><rect x="1" y="1" width="30" height="30" rx="7.5" fill="url(#g)"/><g fill="none" stroke="#fff" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"><path d="M7.8 9.6c2.3-1.3 4-.7 5.2 1.4l6 10.1c1.2 2.1 2.9 2.7 5.2 1.4"/><path d="M22.6 9.2 9.4 22.8"/></g></svg>
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<title>Chi</title>
|
|
6
|
+
<link rel="icon" href="/assets/favicon.svg" type="image/svg+xml">
|
|
6
7
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
7
8
|
<style>
|
|
8
9
|
:root{color-scheme:dark;--bg:#0b0d11;--bg-panel:#151821;--bg-inset:#0f1115;--bg-raised:#22262f;--bg-hover:#1b1f27;--border:#1f232b;--border-strong:#2d333f;--text:#e6e8eb;--text-bright:#f0f3f6;--text-2:#c9d1d9;--text-3:#9aa4b2;--text-muted:#8b949e;--text-faint:#6e7681;--accent:#7aa5ff;--accent-strong:#1f6feb;--accent-bg:#1b2336;--accent-ring:rgba(122,165,255,.18);--primary:#2a68d1;--ok:#3fb950;--ok-text:#7ee787;--ok-bg:#1a2e1a;--warn:#d29922;--warn-text:#e3b341;--warn-bright:#f2cc60;--warn-bg:#2b2108;--warn-border:#6b5416;--danger:#f85149;--danger-bg:#da3633;--danger-text:#ff7b72;--danger-bg-dim:#3d1f1f;--think:#8957e5;--think-text:#d2a8ff;--accent-border:#223150;--pick-border:#79c0ff;--accent-tint:color-mix(in srgb,var(--accent) 7%,transparent);--narr:color-mix(in srgb,var(--accent) 38%,var(--text-muted));
|
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
// an answer or a step's text (ref_scan.js; a link whose text is one too).
|
|
7
7
|
// The DOM carries data-ref-kind and the target's attributes, never what a
|
|
8
8
|
// click does: those are the ref's targets, kept per kind in REF_ACTIONS.
|
|
9
|
+
// A plugin's ref (the server's Ref: {kind, target, label?, actions}) is
|
|
10
|
+
// bound to its element by bindRef: its label and its URL actions live in a
|
|
11
|
+
// WeakMap off the DOM, and refTarget reads them first. Its actions join the
|
|
12
|
+
// kind's own (the `actions` entry); a kind the page has no table for runs
|
|
13
|
+
// only those.
|
|
9
14
|
// A plain click runs the first one available (the main target); a ref
|
|
10
15
|
// with more shows a ▾ on hover that opens a menu of all of them
|
|
11
16
|
// (ref_menu.js), and a tap opens it. A ref with a target is a tab stop
|
|
@@ -26,6 +31,55 @@ export function refAttrs(ref) {
|
|
|
26
31
|
return ` data-ref-kind="${escapeHtml(ref.kind)}"${attr("path", ref.path)}${attr("line", ref.line)}${attr("end-line", ref.end_line)}`;
|
|
27
32
|
}
|
|
28
33
|
|
|
34
|
+
// A kind, as the server's Ref allows it (Ref::KIND).
|
|
35
|
+
const KIND = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
36
|
+
|
|
37
|
+
// A plugin's ref data per element (bindRef): {kind, target, label,
|
|
38
|
+
// actions}. A WeakMap, so a redrawn element takes its data with it.
|
|
39
|
+
const REF_DATA = new WeakMap();
|
|
40
|
+
|
|
41
|
+
// +url+ as the page opens it when it is an http(s) one, else null. The
|
|
42
|
+
// server checks it too (Ref, RefAction); the page never opens another
|
|
43
|
+
// scheme a plugin gave (javascript:, data:, an editor's).
|
|
44
|
+
export function httpUrl(url) {
|
|
45
|
+
if (typeof url !== "string") return null;
|
|
46
|
+
try {
|
|
47
|
+
const parsed = new URL(url.trim());
|
|
48
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.href : null;
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Makes +el+ the ref +ref+ (the server's Ref#to_h): the ref class and the
|
|
55
|
+
// data attributes every ref carries (a file's path and lines, as refAttrs),
|
|
56
|
+
// its label and its actions in the WeakMap. An action without an id, a
|
|
57
|
+
// label or an http(s) URL is dropped; a kind the server's Ref refuses binds
|
|
58
|
+
// nothing. A rebind replaces the target attributes. +el+, or null without one.
|
|
59
|
+
export function bindRef(el, ref) {
|
|
60
|
+
if (!el) return null;
|
|
61
|
+
if (typeof ref?.kind !== "string" || !KIND.test(ref.kind)) return el;
|
|
62
|
+
const target = ref.target || {};
|
|
63
|
+
el.classList.add("ref");
|
|
64
|
+
el.dataset.refKind = ref.kind;
|
|
65
|
+
for (const [name, value] of [["refPath", target.path], ["refLine", target.line], ["refEndLine", target.end_line]]) {
|
|
66
|
+
if (value) el.dataset[name] = String(value);
|
|
67
|
+
else delete el.dataset[name];
|
|
68
|
+
}
|
|
69
|
+
const actions = (Array.isArray(ref.actions) ? ref.actions : []).flatMap((action) => {
|
|
70
|
+
const url = httpUrl(action?.url);
|
|
71
|
+
return url && action.id && action.label ? [{ id: String(action.id), label: String(action.label), url }] : [];
|
|
72
|
+
});
|
|
73
|
+
REF_DATA.set(el, { kind: ref.kind, target, label: ref.label || null, actions });
|
|
74
|
+
return el;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The same URL either way (a link's href against an action's URL).
|
|
78
|
+
function sameUrl(a, b) {
|
|
79
|
+
const url = httpUrl(a);
|
|
80
|
+
return !!url && url === httpUrl(b);
|
|
81
|
+
}
|
|
82
|
+
|
|
29
83
|
// The URL +template+ ({path}, {line}; web.editor) opens +path+ at +line+
|
|
30
84
|
// (1 when none) with: each path segment encoded, the slashes kept. "" with
|
|
31
85
|
// no template or path.
|
|
@@ -41,16 +95,38 @@ export function editorUrl(template, path, line) {
|
|
|
41
95
|
// editorUrl}, the server's capabilities.editor and editor_url) and
|
|
42
96
|
// +target+ (refTarget's). A secondary one (copy) joins a ref that has
|
|
43
97
|
// another, never alone. A line (a diff or read line) has its click only.
|
|
98
|
+
// A link whose text is the ref (github-pr's PR diff): its href stays the
|
|
99
|
+
// plain click's, which the browser follows. Its label is the ref's action
|
|
100
|
+
// with the same URL (the menu shows them once), else the old guess from
|
|
101
|
+
// the URL (an old session's link has no ref data).
|
|
102
|
+
const LINK = Object.freeze({
|
|
103
|
+
id: "link",
|
|
104
|
+
label: (target) => target.actions?.find((action) => sameUrl(action.url, target.href))?.label ||
|
|
105
|
+
(/\/pull\/\d+\/files\b/.test(target.href) ? "PR diff" : "Open link"),
|
|
106
|
+
available: (_viewer, target) => !!target.href,
|
|
107
|
+
run: (target, _viewer, env) => env.openLink(target.href),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// The ref's own actions (a plugin's, bindRef), one target each, for every
|
|
111
|
+
// viewer (a URL opens on a phone too): a new tab with no opener and no
|
|
112
|
+
// referrer. One with the link's URL, or an earlier one's, isn't repeated.
|
|
113
|
+
// Not on a diff or read line (its click only), which shares its title's data.
|
|
114
|
+
const URL_ACTIONS = Object.freeze({
|
|
115
|
+
id: "actions",
|
|
116
|
+
expand: (target) => (target.actions || [])
|
|
117
|
+
.filter((action, at, all) => !sameUrl(action.url, target.href) && all.findIndex((other) => sameUrl(other.url, action.url)) === at)
|
|
118
|
+
.map((action) => Object.freeze({
|
|
119
|
+
id: `url:${action.id}`,
|
|
120
|
+
label: () => action.label,
|
|
121
|
+
available: (_viewer, target) => !target.isLine,
|
|
122
|
+
run: (_target, _viewer, env) => env.openLink(action.url),
|
|
123
|
+
hint: () => action.url,
|
|
124
|
+
})),
|
|
125
|
+
});
|
|
126
|
+
|
|
44
127
|
export const REF_ACTIONS = Object.freeze({
|
|
45
128
|
file: Object.freeze([
|
|
46
|
-
|
|
47
|
-
// the plain click's, which the browser follows.
|
|
48
|
-
Object.freeze({
|
|
49
|
-
id: "link",
|
|
50
|
-
label: (target) => (/\/pull\/\d+\/files\b/.test(target.href) ? "PR diff" : "Open link"),
|
|
51
|
-
available: (_viewer, target) => !!target.href,
|
|
52
|
-
run: (target, _viewer, env) => env.openLink(target.href),
|
|
53
|
-
}),
|
|
129
|
+
LINK,
|
|
54
130
|
Object.freeze({
|
|
55
131
|
id: "editor",
|
|
56
132
|
label: () => "Open in editor",
|
|
@@ -63,6 +139,7 @@ export const REF_ACTIONS = Object.freeze({
|
|
|
63
139
|
// The hover hint: it opens the file as it is now.
|
|
64
140
|
hint: (target) => `Open the current file in your editor${target.line ? ` at line ${target.line}` : ""}`,
|
|
65
141
|
}),
|
|
142
|
+
URL_ACTIONS,
|
|
66
143
|
// The row that touched the file, in the turn: not for a row's own
|
|
67
144
|
// title or lines (they are in the turn already).
|
|
68
145
|
Object.freeze({
|
|
@@ -80,13 +157,18 @@ export const REF_ACTIONS = Object.freeze({
|
|
|
80
157
|
run: (target, _viewer, env) => env.copy(target.path),
|
|
81
158
|
}),
|
|
82
159
|
]),
|
|
160
|
+
// Any other kind (a plugin's issue, commit, …): its link, then its actions.
|
|
161
|
+
"*": Object.freeze([LINK, URL_ACTIONS]),
|
|
83
162
|
});
|
|
84
163
|
|
|
85
164
|
// The targets +viewer+ has for +target+ (refTarget's), in order; none
|
|
86
165
|
// when only a secondary one is.
|
|
87
166
|
export function refActions(target, viewer, actions = REF_ACTIONS) {
|
|
88
167
|
if (!target) return [];
|
|
89
|
-
|
|
168
|
+
// Own keys only: a kind named like an Object property ("constructor") is
|
|
169
|
+
// any other kind.
|
|
170
|
+
const table = (Object.hasOwn(actions, target.kind) ? actions[target.kind] : actions["*"]) || [];
|
|
171
|
+
const found = table.flatMap((action) => (action.expand ? action.expand(target) : [action])).filter((action) => action.available(viewer, target));
|
|
90
172
|
return found.some((action) => !action.secondary) ? found : [];
|
|
91
173
|
}
|
|
92
174
|
|
|
@@ -97,8 +179,9 @@ const OWNERS = ".activity-row, .bubble.approval";
|
|
|
97
179
|
// A line of the owner's file: a diff line, or a read row's numbered line.
|
|
98
180
|
const LINES = ".diff-line[data-line], .read-line[data-line]";
|
|
99
181
|
|
|
100
|
-
// What a click on +el+ points at: {kind, path,
|
|
101
|
-
// href} or null
|
|
182
|
+
// What a click on +el+ points at: {kind, path, id, label, actions, line,
|
|
183
|
+
// owner, el, isLine, href} or null (+id+, +label+ and +actions+ a plugin's
|
|
184
|
+
// ref's, bindRef: the WeakMap is read first). A row title (or an approval card's path) opens its file
|
|
102
185
|
// at the lines the call named (an edit without any at its first changed
|
|
103
186
|
// line); a diff line (or a hunk header) or a read row's numbered line, in
|
|
104
187
|
// a row or a card with a ref, opens that file at that line. +el+ is the
|
|
@@ -109,7 +192,8 @@ export function refTarget(el) {
|
|
|
109
192
|
if (title) {
|
|
110
193
|
const owner = title.closest(OWNERS);
|
|
111
194
|
const href = title.matches("a[href]") ? title.getAttribute("href") : null;
|
|
112
|
-
|
|
195
|
+
const own = targetOf(title);
|
|
196
|
+
return { ...own, line: own.line || firstChange(owner), owner: owner || title, el: title, isLine: false, href };
|
|
113
197
|
}
|
|
114
198
|
const diffLine = el?.closest?.(LINES);
|
|
115
199
|
const owner = diffLine?.closest(OWNERS);
|
|
@@ -119,7 +203,13 @@ export function refTarget(el) {
|
|
|
119
203
|
}
|
|
120
204
|
|
|
121
205
|
function targetOf(el) {
|
|
122
|
-
|
|
206
|
+
const data = REF_DATA.get(el);
|
|
207
|
+
if (!data) return { kind: el.dataset.refKind, path: el.dataset.refPath || "", line: Number(el.dataset.refLine) || null, id: null, label: null, actions: [] };
|
|
208
|
+
// Bound data is the whole ref: no fallback to the attributes.
|
|
209
|
+
const { target } = data;
|
|
210
|
+
return {
|
|
211
|
+
kind: data.kind, path: target.path || "", line: Number(target.line) || null, id: target.id ?? null, label: data.label, actions: data.actions,
|
|
212
|
+
};
|
|
123
213
|
}
|
|
124
214
|
|
|
125
215
|
// An edit/write without a line range opens at its first changed line (its
|
|
@@ -235,9 +325,11 @@ function defaultOpen(url) {
|
|
|
235
325
|
else window.location.href = url;
|
|
236
326
|
}
|
|
237
327
|
|
|
238
|
-
// A link ref's href
|
|
328
|
+
// A link ref's href or a ref's URL action: a new tab, as the answer's
|
|
329
|
+
// links open, with no opener and no referrer (the page's LAN URL and
|
|
330
|
+
// session id stay here).
|
|
239
331
|
function defaultOpenLink(href) {
|
|
240
|
-
window.open(href, "_blank", "noopener");
|
|
332
|
+
window.open(href, "_blank", "noopener,noreferrer");
|
|
241
333
|
}
|
|
242
334
|
|
|
243
335
|
// One delegated listener set on +root+ for every ref on the page.
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -367,6 +367,7 @@ module Samagotchi
|
|
|
367
367
|
# (re)spawn.
|
|
368
368
|
memories: @session.preloaded_memory_names,
|
|
369
369
|
muted_memories: @session.muted_memory_names,
|
|
370
|
+
appended_prompt_files: @session.appended_prompt_files,
|
|
370
371
|
reminders: {
|
|
371
372
|
callback: lambda { |due_names|
|
|
372
373
|
# A reminder is due: the loop runs a reminder turn for it once
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: samagotchi
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.60.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dmitry Dedov
|
|
@@ -178,6 +178,8 @@ files:
|
|
|
178
178
|
- lib/samagotchi/broadcast_command.rb
|
|
179
179
|
- lib/samagotchi/bundle_command.rb
|
|
180
180
|
- lib/samagotchi/bundle_needs.rb
|
|
181
|
+
- lib/samagotchi/bundles/agents-md/manifest.yml
|
|
182
|
+
- lib/samagotchi/bundles/agents-md/plugin.rb
|
|
181
183
|
- lib/samagotchi/bundles/btw/manifest.yml
|
|
182
184
|
- lib/samagotchi/bundles/btw/plugin.rb
|
|
183
185
|
- lib/samagotchi/bundles/check-in/manifest.yml
|
|
@@ -392,6 +394,7 @@ files:
|
|
|
392
394
|
- lib/samagotchi/process_group.rb
|
|
393
395
|
- lib/samagotchi/project_scope.rb
|
|
394
396
|
- lib/samagotchi/prompt.rb
|
|
397
|
+
- lib/samagotchi/prompt_files.rb
|
|
395
398
|
- lib/samagotchi/prompt_history.rb
|
|
396
399
|
- lib/samagotchi/prompt_literal_guard.rb
|
|
397
400
|
- lib/samagotchi/prompt_note.rb
|
|
@@ -403,6 +406,7 @@ files:
|
|
|
403
406
|
- lib/samagotchi/recap_setup.rb
|
|
404
407
|
- lib/samagotchi/recap_store.rb
|
|
405
408
|
- lib/samagotchi/redacted_inspect.rb
|
|
409
|
+
- lib/samagotchi/ref.rb
|
|
406
410
|
- lib/samagotchi/relay.rb
|
|
407
411
|
- lib/samagotchi/relay_desk.rb
|
|
408
412
|
- lib/samagotchi/relay_verifier.rb
|
|
@@ -542,6 +546,7 @@ files:
|
|
|
542
546
|
- lib/samagotchi/web/public/data.js
|
|
543
547
|
- lib/samagotchi/web/public/diff_view.js
|
|
544
548
|
- lib/samagotchi/web/public/families_view.js
|
|
549
|
+
- lib/samagotchi/web/public/favicon.svg
|
|
545
550
|
- lib/samagotchi/web/public/format.js
|
|
546
551
|
- lib/samagotchi/web/public/hold.js
|
|
547
552
|
- lib/samagotchi/web/public/images.js
|