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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -1
  3. data/docs/architecture.md +5 -6
  4. data/docs/cli.md +1 -1
  5. data/docs/guardrails.md +21 -2
  6. data/docs/internals/attached-context.md +4 -2
  7. data/docs/internals/bridge.md +7 -0
  8. data/docs/internals/engine.md +7 -1
  9. data/docs/memory.md +41 -13
  10. data/docs/scheduling.md +16 -3
  11. data/docs/sessions.md +3 -1
  12. data/lib/samagotchi/bridge.rb +11 -2
  13. data/lib/samagotchi/broadcast/scope_card.rb +2 -1
  14. data/lib/samagotchi/bundle_command.rb +1 -0
  15. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  16. data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
  17. data/lib/samagotchi/client_id.rb +4 -0
  18. data/lib/samagotchi/command_intake.rb +56 -0
  19. data/lib/samagotchi/context_absorber.rb +6 -3
  20. data/lib/samagotchi/context_candidate_choice.rb +8 -5
  21. data/lib/samagotchi/context_candidates.rb +11 -8
  22. data/lib/samagotchi/context_command.rb +9 -3
  23. data/lib/samagotchi/context_poller.rb +5 -2
  24. data/lib/samagotchi/context_sources.rb +6 -4
  25. data/lib/samagotchi/engine/events.rb +13 -2
  26. data/lib/samagotchi/engine/guardrails.rb +3 -4
  27. data/lib/samagotchi/engine.rb +1 -0
  28. data/lib/samagotchi/engine_peers.rb +1 -0
  29. data/lib/samagotchi/events.rb +71 -0
  30. data/lib/samagotchi/guardrails/approvals.rb +27 -3
  31. data/lib/samagotchi/hook_routes.rb +0 -1
  32. data/lib/samagotchi/memory_bundle/asset_installer.rb +27 -6
  33. data/lib/samagotchi/memory_bundle/bundle_hook.rb +7 -27
  34. data/lib/samagotchi/memory_bundle/file_actions.rb +3 -4
  35. data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
  36. data/lib/samagotchi/memory_bundle/installer.rb +14 -11
  37. data/lib/samagotchi/memory_bundle/manifest.rb +6 -0
  38. data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -3
  39. data/lib/samagotchi/memory_bundle/status.rb +2 -2
  40. data/lib/samagotchi/memory_bundle/uninstaller.rb +2 -0
  41. data/lib/samagotchi/memory_orphans.rb +14 -4
  42. data/lib/samagotchi/memory_paths.rb +29 -6
  43. data/lib/samagotchi/memory_usage.rb +23 -12
  44. data/lib/samagotchi/plugin/attached_context.rb +3 -1
  45. data/lib/samagotchi/plugin/sessions.rb +4 -1
  46. data/lib/samagotchi/project_command.rb +58 -20
  47. data/lib/samagotchi/project_id.rb +102 -0
  48. data/lib/samagotchi/project_id_clash.rb +62 -0
  49. data/lib/samagotchi/project_init.rb +214 -10
  50. data/lib/samagotchi/project_scope.rb +11 -2
  51. data/lib/samagotchi/schedules.rb +99 -39
  52. data/lib/samagotchi/session.rb +17 -2
  53. data/lib/samagotchi/session_access/local.rb +8 -5
  54. data/lib/samagotchi/session_access/remote.rb +2 -1
  55. data/lib/samagotchi/session_access.rb +4 -1
  56. data/lib/samagotchi/session_commands.rb +7 -3
  57. data/lib/samagotchi/session_continue.rb +10 -6
  58. data/lib/samagotchi/session_manager/spawn.rb +9 -2
  59. data/lib/samagotchi/session_manager/worker_command.rb +7 -19
  60. data/lib/samagotchi/state_format.rb +118 -0
  61. data/lib/samagotchi/tools/context_read.rb +7 -2
  62. data/lib/samagotchi/tools/delegate.rb +10 -2
  63. data/lib/samagotchi/turn_stages/context_judge.rb +7 -7
  64. data/lib/samagotchi/update_command.rb +2 -2
  65. data/lib/samagotchi/version.rb +1 -1
  66. data/lib/samagotchi/web/app.rb +5 -4
  67. data/lib/samagotchi/web/session_access_routes.rb +3 -3
  68. data/lib/samagotchi/worker.rb +18 -6
  69. data/lib/samagotchi/worker_inbound.rb +21 -2
  70. metadata +5 -1
@@ -353,7 +353,8 @@ module Samagotchi
353
353
  def context_candidates(target)
354
354
  return [] if target.project?
355
355
 
356
- ContextCandidates.with_status(target.session_id, project_root: target.project_root, state_dir: @state_dir)
356
+ ContextCandidates.with_status(target.session_id, project_root: target.project_root, project_id: target.project_id,
357
+ state_dir: @state_dir)
357
358
  .map { |candidate, status| ContextCandidate.new(candidate: candidate, status: status) }
