samagotchi 0.10.1 → 0.11.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +66 -1
  3. data/docs/architecture.md +19 -0
  4. data/docs/cli.md +24 -7
  5. data/docs/configuration.md +44 -11
  6. data/docs/guardrails.md +2 -1
  7. data/docs/hooks.md +3 -2
  8. data/docs/internals/context-telemetry.md +4 -2
  9. data/docs/sessions.md +3 -3
  10. data/lib/samagotchi/bootstrap/config_writer.rb +2 -2
  11. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +5 -3
  12. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  13. data/lib/samagotchi/bundles/skills/manifest.yml +3 -3
  14. data/lib/samagotchi/bundles/skills/plugin.rb +3 -23
  15. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  16. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  17. data/lib/samagotchi/client.rb +21 -1
  18. data/lib/samagotchi/config.rb +44 -40
  19. data/lib/samagotchi/context_status.rb +17 -16
  20. data/lib/samagotchi/empty_answer_retry.rb +67 -8
  21. data/lib/samagotchi/engine.rb +64 -20
  22. data/lib/samagotchi/host_registry.rb +50 -90
  23. data/lib/samagotchi/kernel_loop.rb +288 -305
  24. data/lib/samagotchi/llm/chat_loop.rb +78 -87
  25. data/lib/samagotchi/model_profile.rb +22 -3
  26. data/lib/samagotchi/model_ref.rb +60 -0
  27. data/lib/samagotchi/prompt_history.rb +91 -0
  28. data/lib/samagotchi/recap_setup.rb +15 -3
  29. data/lib/samagotchi/self_report.rb +11 -6
  30. data/lib/samagotchi/send_command.rb +8 -8
  31. data/lib/samagotchi/session.rb +9 -2
  32. data/lib/samagotchi/session_commands.rb +10 -12
  33. data/lib/samagotchi/session_manager.rb +20 -3
  34. data/lib/samagotchi/sessions_command.rb +57 -51
  35. data/lib/samagotchi/steer.rb +23 -0
  36. data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -2
  37. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -6
  38. data/lib/samagotchi/terminal_ui/input_support.rb +78 -33
  39. data/lib/samagotchi/terminal_ui/reline_history_hook.rb +93 -0
  40. data/lib/samagotchi/terminal_ui.rb +9 -8
  41. data/lib/samagotchi/tool_response.rb +67 -0
  42. data/lib/samagotchi/tool_runner.rb +6 -1
  43. data/lib/samagotchi/tools/delegate.rb +4 -3
  44. data/lib/samagotchi/tools/memory.rb +8 -6
  45. data/lib/samagotchi/version.rb +1 -1
  46. data/lib/samagotchi/web/app.rb +28 -2
  47. data/lib/samagotchi/web/public/app.js +29 -2
  48. data/lib/samagotchi/web/public/data.js +9 -1
  49. data/lib/samagotchi/web/public/prompt_history.js +63 -0
  50. data/lib/samagotchi/worker.rb +2 -1
  51. metadata +6 -1
@@ -84,8 +84,8 @@ module Samagotchi
84
84
  @scratch = scratch
85
85
  @prompt = prompt
86
86
  @default_model_name = ModelProfile.required_model_name(nil)
87
- aliased_model_name = model_name.to_s.strip.empty? ? nil : ConfigFile.resolve_model_alias(model_name)
88
- flag_model_name = aliased_model_name.to_s.strip.empty? ? nil : ModelProfile.required_model_name(aliased_model_name)
87
+ # As typed: the Engine applies an alias where it resolves the model.
88
+ flag_model_name = model_name.to_s.strip.empty? ? nil : ModelProfile.required_model_name(model_name)
89
89
  @effective_model_name = flag_model_name || @default_model_name
90
90
  @host_registry = host_registry || Samagotchi::HostRegistry.new
91
91
  # An injected client (specs) stands in for every host's client.
@@ -106,6 +106,7 @@ module Samagotchi
106
106
  @effective_model_name = flag_model_name
