samagotchi 0.66.0 → 0.68.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 (151) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -1
  3. data/docs/architecture.md +55 -30
  4. data/docs/cli.md +3 -1
  5. data/docs/configuration.md +50 -9
  6. data/docs/context.md +35 -1
  7. data/docs/desktop.md +90 -22
  8. data/docs/guardrails.md +8 -5
  9. data/docs/hooks.md +2 -2
  10. data/docs/internals/anthropic.md +156 -0
  11. data/docs/internals/background-tasks.md +1 -1
  12. data/docs/internals/bundles.md +2 -0
  13. data/docs/internals/engine.md +129 -38
  14. data/docs/internals/gemma4-contract.md +1 -1
  15. data/docs/internals/llm-context-bench.md +5 -4
  16. data/docs/internals/prompt-caching.md +26 -11
  17. data/docs/plugins.md +126 -23
  18. data/docs/sessions.md +6 -6
  19. data/docs/sub-agent.md +4 -1
  20. data/docs/testing.md +18 -4
  21. data/lib/samagotchi/bootstrap/probe.rb +4 -3
  22. data/lib/samagotchi/bootstrap_command.rb +13 -3
  23. data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
  24. data/lib/samagotchi/bridge.rb +6 -3
  25. data/lib/samagotchi/bundles/claude-peer/manifest.yml +7 -6
  26. data/lib/samagotchi/bundles/claude-peer/plugin.rb +34 -313
  27. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +8 -3
  28. data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +14 -6
  29. data/lib/samagotchi/bundles/codex-peer/manifest.yml +7 -6
  30. data/lib/samagotchi/bundles/codex-peer/plugin.rb +37 -312
  31. data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +8 -3
  32. data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +14 -6
  33. data/lib/samagotchi/bundles/mcp/manifest.yml +5 -5
  34. data/lib/samagotchi/bundles/mcp/plugin.rb +82 -294
  35. data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -3
  36. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  37. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  38. data/lib/samagotchi/config.rb +15 -7
  39. data/lib/samagotchi/context_absorber.rb +66 -13
  40. data/lib/samagotchi/desktop/macos/AgentTerminal.swift +260 -0
  41. data/lib/samagotchi/desktop/macos/App.swift +3 -3
  42. data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -2
  43. data/lib/samagotchi/desktop/macos/Images.swift +2 -2
  44. data/lib/samagotchi/desktop/macos/Kitty.swift +33 -197
  45. data/lib/samagotchi/desktop/macos/Panel.swift +118 -82
  46. data/lib/samagotchi/desktop/macos/ProcessRunner.swift +1 -1
  47. data/lib/samagotchi/desktop/macos/Tmux.swift +239 -0
  48. data/lib/samagotchi/desktop/macos.rb +84 -13
  49. data/lib/samagotchi/desktop_command.rb +14 -3
  50. data/lib/samagotchi/effective_model.rb +283 -0
  51. data/lib/samagotchi/engine.rb +307 -549
  52. data/lib/samagotchi/engine_peers.rb +3 -0
  53. data/lib/samagotchi/guardrail_wiring.rb +1 -1
  54. data/lib/samagotchi/guardrails/approval.rb +1 -1
  55. data/lib/samagotchi/guardrails/approvals.rb +1 -1
  56. data/lib/samagotchi/guardrails/rules.rb +1 -1
  57. data/lib/samagotchi/guardrails/targets.rb +1 -1
  58. data/lib/samagotchi/hook_routes.rb +151 -0
  59. data/lib/samagotchi/hooks/registry.rb +2 -2
  60. data/lib/samagotchi/host_keys.rb +147 -0
  61. data/lib/samagotchi/level_setting_command.rb +99 -0
  62. data/lib/samagotchi/llm/adapter_common.rb +138 -0
  63. data/lib/samagotchi/llm/anthropic_messages.rb +28 -105
  64. data/lib/samagotchi/llm/api_key.rb +23 -7
  65. data/lib/samagotchi/llm/chat_loop.rb +10 -6
  66. data/lib/samagotchi/llm/openai_chat.rb +9 -92
  67. data/lib/samagotchi/llm/refusal_memo.rb +29 -0
  68. data/lib/samagotchi/llm_context_override.rb +1 -0
  69. data/lib/samagotchi/llm_context_strategy.rb +5 -10
  70. data/lib/samagotchi/peer_sources.rb +58 -0
  71. data/lib/samagotchi/plugin/api.rb +64 -155
  72. data/lib/samagotchi/plugin/loader.rb +4 -2
  73. data/lib/samagotchi/plugin/peer.rb +394 -0
  74. data/lib/samagotchi/plugin/tool_spec.rb +166 -0
  75. data/lib/samagotchi/plugin_tasks.rb +2 -2
  76. data/lib/samagotchi/reasoning_history_command.rb +29 -90
  77. data/lib/samagotchi/row_refs.rb +1 -1
  78. data/lib/samagotchi/self_report.rb +5 -3
  79. data/lib/samagotchi/send_command.rb +21 -11
  80. data/lib/samagotchi/session.rb +22 -8
  81. data/lib/samagotchi/session_continue.rb +58 -15
  82. data/lib/samagotchi/session_inbox.rb +0 -13
  83. data/lib/samagotchi/session_setting.rb +86 -0
  84. data/lib/samagotchi/thinking_command.rb +24 -76
  85. data/lib/samagotchi/thinking_notices.rb +169 -0
  86. data/lib/samagotchi/tool_call_parser.rb +1 -1
  87. data/lib/samagotchi/toolkit_binding.rb +138 -0
  88. data/lib/samagotchi/tools/context_read.rb +14 -0
  89. data/lib/samagotchi/tools/execute.rb +12 -12
  90. data/lib/samagotchi/tools/registry.rb +1 -1
  91. data/lib/samagotchi/tools/task_create.rb +6 -6
  92. data/lib/samagotchi/tools/task_get.rb +13 -13
  93. data/lib/samagotchi/tools/task_list.rb +6 -6
  94. data/lib/samagotchi/tools/task_record.rb +52 -0
  95. data/lib/samagotchi/tools/task_runtime.rb +55 -91
  96. data/lib/samagotchi/tools/task_stop.rb +6 -6
  97. data/lib/samagotchi/tools/task_wait.rb +12 -13
  98. data/lib/samagotchi/tools/tool_index.rb +454 -0
  99. data/lib/samagotchi/update_command.rb +9 -6
  100. data/lib/samagotchi/version.rb +1 -1
  101. data/lib/samagotchi/web/app.rb +71 -93
  102. data/lib/samagotchi/web/capabilities.rb +1 -1
  103. data/lib/samagotchi/web/public/activity.js +2 -2
  104. data/lib/samagotchi/web/public/annotate_actions.js +1 -1
  105. data/lib/samagotchi/web/public/app.js +103 -539
  106. data/lib/samagotchi/web/public/bubbles.js +291 -0
  107. data/lib/samagotchi/web/public/cards_place.js +184 -0
  108. data/lib/samagotchi/web/public/chains.js +1 -1
  109. data/lib/samagotchi/web/public/chip_popover.js +111 -0
  110. data/lib/samagotchi/web/public/command_list.js +12 -10
  111. data/lib/samagotchi/web/public/commands.js +2 -2
  112. data/lib/samagotchi/web/public/composer_size.js +2 -2
  113. data/lib/samagotchi/web/public/copy.js +1 -1
  114. data/lib/samagotchi/web/public/ctx.js +1 -1
  115. data/lib/samagotchi/web/public/delivery_line.js +4 -4
  116. data/lib/samagotchi/web/public/families_view.js +1 -1
  117. data/lib/samagotchi/web/public/format.js +11 -7
  118. data/lib/samagotchi/web/public/images.js +25 -2
  119. data/lib/samagotchi/web/public/index.html +15 -7
  120. data/lib/samagotchi/web/public/info_bar.js +3 -3
  121. data/lib/samagotchi/web/public/info_bar_model.js +110 -0
  122. data/lib/samagotchi/web/public/init_row.js +21 -10
  123. data/lib/samagotchi/web/public/level_chip.js +29 -80
  124. data/lib/samagotchi/web/public/lists_view.js +64 -9
  125. data/lib/samagotchi/web/public/llm_context_chip.js +38 -107
  126. data/lib/samagotchi/web/public/model_picker.js +26 -22
  127. data/lib/samagotchi/web/public/notify.js +6 -4
  128. data/lib/samagotchi/web/public/question_card.js +1 -1
  129. data/lib/samagotchi/web/public/ref_scan.js +2 -8
  130. data/lib/samagotchi/web/public/refs.js +13 -0
  131. data/lib/samagotchi/web/public/session_state.js +14 -0
  132. data/lib/samagotchi/web/public/session_title.js +2 -3
  133. data/lib/samagotchi/web/public/sessions_list.js +0 -8
  134. data/lib/samagotchi/web/public/stage_frame.js +179 -0
  135. data/lib/samagotchi/web/public/stage_model.js +35 -2
  136. data/lib/samagotchi/web/public/stage_slots.js +130 -0
  137. data/lib/samagotchi/web/public/stage_view.js +56 -335
  138. data/lib/samagotchi/web/public/stage_watch.js +65 -0
  139. data/lib/samagotchi/web/public/strip.js +3 -3
  140. data/lib/samagotchi/web/public/task_stop.js +3 -2
  141. data/lib/samagotchi/web/public/turn_events.js +1 -1
  142. data/lib/samagotchi/web/public/turn_html.js +2 -2
  143. data/lib/samagotchi/web/public/turn_model.js +2 -2
  144. data/lib/samagotchi/web/public/turn_view.js +1 -1
  145. data/lib/samagotchi/web/public/vendor/directive-helpers.js +8 -0
  146. data/lib/samagotchi/web/public/vendor/directives/repeat.js +9 -0
  147. data/lib/samagotchi/web/server.rb +0 -4
  148. data/lib/samagotchi/worker.rb +22 -24
  149. data/lib/samagotchi/worker_inbound.rb +36 -4
  150. data/lib/samagotchi/worker_wakes.rb +67 -7
  151. metadata +27 -1
