samagotchi 0.148.0 → 0.150.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 (137) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -1
  3. data/README.md +4 -2
  4. data/docs/architecture.md +3 -3
  5. data/docs/bundles.md +38 -1
  6. data/docs/cli.md +76 -24
  7. data/docs/configuration.md +8 -6
  8. data/docs/guardrails.md +2 -1
  9. data/docs/hooks.md +2 -1
  10. data/docs/internals/background-tasks.md +1 -1
  11. data/docs/internals/bridge.md +8 -0
  12. data/docs/internals/bundles.md +42 -14
  13. data/docs/internals/objectives.md +269 -0
  14. data/docs/internals/web-frontend.md +4 -1
  15. data/docs/internals/web-push.md +2 -2
  16. data/docs/internals/working-set.md +5 -2
  17. data/docs/memory.md +47 -9
  18. data/docs/plugins.md +10 -2
  19. data/docs/releasing.md +1 -1
  20. data/docs/sessions.md +45 -2
  21. data/docs/system-one.md +17 -1
  22. data/docs/testing.md +5 -0
  23. data/lib/samagotchi/atomic_file.rb +3 -0
  24. data/lib/samagotchi/bootstrap_command.rb +5 -2
  25. data/lib/samagotchi/bridge/card_store.rb +52 -7
  26. data/lib/samagotchi/bridge.rb +1 -1
  27. data/lib/samagotchi/bundle_command.rb +91 -68
  28. data/lib/samagotchi/bundler_env.rb +24 -0
  29. data/lib/samagotchi/bundles/coordinator/manifest.yml +6 -4
  30. data/lib/samagotchi/bundles/coordinator/plugin.rb +3 -2
  31. data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +9 -0
  32. data/lib/samagotchi/bundles/decision-card/manifest.yml +13 -0
  33. data/lib/samagotchi/bundles/decision-card/plugin.rb +280 -0
  34. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  35. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  36. data/lib/samagotchi/bundles/github-pr/plugin.rb +2 -1
  37. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +3 -1
  38. data/lib/samagotchi/bundles/known-names/manifest.yml +3 -2
  39. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +2 -2
  40. data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
  41. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  42. data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
  43. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  44. data/lib/samagotchi/chi_home.rb +4 -2
  45. data/lib/samagotchi/chi_shim.rb +2 -1
  46. data/lib/samagotchi/child_env.rb +10 -3
  47. data/lib/samagotchi/config.rb +7 -1
  48. data/lib/samagotchi/context_note.rb +17 -5
  49. data/lib/samagotchi/engine.rb +113 -6
  50. data/lib/samagotchi/engine_peers.rb +11 -0
  51. data/lib/samagotchi/git_env.rb +43 -0
  52. data/lib/samagotchi/guardrails/context.rb +2 -0
  53. data/lib/samagotchi/guardrails/pair_boundary.rb +3 -2
  54. data/lib/samagotchi/guardrails/targets.rb +4 -1
  55. data/lib/samagotchi/llm/chat_loop.rb +1 -1
  56. data/lib/samagotchi/memory_bundle/asset_installer.rb +16 -2
  57. data/lib/samagotchi/memory_bundle/file_standing.rb +167 -0
  58. data/lib/samagotchi/memory_bundle/installed_bundle.rb +62 -1
  59. data/lib/samagotchi/memory_bundle/installer.rb +161 -52
  60. data/lib/samagotchi/memory_bundle/listing.rb +4 -3
  61. data/lib/samagotchi/memory_bundle/merger.rb +209 -8
  62. data/lib/samagotchi/memory_bundle/placeholder.rb +2 -1
  63. data/lib/samagotchi/memory_bundle/profile.rb +7 -3
  64. data/lib/samagotchi/memory_bundle/provenance.rb +178 -20
  65. data/lib/samagotchi/memory_bundle/remerge.rb +167 -0
  66. data/lib/samagotchi/memory_bundle/shipped_history.rb +79 -0
  67. data/lib/samagotchi/memory_bundle/shipped_update.rb +89 -24
  68. data/lib/samagotchi/memory_bundle/source.rb +3 -3
  69. data/lib/samagotchi/memory_bundle/status.rb +21 -3
  70. data/lib/samagotchi/memory_bundle/system_bundle.rb +98 -33
  71. data/lib/samagotchi/memory_bundle/text_merge.rb +399 -0
  72. data/lib/samagotchi/memory_bundle/trash.rb +58 -2
  73. data/lib/samagotchi/memory_bundle/uninstaller.rb +26 -6
  74. data/lib/samagotchi/note_command.rb +1 -1
  75. data/lib/samagotchi/note_delivery.rb +6 -2
  76. data/lib/samagotchi/objectives.rb +395 -0
  77. data/lib/samagotchi/objectives_audit.rb +115 -0
  78. data/lib/samagotchi/objectives_check.rb +365 -0
  79. data/lib/samagotchi/objectives_command.rb +192 -0
  80. data/lib/samagotchi/plugin/api.rb +2 -1
  81. data/lib/samagotchi/plugin/context.rb +21 -1
  82. data/lib/samagotchi/project_env.rb +3 -2
  83. data/lib/samagotchi/self_report.rb +2 -0
  84. data/lib/samagotchi/session_commands.rb +15 -0
  85. data/lib/samagotchi/session_continue.rb +3 -0
  86. data/lib/samagotchi/session_inbox.rb +14 -6
  87. data/lib/samagotchi/session_manager.rb +1 -1
  88. data/lib/samagotchi/steer.rb +24 -4
  89. data/lib/samagotchi/testing/boundary.rb +2 -0
  90. data/lib/samagotchi/testing/host.rb +1 -0
  91. data/lib/samagotchi/testing/minitest.rb +3 -0
  92. data/lib/samagotchi/testing/rspec.rb +3 -0
  93. data/lib/samagotchi/testing/sandbox.rb +3 -1
  94. data/lib/samagotchi/tool_activity.rb +17 -0
  95. data/lib/samagotchi/tool_declarations.rb +34 -0
  96. data/lib/samagotchi/tools/builtin_calls.rb +2 -1
  97. data/lib/samagotchi/tools/builtins.rb +4 -1
  98. data/lib/samagotchi/tools/delegate.rb +2 -1
  99. data/lib/samagotchi/tools/objectives_tool.rb +75 -0
  100. data/lib/samagotchi/tools/peers.rb +3 -0
  101. data/lib/samagotchi/tools/task_runtime.rb +13 -10
  102. data/lib/samagotchi/turn_flow.rb +3 -1
  103. data/lib/samagotchi/update_command.rb +76 -28
  104. data/lib/samagotchi/update_hint.rb +4 -1
  105. data/lib/samagotchi/version.rb +1 -1
  106. data/lib/samagotchi/web/auto_update.rb +8 -8
  107. data/lib/samagotchi/web/auto_update_record.rb +6 -4
  108. data/lib/samagotchi/web/message_display.rb +9 -0
  109. data/lib/samagotchi/web/participant_view.rb +2 -2
  110. data/lib/samagotchi/web/public/app.js +15 -3
  111. data/lib/samagotchi/web/public/block_open.js +13 -0
  112. data/lib/samagotchi/web/public/bubbles.js +4 -2
  113. data/lib/samagotchi/web/public/cards_place.js +30 -9
  114. data/lib/samagotchi/web/public/commands.js +4 -1
  115. data/lib/samagotchi/web/public/details_motion.js +2 -1
  116. data/lib/samagotchi/web/public/index.html +20 -14
  117. data/lib/samagotchi/web/public/objectives.css +80 -0
  118. data/lib/samagotchi/web/public/objectives_entry.js +22 -0
  119. data/lib/samagotchi/web/public/objectives_model.js +100 -0
  120. data/lib/samagotchi/web/public/objectives_moves.js +145 -0
  121. data/lib/samagotchi/web/public/objectives_strip.js +242 -0
  122. data/lib/samagotchi/web/public/objectives_wiring.js +62 -0
  123. data/lib/samagotchi/web/public/stage_model.js +5 -6
  124. data/lib/samagotchi/web/public/stage_view.js +8 -7
  125. data/lib/samagotchi/web/public/stream_handlers.js +6 -1
  126. data/lib/samagotchi/web/public/turn_block.js +34 -19
  127. data/lib/samagotchi/web/public/turn_html.js +4 -1
  128. data/lib/samagotchi/web/public/turn_model.js +1 -1
  129. data/lib/samagotchi/web/public/update_sheet.js +8 -3
  130. data/lib/samagotchi/web/public/working_set_view.js +10 -7
  131. data/lib/samagotchi/web/push/notifier.rb +7 -9
  132. data/lib/samagotchi/web/session_hub.rb +2 -1
  133. data/lib/samagotchi/web/session_show.rb +12 -1
  134. data/lib/samagotchi/web/update_ops.rb +2 -1
  135. data/lib/samagotchi/worker.rb +3 -0
  136. data/lib/samagotchi/worker_inbound.rb +16 -8
  137. metadata +22 -1
