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,6 +16,8 @@ module Samagotchi
16
16
  # Context#saved_session reads it; Sessions and AttachedContext load it too.
17
17
  autoload :Session, File.expand_path("../session", __dir__)
18
18
  autoload :PluginSessionState, File.expand_path("../plugin_session_state", __dir__)
19
+ # Context#objectives reads it; the kit shouldn't carry its files.
20
+ autoload :Objectives, File.expand_path("../objectives", __dir__)
19
21
 
20
22
  module Plugin
21
23
  # Loaded on first ctx.sessions / ctx.context (or a bundle naming
@@ -73,7 +75,7 @@ module Samagotchi
73
75
  :ask_model, :model_name, :model_key, :state_dir, :scratch, :setup, :prompt_files,
74
76
  :steer, :stop_turn, :stop_generation, :frontend, :toolkit, :declared_tools, :use_toolkit, :load_tools,
75
77
  :cancel_controller, :cost, :usage, :turn_author, :cache_confirm, :model_names,
76
- :candidate_proposed, :judgments, keyword_init: true)
78
+ :candidate_proposed, :judgments, :turn_running, keyword_init: true)
77
79
 
78
80
  # ctx.ask_model failed: the model couldn't be reached, timed out, or
79
81
  # sent nothing usable. The message says why, for the user.
@@ -321,6 +323,11 @@ module Samagotchi
321
323
  Array(@host.messages.call).map { |message| message.dup.freeze }.freeze
322
324
  end
323
325
 
326
+ # Whether a turn runs now. Its :after_turn hooks run inside it, so a thread
327
+ # one of them starts can use this to wait for the end: a card shown before
328
+ # then belongs to that turn's block.
329
+ def turn_running? = !!@host.turn_running&.call
330
+
324
331
  # Whether #messages leaves out a running turn (the REPL mid-turn), so
325
332
  # a plugin can say what its answer is about.
326
333
  def messages_partial? = !!@host.messages_partial&.call
@@ -380,6 +387,19 @@ module Samagotchi
380
387
  @host.card.call(source: @bundle, title: title, body: body, actions: actions, level: level, id: id)
381
388
  end
382
389
 
390
+ # The session's objectives (docs/internals/objectives.md), read now: the
391
+ # newest list the conversation holds, an edit not yet absorbed over it.
392
+ # Mid-turn a worker's conversation leaves the running turn's tool calls
393
+ # out, so a move shows at the turn's end.
394
+ # @return [Objectives::List] empty when the session has none (or there
395
+ # is no session yet); use its methods, not its class
396
+ def objectives
397
+ id = session_id
398
+ return Objectives::List.new unless id
399
+
400
+ Objectives.current(id, messages, state_dir: @host.state_dir&.call || Session.default_state_dir)
401
+ end
402
+
383
403
  # A side answer (plan D7): one request to the session's current model
384
404
  # on its host, with no tools and thinking off. It writes nothing,
385
405
  # fires no hooks, and doesn't touch the conversation. It blocks until
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "json"
4
4
  require "open3"
5
+ require_relative "bundler_env"
5
6
  require_relative "chi_home"
6
7
  require_relative "chi_shim"
7
8
  require_relative "config"
@@ -57,14 +58,14 @@ module Samagotchi
57
58
  RESERVED_PREFIXES = %w[XDG_ SAMAGOTCHI_].freeze
58
59
  # Never set from an ORIG_ENV record (a tool can write it), only unset:
59
60
  # loader and Bundler variables.
