samagotchi 0.26.0 → 0.27.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 +21 -1
- data/docs/architecture.md +6 -0
- data/docs/hooks.md +1 -1
- data/lib/samagotchi/bridge.rb +37 -1
- data/lib/samagotchi/bridge_client.rb +9 -0
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +2 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/log.rb +19 -3
- data/lib/samagotchi/thinking_tails.rb +1 -1
- data/lib/samagotchi/tool_activity.rb +3 -2
- data/lib/samagotchi/tool_view.rb +14 -3
- data/lib/samagotchi/tools/task_get.rb +3 -2
- data/lib/samagotchi/tools/task_runtime.rb +49 -12
- data/lib/samagotchi/tools/task_stop.rb +1 -1
- data/lib/samagotchi/tools/task_wait.rb +22 -10
- data/lib/samagotchi/turn_note.rb +3 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +25 -0
- data/lib/samagotchi/web/markdown_renderer.rb +21 -6
- data/lib/samagotchi/web/public/app.js +4 -0
- data/lib/samagotchi/web/public/data.js +11 -0
- data/lib/samagotchi/web/public/index.html +19 -0
- data/lib/samagotchi/web/public/stage_model.js +8 -2
- data/lib/samagotchi/web/public/stage_view.js +14 -2
- data/lib/samagotchi/web/public/task_stop.js +101 -0
- data/lib/samagotchi/web/public/turn_view.js +24 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b91f830918a3f58dac1d54820450ec90b4caa2d8f970b3fab03c8a0199b3ac2e
|
|
4
|
+
data.tar.gz: 1d1f0f1dc839247801be1462a895e8a57e189a68882fbaeb5a7707fdb4add0ab
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 398faa2152be479cf78093fb9c752158932a2c186bf1fa5fd1ee9a622895bf742f82f1a25c843febfb877c826cee869623ee9871c50154015fdb54d02a490054
|
|
7
|
+
data.tar.gz: 35a4b45af554dd9cfd79c87d67ed7d819d14759421f8f669ac769ddbb1b37a64f5b0c79101f15a002ebdb4a2e2e9bee1def9eead820d50f61312c9f4a8cc41c8
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,25 @@ and commands may change between minor versions. How releases are made:
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.27.0] - 2026-10-04
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- Web: a "stop task" button on a running `task_wait` stops the background task; the turn goes on and the model is
|
|
16
|
+
told the user stopped it (Cancel still cancels the turn and leaves the task running).
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- `/mcp` lists each server's tools alphabetically, not in the order the server sent them (mcp bundle 0.4.3).
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- A stopped background task ends as `stopped`, not `failed`, and records who stopped it (the user or the model).
|
|
25
|
+
- Code blocks in the web follow the light theme too: their highlighting comes from the page's palette instead of one
|
|
26
|
+
fixed dark theme.
|
|
27
|
+
|
|
28
|
+
Update with `chi update` (mcp 0.4.3).
|
|
29
|
+
|
|
11
30
|
## [0.26.0] - 2026-10-04
|
|
12
31
|
|
|
13
32
|
### Added
|
|
@@ -1419,7 +1438,8 @@ and long-lived sessions.
|
|
|
1419
1438
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
1420
1439
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
1421
1440
|
|
|
1422
|
-
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.
|
|
1441
|
+
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.27.0...HEAD
|
|
1442
|
+
[0.27.0]: https://github.com/dm1try/samagotchi/compare/v0.26.0...v0.27.0
|
|
1423
1443
|
[0.26.0]: https://github.com/dm1try/samagotchi/compare/v0.25.0...v0.26.0
|
|
1424
1444
|
[0.25.0]: https://github.com/dm1try/samagotchi/compare/v0.24.0...v0.25.0
|
|
1425
1445
|
[0.24.0]: https://github.com/dm1try/samagotchi/compare/v0.23.0...v0.24.0
|
data/docs/architecture.md
CHANGED
|
@@ -246,6 +246,12 @@ process that already owns the `Engine`); every worker starts it, and it exposes:
|
|
|
246
246
|
`guardrails.parent_approvals` is `403 parent_approval_refused`, the question still open.
|
|
247
247
|
- `POST /session/:id/question/dismiss` — leave the question unanswered (an approval: denied);
|
|
248
248
|
`200`, or `409` when it is no longer pending.
|
|
249
|
+
- `POST /session/:id/tasks/stop` — `{task_id}`: stop a background task as the user
|
|
250
|
+
(`TaskRuntime.stop_task by: "user"`, the web's stop-task button on a running `task_wait`;
|
|
251
|
+
web route `POST /api/sessions/:id/tasks/:task_id/stop`). Only a task this conversation's
|
|
252
|
+
`task_create` started (the saved messages, or the running turn's accumulator); answers once the
|
|
253
|
+
task is gone: `200 {status, stop_reason, task_id}`, `404 task_not_found`, `409 not_running`.
|
|
254
|
+
The worker names it in its sidecar features (`task_stop`).
|
|
249
255
|
- `POST /session/:id/relay` — the approval relay, on a delegated child's worker
|
|
250
256
|
(`{action, relay_id, question_id}`): `opened` / `closed` (with a `reason`) set or clear the
|
|
251
257
|
pending question's `relayed_to` mark (`QuestionDesk#annotate`, `:question_relay`); `answered`
|
data/docs/hooks.md
CHANGED
|
@@ -56,7 +56,7 @@ The plugin class must respond to `#call(event)` — duck-typed, no base class re
|
|
|
56
56
|
| `:after_generation` | After LLM returns (both loops); not after a generation a hook cut (`stop_generation`) | `{ type: :after_generation, iteration: N, response: "...", messages: [...] (the conversation as sent) }` |
|
|
57
57
|
| `:generation_progress` | While the response streams, in batches (see [Watching the stream](#watching-the-stream)) | `{ type: :generation_progress, iteration: N, thinking: "..." (new since the last fire), text: "..." (new visible text), thinking_chars: N, text_chars: N (this generation so far), elapsed_ms: N }` |
|
|
58
58
|
| `:before_tool_call` | Before tool dispatch (and before `tool_call_started`) | `{ type: :before_tool_call, iteration: N, call: {...}, params: "...", guardrail: Verdict, context: {...}, targets: {...}, blocked: false, block_reason: nil }` |
|
|
59
|
-
| `:after_tool_call` | After tool execution | `{ type: :after_tool_call, iteration: N, tool: "read", output: "..." (capped at max_tool_output_chars), status: "ok" \| "error" \| "blocked" \| "stopped" }` |
|
|
59
|
+
| `:after_tool_call` | After tool execution | `{ type: :after_tool_call, iteration: N, tool: "read", output: "..." (capped at max_tool_output_chars), status: "ok" \| "error" \| "blocked" \| "stopped" }`; `stopped` is a `task_wait` the turn's Stop ended (the task runs on) or whose task was stopped |
|
|
60
60
|
| `:session_end` | After each turn `:after_turn` fires for, after it (turn-level lifecycle) | `{ type: :session_end, session_id: "..." }` |
|
|
61
61
|
|
|
62
62
|
Every event also carries the hook runtime (next section): `hook:` (the label
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -27,6 +27,8 @@ require_relative "version"
|
|
|
27
27
|
require_relative "worker_sidecar"
|
|
28
28
|
require_relative "relay_verifier"
|
|
29
29
|
require_relative "relay_watcher"
|
|
30
|
+
require_relative "tools/task_create"
|
|
31
|
+
require_relative "tools/task_runtime"
|
|
30
32
|
|
|
31
33
|
module Samagotchi
|
|
32
34
|
# Bridge is an optional HTTP transport that lets an external web / desktop
|
|
@@ -461,7 +463,8 @@ module Samagotchi
|
|
|
461
463
|
# sidecar (WorkerSidecar#features): a client checks a name here rather
|
|
462
464
|
# than a version.
|
|
463
465
|
# restart: POST /exit takes "restart": true (WorkerIdleExit#hold_for_restart).
|
|
464
|
-
|
|
466
|
+
# task_stop: POST tasks/stop (#handle_task_stop), the web's stop-task button.
|
|
467
|
+
FEATURES = %w[restart task_stop].freeze
|
|
465
468
|
|
|
466
469
|
# The routes, by method and what follows /session/:id/: a handler takes
|
|
467
470
|
# the session id and the request body and returns [headers, status, body].
|
|
@@ -477,6 +480,7 @@ module Samagotchi
|
|
|
477
480
|
%w[POST recap] => :handle_recap,
|
|
478
481
|
%w[POST relay] => :handle_relay,
|
|
479
482
|
%w[POST relay/status] => :handle_relay_status,
|
|
483
|
+
%w[POST tasks/stop] => :handle_task_stop,
|
|
480
484
|
%w[GET state] => :handle_state,
|
|
481
485
|
%w[GET stats] => :handle_stats,
|
|
482
486
|
%w[GET snapshot] => :handle_snapshot,
|
|
@@ -539,6 +543,38 @@ module Samagotchi
|
|
|
539
543
|
end
|
|
540
544
|
end
|
|
541
545
|
|
|
546
|
+
# POST /session/:id/tasks/stop {task_id}: stop a background task as the
|
|
547
|
+
# user (TaskRuntime.stop_task by: "user"); a task_wait on it returns, and
|
|
548
|
+
# the turn goes on. Only a task this conversation created: records in
|
|
549
|
+
# tmp/tasks are shared by every session in the cwd. Those are in the
|
|
550
|
+
# saved conversation, or, created in the running turn, only in the
|
|
551
|
+
# accumulator's task_create parts. 200 {status, stop_reason, task_id},
|
|
552
|
+
# 404 task_not_found, 409 not_running (it already ended), 400 no id.
|
|
553
|
+
# Answers once the task is gone: up to STOP_GRACE_SEC.
|
|
554
|
+
def handle_task_stop(_session_id, body)
|
|
555
|
+
parsed = parse_json(body)
|
|
556
|
+
task_id = (fetched(parsed, "task_id") if parsed.is_a?(Hash)).to_s.strip
|
|
557
|
+
return [{ "Allow" => "POST" }, 400, { error: "missing_fields", detail: "task_id required" }] if task_id.empty?
|
|
558
|
+
return [{}, 404, { error: "task_not_found", task_id: task_id }] unless own_task_ids.include?(task_id)
|
|
559
|
+
|
|
560
|
+
record, error = Tools::TaskRuntime.get_record(task_id)
|
|
561
|
+
return [{}, 404, { error: "task_not_found", task_id: task_id }] if error
|
|
562
|
+
return [{}, 409, { error: "not_running", status: record["status"], task_id: task_id }] unless record["status"] == "running"
|
|
563
|
+
|
|
564
|
+
Log.info(:bridge, "task_stop", task_id: task_id)
|
|
565
|
+
record, error = Tools::TaskRuntime.stop_task(task_id, by: "user")
|
|
566
|
+
return [{}, 500, { error: "bridge_error", detail: error }] if error
|
|
567
|
+
|
|
568
|
+
[{}, 200, { status: record["status"], stop_reason: record["stop_reason"], task_id: task_id }]
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
def own_task_ids
|
|
572
|
+
running = Array(@accumulator.current_turn&.dig(:parts)).filter_map do |part|
|
|
573
|
+
part[:output].to_s[/\A(?:\[task_create\]\n)?task_id: (\S+)/, 1] if part[:kind] == "tool" && part[:tool] == Tools::TaskCreate::NAME
|
|
574
|
+
end
|
|
575
|
+
Tools::TaskRuntime.created_ids_in(@engine.messages_checkpoint) | running
|
|
576
|
+
end
|
|
577
|
+
|
|
542
578
|
# Answer the pending question. A past +deadline+ (see #handle_post_turn):
|
|
543
579
|
# 408 deadline_passed, and the question stays open. An answer takes no
|
|
544
580
|
# event hold, so it is checked right before it is recorded.
|
|
@@ -187,6 +187,15 @@ module Samagotchi
|
|
|
187
187
|
post("cancel", { reason: reason }, read_body: true)
|
|
188
188
|
end
|
|
189
189
|
|
|
190
|
+
# POST /session/:id/tasks/stop: stop a background task this session's
|
|
191
|
+
# conversation created, as the user (Bridge#handle_task_stop). 200 with
|
|
192
|
+
# its final status, 404 task_not_found, 409 not_running. Synchronous:
|
|
193
|
+
# the worker answers once the task is gone (up to its 3 s grace).
|
|
194
|
+
# @return [Response]
|
|
195
|
+
def stop_task(task_id)
|
|
196
|
+
post("tasks/stop", { task_id: task_id }, read_body: true)
|
|
197
|
+
end
|
|
198
|
+
|
|
190
199
|
# How long the approval relay's calls wait for a reply: a parent's Stop
|
|
191
200
|
# isn't held for READ_TIMEOUT by a child that doesn't answer.
|
|
192
201
|
RELAY_TIMEOUT = 5
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: mcp
|
|
3
|
-
version: 0.4.
|
|
3
|
+
version: 0.4.3
|
|
4
4
|
scope: system
|
|
5
5
|
description: "Tools from MCP servers (stdio): each server in config.yml bundles: mcp: servers: runs for the session, its tools are mcp_<server>_<tool>; /mcp lists them"
|
|
6
6
|
trust_level: reviewed
|
|
7
7
|
files: {}
|
|
8
8
|
plugin:
|
|
9
9
|
file: plugin.rb
|
|
10
|
-
sha256: sha256:
|
|
10
|
+
sha256: sha256:079a42892f10822115235e1ced7ccd481f80c9ef54abb094f9a1c599bb03e8e2
|
|
11
11
|
requires_chi: ">= 0.8.0"
|
|
@@ -752,7 +752,8 @@ class Plugin
|
|
|
752
752
|
|
|
753
753
|
@servers.map do |server|
|
|
754
754
|
head = "**#{server.name}**: #{state_text(server)}"
|
|
755
|
-
|
|
755
|
+
# Sorted: a server lists its tools in its own (often grouped) order.
|
|
756
|
+
tools = server.tools.map { |tool| tool["chi_name"] }.compact.sort
|
|
756
757
|
tools.empty? ? head : "#{head}\n#{tools.map { |t| "- `#{t}`" }.join("\n")}"
|
|
757
758
|
end.join("\n\n")
|
|
758
759
|
end
|
data/lib/samagotchi/log.rb
CHANGED
|
@@ -23,6 +23,8 @@ module Samagotchi
|
|
|
23
23
|
# (once, until Config.reload!). While that runs (Config can warn itself)
|
|
24
24
|
# records only echo. Nothing here raises into the caller: a record that
|
|
25
25
|
# can't be formatted is dropped, an unwritable file pauses (DebugLog).
|
|
26
|
+
# The one exception is strict mode (SAMAGOTCHI_LOG_STRICT=1, the spec
|
|
27
|
+
# suite): an unknown tag or a malformed event raises, whatever the level.
|
|
26
28
|
module Log
|
|
27
29
|
LEVELS = { debug: 0, info: 1, warn: 2, error: 3 }.freeze
|
|
28
30
|
DEFAULT_LEVEL = :info
|
|
@@ -39,6 +41,7 @@ module Samagotchi
|
|
|
39
41
|
@state = nil
|
|
40
42
|
@resolving = false
|
|
41
43
|
@session_id = nil
|
|
44
|
+
@strict = ENV["SAMAGOTCHI_LOG_STRICT"] == "1"
|
|
42
45
|
|
|
43
46
|
class << self
|
|
44
47
|
# @param path [String, nil, :auto] the file; :auto → LogPath.resolve
|
|
@@ -72,6 +75,9 @@ module Samagotchi
|
|
|
72
75
|
# carry its first SID_LENGTH chars unless they pass their own sid:.
|
|
73
76
|
attr_accessor :session_id
|
|
74
77
|
|
|
78
|
+
# Raise on an unknown tag or a bad event instead of dropping the record.
|
|
79
|
+
attr_accessor :strict
|
|
80
|
+
|
|
75
81
|
def debug(tag, event, **kw) = log(:debug, tag, event, **kw)
|
|
76
82
|
def info(tag, event, **kw) = log(:info, tag, event, **kw)
|
|
77
83
|
def warn(tag, event, **kw) = log(:warn, tag, event, **kw)
|
|
@@ -96,7 +102,19 @@ module Samagotchi
|
|
|
96
102
|
resolved_state&.dig(:writer)&.path
|
|
97
103
|
end
|
|
98
104
|
|
|
99
|
-
def log(level, tag, event,
|
|
105
|
+
def log(level, tag, event, **kw)
|
|
106
|
+
check_names!(tag, event) if strict
|
|
107
|
+
write_record(level, tag, event, **kw)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def check_names!(tag, event)
|
|
113
|
+
raise ArgumentError, "unknown log tag #{tag}" unless LogLine::TAGS.include?(tag.to_s)
|
|
114
|
+
raise ArgumentError, "bad log event #{event}" unless event.to_s.match?(LogLine::SLUG)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def write_record(level, tag, event, payload: nil, sid: nil, echo: nil, **fields)
|
|
100
118
|
options = @options
|
|
101
119
|
echo_to_stderr(echo) if echo && options.fetch(:stderr, true)
|
|
102
120
|
state = resolved_state
|
|
@@ -113,8 +131,6 @@ module Samagotchi
|
|
|
113
131
|
nil
|
|
114
132
|
end
|
|
115
133
|
|
|
116
|
-
private
|
|
117
|
-
|
|
118
134
|
def build(level, tag, event, payload:, sid:, fields:)
|
|
119
135
|
tag = tag.to_s
|
|
120
136
|
event = event.to_s
|
|
@@ -23,7 +23,7 @@ module Samagotchi
|
|
|
23
23
|
class ThinkingTails
|
|
24
24
|
FILE = "thinking_tails.jsonl"
|
|
25
25
|
# The last chars of a generation's thinking kept: a loop's cycle and
|
|
26
|
-
# some of its lead-in.
|
|
26
|
+
# some of its lead-in. TurnNote cuts an empty step's thinking to it too.
|
|
27
27
|
TAIL_CHARS = 20_000
|
|
28
28
|
MAX_RECORDS = 10
|
|
29
29
|
|
|
@@ -150,12 +150,13 @@ module Samagotchi
|
|
|
150
150
|
# "error" for an "Error:" result, and for an execute whose command
|
|
151
151
|
# exited non-zero (its last "exit: N" line; no number: killed by a
|
|
152
152
|
# signal), so the turn tally's "(N failed)" counts it. "stopped" for a
|
|
153
|
-
# task_wait the user's Stop ended
|
|
153
|
+
# task_wait the user's Stop ended (the task itself runs on), and for one
|
|
154
|
+
# whose task was stopped (the stop-task button, or the model's task_stop).
|
|
154
155
|
def tool_activity_status(result, tool_name = nil)
|
|
155
156
|
text = result.to_s
|
|
156
157
|
return "error" if text.start_with?("Error:")
|
|
157
158
|
return "error" if tool_name == Tools::Execute::NAME && execute_failed?(text)
|
|
158
|
-
return "stopped" if tool_name == Tools::TaskWait::NAME && text.match?(/^wait_result: canceled$/)
|
|
159
|
+
return "stopped" if tool_name == Tools::TaskWait::NAME && text.match?(/^(wait_result: canceled|status: stopped)$/)
|
|
159
160
|
|
|
160
161
|
"ok"
|
|
161
162
|
end
|
data/lib/samagotchi/tool_view.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "command_steps"
|
|
4
4
|
require_relative "tools/execute"
|
|
5
5
|
require_relative "tools/task_create"
|
|
6
|
+
require_relative "tools/task_wait"
|
|
6
7
|
|
|
7
8
|
module Samagotchi
|
|
8
9
|
# What a richer UI shows of a tool call beyond its one-line params: the
|
|
@@ -18,8 +19,9 @@ module Samagotchi
|
|
|
18
19
|
# can't (the fallback: the UI shows the command itself) or the command
|
|
19
20
|
# is cut. +description+: what the model said the command does (execute's
|
|
20
21
|
# optional description), whitespace collapsed, a trailing period gone;
|
|
21
|
-
# nil when it gave none.
|
|
22
|
-
|
|
22
|
+
# nil when it gave none. +task_id+: the task a task_wait waits on (its
|
|
23
|
+
# only field), for the web's stop-task button.
|
|
24
|
+
ToolView = Data.define(:command, :cwd, :truncated, :chars, :cd, :steps, :description, :task_id) do
|
|
23
25
|
def to_h = super.merge(steps: steps&.map(&:to_h)).compact.reject { |_key, value| value == false }
|
|
24
26
|
end
|
|
25
27
|
|
|
@@ -37,6 +39,7 @@ module Samagotchi
|
|
|
37
39
|
# @return [ToolView, nil] nil for a tool without a view and for an
|
|
38
40
|
# empty command
|
|
39
41
|
def self.for(tool_name, call)
|
|
42
|
+
return task_wait(call) if tool_name == Tools::TaskWait::NAME && call.is_a?(Hash)
|
|
40
43
|
return nil unless TOOLS.include?(tool_name) && call.is_a?(Hash)
|
|
41
44
|
|
|
42
45
|
command = call[:content].to_s
|
|
@@ -47,9 +50,17 @@ module Samagotchi
|
|
|
47
50
|
parsed = truncated ? nil : CommandSteps.parse(command)
|
|
48
51
|
new(command: truncated ? command[0, COMMAND_LIMIT] : command, cwd: cwd.empty? ? nil : cwd,
|
|
49
52
|
truncated: truncated, chars: truncated ? command.length : nil, cd: parsed&.cd, steps: parsed&.steps,
|
|
50
|
-
description: description(call))
|
|
53
|
+
description: description(call), task_id: nil)
|
|
51
54
|
end
|
|
52
55
|
|
|
56
|
+
def self.task_wait(call)
|
|
57
|
+
task_id = call[:content].to_s.strip
|
|
58
|
+
return nil if task_id.empty?
|
|
59
|
+
|
|
60
|
+
new(command: nil, cwd: nil, truncated: false, chars: nil, cd: nil, steps: nil, description: nil, task_id: task_id)
|
|
61
|
+
end
|
|
62
|
+
private_class_method :task_wait
|
|
63
|
+
|
|
53
64
|
# The call's description as a view shows it, nil for none.
|
|
54
65
|
def self.description(call)
|
|
55
66
|
text = call[:description]
|
|
@@ -30,8 +30,9 @@ module Samagotchi
|
|
|
30
30
|
"finished_at: #{record["finished_at"]}",
|
|
31
31
|
"exit_code: #{record["exit_code"]}",
|
|
32
32
|
"stop_reason: #{record["stop_reason"]}",
|
|
33
|
-
"output_path: #{record.fetch("output_path")}"
|
|
34
|
-
|
|
33
|
+
"output_path: #{record.fetch("output_path")}",
|
|
34
|
+
(TaskRuntime::USER_STOP_NOTE if TaskRuntime.stopped_by_user?(record))
|
|
35
|
+
].compact.join("\n")
|
|
35
36
|
end
|
|
36
37
|
private_class_method :format_record
|
|
37
38
|
end
|
|
@@ -22,6 +22,9 @@ module Samagotchi
|
|
|
22
22
|
SANITIZED_ENV_KEYS = %w[RUBYOPT RUBYLIB BUNDLE_GEMFILE BUNDLE_BIN_PATH BUNDLER_VERSION].freeze
|
|
23
23
|
# Set by chi (Builtins.parent_env); the model's env can't change it.
|
|
24
24
|
MARKER_ENV_KEYS = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL].freeze
|
|
25
|
+
# task_wait and task_get add it to a task the user stopped (the web's
|
|
26
|
+
# stop-task button), so the model doesn't rerun the job.
|
|
27
|
+
USER_STOP_NOTE = "note: the user stopped this task; don't restart it unless they ask."
|
|
25
28
|
|
|
26
29
|
module_function
|
|
27
30
|
|
|
@@ -107,19 +110,27 @@ module Samagotchi
|
|
|
107
110
|
# A native tool_response joins its calls' results with "---".
|
|
108
111
|
# @return [Array<Hash>] {id:, command:}, oldest first
|
|
109
112
|
def running_created_in(messages)
|
|
110
|
-
|
|
113
|
+
created_ids_in(messages).filter_map do |id|
|
|
114
|
+
record, _error = get_record(id)
|
|
115
|
+
{ id: id, command: record["command"].to_s } if record&.fetch("status") == "running"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The ids of every task this conversation's task_create calls started,
|
|
120
|
+
# whatever their status now, oldest first.
|
|
121
|
+
# @return [Array<String>]
|
|
122
|
+
def created_ids_in(messages)
|
|
123
|
+
Array(messages).flat_map do |message|
|
|
111
124
|
next [] unless (message[:role] || message["role"]).to_s == "tool_response"
|
|
112
125
|
|
|
113
126
|
(message[:content] || message["content"]).to_s.split("\n\n---\n\n").filter_map do |block|
|
|
114
127
|
block[/\A\[task_create\]\ntask_id: (\S+)/, 1]
|
|
115
128
|
end
|
|
116
|
-
end
|
|
117
|
-
ids.uniq.filter_map do |id|
|
|
118
|
-
record, _error = get_record(id)
|
|
119
|
-
{ id: id, command: record["command"].to_s } if record&.fetch("status") == "running"
|
|
120
|
-
end
|
|
129
|
+
end.uniq
|
|
121
130
|
end
|
|
122
131
|
|
|
132
|
+
def stopped_by_user?(record) = record["stop_reason"] == "stopped_by_user"
|
|
133
|
+
|
|
123
134
|
def get_record(task_id)
|
|
124
135
|
record = load_record(task_id)
|
|
125
136
|
return [nil, "Error: task not found: #{task_id}"] unless record
|
|
@@ -142,13 +153,19 @@ module Samagotchi
|
|
|
142
153
|
content.lines.last(line_count).join
|
|
143
154
|
end
|
|
144
155
|
|
|
145
|
-
|
|
156
|
+
# A requested stop ends as "stopped" whatever the exit code: the flag
|
|
157
|
+
# is on disk before the TERM, so a reader refreshing the record while
|
|
158
|
+
# the group dies records the stop, not a failure.
|
|
159
|
+
# @param by [String] "model" (task_stop) or "user" (the web's button)
|
|
160
|
+
def stop_task(task_id, by:)
|
|
146
161
|
record = load_record(task_id)
|
|
147
162
|
return [nil, "Error: task not found: #{task_id}"] unless record
|
|
148
163
|
|
|
149
164
|
refreshed = refresh_record(record)
|
|
150
165
|
return [refreshed, nil] unless refreshed["status"] == "running"
|
|
151
166
|
|
|
167
|
+
refreshed["stop_requested_by"] = by.to_s
|
|
168
|
+
write_record(refreshed)
|
|
152
169
|
pid = refreshed["pid"].to_i
|
|
153
170
|
|
|
154
171
|
begin
|
|
@@ -168,10 +185,7 @@ module Samagotchi
|
|
|
168
185
|
end
|
|
169
186
|
end
|
|
170
187
|
|
|
171
|
-
updated = refresh_record(refreshed)
|
|
172
|
-
updated["status"] = "stopped" if updated["status"] == "running"
|
|
173
|
-
updated["finished_at"] ||= timestamp
|
|
174
|
-
updated["stop_reason"] = "stopped_by_user"
|
|
188
|
+
updated = mark_stopped(refresh_record(refreshed))
|
|
175
189
|
write_record(updated)
|
|
176
190
|
|
|
177
191
|
[updated, nil]
|
|
@@ -294,9 +308,15 @@ module Samagotchi
|
|
|
294
308
|
end
|
|
295
309
|
|
|
296
310
|
def mark_finished_without_exit_code(record)
|
|
311
|
+
record = current_record(record)
|
|
312
|
+
return record unless record["status"] == "running"
|
|
313
|
+
|
|
297
314
|
exit_code = read_exit_code(record["exit_code_path"])
|
|
298
315
|
|
|
299
|
-
if
|
|
316
|
+
if record["stop_requested_by"]
|
|
317
|
+
record["exit_code"] = exit_code
|
|
318
|
+
mark_stopped(record)
|
|
319
|
+
elsif exit_code.nil?
|
|
300
320
|
record["status"] = "failed"
|
|
301
321
|
record["exit_code"] = nil
|
|
302
322
|
record["stop_reason"] ||= "process_ended_without_exit_code"
|
|
@@ -313,6 +333,23 @@ module Samagotchi
|
|
|
313
333
|
record
|
|
314
334
|
end
|
|
315
335
|
|
|
336
|
+
# The disk copy when it is newer than +record+: already finished, or
|
|
337
|
+
# flagged by a stop that started after +record+ was read.
|
|
338
|
+
def current_record(record)
|
|
339
|
+
disk = load_record(record.fetch("id"))
|
|
340
|
+
return record unless disk
|
|
341
|
+
return disk if disk["status"] != "running" || disk["stop_requested_by"]
|
|
342
|
+
|
|
343
|
+
record
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def mark_stopped(record)
|
|
347
|
+
record["status"] = "stopped"
|
|
348
|
+
record["stop_reason"] = "stopped_by_#{record["stop_requested_by"]}"
|
|
349
|
+
record["finished_at"] ||= timestamp
|
|
350
|
+
record
|
|
351
|
+
end
|
|
352
|
+
|
|
316
353
|
def read_exit_code(path)
|
|
317
354
|
return nil if path.to_s.empty? || !File.exist?(path)
|
|
318
355
|
|
|
@@ -37,10 +37,10 @@ module Samagotchi
|
|
|
37
37
|
return "Error: #{error}" if error
|
|
38
38
|
|
|
39
39
|
status = record.fetch("status")
|
|
40
|
-
return
|
|
40
|
+
return finished_response(record, tail_lines) unless status == "running"
|
|
41
41
|
|
|
42
42
|
if completion_pattern
|
|
43
|
-
output_tail =
|
|
43
|
+
output_tail = output_tail(record, tail_lines)
|
|
44
44
|
return format_response(record, wait_result: "pattern_matched", output_tail: output_tail) if completion_pattern.match?(output_tail)
|
|
45
45
|
end
|
|
46
46
|
|
|
@@ -53,10 +53,9 @@ module Samagotchi
|
|
|
53
53
|
|
|
54
54
|
# Timeout reached — do one final refresh to capture the latest status
|
|
55
55
|
refreshed, = TaskRuntime.get_record(task_id)
|
|
56
|
-
return
|
|
56
|
+
return finished_response(refreshed, tail_lines) unless refreshed.fetch("status") == "running"
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
format_response(refreshed, wait_result: "timeout", output_tail: output_tail)
|
|
58
|
+
format_response(refreshed, wait_result: "timeout", output_tail: output_tail(refreshed, tail_lines))
|
|
60
59
|
rescue StandardError => e
|
|
61
60
|
"Error: #{e.message}"
|
|
62
61
|
end
|
|
@@ -66,18 +65,31 @@ module Samagotchi
|
|
|
66
65
|
def self.cancelled_response(task_id, tail_lines, started)
|
|
67
66
|
record, error = TaskRuntime.get_record(task_id)
|
|
68
67
|
return "Error: #{error}" if error
|
|
69
|
-
return
|
|
68
|
+
return finished_response(record, tail_lines) unless record.fetch("status") == "running"
|
|
70
69
|
|
|
71
|
-
output_tail = TaskRuntime.output_tail_lines(record.fetch("output_path"), tail_lines)
|
|
72
70
|
note = [
|
|
73
71
|
"note: the user stopped the turn while waiting; the task is STILL RUNNING.",
|
|
74
72
|
" To keep waiting: task_wait #{task_id}. If it is stuck or no longer needed: task_stop #{task_id}.",
|
|
75
73
|
"waited: #{(monotonic_time - started).round}s"
|
|
76
74
|
]
|
|
77
|
-
format_response(record, wait_result: "canceled", output_tail: output_tail, extra: note)
|
|
75
|
+
format_response(record, wait_result: "canceled", output_tail: output_tail(record, tail_lines), extra: note)
|
|
78
76
|
end
|
|
79
77
|
private_class_method :cancelled_response
|
|
80
78
|
|
|
79
|
+
# A task the user stopped also says so, with the tail of its output:
|
|
80
|
+
# the model sees where the job was cut off.
|
|
81
|
+
def self.finished_response(record, tail_lines)
|
|
82
|
+
return format_response(record) unless TaskRuntime.stopped_by_user?(record)
|
|
83
|
+
|
|
84
|
+
format_response(record, output_tail: output_tail(record, tail_lines), extra: [TaskRuntime::USER_STOP_NOTE])
|
|
85
|
+
end
|
|
86
|
+
private_class_method :finished_response
|
|
87
|
+
|
|
88
|
+
def self.output_tail(record, tail_lines)
|
|
89
|
+
TaskRuntime.output_tail_lines(record.fetch("output_path"), tail_lines)
|
|
90
|
+
end
|
|
91
|
+
private_class_method :output_tail
|
|
92
|
+
|
|
81
93
|
def self.format_response(record, wait_result: nil, output_tail: nil, extra: [])
|
|
82
94
|
lines = [
|
|
83
95
|
"task_id: #{record.fetch("id")}",
|
|
@@ -86,9 +98,9 @@ module Samagotchi
|
|
|
86
98
|
"stop_reason: #{record["stop_reason"]}",
|
|
87
99
|
"output_path: #{record.fetch("output_path")}"
|
|
88
100
|
]
|
|
89
|
-
return lines.join("\n") unless
|
|
101
|
+
return lines.join("\n") unless output_tail
|
|
90
102
|
|
|
91
|
-
lines << "wait_result: #{wait_result}"
|
|
103
|
+
lines << "wait_result: #{wait_result}" if wait_result
|
|
92
104
|
lines.concat(extra)
|
|
93
105
|
lines << "output_tail:"
|
|
94
106
|
lines << output_tail.rstrip unless output_tail.empty?
|
data/lib/samagotchi/turn_note.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "model_profile"
|
|
4
|
+
require_relative "thinking_tails"
|
|
4
5
|
|
|
5
6
|
module Samagotchi
|
|
6
7
|
# The one-line system note a turn leaves in the conversation when it ends
|
|
@@ -23,7 +24,8 @@ module Samagotchi
|
|
|
23
24
|
EMPTY_ANSWER = :empty_answer
|
|
24
25
|
# An empty step's thinking kept on the marker: its tail, so a model
|
|
25
26
|
# that thought for minutes doesn't put megabytes in the session file.
|
|
26
|
-
|
|
27
|
+
# The same cap as ThinkingTails' saved tails.
|
|
28
|
+
STEP_CHARS = ThinkingTails::TAIL_CHARS
|
|
27
29
|
# A step's native thinking starts with one of these; the cut keeps it,
|
|
28
30
|
# so the UIs still find the thinking (MessageParts).
|
|
29
31
|
THINK_OPENS = ["<think>", ModelProfile::GEMMA_THOUGHT_CHANNEL_OPEN].freeze
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -139,6 +139,7 @@ module Samagotchi
|
|
|
139
139
|
["GET", %r{\A/api/sessions/([^/]+)/stream\z}, :handle_stream],
|
|
140
140
|
["GET", %r{\A/api/sessions/([^/]+)/output\z}, :handle_output],
|
|
141
141
|
["POST", %r{\A/api/sessions/([^/]+)/cancel\z}, :handle_cancel],
|
|
142
|
+
["POST", %r{\A/api/sessions/([^/]+)/tasks/([^/]+)/stop\z}, :handle_task_stop],
|
|
142
143
|
["POST", %r{\A/api/sessions/([^/]+)/stop\z}, :handle_stop],
|
|
143
144
|
["POST", %r{\A/api/sessions/([^/]+)/restart\z}, :handle_restart],
|
|
144
145
|
["POST", %r{\A/api/sessions/([^/]+)/archive\z}, :handle_archive],
|
|
@@ -1019,6 +1020,30 @@ module Samagotchi
|
|
|
1019
1020
|
error_response(404, "not_found", e.message)
|
|
1020
1021
|
end
|
|
1021
1022
|
|
|
1023
|
+
# A task id as TaskRuntime.generate_task_id makes it.
|
|
1024
|
+
TASK_ID = /\A\d{14}-\h{8}\z/
|
|
1025
|
+
|
|
1026
|
+
# The stop-task button: the worker stops the task as the user
|
|
1027
|
+
# (Bridge#handle_task_stop) and its task_wait returns. Its own 404
|
|
1028
|
+
# (not this conversation's task) and 409 (already ended) pass through;
|
|
1029
|
+
# a 404 from a worker without the route is the stale worker's 501.
|
|
1030
|
+
def handle_task_stop(_req, id, task_id)
|
|
1031
|
+
return error_response(400, "invalid_task_id", "not a task id: #{task_id.to_s[0, 80]}") unless TASK_ID.match?(task_id)
|
|
1032
|
+
|
|
1033
|
+
@session_class.load(id, state_dir: default_state_dir)
|
|
1034
|
+
request = ->(client) { client.stop_task(task_id) }
|
|
1035
|
+
relay(id, bridge_client(id), request, what: "the task was not stopped", cant: "stop tasks") do |reply|
|
|
1036
|
+
body = reply.json || {}
|
|
1037
|
+
case reply.status
|
|
1038
|
+
when 200 then json_response(200, { status: body["status"], stop_reason: body["stop_reason"], task_id: task_id, session_id: id })
|
|
1039
|
+
when 404 then (error_response(404, "task_not_found", "no task #{task_id} in this conversation") if body["error"] == "task_not_found")
|
|
1040
|
+
when 409 then json_response(409, { error: "not_running", status: body["status"], task_id: task_id, session_id: id })
|
|
1041
|
+
end
|
|
1042
|
+
end
|
|
1043
|
+
rescue ArgumentError => e
|
|
1044
|
+
error_response(404, "not_found", e.message)
|
|
1045
|
+
end
|
|
1046
|
+
|
|
1022
1047
|
def handle_stop(_req, id)
|
|
1023
1048
|
# Bounded, so a Rack thread isn't held long; a worker still exiting
|
|
1024
1049
|
# after it just means an immediate resume finds it (rare).
|
|
@@ -8,7 +8,7 @@ module Samagotchi
|
|
|
8
8
|
class MarkdownRenderer
|
|
9
9
|
WARNING = "Markdown rendering is enabled, but the optional commonmarker gem is not installed. Install it with: gem install commonmarker"
|
|
10
10
|
# NOTE: `span` is allowed because the syntax highlighter (commonmarker/syntect)
|
|
11
|
-
# emits
|
|
11
|
+
# emits classed <span>s; keeping it lets highlighting render.
|
|
12
12
|
ALLOWED_ELEMENTS = %w[a blockquote br code del em h1 h2 h3 h4 h5 h6 hr li ol p pre s strong span table tbody td th thead tr ul].freeze
|
|
13
13
|
DROP_ELEMENTS = %w[iframe object script style svg].freeze
|
|
14
14
|
ALLOWED_PROTOCOLS = %w[http https mailto].freeze
|
|
@@ -35,6 +35,11 @@ module Samagotchi
|
|
|
35
35
|
html = Commonmarker.to_html(markdown.to_s, options: {
|
|
36
36
|
render: { escape: true }, # escape raw HTML from source; highlighter output is still emitted
|
|
37
37
|
extension: { header_ids: nil }
|
|
38
|
+
}, plugins: {
|
|
39
|
+
# An empty theme makes syntect emit scope classes (`keyword`, `string`, ...)
|
|
40
|
+
# instead of inline colours from one fixed theme, so the page's light and
|
|
41
|
+
# dark palettes colour code blocks (the --code-* tokens in index.html).
|
|
42
|
+
syntax_highlighter: { theme: "" }
|
|
38
43
|
})
|
|
39
44
|
return nil unless html
|
|
40
45
|
|
|
@@ -74,6 +79,7 @@ module Samagotchi
|
|
|
74
79
|
|
|
75
80
|
node.remove_attribute(attribute.name)
|
|
76
81
|
end
|
|
82
|
+
prefix_highlight_classes(node) if node.name == "span"
|
|
77
83
|
return unless node.name == "a"
|
|
78
84
|
|
|
79
85
|
href = node["href"].to_s.strip
|
|
@@ -85,16 +91,25 @@ module Samagotchi
|
|
|
85
91
|
node["rel"] = "noopener noreferrer"
|
|
86
92
|
end
|
|
87
93
|
|
|
88
|
-
# The syntax highlighter emits
|
|
89
|
-
#
|
|
90
|
-
# markdown source is already escaped by commonmarker
|
|
91
|
-
# attributes are safe to keep on these elements only.
|
|
94
|
+
# The syntax highlighter emits `class=` on <pre> ("syntax-highlighting") and
|
|
95
|
+
# <span> (syntect scope names). They come from the highlighter, not user input:
|
|
96
|
+
# raw HTML from the markdown source is already escaped by commonmarker
|
|
97
|
+
# (escape: true), so class attributes are safe to keep on these elements only.
|
|
92
98
|
def allowed_attribute?(node, name)
|
|
93
|
-
return true if name == "
|
|
99
|
+
return true if name == "class" && %w[span pre].include?(node.name)
|
|
94
100
|
|
|
95
101
|
node.name == "a" && %w[href title].include?(name)
|
|
96
102
|
end
|
|
97
103
|
|
|
104
|
+
# Scope names are plain words ("diff", "meta", "name", ...) that page CSS and
|
|
105
|
+
# querySelectors use too: prefix them so they only ever match the hl-* rules.
|
|
106
|
+
def prefix_highlight_classes(node)
|
|
107
|
+
words = node["class"].to_s.split
|
|
108
|
+
return node.remove_attribute("class") if words.empty?
|
|
109
|
+
|
|
110
|
+
node["class"] = words.map { |word| "hl-#{word}" }.join(" ")
|
|
111
|
+
end
|
|
112
|
+
|
|
98
113
|
def safe_href?(href)
|
|
99
114
|
return true if href.start_with?("#", "/")
|
|
100
115
|
|
|
@@ -23,6 +23,7 @@ import { chipName, chipsHtml, imageFiles, restoredChips, thumbsHtml, turnRefs, t
|
|
|
23
23
|
import { escapeHtml, memChipText, memChipTitle, noteHtml, normalize, steerRowHtml, userBodyHtml, ownerBadge, previewForCard, relativeTime, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy } from "./format.js";
|
|
24
24
|
import { EMPTY_ANSWER_CLASS, createTurnView, turnHistoryHtml } from "./turn_view.js";
|
|
25
25
|
import { createStageView } from "./stage_view.js";
|
|
26
|
+
import { createTaskStopper } from "./task_stop.js";
|
|
26
27
|
import { appendQuote } from "./annotations.js";
|
|
27
28
|
import { parsePresets } from "./annotate_presets.js";
|
|
28
29
|
import { createAnnotateBar } from "./annotate_bar.js";
|
|
@@ -86,6 +87,9 @@ const viewArgs = {
|
|
|
86
87
|
isNearBottom: () => isNearBottom(),
|
|
87
88
|
sawText: (k) => seenContent.add(k),
|
|
88
89
|
sessionId: () => selected,
|
|
90
|
+
// The stop-task button on a running task_wait, for a worker that has
|
|
91
|
+
// the route (its sidecar features).
|
|
92
|
+
taskStop: createTaskStopper({ sessionId: () => selected, features: () => current.workerFeatures }),
|
|
89
93
|
};
|
|
90
94
|
const turnDom = view === "stage"
|
|
91
95
|
? createStageView({ ...viewArgs, isNearBottom: () => atHistoryEnd(), dockEl: $("#dock"), centerEl: $("#center"), composerEl })
|
|
@@ -123,6 +123,17 @@ export function stopSession(id, opts = {}) {
|
|
|
123
123
|
return api(`/api/sessions/${id}/stop`, { method: "POST", body: JSON.stringify({}), ...opts });
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
// Kills a background task the session's conversation started (the
|
|
127
|
+
// stop-task button): 200 {status, stop_reason}; 409 it already ended, 503
|
|
128
|
+
// no live worker.
|
|
129
|
+
export function stopTask(id, taskId, opts = {}) {
|
|
130
|
+
return api(`/api/sessions/${encodeURIComponent(id)}/tasks/${encodeURIComponent(taskId)}/stop`, {
|
|
131
|
+
method: "POST",
|
|
132
|
+
body: JSON.stringify({}),
|
|
133
|
+
...opts,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
126
137
|
// Hands the session to a new worker on the newest chi installed: 200
|
|
127
138
|
// {status, from_version, version}, or 409 with the reason in words (detail).
|
|
128
139
|
export function restartSession(id, opts = {}) {
|
|
@@ -8,11 +8,13 @@
|
|
|
8
8
|
: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;--accent-tint:color-mix(in srgb,var(--accent) 7%,transparent);--narr:color-mix(in srgb,var(--accent) 38%,var(--text-muted));
|
|
9
9
|
--glass-bg:color-mix(in srgb,var(--bg-panel) 70%,transparent);--glass-border:rgba(255,255,255,.08);--glass-highlight:rgba(255,255,255,.05);--glass-shadow:0 8px 30px rgba(0,0,0,.35);
|
|
10
10
|
--diff-add-bg:rgba(63,185,80,.14);--diff-add-text:#aff5b4;--diff-del-bg:rgba(248,81,73,.14);--diff-del-text:#ffc1bc;
|
|
11
|
+
--code-text:#c9d1d9;--code-keyword:#ff7b72;--code-string:#a5d6ff;--code-comment:#8b949e;--code-constant:#79c0ff;--code-func:#d2a8ff;--code-type:#ffa657;
|
|
11
12
|
--card-hover-border:rgba(255,255,255,.14);--on-solid:#fff;--on-warn:#fff;--text-hover:#fff;--badge-bg:rgba(255,255,255,.08);--badge-ring:rgba(255,255,255,.18);--shadow-strong:rgba(0,0,0,.5);--backdrop:rgba(0,0,0,.85);--toast-shadow:0 6px 24px rgba(0,0,0,.25)}
|
|
12
13
|
/* Light palette: follows the system setting. :not([data-theme="dark"]) leaves room for a later in-page toggle (data-theme on <html>); there is none yet. Each palette sets color-scheme, so native radios, inputs and scrollbars follow it. */
|
|
13
14
|
@media (prefers-color-scheme:light){:root:not([data-theme="dark"]){color-scheme:light;--bg:#f5f6f8;--bg-panel:#ffffff;--bg-inset:#f1f3f6;--bg-raised:#e8ebf0;--bg-hover:#eceff4;--border:#dde1e7;--border-strong:#c3c9d2;--text:#1f2328;--text-bright:#0d1117;--text-2:#2f363d;--text-3:#475160;--text-muted:#57606a;--text-faint:#636c76;--accent:#0b5cd5;--accent-strong:#0550ae;--accent-bg:#e2ecfd;--accent-ring:rgba(11,92,213,.2);--primary:#1f6feb;--ok:#1a7f37;--ok-text:#116329;--ok-bg:#dafbe1;--warn:#9a6700;--warn-text:#7d4e00;--warn-bright:#875c00;--warn-bg:#fff6cc;--warn-border:#d4a72c;--danger:#cf222e;--danger-bg:#cf222e;--danger-text:#a40e26;--danger-bg-dim:#ffebe9;--think:#8250df;--think-text:#6639ba;--accent-border:#b8cef5;
|
|
14
15
|
--glass-border:rgba(31,35,40,.10);--glass-highlight:rgba(255,255,255,.7);--glass-shadow:0 8px 30px rgba(31,35,40,.10);
|
|
15
16
|
--diff-add-bg:rgba(26,127,55,.12);--diff-add-text:#116329;--diff-del-bg:rgba(207,34,46,.10);--diff-del-text:#82071e;
|
|
17
|
+
--code-text:#1f2328;--code-keyword:#cf222e;--code-string:#0a3069;--code-comment:#57606a;--code-constant:#0550ae;--code-func:#8250df;--code-type:#953800;
|
|
16
18
|
--card-hover-border:rgba(31,35,40,.18);--on-solid:#fff;--on-warn:#1f2328;--text-hover:#0d1117;--badge-bg:rgba(31,35,40,.06);--badge-ring:rgba(31,35,40,.15);--shadow-strong:rgba(31,35,40,.18);--backdrop:rgba(13,17,23,.85);--toast-shadow:0 6px 24px rgba(31,35,40,.14)}}
|
|
17
19
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
18
20
|
body{font-family: ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:radial-gradient(1100px 700px at 8% 0%,color-mix(in srgb,var(--accent) 13%,transparent),transparent 70%),radial-gradient(900px 700px at 100% 100%,color-mix(in srgb,var(--think) 9%,transparent),transparent 70%),var(--bg);background-attachment:fixed;color:var(--text);height:100vh;display:flex;flex-direction:column}
|
|
@@ -62,6 +64,18 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
|
|
|
62
64
|
.bubble.output.markdown code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.88em}
|
|
63
65
|
.bubble.output.markdown :not(pre)>code{background:var(--bg-inset);border:1px solid var(--border);border-radius:4px;padding:1px 5px;color:var(--text)}
|
|
64
66
|
.bubble.output.markdown pre{font-size:13px;line-height:1.45;border:1px solid var(--border)}
|
|
67
|
+
/* Code highlighting (markdown_renderer.rb): syntect scope classes, hl-prefixed, coloured from the palette's --code-* tokens. More classes win over fewer (a function name is also "entity"). */
|
|
68
|
+
.syntax-highlighting{color:var(--code-text)}
|
|
69
|
+
.syntax-highlighting .hl-keyword,.syntax-highlighting .hl-storage{color:var(--code-keyword)}
|
|
70
|
+
.syntax-highlighting .hl-string{color:var(--code-string)}
|
|
71
|
+
.syntax-highlighting .hl-string .hl-embedded{color:var(--code-text)}
|
|
72
|
+
.syntax-highlighting .hl-constant{color:var(--code-constant)}
|
|
73
|
+
.syntax-highlighting .hl-entity.hl-name.hl-function,.syntax-highlighting .hl-support.hl-function{color:var(--code-func)}
|
|
74
|
+
.syntax-highlighting .hl-entity.hl-name.hl-class,.syntax-highlighting .hl-entity.hl-name.hl-type,.syntax-highlighting .hl-entity.hl-other.hl-inherited-class,.syntax-highlighting .hl-support.hl-class,.syntax-highlighting .hl-support.hl-type,.syntax-highlighting .hl-meta.hl-diff.hl-range{color:var(--code-type)}
|
|
75
|
+
.syntax-highlighting .hl-comment{color:var(--code-comment);font-style:italic}
|
|
76
|
+
.syntax-highlighting .hl-markup.hl-inserted{color:var(--diff-add-text)}
|
|
77
|
+
.syntax-highlighting .hl-markup.hl-deleted{color:var(--diff-del-text)}
|
|
78
|
+
.syntax-highlighting .hl-invalid{color:var(--danger-text)}
|
|
65
79
|
.bubble.output.markdown h1,.bubble.output.markdown h2,.bubble.output.markdown h3,.bubble.output.markdown h4,.bubble.output.markdown h5,.bubble.output.markdown h6{font-weight:600;line-height:1.3;margin:18px 0 8px;color:var(--text-bright)}
|
|
66
80
|
.bubble.output.markdown h1{font-size:19px}
|
|
67
81
|
.bubble.output.markdown h2{font-size:17px}
|
|
@@ -122,6 +136,11 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
|
|
|
122
136
|
.activity-status.stopped{color:var(--warn-text);opacity:.8}
|
|
123
137
|
.activity-status.blocked{color:var(--danger-text)}
|
|
124
138
|
.activity-tool{color:var(--accent);font-weight:600}
|
|
139
|
+
/* The stop-task button on a running task_wait (task_stop.js): a small ghost
|
|
140
|
+
button in the danger colours, at the row's end and in the stage's now slot. */
|
|
141
|
+
button.task-stop{flex:none;margin-left:auto;padding:1px 8px;border:1px solid color-mix(in srgb,var(--danger) 45%,transparent);border-radius:6px;background:transparent;color:var(--danger-text);font-size:11.5px;line-height:18px;white-space:nowrap}
|
|
142
|
+
button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:none}
|
|
143
|
+
.ts-now button.task-stop{align-self:center}
|
|
125
144
|
.activity-params{color:var(--text-muted);word-break:break-all}
|
|
126
145
|
.activity-duration{color:var(--text-faint);font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap}
|
|
127
146
|
.activity-output{margin:4px 0 2px 60px;font-size:12px;color:var(--text-muted);white-space:pre-wrap;word-break:break-word;background:var(--bg);border-radius:6px;padding:6px 8px;max-height:160px;overflow:auto}
|
|
@@ -85,6 +85,12 @@ function withCommand(slot, row) {
|
|
|
85
85
|
return row.view?.command ? { ...slot, command: row.view.command } : slot;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
// A running task_wait's slot: the task it waits on and the row's key, for
|
|
89
|
+
// the stop-task button (task_stop.js).
|
|
90
|
+
function withTask(slot, row) {
|
|
91
|
+
return row.tool === "task_wait" && row.view?.task_id ? { ...slot, taskId: row.view.task_id, key: row.key } : slot;
|
|
92
|
+
}
|
|
93
|
+
|
|
88
94
|
// While the first step has no thinking and no tool rows the turn is plain:
|
|
89
95
|
// its text streams in the stage's answer slot, as the turn view's
|
|
90
96
|
// chrome-less block streams like a bubble.
|
|
@@ -106,7 +112,7 @@ const NO_ANSWER = "no answer";
|
|
|
106
112
|
// feed), else the newest thinking one (+thinking+, its ticker),
|
|
107
113
|
// else "…";
|
|
108
114
|
// tool the running call ({name, title, kind}, and command: its full
|
|
109
|
-
// command when it has one) or null;
|
|
115
|
+
// command when it has one; taskId and key: a task_wait's) or null;
|
|
110
116
|
// trail the last three finished calls, newest last (a file's title
|
|
111
117
|
// without its dir; command as for tool);
|
|
112
118
|
// ticks one per call: {kind, status, step (the gen's index)}.
|
|
@@ -137,7 +143,7 @@ export function liveSlots(turn, { narration = "", thinking = "", pending = false
|
|
|
137
143
|
phase,
|
|
138
144
|
step: ended ? gens.filter((g) => !g.answer || g.thinking || g.tools.length).length : gens.length,
|
|
139
145
|
headline,
|
|
140
|
-
tool: running ? withCommand({ name: toolName(running), title: rowTitle(running), kind: toolKind(running.tool) }, running) : null,
|
|
146
|
+
tool: running ? withTask(withCommand({ name: toolName(running), title: rowTitle(running), kind: toolKind(running.tool) }, running), running) : null,
|
|
141
147
|
trail,
|
|
142
148
|
ticks: rows.map(({ row, step }) => ({ kind: toolKind(row.tool), status: row.status, step })),
|
|
143
149
|
};
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { createTurnView } from "./turn_view.js";
|
|
14
14
|
import { flashOf, handOffDue, handOffOrder, headlineOf, inUseFrom, isPlain, liveSlots, placeFor } from "./stage_model.js";
|
|
15
15
|
import { formatDuration } from "./timing.js";
|
|
16
|
+
import { taskCommand, taskStopButton } from "./task_stop.js";
|
|
16
17
|
|
|
17
18
|
const COLLAPSE_KEY = "chi_stage_collapsed";
|
|
18
19
|
const WATCH_MS = 200;
|
|
@@ -45,7 +46,9 @@ function readCollapsed() {
|
|
|
45
46
|
// +historyNearBottom+: the history's own follow check (the hand-off scrolls
|
|
46
47
|
// the history only for a reader at its end). +dockEl+, +centerEl+ and
|
|
47
48
|
// +composerEl+ size the stage's cap.
|
|
48
|
-
|
|
49
|
+
// +taskStop+: the page's stopper (task_stop.js), for the stop-task button
|
|
50
|
+
// on a running task_wait.
|
|
51
|
+
export function createStageView({ historyEl, appendToHistory, isNearBottom: historyNearBottom, sawText, sessionId, dockEl, centerEl, composerEl, taskStop = null }) {
|
|
49
52
|
// The dock becomes a clear column: the stage's card, then the composer's
|
|
50
53
|
// (its rows moved into .dock-card, which wears the glass and the focus ring).
|
|
51
54
|
document.body.classList.add("stage-view");
|
|
@@ -131,6 +134,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
131
134
|
},
|
|
132
135
|
useHold: false,
|
|
133
136
|
newestFirst: true,
|
|
137
|
+
taskStop,
|
|
134
138
|
onChange: () => scheduleRefresh(),
|
|
135
139
|
onThinkingLine: (text) => {
|
|
136
140
|
const { turn } = view.current();
|
|
@@ -223,7 +227,11 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
223
227
|
|
|
224
228
|
function drawNow(tool, gen) {
|
|
225
229
|
const quiet = gen?.text && !gen.textDone ? "writing…" : "thinking…";
|
|
226
|
-
|
|
230
|
+
// A task_wait's stop button, as the row's (task_stop.js): in the key
|
|
231
|
+
// with its in-flight state, so a redraw keeps a clicked one disabled.
|
|
232
|
+
const stopId = tool?.taskId && taskStop?.target({ tool: "task_wait", status: "running", view: { task_id: tool.taskId } });
|
|
233
|
+
const stopKey = stopId ? `|stop:${tool.key}:${taskStop.busy(stopId)}` : "";
|
|
234
|
+
const key = tool ? `run|${tool.name}|${tool.title}|${tool.command || ""}${stopKey}` : `quiet|${quiet}`;
|
|
227
235
|
if (shown.now === key) return;
|
|
228
236
|
shown.now = key;
|
|
229
237
|
now.replaceChildren();
|
|
@@ -236,6 +244,10 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
236
244
|
title.textContent = tool.title;
|
|
237
245
|
// The full command when the call has one (the title is its first step +N).
|
|
238
246
|
title.title = tool.command || tool.title;
|
|
247
|
+
if (stopId) {
|
|
248
|
+
const command = () => taskCommand(view.current().turn?.gens, stopId);
|
|
249
|
+
now.appendChild(taskStopButton(taskStop, stopId, { command, onChange: scheduleRefresh }));
|
|
250
|
+
}
|
|
239
251
|
} else {
|
|
240
252
|
const word = node("span", "ts-title quiet", now);
|
|
241
253
|
word.textContent = quiet;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// The stop-task button on a running task_wait (POST …/tasks/:task_id/stop,
|
|
2
|
+
// Bridge#handle_task_stop): the worker kills the task as the user, the wait
|
|
3
|
+
// returns, and the turn goes on. Which row gets one, what the confirm says,
|
|
4
|
+
// and the stops in flight. The in-flight set lives here, not in the DOM:
|
|
5
|
+
// the stage rebuilds its "now" slot, and a redraw must keep a clicked
|
|
6
|
+
// button disabled. Pure but for the injected calls, so `node --test`
|
|
7
|
+
// covers it; turn_view.js and stage_view.js draw the button.
|
|
8
|
+
import { stopTask as postStopTask } from "./data.js";
|
|
9
|
+
|
|
10
|
+
// What a worker that has the route names in its sidecar features.
|
|
11
|
+
export const TASK_STOP_FEATURE = "task_stop";
|
|
12
|
+
const COMMAND_SHOWN = 120;
|
|
13
|
+
|
|
14
|
+
// The task a row's button would stop: a running task_wait's (its server
|
|
15
|
+
// view's task_id), on a worker that has the route; else null.
|
|
16
|
+
export function stopTarget(row, features) {
|
|
17
|
+
if (!row || row.tool !== "task_wait" || row.status !== "running") return null;
|
|
18
|
+
if (!Array.isArray(features) || !features.includes(TASK_STOP_FEATURE)) return null;
|
|
19
|
+
return row.view?.task_id || null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The command a task_create of this turn started +taskId+ with (its row's
|
|
23
|
+
// view), or null: a task from an earlier turn has no row here.
|
|
24
|
+
export function taskCommand(gens, taskId) {
|
|
25
|
+
for (const gen of gens || []) {
|
|
26
|
+
for (const row of gen.tools || []) {
|
|
27
|
+
if (row.tool !== "task_create") continue;
|
|
28
|
+
const id = /(?:^|\n)task_id: (\S+)/.exec(row.output || "")?.[1];
|
|
29
|
+
if (id === taskId) return row.view?.command || null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function confirmText(taskId, command) {
|
|
36
|
+
const line = String(command || "").replace(/\s+/g, " ").trim();
|
|
37
|
+
const what = !line ? taskId : line.length > COMMAND_SHOWN ? `${line.slice(0, COMMAND_SHOWN - 1)}…` : line;
|
|
38
|
+
return `Stop task ${what}?`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// A failed stop said nothing about: 409 (the task ended meanwhile), 503
|
|
42
|
+
// (no live worker: it can't be stopped now).
|
|
43
|
+
export function quietFailure(error) {
|
|
44
|
+
return error?.status === 409 || error?.status === 503;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// +sessionId+ and +features+ are read at each call (the selected session,
|
|
48
|
+
// its worker's sidecar features).
|
|
49
|
+
export function createTaskStopper({
|
|
50
|
+
sessionId,
|
|
51
|
+
features,
|
|
52
|
+
stopTask = postStopTask,
|
|
53
|
+
confirm = (text) => globalThis.confirm(text),
|
|
54
|
+
alert = (text) => globalThis.alert(text),
|
|
55
|
+
}) {
|
|
56
|
+
const inFlight = new Set();
|
|
57
|
+
const keyOf = (taskId) => `${sessionId()}|${taskId}`;
|
|
58
|
+
return {
|
|
59
|
+
target: (row) => stopTarget(row, features()),
|
|
60
|
+
busy: (taskId) => inFlight.has(keyOf(taskId)),
|
|
61
|
+
// true once the worker stopped it. A stop that went through stays in
|
|
62
|
+
// flight: the button goes with its row's completion a moment later.
|
|
63
|
+
async stop(taskId, command = null) {
|
|
64
|
+
const id = sessionId();
|
|
65
|
+
const key = keyOf(taskId);
|
|
66
|
+
if (!id || inFlight.has(key) || !confirm(confirmText(taskId, command))) return false;
|
|
67
|
+
inFlight.add(key);
|
|
68
|
+
try {
|
|
69
|
+
await stopTask(id, taskId);
|
|
70
|
+
return true;
|
|
71
|
+
} catch (e) {
|
|
72
|
+
inFlight.delete(key);
|
|
73
|
+
if (!quietFailure(e)) alert(e.message);
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The button itself (the one DOM part): a click asks, posts, and keeps it
|
|
81
|
+
// disabled while the stop is in flight. +command+ is read at the click;
|
|
82
|
+
// +onChange+ lets a view redraw (the stage's slot).
|
|
83
|
+
export function taskStopButton(stopper, taskId, { command = () => null, onChange = () => {} } = {}) {
|
|
84
|
+
const btn = document.createElement("button");
|
|
85
|
+
btn.type = "button";
|
|
86
|
+
btn.className = "ghost task-stop";
|
|
87
|
+
btn.textContent = "stop task";
|
|
88
|
+
btn.title = "Kill this background task; the model is told you stopped it";
|
|
89
|
+
btn.disabled = stopper.busy(taskId);
|
|
90
|
+
btn.addEventListener("click", (e) => {
|
|
91
|
+
e.stopPropagation();
|
|
92
|
+
const pending = stopper.stop(taskId, command());
|
|
93
|
+
btn.disabled = stopper.busy(taskId);
|
|
94
|
+
onChange();
|
|
95
|
+
pending.then(() => {
|
|
96
|
+
btn.disabled = stopper.busy(taskId);
|
|
97
|
+
onChange();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
return btn;
|
|
101
|
+
}
|
|
@@ -23,6 +23,7 @@ import { createSentenceFeed } from "./sentences.js";
|
|
|
23
23
|
import { createTicker } from "./thinking_ticker.js";
|
|
24
24
|
import * as model from "./turn_model.js";
|
|
25
25
|
import { createHold } from "./hold.js";
|
|
26
|
+
import { taskCommand, taskStopButton } from "./task_stop.js";
|
|
26
27
|
|
|
27
28
|
// A row's status (live, or a saved tool record's on reload): running,
|
|
28
29
|
// error, stopped (a task_wait the user's Stop ended), blocked (a guardrail
|
|
@@ -90,7 +91,9 @@ function fillActivityRow(el, row, sessionId) {
|
|
|
90
91
|
// (chronological() restores the order);
|
|
91
92
|
// onChange() after anything in the turn changed (the stage redraws
|
|
92
93
|
// its slots from current());
|
|
93
|
-
// onThinkingLine(sentence) the live step's ticker showed a new line
|
|
94
|
+
// onThinkingLine(sentence) the live step's ticker showed a new line;
|
|
95
|
+
// taskStop the page's stopper (task_stop.js): a running task_wait
|
|
96
|
+
// row gets its stop-task button (none: no button).
|
|
94
97
|
export function createTurnView({
|
|
95
98
|
historyEl, appendToHistory, isNearBottom, sawText, sessionId,
|
|
96
99
|
blockHost = appendToHistory,
|
|
@@ -100,6 +103,7 @@ export function createTurnView({
|
|
|
100
103
|
newestFirst = false,
|
|
101
104
|
onChange = () => {},
|
|
102
105
|
onThinkingLine = () => {},
|
|
106
|
+
taskStop = null,
|
|
103
107
|
}) {
|
|
104
108
|
let turn = null;
|
|
105
109
|
let blockEl = null;
|
|
@@ -407,14 +411,33 @@ export function createTurnView({
|
|
|
407
411
|
const stick = isNearBottom();
|
|
408
412
|
body.appendChild(el);
|
|
409
413
|
fillActivityRow(el, r.row, sessionId());
|
|
414
|
+
syncTaskStop(el, r.row);
|
|
410
415
|
if (stick) follow();
|
|
411
416
|
} else {
|
|
412
417
|
fillActivityRow(el, r.row, sessionId());
|
|
418
|
+
syncTaskStop(el, r.row);
|
|
413
419
|
}
|
|
414
420
|
updateBlockSummary();
|
|
415
421
|
onChange();
|
|
416
422
|
}
|
|
417
423
|
|
|
424
|
+
// The stop-task button after a running task_wait's params; gone once the
|
|
425
|
+
// row completes.
|
|
426
|
+
function syncTaskStop(el, row) {
|
|
427
|
+
const taskId = taskStop?.target(row);
|
|
428
|
+
const btn = el.querySelector(":scope > .task-stop");
|
|
429
|
+
if (!taskId) {
|
|
430
|
+
btn?.remove();
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
if (btn) {
|
|
434
|
+
btn.disabled = taskStop.busy(taskId);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
const command = () => taskCommand(turn?.gens, taskId);
|
|
438
|
+
el.querySelector(".activity-params").after(taskStopButton(taskStop, taskId, { command }));
|
|
439
|
+
}
|
|
440
|
+
|
|
418
441
|
// A hook's line (hook_notice) as a row of the current step, above the row
|
|
419
442
|
// of the call it judged; false with no running turn (app.js shows the
|
|
420
443
|
// bubble then). data.line is a whole row of chi's own (an empty-answer
|
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.27.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dmitry Dedov
|
|
@@ -471,6 +471,7 @@ files:
|
|
|
471
471
|
- lib/samagotchi/web/public/stage_view.js
|
|
472
472
|
- lib/samagotchi/web/public/strip.js
|
|
473
473
|
- lib/samagotchi/web/public/tally.js
|
|
474
|
+
- lib/samagotchi/web/public/task_stop.js
|
|
474
475
|
- lib/samagotchi/web/public/thinking_ticker.js
|
|
475
476
|
- lib/samagotchi/web/public/timing.js
|
|
476
477
|
- lib/samagotchi/web/public/turn_events.js
|