@@ -16,11 +16,19 @@ module Samagotchi
16
16
  # context_source: the attached context source a note is about
17
17
  # (ContextAbsorber), so the UIs can tie it to its chip. A note's wall
18
18
  # time (MessageTime::KEY) is every entry's, kept beside these.
19
- KEYS = %i[kind note_id source from_session from_cwd context_source].freeze
19
+ # objectives: the list an objectives note carries (Objectives), string
20
+ # keys inside.
21
+ KEYS = %i[kind note_id source from_session from_cwd context_source objectives].freeze
20
22
  HEADER = "CONTEXT NOTE"
21
23
  # A finished background task's note (TaskFinishNote): a header of its own.
22
24
  TASK_HEADER = "TASK FINISHED"
23
- HEADERS = [HEADER, TASK_HEADER].freeze
25
+ # The session's objectives (Objectives): a frame that instructs by itself,
26
+ # so a session with no list sends no line about them.
27
+ OBJECTIVES_HEADER = "OBJECTIVES"
28
+ OBJECTIVES_FRAME = "[#{OBJECTIVES_HEADER}, set by the user. What the user wants this session to achieve; " \
29
+ "replaces any earlier list. Keep work on it. Say when something falls outside it. " \
30
+ "Don't change it yourself.]".freeze
31
+ HEADERS = [HEADER, TASK_HEADER, OBJECTIVES_HEADER].freeze
24
32
 