107
107
  else
108
108
  @effective_model_name = @resume_session.model_name.to_s.strip.empty? ? @default_model_name : @resume_session.model_name
109
+ @resumed_model_typed = @resume_session.model_typed
109
110
  end
110
111
  # Re-resolve client after resume may change effective model
111
112
  @client = @host_registry.resolve(@effective_model_name).client
@@ -115,7 +116,7 @@ module Samagotchi
115
116
  ModelProfile.check_host!(@effective_model_name, hosts: @host_registry.entries)
116
117
  # Only a caller's profile goes in; otherwise the Engine resolves one
117
118
  # for the effective model and hands it to the kernel.
118
- @kernel = KernelLoop.new(client: @client, profile: profile, no_interrupt: no_interrupt, reminder_store: Samagotchi::ReminderStore.new)
119
+ @kernel = KernelLoop.new(client: @client, profile: profile, reminder_store: Samagotchi::ReminderStore.new)
119
120
  @no_default_input = no_default_input
120
121
  @non_interactive = non_interactive
121
122
  # --memory and --mute, with a resumed session's own lists first: a
@@ -180,7 +181,7 @@ module Samagotchi
180
181
  registry: @engine.command_registry)
181
182
  # Runtime --model flag or resumed session: switch the Engine (client,
182
183
  # kernel profile) without persisting the default.
183
- @engine.switch_model!(@effective_model_name) if @effective_model_name != @default_model_name
184
+ @engine.switch_model!(@effective_model_name, typed: @resumed_model_typed) if @effective_model_name != @default_model_name
184
185
  # Render an idle session-recap via the cursor-safe background writer; the
185
186
  # detector itself is Engine-owned (see Engine#recap) and opt-in.
186
187
  @recap_handle = @engine.subscribe(observer: ->(event) { @between_turns.take_recap(event) })
@@ -217,14 +218,14 @@ module Samagotchi
217
218
  # nothing and return (no transient session, no banner).
218
219
  return if @non_interactive && @prompt.nil?
219
220
 
220
- session = @resume_session || Session.new_session(
221
+ session = @resume_session || @engine.store_model!(Session.new_session(
221
222
  mode: "assist",
222
223
  model_name: @effective_model_name,
223
224
  working_directory: Dir.pwd,
224
225
  preloaded_memory_names: @requested_memories,
225
226
  muted_memory_names: @muted_memory_names,
226
227
  scratch: @scratch
227
- )
228
+ ))
228
229
  claim_session!(session.id) unless @owner_lock
229
230
  # Saved at once, so a process killed before its first turn leaves a
230
231
  # file the sweep knows to delete.
@@ -691,7 +692,7 @@ module Samagotchi
691
692
  end
692
693
 
693
694
  def save_session(session)
694
- session.model_name = @effective_model_name
695
+ @engine.store_model!(session)
695
696
  session.save
696
697
  end
697
698
 
@@ -760,7 +761,7 @@ module Samagotchi
760
761
  canceled = result.respond_to?(:canceled?) && result.canceled?
761
762
  if result && !canceled
762
763
  session.messages = result.conversation if result.respond_to?(:conversation) && result.conversation.is_a?(Array)
763
- session.model_name = @effective_model_name
764
+ @engine.store_model!(session)
764
765
  session.save
765
766
  end
