samagotchi 0.159.0 → 0.160.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 +40 -1
- data/docs/architecture.md +5 -6
- data/docs/cli.md +1 -1
- data/docs/guardrails.md +21 -2
- data/docs/internals/attached-context.md +4 -2
- data/docs/internals/bridge.md +7 -0
- data/docs/internals/engine.md +7 -1
- data/docs/memory.md +41 -13
- data/docs/scheduling.md +16 -3
- data/docs/sessions.md +3 -1
- data/lib/samagotchi/bridge.rb +11 -2
- data/lib/samagotchi/broadcast/scope_card.rb +2 -1
- data/lib/samagotchi/bundle_command.rb +1 -0
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
- data/lib/samagotchi/client_id.rb +4 -0
- data/lib/samagotchi/command_intake.rb +56 -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_command.rb +9 -3
- data/lib/samagotchi/context_poller.rb +5 -2
- data/lib/samagotchi/context_sources.rb +6 -4
- data/lib/samagotchi/engine/events.rb +13 -2
- data/lib/samagotchi/engine/guardrails.rb +3 -4
- 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/hook_routes.rb +0 -1
- data/lib/samagotchi/memory_bundle/asset_installer.rb +27 -6
- 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/installed_bundle.rb +1 -1
- data/lib/samagotchi/memory_bundle/installer.rb +14 -11
- data/lib/samagotchi/memory_bundle/manifest.rb +6 -0
- data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -3
- data/lib/samagotchi/memory_bundle/status.rb +2 -2
- data/lib/samagotchi/memory_bundle/uninstaller.rb +2 -0
- data/lib/samagotchi/memory_orphans.rb +14 -4
- data/lib/samagotchi/memory_paths.rb +29 -6
- data/lib/samagotchi/memory_usage.rb +23 -12
- 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/schedules.rb +99 -39
- data/lib/samagotchi/session.rb +17 -2
- data/lib/samagotchi/session_access/local.rb +8 -5
- data/lib/samagotchi/session_access/remote.rb +2 -1
- data/lib/samagotchi/session_access.rb +4 -1
- data/lib/samagotchi/session_commands.rb +7 -3
- data/lib/samagotchi/session_continue.rb +10 -6
- data/lib/samagotchi/session_manager/spawn.rb +9 -2
- data/lib/samagotchi/session_manager/worker_command.rb +7 -19
- data/lib/samagotchi/state_format.rb +118 -0
- data/lib/samagotchi/tools/context_read.rb +7 -2
- data/lib/samagotchi/tools/delegate.rb +10 -2
- data/lib/samagotchi/turn_stages/context_judge.rb +7 -7
- data/lib/samagotchi/update_command.rb +2 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +5 -4
- data/lib/samagotchi/web/session_access_routes.rb +3 -3
- data/lib/samagotchi/worker.rb +18 -6
- data/lib/samagotchi/worker_inbound.rb +21 -2
- metadata +5 -1
|
@@ -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 }
|
|
@@ -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
|
|
|
@@ -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
|
|
@@ -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)
|
|
@@ -79,8 +79,7 @@ module Samagotchi
|
|
|
79
79
|
announce({ type: :guardrail_warning, message: message }) if message
|
|
80
80
|
plugins = @plugin_failures.message
|
|
81
81
|
announce({ type: :guardrail_warning, message: plugins, label: "plugins" }) if plugins
|
|
82
|
-
|
|
83
|
-
announce({ type: :guardrail_warning, message: memory, label: "memory" }) if memory
|
|
82
|
+
memory_load_warnings.each { |event| announce(event) }
|
|
84
83
|
@extension_load.held_events.each do |event|
|
|
85
84
|
announce(event[:type] == :card ? event.merge(in_turn: false) : event.merge(between_turns: true))
|
|
86
85
|
end
|
|
@@ -88,6 +87,18 @@ module Samagotchi
|
|
|
88
87
|
nil
|
|
89
88
|
end
|
|
90
89
|
|
|
90
|
+
# The session-start memory warnings (label "memory"), for both load
|
|
91
|
+
# announcements (a worker's, a one-shot's first turn): an orphaned
|
|
92
|
+
# project memory folder (MemoryOrphans), a copied project sharing its
|
|
93
|
+
# id (ProjectIdClash, which also claims the id folder for this root).
|
|
94
|
+
# @return [Array<Hash>] the :guardrail_warning events
|
|
95
|
+
def memory_load_warnings
|
|
96
|
+
[MemoryOrphans.message, ProjectIdClash.check&.message].compact.map do |message|
|
|
97
|
+
{ type: :guardrail_warning, message: message, label: "memory" }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
private :memory_load_warnings
|
|
101
|
+
|
|
91
102
|
# Run the block holding back the cards and between-turns notices it
|
|
92
103
|
# announces on this thread, so the caller announces them after its own
|
|
93
104
|
# 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
|
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)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "digest"
|
|
3
4
|
require "json"
|
|
4
5
|
require "fileutils"
|
|
5
6
|
require "time"
|
|
@@ -13,7 +14,9 @@ module Samagotchi
|
|
|
13
14
|
# written aside and renamed under an exclusive flock (several chi
|
|
14
15
|
# processes share it). An entry relaxes an ask, never a deny:
|
|
15
16
|
# session — this exact call (tool + normalized command / paths) in
|
|
16
|
-
# this session;
|
|
17
|
+
# this session; a command's in its place too (the
|
|
18
|
+
# worktree or folder it ran in, and a digest of its env:),
|
|
19
|
+
# since the same text acts on whatever they point at;
|
|
17
20
|
# repo — this exact call in this repo (the cwd outside a repo);
|
|
18
21
|
# rule — anything this rule (from this source) asks about here.
|
|
19
22
|
# "This repo" is the repository (repo: its common git dir), so an
|
|
@@ -74,11 +77,14 @@ module Samagotchi
|
|
|
74
77
|
here = ->(e) { e["repo"] ? e["repo"] == repo : e["repo_root"] == place }
|
|
75
78
|
session_id = verdict.context&.session_id
|
|
76
79
|
session_only = SESSION_ONLY_RULES.include?(verdict.rule.to_s)
|
|
80
|
+
command_place = session_place(verdict)
|
|
77
81
|
list.find do |e|
|
|
78
82
|
next false if session_only && e["scope"] != "session"
|
|
79
83
|
|
|
80
84
|
case e["scope"]
|
|
81
|
-
when "session"
|
|
85
|
+
when "session"
|
|
86
|
+
session_id && e["session_id"] == session_id && e["key"] == key &&
|
|
87
|
+
(command_place.nil? || e.slice("repo_root", "env") == command_place)
|
|
82
88
|
when "repo" then here.call(e) && e["key"] == key
|
|
83
89
|
when "rule"
|
|
84
90
|
# One rule's grant can't answer for a second rule's ask on the call.
|
|
@@ -94,7 +100,9 @@ module Samagotchi
|
|
|
94
100
|
entry = { "scope" => scope, "tool" => verdict.targets&.tool || verdict.call[:name].to_s,
|
|
95
101
|
"acts_as" => verdict.targets&.acts_as, "rule" => verdict.rule, "source" => verdict.source, "created_at" => Time.now.utc.iso8601 }
|
|
96
102
|
case scope
|
|
97
|
-
when "session"
|
|
103
|
+
when "session"
|
|
104
|
+
entry.merge!("session_id" => verdict.context&.session_id, "key" => key_for(verdict))
|
|
105
|
+
entry.merge!(session_place(verdict) || {})
|
|
98
106
|
when "repo"
|
|
99
107
|
entry.merge!("repo_root" => place_for(verdict), "repo" => verdict.targets&.repo, "key" => key_for(verdict))
|
|
100
108
|
when "rule" then entry.merge!("repo_root" => place_for(verdict), "repo" => verdict.targets&.repo)
|
|
@@ -131,6 +139,22 @@ module Samagotchi
|
|
|
131
139
|
t&.repo_root || t&.cwd || verdict.context&.cwd
|
|
132
140
|
end
|
|
133
141
|
|
|
142
|
+
# Where a session entry of a command holds: the call's worktree root
|
|
143
|
+
# (or folder: #place_for; never the common git dir, which every
|
|
144
|
+
# worktree shares) and, when it sets one, a sha256 of its env: (never
|
|
145
|
+
# the env: it may hold a secret). nil for a call with no command:
|
|
146
|
+
# its paths are its place. An entry without them (stored before) no
|
|
147
|
+
# longer matches: one more ask.
|
|
148
|
+
# @return [Hash, nil]
|
|
149
|
+
def self.session_place(verdict)
|
|
150
|
+
t = verdict.targets
|
|
151
|
+
return nil unless t&.command
|
|
152
|
+
|
|
153
|
+
env = t.env.to_h
|
|
154
|
+
digest = env.empty? ? nil : Digest::SHA256.hexdigest(JSON.generate(env.map { |k, v| [k.to_s, v.to_s] }.sort))
|
|
155
|
+
{ "repo_root" => place_for(verdict), "env" => digest }.compact
|
|
156
|
+
end
|
|
157
|
+
|
|
134
158
|
private
|
|
135
159
|
|
|
136
160
|
# @param locked [Boolean] the caller holds the store's lock
|
|
@@ -24,7 +24,6 @@ module Samagotchi
|
|
|
24
24
|
# (a chi.on handler :log, or :deny for an on_error: :fail_closed one).
|
|
25
25
|
HOOK_FAILURE_EFFECTS = {
|
|
26
26
|
log: "it's skipped, and only the log has its next failures",
|
|
27
|
-
skip: "it's skipped, and its next failures are silent",
|
|
28
27
|
deny: "it denies every tool call it guards, and its next failures are in each denial's reason"
|
|
29
28
|
}.freeze
|
|
30
29
|
|
|
@@ -115,7 +115,11 @@ module Samagotchi
|
|
|
115
115
|
files.each do |file|
|
|
116
116
|
@results["hooks/#{file}"] = @dry_run ? retired.with(word: :would_remove) : retired
|
|
117
117
|
end
|
|
118
|
-
|
|
118
|
+
if @dry_run
|
|
119
|
+
edits = Trash.edits(dir, "hooks", @existing&.hooks&.transform_values(&:sha256))
|
|
120
|
+
@warnings << "Would copy to the trash first: #{edits.join(", ")}" unless edits.empty?
|
|
121
|
+
return
|
|
122
|
+
end
|
|
119
123
|
|
|
120
124
|
@trash.copy_edits(dir, "hooks", @existing&.hooks&.transform_values(&:sha256))
|
|
121
125
|
FileUtils.rm_rf(dir)
|
|
@@ -162,9 +166,9 @@ module Samagotchi
|
|
|
162
166
|
# (FileActions' ASSET_INSTALL, or ASSET_UPGRADE over an installed
|
|
163
167
|
# bundle), one result per incoming file (a dry run's DRY_WORDS of it,
|
|
164
168
|
# and nothing written), an edit copied to the trash first (save_first),
|
|
165
|
-
#
|
|
166
|
-
#
|
|
167
|
-
#
|
|
169
|
+
# then the folder replaced. A removed file is named in the summary.
|
|
170
|
+
# A plugin or script whose sha256 differs from the declared one warns
|
|
171
|
+
# (not a dev link: it is the source).
|
|
168
172
|
# @param incoming [Hash{String => String}] name => the source file, in
|
|
169
173
|
# the order the results list them
|
|
170
174
|
# @return [Hash{String => String}] name => installed path ({} on a dry run)
|
|
@@ -173,21 +177,36 @@ module Samagotchi
|
|
|
173
177
|
command = @upgrade && @provenance.installed? ? :upgrade : :install
|
|
174
178
|
states = AssetState.in(dir, kind_of_file: kind_of_file, recorded: recorded, incoming: incoming)
|
|
175
179
|
.to_h { |asset| [asset.name, [asset, FileActions.for_asset(command, asset)]] }
|
|
180
|
+
if @dry_run
|
|
181
|
+
copies = states.filter_map { |_, (asset, action)| asset.label if action&.save_first }
|
|
182
|
+
@warnings << "Would copy to the trash first: #{copies.join(", ")}" unless copies.empty?
|
|
183
|
+
end
|
|
176
184
|
results = incoming.keys.to_h do |name|
|
|
177
185
|
asset, action = states.fetch(name)
|
|
178
186
|
[name, Installer::FileResult.new(word: @dry_run ? DRY_WORDS.fetch(action.word) : action.word, state: asset, action: action)]
|
|
179
187
|
end
|
|
188
|
+
dropped = states.filter_map do |name, (asset, action)|
|
|
189
|
+
next unless action&.do == :remove
|
|
190
|
+
|
|
191
|
+
[result_key(kind_of_file, name),
|
|
192
|
+
Installer::FileResult.new(word: @dry_run ? :would_remove : :removed, state: asset, action: action,
|
|
193
|
+
note: "no longer in the bundle")]
|
|
194
|
+
end
|
|
180
195
|
if @dry_run
|
|
181
196
|
results.each { |name, result| @results[result_key(kind_of_file, name)] = result }
|
|
197
|
+
dropped.each { |key, result| @results[key] = result }
|
|
182
198
|
return {}
|
|
183
199
|
end
|
|
184
200
|
|
|
185
201
|
states.each_value { |asset, action| @trash.copy(asset.path, as: asset.label) if action.save_first }
|
|
186
202
|
FileUtils.rm_rf(dir)
|
|
187
|
-
|
|
203
|
+
if incoming.empty?
|
|
204
|
+
dropped.each { |key, result| @results[key] = result }
|
|
205
|
+
return {}
|
|
206
|
+
end
|
|
188
207
|
|
|
189
208
|
FileUtils.mkdir_p(dir)
|
|
190
|
-
incoming.to_h do |name, src|
|
|
209
|
+
installed = incoming.to_h do |name, src|
|
|
191
210
|
dest = File.join(dir, name)
|
|
192
211
|
place(src, dest)
|
|
193
212
|
@results[result_key(kind_of_file, name)] = results[name]
|
|
@@ -198,6 +217,8 @@ module Samagotchi
|
|
|
198
217
|
end
|
|
199
218
|
[name, dest]
|
|
200
219
|
end
|
|
220
|
+
dropped.each { |key, result| @results[key] = result }
|
|
221
|
+
installed
|
|
201
222
|
end
|
|
202
223
|
|
|
203
224
|
# The kind's folder and the record's shas for it.
|
|
@@ -2,38 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
module Samagotchi
|
|
4
4
|
module MemoryBundle
|
|
5
|
-
# One class-file hook's
|
|
6
|
-
#
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
# ("sha256:<hex>", "" when none is recorded), the events it registers
|
|
10
|
-
# for (manifest `event:` for one, `events:` a list; none: it isn't
|
|
11
|
-
# loaded), on_error (skip, log or fail_closed) and priority. The one
|
|
12
|
-
# place the defaults live.
|
|
13
|
-
class BundleHook < Data.define(:sha256, :events, :on_error, :priority)
|
|
14
|
-
DEFAULT_ON_ERROR = "skip"
|
|
15
|
-
DEFAULT_PRIORITY = 100
|
|
5
|
+
# One class-file hook's sha256 in an older chi's installed record; its
|
|
6
|
+
# other fields are ignored now that class-file hooks are retired.
|
|
7
|
+
BundleHook = Data.define(:sha256) do
|
|
8
|
+
def initialize(sha256: "") = super
|
|
16
9
|
|
|
17
|
-
# @param
|
|
18
|
-
#
|
|
19
|
-
# `events:`); `event:` and `events:` together are joined, blanks
|
|
20
|
-
# and repeats dropped
|
|
21
|
-
def initialize(sha256: "", event: nil, events: nil, on_error: DEFAULT_ON_ERROR, priority: DEFAULT_PRIORITY)
|
|
22
|
-
names = [*event, *events].map { |e| e.to_s.strip }.reject(&:empty?).uniq.freeze
|
|
23
|
-
super(sha256: sha256, events: names, on_error: on_error, priority: priority)
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
# @param raw [Hash, BundleHook, nil] string keys (manifest.yml) or
|
|
27
|
-
# symbol keys (a read record); missing or blank values get the
|
|
28
|
-
# defaults
|
|
10
|
+
# @param raw [Hash, BundleHook, nil] string or symbol keys; legacy
|
|
11
|
+
# metadata beyond sha256 is ignored
|
|
29
12
|
def self.parse(raw)
|
|
30
13
|
return raw if raw.is_a?(BundleHook)
|
|
31
14
|
|
|
32
15
|
h = raw.is_a?(Hash) ? raw.transform_keys(&:to_s) : {}
|
|
33
|
-
|
|
34
|
-
new(sha256: prefixed(h["sha256"]), event: h["event"], events: h["events"],
|
|
35
|
-
on_error: on_error.empty? ? DEFAULT_ON_ERROR : on_error,
|
|
36
|
-
priority: (h["priority"] || DEFAULT_PRIORITY).to_i)
|
|
16
|
+
new(sha256: prefixed(h["sha256"]))
|
|
37
17
|
end
|
|
38
18
|
|
|
39
19
|
# "sha256:<hex>" from either form, "" for none.
|
|
@@ -180,14 +180,13 @@ module Samagotchi
|
|
|
180
180
|
%i[missing any] => ASSET_GONE
|
|
181
181
|
}.freeze
|
|
182
182
|
|
|
183
|
-
# An upgrade (--force too) over the record: a file it replaces is
|
|
184
|
-
# updated, except a rule (an upgrade lists a replaced rule as installed:
|
|
185
|
-
# the corpus's rule-installed).
|
|
183
|
+
# An upgrade (--force too) over the record: a file it replaces is updated.
|
|
186
184
|
ASSET_UPGRADE = ASSET_INSTALL.merge(
|
|
187
185
|
%i[pristine any] => ASSET_WRITE.with(word: :updated),
|
|
188
186
|
%i[edited any] => ASSET_WRITE.with(word: :updated, save_first: true),
|
|
189
187
|
%i[linked any] => ASSET_WRITE.with(word: :updated),
|
|
190
|
-
%i[pristine rule] => ASSET_WRITE
|
|
188
|
+
%i[pristine rule] => ASSET_WRITE.with(word: :updated),
|
|
189
|
+
%i[edited rule] => ASSET_WRITE.with(word: :updated, save_first: true),
|
|
191
190
|
%i[linked rule] => ASSET_WRITE
|
|
192
191
|
).freeze
|
|
193
192
|
|
|
@@ -26,7 +26,7 @@ module Samagotchi
|
|
|
26
26
|
# legacy_base: true on an older mark that has since got its incoming_sha (.legacy_mark?);
|
|
27
27
|
# why, missed, missed_places: FileStanding::Behind's, nil on an older mark;
|
|
28
28
|
# merged_version, merged_at, merged_trash: a merge no report has shown (Provenance#note_merged)
|
|
29
|
-
# hooks basename => BundleHook
|
|
29
|
+
# hooks basename => BundleHook (sha256; other legacy fields ignored)
|
|
30
30
|
# guardrails basename => recorded sha256 ("" when none)
|
|
31
31
|
# scripts file name => recorded sha256 ("" when none)
|
|
32
32
|
# plugin PluginRef, nil without one
|
|
@@ -20,7 +20,7 @@ module Samagotchi
|
|
|
20
20
|
# Installs a bundle into a scoped memories directory.
|
|
21
21
|
#
|
|
22
22
|
# Flow (#run):
|
|
23
|
-
# 1. Normalize source → directory, read manifest.yml (load_source)
|
|
23
|
+
# 1. Normalize source → directory, refuse retired hooks, read manifest.yml (load_source)
|
|
24
24
|
# 2. Refuse a bundle whose requires_chi this chi doesn't meet,
|
|
25
25
|
# unless force (refuse_too_new)
|
|
26
26
|
# 3. Copy .md files to the target scope dir and update index.md
|
|
@@ -356,7 +356,7 @@ module Samagotchi
|
|
|
356
356
|
@bundle_dir = @source_dir = prepared.dir
|
|
357
357
|
@source_owned = prepared.owned
|
|
358
358
|
@source_commit = prepared.commit
|
|
359
|
-
|
|
359
|
+
Manifest::HooksRetired.refuse_source_files(@bundle_dir)
|
|
360
360
|
@manifest = Manifest.read(dir: @bundle_dir)
|
|
361
361
|
clash = @manifest.session_settings_clash
|
|
362
362
|
raise InstallError, clash if clash
|
|
@@ -371,15 +371,6 @@ module Samagotchi
|
|
|
371
371
|
@warnings << "No manifest found — proceeding without strict manifest validation"
|
|
372
372
|
end
|
|
373
373
|
|
|
374
|
-
# A source that ships class-file hooks (hooks/*.rb, listed or not) is
|
|
375
|
-
# refused before its manifest is read; its manifest's hooks: is
|
|
376
|
-
# Manifest.read's refusal (Manifest::HooksRetired either way).
|
|
377
|
-
# @raise [Manifest::HooksRetired]
|
|
378
|
-
def refuse_hooks
|
|
379
|
-
files = Manifest::HooksRetired.files_in(@bundle_dir)
|
|
380
|
-
raise Manifest::HooksRetired.for_files(files) unless files.empty?
|
|
381
|
-
end
|
|
382
|
-
|
|
383
374
|
# A bundle whose requires_chi this chi doesn't meet is not installed at
|
|
384
375
|
# all: --force is what installs it, and its plugin and rules then
|
|
385
376
|
# warn that they won't load (AssetInstaller). The words come from
|
|
@@ -557,6 +548,7 @@ module Samagotchi
|
|
|
557
548
|
# cell's text merge runs on temp copies). --force would install every
|
|
558
549
|
# file, unread.
|
|
559
550
|
def preview_memory(file_key, state, action)
|
|
551
|
+
preview_trash_copy(file_key, state, action)
|
|
560
552
|
return @results[file_key] = FileResult.new(word: :would_install, state: state, action: action) if @force
|
|
561
553
|
return refuse_unreadable(file_key, state.paths[:current]) if action.do == :refuse
|
|
562
554
|
|
|
@@ -564,9 +556,20 @@ module Samagotchi
|
|
|
564
556
|
action = decision.action if decision
|
|
565
557
|
return note_conflict(file_key, decision.outcome, state) if action.behind?
|
|
566
558
|
|
|
559
|
+
preview_trash_copy(file_key, state, action)
|
|
567
560
|
@results[file_key] = FileResult.new(word: DRY_WORDS.fetch(action.word), state: state, action: action)
|
|
568
561
|
end
|
|
569
562
|
|
|
563
|
+
def preview_trash_copy(file_key, state, action)
|
|
564
|
+
return unless action.save_first
|
|
565
|
+
|
|
566
|
+
current = state.paths[:current]
|
|
567
|
+
incoming = state.paths[:incoming]
|
|
568
|
+
return unless File.file?(current) && !FileUtils.identical?(incoming, current)
|
|
569
|
+
|
|
570
|
+
@warnings << "Would copy to the trash first: #{file_key}"
|
|
571
|
+
end
|
|
572
|
+
|
|
570
573
|
# Strict mode: a memory that takes the bundle's text (Action#takes_source?:
|
|
571
574
|
# a new or missing file, --force, an upgrade's or a re-install's
|
|
572
575
|
# fast-forward, a merge into a local edit) warns when the bundle's file
|
|
@@ -83,6 +83,12 @@ module Samagotchi
|
|
|
83
83
|
|
|
84
84
|
# The source's hooks/*.rb, relative and sorted ([] for none).
|
|
85
85
|
def self.files_in(dir) = Dir.glob(File.join(dir, "hooks", "*.rb")).map { |f| "hooks/#{File.basename(f)}" }.sort
|
|
86
|
+
|
|
87
|
+
# Refuse a source's class-file hooks before parsing its manifest.
|
|
88
|
+
def self.refuse_source_files(dir)
|
|
89
|
+
files = files_in(dir)
|
|
90
|
+
raise for_files(files) unless files.empty?
|
|
91
|
+
end
|
|
86
92
|
end
|
|
87
93
|
|
|
88
94
|
# A plugin file is a plain .rb name in the bundle's top directory.
|