25
33
  FRAME_HEAD = /\A\[(?:#{HEADERS.map { |h| Regexp.escape(h) }.join("|")})[^\n]*\n/
26
34
 
@@ -30,18 +38,22 @@ module Samagotchi
30
38
  # note starts a turn chi runs for it (a context wake), as a wake turn's
31
39
  # delegate report does (Steer.turn_prompt?). +header+: the frame's
32
40
  # (HEADERS), not kept as a key: the content carries it. Its wall time
33
- # (MessageTime::KEY) is +created_at+'s, else now.
41
+ # (MessageTime::KEY) is +created_at+'s, else now. +objectives+: an
42
+ # objectives note's payload (Objectives::List#to_payload).
34
43
  # @return [Hash] the conversation message
35
44
  def message(note_id:, text:, source:, created_at: nil, from_session: nil, from_cwd: nil, context_source: nil,
36
- turn_start: nil, turn_id: nil, header: HEADER, **)
45
+ turn_start: nil, turn_id: nil, header: HEADER, objectives: nil, **)
37
46
  { role: "system", kind: KIND, note_id: note_id, source: source, from_session: from_session,
38
- from_cwd: from_cwd, context_source: context_source, turn_start: (true if turn_start == true), turn_id: turn_id,
47
+ from_cwd: from_cwd, context_source: context_source, objectives: objectives,
48
+ turn_start: (true if turn_start == true), turn_id: turn_id,
39
49
  content: frame(text, source: source, created_at: created_at, from_session: from_session,
40
50
  from_cwd: from_cwd, header: header),
41
51
  MessageTime::KEY => MessageTime.iso_of(created_at) || MessageTime.iso(MessageTime.now) }.compact
42
52
  end
43
53
 
44
54
  def frame(text, source:, created_at: nil, from_session: nil, from_cwd: nil, header: HEADER)
55
+ return "#{OBJECTIVES_FRAME}\n#{text}\n[END NOTE]" if header == OBJECTIVES_HEADER
56
+
45
57
  time = clock(created_at)
46
58
  from = label(source: source, from_session: from_session, from_cwd: from_cwd)
47
59
  "[#{header || HEADER} from #{from}#{", #{time}" if time}]\n#{text}\n[END NOTE]"
@@ -51,6 +51,7 @@ require_relative "context_candidates"
51
51
  require_relative "context_candidates_log"
52
52
  require_relative "context_candidate_choice"
53
53
  require_relative "context_judge"
54
+ require_relative "objectives_audit"
54
55
  require_relative "owner_prompt"
55
56
  require_relative "side_judgments"
56
57
  require_relative "toolkit_suggest"
@@ -156,6 +157,8 @@ module Samagotchi
156
157
  # steers) and the idle layer's activity clock. Built first: a plugin
157
158
  # may steer or ask whether a turn runs while it loads.
158
159
  @turn_state = TurnState.new(clock: -> { monotonic_now })
160
+ @objective_proposals = []
161
+ @objective_proposals_mutex = Mutex.new
159
162
  # A message for the running turn cutting its generation.
160
163
  @steer_cut = SteerCut.new(clock: -> { monotonic_now }, controller: -> { active_cancel_controller },
161
164
  cut_after: -> { steer_cut_after })
@@ -767,6 +770,8 @@ module Samagotchi
767
770
  # command's ask_model between turns).
768
771
  # @return [String, nil]
769
772
  def side_author(kind)
773
+ # Check this is only ever the owner's, whichever turn runs as it ends.
774
+ return nil if kind.to_s == ObjectivesCheck::SOURCE
770
775
  return nil unless turn_running? || kind.to_s == RECAP_SIDE_KIND
771
776
 
772
777
  @guardrail_wiring.turn_author
@@ -956,7 +961,7 @@ module Samagotchi
956
961
  ANNOUNCEABLE_EVENTS = %i[turn_enqueued turn_dequeued input_merged prompt_restored continue_offered continue_resolved
957
962
  command_queued command_ran context_added card hook_notice guardrail_warning
958
963
  plugin_init_started plugin_init_finished working_set_updated participants_changed
959
- verdict_recorded].freeze
964
+ verdict_recorded objectives_updated turn_rolled_back].freeze
960
965
 
961
966
  # Put a transport-level event into the ordered event log. Unlike turn
962
967
  # events it reaches only persistent observers (no turn sink, no memory
@@ -987,6 +992,63 @@ module Samagotchi
987
992
  synchronize_events { emit_working_set(nil, session, session.messages) }
988
993
  end
989
994
 
995
+ # The session's objectives as the page draws them (Objectives.view), when
996
+ # that differs from the last announced: after an edit (its file written,
997
+ # pending), once a list note is in, at a turn's end (its moves) and after
998
+ # a rollback. A session with no list announces nothing. It only informs:
999
+ # a failure is logged, never the turn's.
1000
+ def announce_objectives(session = @session)
1001
+ return unless session
1002
+
1003
+ synchronize_events do
1004
+ view = Objectives.view(session.id, session.messages, state_dir: session_state_dir, running: live_conversation&.dup)
1005
+ next if view.nil? || view == @objectives_announced
1006
+
1007
+ @objectives_announced = view
1008
+ announce({ type: :objectives_updated, session_id: session.id, **view })
1009
+ end
1010
+ rescue StandardError => e
1011
+ Log.warn(:turn, "objectives_announce_failed", error: e.class.name, msg: e.message)
1012
+ end
1013
+
1014
+ # Check this (ObjectivesCheck)'s one side request. Its usage is in the
1015
+ # session's side line. Raises as ctx.ask_model's request does.
1016
+ # @param request [Array<Hash>] chat messages
1017
+ # @param model [String, nil] a configured model; nil: the session's
1018
+ # @return [String] the answer
1019
+ def ask_objective_check(request, model:)
1020
+ ask_side_model(request, timeout: ObjectivesCheck::TIMEOUT_SECONDS, max_tokens: ObjectivesCheck::MAX_TOKENS,
1021
+ cancel_controller: nil, model: model, source: ObjectivesCheck::SOURCE)
1022
+ end
1023
+
1024
+ # A change the model proposes (the objectives tool), kept until the turn
1025
+ # is over, as #context_choice's cards are: a card shown then is a quiet one, between turns, with its Apply kept
1026
+ # over a restart (CardStore), not a running turn's that says it waits on the
1027
+ # user. The same change twice in a turn is one card.
1028
+ # @return [Boolean] false past Objectives::PROPOSALS_PER_TURN
1029
+ def propose_objective(proposal)
1030
+ @objective_proposals_mutex.synchronize do
1031
+ next true if @objective_proposals.any? { |p| p.line == proposal.line }
1032
+ next false if @objective_proposals.size >= Objectives::PROPOSALS_PER_TURN
1033
+
1034
+ @objective_proposals << proposal
1035
+ true
1036
+ end
1037
+ end
1038
+
1039
+ def show_objective_proposals
1040
+ proposals = @objective_proposals_mutex.synchronize { @objective_proposals.slice!(0..) }
1041
+ proposals.each do |proposal|
1042
+ id = "objectives-#{SecureRandom.hex(4)}"
1043
+ show_card(source: Objectives::SOURCE, id: id, title: proposal.title, body: proposal.body,
1044
+ actions: [{ label: "Apply", command: proposal.action_line(id) },
1045
+ { label: "Dismiss", command: "/objectives --card #{id} dismiss" }])
1046
+ end
1047
+ rescue StandardError => e
1048
+ Log.warn(:turn, "objectives_proposal_failed", error: e.class.name, msg: e.message)
1049
+ end
1050
+ private :show_objective_proposals
1051
+
990
1052
  # The strategy the working set is built under now, as LLMContextView