60
- RESTORE_NEVER = /\A(?:DYLD_|LD_|RUBYOPT\z|RUBYLIB\z|RUBYGEMS_|BUNDLE)/
61
+ RESTORE_NEVER = /\A(?:DYLD_|LD_|RUBYOPT\z|RUBYLIB\z|RUBYGEMS_|#{BundlerEnv::PREFIX})/
61
62
  # Never passed to `mise env`: MISE_YES answers its trust prompt (it
62
63
  # would trust, and remember, an untrusted repo's mise.toml); CI too.
63
64
  MISE_UNSET = %w[MISE_YES CI].freeze
64
65
  TIMEOUT = 5
65
66
  # What execute and tasks set themselves (Tools::TaskRuntime's
66
67
  # sanitized env): never taken from the project's.
67
- TOOLS_DROP = %w[RUBYOPT RUBYLIB BUNDLE_GEMFILE BUNDLE_BIN_PATH BUNDLER_VERSION].freeze
68
+ TOOLS_DROP = BundlerEnv::CHILD_RUBY
68
69
 
69
70
  # What #compute found: +env+ the tools' overlay ({key => value, or nil
70
71
  # to unset}; frozen, with the marker and the ORIG_ENV record),
@@ -494,6 +494,8 @@ module Samagotchi
494
494
  version = bundle.version || "?"
495
495
  label = "#{name} #{version}"
496
496
  label += " (shipped #{shipped})" if name == MemoryBundle::SystemBundle::BUNDLE_NAME
497
+ behind = bundle.behind_files
498
+ label += " (#{MemoryBundle::FileStanding.behind_count(behind.size, unsure: behind.any?(&:unsure))})" if behind.any?
497
499
  label
498
500
  end
499
501
  installed.empty? ? "(none installed; shipped system bundle #{shipped || "?"})" : installed.join(", ")
@@ -27,6 +27,10 @@ require_relative "system_one/approval_label"
27
27
  require_relative "system_one/record"
28
28
 
29
29
  module Samagotchi
30
+ # Loaded on first use (a /objectives line): keeps the bundle test kit's
31
+ # load budget (spec/samagotchi/testing/sandbox_spec.rb).
32
+ autoload :ObjectivesCommand, File.expand_path("objectives_command", __dir__)
33
+
30
34
  # The session commands a REPL and a session worker both run: /model,
31
35
  # /models, /llm-context, /guardrails, /context, !rollback, !cmd and the answer to a continue offer. They act on
32
36
  # the Engine and its TurnFlow; the host prints the result's output and
@@ -50,6 +54,8 @@ module Samagotchi
50
54
  REASONING_HISTORY_COMMAND = ReasoningHistoryCommand::NAME
51
55
  WORKING_SET_COMMAND = WorkingSetCommand::NAME
52
56
  RECAP_COMMAND = RecapCommand::NAME
57
+ # A literal: ObjectivesCommand loads on first use (the autoload above).
58
+ OBJECTIVES_COMMAND = "/objectives"
53
59
  # A remote catalog has hundreds of ids (OpenRouter ~380): plain /models
54
60
  # shows this many per host; /models <text> lists every match.
55
61
  MODELS_PER_HOST = 20
@@ -134,6 +140,9 @@ module Samagotchi
134
140
  # Only a name: renaming a running session doesn't wait for its turn.
135
141
  registry.register(TITLE_COMMAND, "show or set this session's title (--clear: back to its first message)",
136
142
  id: :title, anytime: true, match: ->(text) { text.match?(%r{\A/title(?:\s+.*)?\z}) }) { |text| title(text) }
143
+ # An edit only writes a note file (it goes in between turns): mid-turn too.
144
+ registry.register(OBJECTIVES_COMMAND, "show or edit what this session is to achieve (set A | B, add TEXT, edit ID TEXT, done ID…, drop ID…, undo ID…, check ID)",
145
+ id: :objectives, anytime: true) { |text| objectives(text) }
137
146
  registry.register(HELP_COMMAND, "list the commands, the bundles' too", anytime: true) { |_text| reply(help_listing) }
138
147
  registry.register("/stats", "show the session's stats", local: true)
139
148
  # /recap with words (on|off|default, or a slip: the usage) runs here:
@@ -703,6 +712,12 @@ module Samagotchi
703
712
  reply(output, status: status, changed: changed ? [:title] : [])
704
713
  end
705
714
 
715
+ # /objectives (ObjectivesCommand): nothing to save, an edit is a note file.
716
+ def objectives(text)
717
+ output, status = ObjectivesCommand.new(engine: @engine).run(text.delete_prefix(OBJECTIVES_COMMAND))
718
+ reply(output, status: status)
719
+ end
720
+
706
721
  # /recap on|off|default (RecapCommand); :recap in changed when this
707
722
  # session's own recap.enabled moved. It doesn't save: its host does on
708
723
  # a changed result (Worker#run_command, the REPL).
@@ -5,6 +5,7 @@ require_relative "session"
5
5
  require_relative "session_setup"
6
6
  require_relative "session_inbox"
7
7
  require_relative "session_chain"
8
+ require_relative "objectives"
8
9
  require_relative "child_move"
9
10
  require_relative "context_move"
10
11
  require_relative "archive_store"
@@ -209,6 +210,8 @@ module Samagotchi
209
210
  ChildMove.apply(move, from: previous.id, to: link.id, state_dir: state_dir)
210
211
  # Its movable context too, with a note saying what moved and stayed.
211
212
  moved_context = ContextMove.call(from: previous.id, to: link.id, mode: :continue, state_dir: state_dir)
213
+ # Its objectives last: the list reads as the latest word.
214
+ Objectives.carry(previous, to: link.id, state_dir: state_dir)
212
215
  end
213
216
  link = SessionManager.spawn_session(
214
217
  prompt: start, preview: preview, title: previous.title, working_directory: previous.working_directory,
@@ -5,6 +5,7 @@ require "json"
5
5
  require "time"
6
6
  require "securerandom"
7
7
  require_relative "atomic_file"
8
+ require_relative "context_note"
8
9
  require_relative "delivery"
9
10
  require_relative "log"
10
11
  require_relative "session"
@@ -83,11 +84,13 @@ module Samagotchi
83
84
  # to the conversation between turns. It never starts a turn.
84
85
  # @param source [String] where it came from ("cli", "slack", "session")
85
86
  # @param from_session [String, nil] the sending session, for a peer's note
87
+ # @param objectives [Hash, nil] an objectives note's payload
88
+ # (Objectives::List#to_payload)
86
89
  # @param now [Time] the time the file is named and stamped by (a spec's clock)
87
90
  # @return [String] the note file's path
88
91
  # @raise [NoteRejected] for an empty note or one over 16 KiB (never cut)
89
92
  def self.write_note(session_id, text:, source: "cli", from_session: nil, from_cwd: nil, state_dir: nil,
90
- now: Time.now)
93
+ objectives: nil, now: Time.now)
91
94
  body = checked_text(text)
92
95
  sd = state_dir || Session.default_state_dir
93
96
  notes_dir = File.join(Session.session_dir(session_id, state_dir: sd), NOTES_DIR)
@@ -98,7 +101,7 @@ module Samagotchi
98
101
  name = "#{now.strftime("%Y%m%d%H%M%S%9N")}-#{SecureRandom.hex(3)}.json"
99
102
  path = File.join(notes_dir, name)
100
103
  record = { "text" => body, "source" => source.to_s, "from_session" => from_session,
101
- "from_cwd" => from_cwd, "created_at" => now.iso8601 }.compact
104
+ "from_cwd" => from_cwd, "created_at" => now.iso8601, "objectives" => objectives }.compact
102
105
  AtomicFile.write(path, JSON.generate(record))
