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
@@ -2,13 +2,15 @@
2
2
 
3
3
  require "fileutils"
4
4
  require_relative "../memory_paths"
5
+ require_relative "provenance"
5
6
 
6
7
  module Samagotchi
7
8
  module MemoryBundle
8
9
  # Where a bundle operation puts the memory files it takes away
9
10
  # (uninstall, an upgrade's dropped file): moved, never deleted, into
10
11
  # <system memories>/.bundles/.trash/<bundle>-<YYYYmmdd-HHMMSS>/, one dir
11
- # per operation (-2, -3… when one already has that name). A dot dir, so
12
+ # per operation (-2, -3… when one already has that name). A file about to
13
+ # be overwritten is copied there instead (#copy). A dot dir, so
12
14
  # Provenance.each_installed never reads it as a bundle.
13
15
  #
14
16
  # `chi bundle trash` lists and empties the trash.
@@ -55,14 +57,25 @@ module Samagotchi
55
57
  entries
56
58
  end
57
59
 
58
- # The dir, once a file was moved (nil before).
60
+ # The one refusal for a file system error while files are being saved and
61
+ # replaced. Every save comes before its removal, so an edit is where it
62
+ # was or in the trash; a retry finds what this run already placed.
63
+ def self.failure_message(error)
64
+ "#{error.message}: nothing was lost (your edits are in place or in the trash); fix that and run the command again"
65
+ end
66
+
67
+ # The dir, once a file was moved or copied (nil before).
59
68
  attr_reader :dir
60
69
 
70
+ # What #copy saved, each by its name in the dir.
71
+ attr_reader :copied
72
+
61
73
  def initialize(name, now: Time.now, bundles_dir: MemoryPaths.bundles_dir)
62
74
  @name = name.to_s
63
75
  @now = now
64
76
  @bundles_dir = bundles_dir
65
77
  @dir = nil
78
+ @copied = []
66
79
  end
67
80
 
68
81
  # Moves +path+ into the trash dir (created on the first call).
@@ -73,6 +86,49 @@ module Samagotchi
73
86
  dest
74
87
  end
75
88
 
89
+ # Copies +path+ (through a link, its mode kept) into the trash dir,
90
+ # the original staying: for a file about to be overwritten.
91
+ # @param as [String] its name there; "hooks/x" keeps a subfolder, so
92
+ # hooks/x and scripts/x don't collide
93
+ # @return [String] the copy's path
94
+ def copy(path, as: File.basename(path))
95
+ dest = File.join(ensure_dir, as)
96
+ FileUtils.mkdir_p(File.dirname(dest))
97
+ FileUtils.cp(path, dest, preserve: true)
98
+ @copied << as
99
+ dest
100
+ end
101
+
102
+ # Copies the file at +path+ as +as+ before it is replaced or removed, when
103
+ # that would lose something: its bytes are neither what the record says
104
+ # was installed (no sha recorded: unknown, so it counts as edited) nor
105
+ # +incoming+'s. A link (a dev install's) points at its source, which
106
+ # nothing here touches.
107
+ def copy_edit(path, as:, recorded: nil, incoming: nil)
108
+ return if File.symlink?(path) || !File.file?(path)
109
+ return if incoming && File.file?(incoming) && FileUtils.identical?(incoming, path)
110
+ return if !Provenance.recorded_sha(recorded).empty? && Provenance.sha_matches?(path, recorded)
111
+
112
+ copy(path, as: as)
113
+ end
114
+
115
+ # copy_edit for every file under +dir+, which is about to go, at any depth
116
+ # (kept as <label>/<its path in dir>). A linked dir isn't followed.
117
+ # @param recorded [Hash, nil] path in dir => its recorded sha256
118
+ # @param incoming [Hash] path in dir => the source replacing it
119
+ def copy_edits(dir, label, recorded = nil, incoming = {})
120
+ return if File.symlink?(dir) || !File.directory?(dir)
121
+
122
+ Dir.glob("**/*", File::FNM_DOTMATCH, base: dir).sort.each do |file|
123
+ copy_edit(File.join(dir, file), as: "#{label}/#{file}", recorded: recorded&.[](file), incoming: incoming[file])
124
+ end
125
+ end
126
+
127
+ # What #copy saved, said once (nil: nothing).
128
+ def copy_line
129
+ "Copied to the trash first: #{@copied.join(", ")} (#{@dir})" unless @copied.empty?
130
+ end
131
+
76
132
  private