991
1053
  # takes it: the running turn's, else the next turn's (#emit_working_set).
992
1054
  # /working-set's ops act under it.
@@ -1018,11 +1080,14 @@ module Samagotchi
1018
1080
  next nil if Array(session.messages).any? { |m| m[:note_id] == note[:note_id] }
1019
1081
 
1020
1082
  message = ContextNote.message(**note)
1083
+ entry = note[:objectives] && Objectives.entry(note[:objectives], before: Objectives.build(session.messages))
1021
1084
  replace_session_messages(session, Array(session.messages) + [message])
1022
1085
  announce({ type: :context_added, session_id: session.id, note_id: note[:note_id], source: note[:source],
1023
1086
  label: ContextNote.label_of(message), from_session: note[:from_session],
1024
1087
  from_cwd: note[:from_cwd], text: note[:text], context_source: note[:context_source],
1025
- created_at: note[:created_at] }.compact)
1088
+ created_at: note[:created_at], objectives: entry }.compact)
1089
+ announce_objectives(session) if entry
1090
+ objectives_audit.note(session, note)
1026
1091
  message
1027
1092
  end
1028
1093
  end
@@ -1677,6 +1742,7 @@ module Samagotchi
1677
1742
  event[:verdict] = verdict if verdict
1678
1743
  event[:revises] = revises.to_h if revises
1679
1744
  emit_working_set(turn.on_event, turn.session, turn.session.messages)
1745
+ announce_objectives(turn.session)
1680
1746
  turn.session.status = Session::STATUS_IDLE
1681
1747
  record_last_turn(turn.session, outcome, seconds, turn.origin, event, limit: exhausted ? turn.limit : nil)
1682
1748
  emit_event(turn.on_event, turn.tag(event))
@@ -2366,6 +2432,7 @@ module Samagotchi
2366
2432
  ensure
2367
2433
  release_turn(probe_cancel_before)
2368
2434
  show_context_cards_after_turn
2435
+ show_objective_proposals
2369
2436
  end
2370
2437
  end
2371
2438
 
@@ -2469,6 +2536,7 @@ module Samagotchi
2469
2536
  # The candidate judge: its asks run beside the turn; on mode's
2470
2537
  # attaches reach the model at the next between-turn delivery.
2471
2538
  start_context_judge(turn)
2539
+ audit_objectives_incoming(turn)
2472
2540
  apply_toolkit!(session, turn.on_event)
2473
2541
  refresh_participants!(session)
2474
2542
  # A Stop during the waits above: the turn ends here,
@@ -2740,14 +2808,18 @@ module Samagotchi
2740
2808
 
2741
2809
  # Fire :after_turn hook (an answer or a Stop; an Interrupt or a failure
2742
2810
  # ends the turn in #run_turn without it), with a read-only
2743
- # copy of the conversation the turn stored, event[:present] for
2811
+ # copy of the conversation the turn stored, event[:answer] (the answer's
2812
+ # text: the native loop's stored message still holds its thought),
2813
+ # event[:present] for
2744
2814
  # a display version of the answer (AnswerDisplay) and event[:refs]
2745
2815
  # for refs on it (AnswerRefs).
2746
2816
  session = turn.session
2817
+ audit_objectives_outgoing(turn, result) unless canceled || result.exhausted?
2747
2818
  display = AnswerDisplay.new(session.messages)
2748
2819
  refs = AnswerRefs.new(session.messages, cwd: session.working_directory)
2749
2820
  after_turn = { type: :after_turn, status: canceled ? "canceled" : "completed",
2750
- messages: hook_messages(session.messages) }
2821
+ messages: hook_messages(session.messages),
2822
+ answer: (result.text unless canceled || result.exhausted?) }
2751
2823
  after_turn[:present] = display.presenter(after_turn)
2752
2824
  after_turn[:refs] = refs.adder(after_turn)
2753
2825
  @hooks.fire(:after_turn, after_turn)
@@ -2855,7 +2927,8 @@ module Samagotchi
2855
2927
  # The running turn's conversation as its loop builds it (both loops
2856
2928
  # register it on the kernel's llm_context_turn): the same array the loop
2857
2929
  # appends to, not a copy, so read it on the turn's thread (the pending
2858
- # input drain, at an iteration boundary: TurnCheckpoint). nil when no
2930
+ # input drain, at an iteration boundary: TurnCheckpoint), or from another
2931
+ # only as a dup, read only (/objectives). nil when no
2859
2932
  # turn runs (llm_context_turn is never cleared, and still holds the
2860
2933
  # last turn's then).
2861
2934
  # @return [Array<Hash>, nil]
@@ -2885,7 +2958,10 @@ module Samagotchi
2885
2958
  raise ArgumentError, "no current session" unless @session
2886
2959
 
2887
2960
  messages = replace_session_messages(@session, clone_messages(checkpoint))
2888
- announce_working_set unless turn_running?
2961
+ unless turn_running?
2962
+ announce_working_set
2963
+ announce_objectives
2964
+ end
2889
2965
  messages
2890
2966
  end
2891
2967
 
@@ -3296,6 +3372,36 @@ module Samagotchi
3296
3372
  end
3297
3373
  private :start_context_judge
3298
3374
 