766
767
  # The synthetic turn is activity: the next reminder waits a full
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # A tool batch and the conversation entries its results become, for both
5
+ # loops. The calls run through ToolRunner between the dispatch events;
6
+ # what the model gets back is per format: the native loop answers a batch
7
+ # with ONE tool_response (#joined: the full outputs), the chat loop each
8
+ # call with its own, paired by tool_call_id (#single: the capped output,
9
+ # D-P2-6).
10
+ module ToolResponse
11
+ SEPARATOR = "\n\n---\n\n"
12
+ # Saved with a result for the web's reload (a plugin tool's params line
13
+ # and label, an edit/write's diff); the prompt never reads them.
14
+ SAVED_KEYS = %i[tool_params tool_labels tool_diffs].freeze
15
+
16
+ module_function
17
+
18
+ # Runs +calls+ in order (ToolRunner#run each), yielding each run and its
19
+ # index as it finishes. +emit+ takes the dispatch events, the runner's
20
+ # own go to +on_stream_event+.
21
+ # @return [Array<Hash>] the runs
22
+ def run_batch(runner, calls, iteration:, emit:, on_stream_event:, cap:)
23
+ emit.call({ type: :tool_dispatch_started, iteration: iteration, call_count: calls.length })
24
+ runs = calls.each_with_index.map do |call, index|
25
+ run = runner.run(call, iteration: iteration, call_index: index + 1, call_count: calls.length,
26
+ on_stream_event: on_stream_event, max_tool_output_chars: cap)
27
+ yield run, index if block_given?
28
+ run
29
+ end
30
+ emit.call({ type: :tool_dispatch_completed, iteration: iteration, call_count: calls.length })
31
+ runs
32
+ end
33
+
34
+ # The runs' activity, for the turn's tool list (none for a call whose
35
+ # dispatcher raised).
36
+ def activities(runs)
37
+ runs.filter_map { |run| run[:activity] }
38
+ end
39
+
40
+ # The native loop's entry for a batch: the outputs joined, every image
41
+ # in call order with how many each call returned (the web's reload puts
42
+ # each on its own tool row), and the saved-only fields one per call.
43
+ def joined(runs)
44
+ images = runs.flat_map { |run| Array(run[:images]) }
45
+ entry = { role: "tool_response", content: runs.map { |run| run[:output] }.join(SEPARATOR) }
46
+ unless images.empty?
47
+ entry[:images] = images
48
+ entry[:image_counts] = runs.map { |run| Array(run[:images]).size }
49
+ end
50
+ saved = { tool_params: runs.map { |run| run[:shown_params] }, tool_labels: runs.map { |run| run[:shown_label] },
51
+ tool_diffs: runs.map { |run| run[:diff] } }
52
+ saved.each { |key, values| entry[key] = values if values.any? }
53
+ entry
54
+ end
55
+
56
+ # The chat loop's entry for one call: its capped output, paired with the
57
+ # call by +tool_call_id+.
58
+ def single(run, tool_call_id:)
59
+ entry = { role: "tool_response", content: run[:capped_output], tool_call_id: tool_call_id }
60
+ entry[:images] = run[:images] if run[:images]&.any?
61
+ entry[:tool_params] = run[:shown_params] if run[:shown_params]
62
+ entry[:tool_labels] = run[:shown_label] if run[:shown_label]
63
+ entry[:tool_diffs] = run[:diff] if run[:diff]
64
+ entry
65
+ end
66
+ end
67
+ end
@@ -80,7 +80,12 @@ module Samagotchi
80
80
  capped = output[0, max_tool_output_chars]
81
81
  end
82
82
 
83
- fire(:after_tool_call, { type: :after_tool_call, iteration: iteration, tool: call[:name], output: capped })
83
+ # The call's outcome as its activity line has it (worked out from the
84
+ # full output: ok, error, blocked, stopped); a dispatcher that raised
85
+ # left none, an error.
86
+ status = result[:activity].is_a?(Hash) ? result[:activity][:status] : nil
87
+ fire(:after_tool_call, { type: :after_tool_call, iteration: iteration, tool: call[:name], output: capped,
88
+ status: status || "error" })
84
89
  completed = { type: :tool_call_completed, iteration: iteration, call_count: call_count, call_index: call_index,
85
90
  tool: call[:name], output: capped, output_truncated: truncated, activity: result[:activity] }
86
91
  completed[:images] = images if images&.any?
@@ -117,12 +117,13 @@ module Samagotchi
117
117
  end
118
118
  private_class_method :follow_up
119
119
 