103
106
  path
104
107
  end
@@ -134,15 +137,20 @@ module Samagotchi
134
137
  end
135
138
 
136
139
  # @return [Hash, nil] {note_id:, text:, source:, from_session:, from_cwd:,
137
- # created_at:}, or nil for a file that holds no usable note
140
+ # created_at:}, with an objectives note's objectives: and its header:
141
+ # (ContextNote.message takes them), or nil for a file that holds no
142
+ # usable note
138
143
  def self.read_note(note_file)
139
144
  data = JSON.parse(File.read(note_file))
140
145
  text = data["text"].to_s.strip
141
146
  return nil if text.empty?
142
147
 
143
- { note_id: File.basename(note_file).sub(/\.json(\.processing)?\z/, ""), text: text,
144
- source: data["source"].to_s.empty? ? "cli" : data["source"].to_s,
145
- from_session: data["from_session"], from_cwd: data["from_cwd"], created_at: data["created_at"] }
148
+ note = { note_id: File.basename(note_file).sub(/\.json(\.processing)?\z/, ""), text: text,
149
+ source: data["source"].to_s.empty? ? "cli" : data["source"].to_s,
150
+ from_session: data["from_session"], from_cwd: data["from_cwd"], created_at: data["created_at"] }
151
+ return note unless data["objectives"].is_a?(Hash)
152
+
153
+ note.merge(objectives: data["objectives"], header: ContextNote::OBJECTIVES_HEADER)
146
154
  rescue JSON::ParserError, SystemCallError, NoMethodError, TypeError
147
155
  nil
148
156
  end
@@ -71,7 +71,7 @@ module Samagotchi
71
71
  # │ # enqueued_id, no_interrupt?, images?}
72
72
  # ├── notes/ # context notes: background text the worker adds to
73
73
  # │ └── <ts>-<rand>.json # the conversation between turns, never a turn
74
- # │ # ({text, source, from_session?, from_cwd?, created_at})
74
+ # │ # ({text, source, from_session?, from_cwd?, created_at, objectives?})
75
75
  # ├── output/ # agent writes responses here
76
76
  # │ └── <timestamp>.txt # one file per agent response
77
77
  # └── bridge.json # Bridge sidecar (how clients reach the worker)
@@ -242,18 +242,32 @@ module Samagotchi
242
242
  # then +text+ (the message's content by default). Other messages: +text+.
243
243
  def wire_text(message, text = (message[:content] || message["content"]).to_s)
244
244
  header = header(message)