3375
+ # System 1 on the objectives, in audit (ObjectivesAudit): the owner's turns
3376
+ # only, and for a message a person's: a reminder's or a delegate's prompt isn't one.
3377
+ def audit_objectives_incoming(turn)
3378
+ return unless turn.prompt.is_a?(String) && turn.origin.human? && OwnerPrompt.turn?(session: turn.session, author: turn.origin.author)
3379
+
3380
+ objectives_audit.incoming(turn.session, turn.prompt)
3381
+ end
3382
+ private :audit_objectives_incoming
3383
+
3384
+ def audit_objectives_outgoing(turn, result)
3385
+ return unless OwnerPrompt.turn?(session: turn.session, author: turn.origin.author)
3386
+
3387
+ objectives_audit.outgoing(turn.session, result.text)
3388
+ end
3389
+ private :audit_objectives_outgoing
3390
+
3391
+ # With saving off nothing would come of an ask, so none is made.
3392
+ def objectives_audit
3393
+ @objectives_audit ||= ObjectivesAudit.new(
3394
+ side: @side_judgments, state_dir: -> { session_state_dir },
3395
+ client_for: lambda do |judgment, session|
3396
+ next nil unless system_one_runs.on?
3397
+
3398
+ system_one_recording(SystemOne::Client.from_config(host_registry: @host_registry, mode: ObjectivesAudit::MODE, tag: :system_one),
3399
+ judgment, session)
3400
+ end
3401
+ )
3402
+ end
3403
+ private :objectives_audit
3404
+
3299
3405
  # A late proposal no owner prompt mentions (Plugin::Host
3300
3406
  # #candidate_proposed: github-pr's init task runs beside the first
3301
3407
  # turn): judged on the owner's prompts so far, the running turn's too.
@@ -3446,6 +3552,7 @@ module Samagotchi
3446
3552
  cwd: -> { @session&.working_directory },
3447
3553
  messages: -> { plugin_messages },
3448
3554
  messages_partial: -> { turn_running? && @running_turn_messages.nil? },
3555
+ turn_running: -> { turn_running? },
3449
3556
  notify: ->(text, level, label, fallback_for: nil) { hook_notify(text, level, label, fallback_for: fallback_for) },
3450
3557
  ask_user: ->(question) { hook_ask_user(question) },
3451
3558
  # An init task's is its own (a Ctrl-C ends a turn, not the task).
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "objectives"
4
+
3
5
  module Samagotchi
4
6
  # The views of an Engine its peers get: the kernel's tools
5
7
  # (Engine#initialize sets @kernel.peers) and a delegate's approval relay
@@ -28,6 +30,15 @@ module Samagotchi
28
30
  # The peer's name when +name+ is a source a peer bundle declared
29
31
  # (PeerSources), for context_read's header.
30
32
  def peer_label(name) = engine.peer_sources&.label_for(name)
33
+
34
+ # The session's objectives now (the objectives tool), nil outside one.
35
+ def objectives
36
+ session = engine.session
37
+ session && Objectives.current(session.id, session.messages, state_dir: engine.peer_state_dir)
38
+ end
39
+
40
+ # A proposed change: its card shows at the turn's end (Engine#propose_objective).
41
+ def propose_objective(proposal) = engine.propose_objective(proposal)
31
42
  end
32
43
 
33
44
  # What the approval relay needs from the parent's Engine (Peers#relay):
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # The variables that tell a git which repo to act on. A process started by
5
+ # git (`rebase --exec`, `bisect run`, a hook) inherits them, and then its
6
+ # `git -C dir ...`, or a clone into a temp dir, reads or writes that repo
7
+ # instead of dir. chi's own git calls on a path chi chose spawn with UNSET;
8
+ # git then finds the repo from its cwd or -C. A model's and a user's commands
9
+ # (execute, tasks, !cmd) are theirs: they keep what chi was given.
10
+ module GitEnv
11
+ # `git rev-parse --local-env-vars` (git_env_spec checks it) less
12
+ # GIT_CONFIG_COUNT, which only adds settings and which git never sets for
13
+ # a child: like ~/.gitconfig it is the user's (a CI's clone credentials),
14
+ # and the suite's own (spec_helper). spec/e2e/support/env.js asks git for
15
+ # the same list.
16
+ VARS = %w[
17
+ GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR GIT_CONFIG GIT_CONFIG_PARAMETERS GIT_DIR GIT_GRAFT_FILE
18
+ GIT_IMPLICIT_WORK_TREE GIT_INDEX_FILE GIT_NO_REPLACE_OBJECTS GIT_OBJECT_DIRECTORY GIT_PREFIX
19
+ GIT_REPLACE_REF_BASE GIT_SHALLOW_FILE GIT_WORK_TREE
20
+ ].freeze
21
+
22
+ # What a spawn takes as its env: Process.spawn unsets a key mapped to nil.
23
+ # Not GIT_CONFIG_PARAMETERS, GIT_CONFIG_COUNT's older form (`git -c ...` around
24
+ # chi): settings for this git, not a repo, like a url.insteadOf for a clone.
25
+ # A test run clears it (VARS); what wants no config at all says so (TextMerge).
26
+ UNSET = (VARS - ["GIT_CONFIG_PARAMETERS"]).to_h { |name| [name, nil] }.freeze
27
+
28
+ module_function
29
+
30
+ # For a test run, as spec_helper and the kit's adapters load: its specs'
31
+ # `git -C tmp init` must not reach the real repo. Clears them for this
32
+ # process and all it starts, also from the copy Bundler keeps of the
33
+ # environment it started in: Bundler.with_unbundled_env and
34
+ # InstalledGem.unbundled_env_changes would put them back in a child.
35
+ def scrub!
36
+ clear(ENV)
37
+ clear(::Bundler::ORIGINAL_ENV) if defined?(::Bundler::ORIGINAL_ENV)
38
+ end
39
+
40
+ # @param env [#delete] ENV or a Hash
41
+ def clear(env) = VARS.each { |name| env.delete(name) }
42
+ end
43
+ end
@@ -93,6 +93,8 @@ module Samagotchi
93
93
  return {} unless File.directory?(dir)
94
94
 