120
- # --model as the REPL reads it: an alias resolved, a host prefix kept.
121
- # No existence check: an unknown model fails the child's first turn.
120
+ # The model as typed (spawn_session stores its resolved ref), else the
121
+ # parent's. No existence check: an unknown model fails the child's
122
+ # first turn.
122
123
  def self.child_model(model, parent)
123
124
  return parent.model_name if model.to_s.strip.empty?
124
125
 
125
- ModelProfile.required_model_name(ConfigFile.resolve_model_alias(model.to_s.strip))
126
+ ModelProfile.required_model_name(model.to_s.strip)
126
127
  end
127
128
  private_class_method :child_model
128
129
 
@@ -22,7 +22,8 @@ module Samagotchi
22
22
 
23
23
  SEPARATOR = "\n\n---\n\n"
24
24
 
25
- def self.call(entry_name, scope: nil, model_key: nil)
25
+ # @param fallback_model_key [String, nil] read when +model_key+ has no overlay
26
+ def self.call(entry_name, scope: nil, model_key: nil, fallback_model_key: nil)
26
27
  entry_name = entry_name.to_s.strip
27
28
  scope = normalize_scope(scope)
28
29
 
@@ -60,11 +61,12 @@ module Samagotchi
60
61
  if File.exist?(path)
61
62
  body = File.read(path)
62
63
  # Append model-specific overlay if key is provided and overlay exists.
63
- if model_key
64
- overlay_path = ModelOverlay.overlay_path_for(name, model_key, resolved_scope)
65
- if overlay_path && File.exist?(overlay_path)
66
- body += SEPARATOR + "Model-specific guidance (#{model_key}):\n" + File.read(overlay_path)
67
- end
64
+ [model_key, fallback_model_key].compact.each do |key|
65
+ overlay_path = ModelOverlay.overlay_path_for(name, key, resolved_scope)
66
+ next unless overlay_path && File.exist?(overlay_path)
67
+
68
+ body += SEPARATOR + "Model-specific guidance (#{key}):\n" + File.read(overlay_path)
69
+ break
68
70
  end
69
71
  results << body
70
72
  found = true
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.10.1"
4
+ VERSION = "0.11.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -18,6 +18,7 @@ require_relative "../steer"
18
18
  require_relative "../host_registry"
19
19
  require_relative "../model_profile"
20
20
  require_relative "../project_scope"
21
+ require_relative "../prompt_history"
21
22
  require_relative "../version"
22
23
  require_relative "../config"
23
24
  require_relative "../output_formatter"
@@ -127,6 +128,7 @@ module Samagotchi
127
128
  ["POST", %r{\A/api/sessions\z}, :handle_create],
128
129
  ["GET", %r{\A/api/info\z}, :handle_info],
129
130
  ["GET", %r{\A/api/models\z}, :handle_models],
131
+ ["GET", %r{\A/api/history\z}, :handle_history],
130
132
  ["GET", %r{\A/api/events\z}, :handle_events],
131
133
  ["GET", %r{\A/api/sessions/([^/]+)/stream\z}, :handle_stream],
132
134
  ["GET", %r{\A/api/sessions/([^/]+)/output\z}, :handle_output],
@@ -381,6 +383,25 @@ module Samagotchi
381
383
  # a bounded time for a fresh listing and answers with what it has, a
382
384
  # host that failed or the wait running out noted in +warning+, never a
383
385
  # failure: the default alone is enough for the page.
386
+ # GET /api/history: the prompt history the TUI's ↑ walks, which the
387
+ # composer's ↑/↓ share (PromptHistory), oldest first.
388
+ def handle_history(_req)
389
+ json_response(200, { entries: PromptHistory.entries })
390
+ end
391
+
392
+ # A line the page sent, once the worker took it, into the shared prompt
393
+ # history; "history": false (an image-only send's placeholder text)
394
+ # keeps it out. A failed write never fails the send. There's no scratch
395
+ # check: a live scratch session is the REPL's (OwnedByTUI, 409 before
396
+ # any delivery), and one whose REPL died is left as an edge case.
397
+ def record_history(body, line)
398
+ return if body["history"] == false
399
+
400
+ PromptHistory.append(line.to_s)
401
+ rescue StandardError => e
402
+ Log.warn(:web, "history_write_failed", error: e.class.name, msg: e.message)
403
+ end
404
+
384
405
  def handle_models(_req)