77
133
 
78
134
  def ensure_dir
@@ -18,9 +18,10 @@ module Samagotchi
18
18
  def self.not_installed(name) = new("Bundle '#{name}' is not installed")
19
19
  end
20
20
 
21
- # removed_files: the bundle's own hooks/ and plugin/ files (deleted);
22
- # trashed_files: its memory files, moved to trash_dir (Trash), nil
23
- # when none was moved.
21
+ # removed_files: the bundle's own hooks/ and plugin/ files (deleted, an
22
+ # edited one copied to trash_dir first: trash_copy_line);
23
+ # trashed_files: its memory files, moved to trash_dir (Trash), which is
24
+ # nil when nothing went there.
24
25
  attr_reader :warnings, :removed_files, :trashed_files
25
26
 
26
27
  def initialize(name:, scope: nil, force: false)
@@ -35,6 +36,10 @@ module Samagotchi
35
36
 
36
37
  def trash_dir = @trash.dir
37
38
 
39
+ # What it copied to the trash from the bundle's own folder, said once
40
+ # (nil: nothing).
41
+ def trash_copy_line = @trash.copy_line
42
+
38
43
  def run
39
44
  provenance = Provenance.new(name: @name)
40
45
  bundle = provenance.record
@@ -47,7 +52,9 @@ module Samagotchi
47
52
  files = bundle.files
48
53
  hooks = bundle.hooks
49
54
  if files.empty? && hooks.empty?
50
- # No file list, just remove provenance
55
+ # No file list, just remove provenance (rules, a plugin and scripts
56
+ # live there too)
57
+ save_edits(provenance, bundle)
51
58
  FileUtils.rm_rf(provenance.bundle_dir)
52
59
  return true
53
60
  end
@@ -67,11 +74,13 @@ module Samagotchi
67
74
 
68
75
  if !@force && File.exist?(base_path) && Merger.current_modified?(base_path, target_path)
69
76
  blocked << file_key_str
70
- @warnings << "Skipped #{file_key_str}: local edits detected (use --force to remove)"
77
+ @warnings << "Skipped #{file_key_str}: local edits detected (#{FileStanding::FORCE_MOVES})"
71
78
  end
72
79
  end
73
80
 
74
- raise UninstallError, "Uninstall blocked: #{blocked.join(", ")} has local edits (use --force)" if blocked.any?
81
+ raise UninstallError, "Uninstall blocked: #{blocked.join(", ")} has local edits (#{FileStanding::FORCE_MOVES})" if blocked.any?
82
+
83
+ save_edits(provenance, bundle)
75
84
 
76
85
  files.each do |file_key, _meta|
77
86
  file_key_str = file_key.to_s
@@ -123,10 +132,21 @@ module Samagotchi
123
132
  @removed_files << "plugin/#{File.basename(plugin_path)}" if plugin_path && File.exist?(plugin_path)
124
133
  FileUtils.rm_rf(provenance.bundle_dir)
125
134
  true
135
+ rescue SystemCallError => e
136
+ raise UninstallError, Trash.failure_message(e)
126
137
  end
127
138
 
128
139
  private
129
140
 
141
+ # Copies each edited file of the bundle's own folder to the trash: the
142
+ # rm_rf below takes them, --force or not.
143
+ def save_edits(provenance, bundle)
144
+ @trash.copy_edits(provenance.hooks_dir, "hooks", bundle.hooks.transform_values(&:sha256))
145
+ @trash.copy_edits(provenance.guardrails_dir, "guardrails", bundle.guardrails)
146
+ @trash.copy_edits(provenance.plugin_dir, "plugin", bundle.plugin_shas)
147
+ @trash.copy_edits(provenance.scripts_dir, "scripts", bundle.scripts)
148
+ end
149
+
130
150
  # A kept shared file's index line names the bundle that still has it
131
151
  # (a model overlay has none: the caller skips it).
132
152
  def retag(scope, path, file_key, bundle)
@@ -123,7 +123,7 @@ module Samagotchi
123
123
 
124
124
  def outcome(result)
125
125
  case result.status
