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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +66 -1
- data/docs/architecture.md +19 -0
- data/docs/cli.md +24 -7
- data/docs/configuration.md +44 -11
- data/docs/guardrails.md +2 -1
- data/docs/hooks.md +3 -2
- data/docs/internals/context-telemetry.md +4 -2
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bootstrap/config_writer.rb +2 -2
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +5 -3
- data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/manifest.yml +3 -3
- data/lib/samagotchi/bundles/skills/plugin.rb +3 -23
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/client.rb +21 -1
- data/lib/samagotchi/config.rb +44 -40
- data/lib/samagotchi/context_status.rb +17 -16
- data/lib/samagotchi/empty_answer_retry.rb +67 -8
- data/lib/samagotchi/engine.rb +64 -20
- data/lib/samagotchi/host_registry.rb +50 -90
- data/lib/samagotchi/kernel_loop.rb +288 -305
- data/lib/samagotchi/llm/chat_loop.rb +78 -87
- data/lib/samagotchi/model_profile.rb +22 -3
- data/lib/samagotchi/model_ref.rb +60 -0
- data/lib/samagotchi/prompt_history.rb +91 -0
- data/lib/samagotchi/recap_setup.rb +15 -3
- data/lib/samagotchi/self_report.rb +11 -6
- data/lib/samagotchi/send_command.rb +8 -8
- data/lib/samagotchi/session.rb +9 -2
- data/lib/samagotchi/session_commands.rb +10 -12
- data/lib/samagotchi/session_manager.rb +20 -3
- data/lib/samagotchi/sessions_command.rb +57 -51
- data/lib/samagotchi/steer.rb +23 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -6
- data/lib/samagotchi/terminal_ui/input_support.rb +78 -33
- data/lib/samagotchi/terminal_ui/reline_history_hook.rb +93 -0
- data/lib/samagotchi/terminal_ui.rb +9 -8
- data/lib/samagotchi/tool_response.rb +67 -0
- data/lib/samagotchi/tool_runner.rb +6 -1
- data/lib/samagotchi/tools/delegate.rb +4 -3
- data/lib/samagotchi/tools/memory.rb +8 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +28 -2
- data/lib/samagotchi/web/public/app.js +29 -2
- data/lib/samagotchi/web/public/data.js +9 -1
- data/lib/samagotchi/web/public/prompt_history.js +63 -0
- data/lib/samagotchi/worker.rb +2 -1
- 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
|
-
|
|
88
|
-
flag_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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
121
|
-
# No existence check: an unknown model fails the child's
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
overlay_path = ModelOverlay.overlay_path_for(name,
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -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.
|
|
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
|