358
359
  end
359
360
 
@@ -371,7 +372,9 @@ module Samagotchi
371
372
  ContextSources.check_name!(name)
372
373
  return :removed if context_location(target).remove(name)
373
374
 
374
- project = !target.project? && ContextSources.project_location_for(target.project_root, state_dir: @state_dir)
375
+ project = !target.project? && ContextSources.project_location_for(target.project_root,
376
+ project_id: target.project_id,
377
+ state_dir: @state_dir)
375
378
  project&.source(name) ? :project : :missing
376
379
  end
377
380
 
@@ -394,7 +397,7 @@ module Samagotchi
394
397
  # live in.
395
398
  def context_location(target)
396
399
  if target.project?
397
- ContextSources.project_location_for(target.project_root, state_dir: @state_dir)
400
+ ContextSources.project_location_for(target.project_root, project_id: target.project_id, state_dir: @state_dir)
398
401
  else
399
402
  ContextSources.session_location(target.session_id, state_dir: @state_dir)
400
403
  end
@@ -534,8 +537,8 @@ module Samagotchi
534
537
  end
535
538
 
536
539
  def attached_to(target, shadowed: false)
537
- ContextSources.attached(target.session_id, project_root: target.project_root, state_dir: @state_dir,
538
- shadowed: shadowed)
540
+ ContextSources.attached(target.session_id, project_root: target.project_root, project_id: target.project_id,
541
+ state_dir: @state_dir, shadowed: shadowed)
539
542
  end
540
543
 
541
544
  # The block's post to the session's live worker, again on a 408 (the
@@ -809,7 +809,8 @@ module Samagotchi
809
809
  owner = json["owner"] && OwnerLock::Owner.new(pid: nil, kind: json["owner"].to_s, started_at: nil)
810
810
  SessionView.new(id: json["id"], status: json["status"], title: json["title"],
811
811
  display_title: json["display_title"] || json["title"] || json["first_preview"].to_s,
812
- project_root: json["project_root"], working_directory: json["working_directory"],
812
+ project_root: json["project_root"], project_id: json["project_id"],
813
+ working_directory: json["working_directory"],
813
814
  model_name: json["model_name"], model_typed: json["model_typed"],
814
815
  parent_answers: json["parent_answers"], owner: owner,
815
816
  pending_question: PendingQuestion.from_file(pending))
@@ -166,7 +166,10 @@ module Samagotchi
166
166
  # @!attribute owner [OwnerLock::Owner, nil] its worker or one-turn chi run
167
167
  # @!attribute pending_question [PendingQuestion, nil] the file's
168
168
  SessionView = Data.define(:id, :status, :title, :display_title, :project_root, :working_directory, :model_name,
169
- :model_typed, :parent_answers, :owner, :pending_question)
169
+ :model_typed, :parent_answers, :owner, :pending_question, :project_id) do
170
+ def initialize(id:, status:, title:, display_title:, project_root:, working_directory:, model_name:,
171
+ model_typed:, parent_answers:, owner:, pending_question:, project_id: nil) = super
172
+ end
170
173
 
171
174
  # What chi send --new created (Remote; Local gives the Session): its
172
175
  # id, a model warning, the session it continues, the delegates it took
@@ -473,7 +473,8 @@ module Samagotchi
473
473
 
474
474
  peers = Tools::Peers.new(session_id: session.id, cwd: session.working_directory, state_dir: @engine.peer_state_dir)
475
475
  listing = Tools::ContextRead.call("", peers: peers)
476
- candidates = ContextCandidates.listing(session.id, project_root: session.project_root, state_dir: @engine.peer_state_dir)
476
+ candidates = ContextCandidates.listing(session.id, project_root: session.project_root, project_id: session.project_id,
477
+ state_dir: @engine.peer_state_dir)
477
478
  candidates ? "#{listing}\n\n#{candidates}" : listing
478
479
  end
479
480
 
@@ -618,9 +619,12 @@ module Samagotchi
618
619
 
619
620
  def approval_line(entry)
620
621
  what = entry["key"] ? entry["key"].tr("\n", " ") : "any call rule #{entry["rule"]} asks about"
621
- where = if entry["scope"] == "session" then "session #{entry["session_id"].to_s[0, 8]}"
622
+ where = if entry["scope"] == "session"
623
+ place = entry["repo_root"] ? " in #{entry["repo_root"]}" : ""
624
+ "session #{entry["session_id"].to_s[0, 8]}#{place}#{" with its env:" if entry["env"]}"
622
625
  elsif entry["repo"] then "in repo #{entry["repo"]}"
623
- else "in #{entry["repo_root"]}"
626
+ else
627
+ "in #{entry["repo_root"]}"
624
628
  end
