samagotchi 0.6.0 → 0.8.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 +72 -1
- data/README.md +7 -3
- data/docs/architecture.md +4 -4
- data/docs/cli.md +19 -4
- data/docs/configuration.md +20 -0
- data/docs/desktop.md +49 -0
- data/docs/guardrails.md +2 -2
- data/docs/hooks.md +2 -2
- data/docs/internals/context-telemetry.md +3 -1
- data/docs/memory.md +48 -1
- data/docs/plugins.md +7 -5
- data/docs/releasing.md +5 -1
- data/docs/sessions.md +2 -2
- data/lib/samagotchi/archive_store.rb +3 -3
- data/lib/samagotchi/atomic_file.rb +34 -0
- data/lib/samagotchi/bootstrap/bundles.rb +37 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +4 -6
- data/lib/samagotchi/bootstrap/probe.rb +9 -5
- data/lib/samagotchi/bootstrap_command.rb +78 -8
- data/lib/samagotchi/bridge/pending_card.rb +2 -3
- data/lib/samagotchi/bridge.rb +56 -116
- data/lib/samagotchi/bridge_client.rb +7 -11
- data/lib/samagotchi/bundle_command.rb +81 -21
- data/lib/samagotchi/bundle_needs.rb +2 -11
- data/lib/samagotchi/bundles/core/manifest.yml +11 -0
- data/lib/samagotchi/bundles/dev/manifest.yml +13 -0
- data/lib/samagotchi/bundles/system/identity.md +1 -0
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +3 -2
- data/lib/samagotchi/client.rb +3 -23
- data/lib/samagotchi/config.rb +11 -11
- data/lib/samagotchi/context_note.rb +1 -1
- data/lib/samagotchi/context_status.rb +255 -0
- data/lib/samagotchi/desktop/macos/App.swift +5 -1
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +7 -63
- data/lib/samagotchi/desktop/macos/Images.swift +38 -3
- data/lib/samagotchi/desktop/macos/Kitty.swift +346 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +233 -55
- data/lib/samagotchi/desktop/macos/ProcessRunner.swift +74 -0
- data/lib/samagotchi/desktop/macos.rb +25 -11
- data/lib/samagotchi/desktop_command.rb +1 -1
- data/lib/samagotchi/engine.rb +6 -7
- data/lib/samagotchi/guardrail_wiring.rb +2 -5
- data/lib/samagotchi/guardrails/approvals.rb +2 -7
- data/lib/samagotchi/hooks/bundle_loader.rb +6 -3
- data/lib/samagotchi/hooks/loader.rb +2 -1
- data/lib/samagotchi/hooks/registry.rb +21 -0
- data/lib/samagotchi/image_store.rb +2 -3
- data/lib/samagotchi/kernel_loop.rb +21 -283
- data/lib/samagotchi/live_versions.rb +9 -13
- data/lib/samagotchi/llm/chat_loop.rb +1 -1
- data/lib/samagotchi/llm/native_backend.rb +8 -8
- data/lib/samagotchi/llm/openai_chat.rb +4 -17
- data/lib/samagotchi/llm/utf8_scrub.rb +32 -0
- data/lib/samagotchi/log_path.rb +2 -3
- data/lib/samagotchi/memory_bundle/index_updater.rb +2 -11
- data/lib/samagotchi/memory_bundle/installer.rb +5 -7
- data/lib/samagotchi/memory_bundle/listing.rb +27 -20
- data/lib/samagotchi/memory_bundle/manifest.rb +32 -1
- data/lib/samagotchi/memory_bundle/profile.rb +145 -0
- data/lib/samagotchi/memory_bundle/provenance.rb +42 -53
- data/lib/samagotchi/memory_bundle/shipped_update.rb +52 -27
- data/lib/samagotchi/memory_bundle/source.rb +2 -0
- data/lib/samagotchi/memory_bundle/status.rb +1 -2
- data/lib/samagotchi/memory_bundle.rb +1 -0
- data/lib/samagotchi/note_command.rb +5 -4
- data/lib/samagotchi/owner_lock.rb +0 -6
- data/lib/samagotchi/paths.rb +32 -0
- data/lib/samagotchi/plugin/api.rb +3 -6
- data/lib/samagotchi/plugin/context.rb +2 -3
- data/lib/samagotchi/plugin/loader.rb +1 -1
- data/lib/samagotchi/question_desk.rb +3 -2
- data/lib/samagotchi/recap_store.rb +2 -2
- data/lib/samagotchi/reply_wait.rb +2 -1
- data/lib/samagotchi/self_report.rb +2 -3
- data/lib/samagotchi/send_command.rb +3 -2
- data/lib/samagotchi/session.rb +41 -143
- data/lib/samagotchi/session_inbox.rb +175 -0
- data/lib/samagotchi/session_manager.rb +62 -363
- data/lib/samagotchi/session_metrics.rb +2 -3
- data/lib/samagotchi/session_retention.rb +259 -0
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -2
- data/lib/samagotchi/terminal_ui/input_support.rb +2 -9
- data/lib/samagotchi/terminal_ui.rb +10 -20
- data/lib/samagotchi/tools/memory.rb +3 -2
- data/lib/samagotchi/tools/send_note.rb +4 -3
- data/lib/samagotchi/tools/task_runtime.rb +2 -3
- data/lib/samagotchi/turn_note.rb +13 -2
- data/lib/samagotchi/update_command.rb +10 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +44 -76
- data/lib/samagotchi/web/info_probe.rb +33 -0
- data/lib/samagotchi/web/public/app.js +1 -1
- data/lib/samagotchi/web/public/index.html +1 -0
- data/lib/samagotchi/web/public/stage_model.js +22 -3
- data/lib/samagotchi/web/public/stage_view.js +25 -10
- data/lib/samagotchi/web/server.rb +8 -11
- data/lib/samagotchi/web/session_hub.rb +8 -7
- data/lib/samagotchi/web/session_summary.rb +4 -3
- data/lib/samagotchi/web/token.rb +2 -5
- data/lib/samagotchi/worker.rb +34 -20
- data/lib/samagotchi/worker_sidecar.rb +42 -0
- metadata +15 -1
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -121,6 +121,31 @@ module Samagotchi
|
|
|
121
121
|
|
|
122
122
|
private
|
|
123
123
|
|
|
124
|
+
# The routes, first match wins: a handler takes the request and the
|
|
125
|
+
# path's captures (the session id, an image's name).
|
|
126
|
+
ROUTES = [
|
|
127
|
+
["GET", %r{\A/(?:index\.html)?\z}, :serve_index],
|
|
128
|
+
["GET", %r{\A/api/sessions\z}, :handle_list],
|
|
129
|
+
["POST", %r{\A/api/sessions\z}, :handle_create],
|
|
130
|
+
["GET", %r{\A/api/info\z}, :handle_info],
|
|
131
|
+
["GET", %r{\A/api/models\z}, :handle_models],
|
|
132
|
+
["GET", %r{\A/api/events\z}, :handle_events],
|
|
133
|
+
["GET", %r{\A/api/sessions/([^/]+)/stream\z}, :handle_stream],
|
|
134
|
+
["GET", %r{\A/api/sessions/([^/]+)/output\z}, :handle_output],
|
|
135
|
+
["POST", %r{\A/api/sessions/([^/]+)/cancel\z}, :handle_cancel],
|
|
136
|
+
["POST", %r{\A/api/sessions/([^/]+)/stop\z}, :handle_stop],
|
|
137
|
+
["POST", %r{\A/api/sessions/([^/]+)/archive\z}, :handle_archive],
|
|
138
|
+
["POST", %r{\A/api/sessions/([^/]+)/unarchive\z}, :handle_unarchive],
|
|
139
|
+
["POST", %r{\A/api/sessions/([^/]+)/turn\z}, :handle_turn],
|
|
140
|
+
["POST", %r{\A/api/sessions/([^/]+)/answer\z}, :handle_question_answer],
|
|
141
|
+
["POST", %r{\A/api/sessions/([^/]+)/question/dismiss\z}, :handle_question_dismiss],
|
|
142
|
+
["POST", %r{\A/api/sessions/([^/]+)/command\z}, :handle_command],
|
|
143
|
+
["POST", %r{\A/api/sessions/([^/]+)/images\z}, :handle_image_upload],
|
|
144
|
+
["GET", %r{\A/api/sessions/([^/]+)/images/([^/]+)\z}, :handle_image],
|
|
145
|
+
["GET", %r{\A/api/sessions/([^/]+)\z}, :handle_show],
|
|
146
|
+
["DELETE", %r{\A/api/sessions/([^/]+)\z}, :handle_delete]
|
|
147
|
+
].freeze
|
|
148
|
+
|
|
124
149
|
def route(env)
|
|
125
150
|
req = Rack::Request.new(env)
|
|
126
151
|
return forbidden unless local_host_header?(env)
|
|
@@ -130,69 +155,19 @@ module Samagotchi
|
|
|
130
155
|
return denied if denied
|
|
131
156
|
end
|
|
132
157
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
handle_create(req)
|
|
140
|
-
when ["GET", "/api/info"]
|
|
141
|
-
handle_info
|
|
142
|
-
when ["GET", "/api/models"]
|
|
143
|
-
handle_models
|
|
144
|
-
when ["GET", "/api/events"]
|
|
145
|
-
handle_events(req)
|
|
146
|
-
else
|
|
147
|
-
# Dynamic routes
|
|
148
|
-
if (m = %r{\A/api/sessions/([^/]+)/stream\z}.match(req.path_info)) && req.get?
|
|
149
|
-
return handle_stream(req, m[1])
|
|
150
|
-
end
|
|
151
|
-
if (m = %r{\A/api/sessions/([^/]+)/output\z}.match(req.path_info)) && req.get?
|
|
152
|
-
return handle_output(req, m[1])
|
|
153
|
-
end
|
|
154
|
-
if (m = %r{\A/api/sessions/([^/]+)/cancel\z}.match(req.path_info)) && req.post?
|
|
155
|
-
return handle_cancel(req, m[1])
|
|
156
|
-
end
|
|
157
|
-
if (m = %r{\A/api/sessions/([^/]+)/stop\z}.match(req.path_info)) && req.post?
|
|
158
|
-
return handle_stop(req, m[1])
|
|
159
|
-
end
|
|
160
|
-
if (m = %r{\A/api/sessions/([^/]+)/(archive|unarchive)\z}.match(req.path_info)) && req.post?
|
|
161
|
-
return m[2] == "archive" ? handle_archive(req, m[1]) : handle_unarchive(req, m[1])
|
|
162
|
-
end
|
|
163
|
-
if (m = %r{\A/api/sessions/([^/]+)/turn\z}.match(req.path_info)) && req.post?
|
|
164
|
-
return handle_turn(req, m[1])
|
|
165
|
-
end
|
|
166
|
-
if (m = %r{\A/api/sessions/([^/]+)/answer\z}.match(req.path_info)) && req.post?
|
|
167
|
-
return handle_question_answer(req, m[1])
|
|
168
|
-
end
|
|
169
|
-
if (m = %r{\A/api/sessions/([^/]+)/question/dismiss\z}.match(req.path_info)) && req.post?
|
|
170
|
-
return handle_question_dismiss(req, m[1])
|
|
171
|
-
end
|
|
172
|
-
if (m = %r{\A/api/sessions/([^/]+)/command\z}.match(req.path_info)) && req.post?
|
|
173
|
-
return handle_command(req, m[1])
|
|
158
|
+
ROUTES.each do |verb, pattern, handler|
|
|
159
|
+
next unless req.request_method == verb && (m = pattern.match(req.path_info))
|
|
160
|
+
# Every /api/sessions/:id route: an id that isn't one (Session.valid_id?,
|
|
161
|
+
# "..", a %2F) is an unknown session before any handler makes a path.
|
|
162
|
+
if req.path_info.start_with?("/api/sessions/") && !Session.valid_id?(m.captures.first)
|
|
163
|
+
return error_response(404, "not_found", "Session not found: #{m.captures.first.to_s[0, 80]}")
|
|
174
164
|
end
|
|
175
|
-
if (m = %r{\A/api/sessions/([^/]+)/images\z}.match(req.path_info)) && req.post?
|
|
176
|
-
return handle_image_upload(req, m[1])
|
|
177
|
-
end
|
|
178
|
-
if (m = %r{\A/api/sessions/([^/]+)/images/([^/]+)\z}.match(req.path_info)) && req.get?
|
|
179
|
-
return handle_image(req, m[1], m[2])
|
|
180
|
-
end
|
|
181
|
-
if (m = %r{\A/api/sessions/([^/]+)\z}.match(req.path_info)) && req.get?
|
|
182
|
-
return handle_show(req, m[1])
|
|
183
|
-
end
|
|
184
|
-
if (m = %r{\A/api/sessions/([^/]+)\z}.match(req.path_info)) && req.delete?
|
|
185
|
-
return handle_delete(req, m[1])
|
|
186
|
-
end
|
|
187
|
-
if req.path_info.start_with?("/assets/") || req.path_info.start_with?("/public/")
|
|
188
|
-
return serve_static(req)
|
|
189
|
-
end
|
|
190
|
-
# Fallback static file serve from public_dir (e.g. /style.css)
|
|
191
|
-
maybe_static = serve_static(req)
|
|
192
|
-
return maybe_static if maybe_static[0] != 404
|
|
193
165
|
|
|
194
|
-
|
|
166
|
+
return send(handler, req, *m.captures)
|
|
195
167
|
end
|
|
168
|
+
# Anything else is a file under public_dir (/assets/*, /public/* or
|
|
169
|
+
# a bare path such as /style.css), or 404.
|
|
170
|
+
serve_static(req)
|
|
196
171
|
rescue StandardError => e
|
|
197
172
|
Log.exception(:web, "request_failed", e, method: env["REQUEST_METHOD"], path: env["PATH_INFO"])
|
|
198
173
|
error_response(500, "internal_error", e.message)
|
|
@@ -429,7 +404,7 @@ module Samagotchi
|
|
|
429
404
|
|
|
430
405
|
# What a second `chi web` probes before starting its own server: this
|
|
431
406
|
# is chi web, and it knows ?dir (features).
|
|
432
|
-
def handle_info
|
|
407
|
+
def handle_info(_req)
|
|
433
408
|
json_response(200, { app: "chi-web", version: Samagotchi::VERSION, pid: Process.pid, cwd: Dir.pwd,
|
|
434
409
|
features: ["dir"], lan: @lan && @lan[:ip] })
|
|
435
410
|
end
|
|
@@ -442,7 +417,7 @@ module Samagotchi
|
|
|
442
417
|
# a bounded time for a fresh listing and answers with what it has, a
|
|
443
418
|
# host that failed or the wait running out noted in +warning+, never a
|
|
444
419
|
# failure: the default alone is enough for the page.
|
|
445
|
-
def handle_models
|
|
420
|
+
def handle_models(_req)
|
|
446
421
|
default = begin
|
|
447
422
|
ModelProfile.required_model_name
|
|
448
423
|
rescue StandardError
|
|
@@ -586,12 +561,14 @@ module Samagotchi
|
|
|
586
561
|
snapshot = live && live["session_state_snapshot"]
|
|
587
562
|
last_event_seq = snapshot ? snapshot["event_seq"] : bridge_event_seq(id)
|
|
588
563
|
current_turn = turn_snapshot && turn_snapshot["current_turn"]
|
|
564
|
+
owner = session_owner(id)
|
|
565
|
+
# The file's question only with a live owner: one a dead worker
|
|
566
|
+
# saved can't be answered (its turn is gone).
|
|
589
567
|
pending = if turn_snapshot
|
|
590
568
|
current_turn && current_turn["pending_question"]
|
|
591
|
-
elsif session.respond_to?(:pending_question)
|
|
569
|
+
elsif owner && session.respond_to?(:pending_question)
|
|
592
570
|
session.pending_question
|
|
593
571
|
end
|
|
594
|
-
owner = session_owner(id)
|
|
595
572
|
# A /model in the worker changes it before the file catches up.
|
|
596
573
|
session.model_name = snapshot["model_name"] if snapshot && !snapshot["model_name"].to_s.empty?
|
|
597
574
|
session_json = session_to_json(session, status: displayed_status(session, snapshot, owner: owner), owner: owner)
|
|
@@ -673,16 +650,9 @@ module Samagotchi
|
|
|
673
650
|
unless body.is_a?(Hash)
|
|
674
651
|
return error_response(400, "invalid_json", "invalid JSON body")
|
|
675
652
|
end
|
|
676
|
-
qid = body["id"]
|
|
677
|
-
selected = body["selected"]
|
|
678
|
-
freeform = body["freeform"]
|
|
679
|
-
# Allow payload nested under answer
|
|
680
|
-
if body["answer"].is_a?(Hash)
|
|
681
|
-
ans = body["answer"]
|
|
682
|
-
qid ||= ans["id"] || ans[:id]
|
|
683
|
-
selected ||= ans["selected"] || ans[:selected]
|
|
684
|
-
freeform ||= ans["freeform"] || ans[:freeform]
|
|
685
|
-
end
|
|
653
|
+
qid = body["id"]
|
|
654
|
+
selected = body["selected"]
|
|
655
|
+
freeform = body["freeform"]
|
|
686
656
|
if qid.to_s.strip.empty?
|
|
687
657
|
return error_response(400, "missing_fields", "id is required")
|
|
688
658
|
end
|
|
@@ -817,8 +787,6 @@ module Samagotchi
|
|
|
817
787
|
end
|
|
818
788
|
rescue SessionManager::OwnedByTUI => e
|
|
819
789
|
error_response(409, "owned_by_tui", e.message)
|
|
820
|
-
rescue SessionManager::ImagesUnsupported => e
|
|
821
|
-
error_response(409, "images_unsupported", e.message)
|
|
822
790
|
rescue ArgumentError => e
|
|
823
791
|
error_response(404, "not_found", e.message)
|
|
824
792
|
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
module Web
|
|
8
|
+
# What answers GET /api/info on host:port: the one probe for a running
|
|
9
|
+
# chi web (chi web's own port check, chi update, chi self).
|
|
10
|
+
module InfoProbe
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# @return [Hash, :free, :other] a chi web's /api/info; :free when
|
|
14
|
+
# nothing listens; :other for anything else (another program, an
|
|
15
|
+
# error status, a body that isn't a chi web's)
|
|
16
|
+
def call(host, port, timeout:)
|
|
17
|
+
response = Net::HTTP.start(host, port, open_timeout: timeout, read_timeout: timeout) { |http| http.get("/api/info") }
|
|
18
|
+
verdict(response.code.to_i, response.body)
|
|
19
|
+
rescue Errno::ECONNREFUSED, Errno::EADDRNOTAVAIL
|
|
20
|
+
:free
|
|
21
|
+
rescue StandardError
|
|
22
|
+
:other
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def verdict(status, body)
|
|
26
|
+
info = status == 200 ? JSON.parse(body.to_s) : nil
|
|
27
|
+
info.is_a?(Hash) && info["app"] == "chi-web" ? info : :other
|
|
28
|
+
rescue JSON::ParserError
|
|
29
|
+
:other
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -2018,7 +2018,7 @@ async function handleSendTurn() {
|
|
|
2018
2018
|
} catch (e) {
|
|
2019
2019
|
echo.remove();
|
|
2020
2020
|
// The chips stay (uploaded ones keep their ref) for another try.
|
|
2021
|
-
if (/
|
|
2021
|
+
if (/bad_image|too_large/.test(e.message)) {
|
|
2022
2022
|
alert(e.message);
|
|
2023
2023
|
return;
|
|
2024
2024
|
}
|
|
@@ -482,6 +482,7 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
|
|
|
482
482
|
.ts-trail-item{flex:none;max-width:36ch;overflow:hidden;text-overflow:ellipsis}
|
|
483
483
|
.ts-trail-item.error{color:var(--danger-text)}
|
|
484
484
|
.ts-trail-item.warn{color:var(--warn-text)}
|
|
485
|
+
.ts-trail-item.info,.ts-trail-item.steer{color:var(--accent)}
|
|
485
486
|
.ts-trail-item.none{font-style:italic}
|
|
486
487
|
/* A card waits: the tool row and trail give it their room. */
|
|
487
488
|
.turn-stage.waiting .ts-now,.turn-stage.waiting .ts-trail{display:none}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// covers the rules; stage_view.js draws them.
|
|
5
5
|
import { toolName } from "./activity.js";
|
|
6
6
|
import { splitSentences } from "./sentences.js";
|
|
7
|
+
import { hookNoticeLabel } from "./turn_events.js";
|
|
7
8
|
|
|
8
9
|
const READ_LIKE = new Set(["read", "memory_read", "web_fetch", "task_get", "task_list", "list_sessions"]);
|
|
9
10
|
const EDITS = new Set(["edit", "write", "memory_write"]);
|
|
@@ -48,12 +49,19 @@ export function plainHeadline(line) {
|
|
|
48
49
|
|
|
49
50
|
// The step's newest narration sentence, as plain text: the end of a
|
|
50
51
|
// finished text is a boundary (its last line may have no period), a live
|
|
51
|
-
// one's not yet. A sentence that is only markdown syntax is skipped
|
|
52
|
+
// one's not yet. A sentence that is only markdown syntax is skipped, and so
|
|
53
|
+
// is code: the lines between a fence and its close (or the end, while one
|
|
54
|
+
// is open).
|
|
52
55
|
export function headlineOf(text, done) {
|
|
53
56
|
const { sentences, rest } = splitSentences(text, { atEnd: true });
|
|
54
|
-
|
|
57
|
+
let fenced = false;
|
|
58
|
+
const plain = [];
|
|
59
|
+
for (const sentence of sentences) {
|
|
60
|
+
if (/^(```|~~~)/.test(sentence)) fenced = !fenced;
|
|
61
|
+
else if (!fenced && plainHeadline(sentence)) plain.push(plainHeadline(sentence));
|
|
62
|
+
}
|
|
63
|
+
const fragment = fenced ? "" : plainHeadline(rest);
|
|
55
64
|
if (done && fragment) return fragment;
|
|
56
|
-
const plain = sentences.map(plainHeadline).filter(Boolean);
|
|
57
65
|
if (plain.length) return plain[plain.length - 1];
|
|
58
66
|
return fragment.length > 160 ? fragment : "";
|
|
59
67
|
}
|
|
@@ -148,6 +156,17 @@ export function handOffOrder({ prompt = null, block = null, extras = [], answer
|
|
|
148
156
|
// Where a row app.js would append goes: the stage's extras while a turn is
|
|
149
157
|
// live (and not handing off), else the history; the recap always the
|
|
150
158
|
// history (it is idle-only).
|
|
159
|
+
// What flashes in the trail for a few seconds while the turn runs (the
|
|
160
|
+
// rows themselves go into their step inside the closed cloud): a hook's
|
|
161
|
+
// notice, warn or info, as its row reads, and a plugin's nudge (steer).
|
|
162
|
+
// chi's own asking-again row (a notice with a line and no hook) does not:
|
|
163
|
+
// the status line tells that already. null: nothing flashes.
|
|
164
|
+
export function flashOf(what, data = {}) {
|
|
165
|
+
if (what === "steer") return { kind: "steer", text: data.source ? `${data.source} nudged the model` : "nudged the model" };
|
|
166
|
+
if (what !== "notice" || !data.hook) return null;
|
|
167
|
+
return { kind: data.level === "warn" ? "warn" : "info", text: data.line || `${hookNoticeLabel(data.hook)}: ${data.text || ""}` };
|
|
168
|
+
}
|
|
169
|
+
|
|
151
170
|
export function placeFor({ live, handingOff, kind }) {
|
|
152
171
|
if (kind === "recap") return "history";
|
|
153
172
|
return live && !handingOff ? "stage" : "history";
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// reload. Slots are patched in place, never rebuilt from HTML: a selection
|
|
12
12
|
// and annotate survive. The rules are pure in stage_model.js.
|
|
13
13
|
import { createTurnView } from "./turn_view.js";
|
|
14
|
-
import { handOffDue, handOffOrder, headlineOf, inUseFrom, isPlain, liveSlots, placeFor } from "./stage_model.js";
|
|
14
|
+
import { flashOf, handOffDue, handOffOrder, headlineOf, inUseFrom, isPlain, liveSlots, placeFor } from "./stage_model.js";
|
|
15
15
|
import { formatDuration } from "./timing.js";
|
|
16
16
|
|
|
17
17
|
const COLLAPSE_KEY = "chi_stage_collapsed";
|
|
@@ -27,6 +27,9 @@ const PENDING = ".bubble.question:not(.answered):not(.cancelled), .bubble.contin
|
|
|
27
27
|
// and never holds the hand-off (a turn cut short can leave it open).
|
|
28
28
|
const ASKS = ".plugin-card[data-in-turn] .card-action";
|
|
29
29
|
const MARKS = { answered: "✓", canceled: "■", failed: "✕" };
|
|
30
|
+
// The mark of a flash in the trail: a warn notice, a hook's info notice, a
|
|
31
|
+
// plugin's nudge (steer).
|
|
32
|
+
const FLASH_MARKS = { warn: "⚠", info: "ℹ", steer: "↪" };
|
|
30
33
|
|
|
31
34
|
function node(tag, cls, parent = null) {
|
|
32
35
|
const el = document.createElement(tag);
|
|
@@ -95,7 +98,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
95
98
|
let startedAt = 0;
|
|
96
99
|
let endedAt = 0;
|
|
97
100
|
let thinkingLine = null; // { step, text } from the live step's ticker
|
|
98
|
-
let flash = null; // { text, until } a
|
|
101
|
+
let flash = null; // { kind, text, until } a notice or nudge in the trail
|
|
99
102
|
let collapsed = readCollapsed();
|
|
100
103
|
let cloudOpen = false;
|
|
101
104
|
let rafPending = false;
|
|
@@ -236,12 +239,12 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
236
239
|
}
|
|
237
240
|
|
|
238
241
|
function drawTrail(items) {
|
|
239
|
-
const flashing = flash && flash.until > Date.now() ? flash
|
|
240
|
-
const key = JSON.stringify([flashing, items]);
|
|
242
|
+
const flashing = flash && flash.until > Date.now() ? flash : null;
|
|
243
|
+
const key = JSON.stringify([flashing?.kind, flashing?.text, items]);
|
|
241
244
|
if (shown.trail === key) return;
|
|
242
245
|
shown.trail = key;
|
|
243
246
|
trail.replaceChildren();
|
|
244
|
-
if (flashing) node("span",
|
|
247
|
+
if (flashing) node("span", `ts-trail-item ${flashing.kind}`, trail).textContent = `${FLASH_MARKS[flashing.kind]} ${flashing.text}`;
|
|
245
248
|
for (const item of items) {
|
|
246
249
|
const failed = item.status === "error";
|
|
247
250
|
const el = node("span", `ts-trail-item${failed ? " error" : ""}`, trail);
|
|
@@ -250,6 +253,15 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
250
253
|
if (!flashing && !items.length) node("span", "ts-trail-item none", trail).textContent = "no tool calls yet";
|
|
251
254
|
}
|
|
252
255
|
|
|
256
|
+
// One line in the trail for FLASH_MS, ahead of the calls (a newer flash
|
|
257
|
+
// takes its place).
|
|
258
|
+
function flashTrail(what) {
|
|
259
|
+
if (!what) return;
|
|
260
|
+
flash = { ...what, until: Date.now() + FLASH_MS };
|
|
261
|
+
scheduleRefresh();
|
|
262
|
+
setTimeout(scheduleRefresh, FLASH_MS + 50);
|
|
263
|
+
}
|
|
264
|
+
|
|
253
265
|
function drawTicks(list) {
|
|
254
266
|
list.forEach((tick, i) => {
|
|
255
267
|
let el = ticks.children[i];
|
|
@@ -540,15 +552,18 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
540
552
|
startWatch();
|
|
541
553
|
return kept;
|
|
542
554
|
},
|
|
555
|
+
// A hook's notice and a plugin's nudge go into their step inside the
|
|
556
|
+
// closed cloud; they flash in the trail meanwhile (flashOf).
|
|
543
557
|
notice(data) {
|
|
544
558
|
const placed = view.notice(data);
|
|
545
|
-
if (placed
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
559
|
+
if (placed) flashTrail(flashOf("notice", data));
|
|
560
|
+
return placed;
|
|
561
|
+
},
|
|
562
|
+
steer(data) {
|
|
563
|
+
const placed = view.steer(data);
|
|
564
|
+
if (placed) flashTrail(flashOf("steer", data));
|
|
549
565
|
return placed;
|
|
550
566
|
},
|
|
551
|
-
steer: (data) => view.steer(data),
|
|
552
567
|
// A warn card (a plugin's warning) stays in sight: false puts it in the
|
|
553
568
|
// extras (place), not in its step inside the closed cloud.
|
|
554
569
|
card: (el) => !el.classList.contains("warn") && view.card(el),
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
-
require "net/http"
|
|
5
4
|
require "rack"
|
|
6
5
|
require "rackup/handler/webrick"
|
|
7
6
|
require "uri"
|
|
8
7
|
require "webrick"
|
|
9
8
|
|
|
10
9
|
require_relative "app"
|
|
10
|
+
require_relative "info_probe"
|
|
11
11
|
require_relative "lan"
|
|
12
12
|
require_relative "qr"
|
|
13
13
|
require_relative "session_hub"
|
|
@@ -206,21 +206,18 @@ module Samagotchi
|
|
|
206
206
|
# ?dir, :free when nothing listens, :other for anything else (an older
|
|
207
207
|
# chi web, another program).
|
|
208
208
|
def self.probe(host, port, timeout: PROBE_TIMEOUT)
|
|
209
|
-
|
|
210
|
-
probe_verdict(response.code.to_i, response.body)
|
|
211
|
-
rescue Errno::ECONNREFUSED, Errno::EADDRNOTAVAIL
|
|
212
|
-
:free
|
|
213
|
-
rescue StandardError
|
|
214
|
-
:other
|
|
209
|
+
knows_dir(InfoProbe.call(host, port, timeout: timeout))
|
|
215
210
|
end
|
|
216
211
|
|
|
217
212
|
def self.probe_verdict(status, body)
|
|
218
|
-
|
|
219
|
-
info.is_a?(Hash) && info["app"] == "chi-web" && Array(info["features"]).include?("dir") ? info : :other
|
|
220
|
-
rescue JSON::ParserError
|
|
221
|
-
:other
|
|
213
|
+
knows_dir(InfoProbe.verdict(status, body))
|
|
222
214
|
end
|
|
223
215
|
|
|
216
|
+
def self.knows_dir(info)
|
|
217
|
+
info.is_a?(Hash) && !Array(info["features"]).include?("dir") ? :other : info
|
|
218
|
+
end
|
|
219
|
+
private_class_method :knows_dir
|
|
220
|
+
|
|
224
221
|
# What a phone needs: the LAN link with the token, the warnings, and
|
|
225
222
|
# the link's QR code (at a terminal only).
|
|
226
223
|
def self.lan_lines(ip:, port:, token:, others: [], public: false, qr: $stdout.tty?)
|
|
@@ -7,6 +7,7 @@ require "time"
|
|
|
7
7
|
require_relative "../session"
|
|
8
8
|
require_relative "../session_manager"
|
|
9
9
|
require_relative "../bridge_client"
|
|
10
|
+
require_relative "../worker_sidecar"
|
|
10
11
|
require_relative "../log"
|
|
11
12
|
require_relative "session_summary"
|
|
12
13
|
|
|
@@ -20,7 +21,7 @@ module Samagotchi
|
|
|
20
21
|
# unlink bump the parent dir's mtime, so one stat of the state dir says
|
|
21
22
|
# "some session file changed" and one stat of `<id>/` says "recap.json
|
|
22
23
|
# or bridge.json appeared, changed or went". Nothing in-place is
|
|
23
|
-
# watched:
|
|
24
|
+
# watched: owner.lock is read by the owner probes.
|
|
24
25
|
#
|
|
25
26
|
# #scan does one pass and is driven by the tick thread (#start) or by a
|
|
26
27
|
# spec. Scan, #touch, the projection and the deliveries all run under
|
|
@@ -127,8 +128,10 @@ module Samagotchi
|
|
|
127
128
|
# Rescan one session now (its file, its folder, its owner), for the
|
|
128
129
|
# page's own actions: no waiting for the next tick.
|
|
129
130
|
def touch(id)
|
|
131
|
+
return nil unless Session.valid_id?(id)
|
|
132
|
+
|
|
130
133
|
@monitor.synchronize do
|
|
131
|
-
path =
|
|
134
|
+
path = Session.session_file(id, state_dir: @state_dir)
|
|
132
135
|
if File.file?(path)
|
|
133
136
|
@sessions[id] ||= Entry.new
|
|
134
137
|
refresh(id, probe: true)
|
|
@@ -220,7 +223,7 @@ module Samagotchi
|
|
|
220
223
|
@dir_mtime = dir_stat.mtime
|
|
221
224
|
on_disk = Dir.glob(File.join(@state_dir, "*#{Session::FILE_EXT}")).to_h do |path|
|
|
222
225
|
[File.basename(path, Session::FILE_EXT), path]
|
|
223
|
-
end
|
|
226
|
+
end.select { |id, _| Session.valid_id?(id) }
|
|
224
227
|
(@sessions.keys - on_disk.keys).each { |id| drop(id) }
|
|
225
228
|
on_disk.each_key { |id| @sessions[id] ||= Entry.new }
|
|
226
229
|
end
|
|
@@ -231,7 +234,7 @@ module Samagotchi
|
|
|
231
234
|
# and when its folder changed (a new owner.lock, a bridge.json).
|
|
232
235
|
def refresh(id, probe: false)
|
|
233
236
|
entry = @sessions[id]
|
|
234
|
-
path =
|
|
237
|
+
path = Session.session_file(id, state_dir: @state_dir)
|
|
235
238
|
stat = begin
|
|
236
239
|
File.stat(path)
|
|
237
240
|
rescue SystemCallError
|
|
@@ -305,9 +308,7 @@ module Samagotchi
|
|
|
305
308
|
end
|
|
306
309
|
|
|
307
310
|
def bridge_started_at(dir)
|
|
308
|
-
|
|
309
|
-
rescue StandardError
|
|
310
|
-
nil
|
|
311
|
+
WorkerSidecar.read(dir)&.started_at
|
|
311
312
|
end
|
|
312
313
|
|
|
313
314
|
def emit(type, data)
|
|
@@ -5,6 +5,7 @@ require_relative "../recap_store"
|
|
|
5
5
|
require_relative "../archive_store"
|
|
6
6
|
require_relative "../session_metrics"
|
|
7
7
|
require_relative "../bridge_client"
|
|
8
|
+
require_relative "../worker_sidecar"
|
|
8
9
|
require_relative "../bridge/pending_card"
|
|
9
10
|
|
|
10
11
|
module Samagotchi
|
|
@@ -60,8 +61,8 @@ module Samagotchi
|
|
|
60
61
|
bridge_up: up,
|
|
61
62
|
# For the tab's notifications (notify.js): an open question, the
|
|
62
63
|
# running turn's open card with actions, and how the last turn
|
|
63
|
-
# ended.
|
|
64
|
-
pending_question: pending_question_for(session),
|
|
64
|
+
# ended. A question saved by a worker that died is not open.
|
|
65
|
+
pending_question: owner ? pending_question_for(session) : nil,
|
|
65
66
|
pending_card: up ? Bridge::PendingCard.read(session_dir) : nil,
|
|
66
67
|
last_turn: session.respond_to?(:last_turn) ? session.last_turn : nil
|
|
67
68
|
}
|
|
@@ -89,7 +90,7 @@ module Samagotchi
|
|
|
89
90
|
# @return [Boolean] the sidecar file is there and the session has a
|
|
90
91
|
# live owner (the file alone says nothing: the worker may be gone)
|
|
91
92
|
def bridge_up?(session_dir, owner)
|
|
92
|
-
!owner.nil? && File.file?(
|
|
93
|
+
!owner.nil? && File.file?(WorkerSidecar.path(session_dir))
|
|
93
94
|
rescue StandardError
|
|
94
95
|
false
|
|
95
96
|
end
|
data/lib/samagotchi/web/token.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
require "securerandom"
|
|
5
|
+
require_relative "../atomic_file"
|
|
5
6
|
|
|
6
7
|
require_relative "../session"
|
|
7
8
|
|
|
@@ -53,12 +54,8 @@ module Samagotchi
|
|
|
53
54
|
def write(path, token)
|
|
54
55
|
dir = File.dirname(path)
|
|
55
56
|
FileUtils.mkdir_p(dir, mode: 0o700)
|
|
56
|
-
|
|
57
|
-
File.open(tmp, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |io| io.write("#{token}\n") }
|
|
58
|
-
File.rename(tmp, path)
|
|
57
|
+
AtomicFile.write(path, "#{token}\n", perm: 0o600)
|
|
59
58
|
token
|
|
60
|
-
ensure
|
|
61
|
-
FileUtils.rm_f(tmp) if tmp && File.exist?(tmp)
|
|
62
59
|
end
|
|
63
60
|
|
|
64
61
|
# The token a running server checks against: the file is re-read when
|