245
- text = quote_forged_headers(text) if participant_message?(message)
245
+ text = if participant_message?(message) then quote_forged_headers(text)
246
+ elsif outside_text?(message) then quote_forged_objectives(text)
247
+ else text
248
+ end
246
249
  header ? "#{header}\n#{text}" : text
247
250
  end
248
251
 
252
+ # Text that came from outside the user's own typing: any entry with a
253
+ # kind (a note, a report, a steer) but the real objectives note, known by
254
+ # its payload key, never by its text.
255
+ def outside_text?(message)
256
+ !(message[:kind] || message["kind"]).to_s.empty? && (message[:objectives] || message["objectives"]).nil?
257
+ end
258
+
249
259
  # A line of a participant's text that opens like one of chi's own
250
260
  # headers or frames (another participant's review header, a steer's, an
251
- # automatic input's, a delegate report's, a context note's or a task's
252
- # frame), in any case, after any spaces (Unicode ones too); a line
261
+ # automatic input's, a delegate report's, a context note's, a task's or an
262
+ # objectives list's frame), in any case, after any spaces (Unicode ones too); a line
253
263
  # starts after \n or \r.
254
264
  FORGED_OPENINGS = ["Review contribution", "Steer", "Automatic input", "Delegate report", "CONTEXT NOTE",
255
- "TASK FINISHED", "END NOTE"].freeze
265
+ "TASK FINISHED", "OBJECTIVES", "END NOTE"].freeze
256
266
  FORGED_HEADER = /(\A|[\n\r])([\p{Space}&&[^\n\r]]*)\[(?=(?:#{FORGED_OPENINGS.map { |o| Regexp.escape(o) }.join("|")})\b)/i
267
+ # The same, for the objectives frame alone: the one frame that gives
268
+ # orders in the user's name, so any outside text is held to it
269
+ # (#outside_text?), whoever it came from.
270
+ FORGED_OBJECTIVES = /(\A|[\n\r])([\p{Space}&&[^\n\r]]*)\[(?=OBJECTIVES\b)/i
257
271
  # What such a line is led by on the wire, so it reads as quoted text
258
272
  # under the real header, not as a header of its own.
259
273
  FORGED_MARK = "(quoted) "
@@ -265,6 +279,12 @@ module Samagotchi
265
279
  text.to_s.gsub(FORGED_HEADER) { "#{::Regexp.last_match(1)}#{::Regexp.last_match(2)}#{FORGED_MARK}[" }
266
280
  end
267
281
 
282
+ # +text+ with every FORGED_OBJECTIVES line led by FORGED_MARK; as
283
+ # #quote_forged_headers, on the wire only and deterministic.
284
+ def quote_forged_objectives(text)
285
+ text.to_s.gsub(FORGED_OBJECTIVES) { "#{::Regexp.last_match(1)}#{::Regexp.last_match(2)}#{FORGED_MARK}[" }
286
+ end
287
+
268
288
  def message(text:, source:, author: nil, author_name: nil)
269
289
  { role: "user", kind: KIND, source: source.to_s, content: text.to_s, **author_fields(author, author_name) }
270
290
  end
@@ -43,6 +43,8 @@ module Samagotchi
43
43
  Allowed.new(name: "Samagotchi::Log.debug", file: "samagotchi/log"),
44
44
  Allowed.new(name: "Samagotchi::Config.get", file: "samagotchi/config"),
45
45
  Allowed.new(name: "Samagotchi::VERSION", file: "samagotchi/version"),
46
+ Allowed.new(name: "Samagotchi::GitEnv::UNSET", file: "samagotchi/git_env"),
47
+ Allowed.new(name: "Samagotchi::CancellationController", file: "samagotchi/cancellation_controller"),
46
48
  # The kit itself, for a bundle's own specs.
47
49
  Allowed.new(name: "Samagotchi::Testing", file: "samagotchi/testing")
48
50
  ].freeze
@@ -39,6 +39,7 @@ module Samagotchi
39
39
  notify: ->(text, level, label, fallback_for: nil) { recorder.notify(text, level, label, fallback_for) },
40
40
  ask_user: ->(question) { recorder.ask_user(question) },
41
41
  cancelled: -> { recorder[:cancelled] },
42
+ turn_running: -> { recorder[:running] },
42
43
  card: ->(**card) { recorder.card(**card) },
43
44
  ask_model: lambda { |request, timeout:, max_tokens:, cancel_controller:, model: nil, source: nil|
44
45
  recorder.ask_model(request, timeout: timeout, max_tokens: max_tokens, cancel_controller: cancel_controller,
@@ -3,6 +3,9 @@
3
3
  require "minitest"
4
4
  require_relative "../testing"
5
5
 
6
+ # Not in testing.rb: `chi bundle check` loads that into a real chi.
7
+ Samagotchi::GitEnv.scrub!
8
+
6
9
  module Samagotchi
7
10
  module Testing
8
11
  # The bundle test kit in Minitest (EXPERIMENTAL, docs/plugins.md): a
@@ -3,6 +3,9 @@
3
3
  require "rspec/core"
4
4
  require_relative "../testing"
5
5
 
6
+ # Not in testing.rb: `chi bundle check` loads that into a real chi.
7
+ Samagotchi::GitEnv.scrub!
8
+
6
9
  # The bundle test kit in RSpec (EXPERIMENTAL, docs/plugins.md): example groups
7
10
  # tagged :bundle get Samagotchi::Testing::Helpers (load_bundle,
8
11
  # shipped_bundle, sandbox), and each of their examples runs in its own
@@ -7,12 +7,13 @@ require_relative "../log"
7
7
  require_relative "../paths"
8
8
  require_relative "../sandbox_layout"
9
9
  require_relative "fake_executable"
10
+ require_relative "../git_env"
10
11
 
11
12
  module Samagotchi
12
13
  module Testing
13
14
  # The environment a bundle test runs in: temp config, state, data and cache
14
15
  # homes (config.yml, memories, ctx.data_dir, the debug log), HOME and the peer
15
- # CLIs' folders, git without the user's config, no Keychain, none of
16
+ # CLIs' folders, git without the user's config or an inherited repo (GitEnv), no Keychain, none of
16
17
  # the user's SAMAGOTCHI_* settings, a bin/ first on PATH for fake
17
18
  # commands. ENV is put back on #leave.
18
19
  #
@@ -166,6 +167,7 @@ module Samagotchi
166
167
  # ENV at +layout+'s folders, none of the SAMAGOTCHI_* settings but KEPT.
167
168
  def point_env(layout)
168
169
  ENV.each_key.select { |key| key.start_with?("SAMAGOTCHI_") && !KEPT.include?(key) }.each { |key| ENV.delete(key) }
170
+ GitEnv.clear(ENV)
169
171
  File.write(File.join(@root, "gitconfig"), "")
170
172
  ENV.update(
171
173
  **layout.env,
@@ -23,6 +23,7 @@ require_relative "tools/context_read"
23
23
  require_relative "tools/delegate"
24
24
  require_relative "tools/delegate_result"
25
25
  require_relative "tools/forget_outputs"
26
+ require_relative "tools/objectives_tool"
26
27
 
27
28
  module Samagotchi
28
29
  # The one-line summary of a tool call that the UIs show ("reading file",
@@ -85,6 +86,15 @@ module Samagotchi
85
86
  event
86
87
  end
87
88
 
89
+ def objectives_proposal?(call) = call[:action].to_s.strip.downcase == Tools::ObjectivesTool::PROPOSE
90
+
91
+ # An objectives row's words: "O2 note · evidence", or "propose done O2 note".
92
+ def objectives_title(call)
93
+ return "#{Tools::ObjectivesTool::PROPOSE} #{call[:change]} #{call[:id]} #{call[:text]} · #{call[:note]}".squeeze(" ") if objectives_proposal?(call)
94
+
95
+ "#{call[:id]} #{call[:note]}#{" · #{call[:evidence]}" unless call[:evidence].to_s.strip.empty?}"
96
+ end
97
+
88
98
  # An execute's description, cut as a row's title; nil for the rest.
89
99
  def command_description(tool_name, call)
90
100
  return nil unless call.is_a?(Hash) && ToolView::TOOLS.include?(tool_name)
@@ -112,6 +122,7 @@ module Samagotchi
112
122
  when Tools::Read::NAME, Tools::MemoryRead::NAME then call[:content]
113
123
  when Tools::Write::NAME, Tools::Edit::NAME, Tools::MemoryWrite::NAME then call[:path]
114
124
  when Tools::Execute::NAME, Tools::TaskCreate::NAME then command_title(call[:content])
125
+ when Tools::ObjectivesTool::NAME then objectives_title(call)
115
126
  end
116
127
  text = text.to_s.strip
117
128
  return nil if text.empty?
@@ -205,6 +216,7 @@ module Samagotchi
205
216
  when Tools::Delegate::NAME then "delegating"
206
217
  when Tools::DelegateResult::NAME then "waiting for a delegate"
207
218
  when Tools::ForgetOutputs::NAME then forget_outputs_action(call)
219
+ when Tools::ObjectivesTool::NAME then objectives_proposal?(call) ? "proposing a change" : "recording a move"
208
220
  else registry_entry(registry, tool_name)&.label || "calling tool"
209
221
  end
210
222
  end
@@ -430,6 +442,11 @@ module Samagotchi
430
442
  parts << "restore=#{preview_tool_param(request.restore.join(","))}" if request.restore?
431
443
  parts << "note=#{preview_tool_param(request.note)}" unless request.note.empty?
432
444
  parts.empty? ? nil : parts.join(" ")
445
+ when Tools::ObjectivesTool::NAME
446
+ parts = ["id=#{preview_tool_param(call[:id])}", "note=#{preview_tool_param(call[:note])}"]
447
+ parts.unshift("change=#{preview_tool_param(call[:change])}") if objectives_proposal?(call)
448
+ parts << "evidence=#{preview_tool_param(call[:evidence])}" unless call[:evidence].to_s.strip.empty?
449
+ parts.join(" ")
433
450
  when Tools::AskUserQuestion::NAME
434
451
  parts = ["question=#{preview_tool_param(call[:question] || call[:content])}"]
435
452
  opts = call[:options]
@@ -440,6 +440,40 @@ module Samagotchi
440
440
  },
441
441
  required: %w[question options]
442
442
  }
443
+ },
444
+ {
445
+ name: "objectives",
446
+ description: "Record a move on one of the objectives in the OBJECTIVES note: in a line, what you did or found that moves it, so the user can follow. One when an objective really moved, not per step. Or propose a change to the list (action propose): the user sees a card and applies it or not. This tool doesn't edit the list, change a status or mark anything done, and it approves nothing; the user edits the list with /objectives. Asked to change it, propose it or say so, instead of saying you changed it.",
447
+ parameters: {
448
+ type: "object",
449
+ properties: {
450
+ action: {
451
+ type: "string",
452
+ description: "move or propose"
453
+ },
454
+ id: {
455
+ type: "string",
456
+ description: "The objective's id, e.g. O2 (not for a proposed add)"
457
+ },
458
+ note: {
459
+ type: "string",
460
+ description: "A move: what moved, in a line. A proposal: why, in a line"
461
+ },
462
+ evidence: {
463
+ type: "string",
464
+ description: "Optional, a move's: what shows it (a commit, a test count, a file)"
465
+ },
466
+ change: {
467
+ type: "string",
468
+ description: "A proposal's: add, reword, done or drop"
469
+ },
470
+ text: {
471
+ type: "string",
472
+ description: "A proposed add's or reword's new text"
473
+ }
474
+ },
475
+ required: %w[action note]
476
+ }
443
477
  }
444
478
  ].freeze
445
479
 
@@ -53,7 +53,8 @@ module Samagotchi
53
53
  "delegate" => { content: "task", fallback: :prefix_or_raw },
54
54
  "ask_user_question" => { content: "question", also: %w[question], verbatim: %w[options], options: true,
55
55
  fallback: :raw },
56
- "forget_outputs" => { content: "note", also: %w[note], verbatim: %w[ids keep restore] }
56
+ "forget_outputs" => { content: "note", also: %w[note], verbatim: %w[ids keep restore] },
57
+ "objectives" => { content: "note", also: %w[note] }
57
58
  }.freeze
58
59
 
59
60
  # A name the model uses for a built-in, mapped to that built-in (a model
@@ -25,6 +25,7 @@ require_relative "delegate"
25
25
  require_relative "delegate_result"
26
26
  require_relative "ask_user_question"
27
27
  require_relative "forget_outputs"
28
+ require_relative "objectives_tool"
28
29
  require_relative "../guardrails/parent_approvals"
29
30
  require_relative "../guardrails/pair_boundary"
30
31
  require_relative "../model_profile"
@@ -58,7 +59,8 @@ module Samagotchi
58
59
  ContextRead,
59
60
  Delegate,
60
61
  DelegateResult,
61
- AskUserQuestion
62
+ AskUserQuestion,
63
+ ObjectivesTool
62
64
  ].freeze