625
629
  rule = entry["rule"] ? " (rule #{entry["rule"]}, #{entry["source"]})" : ""
626
630
  "#{entry["scope"]}: #{what} — #{where}#{rule}, #{entry["created_at"]}"
@@ -91,6 +91,9 @@ module Samagotchi
91
91
  # @param prompt [String, nil] its first message (nil: it starts idle)
92
92
  # @param preview [String, nil] what the lists show before the first turn;
93
93
  # by default the prompt, else the previous link's preview
94
+ # @param client_id [String, nil] who sends +prompt+ when no person does
95
+ # (a schedule's chain firing: ClientId.schedule), the first turn's
96
+ # origin (spawn_session's client_id:)
94
97
  # @return [Session] the new link (#model_warning as spawn_session's,
95
98
  # #moved_children: the delegates it took over, #moved_context: the
96
99
  # attached context it took (ContextMove), #opener:
@@ -99,7 +102,8 @@ module Samagotchi
99
102
  # @raise [Refused] continued already, delegates open that can't
100
103
  # move, or its folder is gone
101
104
  # @raise [ArchiveRefused, OwnedByTUI] as archive_session
102
- def self.run(id_or_ref, prompt: nil, preview: nil, model: nil, state_dir: nil, recap_wait: RECAP_WAIT, archive_wait: 5)
105
+ def self.run(id_or_ref, prompt: nil, preview: nil, model: nil, state_dir: nil, recap_wait: RECAP_WAIT, archive_wait: 5,
106
+ client_id: nil)
103
107
  sd = state_dir || Session.default_state_dir
104
108
  TRIES.times do |try|
105
109
  id = SessionChain.resolve(id_or_ref, state_dir: sd)
@@ -108,7 +112,7 @@ module Samagotchi
108
112
  next :moved if follow && SessionChain.next_of(id, state_dir: sd)
109
113
 
110
114
  run_locked(id, prompt: prompt, preview: preview, model: model, state_dir: sd, recap_wait: recap_wait,
111
- archive_wait: archive_wait)
115
+ archive_wait: archive_wait, client_id: client_id)
112
116
  end
113
117
  return result unless result == :moved
114
118
  end
@@ -123,7 +127,7 @@ module Samagotchi
123
127
  end
124
128
  end
125
129
 
126
- private_class_method def self.run_locked(id, prompt:, preview:, model:, state_dir:, recap_wait:, archive_wait:)
130
+ private_class_method def self.run_locked(id, prompt:, preview:, model:, state_dir:, recap_wait:, archive_wait:, client_id:)
127
131
  previous = Session.load(id, state_dir: state_dir)
128
132
  checked = check!(previous, state_dir, model: model)
129
133
  plan = checked.plan
@@ -140,7 +144,7 @@ module Samagotchi
140
144
  archived = archive.archived - already
141
145
  begin
142
146
  spawn_continuation(previous, prompt: prompt, preview: preview, choice: checked.choice, state_dir: state_dir,
143
- move: plan.move)
147
+ move: plan.move, client_id: client_id)
144
148
  rescue StandardError
145
149
  # Nothing started: the delegates go back, what this archived goes
146
150
  # back to the lists, and a new link saved before its worker failed
@@ -201,7 +205,7 @@ module Samagotchi
201
205
 
202
206
  # The new link, holding off any worker but its own (StartingMarker.fresh?)
203
207
  # while +move+ goes to it.
204
- private_class_method def self.spawn_continuation(previous, prompt:, preview:, choice:, state_dir:, move:)
208
+ private_class_method def self.spawn_continuation(previous, prompt:, preview:, choice:, state_dir:, move:, client_id: nil)
205
209
  recap = RecapStore.read(Session.session_dir(previous.id, state_dir: state_dir))
206
210
  start = prompt.to_s.strip.empty? ? nil : prompt
207
211
  preview = previous.first_preview if preview.to_s.strip.empty? && start.nil?
@@ -218,7 +222,7 @@ module Samagotchi
218
222
  prompt: start, preview: preview, title: previous.title, working_directory: previous.working_directory,
219
223
  model_name: choice.model, setup: SessionSetup.of(previous), review: previous.review,
220
224
  continues: previous.id, note: SessionChain.note_text(previous, recap: recap, state_dir: state_dir),
221
- note_source: SessionChain::NOTE_SOURCE, state_dir: state_dir, before_spawn: before_spawn
225
+ note_source: SessionChain::NOTE_SOURCE, state_dir: state_dir, before_spawn: before_spawn, client_id: client_id
222
226
  )
223
227
  link.moved_children = move
224
228
  link.moved_context = moved_context
@@ -54,12 +54,16 @@ module Samagotchi
54
54
  # @param scratch [Boolean] a `chi scratch` session (its terminal's