95
95
  paths = ["--show-toplevel", "--git-path", "hooks", "--git-common-dir"]
96
+ # In chi's own environment, on purpose: a tool's git inherits it, and a
97
+ # card or a rule must judge the repo that git acts on (GIT_DIR around chi too).
96
98
  out, status = Open3.capture2e("git", "-C", dir, "rev-parse", *paths, "--abbrev-ref", "HEAD")
97
99
  # A repo with no commits yet has no HEAD: only the paths answer.
98
100
  out, status = Open3.capture2e("git", "-C", dir, "rev-parse", *paths) unless status.success?
@@ -34,7 +34,8 @@ module Samagotchi
34
34
  # - owner-voiced tools (OWNER_VOICED, and any delegate_* tool): what
35
35
  # speaks or acts in the owner's name beyond this turn (a delegate, a
36
36
  # reminder, a note to another session, a background task, a
37
- # forgotten output, the session's loaded tools); the tools that read
37
+ # forgotten output, the session's loaded tools, a move on the owner's
38
+ # objectives); the tools that read
38
39
  # the owner's background tasks (OWNER_TASK_READERS); and memory_write:
39
40
  # a memory reaches the owner's later sessions;
40
41
  # - ask_user_question: there is no one on the participant's side to
@@ -51,7 +52,7 @@ module Samagotchi
51
52
  RULE = "pair-boundary"
52
53
  SOURCE = "core"
53
54
  OWNER_VOICED = %w[delegate register_reminder cancel_reminder send_note forget_outputs task_create task_stop
54
- find_tools].freeze
55
+ find_tools objectives].freeze
55
56
  OWNER_VOICED_PREFIX = "delegate_"
56
57
  # The background task tools that read the owner's tasks (a pair turn
57
58
  # starts none: task_create is owner-voiced, and its execute is never
@@ -401,7 +401,10 @@ module Samagotchi
401
401
  # :unknown for one the text doesn't tell); [] for other tools.
402
402
  def git_dirs
403
403
  @git_dirs ||= if shell?
404
- # chi's own GIT_DIR is not the command's concern
404
+ # The text's and the call's env: only. chi's own GIT_DIR and
405
+ # GIT_WORK_TREE aren't counted here, though execute inherits
406
+ # them and the command does act on that repo; GitInfo (the
407
+ # repo root and common dir this is compared with) follows them.
405
408
  shell_envs.each_with_index.flat_map do |(home, vars), i|
406
409
  ShellGitDirs.for(@command, cwd: @cwd, home: home, env: i.zero? ? {} : @env, vars: vars)
407
410
  end.uniq
@@ -259,7 +259,7 @@ module Samagotchi
259
259
  # Claude model's cache breakpoint there (PromptCache) and drops it
260
260
  # for every other request.
261
261
  def system_message(entry, index)
262
- text = entry[:content].to_s
262
+ text = Steer.wire_text(entry, entry[:content].to_s)
263
263
  split = index.zero? && @stable_length&.call(text)
264
264
  split ? { role: "system", content: text, cache_split: split } : { role: "system", content: text }
265
265
  end
@@ -19,6 +19,8 @@ module Samagotchi
19
19
  # warnings, and #run returns what the record (Provenance#write) keeps.
20
20
  # A dev install (+dev+, `chi bundle install --dev`) links each file to
21
21
  # the source instead of copying it, and warns of no checksum mismatch.
22
+ # An edited file it replaces or removes is copied to the trash first,
23
+ # under its subfolder (Trash#copy_edit); a link never is.
22
24
  class AssetInstaller
23
25
  # What was installed: the paths Provenance#write records (each
24
26
  # empty / nil on a dry run).
@@ -33,7 +35,8 @@ module Samagotchi
33
35
  # @param existing [InstalledBundle, nil] the record before this install
34
36
  # @param results, warnings, conflicts the Installer's (results and
35
37
  # warnings are added to)