63
65
  # The built-ins an LLM context layer adds (offered only under it).
64
66
  LAYER_CLASSES = [ForgetOutputs].freeze
@@ -115,6 +117,7 @@ module Samagotchi
115
117
  DelegateResult.call(call[:content], session: call[:session], timeout: call[:timeout], peers: kctx.peers)
116
118
  end,
117
119
  AskUserQuestion::NAME => ->(call, kctx) { kctx.ask_user_question(call) },
120
+ ObjectivesTool::NAME => ->(call, kctx) { ObjectivesTool.call(call, peers: kctx.peers) },
118
121
  ForgetOutputs::NAME => ->(call, kctx) { kctx.forget_outputs(call) }
119
122
  }.freeze
120
123
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../client_id"
4
+ require_relative "../git_env"
4
5
  require_relative "../session"
5
6
  require_relative "../config"
6
7
  require_relative "../model_profile"
@@ -272,7 +273,7 @@ module Samagotchi
272
273
  git_dir = MemoryPaths.git_dir(folder)
273
274
  return false if git_dir && within?(folder, File.realpath(git_dir))
274
275
 
275
- out = IO.popen(["git", "-C", folder, "rev-parse", "--is-inside-work-tree"], err: File::NULL, &:read)
276
+ out = IO.popen(GitEnv::UNSET, ["git", "-C", folder, "rev-parse", "--is-inside-work-tree"], err: File::NULL, &:read)
276
277
  out.strip == "true"