55
55
  # AttachLauncher): its worker runs a scratch Engine and deletes it
56
56
  # once its last client is gone
57
+ # @param client_id [String, nil] who sent +prompt+ when no person did
58
+ # (a ClientId: a delegating parent, a schedule's chain link): its
59
+ # first turn's origin (Session#first_turn_client_id)
57
60
  # @return [Session] with #seed_images_dropped: refs whose file was gone,
58
61
  # and #model_warning: an id its host's saved list doesn't have
59
62
  def spawn_session(prompt:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
60
63
  memories: [], muted_memories: [], parent_id: nil, messages: [], images_from: nil,
61
64
  preview: nil, title: nil, delegate: false, setup: SessionSetup.new, continues: nil, note: nil,
62
- note_source: "chi", before_spawn: nil, review: nil, verify_fresh: nil, scratch: false)
65
+ note_source: "chi", before_spawn: nil, review: nil, verify_fresh: nil, scratch: false,
66
+ client_id: nil)
63
67
  sd = state_dir || Session.default_state_dir
64
68
  # The resolved ref is stored (a resumed session keeps its model when an
65
69
  # alias is retargeted), with the name as typed beside it. A wrong host
@@ -98,7 +102,10 @@ module Samagotchi
98
102
  # for it can name it (chi send --new --wait, chi web's create): the
99
103
  # worker runs it with that origin, and its reply file and last_turn
100
104
  # carry the id.
101
- session.first_turn_id = SecureRandom.uuid unless prompt.to_s.strip.empty?
105
+ unless prompt.to_s.strip.empty?
106
+ session.first_turn_id = SecureRandom.uuid
107
+ session.first_turn_client_id = client_id
108
+ end
102
109
  # The worker takes last_prompt and clears it, and messages are saved at
103
110
  # the turn's end: until then this is the only preview a list has.
104
111
  session.first_preview = Session.preview_of(preview.to_s.strip.empty? ? prompt : preview)
@@ -3,10 +3,10 @@
3
3
  module Samagotchi
4
4
  class SessionManager
5
5
  # CROSS-VERSION CONTRACT: this boot string, the entry it names
6
- # (WorkerBoot.run_session_loop(id, state_dir:)), the legacy forwarder
7
- # below, the env keys spawn_options sets and the input/ file
8
- # format are read by a NEWER chi than the one writing them. Change them
9
- # only additively (spec/worker_contract_spec.rb).
6
+ # (WorkerBoot.run_session_loop(id, state_dir:)), the env keys
7
+ # spawn_options sets and the input/ file format are read by a NEWER chi
8
+ # than the one writing them. Change them only additively
9
+ # (spec/worker_contract_spec.rb).
10
10
  BOOT_FALLBACK_ENV = "SAMAGOTCHI_BOOT_FALLBACK"
11
11
  # "1" in a worker spawn_options started: it traps signals (WorkerSignals).
12
12
  # WorkerBoot.run_session_loop deletes it from ENV as it reads it.
@@ -28,9 +28,9 @@ module Samagotchi
28
28
 
29
29
  # WorkerCommand: the worker's command line and env (CROSS-VERSION
30
30
  # CONTRACT: spec/worker_contract_spec.rb); the worker process itself is
31
- # WorkerBoot (worker_boot.rb). The boot command loads it directly; the
32
- # forwarder (run_session_loop) remains for older boot strings. Calls no
33
- # other part. Meant to be loaded through samagotchi/session_manager.
31
+ # WorkerBoot (worker_boot.rb), which the boot command loads directly.
32
+ # Calls no other part. Meant to be loaded through
33
+ # samagotchi/session_manager.
34
34
  module WorkerCommandPart
35
35
  # Build the opts hash passed to Process.spawn for a forked worker. The
36
36
  # child inherits this process's ENV and reads config.yml itself (as it
@@ -94,18 +94,6 @@ module Samagotchi
94
94
  opts
95
95
  end
96
96
 
97
- # The worker's legacy entry for boot strings written by older chi
98
- # (CROSS-VERSION CONTRACT: spec/worker_contract_spec.rb): it forwards to
99
- # WorkerBoot.run_session_loop, which runs the worker process. Looked up
100
- # by name on purpose: a constant reference here would put SessionManager
101
- # back into one cycle with Worker, Engine and Bridge (the class map).
102
- # REMOVE in the next release: boot strings name WorkerBoot since this
103
- # one (see ~/.claude/plans/arch-worker-boot.md).
104
- def run_session_loop(session_id, state_dir: nil, **)
105
- require_relative "../worker_boot"
106
- Samagotchi.const_get(:WorkerBoot).run_session_loop(session_id, state_dir: state_dir, **)
107
- end
108
-
109
97
  # The worker's command line. Run from an installed gem, the worker
