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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +80 -1
- data/docs/architecture.md +55 -30
- data/docs/cli.md +3 -1
- data/docs/configuration.md +50 -9
- data/docs/context.md +35 -1
- data/docs/desktop.md +90 -22
- data/docs/guardrails.md +8 -5
- data/docs/hooks.md +2 -2
- data/docs/internals/anthropic.md +156 -0
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bundles.md +2 -0
- data/docs/internals/engine.md +129 -38
- data/docs/internals/gemma4-contract.md +1 -1
- data/docs/internals/llm-context-bench.md +5 -4
- data/docs/internals/prompt-caching.md +26 -11
- data/docs/plugins.md +126 -23
- data/docs/sessions.md +6 -6
- data/docs/sub-agent.md +4 -1
- data/docs/testing.md +18 -4
- data/lib/samagotchi/bootstrap/probe.rb +4 -3
- data/lib/samagotchi/bootstrap_command.rb +13 -3
- data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
- data/lib/samagotchi/bridge.rb +6 -3
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +7 -6
- data/lib/samagotchi/bundles/claude-peer/plugin.rb +34 -313
- data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +8 -3
- data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +14 -6
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +7 -6
- data/lib/samagotchi/bundles/codex-peer/plugin.rb +37 -312
- data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +8 -3
- data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +14 -6
- data/lib/samagotchi/bundles/mcp/manifest.yml +5 -5
- data/lib/samagotchi/bundles/mcp/plugin.rb +82 -294
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -3
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/config.rb +15 -7
- data/lib/samagotchi/context_absorber.rb +66 -13
- data/lib/samagotchi/desktop/macos/AgentTerminal.swift +260 -0
- data/lib/samagotchi/desktop/macos/App.swift +3 -3
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -2
- data/lib/samagotchi/desktop/macos/Images.swift +2 -2
- data/lib/samagotchi/desktop/macos/Kitty.swift +33 -197
- data/lib/samagotchi/desktop/macos/Panel.swift +118 -82
- data/lib/samagotchi/desktop/macos/ProcessRunner.swift +1 -1
- data/lib/samagotchi/desktop/macos/Tmux.swift +239 -0
- data/lib/samagotchi/desktop/macos.rb +84 -13
- data/lib/samagotchi/desktop_command.rb +14 -3
- data/lib/samagotchi/effective_model.rb +283 -0
- data/lib/samagotchi/engine.rb +307 -549
- data/lib/samagotchi/engine_peers.rb +3 -0
- data/lib/samagotchi/guardrail_wiring.rb +1 -1
- data/lib/samagotchi/guardrails/approval.rb +1 -1
- data/lib/samagotchi/guardrails/approvals.rb +1 -1
- data/lib/samagotchi/guardrails/rules.rb +1 -1
- data/lib/samagotchi/guardrails/targets.rb +1 -1
- data/lib/samagotchi/hook_routes.rb +151 -0
- data/lib/samagotchi/hooks/registry.rb +2 -2
- data/lib/samagotchi/host_keys.rb +147 -0
- data/lib/samagotchi/level_setting_command.rb +99 -0
- data/lib/samagotchi/llm/adapter_common.rb +138 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +28 -105
- data/lib/samagotchi/llm/api_key.rb +23 -7
- data/lib/samagotchi/llm/chat_loop.rb +10 -6
- data/lib/samagotchi/llm/openai_chat.rb +9 -92
- data/lib/samagotchi/llm/refusal_memo.rb +29 -0
- data/lib/samagotchi/llm_context_override.rb +1 -0
- data/lib/samagotchi/llm_context_strategy.rb +5 -10
- data/lib/samagotchi/peer_sources.rb +58 -0
- data/lib/samagotchi/plugin/api.rb +64 -155
- data/lib/samagotchi/plugin/loader.rb +4 -2
- data/lib/samagotchi/plugin/peer.rb +394 -0
- data/lib/samagotchi/plugin/tool_spec.rb +166 -0
- data/lib/samagotchi/plugin_tasks.rb +2 -2
- data/lib/samagotchi/reasoning_history_command.rb +29 -90
- data/lib/samagotchi/row_refs.rb +1 -1
- data/lib/samagotchi/self_report.rb +5 -3
- data/lib/samagotchi/send_command.rb +21 -11
- data/lib/samagotchi/session.rb +22 -8
- data/lib/samagotchi/session_continue.rb +58 -15
- data/lib/samagotchi/session_inbox.rb +0 -13
- data/lib/samagotchi/session_setting.rb +86 -0
- data/lib/samagotchi/thinking_command.rb +24 -76
- data/lib/samagotchi/thinking_notices.rb +169 -0
- data/lib/samagotchi/tool_call_parser.rb +1 -1
- data/lib/samagotchi/toolkit_binding.rb +138 -0
- data/lib/samagotchi/tools/context_read.rb +14 -0
- data/lib/samagotchi/tools/execute.rb +12 -12
- data/lib/samagotchi/tools/registry.rb +1 -1
- data/lib/samagotchi/tools/task_create.rb +6 -6
- data/lib/samagotchi/tools/task_get.rb +13 -13
- data/lib/samagotchi/tools/task_list.rb +6 -6
- data/lib/samagotchi/tools/task_record.rb +52 -0
- data/lib/samagotchi/tools/task_runtime.rb +55 -91
- data/lib/samagotchi/tools/task_stop.rb +6 -6
- data/lib/samagotchi/tools/task_wait.rb +12 -13
- data/lib/samagotchi/tools/tool_index.rb +454 -0
- data/lib/samagotchi/update_command.rb +9 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +71 -93
- data/lib/samagotchi/web/capabilities.rb +1 -1
- data/lib/samagotchi/web/public/activity.js +2 -2
- data/lib/samagotchi/web/public/annotate_actions.js +1 -1
- data/lib/samagotchi/web/public/app.js +103 -539
- data/lib/samagotchi/web/public/bubbles.js +291 -0
- data/lib/samagotchi/web/public/cards_place.js +184 -0
- data/lib/samagotchi/web/public/chains.js +1 -1
- data/lib/samagotchi/web/public/chip_popover.js +111 -0
- data/lib/samagotchi/web/public/command_list.js +12 -10
- data/lib/samagotchi/web/public/commands.js +2 -2
- data/lib/samagotchi/web/public/composer_size.js +2 -2
- data/lib/samagotchi/web/public/copy.js +1 -1
- data/lib/samagotchi/web/public/ctx.js +1 -1
- data/lib/samagotchi/web/public/delivery_line.js +4 -4
- data/lib/samagotchi/web/public/families_view.js +1 -1
- data/lib/samagotchi/web/public/format.js +11 -7
- data/lib/samagotchi/web/public/images.js +25 -2
- data/lib/samagotchi/web/public/index.html +15 -7
- data/lib/samagotchi/web/public/info_bar.js +3 -3
- data/lib/samagotchi/web/public/info_bar_model.js +110 -0
- data/lib/samagotchi/web/public/init_row.js +21 -10
- data/lib/samagotchi/web/public/level_chip.js +29 -80
- data/lib/samagotchi/web/public/lists_view.js +64 -9
- data/lib/samagotchi/web/public/llm_context_chip.js +38 -107
- data/lib/samagotchi/web/public/model_picker.js +26 -22
- data/lib/samagotchi/web/public/notify.js +6 -4
- data/lib/samagotchi/web/public/question_card.js +1 -1
- data/lib/samagotchi/web/public/ref_scan.js +2 -8
- data/lib/samagotchi/web/public/refs.js +13 -0
- data/lib/samagotchi/web/public/session_state.js +14 -0
- data/lib/samagotchi/web/public/session_title.js +2 -3
- data/lib/samagotchi/web/public/sessions_list.js +0 -8
- data/lib/samagotchi/web/public/stage_frame.js +179 -0
- data/lib/samagotchi/web/public/stage_model.js +35 -2
- data/lib/samagotchi/web/public/stage_slots.js +130 -0
- data/lib/samagotchi/web/public/stage_view.js +56 -335
- data/lib/samagotchi/web/public/stage_watch.js +65 -0
- data/lib/samagotchi/web/public/strip.js +3 -3
- data/lib/samagotchi/web/public/task_stop.js +3 -2
- data/lib/samagotchi/web/public/turn_events.js +1 -1
- data/lib/samagotchi/web/public/turn_html.js +2 -2
- data/lib/samagotchi/web/public/turn_model.js +2 -2
- data/lib/samagotchi/web/public/turn_view.js +1 -1
- data/lib/samagotchi/web/public/vendor/directive-helpers.js +8 -0
- data/lib/samagotchi/web/public/vendor/directives/repeat.js +9 -0
- data/lib/samagotchi/web/server.rb +0 -4
- data/lib/samagotchi/worker.rb +22 -24
- data/lib/samagotchi/worker_inbound.rb +36 -4
- data/lib/samagotchi/worker_wakes.rb +67 -7
- 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/
|
|
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::
|
|
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)
|