277
278
  rescue SystemCallError
278
279
  false
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../tool_response"
4
+
5
+ module Samagotchi
6
+ # Read at a call only: the builtins' file list loads this, Session not with it.
7
+ autoload :Objectives, File.expand_path("../objectives", __dir__)
8
+
9
+ module Tools
10
+ # The objectives tool's two actions. A move: what the model did or found for
11
+ # an objective. Information only: it never changes a status or the list
12
+ # (the user does, /objectives), and nothing is written here: the call in
13
+ # history is the record, which Objectives.build reads back. A proposal: a
14
+ # change to the list the user applies from a card with one tap, the card's
15
+ # action being the /objectives line; the model never changes the list by
16
+ # itself. See docs/internals/objectives.md.
17
+ class ObjectivesTool
18
+ NAME = "objectives"
19
+ MOVE = "move"
20
+ PROPOSE = "propose"
21
+ PROPOSED = "Proposed %s. The list is unchanged: the user sees a card and applies it or not."
22
+ RECORDED = "Recorded a move on %s."
23
+ EDITED_BY_USER = "The user edits the list with /objectives."
24
+ NONE = "There are no objectives here, so nothing was recorded. #{EDITED_BY_USER}".freeze
25
+
26
+ def self.name = NAME
27
+
28
+ # Whether +output+, a saved run, is this tool's answer that it recorded a
29
+ # move on +id+ (Objectives.moves_in). Positive: a run that recorded
30
+ # nothing (no list, a refusal, a denial, an interrupt) has no such answer.
31
+ def self.recorded?(output, id)
32
+ output.to_s.sub(ToolResponse::LEAD, "").start_with?(RECORDED % id)
33
+ end
34
+
35
+ # @param call [Hash] the internal call (BuiltinCalls: action:, id:,
36
+ # note:, evidence:)
37
+ # @param peers [#objectives, nil] the session's list
38
+ # @return [String] the tool result
39
+ def self.call(call, peers:)
40
+ action = call[:action].to_s.strip.downcase
41
+ unless action.empty? || [MOVE, PROPOSE].include?(action)
42
+ return "Error: this tool records a move (action: move) or proposes a change (action: propose); it edits nothing. #{EDITED_BY_USER}"
43
+ end
44
+
45
+ list = peers&.objectives
46
+ return NONE if list.nil? || list.empty?
47
+ return propose(list, call, peers) if action == PROPOSE
48
+
49
+ id = Objectives.move_id(call)
50
+ refusal(list, id, call) || "#{RECORDED % id} The list is unchanged: this tool doesn't edit it. #{EDITED_BY_USER}"
51
+ end
52
+
53
+ def self.propose(list, call, peers)
54
+ proposal = Objectives::Proposal.against(list, change: call[:change].to_s.strip.downcase, id: Objectives.move_id(call),
55
+ text: Objectives.clean(call[:text]), note: Objectives.clean(call[:note]))
56
+ refused = proposal.refusal(list)
57
+ return "Error: #{refused}. Nothing was proposed." if refused
58
+
59
+ return "Error: at most #{Objectives::PROPOSALS_PER_TURN} proposals a turn. Nothing more was proposed." unless peers.propose_objective(proposal)
60
+
61
+ format(PROPOSED, proposal.line.delete_prefix("/objectives "))
62
+ end
63
+ private_class_method :propose
64
+
65
+ def self.refusal(list, id, call)
66
+ item = list.find(id)
67
+ return "Error: no objective #{id} (this session's: #{list.items.map(&:id).join(", ")}). Nothing was recorded." unless item
68
+ return "Error: #{id} was dropped, so nothing was recorded." if item.status == Objectives::DROPPED
69
+
70
+ "Error: a move needs a note: what moved, in a line." if Objectives.clean(call[:note]).empty?
71
+ end
72
+ private_class_method :refusal
73
+ end
74
+ end
75
+ end
@@ -27,6 +27,9 @@ module Samagotchi
27
27
  # A person's message waits for the turn's next boundary: never here