110
98
  # activates the newest installed samagotchi (`>= 0.a`, prereleases
111
99
  # count), whoever spawns it: an old chi web, chi send or --attach then
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # A versioned JSON state-file schema. Missing members must be required or
5
+ # declare the format version and default that introduced them.
6
+ class StateFormat
7
+ REQUIRED = Object.new.freeze
8
+
9
+ Member = Data.define(:name, :introduced_in, :default, :persisted) do
10
+ def required? = introduced_in.nil?
11
+ def defaulted? = !required?
12
+ def persisted? = persisted
13
+ end
14
+
15
+ Record = Data.define(:attributes, :extra, :raw, :missing) do
16
+ def readable? = missing.empty?
17
+
18
+ def to_h
19
+ return raw unless readable?
20
+
21
+ extra.transform_keys(&:to_s).merge(attributes.transform_keys(&:to_s))
22
+ end
23
+ end
24
+
25
+ Document = Data.define(:version, :extra, :entries)
26
+
27
+ class InvalidDocument < StandardError; end
28
+
29
+ def self.required(name)
30
+ Member.new(name: name.to_sym, introduced_in: nil, default: REQUIRED, persisted: true)
31
+ end
32
+
33
+ def self.added(name, in_version:, default:)
34
+ Member.new(name: name.to_sym, introduced_in: in_version, default: default, persisted: true)
35
+ end
36
+
37
+ def self.extra(name, in_version:)
38
+ Member.new(name: name.to_sym, introduced_in: in_version, default: ->(_attributes) { {} }, persisted: false)
39
+ end
40
+
41
+ attr_reader :members
42
+
43
+ def initialize(version:, members:)
44
+ @version = version
45
+ @members = members.freeze
46
+ @extra_member = members.find { |member| !member.persisted? }
47
+ raise ArgumentError, "a state format can declare only one extra member" if members.count { |member| !member.persisted? } > 1
48
+ raise ArgumentError, "duplicate state format members" unless members.map(&:name).uniq == members.map(&:name)
49
+
50
+ members.each do |member|
51
+ required = member.required? && member.default.equal?(REQUIRED)
52
+ defaulted = member.defaulted? && member.introduced_in.is_a?(Integer) &&
53
+ member.introduced_in.positive? && member.introduced_in <= version &&
54
+ !member.default.equal?(REQUIRED)
55
+ next if required || defaulted
56
+
57
+ raise ArgumentError, "#{member.name} must be required or have an introduction version and default"
58
+ end
59
+ end
60
+
61
+ def read(data, collection:)
62
+ raise InvalidDocument, "the state file must contain a JSON object" unless data.is_a?(Hash)
63
+
64
+ collection = collection.to_s
65
+ entries = Array(data[collection]).map { |raw| read_record(raw) }
66
+ known = %w[version updated_at] + [collection]
67
+ stored_version = data["version"]
68
+ stored_version = 0 unless stored_version.is_a?(Integer) && stored_version >= 0
69
+
70
+ Document.new(version: [@version, stored_version].max,
71
+ extra: data.reject { |key, _value| known.include?(key.to_s) },
72
+ entries: entries)
73
+ end
74
+
75
+ def write(document, collection:, updated_at:)
76
+ document.extra.transform_keys(&:to_s).merge(
77
+ "version" => [document.version, @version].max,
78
+ "updated_at" => updated_at,
79
+ collection.to_s => document.entries.map(&:to_h)
80
+ )
81
+ end
82
+
83
+ private
84
+
85
+ def read_record(raw)
86
+ unless raw.is_a?(Hash)
87
+ missing = members.select(&:required?).map(&:name)
88
+ return Record.new(attributes: {}, extra: {}, raw: raw, missing: missing)
89
+ end
90
+
91
+ attributes = {}
92
+ missing = []
93
+ members.each do |member|
94
+ next unless member.persisted?
95
+
96
+ key = member.name.to_s
97
+ if raw.key?(key)
98
+ attributes[member.name] = raw[key]
99
+ elsif member.required?
100
+ missing << member.name
101
+ else
102
+ attributes[member.name] = default_value(member.default, attributes)
103
+ end
104
+ end
105
+
106
+ known = members.select(&:persisted?).map { |member| member.name.to_s }
107
+ extra = raw.reject { |key, _value| known.include?(key.to_s) }
108
+ extra = default_value(@extra_member.default, attributes).merge(extra) if @extra_member
109
+ Record.new(attributes: attributes, extra: extra, raw: raw, missing: missing)
110
+ end
111
+
112
+ def default_value(default, attributes)
113
+ value = default.respond_to?(:call) ? default.call(attributes) : default
114
+ value = value.dup if value.is_a?(Hash) || value.is_a?(Array)
115
+ value
116
+ end
117
+ end
118
+ end
@@ -51,8 +51,13 @@ module Samagotchi
51
51
  @own = ContextSources.session_location(peers.session_id, state_dir: @state_dir)