385
406
  default = begin
386
407
  ModelProfile.required_model_name
@@ -500,6 +521,7 @@ module Samagotchi
500
521
  port = await_bridge_port(session.id)
501
522
  # The projection holds it before the page hears back (no tick wait).
502
523
  @hub&.touch(session.id)
524
+ record_history(body, prompt) if !idle && PromptHistory.shell_line?(prompt.to_s.strip)
503
525
  json_response(201, session_to_json(session).merge(bridge_port: port))
504
526
  end
505
527
 
@@ -660,7 +682,9 @@ module Samagotchi
660
682
  request = ->(client) { client.post_command(line: line, client_id: body["client_id"], card: body["card"] == true) }
661
683
  relay(id, live_bridge_client(id), request, what: "the command was not run", cant: "run commands") do |reply|
662
684
  case reply.status
663
- when 202 then json_response(202, reply.json || { status: "accepted" })
685
+ when 202
686
+ record_history(body, line) if PromptHistory.shell_line?(line)
687
+ json_response(202, reply.json || { status: "accepted" })
664
688
  when 400 then error_response(400, reply.json&.dig("error") || "unknown_command", reply_detail(reply, "not a session command"))
665
689
  end
666
690
  end
@@ -747,7 +771,9 @@ module Samagotchi
747
771
  result = SessionManager.deliver_turn(id, prompt: prompt.to_s, client_id: client_id, images: images, state_dir: @state_dir,
748
772
  manager: @manager, bridge: -> { live_bridge_client(id) })
749
773
  case result[:status]
750
- when :accepted then json_response(202, result[:ack])
774
+ when :accepted
775
+ record_history(body, prompt)
776
+ json_response(202, result[:ack])
751
777
  when :refused then json_response(result[:code], result[:ack])
752
778
  when :timeout then error_response(504, "worker_timeout", result[:ack]["detail"])
753
779
  else error_response(500, "enqueue_failed", "could not write turn input")
@@ -14,6 +14,7 @@ import {
14
14
  createIdleSession,
15
15
  uploadImage,
16
16
  listModels,
17
+ fetchHistory,
17
18
  } from "./data.js";
18
19
  import { MODEL_KEY } from "./model_pick.js";
19
20
  import { emptySessionState } from "./session_state.js";
@@ -32,6 +33,7 @@ import { createQuestionCards } from "./question_cards.js";
32
33
  import { cardClass, cardInnerHtml, cardPlace, isCard, splitSnapshotCards, turnNoticePlace } from "./card.js";
33
34
  import { gripFloor, promptHeight } from "./composer_size.js";
34
35
  import { createCommandList } from "./command_list.js";
36
+ import { createPromptHistory } from "./prompt_history.js";
35
37
  import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
36
38
  import { allScopeHref, cardFolder, projectScopeHref, scopeDir } from "./scope.js";
37
39
  import { shouldFollowScroll, keepFollowing } from "./scroll.js";
@@ -473,7 +475,7 @@ function setPlaceholder(long, short) {
473
475
  }
474
476
 
475
477
  // The key hint under the composer (shown while it has focus).
476
- const SEND_HINT = "Enter to send · Shift+Enter for a newline";
478
+ const SEND_HINT = "Enter to send · Shift+Enter for a newline · ↑ history";
477
479
 
478
480
  // While a turn runs, Send still sends: the prompt merges into the turn at
479
481
  // its next step (steering). Cancel is its own button then.