28
28
  # (Engine::PeerView asks the running turn).
29
29
  def input_waiting? = false
30
+
31
+ # The session's objectives: none here (Engine::PeerView reads them).
32
+ def objectives = nil
30
33
  end
31
34
  end
32
35
  end
@@ -6,6 +6,7 @@ require "securerandom"
6
6
  require "shellwords"
7
7
  require "time"
8
8
  require_relative "../atomic_file"
9
+ require_relative "../bundler_env"
9
10
  require_relative "../child_env"
10
11
  require_relative "../log"
11
12
  require_relative "../process_group"
@@ -28,7 +29,7 @@ module Samagotchi
28
29
  STOP_POLL_INTERVAL_SEC = 0.1
29
30
  METADATA_VERSION = TaskRecord::METADATA_VERSION
30
31
  OUTPUT_TAIL_READ_BYTES = 16 * 1024
31
- SANITIZED_ENV_KEYS = %w[RUBYOPT RUBYLIB BUNDLE_GEMFILE BUNDLE_BIN_PATH BUNDLER_VERSION].freeze
32
+ SANITIZED_ENV_KEYS = BundlerEnv::CHILD_RUBY
32
33
  # Set by chi (Builtins.parent_env); the model's env can't change it.
33
34
  MARKER_ENV_KEYS = ChildEnv::SESSION_MARKERS
