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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +62 -1
- data/README.md +4 -2
- data/docs/architecture.md +3 -3
- data/docs/bundles.md +38 -1
- data/docs/cli.md +76 -24
- data/docs/configuration.md +8 -6
- data/docs/guardrails.md +2 -1
- data/docs/hooks.md +2 -1
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bridge.md +8 -0
- data/docs/internals/bundles.md +42 -14
- data/docs/internals/objectives.md +269 -0
- data/docs/internals/web-frontend.md +4 -1
- data/docs/internals/web-push.md +2 -2
- data/docs/internals/working-set.md +5 -2
- data/docs/memory.md +47 -9
- data/docs/plugins.md +10 -2
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +45 -2
- data/docs/system-one.md +17 -1
- data/docs/testing.md +5 -0
- data/lib/samagotchi/atomic_file.rb +3 -0
- data/lib/samagotchi/bootstrap_command.rb +5 -2
- data/lib/samagotchi/bridge/card_store.rb +52 -7
- data/lib/samagotchi/bridge.rb +1 -1
- data/lib/samagotchi/bundle_command.rb +91 -68
- data/lib/samagotchi/bundler_env.rb +24 -0
- data/lib/samagotchi/bundles/coordinator/manifest.yml +6 -4
- data/lib/samagotchi/bundles/coordinator/plugin.rb +3 -2
- data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +9 -0
- data/lib/samagotchi/bundles/decision-card/manifest.yml +13 -0
- data/lib/samagotchi/bundles/decision-card/plugin.rb +280 -0
- data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
- data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
- data/lib/samagotchi/bundles/github-pr/plugin.rb +2 -1
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +3 -1
- data/lib/samagotchi/bundles/known-names/manifest.yml +3 -2
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +2 -2
- data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/chi_home.rb +4 -2
- data/lib/samagotchi/chi_shim.rb +2 -1
- data/lib/samagotchi/child_env.rb +10 -3
- data/lib/samagotchi/config.rb +7 -1
- data/lib/samagotchi/context_note.rb +17 -5
- data/lib/samagotchi/engine.rb +113 -6
- data/lib/samagotchi/engine_peers.rb +11 -0
- data/lib/samagotchi/git_env.rb +43 -0
- data/lib/samagotchi/guardrails/context.rb +2 -0
- data/lib/samagotchi/guardrails/pair_boundary.rb +3 -2
- data/lib/samagotchi/guardrails/targets.rb +4 -1
- data/lib/samagotchi/llm/chat_loop.rb +1 -1
- data/lib/samagotchi/memory_bundle/asset_installer.rb +16 -2
- data/lib/samagotchi/memory_bundle/file_standing.rb +167 -0
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +62 -1
- data/lib/samagotchi/memory_bundle/installer.rb +161 -52
- data/lib/samagotchi/memory_bundle/listing.rb +4 -3
- data/lib/samagotchi/memory_bundle/merger.rb +209 -8
- data/lib/samagotchi/memory_bundle/placeholder.rb +2 -1
- data/lib/samagotchi/memory_bundle/profile.rb +7 -3
- data/lib/samagotchi/memory_bundle/provenance.rb +178 -20
- data/lib/samagotchi/memory_bundle/remerge.rb +167 -0
- data/lib/samagotchi/memory_bundle/shipped_history.rb +79 -0
- data/lib/samagotchi/memory_bundle/shipped_update.rb +89 -24
- data/lib/samagotchi/memory_bundle/source.rb +3 -3
- data/lib/samagotchi/memory_bundle/status.rb +21 -3
- data/lib/samagotchi/memory_bundle/system_bundle.rb +98 -33
- data/lib/samagotchi/memory_bundle/text_merge.rb +399 -0
- data/lib/samagotchi/memory_bundle/trash.rb +58 -2
- data/lib/samagotchi/memory_bundle/uninstaller.rb +26 -6
- data/lib/samagotchi/note_command.rb +1 -1
- data/lib/samagotchi/note_delivery.rb +6 -2
- data/lib/samagotchi/objectives.rb +395 -0
- data/lib/samagotchi/objectives_audit.rb +115 -0
- data/lib/samagotchi/objectives_check.rb +365 -0
- data/lib/samagotchi/objectives_command.rb +192 -0
- data/lib/samagotchi/plugin/api.rb +2 -1
- data/lib/samagotchi/plugin/context.rb +21 -1
- data/lib/samagotchi/project_env.rb +3 -2
- data/lib/samagotchi/self_report.rb +2 -0
- data/lib/samagotchi/session_commands.rb +15 -0
- data/lib/samagotchi/session_continue.rb +3 -0
- data/lib/samagotchi/session_inbox.rb +14 -6
- data/lib/samagotchi/session_manager.rb +1 -1
- data/lib/samagotchi/steer.rb +24 -4
- data/lib/samagotchi/testing/boundary.rb +2 -0
- data/lib/samagotchi/testing/host.rb +1 -0
- data/lib/samagotchi/testing/minitest.rb +3 -0
- data/lib/samagotchi/testing/rspec.rb +3 -0
- data/lib/samagotchi/testing/sandbox.rb +3 -1
- data/lib/samagotchi/tool_activity.rb +17 -0
- data/lib/samagotchi/tool_declarations.rb +34 -0
- data/lib/samagotchi/tools/builtin_calls.rb +2 -1
- data/lib/samagotchi/tools/builtins.rb +4 -1
- data/lib/samagotchi/tools/delegate.rb +2 -1
- data/lib/samagotchi/tools/objectives_tool.rb +75 -0
- data/lib/samagotchi/tools/peers.rb +3 -0
- data/lib/samagotchi/tools/task_runtime.rb +13 -10
- data/lib/samagotchi/turn_flow.rb +3 -1
- data/lib/samagotchi/update_command.rb +76 -28
- data/lib/samagotchi/update_hint.rb +4 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/auto_update.rb +8 -8
- data/lib/samagotchi/web/auto_update_record.rb +6 -4
- data/lib/samagotchi/web/message_display.rb +9 -0
- data/lib/samagotchi/web/participant_view.rb +2 -2
- data/lib/samagotchi/web/public/app.js +15 -3
- data/lib/samagotchi/web/public/block_open.js +13 -0
- data/lib/samagotchi/web/public/bubbles.js +4 -2
- data/lib/samagotchi/web/public/cards_place.js +30 -9
- data/lib/samagotchi/web/public/commands.js +4 -1
- data/lib/samagotchi/web/public/details_motion.js +2 -1
- data/lib/samagotchi/web/public/index.html +20 -14
- data/lib/samagotchi/web/public/objectives.css +80 -0
- data/lib/samagotchi/web/public/objectives_entry.js +22 -0
- data/lib/samagotchi/web/public/objectives_model.js +100 -0
- data/lib/samagotchi/web/public/objectives_moves.js +145 -0
- data/lib/samagotchi/web/public/objectives_strip.js +242 -0
- data/lib/samagotchi/web/public/objectives_wiring.js +62 -0
- data/lib/samagotchi/web/public/stage_model.js +5 -6
- data/lib/samagotchi/web/public/stage_view.js +8 -7
- data/lib/samagotchi/web/public/stream_handlers.js +6 -1
- data/lib/samagotchi/web/public/turn_block.js +34 -19
- data/lib/samagotchi/web/public/turn_html.js +4 -1
- data/lib/samagotchi/web/public/turn_model.js +1 -1
- data/lib/samagotchi/web/public/update_sheet.js +8 -3
- data/lib/samagotchi/web/public/working_set_view.js +10 -7
- data/lib/samagotchi/web/push/notifier.rb +7 -9
- data/lib/samagotchi/web/session_hub.rb +2 -1
- data/lib/samagotchi/web/session_show.rb +12 -1
- data/lib/samagotchi/web/update_ops.rb +2 -1
- data/lib/samagotchi/worker.rb +3 -0
- data/lib/samagotchi/worker_inbound.rb +16 -8
- 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
|
-
|
|
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
|
-
|
|
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,
|
|
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]"
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -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[:
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|