126
- when :refused then "refused: it is open in a chi REPL; notes need attached mode"
126
+ when :refused then "refused: #{NoteDelivery::REPL_REFUSAL}"
127
127
  when :queued then "queued: its worker adds it within a few seconds"
128
128
  else "waits for the session's next start (#{result.queued} #{result.queued == 1 ? "note" : "notes"} queued)"
129
129
  end
@@ -14,6 +14,9 @@ module Samagotchi
14
14
  # note in its notes/ folder, which a live worker adds within seconds and
15
15
  # a session with no worker at its next start. Callers word the outcome.
16
16
  module NoteDelivery
17
+ # Why a chi REPL's session takes no note.
18
+ REPL_REFUSAL = "it is open in a chi REPL; notes need attached mode"
19
+
17
20
  # The outcome for one session.
18
21
  # @!attribute status [Symbol] :queued (a worker runs it), :waits (no
19
22
  # worker: it waits for the next start) or :refused (a chi REPL owns it)
@@ -26,14 +29,15 @@ module Samagotchi
26
29
  module_function
27
30
 
28
31
  # @param id [String] a full session id (resolved by the caller)
32
+ # @param objectives [Hash, nil] an objectives note's payload
29
33
  # @return [Result]
30
34
  # @raise [SessionInbox::NoteRejected] an empty note or one over 16 KiB
31
- def deliver(id, text:, source:, state_dir:, from_session: nil, from_cwd: nil)
35
+ def deliver(id, text:, source:, state_dir:, from_session: nil, from_cwd: nil, objectives: nil)
32
36
  owner = SessionManager.session_owner(id, state_dir: state_dir)
33
37
  return Result.new(id: id, status: :refused, queued: 0) if owner&.tui?
34
38
 
35
39
  path = SessionInbox.write_note(id, text: text, source: source, from_session: from_session, from_cwd: from_cwd,
36
- state_dir: state_dir)
40
+ objectives: objectives, state_dir: state_dir)
37
41
  return Result.new(id: id, status: :queued, queued: 0) if owner
38
42
 
39
43
  Result.new(id: id, status: :waits, queued: SessionInbox.find_new_note_files(File.dirname(path, 2)).size)