34
35
  # task_wait and task_get add it to a task the user stopped (the web's
@@ -448,15 +449,16 @@ module Samagotchi
448
449
  # chi's own Ruby/Bundler setup, kept out of a command's environment
449
450
  # (execute and task_create both spawn with it): run under `bundle
450
451
  # exec`, chi would otherwise hand a project's `ruby -e` or
451
- # `bundle exec` its Gemfile (RUBYOPT=-rbundler/setup, BUNDLE_GEMFILE).
452
+ # `bundle exec` its Gemfile (RUBYOPT=-rbundler/setup, BUNDLE_GEMFILE,
453
+ # BUNDLER_SETUP) and lockfile.
452
454
  def sanitized_spawn_env
453
- {
454
- "RUBYOPT" => sanitized_rubyopt,
455
- "RUBYLIB" => nil,
456
- "BUNDLE_GEMFILE" => nil,
457
- "BUNDLE_BIN_PATH" => nil,
458
- "BUNDLER_VERSION" => nil
459
- }
455
+ SANITIZED_ENV_KEYS.to_h { |key| [key, nil] }.merge("RUBYOPT" => sanitized_rubyopt)
456
+ end
457
+
458
+ # The model's way out: a Ruby or Bundler variable it does want, for one command.
459
+ def sanitized_key_error(key)
460
+ "Error: env key is reserved: #{key} (chi clears it for every command; to set it for this one, " \
461
+ "put it in the command: #{key}=value cmd)"
460
462
  end
461
463
 
462
464
  def spawn_env_for(overrides)
@@ -469,7 +471,8 @@ module Samagotchi
469
471
  overrides.each do |key, value|
470
472
  return [nil, "Error: env keys and values must be strings"] unless key.is_a?(String) && value.is_a?(String)
471
473
  return [nil, "Error: env keys and values cannot contain NUL bytes"] if key.include?("\0") || value.include?("\0")
472
- return [nil, "Error: env key is reserved: #{key}"] if SANITIZED_ENV_KEYS.include?(key) || MARKER_ENV_KEYS.include?(key)
474
+ return [nil, "Error: env key is reserved: #{key}"] if MARKER_ENV_KEYS.include?(key)
475
+ return [nil, sanitized_key_error(key)] if SANITIZED_ENV_KEYS.include?(key)
473
476
 
474
477
  normalized[key] = value
475
478
  end
@@ -191,7 +191,8 @@ module Samagotchi
191
191
  # Restore the pre-turn checkpoint (!rollback after a Ctrl-C). A pending
192
192
  # offer goes too: the turn it offered to continue is gone. (The REPL
193
193
  # reads every line as an answer while an offer is pending; a worker
194
- # also takes !rollback then.)
194
+ # also takes !rollback then.) :turn_rolled_back tells the cards' count
195
+ # (Bridge::CardStore) that the turn's prompt is gone.
195
196
  # @return [Boolean] false when there is none
196
197
  def rollback!
197
198
  return false unless @checkpoint
@@ -199,6 +200,7 @@ module Samagotchi
199
200
  restore(@checkpoint)
200
201
  @checkpoint = nil
201
202
  @offer = nil
203
+ @engine.announce({ type: :turn_rolled_back })
202
204
  true
203
205
  end
204
206