@@ -1698,6 +1700,7 @@ async function handleCreate() {
1698
1700
  select(s.id);
1699
1701
  promptEl.value = "";
1700
1702
  fitPrompt();
1703
+ refreshHistory();
1701
1704
  } catch (e) {
1702
1705
  alert(e.message);
1703
1706
  } finally {
@@ -1710,6 +1713,8 @@ async function handleSendTurn() {
1710
1713
  if (current.owner === "tui") return;
1711
1714
  const prompt = turnText(promptEl.value, chips);
1712
1715
  if (!prompt) return;
1716
+ // An image-only send's text is its placeholders: not for ↑.
1717
+ const typed = promptEl.value.trim() !== "";
1713
1718
  if (!chips.length && isCommandLine(prompt)) return handleSendCommand(prompt);
1714
1719
  if (!selected) {
1715
1720
  actionBtn.disabled = true;
@@ -1742,8 +1747,9 @@ async function handleSendTurn() {
1742
1747
  for (const chip of sending) {
1743
1748
  if (!chip.ref) chip.ref = await uploadImage(selected, chip.file, chip.name);
1744
1749
  }
1745
- const ack = await sendTurn(selected, prompt, { clientId, images: turnRefs(sending) });
1750
+ const ack = await sendTurn(selected, prompt, { clientId, images: turnRefs(sending), history: typed });
1746
1751
  if (ack?.enqueued_id) sentIds.add(ack.enqueued_id);
1752
+ refreshHistory();
1747
1753
  // A turn that failed fast was restored before this ack: its prompt
1748
1754
  // (and images) go back into the composer.
1749
1755
  const restored = restoreOnAck(ack?.enqueued_id, earlyRestores, { myId: clientId, sentIds });
@@ -1795,6 +1801,7 @@ async function handleSendCommand(line) {
1795
1801
  await sendCommand(selected, line, { clientId });
1796
1802
  promptEl.value = "";
1797
1803
  fitPrompt();
1804
+ refreshHistory();
1798
1805
  // The command woke the worker: follow its stream if none is open.
1799
1806
  if (!streamControl?.live) startStream(selected, selectSeq ?? 0);
1800
1807
  } catch (e) {
@@ -2043,8 +2050,28 @@ const commandList = createCommandList({
2043
2050
  afterPick: () => fitPrompt(),
2044
2051
  });
2045
2052
 
2053
+ // ↑/↓: the prompt history shared with the TUI (prompt_history.js), from a
2054
+ // copy fetched on focus and after each send (keydown can't wait for one).
2055
+ const promptHistory = createPromptHistory();
2056
+ function refreshHistory() {
2057
+ fetchHistory().then((entries) => promptHistory.setEntries(entries), () => {});
2058
+ }
2059
+ promptEl.addEventListener("focus", refreshHistory);
2060
+ function historyKey(e) {
2061
+ if (e.isComposing || e.shiftKey || e.altKey || e.ctrlKey || e.metaKey) return false;
2062
+ const step = e.key === "ArrowUp" ? promptHistory.up : e.key === "ArrowDown" ? promptHistory.down : null;
2063
+ const recalled = step?.({ value: promptEl.value, start: promptEl.selectionStart, end: promptEl.selectionEnd });
2064
+ if (!recalled) return false;
2065
+ e.preventDefault();
2066
+ promptEl.value = recalled.value;
2067
+ promptEl.setSelectionRange(recalled.caret, recalled.caret);
2068
+ fitPrompt();
2069
+ return true;
2070
+ }
2071
+
2046
2072
  promptEl.addEventListener("keydown", (e) => {
2047
2073
  if (commandList.handleKey(e)) return;
2074
+ if (historyKey(e)) return;
2048
2075
  if (e.key === "Enter" && !e.shiftKey) {
2049
2076
  e.preventDefault();
2050
2077
  if (!selected) handleCreate();
@@ -45,6 +45,11 @@ export function listModels(opts = {}) {
45
45
  return api("/api/models", opts);
46
46
  }
47
47
 
48
+ // The prompt history the composer's ↑/↓ walk (the TUI's too), oldest first.
49
+ export function fetchHistory(opts = {}) {
50
+ return api("/api/history", opts).then((j) => (Array.isArray(j.entries) ? j.entries : []));
51
+ }
52
+
48
53
  // Upload one image (the raw file as the body); answers its ref.
49
54
  export function uploadImage(id, file, name, opts = {}) {
50
55
  return api(`/api/sessions/${id}/images?name=${encodeURIComponent(name || file?.name || "image")}`, {
@@ -66,9 +71,12 @@ export function getSession(id, { tail, parts, cards, ...opts } = {}) {
66
71
 
67
72
  // opts.clientId: this tab's id, so the events tell its prompts from others'.
68
73
  // opts.images: refs ({file, name}) to images uploaded into the session.
69
- export function sendTurn(id, prompt, { clientId, images, ...opts } = {}) {
74
+ // history: false keeps the prompt out of the shared prompt history (an
75
+ // image-only send, whose text is only its [image: …] placeholders).
76
+ export function sendTurn(id, prompt, { clientId, images, history = true, ...opts } = {}) {
70
77
  const body = clientId ? { prompt, client_id: clientId } : { prompt };
71
78
  if (images?.length) body.images = images;
79
+ if (history === false) body.history = false;
72
80
  return api(`/api/sessions/${id}/turn`, {
73
81
  method: "POST",
74
82
  body: JSON.stringify(body),
@@ -0,0 +1,63 @@
1
+ // ↑/↓ in the composer: the prompt history the TUI's ↑ walks (GET
2
+ // /api/history, oldest first), shared by every chi prompt. Pure: the page
3
+ // hands in the composer's value and selection, and sets what comes back.
4
+ //
5
+ // ↑ recalls only with the caret on the first line and nothing selected,
6
+ // ↓ moves forward only while browsing and from the last line; otherwise
7
+ // the keys are the browser's (null). ↑ puts the caret at the start, so ↑
8
+ // again keeps walking back; ↓ at the end. Past the newest entry ↓ brings
9
+ // back the draft there was when browsing started. Browsing ends whenever
10
+ // the value isn't the one the history last set: typing, a send clearing
11
+ // it, a refill, a quote — every path that sets the value, unhooked.
12
+ // An entry equal to the one just shown is skipped.
13
+
14
+ const onFirstLine = ({ value, start }) => !value.slice(0, start).includes("\n");
15
+ const onLastLine = ({ value, end }) => !value.slice(end).includes("\n");
16
+
17
+ export function createPromptHistory() {
18
+ let entries = [];
19
+ let index = -1; // the entry shown; -1 = not browsing
20
+ let draft = "";
21
+ let lastSet = null;
22
+
23
+ function browsing(value) {
24
+ if (index >= 0 && value !== lastSet) index = -1;
25
+ return index >= 0;
26
+ }
27
+
28
+ function show(i, caretAtEnd) {
29
+ index = i;
30
+ lastSet = entries[i];
31
+ return { value: lastSet, caret: caretAtEnd ? lastSet.length : 0 };
32
+ }
33
+
34
+ return {
35
+ // The list as fetched; a place in it survives while its entry does.
36
+ setEntries(list) {
37
+ entries = Array.isArray(list) ? list.map(String) : [];
38
+ if (index >= 0) index = entries.lastIndexOf(lastSet);
39
+ },
40
+
41
+ up(state) {
42
+ const was = browsing(state.value);
43
+ if (state.start !== state.end || !onFirstLine(state)) return null;
44
+ const shown = was ? lastSet : state.value;
45
+ let i = was ? index - 1 : entries.length - 1;
46
+ while (i >= 0 && entries[i] === shown) i--;
47
+ if (i < 0) return null;
48
+ if (!was) draft = state.value;
49
+ return show(i, false);
50
+ },
51
+
52
+ down(state) {
53
+ if (!browsing(state.value)) return null;
54
+ if (state.start !== state.end || !onLastLine(state)) return null;
55
+ let i = index + 1;
56
+ while (i < entries.length && entries[i] === lastSet) i++;
57
+ if (i < entries.length) return show(i, true);
58
+ index = -1;
59
+ lastSet = null;
60
+ return { value: draft, caret: draft.length };
61
+ },
62
+ };
63
+ }
@@ -45,7 +45,7 @@ module Samagotchi
45
45
  COMMAND_OUTPUT_LIMIT = 4096
46
46
  BUSY_OUTPUT = "busy: wait for the turn to end"
47
47
  DEFAULT_MAX_ITERATIONS = 100
48
- NO_INTERRUPT_MAX_ITERATIONS = 1000
48
+ NO_INTERRUPT_MAX_ITERATIONS = 1000 # as Engine::NO_INTERRUPT_MAX_ITERATIONS
49
49
 
50
50
  # Wakes the worker loop. Whoever queues work writes it first and wakes
51
51
  # after, and #wait drains every wake before the loop looks for work: a
@@ -216,6 +216,7 @@ module Samagotchi
216
216
  engine = nil
217
217
  engine = Samagotchi::Engine.new(
218
218
  model_name: @session.model_name,
219
+ model_typed: @session.model_typed,
219
220
  # The session's --memory and --mute lists: the same prompt on every
220
221
  # (re)spawn.
221
222
  memories: @session.preloaded_memory_names,
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.10.1
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -277,6 +277,7 @@ files:
277
277
  - lib/samagotchi/memory_paths.rb
278
278
  - lib/samagotchi/model_overlay.rb
279
279
  - lib/samagotchi/model_profile.rb
280
+ - lib/samagotchi/model_ref.rb
280
281
  - lib/samagotchi/muted_memories.rb
281
282
  - lib/samagotchi/note_command.rb
282
283
  - lib/samagotchi/output_formatter.rb
@@ -293,6 +294,7 @@ files:
293
294
  - lib/samagotchi/plugin_tasks.rb
294
295
  - lib/samagotchi/project_scope.rb
295
296
  - lib/samagotchi/prompt.rb
297
+ - lib/samagotchi/prompt_history.rb
296
298
  - lib/samagotchi/prompt_literal_guard.rb
297
299
  - lib/samagotchi/question_desk.rb
298
300
  - lib/samagotchi/recap_setup.rb
@@ -329,6 +331,7 @@ files:
329
331
  - lib/samagotchi/terminal_ui/plain_surface.rb
330
332
  - lib/samagotchi/terminal_ui/question_prompt.rb
331
333
  - lib/samagotchi/terminal_ui/question_slot.rb
334
+ - lib/samagotchi/terminal_ui/reline_history_hook.rb
332
335
  - lib/samagotchi/terminal_ui/reline_seam.rb
333
336
  - lib/samagotchi/terminal_ui/repl_input.rb
334
337
  - lib/samagotchi/terminal_ui/screen.rb
@@ -343,6 +346,7 @@ files:
343
346
  - lib/samagotchi/tool_activity.rb
344
347
  - lib/samagotchi/tool_call_parser.rb
345
348
  - lib/samagotchi/tool_declarations.rb
349
+ - lib/samagotchi/tool_response.rb
346
350
  - lib/samagotchi/tool_runner.rb
347
351
  - lib/samagotchi/tools/args.rb
348
352
  - lib/samagotchi/tools/ask_user_question.rb
@@ -408,6 +412,7 @@ files:
408
412
  - lib/samagotchi/web/public/model_picker.js
409
413
  - lib/samagotchi/web/public/notifications.js
410
414
  - lib/samagotchi/web/public/notify.js
415
+ - lib/samagotchi/web/public/prompt_history.js
411
416
  - lib/samagotchi/web/public/question_card.js
412
417
  - lib/samagotchi/web/public/question_cards.js
413
418
  - lib/samagotchi/web/public/route.js