samagotchi 0.159.0 → 0.161.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 +125 -1
- data/docs/architecture.md +12 -9
- data/docs/cli.md +7 -5
- data/docs/context.md +10 -1
- data/docs/guardrails.md +33 -5
- data/docs/internals/attached-context.md +4 -2
- data/docs/internals/bridge.md +28 -6
- data/docs/internals/bundles.md +24 -5
- data/docs/internals/engine.md +7 -1
- data/docs/internals/objectives.md +8 -2
- data/docs/internals/remote-sessions.md +2 -1
- data/docs/memory.md +43 -13
- data/docs/plugins.md +13 -4
- data/docs/releasing.md +7 -4
- data/docs/scheduling.md +53 -8
- data/docs/sessions.md +17 -12
- data/docs/testing.md +9 -1
- data/lib/samagotchi/bridge/card_store.rb +31 -3
- data/lib/samagotchi/bridge/ring_buffer.rb +11 -1
- data/lib/samagotchi/bridge/sse_writer.rb +8 -5
- data/lib/samagotchi/bridge.rb +103 -44
- data/lib/samagotchi/bridge_client.rb +9 -6
- data/lib/samagotchi/broadcast/scope_card.rb +2 -1
- data/lib/samagotchi/broadcast_command.rb +24 -8
- data/lib/samagotchi/bundle_command.rb +57 -6
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
- data/lib/samagotchi/child_reports.rb +29 -23
- data/lib/samagotchi/client_id.rb +6 -0
- data/lib/samagotchi/command_intake.rb +56 -0
- data/lib/samagotchi/compat.rb +32 -0
- data/lib/samagotchi/context_absorber.rb +6 -3
- data/lib/samagotchi/context_candidate_choice.rb +8 -5
- data/lib/samagotchi/context_candidates.rb +11 -8
- data/lib/samagotchi/context_candidates_log.rb +10 -1
- data/lib/samagotchi/context_command.rb +10 -4
- data/lib/samagotchi/context_move.rb +6 -1
- data/lib/samagotchi/context_poller.rb +5 -2
- data/lib/samagotchi/context_sources.rb +6 -4
- data/lib/samagotchi/engine/events.rb +25 -2
- data/lib/samagotchi/engine/guardrails.rb +3 -4
- data/lib/samagotchi/engine/plugins.rb +1 -1
- data/lib/samagotchi/engine/steering.rb +2 -2
- data/lib/samagotchi/engine.rb +1 -0
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/events.rb +71 -0
- data/lib/samagotchi/guardrails/approvals.rb +27 -3
- data/lib/samagotchi/guardrails/child_boundary.rb +37 -4
- data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
- data/lib/samagotchi/guardrails/targets.rb +25 -6
- data/lib/samagotchi/hook_routes.rb +0 -1
- data/lib/samagotchi/live_versions.rb +7 -2
- data/lib/samagotchi/memory_bundle/asset_installer.rb +86 -18
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +7 -27
- data/lib/samagotchi/memory_bundle/file_actions.rb +3 -4
- data/lib/samagotchi/memory_bundle/file_state.rb +11 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +7 -4
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
- data/lib/samagotchi/memory_bundle/installer.rb +93 -23
- data/lib/samagotchi/memory_bundle/manifest.rb +6 -0
- data/lib/samagotchi/memory_bundle/provenance.rb +158 -6
- data/lib/samagotchi/memory_bundle/shipped_update.rb +7 -4
- data/lib/samagotchi/memory_bundle/status.rb +3 -3
- data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +29 -13
- data/lib/samagotchi/memory_orphans.rb +54 -13
- data/lib/samagotchi/memory_paths.rb +29 -6
- data/lib/samagotchi/memory_usage.rb +23 -12
- data/lib/samagotchi/note_command.rb +9 -1
- data/lib/samagotchi/note_delivery.rb +34 -24
- data/lib/samagotchi/note_move.rb +205 -0
- data/lib/samagotchi/objectives_command.rb +3 -1
- data/lib/samagotchi/owner_lock.rb +7 -1
- data/lib/samagotchi/plugin/attached_context.rb +3 -1
- data/lib/samagotchi/plugin/sessions.rb +4 -1
- data/lib/samagotchi/project_command.rb +58 -20
- data/lib/samagotchi/project_id.rb +102 -0
- data/lib/samagotchi/project_id_clash.rb +62 -0
- data/lib/samagotchi/project_init.rb +214 -10
- data/lib/samagotchi/project_scope.rb +11 -2
- data/lib/samagotchi/review_start.rb +5 -3
- data/lib/samagotchi/schedule_command.rb +12 -0
- data/lib/samagotchi/schedules.rb +374 -76
- data/lib/samagotchi/send_command.rb +2 -15
- data/lib/samagotchi/session.rb +59 -7
- data/lib/samagotchi/session_access/local.rb +53 -22
- data/lib/samagotchi/session_access/remote.rb +13 -5
- data/lib/samagotchi/session_access/web_bridge_client.rb +2 -2
- data/lib/samagotchi/session_access.rb +20 -4
- data/lib/samagotchi/session_commands.rb +7 -3
- data/lib/samagotchi/session_continue.rb +162 -24
- data/lib/samagotchi/session_delete_command.rb +3 -1
- data/lib/samagotchi/session_inbox.rb +41 -1
- data/lib/samagotchi/session_inbox_delivery.rb +254 -0
- data/lib/samagotchi/session_manager/delivery.rb +66 -12
- data/lib/samagotchi/session_manager/emptiness.rb +7 -2
- data/lib/samagotchi/session_manager/lifecycle.rb +2 -1
- data/lib/samagotchi/session_manager/listing.rb +4 -1
- data/lib/samagotchi/session_manager/removal.rb +130 -26
- data/lib/samagotchi/session_manager/spawn.rb +167 -100
- data/lib/samagotchi/session_manager/worker_command.rb +7 -19
- data/lib/samagotchi/session_manager.rb +1 -0
- data/lib/samagotchi/session_retention.rb +53 -23
- data/lib/samagotchi/session_setup.rb +27 -7
- data/lib/samagotchi/starting_marker.rb +93 -24
- data/lib/samagotchi/state_format.rb +129 -0
- data/lib/samagotchi/system_one/runs.rb +6 -1
- data/lib/samagotchi/system_one/runs_store.rb +18 -14
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +7 -4
- data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +5 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -0
- data/lib/samagotchi/terminal_ui/version_lines.rb +4 -2
- data/lib/samagotchi/terminal_ui.rb +2 -3
- data/lib/samagotchi/tools/context_read.rb +7 -2
- data/lib/samagotchi/tools/delegate.rb +19 -6
- data/lib/samagotchi/tools/schedule_task.rb +5 -3
- data/lib/samagotchi/tools/send_note.rb +10 -4
- data/lib/samagotchi/turn_stages/context_judge.rb +7 -7
- data/lib/samagotchi/turn_state.rb +108 -4
- data/lib/samagotchi/update_command.rb +9 -4
- data/lib/samagotchi/verify_fresh.rb +27 -9
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +6 -5
- data/lib/samagotchi/web/public/app.js +2 -7
- data/lib/samagotchi/web/public/chains.js +4 -4
- data/lib/samagotchi/web/public/data.js +18 -2
- data/lib/samagotchi/web/public/info_bar_model.js +1 -1
- data/lib/samagotchi/web/public/session_fields.js +3 -2
- data/lib/samagotchi/web/public/session_state.js +2 -0
- data/lib/samagotchi/web/public/update_notice.js +2 -2
- data/lib/samagotchi/web/session_access_routes.rb +15 -11
- data/lib/samagotchi/web/session_control.rb +9 -3
- data/lib/samagotchi/web/session_create.rb +8 -4
- data/lib/samagotchi/web/session_hub.rb +50 -5
- data/lib/samagotchi/web/session_summary.rb +5 -2
- data/lib/samagotchi/worker.rb +61 -11
- data/lib/samagotchi/worker_boot.rb +22 -11
- data/lib/samagotchi/worker_inbound.rb +62 -10
- data/lib/samagotchi/worker_link.rb +4 -1
- data/lib/samagotchi/worker_presence.rb +106 -0
- data/lib/samagotchi/worker_sidecar.rb +3 -4
- metadata +9 -1
|
@@ -52,12 +52,15 @@ module Samagotchi
|
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
# Ring +child+'s parent; best effort (a failure is logged, never
|
|
55
|
-
# raised)
|
|
56
|
-
#
|
|
55
|
+
# raised): a ring delivery (SessionInbox.deliver :ring). The ring file
|
|
56
|
+
# goes to the parent parent.json names; its WakePolicy decides whether
|
|
57
|
+
# a worker starts (a parent with none, idle, in wake mode), and for an
|
|
58
|
+
# archived parent with a later link, that link (the one whose
|
|
59
|
+
# ChildReports reads the ring). A start runs on a thread.
|
|
57
60
|
# @param child [Session] a delegate child
|
|
58
61
|
# @param why [String] "turn_end", "question" or "crash"
|
|
59
|
-
# @param wake [#call, nil] (parent id)
|
|
60
|
-
# nil: SessionManager.
|
|
62
|
+
# @param wake [#call, nil] (parent id) starts the worker the policy
|
|
63
|
+
# decided on; nil: SessionManager.start_for_inbox on a thread
|
|
61
64
|
# @return [String, nil] the ring file, nil when none was written
|
|
62
65
|
# The parent is read fresh: a continue may have moved the child to the
|
|
63
66
|
# chain's new link (Session::PARENT_FILE) since its worker loaded it.
|
|
@@ -70,33 +73,36 @@ module Samagotchi
|
|
|
70
73
|
|
|
71
74
|
parent_id = Session.parent_override(child.id, state_dir: state_dir) || child.parent_id
|
|
72
75
|
return nil unless parent_id
|
|
73
|
-
# A deleted parent gets no orphan dir.
|
|
74
|
-
return nil unless Session.exist?(parent_id, state_dir: state_dir)
|
|
75
76
|
|
|
76
|
-
|
|
77
|
-
path =
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
# A deleted parent gets no orphan dir: the delivery refuses it (:gone).
|
|
78
|
+
path = nil
|
|
79
|
+
SessionInbox.deliver(:ring, parent_id, state_dir: state_dir, wake: mode == "wake",
|
|
80
|
+
start: starter(state_dir, wake)) do |at|
|
|
81
|
+
path = SessionInbox.write_ring(Session.session_dir(at, state_dir: state_dir), child_id: child.id, why: why)
|
|
82
|
+
Log.info(:worker, "delegate_rang", parent: at[0, 8], why: why)
|
|
83
|
+
true
|
|
84
|
+
end
|
|
80
85
|
path
|
|
81
86
|
rescue StandardError => e
|
|
82
87
|
Log.warn(:worker, "delegate_ring_failed", parent: child&.parent_id.to_s[0, 8], error: e.class.name, msg: e.message)
|
|
83
88
|
nil
|
|
84
89
|
end
|
|
85
90
|
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
91
|
+
# The ring delivery's start: +wake+, or SessionManager.start_for_inbox
|
|
92
|
+
# on a thread (#await_wakes joins it), so the child's turn doesn't wait
|
|
93
|
+
# for a spawn.
|
|
94
|
+
def starter(state_dir, wake)
|
|
95
|
+
return ->(id) { wake.call(id).then { :woken } } if wake
|
|
96
|
+
|
|
97
|
+
lambda do |id|
|
|
98
|
+
thread = Thread.new do
|
|
99
|
+
SessionManager.start_for_inbox(id, state_dir: state_dir, event: :delegate_woke_parent)
|
|
100
|
+
rescue StandardError => e
|
|
101
|
+
Log.warn(:worker, "delegate_wake_failed", parent: id[0, 8], error: e.class.name, msg: e.message)
|
|
102
|
+
end
|
|
103
|
+
@wakes_mutex.synchronize { @wakes = @wakes.select(&:alive?) << thread }
|
|
104
|
+
:woken
|
|
97
105
|
end
|
|
98
|
-
@wakes_mutex.synchronize { @wakes = @wakes.select(&:alive?) << thread }
|
|
99
|
-
thread
|
|
100
106
|
end
|
|
101
107
|
|
|
102
108
|
# Waits up to +timeout+ for the parent wakes this process started: a
|
data/lib/samagotchi/client_id.rb
CHANGED
|
@@ -9,6 +9,8 @@ module Samagotchi
|
|
|
9
9
|
# A web tab (web:<random>, per tab); web:restart, the page's Restart.
|
|
10
10
|
WEB_PREFIX = "web:"
|
|
11
11
|
WEB_RESTART = "web:restart"
|
|
12
|
+
# A web page's first prompt when creating a session.
|
|
13
|
+
WEB_CREATE = "web:create"
|
|
12
14
|
# An attached chi TUI: tui:<pid>.
|
|
13
15
|
TUI_PREFIX = "tui:"
|
|
14
16
|
# chi send's turns and commands.
|
|
@@ -98,6 +100,10 @@ module Samagotchi
|
|
|
98
100
|
# Whether +client_id+ is a participant's (review:).
|
|
99
101
|
def review?(client_id) = client_id.to_s.start_with?(REVIEW_PREFIX)
|
|
100
102
|
|
|
103
|
+
# The client id a delegating parent's task and follow-ups carry:
|
|
104
|
+
# delegate:<parent8>.
|
|
105
|
+
def delegate(parent_id) = "#{DELEGATE_PREFIX}#{parent_id.to_s[0, 8]}"
|
|
106
|
+
|
|
101
107
|
# The client id a durable schedule's turn carries: system:schedule:<id>.
|
|
102
108
|
def schedule(schedule_id) = "#{SCHEDULE_PREFIX}#{schedule_id}"
|
|
103
109
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "client_id"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# Who may send a session command as a message (a turn's text: chi send -m
|
|
7
|
+
# "/model x", a web page's first message, an input file, a session's
|
|
8
|
+
# first prompt), and what a line from anyone else is. The one rule the
|
|
9
|
+
# two intakes ask (WorkerInbound#queue_as_command, Bridge#handle_post_turn)
|
|
10
|
+
# and the senders that refuse up front (Tools::Delegate, Schedules#add).
|
|
11
|
+
# No Engine needed: a schedule's tick and the CLI have none.
|
|
12
|
+
#
|
|
13
|
+
# From a sender that may not, a /word line is plain prompt text (the
|
|
14
|
+
# model reads it) and a ! line (a shell command: SessionCommands#shell
|
|
15
|
+
# runs it with no Gate) is refused with REFUSED, never run.
|
|
16
|
+
module CommandIntake
|
|
17
|
+
# A shell line's prefix: !<cmd>, and !rollback (SessionCommands::
|
|
18
|
+
# SHELL_BANG_PREFIX, which a spec keeps the same: SessionCommands is too
|
|
19
|
+
# big a load for the bundle test kit's budget).
|
|
20
|
+
SHELL_PREFIX = "!"
|
|
21
|
+
|
|
22
|
+
# The one text a refused shell line gets: the Bridge's 403 detail, the
|
|
23
|
+
# worker's command_ran error, a delegate's or a schedule's refusal.
|
|
24
|
+
REFUSED = "a message that starts with ! is a shell command, which only a person runs as a message " \
|
|
25
|
+
"(a web tab, a chi TUI, chi send); from a delegate, a schedule or another automatic sender it is not run. " \
|
|
26
|
+
"Say what to run in words instead (\"run `ls`\"), or leave out the leading !"
|
|
27
|
+
|
|
28
|
+
# The Bridge's error code for it (403).
|
|
29
|
+
REFUSED_ERROR = "command_not_permitted"
|
|
30
|
+
|
|
31
|
+
# chi answer's marker (a parent agent's /continue yes and
|
|
32
|
+
# /parent-answers; Worker#as_parent? holds those to its own rules): any
|
|
33
|
+
# command, with chi send's trust.
|
|
34
|
+
ALLOWED_IDS = [ClientId::CLI_ANSWER].freeze
|
|
35
|
+
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
# Whether a message from +client_id+ may run as a session command: a
|
|
39
|
+
# human's input (ClientId.human?: a web tab, a chi TUI, chi send, no
|
|
40
|
+
# client) but a participant's (review:, refused on its own), and chi
|
|
41
|
+
# answer's. A delegate, a child's report, a context or task wake, a
|
|
42
|
+
# schedule, a reminder, a relay and a plugin may not (the bundles that
|
|
43
|
+
# send, coordinator and skills, send prose).
|
|
44
|
+
def allowed?(client_id)
|
|
45
|
+
ALLOWED_IDS.include?(client_id.to_s) || (ClientId.human?(client_id) && !ClientId.review?(client_id))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Whether +text+ would be a shell line (!<cmd>, !rollback) if a command:
|
|
49
|
+
# what a delegate's task or a schedule's prompt may not start with.
|
|
50
|
+
def shell_line?(text) = text.to_s.lstrip.start_with?(SHELL_PREFIX)
|
|
51
|
+
|
|
52
|
+
# Whether the command line +line+ from +client_id+ is a shell line that
|
|
53
|
+
# must not run.
|
|
54
|
+
def refused?(line, client_id) = shell_line?(line) && !allowed?(client_id)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubygems/version"
|
|
4
|
+
require_relative "version"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
# The compatibility decision for state and bridge data from a chi version.
|
|
8
|
+
module Compat
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def verdict(worker_version:, version: VERSION, worker_protocol: nil, protocol: nil)
|
|
12
|
+
return :refused if protocol && worker_protocol.to_i > protocol
|
|
13
|
+
|
|
14
|
+
worker_version = worker_version.to_s
|
|
15
|
+
version = version.to_s
|
|
16
|
+
return :restart_suggested if worker_version.empty? || version.empty?
|
|
17
|
+
return :restart_suggested unless Gem::Version.correct?(worker_version) && Gem::Version.correct?(version)
|
|
18
|
+
|
|
19
|
+
worker = Gem::Version.new(worker_version)
|
|
20
|
+
current = Gem::Version.new(version)
|
|
21
|
+
return :restart_suggested if worker < previous_minor(current)
|
|
22
|
+
|
|
23
|
+
:ok
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def previous_minor(version)
|
|
27
|
+
major, minor = version.segments.first(2)
|
|
28
|
+
Gem::Version.new("#{major}.#{[(minor || 0) - 1, 0].max}.0")
|
|
29
|
+
end
|
|
30
|
+
private_class_method :previous_minor
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -59,17 +59,19 @@ module Samagotchi
|
|
|
59
59
|
VERDICT = /\bVERDICT:?\s*([A-Z]+)(?![A-Za-z|])/
|
|
60
60
|
|
|
61
61
|
# @param project_root [String, nil] Session#project_root
|
|
62
|
+
# @param project_id [String, nil] Session#project_id
|
|
62
63
|
# @param peer_sources [PeerSources, nil] the peer bundles' declared sources
|
|
63
64
|
# @param peer_turns [#call, nil] the peer wake turns in a row
|
|
64
65
|
# (WorkerWakes#peer_turns); 0 or nil: no source is a peer's
|
|
65
|
-
def initialize(session_id:, state_dir:, project_root:, peer_sources: nil, peer_turns: nil)
|
|
66
|
+
def initialize(session_id:, state_dir:, project_root:, project_id: nil, peer_sources: nil, peer_turns: nil)
|
|
66
67
|
@session_id = session_id
|
|
67
68
|
@peer_sources = peer_sources
|
|
68
69
|
@peer_turns = peer_turns
|
|
69
70
|
@state_dir = state_dir
|
|
70
71
|
@own = ContextSources.session_location(session_id, state_dir: state_dir)
|
|
71
|
-
@project = ContextSources.project_location_for(project_root, state_dir: state_dir)
|
|
72
|
+
@project = ContextSources.project_location_for(project_root, project_id: project_id, state_dir: state_dir)
|
|
72
73
|
@project_root = project_root
|
|
74
|
+
@project_id = project_id
|
|
73
75
|
@signature = nil
|
|
74
76
|
end
|
|
75
77
|
|
|
@@ -79,7 +81,8 @@ module Samagotchi
|
|
|
79
81
|
return nil if signature == @signature
|
|
80
82
|
|
|
81
83
|
subs = @own.subscriptions
|
|
82
|
-
visible = ContextSources.attached(@session_id, project_root: @project_root,
|
|
84
|
+
visible = ContextSources.attached(@session_id, project_root: @project_root, project_id: @project_id,
|
|
85
|
+
state_dir: @state_dir)
|
|
83
86
|
deliveries = visible.filter_map do |attached|
|
|
84
87
|
next if @own.muted?(attached.name)
|
|
85
88
|
|
|
@@ -56,11 +56,12 @@ module Samagotchi
|
|
|
56
56
|
# @param show_card [#call] (**card) → shows it; false/nil: it wasn't
|
|
57
57
|
# shown (the UI is on another session), so no carded line
|
|
58
58
|
# @return [Symbol, nil] :attached, :carded or nil
|
|
59
|
-
def after_judged(job:, fields:, settings:, project_root:, state_dir:, log:, show_card:)
|
|
59
|
+
def after_judged(job:, fields:, settings:, project_root:, state_dir:, log:, show_card:, project_id: nil)
|
|
60
60
|
return nil unless settings.mode == "on" && fields && fields["stated"]
|
|
61
61
|
return nil unless %w[attach offer].include?(fields["would"])
|
|
62
62
|
|
|
63
|
-
candidate = proposed(job.session_id, job.name, project_root: project_root,
|
|
63
|
+
candidate = proposed(job.session_id, job.name, project_root: project_root, project_id: project_id,
|
|
64
|
+
state_dir: state_dir) or return nil
|
|
64
65
|
if fields["would"] == "attach"
|
|
65
66
|
judge_attach(candidate, job: job, p: fields["p"], state_dir: state_dir, log: log, show_card: show_card)
|
|
66
67
|
else
|
|
@@ -72,8 +73,9 @@ module Samagotchi
|
|
|
72
73
|
end
|
|
73
74
|
|
|
74
75
|
# The candidate +name+ when it is still proposed in the session.
|
|
75
|
-
def proposed(session_id, name, project_root:, state_dir:)
|
|
76
|
-
rows = ContextCandidates.with_status(session_id, project_root: project_root,
|
|
76
|
+
def proposed(session_id, name, project_root:, state_dir:, project_id: nil)
|
|
77
|
+
rows = ContextCandidates.with_status(session_id, project_root: project_root, project_id: project_id,
|
|
78
|
+
state_dir: state_dir)
|
|
77
79
|
rows.find { |candidate, status| candidate.name == name && status == "proposed" }&.first
|
|
78
80
|
end
|
|
79
81
|
|
|
@@ -128,7 +130,8 @@ module Samagotchi
|
|
|
128
130
|
end
|
|
129
131
|
|
|
130
132
|
def find(session, name, state_dir:)
|
|
131
|
-
ContextCandidates.with_status(session.id, project_root: session.project_root,
|
|
133
|
+
ContextCandidates.with_status(session.id, project_root: session.project_root, project_id: session.project_id,
|
|
134
|
+
state_dir: state_dir)
|
|
132
135
|
.find { |candidate, _status| candidate.name == name }
|
|
133
136
|
end
|
|
134
137
|
|
|
@@ -58,18 +58,19 @@ module Samagotchi
|
|
|
58
58
|
|
|
59
59
|
# The session's candidates with their status, by name.
|
|
60
60
|
# @return [Array<Array(ContextSources::Candidate, String)>]
|
|
61
|
-
def with_status(session_id, project_root:, state_dir: nil)
|
|
61
|
+
def with_status(session_id, project_root:, project_id: nil, state_dir: nil)
|
|
62
62
|
own = ContextSources.session_location(session_id, state_dir: state_dir)
|
|
63
63
|
candidates = own.candidates
|
|
64
64
|
return [] if candidates.empty?
|
|
65
65
|
|
|
66
|
-
attached = ContextSources.attached(session_id, project_root: project_root,
|
|
66
|
+
attached = ContextSources.attached(session_id, project_root: project_root, project_id: project_id, state_dir: state_dir,
|
|
67
|
+
shadowed: true)
|
|
67
68
|
candidates.map { |candidate| [candidate, status(candidate, own: own, attached: attached)] }
|
|
68
69
|
end
|
|
69
70
|
|
|
70
71
|
# The candidates section of /context, nil for none.
|
|
71
|
-
def listing(session_id, project_root:, state_dir: nil)
|
|
72
|
-
rows = with_status(session_id, project_root: project_root, state_dir: state_dir)
|
|
72
|
+
def listing(session_id, project_root:, project_id: nil, state_dir: nil)
|
|
73
|
+
rows = with_status(session_id, project_root: project_root, project_id: project_id, state_dir: state_dir)
|
|
73
74
|
return nil if rows.empty?
|
|
74
75
|
|
|
75
76
|
lines = rows.map { |candidate, state| "- #{candidate.name} · #{state} · #{candidate.title}" }
|
|
@@ -171,8 +172,8 @@ module Samagotchi
|
|
|
171
172
|
end
|
|
172
173
|
|
|
173
174
|
# The proposed candidates (no source of theirs attached, not declined).
|
|
174
|
-
def proposed(session_id, project_root:, state_dir: nil)
|
|
175
|
-
with_status(session_id, project_root: project_root, state_dir: state_dir)
|
|
175
|
+
def proposed(session_id, project_root:, project_id: nil, state_dir: nil)
|
|
176
|
+
with_status(session_id, project_root: project_root, project_id: project_id, state_dir: state_dir)
|
|
176
177
|
.filter_map { |candidate, state| candidate if state == "proposed" }
|
|
177
178
|
end
|
|
178
179
|
|
|
@@ -194,7 +195,8 @@ module Samagotchi
|
|
|
194
195
|
# The earlier owner prompts too: a proposal that came while one ran
|
|
195
196
|
# (a one-shot's plugins never see a running turn's prompt).
|
|
196
197
|
texts = owner_texts(session.messages) + (prompt.is_a?(String) ? [prompt] : [])
|
|
197
|
-
found = hits(texts, proposed(session.id, project_root: session.project_root,
|
|
198
|
+
found = hits(texts, proposed(session.id, project_root: session.project_root, project_id: session.project_id,
|
|
199
|
+
state_dir: state_dir))
|
|
198
200
|
names = found.filter_map { |hit| attach(hit, own: own, state_dir: state_dir) }
|
|
199
201
|
first_fetch(own, unfetched(own, names), cwd: session.working_directory, seconds: seconds, cancelled: cancelled)
|
|
200
202
|
names
|
|
@@ -213,7 +215,8 @@ module Samagotchi
|
|
|
213
215
|
names = own.candidates.map(&:name)
|
|
214
216
|
return 0 if names.empty?
|
|
215
217
|
|
|
216
|
-
absorber = ContextAbsorber.new(session_id: session.id, state_dir: state_dir, project_root: session.project_root
|
|
218
|
+
absorber = ContextAbsorber.new(session_id: session.id, state_dir: state_dir, project_root: session.project_root,
|
|
219
|
+
project_id: session.project_id)
|
|
217
220
|
batch = absorber.pending or return 0
|
|
218
221
|
picked = batch.deliveries.select { |delivery| names.include?(delivery.name) }
|
|
219
222
|
picked.each { |delivery| engine.add_context_note(session, delivery.note) if delivery.note }
|
|
@@ -65,7 +65,16 @@ module Samagotchi
|
|
|
65
65
|
# its prompts. Never raises.
|
|
66
66
|
# @return [Integer] how many lines were dropped
|
|
67
67
|
def purge_session(session_id, state_dir: nil)
|
|
68
|
-
|
|
68
|
+
purge_sessions([session_id], state_dir: state_dir)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Many sessions' lines, pruned in one pass through the log. Never raises.
|
|
72
|
+
# @return [Integer] how many lines were dropped
|
|
73
|
+
def purge_sessions(session_ids, state_dir: nil)
|
|
74
|
+
ids = Array(session_ids).map(&:to_s).reject(&:empty?).to_h { |id| [id, true] }
|
|
75
|
+
return 0 if ids.empty?
|
|
76
|
+
|
|
77
|
+
log_for(state_dir).prune { |line| !ids[line["session_id"]] }
|
|
69
78
|
rescue StandardError => e
|
|
70
79
|
Log.warn(:context, "context_candidates_log_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
71
80
|
0
|
|
@@ -80,7 +80,12 @@ module Samagotchi
|
|
|
80
80
|
# Where a command acts: a session (its own sources, then its project's)
|
|
81
81
|
# or a project's sources. +label+ names it in the output, +cwd+ is
|
|
82
82
|
# where its session's sources run (ContextPoller's choice).
|
|
83
|
-
|
|
83
|
+
# +project_id+ is the project's id (Session#project_id, a marked
|
|
84
|
+
# project's ProjectLookup#id): with +project_root+ it keys the project's
|
|
85
|
+
# sources (MemoryPaths.key_for).
|
|
86
|
+
Target = Data.define(:session_id, :project_root, :cwd, :label, :project_id) do
|
|
87
|
+
def initialize(session_id:, project_root:, cwd:, label:, project_id: nil) = super
|
|
88
|
+
|
|
84
89
|
def project? = session_id.nil?
|
|
85
90
|
end
|
|
86
91
|
|
|
@@ -234,7 +239,7 @@ module Samagotchi
|
|
|
234
239
|
end
|
|
235
240
|
|
|
236
241
|
@stdout.puts("#{target.label} #{name}: #{pushed.revision == pushed.before ? "unchanged" : "new text"} " \
|
|
237
|
-
"(#{pushed.revision[0, 12]})")
|
|
242
|
+
"(#{pushed.revision[0, 12]})#{" · #{pushed.wake_line}" if pushed.wake_line}")
|
|
238
243
|
true
|
|
239
244
|
end
|
|
240
245
|
end
|
|
@@ -421,14 +426,15 @@ module Samagotchi
|
|
|
421
426
|
root = found.root
|
|
422
427
|
# Its sources run in the root, or in this checkout when the root is a
|
|
423
428
|
# git dir (ContextSources.project_cwd).
|
|
424
|
-
Target.new(session_id: nil, project_root: root, cwd: found.top || @cwd,
|
|
429
|
+
Target.new(session_id: nil, project_root: root, project_id: found.id, cwd: found.top || @cwd,
|
|
425
430
|
label: "project #{File.basename(root)}")
|
|
426
431
|
end
|
|
427
432
|
|
|
428
433
|
def session_target(given)
|
|
429
434
|
id = @access.resolve_id(given)
|
|
430
435
|
session = @access.show(id)
|
|
431
|
-
Target.new(session_id: id, project_root: session.project_root,
|
|
436
|
+
Target.new(session_id: id, project_root: session.project_root, project_id: session.project_id,
|
|
437
|
+
cwd: session.working_directory, label: id[0, 8])
|
|
432
438
|
rescue ArgumentError => e
|
|
433
439
|
error_line("#{command_name}: #{e.message}")
|
|
434
440
|
nil
|
|
@@ -70,13 +70,18 @@ module Samagotchi
|
|
|
70
70
|
reserve = CARRY_MORE_TRUNCATION.bytesize
|
|
71
71
|
text_limit = [CARRY_SOURCE_MAX_BYTES, remaining - heading.bytesize - reserve].min
|
|
72
72
|
if text_limit < CARRY_SOURCE_TRUNCATION.bytesize
|
|
73
|
+
# Not even room for a trimmed source: this one is dropped, so the
|
|
74
|
+
# note says text is missing even when it was the last.
|
|
73
75
|
truncated = true
|
|
74
76
|
break
|
|
75
77
|
end
|
|
76
78
|
|
|
77
79
|
bounded, source_truncated = bound_source_text(text, text_limit)
|
|
78
80
|
note << heading << bounded
|
|
79
|
-
|
|
81
|
+
# Only a cut by the note's own budget ends the note: a source trimmed to
|
|
82
|
+
# CARRY_SOURCE_MAX_BYTES still leaves room for the next one, and one that
|
|
83
|
+
# fits the smaller room left is carried whole.
|
|
84
|
+
if source_truncated && text_limit < CARRY_SOURCE_MAX_BYTES
|
|
80
85
|
truncated = more
|
|
81
86
|
break
|
|
82
87
|
end
|
|
@@ -23,12 +23,14 @@ module Samagotchi
|
|
|
23
23
|
TICK_SECONDS = 5
|
|
24
24
|
|
|
25
25
|
# @param project_root [String, nil] Session#project_root
|
|
26
|
+
# @param project_id [String, nil] Session#project_id
|
|
26
27
|
# @param cwd [String] the session's folder (a session source runs there)
|
|
27
28
|
# @param on_change [#call] after a fetch brought a new text or failed
|
|
28
|
-
def initialize(session_id:, state_dir:, project_root:, cwd:, on_change:, tick: TICK_SECONDS)
|
|
29
|
+
def initialize(session_id:, state_dir:, project_root:, cwd:, on_change:, project_id: nil, tick: TICK_SECONDS)
|
|
29
30
|
@session_id = session_id
|
|
30
31
|
@state_dir = state_dir
|
|
31
32
|
@project_root = project_root
|
|
33
|
+
@project_id = project_id
|
|
32
34
|
@cwd = cwd
|
|
33
35
|
@on_change = on_change
|
|
34
36
|
@tick = tick
|
|
@@ -56,7 +58,8 @@ module Samagotchi
|
|
|
56
58
|
# One pass (the thread's loop body; specs call it).
|
|
57
59
|
# @param first [Boolean] the worker's first pass
|
|
58
60
|
def poll(first: false)
|
|
59
|
-
ContextSources.attached(@session_id, project_root: @project_root,
|
|
61
|
+
ContextSources.attached(@session_id, project_root: @project_root, project_id: @project_id,
|
|
62
|
+
state_dir: @state_dir).each do |attached|
|
|
60
63
|
break if @stopping
|
|
61
64
|
next if attached.source.push? || @own.muted?(attached.name)
|
|
62
65
|
|
|
@@ -634,19 +634,21 @@ module Samagotchi
|
|
|
634
634
|
|
|
635
635
|
# The project a session's sources come from: nil when it is in no repo.
|
|
636
636
|
# @param project_root [String, nil] Session#project_root
|
|
637
|
-
|
|
637
|
+
# @param project_id [String, nil] Session#project_id (a marked project's
|
|
638
|
+
# id keys its folder: MemoryPaths.key_for)
|
|
639
|
+
def project_location_for(project_root, project_id: nil, state_dir: nil)
|
|
638
640
|
return nil if project_root.to_s.empty?
|
|
639
641
|
|
|
640
|
-
project_location(MemoryPaths.
|
|
642
|
+
project_location(MemoryPaths.key_for(root: project_root, id: project_id), state_dir: state_dir)
|
|
641
643
|
end
|
|
642
644
|
|
|
643
645
|
# A session's sources: its own, then the project's it doesn't shadow
|
|
644
646
|
# (+shadowed+ lists those too, marked).
|
|
645
647
|
# @return [Array<Attached>]
|
|
646
|
-
def attached(session_id, project_root:, state_dir: nil, shadowed: false)
|
|
648
|
+
def attached(session_id, project_root:, project_id: nil, state_dir: nil, shadowed: false)
|
|
647
649
|
own = session_location(session_id, state_dir: state_dir)
|
|
648
650
|
list = own.sources.map { |source| Attached.new(source: source, location: own, shadowed: false) }
|
|
649
|
-
project = project_location_for(project_root, state_dir: state_dir)
|
|
651
|
+
project = project_location_for(project_root, project_id: project_id, state_dir: state_dir)
|
|
650
652
|
return list unless project
|
|
651
653
|
|
|
652
654
|
names = list.map(&:name)
|
|
@@ -51,6 +51,18 @@ module Samagotchi
|
|
|
51
51
|
@session_observer.synchronize(&)
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
def reserve_enqueued_id(id) = @turn_state.reserve_enqueued_id(id)
|
|
55
|
+
|
|
56
|
+
def claim_enqueued_id(id) = @turn_state.claim_enqueued_id(id)
|
|
57
|
+
|
|
58
|
+
def finish_enqueued_id(id) = @turn_state.finish_enqueued_id(id)
|
|
59
|
+
|
|
60
|
+
def forget_enqueued_id(id) = @turn_state.forget_enqueued_id(id)
|
|
61
|
+
|
|
62
|
+
def drop_enqueued_id(id) = @turn_state.drop_enqueued_id(id)
|
|
63
|
+
|
|
64
|
+
def enqueued_id?(id) = @turn_state.enqueued_id?(id)
|
|
65
|
+
|
|
54
66
|
# The working set's :working_set_updated, announced between turns
|
|
55
67
|
# (after a command changed what the next request sends), when it
|
|
56
68
|
# differs from the last one emitted (#emit_working_set). Under the
|
|
@@ -79,8 +91,7 @@ module Samagotchi
|
|
|
79
91
|
announce({ type: :guardrail_warning, message: message }) if message
|
|
80
92
|
plugins = @plugin_failures.message
|
|
81
93
|
announce({ type: :guardrail_warning, message: plugins, label: "plugins" }) if plugins
|
|
82
|
-
|
|
83
|
-
announce({ type: :guardrail_warning, message: memory, label: "memory" }) if memory
|
|
94
|
+
memory_load_warnings.each { |event| announce(event) }
|
|
84
95
|
@extension_load.held_events.each do |event|
|
|
85
96
|
announce(event[:type] == :card ? event.merge(in_turn: false) : event.merge(between_turns: true))
|
|
86
97
|
end
|
|
@@ -88,6 +99,18 @@ module Samagotchi
|
|
|
88
99
|
nil
|
|
89
100
|
end
|
|
90
101
|
|
|
102
|
+
# The session-start memory warnings (label "memory"), for both load
|
|
103
|
+
# announcements (a worker's, a one-shot's first turn): an orphaned
|
|
104
|
+
# project memory folder (MemoryOrphans), a copied project sharing its
|
|
105
|
+
# id (ProjectIdClash, which also claims the id folder for this root).
|
|
106
|
+
# @return [Array<Hash>] the :guardrail_warning events
|
|
107
|
+
def memory_load_warnings
|
|
108
|
+
[MemoryOrphans.message, ProjectIdClash.check&.message].compact.map do |message|
|
|
109
|
+
{ type: :guardrail_warning, message: message, label: "memory" }
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
private :memory_load_warnings
|
|
113
|
+
|
|
91
114
|
# Run the block holding back the cards and between-turns notices it
|
|
92
115
|
# announces on this thread, so the caller announces them after its own
|
|
93
116
|
# event (a worker's command: its command_ran first, so a UI prints the
|
|
@@ -46,8 +46,8 @@ module Samagotchi
|
|
|
46
46
|
|
|
47
47
|
# Once per Engine, on its first turn: what failed to load, so every UI
|
|
48
48
|
# (the attached TUI, the web, a one-shot's stderr) shows it: the guardrails, then the plugins
|
|
49
|
-
# (label: "plugins"; the UIs say guardrails without one),
|
|
50
|
-
#
|
|
49
|
+
# (label: "plugins"; the UIs say guardrails without one), the memory
|
|
50
|
+
# warnings (label: "memory": #memory_load_warnings), then the notices and cards plugins showed as they loaded.
|
|
51
51
|
def announce_guardrail_failures(on_event)
|
|
52
52
|
return if @guardrail_failures_announced
|
|
53
53
|
|
|
@@ -56,8 +56,7 @@ module Samagotchi
|
|
|
56
56
|
emit_event(on_event, { type: :guardrail_warning, message: message }) if message
|
|
57
57
|
plugins = @plugin_failures.message
|
|
58
58
|
emit_event(on_event, { type: :guardrail_warning, message: plugins, label: "plugins" }) if plugins
|
|
59
|
-
|
|
60
|
-
emit_event(on_event, { type: :guardrail_warning, message: memory, label: "memory" }) if memory
|
|
59
|
+
memory_load_warnings.each { |event| emit_event(on_event, event) }
|
|
61
60
|
@extension_load.held_events.each { |event| emit_event(on_event, event) }
|
|
62
61
|
end
|
|
63
62
|
private :announce_guardrail_failures
|
|
@@ -166,7 +166,7 @@ module Samagotchi
|
|
|
166
166
|
end,
|
|
167
167
|
state_dir: -> { session_state_dir },
|
|
168
168
|
scratch: -> { @scratch },
|
|
169
|
-
setup: -> { SessionSetup.of(@session) },
|
|
169
|
+
setup: -> { SessionSetup.of(@session, forked: true) },
|
|
170
170
|
# The files whose text the built system prompt holds; [] until a
|
|
171
171
|
# build and after a rebuild that drops them (SystemPrompt#prompt_files
|
|
172
172
|
# returns [] itself: the || keeps a nil builder safe).
|
|
@@ -126,8 +126,8 @@ module Samagotchi
|
|
|
126
126
|
# Cancel the currently running turn, if any.
|
|
127
127
|
# @param reason [Symbol] cancellation reason
|
|
128
128
|
# @return [Boolean] whether a cancellation was triggered
|
|
129
|
-
def cancel_current_turn!(reason = :manual)
|
|
130
|
-
@turn_state.cancel!(reason)
|
|
129
|
+
def cancel_current_turn!(reason = :manual, turn_id: nil)
|
|
130
|
+
@turn_state.cancel!(reason, turn_id: turn_id)
|
|
131
131
|
end
|
|
132
132
|
end
|
|
133
133
|
end
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -83,6 +83,7 @@ require_relative "guardrails"
|
|
|
83
83
|
require_relative "reminder_store"
|
|
84
84
|
require_relative "reminder_queue"
|
|
85
85
|
require_relative "memory_orphans"
|
|
86
|
+
require_relative "project_id_clash"
|
|
86
87
|
require_relative "tools/memory"
|
|
87
88
|
require_relative "muted_memories"
|
|
88
89
|
require_relative "used_memories"
|
|
@@ -17,6 +17,7 @@ module Samagotchi
|
|
|
17
17
|
def session_id = engine.session&.id
|
|
18
18
|
def cwd = engine.session&.working_directory
|
|
19
19
|
def project_root = engine.session&.project_root
|
|
20
|
+
def project_id = engine.session&.project_id
|
|
20
21
|
def state_dir = engine.peer_state_dir
|
|
21
22
|
def cancelled? = !!engine.active_cancel_controller&.cancelled?
|
|
22
23
|
def cancel_reason = engine.active_cancel_controller&.reason
|
data/lib/samagotchi/events.rb
CHANGED
|
@@ -22,6 +22,77 @@ module Samagotchi
|
|
|
22
22
|
STREAM_FRAMES = %i[snapshot reset stream_closed].freeze
|
|
23
23
|
SYNTHETIC = %i[merged_input].freeze
|
|
24
24
|
|
|
25
|
+
# The client-facing lifetime of each event. Rows are drawn in the
|
|
26
|
+
# conversation and survive reload; state updates the live UI; transient
|
|
27
|
+
# is live-only; internal is not sent to clients.
|
|
28
|
+
CLASSIFICATION = {
|
|
29
|
+
after_generation: :internal,
|
|
30
|
+
after_tool_call: :internal,
|
|
31
|
+
after_turn: :internal,
|
|
32
|
+
answer_display: :state,
|
|
33
|
+
before_generation: :internal,
|
|
34
|
+
before_tool_call: :internal,
|
|
35
|
+
before_turn: :internal,
|
|
36
|
+
card: :row,
|
|
37
|
+
command_queued: :state,
|
|
38
|
+
command_ran: :state,
|
|
39
|
+
context_added: :row, # Reloaded from the session messages, not a turn fold or card store.
|
|
40
|
+
context_status: :state,
|
|
41
|
+
continue_offered: :state,
|
|
42
|
+
continue_resolved: :state,
|
|
43
|
+
empty_answer_retry: :row,
|
|
44
|
+
generation_cancelled: :state,
|
|
45
|
+
generation_chunk: :transient,
|
|
46
|
+
generation_completed: :state,
|
|
47
|
+
generation_progress: :internal,
|
|
48
|
+
generation_retrying: :state, # Updates live retry status; a restart also removes invalidated chunks.
|
|
49
|
+
generation_started: :state,
|
|
50
|
+
guardrail_warning: :state, # Worker startup state; not a persistent conversation notice.
|
|
51
|
+
hook_notice: :row,
|
|
52
|
+
input_merged: :state,
|
|
53
|
+
llm_context_edited: :row,
|
|
54
|
+
merged_input: :row,
|
|
55
|
+
objectives_updated: :state,
|
|
56
|
+
participants_changed: :state,
|
|
57
|
+
pending_input_merged: :row,
|
|
58
|
+
plugin_init_finished: :state,
|
|
59
|
+
plugin_init_started: :state,
|
|
60
|
+
plugin_init_wait: :state,
|
|
61
|
+
prompt_restored: :state,
|
|
62
|
+
question_answered: :state,
|
|
63
|
+
question_cancelled: :state,
|
|
64
|
+
question_relay: :state,
|
|
65
|
+
question_requested: :state,
|
|
66
|
+
reasoning_history_refused: :internal,
|
|
67
|
+
recap_ready: :state,
|
|
68
|
+
reminder_injected: :row,
|
|
69
|
+
reset: :state,
|
|
70
|
+
sampling_refused: :internal,
|
|
71
|
+
session_end: :internal,
|
|
72
|
+
session_start: :internal,
|
|
73
|
+
side_usage: :state,
|
|
74
|
+
steer_cut: :row,
|
|
75
|
+
stream_closed: :state,
|
|
76
|
+
stream_resumed: :transient,
|
|
77
|
+
stream_retrying: :transient,
|
|
78
|
+
thinking_dropped: :transient,
|
|
79
|
+
thinking_refused: :internal,
|
|
80
|
+
tool_call_completed: :row,
|
|
81
|
+
tool_call_started: :row,
|
|
82
|
+
tool_dispatch_completed: :state,
|
|
83
|
+
tool_dispatch_started: :state,
|
|
84
|
+
turn_canceled: :row,
|
|
85
|
+
turn_completed: :row,
|
|
86
|
+
turn_dequeued: :state,
|
|
87
|
+
turn_enqueued: :state,
|
|
88
|
+
turn_failed: :row,
|
|
89
|
+
turn_started: :row,
|
|
90
|
+
used_memories_updated: :state,
|
|
91
|
+
verdict_recorded: :state,
|
|
92
|
+
worker_changed: :state,
|
|
93
|
+
working_set_updated: :state
|
|
94
|
+
}.freeze
|
|
95
|
+
|
|
25
96
|
# Whether +event+ ends a turn that stays in the conversation: completed,
|
|
26
97
|
# canceled, or failed with its work kept (turn_failed's kept_steps).
|
|
27
98
|
def self.kept_turn?(event)
|