52
52
  # The Engine's PeerView knows the session's project; a plain Peers
53
53
  # (specs, a bare kernel) has only its folder.
54
- root = peers.respond_to?(:project_root) ? peers.project_root : ProjectScope.root_for(peers.cwd)
55
- @attached = ContextSources.attached(peers.session_id, project_root: root, state_dir: @state_dir)
54
+ root, id = if peers.respond_to?(:project_root)
55
+ [peers.project_root, peers.project_id]
56
+ else
57
+ found = ProjectScope.project_for(peers.cwd)
58
+ [found&.root, found&.id]
59
+ end
60
+ @attached = ContextSources.attached(peers.session_id, project_root: root, project_id: id, state_dir: @state_dir)
56
61
  @max_chars = [max_chars.to_i, MIN_CHARS].max
57
62
  @now = now
58
63
  # The peer bundles' declared sources (PeerSources), through the
@@ -13,6 +13,10 @@ require_relative "../child_reports"
13
13
  require_relative "../worker_link"
14
14
 
15
15
  module Samagotchi
16
+ # Loaded on the first task (a leaf; the bundle test kit's load budget,
17
+ # spec/samagotchi/testing/sandbox_spec.rb).
18
+ autoload :CommandIntake, File.expand_path("../command_intake", __dir__)
19
+
16
20
  # Loaded on first use: this autoload keeps SessionManager and its ~80
17
21
  # files unloaded until the delegate/session path needs them.
18
22
  autoload :SessionManager, File.expand_path("../session_manager", __dir__)
@@ -53,6 +57,9 @@ module Samagotchi
53
57
 
54
58
  task = content.to_s.strip
55
59
  return "Error: give the task, the child's first message" if task.empty?
60
+ # The child's intake takes no shell line from its parent: say so
61
+ # now, so the model rewrites the task (a /word one is the child's text).
62
+ return "Error: #{CommandIntake::REFUSED}" if CommandIntake.shell_line?(task)
56
63
 
57
64
  sd = peers.state_dir || Session.default_state_dir
58
65
  parent = Session.load(peers.session_id, state_dir: sd)
@@ -172,7 +179,8 @@ module Samagotchi
172
179
  parent_answers: parent.parent_answers)
173
180
  child = SessionManager.spawn_session(prompt: task, working_directory: folder,
174
181
  model_name: child_model(model, parent), memories: CHILD_MEMORIES,
175
- parent_id: parent.id, delegate: true, setup: setup, state_dir: state_dir)
182
+ parent_id: parent.id, delegate: true, setup: setup, state_dir: state_dir,
183
+ client_id: ClientId.delegate(parent.id))
176
184
  DelegateWait.mark_started(parent.id, child, state_dir: state_dir)
177
185
  # The line for a task that names a worktree while the child runs in
178
186
  # the parent's folder (no cwd: given): the child still starts.
@@ -358,7 +366,7 @@ module Samagotchi
358
366
 
359
367
  # Only a reply after this delivery counts as the follow-up's.
360
368
  DelegateWait.mark_seen(parent.id, child, state_dir: state_dir)