@@ -0,0 +1,395 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+
7
+ require_relative "atomic_file"
8
+ require_relative "context_note"
9
+ require_relative "llm_context_stale"
10
+ require_relative "log"
11
+ require_relative "message_time"
12
+ require_relative "objectives_check"
13
+ require_relative "session"
14
+ require_relative "session_chain"
15
+ require_relative "session_inbox"
16
+ require_relative "tools/objectives_tool"
17
+
18
+ module Samagotchi
19
+ # A session's objectives: what the user wants it to achieve, a list they
20
+ # type (/objectives, ObjectivesCommand). The list lives in the conversation
21
+ # as a note entry (ContextNote::OBJECTIVES_HEADER, its payload under the
22
+ # message's :objectives), each edit a whole new list that replaces the one
23
+ # before it. Pure but for the file reads named below, built from a
24
+ # conversation as WorkingSet is. The model's moves are its calls of the
25
+ # objectives tool (Tools::ObjectivesTool), read back from the same
26
+ # conversation: not entries. See docs/internals/objectives.md.
27
+ module Objectives
28
+ # Who a list's note is from (ContextNote's label).
29
+ SOURCE = "objectives"
30
+ ACTIVE = "active"
31
+ DONE = "done"
32
+ DROPPED = "dropped"
33
+ STATUSES = [ACTIVE, DONE, DROPPED].freeze
34
+ ID = /\AO(\d+)\z/
35
+ # What a list with nothing left to send says, so that it still replaces
36
+ # the one before it.
37
+ NONE_LEFT = "(no objectives now)"
38
+ # Cards a turn may leave: a model's proposals are for reading.
39
+ PROPOSALS_PER_TURN = 3
40
+ # Notes that say nothing new from outside: the list's own, and a continue's
41
+ # carry note, which holds the agent's last answer (#outside_note?).
42
+ NOT_OUTSIDE = [SOURCE, SessionChain::NOTE_SOURCE].freeze
43
+ # Beside the session's notes/: what #with_lock takes.
44
+ LOCK_FILE = "objectives.lock"
45
+ # Proposal cards answered, {card id => APPLIED | DISMISSED}, so that a repeat
46
+ # of a card's line can't apply again. Under #with_lock.
47
+ CARDS_FILE = "objectives_cards.json"
48
+ APPLIED = "applied"
49
+ DISMISSED = "dismissed"
50
+ # A text's own done-when: the last group, an earlier one stays text.
51
+ DONE_WHEN = /\A(.*)\(done when:\s*(.*?)\s*\)\z/i
52
+
53
+ # One objective: +id+ "O1", never reused; +status+ ACTIVE, DONE or
54
+ # DROPPED; +done_when+ an optional hint, never enforced.
55
+ Item = Data.define(:id, :text, :status, :done_when) do
56
+ def initialize(id:, text:, status: ACTIVE, done_when: nil)
57
+ super
58
+ end
59
+
60
+ # The line the frame and /objectives show.
61
+ def line
62
+ mark = status == ACTIVE ? "" : "[#{status}] "
63
+ "#{id} #{mark}#{text}#{" (done when: #{done_when})" if done_when}"
64
+ end
65
+
66
+ def to_payload = { "id" => id, "text" => text, "status" => status, "done_when" => done_when }.compact
67
+ end
68
+
69
+ # What the model last said moved an objective: information only, read
70
+ # from its tool call, and shown by /objectives.
71
+ # +ref+ names the call itself, words apart: its run's id and its model
72
+ # entry's time, so a later move in the same words is another move, and so is
73
+ # one after a rollback hands the same id out again.
74
+ Move = Data.define(:id, :note, :evidence, :ref) do
75
+ def initialize(id:, note:, evidence:, ref: nil) = super
76
+
77
+ def line = evidence.empty? ? note : "#{note} (evidence: #{evidence})"
78
+ end
79
+
80
+ # What the user did to one objective between two lists, for the entry's
81
+ # line: +kind+ added, done, dropped, reopened or reworded; +was+ a
82
+ # reworded one's old text. Display only, computed from the two lists.
83
+ Change = Data.define(:id, :kind, :text, :was) do
84
+ def to_h = { id: id, kind: kind, text: text, was: was }.compact
85
+ end
86
+
87
+ # A change the model proposes (the objectives tool), shown to the user as a
88
+ # card whose action is #line: the same edit they could type. +change+ add,
89
+ # reword, done or drop; +note+ why.
90
+ Proposal = Data.define(:change, :id, :text, :note, :was) do
91
+ # +was+: what a reword was proposed against (Objectives.fingerprint).
92
+ def initialize(change:, id:, text:, note:, was: nil)
93
+ super
94
+ end
95
+
96
+ # A proposal against +list+: a reword keeps the item's done-when unless its
97
+ # text brings one, and carries what it was proposed against.
98
+ def self.against(list, change:, id:, text:, note:)
99
+ item = change == "reword" ? list.find(id) : nil
100
+ return new(change: change, id: id, text: text, note: note) unless item
101
+
102
+ kept = item.done_when && !text.empty? && !text.match?(DONE_WHEN) ? "#{text} (done when: #{item.done_when})" : text
103
+ new(change: change, id: id, text: kept, note: note, was: Objectives.fingerprint(item))
104
+ end
105
+
106
+ # What's wrong with it against +list+, nil when it can apply.
107
+ def refusal(list)
108
+ return "change must be add, reword, done or drop" unless %w[add reword done drop].include?(change)
109
+ return "#{change} needs a text" if %w[add reword].include?(change) && text.empty?
110
+ return nil if change == "add"
111
+
112
+ list.find(id) ? nil : "no objective #{id} (this session's: #{list.items.map(&:id).join(", ")})"
113
+ end
114
+
115
+ # The /objectives line that applies it. An add's text can't split in two.
116
+ def line
117
+ case change
118
+ when "add" then "/objectives add #{text.gsub(/(?<!\S)\|(?!\S)/, "/")}"
119
+ when "reword" then "/objectives edit #{id} #{text}"
120
+ else "/objectives #{change} #{id}"
121
+ end
122
+ end
123
+
124
+ # The card's Apply: #line behind the prefix ObjectivesCommand reads.
125
+ def action_line(card)
126
+ line.sub("/objectives ", "/objectives --card #{card} #{"--was #{was} " if was}")
127
+ end
128
+
129
+ def title
130
+ case change
131
+ when "add" then "Add an objective?"
132
+ when "reword" then "Reword #{id}?"
133
+ when "done" then "Mark #{id} done?"
134
+ else "Drop #{id}?"
135
+ end
136
+ end
137
+
138
+ # The card's body: what, why, and the line for a terminal, which shows no button.
139
+ def body = [(text unless text.empty?), (note unless note.empty?), "`#{line}`"].compact.join("\n\n")
140
+ end
141
+
142
+ # The list: its items and the number the next id takes (it only grows,
143
+ # so a /objectives set doesn't reuse an id it dropped), and +moves+, the
144
+ # latest Move per id, which #build folds from the tool calls (never in
145
+ # the frame or the payload). Edits answer a new List.
146
+ List = Data.define(:items, :next_number, :moves) do
147
+ def initialize(items: [], next_number: 1, moves: {})
148
+ super
149
+ end
150
+
151
+ # Nothing was ever set.
152
+ def empty? = items.empty?
153
+
154
+ def find(id) = items.find { |item| item.id == id }
155
+
156
+ # @param text [String]
157
+ # @param done_when [String, nil]
158
+ # @return [List]
159
+ def add(text, done_when: nil)
160
+ with(items: items + [Item.new(id: "O#{next_number}", text: text, done_when: done_when)], next_number: next_number + 1)
161
+ end
162
+
163
+ # A new list in place of this one; the ids go on from this one's.
164
+ # @param entries [Array<Hash>] {text:, done_when:}
165
+ # @return [List]
166
+ def set(entries)
167
+ entries.reduce(with(items: [])) { |list, entry| list.add(entry[:text], done_when: entry[:done_when]) }
168
+ end
169
+
170
+ # @return [List]
171
+ def mark(id, status)
172
+ with(items: items.map { |item| item.id == id ? item.with(status: status) : item })
173
+ end
174
+
175
+ # @return [List]
176
+ def reword(id, text, done_when: nil)
177
+ with(items: items.map { |item| item.id == id ? item.with(text: text, done_when: done_when) : item })
178
+ end
179
+
180
+ # The note's text: what the model is shown (a dropped one isn't).
181
+ def text
182
+ lines = items.reject { |item| item.status == DROPPED }.map(&:line)
183
+ lines.empty? ? NONE_LEFT : lines.join("\n")
184
+ end
185
+
186
+ # What the note carries, in one key style: strings, as a save leaves them.
187
+ def to_payload = { "next" => next_number, "items" => items.map(&:to_payload) }
188
+
189
+ # What the page draws: the items, each with its last move and last check,
190
+ # and whether the newest edit is still a file waiting to go in.
191
+ # @param checks [Hash{String => Hash}] ObjectivesCheck.view_of's
192
+ def to_view(pending:, checks: {})
193
+ { items: items.map { |item| item_view(item, checks[item.id]) }, pending: pending }
194
+ end
195
+
196
+ private
197
+
198
+ def item_view(item, check)
199
+ move = moves[item.id]
200
+ view = item.to_payload
201
+ view = view.merge("move" => { "note" => move.note, "evidence" => move.evidence }) if move
202
+ check ? view.merge("check" => check) : view
203
+ end
204
+ end
205
+
206
+ module_function
207
+
208
+ # One edit's read and write as one step, across threads (each anytime command
209
+ # has its own) and processes: each note holds a whole list, so two edits that
210
+ # read the same list would lose one. The worker's absorb holds it too, so an
211
+ # edit reads neither between the note going into the conversation and its
212
+ # file going. An edit's events go under it: the order is always this lock,
213
+ # then the event log. Nothing takes this under the event log.
214
+ def with_lock(session_dir)
215
+ FileUtils.mkdir_p(session_dir)
216
+ File.open(File.join(session_dir, LOCK_FILE), File::RDWR | File::CREAT, 0o600) do |file|
217
+ file.flock(File::LOCK_EX)
218
+ yield
219
+ end
220
+ end
221
+
222
+ # An item's words, short: what a proposed reword was made against.
223
+ def fingerprint(item) = Digest::SHA256.hexdigest("#{item.text}\0#{item.done_when}")[0, 12]
224
+
225
+ # How a proposal card was answered, nil when it wasn't. Under #with_lock.
226
+ def card_state(session_dir, card)
227
+ JSON.parse(File.read(File.join(session_dir, CARDS_FILE)))[card]
228
+ rescue SystemCallError, JSON::ParserError, TypeError
229
+ nil
230
+ end
231
+
232
+ # Under #with_lock.
233
+ def record_card(session_dir, card, state)
234
+ path = File.join(session_dir, CARDS_FILE)
235
+ known = begin
236
+ JSON.parse(File.read(path))
237
+ rescue SystemCallError, JSON::ParserError
238
+ {}
239
+ end
240
+ AtomicFile.write(path, JSON.generate(known.merge(card => state)))
241
+ end
242
+
243
+ # The id a call names, as the list spells it.
244
+ def move_id(call) = call[:id].to_s.strip.upcase
245
+
246
+ # A call's text as one line.
247
+ def clean(text) = text.to_s.gsub(/[[:space:]]+/, " ").strip
248
+
249
+ # The list as +conversation+ leaves it, +pending+ over it: a pending file
250
+ # is newer than anything the conversation holds. Pure.
251
+ # @param conversation [Array<Hash>, nil] the stored entries
252
+ # @param pending [Hash, nil] #pending_payload's
253
+ # @param running [Array<Hash>, nil] the running turn's conversation, which
254
+ # +conversation+ (a session's messages) doesn't hold yet: its moves count
255
+ # @return [List]
256
+ def build(conversation, pending: nil, running: nil)
257
+ conversation = Array(conversation)
258
+ list = from_payload(pending) || conversation.reduce(List.new) do |so_far, message|
259
+ (ContextNote.note?(message) && from_payload(ContextNote.fetch(message, :objectives))) || so_far
260
+ end
261
+ list.empty? ? list : list.with(moves: moves_in(conversation).merge(moves_in(Array(running))))
262
+ end
263
+
264
+ # The latest Move per id among the conversation's objectives calls that
265
+ # recorded one (LLMContextStale.paired_runs, as the working set reads its
266
+ # runs: a call_tool's too, both hosts' forms). Tools::ObjectivesTool
267
+ # .recorded? is the check: a call with no list, a refused, a denied or an
268
+ # interrupted one recorded nothing.
269
+ # @return [Hash{String => Move}]
270
+ def moves_in(conversation)
271
+ LLMContextStale.paired_runs(conversation).each_with_object({}) do |run, moves|
272
+ next unless run.call[:name] == Tools::ObjectivesTool::NAME
273
+
274
+ id = move_id(run.call)
275
+ next unless Tools::ObjectivesTool.recorded?(run.text, id)
276
+
277
+ moves[id] = Move.new(id: id, note: clean(run.call[:note]), evidence: clean(run.call[:evidence]), ref: move_ref(conversation, run))
278
+ end
279
+ end
280
+
281
+ # Whether a note is news from outside the session's own words, for what reads
282
+ # a conversation as evidence or as incoming (ObjectivesCheck, ObjectivesAudit).
283
+ # @param source [String, nil] the note's source
284
+ # @param payload [Object] the note's :objectives
285
+ def outside_note?(source, payload) = payload.nil? && !NOT_OUTSIDE.include?(source)
286
+
287
+ def move_ref(conversation, run)
288
+ called = conversation[0...run.index].reverse_each.find { |entry| entry[:role].to_s == "model" }
289
+ "#{run.ref&.id}@#{called && called[MessageTime::KEY]}"
290
+ end
291
+ private_class_method :move_ref
292
+
293
+ # A session's list now, its unabsorbed newest file included.
294
+ # @return [List]
295
+ def current(session_id, conversation, state_dir:, running: nil)
296
+ build(conversation, pending: pending_payload(Session.session_dir(session_id, state_dir: state_dir)), running: running)
297
+ end
298
+
299
+ # The list as the page draws it (List#to_view), nil with no list: the page
300
+ # shows nothing then.
301
+ # @return [Hash, nil]
302
+ def view(session_id, conversation, state_dir:, running: nil)
303
+ dir = Session.session_dir(session_id, state_dir: state_dir)
304
+ pending = pending_payload(dir)
305
+ list = build(conversation, pending: pending, running: running)
306
+ list.empty? ? nil : list.to_view(pending: !pending.nil?, checks: ObjectivesCheck.view_of(dir, list))
307
+ end
308
+
309
+ # One note's list for the page's entry: its items and, when there was a
310
+ # list before it, what changed.
311
+ # @param payload [Object] the note's :objectives
312
+ # @param before [List, nil] the list before this note
313
+ # @return [Hash, nil] nil for what isn't a list
314
+ def entry(payload, before: nil)
315
+ list = from_payload(payload) or return nil
316
+
317
+ fields = { items: list.items.map(&:to_payload) }
318
+ fields[:changes] = changes(before, list).map(&:to_h) if before && !before.empty?
319
+ fields
320
+ end
321
+
322
+ # What the user did between two lists, in the new list's order.
323
+ # @return [Array<Change>]
324
+ def changes(before, after)
325
+ (before.items.map(&:id) | after.items.map(&:id)).filter_map do |id|
326
+ change_of(before.find(id), after.find(id))
327
+ end
328
+ end
329
+
330
+ # The newest objectives note waiting in +session_dir+'s notes/.
331
+ # @return [Hash, nil] its payload
332
+ def pending_payload(session_dir)
333
+ SessionInbox.find_new_note_files(session_dir).reverse_each do |file|
334
+ payload = SessionInbox.read_note(file)&.dig(:objectives)
335
+ return payload if from_payload(payload)
336
+ end
337
+ nil
338
+ end
339
+
340
+ # @param payload [Object] a note's :objectives; either key style reads
341
+ # @return [List, nil] nil for what isn't a list (+next+ not an Integer,
342
+ # +items+ not an Array): it can't wipe the fold or restart the ids
343
+ def from_payload(payload)
344
+ return nil unless payload.is_a?(Hash)
345
+
346
+ payload = payload.transform_keys(&:to_s)
347
+ return nil unless payload["next"].is_a?(Integer) && payload["items"].is_a?(Array)
348
+
349
+ items = payload["items"].filter_map { |raw| item_of(raw) }
350
+ highest = items.map { |item| item.id[ID, 1].to_i }.max || 0
351
+ List.new(items: items, next_number: [payload["next"], highest + 1].max)
352
+ end
353
+
354
+ # A continue's copy: +previous+'s list as a note file in the new link,
355
+ # read fresh, its unabsorbed newest file included. It runs inside the
356
+ # continue's rollback, and a link without the list is still a good link:
357
+ # a failure is logged. Not for a review link, whose next link's
358
+ # participants could differ (as SessionChain.carried_answer).
359
+ # @return [String, nil] the note file's path
360
+ def carry(previous, to:, state_dir:)
361
+ return nil if previous.review?
362
+
363
+ list = current(previous.id, Session.load(previous.id, state_dir: state_dir).messages, state_dir: state_dir)
364
+ return nil if list.empty?
365
+
366
+ SessionInbox.write_note(to, text: list.text, source: SOURCE, objectives: list.to_payload, state_dir: state_dir)
367
+ rescue StandardError => e
368
+ Log.warn(:worker, "continue_objectives_failed", sid: previous.id, error: e.class.name, msg: e.message)
369
+ nil
370
+ end
371
+
372
+ def change_of(was, now)
373
+ return Change.new(id: was.id, kind: DROPPED, text: was.text, was: nil) unless now
374
+ return Change.new(id: now.id, kind: "added", text: now.text, was: nil) unless was
375
+
376
+ if now.text != was.text || now.done_when != was.done_when
377
+ Change.new(id: now.id, kind: "reworded", text: now.text, was: was.text)
378
+ elsif now.status != was.status
379
+ Change.new(id: now.id, kind: now.status == ACTIVE ? "reopened" : now.status, text: now.text, was: nil)
380
+ end
381
+ end
382
+ private_class_method :change_of
383
+
384
+ def item_of(raw)
385
+ return nil unless raw.is_a?(Hash)
386
+
387
+ raw = raw.transform_keys(&:to_s)
388
+ return nil unless ID.match?(raw["id"].to_s) && !raw["text"].to_s.empty?
389
+
390
+ status = STATUSES.include?(raw["status"]) ? raw["status"] : ACTIVE
391
+ Item.new(id: raw["id"].to_s, text: raw["text"].to_s, status: status, done_when: raw["done_when"]&.to_s)
392
+ end
393
+ private_class_method :item_of
394
+ end
395
+ end