@@ -0,0 +1,394 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "open3"
6
+ require "tempfile"
7
+ require "time"
8
+
9
+ module Samagotchi
10
+ module Plugin
11
+ # The base of the peer bundles' plugins (claude-peer, codex-peer;
12
+ # docs/plugins.md): a live link between a chi session and another agent's
13
+ # session the user works with. The peer pairs (its chi-peer skill,
14
+ # scripts/pair): it attaches the `<peer>-inbox` context, whose hint says
15
+ # where to send (a subclass's #check_hint!), and `<peer>-review`, where
16
+ # its answers come back as pushes that wake chi.
17
+ #
18
+ # The `<peer>_send` tool sends a part of the work (a plan step, a diff, a
19
+ # finding): a header, `chi <id> · <kind> · <title>`, then a short part
20
+ # inline, or a long one's file under the bundle's data dir
21
+ # (sessions/<id>/, removed with the session), named in the message
22
+ # (`payload: <path>`). The tool is declared whether or not the peer is
23
+ # paired, so pairing never changes the tools (no prompt rebuild); without
24
+ # a paired peer it fails with how to pair.
25
+ #
26
+ # The `/<peer>` command (anytime) lets the user send without the model:
27
+ # alone it is a status card; `validate|context <text>` sends the text as
28
+ # it is, titled by its first line; `last` sends the model's last completed
29
+ # answer as context (mid-turn, a worker's ctx.messages holds the running
30
+ # turn's partial text, so the before_turn and after_turn hooks keep what
31
+ # was completed); `install-skill` shows a card with the peer's chi-peer
32
+ # skill's target and an Install action (`--yes`), the card being the
33
+ # user's consent: the copy happens in-process, where no guardrail sees
34
+ # it. The skill is the bundle's scripts/chi-peer-SKILL.md, with the
35
+ # installed scripts/pair path and the payload folder written in.
36
+ #
37
+ # In the web, selecting text offers "Confirm with <peer>"
38
+ # (chi.annotation): it fills the composer with `/<peer> validate` and the
39
+ # quote. Such a text is titled by its first quoted line, not the quote's
40
+ # `From your thinking:` label.
41
+ #
42
+ # A subclass (the bundle's Plugin) sets the strings, as constants:
43
+ # INBOX, REVIEW ("<peer>-inbox", "-review"), BUNDLE, NAME (the peer, as
44
+ # the model and user call it: "Claude"), APP (its product: "Claude
45
+ # Code"), COMMAND ("/claude"), TOOL, TOOL_DESCRIPTION, TOOL_PARAMS,
46
+ # USAGE, STATUS_CARD, SKILL_CARD, CHECKED_ON and SCRIPTS_DIRS (beside the
47
+ # bundle's own file), and the transport, as the hooks below that raise
48
+ # NotImplementedError. Its constants are read as self.class::X: a bare
49
+ # name here would find only this class's.
50
+ class Peer
51
+ KINDS = %w[validate context question].freeze
52
+ # A part up to this long goes inline: the peer reads it with no file
53
+ # access.
54
+ INLINE_CHARS = 4_000
55
+ # A part longer than this is refused (a send is a part, not a session).
56
+ MAX_CHARS = 200_000
57
+ TITLE_CHARS = 80
58
+ SLUG_CHARS = 40
59
+ LAST_SEND = "last-send.json"
60
+ SKILL_FILE = "chi-peer-SKILL.md"
61
+ PAIR_PLACEHOLDER = "__CHI_PEER_PAIR__"
62
+ PAYLOADS_PLACEHOLDER = "__CHI_PEER_PAYLOADS__"
63
+ DIFF_LINES = 20
64
+ # The label line a web quote starts with (annotations.js sourceLabel).
65
+ QUOTE_LABEL = /\AFrom (?:your thinking|your earlier step|the .+ call|my earlier message):\z/
66
+
67
+ # A send that didn't happen; the message says why, for the model or the user.
68
+ class SendError < StandardError; end
69
+
70
+ def initialize(_settings = {})
71
+ @lock = Mutex.new
72
+ # The running turn's history before it (nil between turns), and the
73
+ # last completed answer this process saw.
74
+ @before_turn = nil
75
+ @last_answer = nil
76
+ end
77
+
78
+ def register(chi)
79
+ peer = self.class
80
+ chi.tool peer::TOOL, peer::TOOL_DESCRIPTION, params: peer::TOOL_PARAMS, label: "sent to #{peer::NAME}",
81
+ preview: ->(args) { "#{args["kind"]}: #{args["title"]}" } do |args, ctx|
82
+ sent = deliver(ctx, kind: args["kind"].to_s, title: args["title"].to_s, text: args["text"].to_s)
83
+ "#{sent}. #{follow_up(args["kind"].to_s)}"
84
+ rescue SendError => e
85
+ "Error: #{e.message}"
86
+ end
87
+ chi.command peer::COMMAND, "the paired #{peer::APP} session: status; validate|context <text>, last: send to " \
88
+ "it; install-skill: install #{peer::NAME}'s chi-peer skill", anytime: true do |args, ctx|
89
+ run_command(args.to_s.strip, ctx)
90
+ end
91
+ # A chi without chi.annotation (before 0.66) loads the bundle without the pill.
92
+ chi.annotation "Confirm with #{peer::NAME}", command: "#{peer::COMMAND} validate" if chi.respond_to?(:annotation)
93
+ # The review source is the peer's answers: its own wake budget and
94
+ # line (context.peer_turns). A chi before 0.67 has no peer_source.
95
+ chi.peer_source peer::REVIEW, label: peer::NAME if chi.respond_to?(:peer_source)
96
+ chi.on(:before_turn) do |event|
97
+ @lock.synchronize { @before_turn = Array(event[:messages]) }
98
+ end
99
+ chi.on(:after_turn) do |event|
100
+ answer = completed_answer(event[:messages]) if event[:status].to_s == "completed"
101
+ @lock.synchronize do
102
+ @before_turn = nil
103
+ @last_answer = answer if answer
104
+ end
105
+ end
106
+ end
107
+
108
+ # Sends +text+ to the paired peer: the header, then the text inline or
109
+ # a payload file's path. Records it as the last send.
110
+ # @return [String] what was sent ("sent to Claude: validate · title")
111
+ # @raise [SendError]
112
+ def deliver(ctx, kind:, title:, text:)
113
+ raise SendError, "kind must be one of #{KINDS.join(", ")}" unless KINDS.include?(kind)
114
+ raise SendError, "nothing to send: the text is empty" if text.strip.empty?
115
+ if text.length > MAX_CHARS
116
+ raise SendError, "the text is #{text.length} chars, over #{MAX_CHARS}: send a part, not a session"
117
+ end
118
+
119
+ id = ctx.session_id or raise SendError, "this session has no id yet: send after its first turn"
120
+ hint = peer_hint!(ctx)
121
+ title = one_line(title.strip.empty? ? text.strip.lines.first : title)
122
+ body, where = if text.length <= INLINE_CHARS
123
+ [text, "inline"]
124
+ else
125
+ path = write_payload(ctx, id, title, text)
126
+ ["payload: #{path} (#{text.lines.size} lines, #{text.length} chars)", path]
127
+ end
128
+ post(hint, "chi #{id[0, 8]} · #{kind} · #{title}\n#{body}")
129
+ record(ctx, id, kind: kind, title: title, chars: text.length, payload: where == "inline" ? nil : where)
130
+ "sent to #{self.class::NAME}: #{kind} · #{title}#{" (the text in #{where})" unless where == "inline"}"
131
+ end
132
+
133
+ private
134
+
135
+ # The transport: sends +text+ to the peer at +hint+ (the inbox's hint).
136
+ # @raise [SendError]
137
+ def post(_hint, _text) = raise(NotImplementedError, "#{self.class} must define #post")
138
+
139
+ # Raises SendError when +hint+ isn't an inbox hint this transport takes.
140
+ def check_hint!(_hint) = raise(NotImplementedError, "#{self.class} must define #check_hint!")
141
+
142
+ # The status card's lines for a paired peer at +hint+.
143
+ def paired_lines(_hint) = raise(NotImplementedError, "#{self.class} must define #paired_lines")
144
+
145
+ # The status card's last line: how sends go and what they were checked on.
146
+ def transport_line = raise(NotImplementedError, "#{self.class} must define #transport_line")
147
+
148
+ # The peer's config folder, where its skills go.
149
+ def peer_dir = raise(NotImplementedError, "#{self.class} must define #peer_dir")
150
+
151
+ # The skill card's last paragraph: what the peer's settings need.
152
+ def setup_paragraph(_ctx) = raise(NotImplementedError, "#{self.class} must define #setup_paragraph")
153
+
154
+ # The installed card's next step, after the skill's path.
155
+ def installed_line(short) = "In #{self.class::APP}, say \"pair with chi #{short}\"."
156
+
157
+ # More placeholders the installed skill fills in: {placeholder => text}.
158
+ def skill_substitutions(_ctx) = {}
159
+
160
+ def run_command(args, ctx)
161
+ verb = args.split(/\s+/, 2).first
162
+ # The rest as typed: a pasted selection keeps its lines.
163
+ rest = args.sub(/\A\S+\s*/, "")
164
+ case verb
165
+ when nil then status(ctx)
166
+ when "validate", "context" then rest.strip.empty? ? self.class::USAGE : send_text(ctx, verb, rest)
167
+ when "last" then rest.empty? ? send_last(ctx) : self.class::USAGE
168
+ when "install-skill" then install_skill(ctx, yes: rest == "--yes")
169
+ else self.class::USAGE
170
+ end
171
+ end
172
+
173
+ def send_text(ctx, kind, text)
174
+ "#{deliver(ctx, kind: kind, title: text_title(text), text: text)}; #{self.class::NAME}'s answer comes as the " \
175
+ "#{self.class::REVIEW} context"
176
+ rescue SendError => e
177
+ "#{self.class::COMMAND}: #{e.message}"
178
+ end
179
+
180
+ # A typed text's title: its first line, past a web quote's label line
181
+ # (`From your thinking:`) and the `> ` of a quoted line.
182
+ def text_title(text)
183
+ lines = text.strip.lines.map(&:strip).reject { |line| line.match?(QUOTE_LABEL) }
184
+ lines.map { |line| line.sub(/\A>\s*/, "") }.find { |line| !line.empty? }.to_s
185
+ end
186
+
187
+ def send_last(ctx)
188
+ answer, before = @lock.synchronize { [@last_answer, @before_turn] }
189
+ answer ||= completed_answer(before || ctx.messages)
190
+ command = self.class::COMMAND
191
+ return "#{command} last: no completed answer to send yet (the last turn has none); #{command} context <text> sends a text" unless answer
192
+
193
+ deliver(ctx, kind: "context", title: "chi's last answer: #{answer.strip.lines.first}", text: answer)
194
+ rescue SendError => e
195
+ "#{command}: #{e.message}"
196
+ end
197
+
198
+ # The answer a completed turn left: the history's last message when it
199
+ # is the model's (a cancelled, failed or empty turn ends in a turn note),
200
+ # after any notes that joined between turns; its thinking left out.
201
+ def completed_answer(messages)
202
+ last = Array(messages).reverse.find { |message| !(role(message) == "system" && kind(message) != "turn_note") }
203
+ return nil unless last && role(last) == "model" && !field(last, :interrupted) && !field(last, :tool_calls)
204
+
205
+ text = field(last, :content)
206
+ text = text.filter_map { |part| field(part, :text) if part.is_a?(Hash) }.join("\n") if text.is_a?(Array)
207
+ text = text.to_s.gsub(%r{<think>.*?</think>}m, "").strip
208
+ text.empty? ? nil : text
209
+ end
210
+
211
+ # The session's attached sources; none when they can't be read (a new
212
+ # session not saved yet; a corrupt session file).
213
+ def sources(ctx)
214
+ ctx.context.list
215
+ rescue ArgumentError, SystemCallError
216
+ []
217
+ end
218
+
219
+ def role(message) = field(message, :role).to_s
220
+ def kind(message) = field(message, :kind).to_s
221
+ def field(hash, key) = hash.is_a?(Hash) ? hash[key] || hash[key.to_s] : nil
222
+
223
+ # The paired peer's inbox hint: the inbox context's hint, checked by
224
+ # the transport.
225
+ # @raise [SendError]
226
+ def peer_hint!(ctx)
227
+ peer = self.class
228
+ inbox = sources(ctx).find { |source| source[:name] == peer::INBOX }
229
+ hint = inbox && inbox[:hint].to_s.strip
230
+ if hint.nil? || hint.empty?
231
+ raise SendError, "no #{peer::INBOX}: pair from #{peer::NAME} (install its skill with #{peer::COMMAND} " \
232
+ "install-skill, then tell #{peer::NAME} \"pair with chi #{ctx.session_id.to_s[0, 8]}\")"
233
+ end
234
+ check_hint!(hint)
235
+ hint
236
+ end
237
+
238
+ # The status card; nil, so the command adds no text.
239
+ def status(ctx)
240
+ peer = self.class
241
+ id = ctx.session_id
242
+ sources = id ? sources(ctx) : []
243
+ inbox, review = [peer::INBOX, peer::REVIEW].map { |name| sources.find { |source| source[:name] == name } }
244
+ hint = inbox && inbox[:hint].to_s.strip
245
+ paired = !(hint.nil? || hint.empty?)
246
+ lines = if paired
247
+ paired_lines(hint)
248
+ else
249
+ ["**not paired.** In #{peer::APP}, install the chi-peer skill (Install skill below), then tell " \
250
+ "#{peer::NAME} \"pair with chi #{id.to_s[0, 8]}\"."]
251
+ end
252
+ lines << "last send: #{last_send_line(ctx, id)}" if id && last_send(ctx, id)
253
+ lines << "last answer (#{peer::REVIEW}): #{review_line(review)}" if review
254
+ lines << transport_line
255
+ ctx.card(id: peer::STATUS_CARD, title: "#{peer::BUNDLE}: #{paired ? "paired" : "not paired"}",
256
+ body: lines.join("\n\n"), actions: [{ label: "Refresh", command: peer::COMMAND },
257
+ { label: "Install skill", command: "#{peer::COMMAND} install-skill" }])
258
+ nil
259
+ end
260
+
261
+ def last_send(ctx, id)
262
+ path = File.join(ctx.data_dir, "sessions", id, LAST_SEND)
263
+ File.file?(path) ? JSON.parse(File.read(path)) : nil
264
+ rescue JSON::ParserError, SystemCallError
265
+ nil
266
+ end
267
+
268
+ def last_send_line(ctx, id)
269
+ sent = last_send(ctx, id)
270
+ where = sent["payload"] ? "in `#{sent["payload"]}`" : "inline"
271
+ "#{ago(sent["at"])} · #{sent["kind"]} · #{sent["title"]} (#{sent["chars"]} chars, #{where})"
272
+ end
273
+
274
+ def review_line(review)
275
+ return "none yet" unless review[:fetched_at]
276
+
277
+ [review[:summary], ago(review[:fetched_at])].compact.join(" · ")
278
+ end
279
+
280
+ def ago(stamp)
281
+ seconds = (Time.now - Time.iso8601(stamp.to_s)).to_i
282
+ if seconds < 60 then "just now"
283
+ elsif seconds < 3600 then "#{seconds / 60} min ago"
284
+ elsif seconds < 86_400 then "#{seconds / 3600} h ago"
285
+ else Time.iso8601(stamp).localtime.strftime("%Y-%m-%d %H:%M")
286
+ end
287
+ rescue ArgumentError
288
+ stamp.to_s
289
+ end
290
+
291
+ # /<peer> install-skill [--yes]
292
+ def install_skill(ctx, yes:)
293
+ peer = self.class
294
+ source = peer::SCRIPTS_DIRS.map { |dir| File.join(dir, SKILL_FILE) }.find { |path| File.file?(path) }
295
+ return "#{peer::COMMAND} install-skill: this bundle has no scripts/#{SKILL_FILE}; reinstall it (chi bundle install #{peer::BUNDLE} --force)" unless source
296
+
297
+ substitutions = { PAIR_PLACEHOLDER => File.join(File.dirname(source), "pair"),
298
+ PAYLOADS_PLACEHOLDER => tilde(ctx.data_dir) }.merge(skill_substitutions(ctx))
299
+ text = substitutions.reduce(File.read(source)) { |skill, (placeholder, value)| skill.gsub(placeholder, value) }
300
+ target = File.join(peer_dir, "skills", "chi-peer", "SKILL.md")
301
+ yes ? write_skill(ctx, target, text) : skill_card(ctx, target, text)
302
+ end
303
+
304
+ def skill_card(ctx, target, text)
305
+ peer = self.class
306
+ current = File.file?(target) ? File.read(target) : nil
307
+ state = if current.nil? then "Not installed yet."
308
+ elsif current == text then "Installed and up to date."
309
+ else "Installed, and different from this bundle's (Replace overwrites it):\n\n```diff\n#{diff(target, text)}\n```"
310
+ end
311
+ body = ["#{peer::APP}'s side: it pairs (`pair with chi <id>`), answers validates and replies into chi.",
312
+ "target: `#{target}`", state, setup_paragraph(ctx)]
313
+ actions = if current == text
314
+ []
315
+ else
316
+ [{ label: current ? "Replace" : "Install", command: "#{peer::COMMAND} install-skill --yes" }]
317
+ end
318
+ ctx.card(id: peer::SKILL_CARD, title: "#{peer::NAME}'s chi-peer skill", body: body.join("\n\n"), actions: actions)
319
+ nil
320
+ end
321
+
322
+ def write_skill(ctx, target, text)
323
+ FileUtils.mkdir_p(File.dirname(target))
324
+ File.write(target, text)
325
+ ctx.card(id: self.class::SKILL_CARD, title: "#{self.class::NAME}'s chi-peer skill",
326
+ body: "Installed `#{target}`. #{installed_line(ctx.session_id.to_s[0, 8])}")
327
+ nil
328
+ rescue SystemCallError => e
329
+ "#{self.class::COMMAND} install-skill: couldn't write #{target}: #{e.message}"
330
+ end
331
+
332
+ # The first lines of `diff -u installed new`.
333
+ def diff(target, text)
334
+ Tempfile.create(["chi-peer", ".md"]) do |file|
335
+ file.write(text)
336
+ file.flush
337
+ out, = Open3.capture2("diff", "-u", "--label", "installed", "--label", "this bundle's", target, file.path)
338
+ lines = out.lines
339
+ lines = lines.first(DIFF_LINES) + ["… #{lines.size - DIFF_LINES} more lines\n"] if lines.size > DIFF_LINES
340
+ lines.join.chomp
341
+ end
342
+ rescue SystemCallError
343
+ "(diff isn't there to show how)"
344
+ end
345
+
346
+ def tilde(path)
347
+ home = Dir.home
348
+ path.start_with?("#{home}/") ? "~#{path.delete_prefix(home)}" : path
349
+ end
350
+
351
+ def follow_up(kind)
352
+ case kind
353
+ when "context" then "#{self.class::NAME} takes it in; it answers only if something in it is wrong."
354
+ else "#{self.class::NAME}'s answer comes as an update of the #{self.class::REVIEW} context; don't wait or poll."
355
+ end
356
+ end
357
+
358
+ # sessions/<id>/<HHMMSS>-<slug>.md under the bundle's data dir, readable
359
+ # by its owner only.
360
+ def write_payload(ctx, id, title, text)
361
+ dir = session_dir(ctx, id)
362
+ base = "#{Time.now.strftime("%H%M%S")}-#{slug(title)}"
363
+ path = File.join(dir, "#{base}.md")
364
+ n = 1
365
+ path = File.join(dir, "#{base}-#{n += 1}.md") while File.exist?(path)
366
+ File.open(path, "w", 0o600) { |file| file.write(text) }
367
+ path
368
+ rescue SystemCallError => e
369
+ raise SendError, "couldn't write the payload file: #{e.message}"
370
+ end
371
+
372
+ def session_dir(ctx, id)
373
+ File.join(ctx.data_dir, "sessions", id).tap { |dir| FileUtils.mkdir_p(dir) }
374
+ end
375
+
376
+ def record(ctx, id, **send)
377
+ entry = { at: Time.now.utc.iso8601, **send }
378
+ @lock.synchronize { File.write(File.join(session_dir(ctx, id), LAST_SEND), JSON.generate(entry)) }
379
+ rescue SystemCallError => e
380
+ ctx.log.info(:last_send_not_saved, error: e.message)
381
+ end
382
+
383
+ def slug(title)
384
+ slug = title.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")[0, SLUG_CHARS].delete_suffix("-")
385
+ slug.empty? ? "part" : slug
386
+ end
387
+
388
+ def one_line(text)
389
+ line = text.to_s.gsub(/\s+/, " ").strip
390
+ line.length > TITLE_CHARS ? "#{line[0, TITLE_CHARS - 1]}…" : line
391
+ end
392
+ end
393
+ end
394
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../tools/args"
5
+
6
+ module Samagotchi
7
+ module Plugin
8
+ # A checked tool declaration: what Api#tool, ToolSet#tool and
9
+ # ToolSpec.build make, and what Tools::Registry#register gets
10
+ # (#entry_fields). +schema+ is what reaches the prompt ({name:,
11
+ # description:, parameters:}); the block, preview:, targets: and refs:
12
+ # never do.
13
+ class ToolSpec < Data.define(:name, :schema, :label, :preview, :targets, :refs, :block)
14
+ TOOL_NAME = /\A[a-z][a-z0-9_]{0,47}\z/
15
+ PARAM_NAME = /\A[a-z_][a-z0-9_]*\z/i
16
+
17
+ # A checked tool declaration from #tool's arguments (Api#tool and
18
+ # ToolSet#tool pass theirs on, so a new keyword is added here).
19
+ # @return [ToolSpec]
20
+ def self.build(name, description, params: {}, schema: nil, label: nil, preview: nil, targets: nil, refs: nil,
21
+ &block)
22
+ raise ArgumentError, "tool #{name.inspect} needs a block" unless block
23
+
24
+ name = name.to_s
25
+ raise ArgumentError, "tool name #{name.inspect} must be a-z, 0-9 and _ (at most 48)" unless name.match?(TOOL_NAME)
26
+ raise ArgumentError, "tool #{name}: preview must respond to #call" if preview && !preview.respond_to?(:call)
27
+ raise ArgumentError, "tool #{name}: targets must respond to #call" if targets && !targets.respond_to?(:call)
28
+ raise ArgumentError, "tool #{name}: refs must respond to #call" if refs && !refs.respond_to?(:call)
29
+
30
+ parameters = schema ? schema_parameters(name, schema) : params_schema(name, params)
31
+ new(name: name, schema: { name: name, description: description.to_s, parameters: parameters },
32
+ label: label&.to_s, preview: preview, targets: targets, refs: refs, block: block)
33
+ end
34
+
35
+ # Make +bundle+'s tools in +registry+ the +specs+: its tools not in
36
+ # them go; a new one, or one whose schema or label changed, is
37
+ # registered (a changed one again, in its place: Tools::Registry
38
+ # orders bundle tools by bundle and name); an unchanged one is kept
39
+ # as it is, but for its block, preview:, targets: and refs:, swapped
40
+ # in place: none of them reaches the prompt, so not a change (that
41
+ # would build the prompts again). A name
42
+ # another source has is left out. Called on the turn thread
43
+ # (Engine#apply_staged_tools!).
44
+ # @return [Hash] {changed: Boolean, skipped: [String] (why)}
45
+ def self.apply(registry, bundle, specs, context)
46
+ wanted = specs.to_h { |spec| [spec.name, spec] }
47
+ changed = false
48
+ registry.by_source(bundle).each do |entry|
49
+ if (spec = wanted[entry.name]) && spec.schema == entry.schema && spec.label == entry.label
50
+ spec.entry_fields(context).slice(:handler, :preview, :targets, :refs).each { |key, value| entry[key] = value }
51
+ next
52
+ end
53
+
54
+ registry.unregister(entry.name)
55
+ changed = true
56
+ end
57
+ skipped = []
58
+ specs.each do |spec|
59
+ if (taken = registry[spec.name])
60
+ skipped << "tool #{spec.name} is already registered (#{taken.source})" unless taken.source == bundle
61
+ next
62
+ end
63
+
64
+ registry.register(spec.name, source: bundle, **spec.entry_fields(context))
65
+ changed = true
66
+ end
67
+ { changed: changed, skipped: skipped }
68
+ end
69
+
70
+ # A tool's result as the model reads it: a String (a ToolResult too,
71
+ # with its images) as it is, a Hash or Array as JSON (its #to_s is
72
+ # Hash#inspect, which changes with the Ruby version), else #to_s.
73
+ def self.result_text(result)
74
+ case result
75
+ when String then result
76
+ when Hash, Array then JSON.generate(result)
77
+ else result.to_s
78
+ end
79
+ rescue JSON::GeneratorError
80
+ result.to_s
81
+ end
82
+
83
+ # A tool call's arguments as a plugin sees them: the parsers' args:
84
+ # (a call built without one, in a spec say: the call without its
85
+ # name), string keys, typed by +parameters+, frozen.
86
+ def self.args_of(call, parameters = nil)
87
+ given = call[:args].is_a?(Hash) ? call[:args] : call.reject { |key, _| %i[name args].include?(key) }
88
+ Tools::Args.coerce(given, parameters).freeze
89
+ end
90
+
91
+ # A JSON Schema with symbol keys (property names included), as
92
+ # ToolDeclarations::TOOL_SCHEMAS has them.
93
+ def self.symbolize(value)
94
+ case value
95
+ when Hash then value.to_h { |key, item| [key.to_sym, symbolize(item)] }
96
+ when Array then value.map { |item| symbolize(item) }
97
+ else value
98
+ end
99
+ end
100
+
101
+ # The parameters object from params: (name => property spec).
102
+ def self.params_schema(name, params)
103
+ raise ArgumentError, "tool #{name}: params must be a Hash" unless params.is_a?(Hash)
104
+
105
+ required = []
106
+ properties = params.each_with_object({}) do |(param, spec), acc|
107
+ param = param.to_s
108
+ raise ArgumentError, "tool #{name}: parameter name #{param.inspect} is not a plain word" unless param.match?(PARAM_NAME)
109
+ raise ArgumentError, "tool #{name}: parameter #{param} must be a Hash {type:, description:}" unless spec.is_a?(Hash)
110
+
111
+ spec = symbolize(spec)
112
+ required << param if spec.delete(:required) == true
113
+ acc[param.to_sym] = { type: (spec.delete(:type) || "string").to_s, description: spec.delete(:description).to_s }.merge(spec)
114
+ end
115
+ { type: "object", properties: properties, required: required }
116
+ end
117
+ private_class_method :params_schema
118
+
119
+ # The parameters object from schema: (a JSON Schema object).
120
+ def self.schema_parameters(name, schema)
121
+ raise ArgumentError, "tool #{name}: schema must be a Hash" unless schema.is_a?(Hash)
122
+
123
+ schema = symbolize(schema)
124
+ raise ArgumentError, "tool #{name}: schema must be {type: \"object\", properties: {…}}" unless schema.fetch(:type, "object").to_s == "object"
125
+
126
+ properties = schema[:properties] || {}
127
+ raise ArgumentError, "tool #{name}: schema properties must be a Hash" unless properties.is_a?(Hash)
128
+
129
+ { type: "object", properties: properties, required: Array(schema[:required]).map(&:to_s) }
130
+ .merge(schema.except(:type, :properties, :required))
131
+ end
132
+ private_class_method :schema_parameters
133
+
134
+ # A field by name, as a Hash reads (spec[:schema]; nil for a name it
135
+ # hasn't): bundles read the spec Api.tool_spec gives that way.
136
+ def [](key) = (public_send(key) if members.include?(key))
137
+
138
+ # Registry#register's keywords for it: its block wrapped as a handler
139
+ # (typed args, the Context), preview, targets, refs.
140
+ def entry_fields(context)
141
+ block = self.block
142
+ parameters = schema[:parameters]
143
+ preview = per_call(self.preview, parameters)
144
+ {
145
+ schema: schema, label: label,
146
+ handler: lambda { |call, _kctx|
147
+ result = block.call(ToolSpec.args_of(call, parameters), context)
148
+ ToolSpec.result_text(result)
149
+ },
150
+ preview: preview && ->(call) { preview.call(call)&.to_s },
151
+ targets: per_call(targets, parameters),
152
+ refs: per_call(refs, parameters)
153
+ }
154
+ end
155
+
156
+ private
157
+
158
+ # A plugin's args → value lambda (+fn+) as call → value: it gets the
159
+ # call's typed args (ToolSpec.args_of), as the tool's block does. nil
160
+ # without one.
161
+ def per_call(fn, parameters)
162
+ fn && ->(call) { fn.call(ToolSpec.args_of(call, parameters)) }
163
+ end
164
+ end
165
+ end
166
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require_relative "cancellation_controller"
4
4
  require_relative "log"
5
- require_relative "plugin/api"
5
+ require_relative "plugin/tool_spec"
6
6
 
7
7
  module Samagotchi
8
8
  # An Engine's plugin init tasks (chi.init) and the tool sets plugins stage
@@ -153,7 +153,7 @@ module Samagotchi
153
153
  end
154
154
  changed = false
155
155
  staged.each do |bundle, (specs, context)|
156
- result = Plugin::Api.apply_tools(@tools.call, bundle, specs, context)
156
+ result = Plugin::ToolSpec.apply(@tools.call, bundle, specs, context)
157
157
  changed ||= result[:changed]
158
158
  result[:skipped].each do |why|
159
159
  Log.warn(:plugins, "plugin_tool_skipped", bundle: bundle, msg: why)