361
- delivered = SessionManager.deliver_turn(id, prompt: task, client_id: "#{ClientId::DELEGATE_PREFIX}#{parent.id[0, 8]}",
369
+ delivered = SessionManager.deliver_turn(id, prompt: task, client_id: ClientId.delegate(parent.id),
362
370
  state_dir: state_dir)
363
371
  case delivered.status
364
372
  when :accepted
@@ -89,7 +89,8 @@ module Samagotchi
89
89
  def pass(session, texts, phase:, state_dir:, recorder:)
90
90
  return if session.id.nil? || session.scratch
91
91
 
92
- rows = ContextCandidates.with_status(session.id, project_root: session.project_root, state_dir: state_dir)
92
+ rows = ContextCandidates.with_status(session.id, project_root: session.project_root,
93
+ project_id: session.project_id, state_dir: state_dir)
93
94
  return if rows.empty?
94
95
 
95
96
  log = ContextCandidatesLog.log_for(state_dir)
@@ -134,6 +135,7 @@ module Samagotchi
134
135
  def choice(session, log, settings, state_dir)
135
136
  session_id = session.id
136
137
  project_root = session.project_root
138
+ project_id = session.project_id
137
139
  show = lambda do |**card|
138
140
  current = @current_session.call
139
141
  next nil unless current.nil? || current.id == session_id
@@ -146,6 +148,7 @@ module Samagotchi
146
148
  end
147
149
  lambda do |job, fields|
148
150
  ContextCandidateChoice.after_judged(job: job, fields: fields, settings: settings, project_root: project_root,
151
+ project_id: project_id,
149
152
  state_dir: state_dir, log: log, show_card: show)
150
153
  end
151
154
  end
@@ -156,12 +159,9 @@ module Samagotchi
156
159
  def parent_attached(session, state_dir)
157
160
  return nil unless session.delegate? && session.parent_id
158
161
 
159
- parent_root = if Session.exist?(session.parent_id, state_dir: state_dir)
160
- Session.load(session.parent_id, state_dir: state_dir).project_root
161
- else
162
- session.project_root
163
- end
164
- ContextSources.attached(session.parent_id, project_root: parent_root, state_dir: state_dir)
162
+ parent = Session.exist?(session.parent_id, state_dir: state_dir) ? Session.load(session.parent_id, state_dir: state_dir) : session
163
+ ContextSources.attached(session.parent_id, project_root: parent.project_root, project_id: parent.project_id,
164
+ state_dir: state_dir)
165
165
  .map { |attached| attached.source.title || attached.name }
166
166
  rescue StandardError => e
167
167
  Log.warn(:context, "context_parent_attached_failed", error: e.class.name, msg: e.message.to_s[0, 200])
@@ -356,7 +356,7 @@ module Samagotchi
356
356
  notes = file_notes(row, update.name, update.to || update.from, update.merged, update.behind, update.trash_copy, earlier: earlier)
357
357
  case update.status
358
358
  when :would_update, :updated
359
- notes = [adds(update.adds), *notes, replaced(update.replaced), *update.kept_own, update.note].compact
359
+ notes = [adds(update.added_files), adds(update.adds), *notes, replaced(update.replaced), *update.kept_own, update.note].compact
360
360
  done(row, :update, notes.empty? ? nil : notes.join("; "))
361
361
  when :would_merge, :merged, :up_to_date then same_version(row, [*notes, update.note, update.manifest].compact, changed: Array(update.merged).any?)
362
362
  when :skipped then skipped(row, [update.note, *notes].join("; "))
@@ -522,7 +522,7 @@ module Samagotchi
522
522
  row
523
523
  end
524
524
 
525
- # A profile's new members: "+ btw".
525
+ # New memory files or profile members: "+ file".
526
526
  def adds(members)
527
527
  members.nil? || members.empty? ? nil : "+ #{members.join(", ")}"
528
528
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.159.0"
4
+ VERSION = "0.160.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -1030,7 +1030,7 @@ module Samagotchi
1030
1030
 
1031
1031
  # One normal form for the decision, the mkdir and the realpath (a `..` after a symlink would differ).
1032
1032
  dir = File.expand_path(dir)
1033
- outcome = ProjectInit.call(dir, home: home)
1033
+ outcome = ProjectInit.call(dir, home: home, state_dir: default_state_dir)
1034
1034
  return error_response(422, outcome.reason.to_s, outcome.message) if outcome.kind == :refused
1035
1035
 
1036
1036
  if outcome.kind == :git
@@ -1455,7 +1455,8 @@ module Samagotchi
1455
1455
  body = { session_id: id, sources: rows, can_add_url: ContextProviders.any? }
1456
1456
  # The candidates too, for chi context ls (no UI shows them yet).
1457
1457
  if full
1458
- body[:candidates] = ContextCandidates.with_status(id, project_root: session.project_root, state_dir: default_state_dir)
1458
+ body[:candidates] = ContextCandidates.with_status(id, project_root: session.project_root, project_id: session.project_id,
1459
+ state_dir: default_state_dir)
1459
1460
  .map { |candidate, status| candidate.to_h.merge("name" => candidate.name, "status" => status) }
1460
1461
  end
1461
1462
  json_response(200, body)
@@ -1573,8 +1574,8 @@ module Samagotchi
1573
1574
  end
1574
1575
 
1575
1576
  def attached_context(session, shadowed: false)
1576
- ContextSources.attached(session.id, project_root: session.project_root, state_dir: default_state_dir,
1577
- shadowed: shadowed)
1577
+ ContextSources.attached(session.id, project_root: session.project_root, project_id: session.project_id,
1578
+ state_dir: default_state_dir, shadowed: shadowed)
1578
1579
  end
1579
1580
 
1580
1581
  # @return [ContextSources::Attached, nil]
@@ -101,7 +101,7 @@ module Samagotchi
101
101
  brief: true,
102
102
  session: { id: session.id, status: session.status, title: session.title,
103
103
  display_title: session.display_title, project_root: session.project_root,
104
- working_directory: session.working_directory, model_name: session.model_name,
104
+ project_id: session.project_id, working_directory: session.working_directory, model_name: session.model_name,
105
105
  model_typed: session.model_typed, parent_answers: session.parent_answers,
106
106
  owner: owner&.kind },
107
107
  pending_question: session.pending_question&.to_file
@@ -492,8 +492,8 @@ module Samagotchi
492
492
  # @raise [ArgumentError] no such session
493
493
  def context_target(id)
494
494
  session = session_access.show(id)
495
- ContextCommand::Target.new(session_id: id, project_root: session.project_root, cwd: session.working_directory,
496
- label: id[0, 8])
495
+ ContextCommand::Target.new(session_id: id, project_root: session.project_root, project_id: session.project_id,
496
+ cwd: session.working_directory, label: id[0, 8])
497
497
  end
498
498
  end
499
499
  end
@@ -4,6 +4,7 @@ require "fileutils"
4
4
  require "securerandom"
5
5
 
6
6
  require_relative "client_id"
7
+ require_relative "command_intake"
7
8
  require_relative "config"
8
9
  require_relative "engine"
9
10
  require_relative "guardrails/parent_policy"
@@ -338,7 +339,7 @@ module Samagotchi
338
339
  predecessor: @session.continues)
