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
@@ -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 in the cwd, else at the top of its git work tree.
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
- path = agent_description_path
472
- return nil unless path
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
- "Project specific description:\n#{content}"
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
- def agent_description_path
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
- [cwd, MemoryPaths.work_tree_root(cwd)].compact.uniq
485
- .map { |dir| File.join(dir, AGENT_DESCRIPTION_FILE) }
486
- .find { |path| File.file?(path) }
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
- warn_memory_flags_ignored(attach || resume, memories, muted_memories, err) if attach || resume
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 --resume are ignored, with a
154
- # line saying so (printed before the live region opens).
155
- def warn_memory_flags_ignored(session_id, memories, muted_memories, err)
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
- err.puts "(#{flags.join(" and ")} #{verb} to a new session; #{session_id}'s prompt is already built)"
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"))
@@ -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 "no, <reason>")
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
- @offer = { context: interrupted_turn_context(result), no_interrupt: no_interrupt }
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
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.59.0"
4
+ VERSION = "0.60.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -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
- // A link whose text is the ref (github-pr's PR diff): its href stays
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
- const found = (actions[target.kind] || []).filter((action) => action.available(viewer, target));
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, line, owner, el, isLine,
101
- // href} or null. A row title (or an approval card's path) opens its file
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
- return { ...targetOf(title), line: Number(title.dataset.refLine) || firstChange(owner), owner: owner || title, el: title, isLine: false, href };
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
- return { kind: el.dataset.refKind, path: el.dataset.refPath || "" };
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 from the menu: a new tab, as the answer's links open.
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.
@@ -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.59.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