36
- def initialize(name:, source_dir:, manifest:, provenance:, existing:, results:, warnings:, conflicts:,
38
+ # @param trash [Trash] the Installer's
39
+ def initialize(name:, source_dir:, manifest:, provenance:, existing:, results:, warnings:, conflicts:, trash:,
37
40
  force: false, strict: true, upgrade: false, dry_run: false, dev: false)
38
41
  @name = name
39
42
  @bundle_dir = source_dir
@@ -43,6 +46,7 @@ module Samagotchi
43
46
  @results = results
44
47
  @warnings = warnings
45
48
  @conflicts = conflicts
49
+ @trash = trash
46
50
  @force = force
47
51
  @strict = strict
48
52
  @upgrade = upgrade
@@ -112,6 +116,7 @@ module Samagotchi
112
116
 
113
117
  FileUtils.mkdir_p(@provenance.hooks_dir)
114
118
  unchanged = File.exist?(dest) && FileUtils.identical?(src, dest)
119
+ @trash.copy_edit(dest, as: "hooks/#{basename}", recorded: prev_sha, incoming: src)
115
120
  place(src, dest)
116
121
  @results[basename] = if unchanged
117
122
  { status: "skipped", reason: "already up to date" }
@@ -135,6 +140,7 @@ module Samagotchi
135
140
  else
136
141
  FileUtils.mkdir_p(@provenance.hooks_dir)
137
142
  dest = File.join(@provenance.hooks_dir, basename)
143
+ @trash.copy_edit(dest, as: "hooks/#{basename}", recorded: @existing&.hooks&.dig(basename)&.sha256, incoming: src)
138
144
  place(src, dest)
139
145
  @results[basename] = { status: "installed" }
140
146
  @hook_files[basename] = dest
@@ -152,6 +158,7 @@ module Samagotchi
152
158
  if File.exist?(old_dest) && !@force && !File.symlink?(old_dest)
153
159
  @warnings << "Bundle no longer includes hook #{old_key_str} but local file exists — removing"
154
160
  end
161
+ @trash.copy_edit(old_dest, as: "hooks/#{old_key_str}", recorded: @existing.hooks[old_key_str]&.sha256)
155
162
  FileUtils.rm_f(old_dest)
156
163
  end
157
164
  end
@@ -190,6 +197,7 @@ module Samagotchi
190
197
  dest = File.join(target, File.basename(src))
191
198
  File.file?(dest) && FileUtils.identical?(src, dest)
192
199
  end
200
+ @trash.copy_edits(target, "guardrails", @existing&.guardrails, incoming_rules.to_h { |src| [File.basename(src), src] })
193
201
  FileUtils.rm_rf(target)
194
202
  return {} if incoming_rules.empty?
195
203
 
@@ -210,7 +218,10 @@ module Samagotchi
210
218
  def install_plugin
211
219
  plugin = @manifest&.plugin
212
220
  unless plugin
213
- FileUtils.rm_rf(@provenance.plugin_dir) unless @dry_run
221
+ unless @dry_run
222
+ @trash.copy_edits(@provenance.plugin_dir, "plugin", @existing&.plugin_shas)
223
+ FileUtils.rm_rf(@provenance.plugin_dir)
224
+ end
214
225
  return nil
215
226
  end
216
227
 
@@ -228,6 +239,7 @@ module Samagotchi
228
239
 
229
240
  dest = File.join(@provenance.plugin_dir, plugin[:file])
230
241
  unchanged = File.file?(dest) && FileUtils.identical?(src, dest)
242
+ @trash.copy_edits(@provenance.plugin_dir, "plugin", @existing&.plugin_shas, { plugin[:file] => src })
231
243
  FileUtils.rm_rf(@provenance.plugin_dir)
232
244
  FileUtils.mkdir_p(@provenance.plugin_dir)
233
245
  place(src, dest)
@@ -265,6 +277,8 @@ module Samagotchi
265
277
  dest = File.join(@provenance.scripts_dir, file)
266
278
  File.file?(dest) && FileUtils.identical?(File.join(@bundle_dir, "scripts", file), dest)
267
279
  end
280
+ @trash.copy_edits(@provenance.scripts_dir, "scripts", @existing&.scripts,
281
+ scripts.keys.to_h { |file| [file, File.join(@bundle_dir, "scripts", file)] })
268
282
  FileUtils.rm_rf(@provenance.scripts_dir)
269
283
  return {} if scripts.empty?
270
284
 
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "time"
5
+
6
+ module Samagotchi
7
+ module MemoryBundle
8
+ # What chi says of an edited bundle file, everywhere it reports one: the one
9
+ # owner of these words (chi update's row and --json, chi bundle list and
10
+ # status, chi self, chi bootstrap, SystemBundle.ensure!'s echo, the Installer's
11
+ # summary, the phone's push).
12
+ #
13
+ # A file is merged (it took the update) or behind: marked conflict on its
14
+ # record, with bytes other than the bundle's text it speaks of. A marked file
15
+ # brought level by hand isn't behind, and a missing one is a missing file.
16
+ module FileStanding
17
+ # Why a merge of a mark from an older chi didn't go ahead (Merger.merge_outcome).
18
+ LEGACY_BASE = "an older chi may have taken your text as the base, and the merge would remove lines from your file"
19
+ # A real conflict on such a mark: its base can't say what the bundle changed.
20
+ LEGACY_CONFLICT = "it conflicts with the bundle's text, and an older chi's base can't say what it missed"
21
+ # A marked file Remerge leaves for the next upgrade or restore.
22
+ WAITS_FOR_UPGRADE = "it matches its old base, and the bundle's next version replaces it"
23
+ FILE_MISSING = "the file is missing, and the bundle's next version puts it back"
24
+ UNSURE = "an older chi marked it, and the next upgrade checks it"
25
+ NOT_SHIPPED = "not in the bundle now, nothing to merge"
26
+
27
+ # What --force does to a file that stays behind, and to an edited one being removed.
28
+ FORCE_TAKES = "--force takes the bundle's version, your file copied to the trash first"
29
+ FORCE_MOVES = "--force moves it to the trash"
30
+ # Why a file an agent step was given stays behind (BundleCommand#settle_conflicts).
31
+ STILL_BEHIND = { untouched: "Nothing changed in %s: still behind.",
32
+ markers: "%s still holds conflict markers: still behind.",
33
+ incoming_gone: "The bundle's copy of %s is gone: still behind." }.freeze
34
+ # A chi update row's short word for the trash; the path is long for a table.
35
+ IN_THE_TRASH = "old copies: chi bundle trash"
36
+ # How many missed headings a line names.
37
+ SHOWN_MISSED = 3
38
+
39
+ # One file that stays behind. why: the clause saying why no merge was tried
40
+ # (.no_merge, or a plain reason), nil for a real conflict. missed: the
41
+ # headings of the sections the bundle changed. places: the changes under no
42
+ # heading. unsure: an older mark that no retry has looked at, with nothing to
43
+ # compare the file to, so chi can't say it is behind.
44
+ # What a record gives is read as it is shaped: anything else is nothing known.
45
+ Behind = Data.define(:file, :version, :why, :missed, :places, :unsure) do
46
+ def initialize(file:, version: nil, why: nil, missed: [], places: 0, unsure: false)
47
+ super(file: file, version: version, why: why.is_a?(String) ? FileStanding.text(why) : nil,
48
+ missed: (missed.is_a?(Array) ? missed : []).grep(String).map { |heading| FileStanding.text(heading) },
49
+ places: places.is_a?(Integer) && places.positive? ? places : 0, unsure: unsure)
50
+ end
51
+
52
+ # "stays behind 0.8.0: missed ## Objectives"
53
+ def state(dry_run: false)
54
+ return "may be behind#{" #{version}" if version}: #{UNSURE}" if unsure
55
+
56
+ head = "#{dry_run ? "would stay" : "stays"} behind#{" #{version}" if version}"
57
+ rest = [why, FileStanding.missed_words(missed, places)].compact.join("; ")
58
+ rest.empty? ? head : "#{head}: #{rest}"
59
+ end
60
+
61
+ # "x.md stays behind 0.8.0: missed ## Objectives"
62
+ def line(dry_run: false) = "#{FileStanding.text(file)} #{state(dry_run: dry_run)}"
63
+ end
64
+
65
+ # A merge no report has shown yet, as the file's record keeps it
66
+ # (Provenance#note_merged). Every merge leaves one: a worker's reaches nothing
67
+ # but its log, an automatic update's rows no person. It shows in chi bundle
68
+ # status while it exists. A report someone reads ends it: chi update's row, for
69
+ # what it printed (this run's merges too), and chi bundle upgrade's output. No
70
+ # start says it: most sessions never read that file.
71
+ MergeNote = Data.define(:file, :version, :at, :trash) do
72
+ # "merged 0.8.0 into your x.md on 2026-11-05 14:03"
73
+ def line
74
+ "#{FileStanding.merged_line([file], version)}#{" on #{FileStanding.local_time(at)}" if at}"
75
+ end
76
+ end
77
+
78
+ module_function
79
+
80
+ # +value+ as UTF-8, whatever it was tagged, what isn't valid replaced: a file
81
+ # name under LC_ALL=C and a heading must join in one line.
82
+ def text(value) = value.to_s.dup.force_encoding(Encoding::UTF_8).scrub
83
+
84
+ # "merged 0.8.0 into your x.md, y.md"
85
+ def merged_line(files, version, dry_run: false)
86
+ "#{dry_run ? "would merge" : "merged"}#{" #{version}" if version} into your #{files.map { |file| text(file) }.join(", ")}"
87
+ end
88
+
89
+ # The clause for a merge that couldn't answer: +why+ is TextMerge's reason
90
+ # or LEGACY_BASE, where chi can't even say the file holds edits.
91
+ def no_merge(why)
92
+ why == LEGACY_BASE ? "chi can't tell whether it holds edits of yours (#{why})" : "no merge possible (#{why})"
93
+ end
94
+
95
+ def still_behind(reason, file) = format(STILL_BEHIND.fetch(reason), text(file))
96
+
97
+ def left_as_it_is(message) = "left as it is (#{message})"
98
+
99
+ # "missed ## A, ## B, ## C and 2 more", "missed 2 places"; nil for nothing known.
100
+ def missed_words(headings, places)
101
+ return places.positive? ? "missed #{counted(places, "place")}" : nil if headings.empty?
102
+
103
+ more = [headings.size - SHOWN_MISSED, 0].max + places
104
+ words = "missed #{headings.first(SHOWN_MISSED).join(", ")}"
105
+ more.positive? ? "#{words} and #{more} more" : words
106
+ end
107
+
108
+ # "1 file behind"; "2 files may be behind" if chi isn't sure of one of them.
109
+ def behind_count(count, unsure: false) = "#{counted(count, "file")} #{"may be " if unsure}behind"
110
+
111
+ # "1 file stays behind", "2 files stay behind"; "1 file may be behind"
112
+ def stay_behind(count, unsure: false)
113
+ return behind_count(count, unsure: true) if unsure
114
+
115
+ "#{counted(count, "file")} #{count == 1 ? "stays" : "stay"} behind"
116
+ end
117
+
118
+ # The sentence of a push, merged and behind as counts: "" for neither.
119
+ def counts_sentence(merged:, behind:)
120
+ parts = []
121
+ parts << "Merged the update into #{counted(merged, "file")} you edited." if merged.to_i.positive?
122
+ parts << "#{stay_behind(behind)}." if behind.to_i.positive?
123
+ parts.join(" ")
124
+ end
125
+
126
+ # A report's clauses for one bundle: what merged, what stays behind with
127
+ # how to see it, what an earlier merge did.
128
+ # @param merged [Array<String>] files
129
+ # @param behind [Array<Behind>]
130
+ # @param earlier [Array<MergeNote>]
131
+ # @return [Array<String>]
132
+ def clauses(bundle, version, merged: [], behind: [], earlier: [], dry_run: false)
133
+ behind_lines = behind.map { |file| file.line(dry_run: dry_run) }
134
+ behind_lines[0] += " (#{diff_hint(bundle, behind.first.file)})" unless behind.empty?
135
+ [*(merged.empty? ? nil : merged_line(merged, version, dry_run: dry_run)), *behind_lines, *earlier.map(&:line)]
136
+ end
137
+
138
+ def diff_hint(bundle, file) = "chi bundle diff #{text(bundle)} #{text(file)}"
139
+
140
+ def local_time(stamp)
141
+ Time.iso8601(stamp.to_s).localtime.strftime("%Y-%m-%d %H:%M")
142
+ rescue ArgumentError
143
+ stamp.to_s
144
+ end
145
+
146
+ # The one answer to whether a file stays behind, for a report and a retry
147
+ # alike: marked, there, and not level with the bundle's text the mark speaks
148
+ # of. A missing file is a missing file (a project-scope record doesn't even
149
+ # say which project it means).
150
+ # @param incoming_sha [String, nil] that text's digest, for a mark with none
151
+ def behind?(path, entry, incoming_sha: entry[:incoming_sha])
152
+ entry[:conflict] == true && File.exist?(path) && differs?(path, incoming_sha)
153
+ end
154
+
155
+ # The file's bytes aren't what the mark says the bundle ships. One it can't
156
+ # read isn't known to be level. No digest (an older mark): it isn't known
157
+ # either, and a retry would have settled it.
158
+ def differs?(path, incoming_sha)
159
+ incoming_sha.nil? || Digest::SHA256.file(path).hexdigest != incoming_sha
160
+ rescue SystemCallError
161
+ true
162
+ end
163
+
164
+ def counted(count, noun) = "#{count} #{noun}#{"s" unless count == 1}"
165
+ end
166
+ end
167
+ end