339
340
  # Its attached context's notes (ContextSources).
340
341
  @context_absorber = ContextAbsorber.new(session_id: @session_id, state_dir: @state_dir,
341
- project_root: @session.project_root,
342
+ project_root: @session.project_root, project_id: @session.project_id,
342
343
  peer_sources: @engine.peer_sources, peer_turns: -> { @wakes.peer_turns })
343
344
  # What comes in between turns, and how each kind is saved.
344
345
  @inbound = WorkerInbound.new(session: @session, state_dir: @state_dir, session_dir: @session_dir, engine: @engine,
@@ -468,8 +469,8 @@ module Samagotchi
468
469
  # Attached context's commands, in the background; a new text wakes
469
470
  # the loop to absorb it. Not activity: the idle exit stops it.
470
471
  @context_poller = ContextPoller.new(session_id: @session_id, state_dir: @state_dir,
471
- project_root: @session.project_root, cwd: @session.working_directory,
472
- on_change: -> { @waker.wake }).start
472
+ project_root: @session.project_root, project_id: @session.project_id,
473
+ cwd: @session.working_directory, on_change: -> { @waker.wake }).start
473
474
  @scratch_watch = start_scratch_watch if @session.scratch
474
475
  end
475
476
 
@@ -759,14 +760,15 @@ module Samagotchi
759
760
  end
760
761
 
761
762
  # The first prompt (spawn_session's): a delegate child's task, which is
762
- # its parent's although no client sent it. Its turn carries the id
763
- # spawn_session gave it, so a wait for it (--new --wait) names it.
763
+ # its parent's. Its turn carries the id spawn_session gave it, so a
764
+ # wait for it (--new --wait) names it, and its sender
765
+ # (WorkerInbound#initial_origin).
764
766
  # A Verify fresh child's is a Verify turn on its claim (its verdict is
765
767
  # recorded and forwarded to the parent).
766
768
  def run_initial_prompt(prompt)
767
769
  @initial_turn = true
768
770
  fresh = @session.verify_fresh
769
- run_prompt(WorkerInbound::Prompt.new(text: prompt, origin: TurnOrigin.new(enqueued_id: @session.first_turn_id),
771
+ run_prompt(WorkerInbound::Prompt.new(text: prompt, origin: @inbound.initial_origin,
770
772
  anchors: { verify: fresh&.anchor }))
771
773
  ensure
772
774
  @initial_turn = false
@@ -1215,6 +1217,8 @@ module Samagotchi
1215
1217
  # the cards and notices it showed, held: they follow its command_ran,
1216
1218
  # as its output
1217
1219
  def run_command_line(command)
1220
+ return [shell_refused(command), []] if CommandIntake.refused?(command.line, command.client_id)
1221
+
1218
1222
  @engine.holding_announcements do
1219
1223
  command_result(command) do
1220
1224
  @commands.run(command.line, confirm: confirm?(command), as_parent: as_parent?(command), user_ui: user_ui?(command))
@@ -1222,6 +1226,14 @@ module Samagotchi
1222
1226
  end
1223
1227
  end
1224
1228
 
1229
+ # A shell line from a sender that may not run one (CommandIntake: a
1230
+ # delegate's task, a schedule's prompt, a plugin's message): not run,
1231
+ # its command_ran an error with the one refusal text.
1232
+ def shell_refused(command)
1233
+ Log.warn(:worker, "shell_line_refused", client_id: command.client_id)
1234
+ SessionCommands::Result.new(status: :error, output: CommandIntake::REFUSED, changed: [])
1235
+ end
1236
+
1225
1237
  # Whether +command+ asks before a costly /thinking or /reasoning-history
1226
1238
  # change (CacheConfirm): only a person at a web tab or a TUI sees the
1227
1239
  # question; chi send, a delegate, a plugin and a card's action apply it.