samagotchi 0.32.0 → 0.33.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 +29 -1
- data/docs/context.md +3 -1
- data/docs/hooks.md +6 -3
- data/docs/plugins.md +2 -1
- data/docs/releasing.md +2 -1
- data/lib/samagotchi/answer_command.rb +2 -1
- data/lib/samagotchi/archive_store.rb +1 -15
- data/lib/samagotchi/bridge.rb +8 -3
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_reports.rb +31 -10
- data/lib/samagotchi/client_id.rb +52 -0
- data/lib/samagotchi/context_absorber.rb +2 -1
- data/lib/samagotchi/context_fetch.rb +14 -45
- data/lib/samagotchi/context_sources.rb +20 -10
- data/lib/samagotchi/engine.rb +10 -7
- data/lib/samagotchi/guardrails/parent_approvals.rb +0 -2
- data/lib/samagotchi/memory_bundle/builder.rb +22 -1
- data/lib/samagotchi/memory_bundle/manifest.rb +3 -1
- data/lib/samagotchi/plugin/sessions.rb +2 -1
- data/lib/samagotchi/process_group.rb +160 -0
- data/lib/samagotchi/prompt_warmup.rb +20 -1
- data/lib/samagotchi/question_desk.rb +2 -1
- data/lib/samagotchi/relay_verifier.rb +2 -4
- data/lib/samagotchi/reply_wait.rb +9 -4
- data/lib/samagotchi/send_command.rb +2 -1
- data/lib/samagotchi/session_manager.rb +2 -4
- data/lib/samagotchi/steer.rb +27 -19
- data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -4
- data/lib/samagotchi/terminal_ui/formatting.rb +21 -12
- data/lib/samagotchi/terminal_ui.rb +2 -1
- data/lib/samagotchi/tools/delegate.rb +2 -2
- data/lib/samagotchi/tools/execute.rb +10 -81
- data/lib/samagotchi/tools/task_runtime.rb +25 -38
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +2 -1
- data/lib/samagotchi/web/public/app.js +19 -20
- data/lib/samagotchi/web/public/data.js +3 -0
- data/lib/samagotchi/web/public/question_card.js +3 -4
- data/lib/samagotchi/web/public/shown_texts.js +13 -0
- data/lib/samagotchi/web/public/stable_html.js +18 -0
- data/lib/samagotchi/web/public/turn_events.js +9 -2
- data/lib/samagotchi/web/public/turn_view.js +1 -1
- data/lib/samagotchi/worker.rb +33 -11
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ca8acdcb161f8d093ab1c860913dd3a1e826083bcfff0165039847e38a26c5ad
|
|
4
|
+
data.tar.gz: 5c286ada590d84b793e95d9f2dec051132462469aace12c0faf1da0f1c4e3fdf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 394a942740165c9ea457b3a3043a070ef9338a897c6eb41c387b1101e8426c8e21c938757868d87493216324d84441165a82601b796801be90feef5c8e6c7091
|
|
7
|
+
data.tar.gz: 29d24ccdb0bd41d027ccdaf4b8c6e0a191d292b636fc3af1e27e994f30b1331192f59d3bf6aa5d858e78ec5d84d32d8760f2df03c8dace2feaad1ac87783c1c6
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,33 @@ and commands may change between minor versions. How releases are made:
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.33.0] - 2026-10-06
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- Input from a client chi doesn't know (a script calling the web API with its own id, say) is no longer taken
|
|
16
|
+
for the user's: the model reads it as automatic input, not your message, and it never cuts in on a thinking
|
|
17
|
+
model. The TUI and the web label a prompt by its sender: "chi send", "chi answer", "plugin", or "automatic";
|
|
18
|
+
"user" is left for your own lines.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- `task_stop` no longer signals a process group chi didn't start: a missing or reused pid in a task record could
|
|
23
|
+
stop chi or other processes.
|
|
24
|
+
- A delegate child that crashes still wakes its idle parent.
|
|
25
|
+
- `chi send <id> --wait` to a stopped session with attached context prints the answer instead of "no answer".
|
|
26
|
+
- `chi bundle build` keeps a bundle's `scripts:` and `context_providers:`, so a rebuilt bundle still attaches its URLs.
|
|
27
|
+
- A context source's wake is no longer lost when a later update that doesn't ask to wake comes in before the
|
|
28
|
+
session reads it.
|
|
29
|
+
- A failed wake turn leaves a plain note in the conversation; a reload no longer draws an empty turn for it.
|
|
30
|
+
- Web: the info bar no longer swallows a click on "copy chi --attach" or Restart while a turn runs, and its
|
|
31
|
+
tooltips stay up.
|
|
32
|
+
- Web: an image the server refuses shows why; a 409 that isn't "open in a chi REPL" no longer switches the page to
|
|
33
|
+
the REPL notice.
|
|
34
|
+
- Web: an answer equal to an earlier turn's ("Done." twice) is shown.
|
|
35
|
+
|
|
36
|
+
Update with `chi update`.
|
|
37
|
+
|
|
11
38
|
## [0.32.0] - 2026-10-06
|
|
12
39
|
|
|
13
40
|
### Added
|
|
@@ -1636,7 +1663,8 @@ and long-lived sessions.
|
|
|
1636
1663
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
1637
1664
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
1638
1665
|
|
|
1639
|
-
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.
|
|
1666
|
+
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.33.0...HEAD
|
|
1667
|
+
[0.33.0]: https://github.com/dm1try/samagotchi/compare/v0.32.0...v0.33.0
|
|
1640
1668
|
[0.32.0]: https://github.com/dm1try/samagotchi/compare/v0.31.0...v0.32.0
|
|
1641
1669
|
[0.31.0]: https://github.com/dm1try/samagotchi/compare/v0.30.0...v0.31.0
|
|
1642
1670
|
[0.30.0]: https://github.com/dm1try/samagotchi/compare/v0.29.0...v0.30.0
|
data/docs/context.md
CHANGED
|
@@ -135,7 +135,9 @@ attaches a URL; the web never adds a command.
|
|
|
135
135
|
A change can start a turn by itself, so the agent tells you about it while
|
|
136
136
|
you're away from the terminal. All of these must hold:
|
|
137
137
|
|
|
138
|
-
- the source asked (`"wake": true`; a plain-text source never does)
|
|
138
|
+
- the source asked (`"wake": true`; a plain-text source never does), in
|
|
139
|
+
any change since the session last heard of it: a later change that doesn't
|
|
140
|
+
ask doesn't take the wake back;
|
|
139
141
|
- `context.wake` is on (default `true`);
|
|
140
142
|
- the session's worker is up and idle: no turn running, nothing queued, no
|
|
141
143
|
question or continue offer waiting, not just started;
|
data/docs/hooks.md
CHANGED
|
@@ -122,9 +122,12 @@ Its `source` is the hook's bundle (a config or turn hook's label otherwise).
|
|
|
122
122
|
Lines typed into a running turn are saved as `{role: "user", kind: "input",
|
|
123
123
|
content: "…"}`: part of that turn, not a turn of their own. They carry a
|
|
124
124
|
`source` when they are not the user's own: `chi_send` (`chi send -m`),
|
|
125
|
-
`parent_agent` (a delegate's follow-up)
|
|
126
|
-
|
|
127
|
-
|
|
125
|
+
`parent_agent` (a delegate's follow-up), `plugin_send` (`ctx.sessions.send`),
|
|
126
|
+
or `automatic:<client id>` for a client id chi doesn't know (an unknown sender
|
|
127
|
+
is never taken for the user). The model reads them with the same kind of
|
|
128
|
+
header (`[Steer from the user, …]`, `[Steer sent with chi send, …]`, and so
|
|
129
|
+
on; `[Automatic input from <client id>, sent mid-task; not your user's
|
|
130
|
+
message. …]` for an unknown one).
|
|
128
131
|
|
|
129
132
|
Timing: a notice from `:after_turn` or `:session_end` shows after the turn's
|
|
130
133
|
end line. A question from `:before_tool_call` shows **before** the tool
|
data/docs/plugins.md
CHANGED
|
@@ -1181,7 +1181,8 @@ gem.
|
|
|
1181
1181
|
- `chi bundle diff <name> [plugin.rb]`: shows the installed base and the
|
|
1182
1182
|
file on disk.
|
|
1183
1183
|
- `chi bundle build --name <name>`: puts the installed plugin (and
|
|
1184
|
-
`requires_chi`) into the built
|
|
1184
|
+
`requires_chi`, its `scripts/` and `context_providers:`) into the built
|
|
1185
|
+
bundle.
|
|
1185
1186
|
- `chi bundle uninstall <name>`: removes the plugin with the bundle.
|
|
1186
1187
|
|
|
1187
1188
|
## Not yet
|
data/docs/releasing.md
CHANGED
|
@@ -49,7 +49,8 @@ as the GitHub release body.
|
|
|
49
49
|
| Task | What it does |
|
|
50
50
|
| --- | --- |
|
|
51
51
|
| `rake bundles:sha` | Recomputes the sha256 lines of every shipped bundle manifest (`files:`, `hooks:`, `plugin:`). |
|
|
52
|
-
| `rake bundles:check` | Sha lines match; a bundle changed since the last `v*` tag has a higher version (skipped, with a note, before the first tag). Warns (doesn't fail) when such a bundle's `requires_chi` still admits the tagged chi: raise it if the change uses anything new. |
|
|
52
|
+
| `rake bundles:check` | Sha lines match; a bundle changed since the last `v*` tag has a higher version (skipped, with a note, before the first tag). Warns (doesn't fail) when such a bundle's `requires_chi` still admits the tagged chi: raise it if the change uses anything new. The release workflow and `release:check` run it in full. |
|
|
53
|
+
| `rake 'bundles:check[ci]'` | The CI workflow's (every push and PR): the same, except the version check leaves out the system bundle, which is changed and unbumped between releases (`release:bump` moves its version with VERSION). A task argument, not `CI=1`: GitHub sets `CI` in the release workflow too. |
|
|
53
54
|
| `rake release:draft_changelog` | A draft Unreleased section from the commit subjects since the last tag, grouped. Stdout only. |
|
|
54
55
|
| `rake release:bump[X.Y.Z]` | VERSION, the system manifest, Gemfile.lock, CHANGELOG. No commit. |
|
|
55
56
|
| `rake release:check` | Clean tracked tree, VERSION == system bundle, a CHANGELOG section, `bundles:check`, `rake lint` (RuboCop), rspec + npm test, `gem build`, then a clean install into a temp GEM_HOME that runs `chi --version`, `chi self` and `chi bundle list` with a temp HOME. Needs the network (gem dependencies). |
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "client_id"
|
|
3
4
|
require_relative "session"
|
|
4
5
|
require_relative "session_manager"
|
|
5
6
|
require_relative "bridge_client"
|
|
@@ -189,7 +190,7 @@ module Samagotchi
|
|
|
189
190
|
client.dismiss_question(id: qid)
|
|
190
191
|
else
|
|
191
192
|
client.answer(id: qid, selected: selected, freeform: options[:text],
|
|
192
|
-
client_id:
|
|
193
|
+
client_id: ClientId::CLI_ANSWER)
|
|
193
194
|
end
|
|
194
195
|
break unless reply.status == 408
|
|
195
196
|
end
|
|
@@ -17,12 +17,6 @@ module Samagotchi
|
|
|
17
17
|
# file from memory (and bumps updated_at) at every turn.
|
|
18
18
|
module ArchiveStore
|
|
19
19
|
FILE = "archived"
|
|
20
|
-
# Input a human typed: a web tab, a chi TUI, `chi send`, and nil (a
|
|
21
|
-
# worker's initial prompt; a web client may send none). Delegates,
|
|
22
|
-
# plugins and reminders are not (an allowlist, so a new automatic
|
|
23
|
-
# origin stays out).
|
|
24
|
-
USER_CLIENT_PREFIXES = %w[web: tui:].freeze
|
|
25
|
-
USER_CLIENT_IDS = ["cli:send"].freeze
|
|
26
20
|
|
|
27
21
|
# @return [Hash, nil] the marker (string keys), nil when none or unreadable
|
|
28
22
|
def self.read(session_dir)
|
|
@@ -57,15 +51,7 @@ module Samagotchi
|
|
|
57
51
|
write(session_id, { "unarchived_at" => Time.now.iso8601(3) }, state_dir: state_dir)
|
|
58
52
|
end
|
|
59
53
|
|
|
60
|
-
#
|
|
61
|
-
def self.user_input?(client_id)
|
|
62
|
-
return true if client_id.nil?
|
|
63
|
-
|
|
64
|
-
id = client_id.to_s
|
|
65
|
-
USER_CLIENT_IDS.include?(id) || USER_CLIENT_PREFIXES.any? { |prefix| id.start_with?(prefix) }
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
# A human's input came into the session: it is back in the lists. Its
|
|
54
|
+
# A human's input (ClientId.human?) came into the session: it is back in the lists. Its
|
|
69
55
|
# children stay archived. Never raises.
|
|
70
56
|
# @return [Boolean] whether it was archived
|
|
71
57
|
def self.user_input(session_id, state_dir:)
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -55,6 +55,8 @@ module Samagotchi
|
|
|
55
55
|
CANCEL_REASONS = %w[manual user ctrl_c].freeze
|
|
56
56
|
# How long #stop waits for requests it is answering (not open streams).
|
|
57
57
|
REQUEST_GRACE_SECONDS = 1.0
|
|
58
|
+
# How long #stop waits for each killed RelayWatcher thread to end.
|
|
59
|
+
RELAY_WATCHER_JOIN_SECONDS = 1.0
|
|
58
60
|
# The largest request body read (images travel as refs, never bytes).
|
|
59
61
|
MAX_BODY_BYTES = 1_000_000
|
|
60
62
|
# A request's deadline (see #handle_post_turn) that isn't epoch seconds.
|
|
@@ -198,7 +200,9 @@ module Samagotchi
|
|
|
198
200
|
await_answers(REQUEST_GRACE_SECONDS)
|
|
199
201
|
@connection_threads.each { |t| t.kill rescue nil }
|
|
200
202
|
@connection_threads.clear
|
|
201
|
-
|
|
203
|
+
# Its relay watchers (RelayWatcher) end with it.
|
|
204
|
+
watchers = @mutex.synchronize { (@relay_watchers || []).tap { @relay_watchers = [] } }
|
|
205
|
+
watchers.each { |thread| thread.kill.join(RELAY_WATCHER_JOIN_SECONDS) }
|
|
202
206
|
@accept_thread&.join(2)
|
|
203
207
|
remove_sidecar
|
|
204
208
|
nil
|
|
@@ -839,8 +843,9 @@ module Samagotchi
|
|
|
839
843
|
return [{}, 500, { error: "enqueue_failed", detail: "could not write turn input" }] unless enqueued
|
|
840
844
|
|
|
841
845
|
# Queued (outside the event lock): a running generation that has only
|
|
842
|
-
# been thinking for long is cut for it, unless a plugin
|
|
843
|
-
# image input never merges mid-turn, so
|
|
846
|
+
# been thinking for long is cut for it, unless a plugin or a client
|
|
847
|
+
# chi doesn't know sent it. An image input never merges mid-turn, so
|
|
848
|
+
# it never cuts.
|
|
844
849
|
@engine.cut_for_steer(Steer.source_for_client(client_id), epoch: epoch) if images.empty?
|
|
845
850
|
[{}, 202, { status: "accepted", enqueued_id: enqueued_id, session_id: @session_id }]
|
|
846
851
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
+
require_relative "client_id"
|
|
4
5
|
require_relative "session"
|
|
5
6
|
require_relative "session_inbox"
|
|
6
7
|
require_relative "reply_wait"
|
|
@@ -29,6 +30,13 @@ module Samagotchi
|
|
|
29
30
|
# Turns an idle parent runs for reports in a row with no human input.
|
|
30
31
|
MAX_WAKES_KEY = "session.max_wakes"
|
|
31
32
|
MAX_WAKES_DEFAULT = 10
|
|
33
|
+
# How long a leaving worker waits for the parent wakes it started
|
|
34
|
+
# (#await_wakes): a wake only spawns a worker, well under a second.
|
|
35
|
+
WAKE_JOIN_SECONDS = 5
|
|
36
|
+
|
|
37
|
+
# The wake threads #ring started in this process, until #await_wakes.
|
|
38
|
+
@wakes = []
|
|
39
|
+
@wakes_mutex = Mutex.new
|
|
32
40
|
|
|
33
41
|
module_function
|
|
34
42
|
|
|
@@ -70,15 +78,29 @@ module Samagotchi
|
|
|
70
78
|
def wake_parent(parent_id, state_dir:, wake:)
|
|
71
79
|
return if SessionManager.session_owner(parent_id, state_dir: state_dir)
|
|
72
80
|
|
|
73
|
-
if wake
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
Log.warn(:worker, "delegate_wake_failed", parent: parent_id[0, 8], error: e.class.name, msg: e.message)
|
|
80
|
-
end
|
|
81
|
+
return wake.call(parent_id) if wake
|
|
82
|
+
|
|
83
|
+
thread = Thread.new do
|
|
84
|
+
SessionManager.wake_for_report(parent_id, state_dir: state_dir)
|
|
85
|
+
rescue StandardError => e
|
|
86
|
+
Log.warn(:worker, "delegate_wake_failed", parent: parent_id[0, 8], error: e.class.name, msg: e.message)
|
|
81
87
|
end
|
|
88
|
+
@wakes_mutex.synchronize { @wakes = @wakes.select(&:alive?) << thread }
|
|
89
|
+
thread
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Waits up to +timeout+ for the parent wakes this process started: a
|
|
93
|
+
# worker's teardown, since its process exits right after (a crash's
|
|
94
|
+
# ring comes just before it) and an exit kills a wake midway, before
|
|
95
|
+
# it spawned the parent's worker.
|
|
96
|
+
# @return [Integer] the wakes still running after it
|
|
97
|
+
def await_wakes(timeout = WAKE_JOIN_SECONDS)
|
|
98
|
+
threads = @wakes_mutex.synchronize { @wakes.tap { @wakes = [] } }
|
|
99
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
100
|
+
threads.each { |thread| thread.join([deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max) }
|
|
101
|
+
left = threads.count(&:alive?)
|
|
102
|
+
Log.warn(:worker, "delegate_wakes_left", count: left) if left.positive?
|
|
103
|
+
left
|
|
82
104
|
end
|
|
83
105
|
end
|
|
84
106
|
|
|
@@ -93,7 +115,6 @@ module Samagotchi
|
|
|
93
115
|
# failed: the next turn reads the same rings again.
|
|
94
116
|
class ChildReports
|
|
95
117
|
SOURCE = Steer::DELEGATE_REPORT
|
|
96
|
-
CLIENT_PREFIX = Steer::CHILD_CLIENT_PREFIX
|
|
97
118
|
|
|
98
119
|
# A report for the model: the text delegate_result would return
|
|
99
120
|
# (DelegateWait.finish, its report variant), the cursor it moves the child to,
|
|
@@ -101,7 +122,7 @@ module Samagotchi
|
|
|
101
122
|
# itself, with delegate_result, leaves it alone).
|
|
102
123
|
Report = Data.define(:child_id, :text, :before, :after, :rings) do
|
|
103
124
|
def line(mark: nil) = Steer::Line.new(text: text, source: ChildReports::SOURCE, mark: mark)
|
|
104
|
-
def origin = { client_id: "#{
|
|
125
|
+
def origin = { client_id: "#{ClientId::CHILD_PREFIX}#{child_id[0, 8]}" }
|
|
105
126
|
end
|
|
106
127
|
|
|
107
128
|
def initialize(session_id:, state_dir:)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# Who sent a turn, a line or an answer: its client_id, as a turn's origin,
|
|
5
|
+
# an input file and the events carry it. The one table of the ids chi
|
|
6
|
+
# makes, and of which of them are a human's input (#human?). No requires,
|
|
7
|
+
# so any file can use it.
|
|
8
|
+
module ClientId
|
|
9
|
+
# A web tab (web:<random>, per tab); web:restart, the page's Restart.
|
|
10
|
+
WEB_PREFIX = "web:"
|
|
11
|
+
WEB_RESTART = "web:restart"
|
|
12
|
+
# An attached chi TUI: tui:<pid>.
|
|
13
|
+
TUI_PREFIX = "tui:"
|
|
14
|
+
# chi send's turns and commands.
|
|
15
|
+
CLI_SEND = "cli:send"
|
|
16
|
+
# chi answer's answers (a parent agent's, Guardrails::ParentApprovals).
|
|
17
|
+
CLI_ANSWER = "cli:answer"
|
|
18
|
+
# chi restart.
|
|
19
|
+
CLI_RESTART = "cli:restart"
|
|
20
|
+
# A delegating parent's task or follow-up: delegate:<parent8>.
|
|
21
|
+
DELEGATE_PREFIX = "delegate:"
|
|
22
|
+
# A delegate child's report (ChildReports): child:<child8>.
|
|
23
|
+
CHILD_PREFIX = "child:"
|
|
24
|
+
# A turn chi ran because an attached context source changed:
|
|
25
|
+
# context:<name> (Worker's context wake).
|
|
26
|
+
CONTEXT_PREFIX = "context:"
|
|
27
|
+
# chi's own turns; system:reminder, the turn queued when reminders are due.
|
|
28
|
+
SYSTEM_PREFIX = "system:"
|
|
29
|
+
REMINDER = "system:reminder"
|
|
30
|
+
# A plugin's message (Plugin::Sessions#send).
|
|
31
|
+
PLUGIN = "plugin"
|
|
32
|
+
# A relayed answer (RelayVerifier): relay:<parent8>.
|
|
33
|
+
RELAY_PREFIX = "relay:"
|
|
34
|
+
|
|
35
|
+
# Input a human typed: a web tab, a chi TUI, chi send, and nil (a
|
|
36
|
+
# worker's initial prompt; a web client may send none). An allowlist:
|
|
37
|
+
# delegates, plugins, reminders, context changes and any id chi doesn't
|
|
38
|
+
# know are not.
|
|
39
|
+
HUMAN_PREFIXES = [WEB_PREFIX, TUI_PREFIX].freeze
|
|
40
|
+
HUMAN_IDS = [CLI_SEND].freeze
|
|
41
|
+
|
|
42
|
+
module_function
|
|
43
|
+
|
|
44
|
+
# Whether +client_id+ (a turn's or a line's origin) is a human's input.
|
|
45
|
+
def human?(client_id)
|
|
46
|
+
return true if client_id.nil?
|
|
47
|
+
|
|
48
|
+
id = client_id.to_s
|
|
49
|
+
HUMAN_IDS.include?(id) || HUMAN_PREFIXES.any? { |prefix| id.start_with?(prefix) }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -118,7 +118,8 @@ module Samagotchi
|
|
|
118
118
|
if sub.seen != snapshot.revision
|
|
119
119
|
args = { name: name, hint: hint, summary: snapshot.summary, changes: snapshot.serial - sub.seen_serial }
|
|
120
120
|
found = delivery(name, text_note_id(name, snapshot), self.class.updated_text(**args), now, seen(sub, snapshot))
|
|
121
|
-
|
|
121
|
+
# Any revision since the one the session last saw may have asked.
|
|
122
|
+
return found unless snapshot.wake_serial.to_i > sub.seen_serial
|
|
122
123
|
|
|
123
124
|
return found.with(wake_note: found.note.merge(text: self.class.updated_text(**args, wake: true)))
|
|
124
125
|
end
|
|
@@ -4,6 +4,7 @@ require "fileutils"
|
|
|
4
4
|
require_relative "context_sources"
|
|
5
5
|
require_relative "context_providers"
|
|
6
6
|
require_relative "log"
|
|
7
|
+
require_relative "process_group"
|
|
7
8
|
|
|
8
9
|
module Samagotchi
|
|
9
10
|
# Runs an attached context source's command once and records the result
|
|
@@ -117,47 +118,26 @@ module Samagotchi
|
|
|
117
118
|
|
|
118
119
|
out_r, out_w = IO.pipe
|
|
119
120
|
err_r, err_w = IO.pipe
|
|
120
|
-
pid =
|
|
121
|
+
pid = ProcessGroup.spawn(env, "sh", "-c", cmd, chdir: cwd, in: File::NULL, out: out_w, err: err_w)
|
|
121
122
|
out_w.close
|
|
122
123
|
err_w.close
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
out_reader = Thread.new do
|
|
126
|
-
loop do
|
|
127
|
-
chunk = out_r.readpartial(READ_CHUNK)
|
|
128
|
-
if output.bytesize + chunk.bytesize > ContextSources::TEXT_MAX_BYTES
|
|
129
|
-
over = true
|
|
130
|
-
break
|
|
131
|
-
end
|
|
132
|
-
output << chunk
|
|
133
|
-
end
|
|
134
|
-
rescue IOError # EOFError too
|
|
135
|
-
nil
|
|
136
|
-
end
|
|
137
|
-
stderr = +""
|
|
138
|
-
err_reader = Thread.new do
|
|
139
|
-
loop do
|
|
140
|
-
stderr << err_r.readpartial(READ_CHUNK)
|
|
141
|
-
stderr = stderr.byteslice(-STDERR_TAIL_BYTES, STDERR_TAIL_BYTES) if stderr.bytesize > STDERR_TAIL_BYTES
|
|
142
|
-
end
|
|
143
|
-
rescue IOError # EOFError too
|
|
144
|
-
nil
|
|
145
|
-
end
|
|
124
|
+
out_reader = ProcessGroup::PipeReader.new(out_r, cap: ContextSources::TEXT_MAX_BYTES, chunk: READ_CHUNK)
|
|
125
|
+
err_reader = ProcessGroup::PipeReader.new(err_r, cap: STDERR_TAIL_BYTES, keep: :tail, chunk: READ_CHUNK)
|
|
146
126
|
|
|
147
|
-
error, status = wait(pid, timeout: timeout, over: -> { over }, cancelled: cancelled)
|
|
127
|
+
error, status = wait(pid, timeout: timeout, over: -> { out_reader.over? }, cancelled: cancelled)
|
|
148
128
|
reaped = true
|
|
149
129
|
# Whatever the command left running in its group goes with it.
|
|
150
|
-
|
|
151
|
-
[out_reader, err_reader].each { |
|
|
152
|
-
stderr_text =
|
|
130
|
+
ProcessGroup.signal(pid, "KILL")
|
|
131
|
+
[out_reader, err_reader].each { |reader| reader.wait(KILL_GRACE_SECONDS) || reader.kill }
|
|
132
|
+
stderr_text = err_reader.text(Encoding::UTF_8).scrub
|
|
153
133
|
Log.debug(:context, "stderr", text: stderr_text[-1000..] || stderr_text) unless stderr_text.strip.empty?
|
|
154
134
|
return Run.new(output: nil, error: nil, stderr: stderr_text, cancelled: true) if error == :cancelled
|
|
155
135
|
|
|
156
136
|
# Checked again once the reader is done: a command that exits before
|
|
157
137
|
# wait's next tick never gets an over from it.
|
|
158
|
-
error = OVER_CAP if over
|
|
138
|
+
error = OVER_CAP if out_reader.over?
|
|
159
139
|
error ||= exit_error(status, stderr_text)
|
|
160
|
-
Run.new(output:
|
|
140
|
+
Run.new(output: out_reader.text, error: error, stderr: stderr_text, cancelled: false)
|
|
161
141
|
rescue SystemCallError => e
|
|
162
142
|
Run.new(output: nil, error: "couldn't run it: #{e.message}", stderr: "", cancelled: false)
|
|
163
143
|
ensure
|
|
@@ -188,33 +168,22 @@ module Samagotchi
|
|
|
188
168
|
|
|
189
169
|
def abandon(pid, readers)
|
|
190
170
|
stop_group(pid)
|
|
191
|
-
readers.each { |
|
|
171
|
+
readers.each { |reader| reader&.kill }
|
|
192
172
|
rescue StandardError => e
|
|
193
173
|
Log.warn(:context, "stop_failed", pid: pid, error: e.class.name)
|
|
194
174
|
end
|
|
195
175
|
|
|
196
176
|
# TERM, then KILL after a grace; @return [Process::Status, nil]
|
|
197
177
|
def stop_group(pid)
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
_, status = Process.wait2(pid, Process::WNOHANG)
|
|
202
|
-
return status if status
|
|
178
|
+
status = nil
|
|
179
|
+
reaped = -> { status = Process.wait2(pid, Process::WNOHANG)&.last }
|
|
180
|
+
return status if ProcessGroup.stop(pid, grace: KILL_GRACE_SECONDS, poll: 0.05, stopped: reaped)
|
|
203
181
|
|
|
204
|
-
sleep(0.05)
|
|
205
|
-
end
|
|
206
|
-
signal_group(pid, "KILL")
|
|
207
182
|
Process.wait2(pid).last
|
|
208
183
|
rescue Errno::ECHILD
|
|
209
184
|
nil
|
|
210
185
|
end
|
|
211
186
|
|
|
212
|
-
def signal_group(pid, signal)
|
|
213
|
-
Process.kill(signal, -pid)
|
|
214
|
-
rescue Errno::ESRCH, Errno::EPERM
|
|
215
|
-
nil
|
|
216
|
-
end
|
|
217
|
-
|
|
218
187
|
def exit_error(status, stderr)
|
|
219
188
|
return nil if status.nil? || status.success?
|
|
220
189
|
|
|
@@ -67,26 +67,35 @@ module Samagotchi
|
|
|
67
67
|
# +serial+ counts the revisions (an absorb that missed some says how
|
|
68
68
|
# many); +error+ is the last failure, +error_since+ when the failures
|
|
69
69
|
# began (cleared by a success), so a session hears about each run of
|
|
70
|
-
# failures once. +summary+, +wake+ and +hint+ come with the revision
|
|
70
|
+
# failures once. +summary+, +wake+ and +hint+ come with the revision;
|
|
71
|
+
# +wake_serial+ is the serial of the newest revision that asked to wake
|
|
72
|
+
# (nil: none), so a change that doesn't ask, recorded before an absorb,
|
|
73
|
+
# doesn't hide a wake asked since the session's last one.
|
|
71
74
|
# +fetched_at+ is the last success, +checked_at+ the last try (the
|
|
72
75
|
# poller's clock: a failing source isn't run again every tick).
|
|
73
|
-
Snapshot = Data.define(:text, :summary, :revision, :serial, :fetched_at, :checked_at, :wake, :
|
|
74
|
-
:error_since) do
|
|
76
|
+
Snapshot = Data.define(:text, :summary, :revision, :serial, :fetched_at, :checked_at, :wake, :wake_serial, :hint,
|
|
77
|
+
:error, :error_since) do
|
|
75
78
|
def self.empty
|
|
76
|
-
new(text: nil, summary: nil, revision: nil, serial: 0, fetched_at: nil, checked_at: nil, wake: false,
|
|
77
|
-
error: nil, error_since: nil)
|
|
79
|
+
new(text: nil, summary: nil, revision: nil, serial: 0, fetched_at: nil, checked_at: nil, wake: false,
|
|
80
|
+
wake_serial: nil, hint: nil, error: nil, error_since: nil)
|
|
78
81
|
end
|
|
79
82
|
|
|
80
83
|
def to_h
|
|
81
84
|
{ "text" => text, "summary" => summary, "revision" => revision, "serial" => serial,
|
|
82
|
-
"fetched_at" => fetched_at, "checked_at" => checked_at, "wake" => (wake ? true : nil),
|
|
85
|
+
"fetched_at" => fetched_at, "checked_at" => checked_at, "wake" => (wake ? true : nil),
|
|
86
|
+
"wake_serial" => wake_serial, "hint" => hint,
|
|
83
87
|
"error" => error, "error_since" => error_since }.compact
|
|
84
88
|
end
|
|
85
89
|
|
|
90
|
+
# A snapshot 0.32.0 wrote has no wake_serial: its wake is the latest
|
|
91
|
+
# revision's.
|
|
86
92
|
def self.from_h(data)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
93
|
+
serial = data["serial"].to_i
|
|
94
|
+
wake = data["wake"] == true
|
|
95
|
+
new(text: data["text"], summary: data["summary"], revision: data["revision"], serial: serial,
|
|
96
|
+
fetched_at: data["fetched_at"], checked_at: data["checked_at"] || data["fetched_at"], wake: wake,
|
|
97
|
+
wake_serial: data["wake_serial"]&.to_i || (serial if wake), hint: data["hint"], error: data["error"],
|
|
98
|
+
error_since: data["error_since"])
|
|
90
99
|
end
|
|
91
100
|
|
|
92
101
|
def text? = !text.nil?
|
|
@@ -230,7 +239,8 @@ module Samagotchi
|
|
|
230
239
|
else
|
|
231
240
|
Snapshot.new(text: fetched.text, revision: revision, serial: previous.serial + 1, fetched_at: stamp,
|
|
232
241
|
checked_at: stamp, summary: fetched.summary || ContextSources.plain_summary(previous.text, fetched.text),
|
|
233
|
-
wake: fetched.wake,
|
|
242
|
+
wake: fetched.wake, wake_serial: fetched.wake ? previous.serial + 1 : previous.wake_serial,
|
|
243
|
+
hint: fetched.hint || previous.hint, error: nil, error_since: nil)
|
|
234
244
|
end
|
|
235
245
|
write_snapshot(name, written)
|
|
236
246
|
end
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -6,6 +6,7 @@ require "securerandom"
|
|
|
6
6
|
require "time"
|
|
7
7
|
require "yaml"
|
|
8
8
|
|
|
9
|
+
require_relative "client_id"
|
|
9
10
|
require_relative "iteration_limit"
|
|
10
11
|
require_relative "config"
|
|
11
12
|
require_relative "context_note"
|
|
@@ -349,7 +350,8 @@ module Samagotchi
|
|
|
349
350
|
# again with the message, no nudge, no retry spent. Too early, the
|
|
350
351
|
# message waits (WaitingSteer) and cuts once the thinking passes it
|
|
351
352
|
# (#recheck_waiting_steer, on each thinking chunk), unless a boundary
|
|
352
|
-
# hands it to the model first. A plugin's message never cuts
|
|
353
|
+
# hands it to the model first. A plugin's message never cuts, nor an
|
|
354
|
+
# unknown client's (automatic:<id>).
|
|
353
355
|
# Call it after the message is queued, with the #input_epoch read
|
|
354
356
|
# before queueing it. Outside any lock; a cut that lands just after the
|
|
355
357
|
# generation ended is harmless (the loop re-asks).
|
|
@@ -811,6 +813,7 @@ module Samagotchi
|
|
|
811
813
|
# #stop_idle's work (the scheduler's stop is idempotent), where the
|
|
812
814
|
# callers haven't stopped it already.
|
|
813
815
|
@idle_scheduler&.stop
|
|
816
|
+
@prompt_warmup.stop
|
|
814
817
|
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + join_timeout
|
|
815
818
|
threads.each do |thread|
|
|
816
819
|
next if thread == Thread.current
|
|
@@ -1183,17 +1186,18 @@ module Samagotchi
|
|
|
1183
1186
|
private :hook_messages
|
|
1184
1187
|
|
|
1185
1188
|
# How the turn ended, on the session (Session#last_turn): the caller's
|
|
1186
|
-
# save puts it in the file the session hub watches.
|
|
1189
|
+
# save puts it in the file the session hub watches. Public for the
|
|
1190
|
+
# Worker's turn that failed before it began (no #end_turn ran).
|
|
1187
1191
|
# @param limit [Integer, nil] the turn ran out of iterations at this
|
|
1188
1192
|
# limit: "exhausted" => true, "limit" => N, so a wait with nobody to
|
|
1189
1193
|
# answer the continue offer says so (ReplyWait, ParentReport "limit")
|
|
1190
1194
|
# @param event [Hash] the turn's end event (#stop_facts)
|
|
1191
1195
|
def record_last_turn(session, outcome, seconds, origin, event, limit: nil)
|
|
1192
1196
|
client_id = origin.is_a?(Hash) ? origin[:client_id].to_s : ""
|
|
1193
|
-
source = if client_id.start_with?(
|
|
1194
|
-
elsif client_id.start_with?(
|
|
1195
|
-
elsif client_id.start_with?(
|
|
1196
|
-
elsif client_id ==
|
|
1197
|
+
source = if client_id.start_with?(ClientId::DELEGATE_PREFIX) then "delegate"
|
|
1198
|
+
elsif client_id.start_with?(ClientId::CHILD_PREFIX) then "delegate_report"
|
|
1199
|
+
elsif client_id.start_with?(ClientId::CONTEXT_PREFIX) then "context"
|
|
1200
|
+
elsif client_id == ClientId::REMINDER then "reminder"
|
|
1197
1201
|
else "client"
|
|
1198
1202
|
end
|
|
1199
1203
|
session.last_turn = { "outcome" => outcome, "ended_at" => Time.now.iso8601(3),
|
|
@@ -1201,7 +1205,6 @@ module Samagotchi
|
|
|
1201
1205
|
session.last_turn.merge!("exhausted" => true, "limit" => limit) if limit
|
|
1202
1206
|
session.last_turn.merge!(stop_facts(event))
|
|
1203
1207
|
end
|
|
1204
|
-
private :record_last_turn
|
|
1205
1208
|
|
|
1206
1209
|
# Why a turn stopped, from its end event, for a parent agent's wait
|
|
1207
1210
|
# (ReplyWait): a provider error's kind and whether a retry may help, a
|
|
@@ -30,8 +30,6 @@ module Samagotchi
|
|
|
30
30
|
# rewrite would be "allow everything" from then on.
|
|
31
31
|
module ParentApprovals
|
|
32
32
|
KEY = "guardrails.parent_approvals"
|
|
33
|
-
# The client id chi answer posts its answers with.
|
|
34
|
-
CLIENT_ID = "cli:answer"
|
|
35
33
|
# Set by chi's execute and task_create for the commands they run: a
|
|
36
34
|
# chi started there answers as a parent (#parent_process?).
|
|
37
35
|
PARENT_SESSION_ENV = "SAMAGOTCHI_PARENT_SESSION"
|
|
@@ -5,6 +5,7 @@ require "fileutils"
|
|
|
5
5
|
require "tmpdir"
|
|
6
6
|
require_relative "manifest"
|
|
7
7
|
require_relative "placeholder"
|
|
8
|
+
require_relative "../context_providers"
|
|
8
9
|
require_relative "../memory_paths"
|
|
9
10
|
require_relative "../model_overlay"
|
|
10
11
|
|
|
@@ -152,6 +153,10 @@ module Samagotchi
|
|
|
152
153
|
requires_chi = prov_data && prov_data[:requires_chi]
|
|
153
154
|
needs = prov_data && prov_data[:needs]
|
|
154
155
|
|
|
156
|
+
# ── Scripts and context providers: the installed bundle's ──
|
|
157
|
+
scripts_to_copy = installed_scripts(prov, prov_data)
|
|
158
|
+
context_providers = prov_data ? ContextProviders.parse_list(prov_data[:context_providers]) : []
|
|
159
|
+
|
|
155
160
|
# Determine trust_level for the built bundle
|
|
156
161
|
build_trust_level = @trust_level
|
|
157
162
|
if (build_trust_level.nil? || build_trust_level.empty?) && prov_data && prov_data[:trust_level]
|
|
@@ -185,6 +190,11 @@ module Samagotchi
|
|
|
185
190
|
|
|
186
191
|
FileUtils.cp(plugin_src, File.join(staging, File.basename(plugin_src))) if plugin_src
|
|
187
192
|
|
|
193
|
+
unless scripts_to_copy.empty?
|
|
194
|
+
FileUtils.mkdir_p(File.join(staging, "scripts"))
|
|
195
|
+
scripts_to_copy.each_value { |src| FileUtils.cp(src, File.join(staging, "scripts", File.basename(src))) }
|
|
196
|
+
end
|
|
197
|
+
|
|
188
198
|
Manifest.write(
|
|
189
199
|
dir: staging,
|
|
190
200
|
name: resolved_name,
|
|
@@ -196,7 +206,9 @@ module Samagotchi
|
|
|
196
206
|
trust_level: build_trust_level,
|
|
197
207
|
plugin: plugin_src && { file: File.basename(plugin_src), sha256: Digest::SHA256.hexdigest(File.binread(plugin_src)) },
|
|
198
208
|
requires_chi: requires_chi,
|
|
199
|
-
needs: needs
|
|
209
|
+
needs: needs,
|
|
210
|
+
scripts: scripts_to_copy.transform_values { |src| "sha256:#{Digest::SHA256.hexdigest(File.binread(src))}" },
|
|
211
|
+
context_providers: context_providers
|
|
200
212
|
)
|
|
201
213
|
|
|
202
214
|
case format
|
|
@@ -255,6 +267,15 @@ module Samagotchi
|
|
|
255
267
|
|
|
256
268
|
private
|
|
257
269
|
|
|
270
|
+
# The installed bundle's scripts its record lists, file name => path
|
|
271
|
+
# in its scripts/ (a missing one is left out); {} without a record.
|
|
272
|
+
def installed_scripts(prov, prov_data)
|
|
273
|
+
return {} unless prov && prov_data && prov_data[:scripts].is_a?(Hash)
|
|
274
|
+
|
|
275
|
+
prov_data[:scripts].keys.map(&:to_s).sort.to_h { |file| [file, File.join(prov.scripts_dir, file)] }
|
|
276
|
+
.select { |_, path| File.file?(path) }
|
|
277
|
+
end
|
|
278
|
+
|
|
258
279
|
# Without an allowlist, a memory another installed bundle owns (its
|
|
259
280
|
# record lists it) isn't the user's to share: left out, one line
|
|
260
281
|
# each. The bundle being built (same name) keeps its own.
|
|
@@ -124,7 +124,7 @@ module Samagotchi
|
|
|
124
124
|
|
|
125
125
|
# Writes a fresh manifest.yml from computed checksums at `dir`.
|
|
126
126
|
def self.write(dir:, name:, version:, files:, scope: nil, description: "", hooks: nil, trust_level: nil,
|
|
127
|
-
plugin: nil, requires_chi: nil, needs: nil)
|
|
127
|
+
plugin: nil, requires_chi: nil, needs: nil, scripts: {}, context_providers: [])
|
|
128
128
|
FileUtils.mkdir_p(dir)
|
|
129
129
|
manifest = {
|
|
130
130
|
"name" => name,
|
|
@@ -149,6 +149,8 @@ module Samagotchi
|
|
|
149
149
|
manifest["requires_chi"] = requires_chi.to_s if requires_chi && !requires_chi.to_s.empty?
|
|
150
150
|
needs = parse_needs(needs)
|
|
151
151
|
manifest["needs"] = needs.map { |n| n.transform_keys(&:to_s).compact } unless needs.empty?
|
|
152
|
+
manifest["scripts"] = scripts.transform_keys(&:to_s) unless scripts.empty?
|
|
153
|
+
manifest["context_providers"] = context_providers.map(&:to_h) unless context_providers.empty?
|
|
152
154
|
File.write(File.join(dir, "manifest.yml"), YAML.dump(manifest))
|
|
153
155
|
end
|
|
154
156
|
|