samagotchi 0.8.1 → 0.10.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 +89 -1
- data/docs/architecture.md +24 -9
- data/docs/cli.md +4 -3
- data/docs/guardrails.md +9 -2
- data/docs/hooks.md +44 -7
- data/docs/internals/gemma4-contract.md +1 -0
- data/docs/memory.md +5 -3
- data/docs/plugins.md +47 -17
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bootstrap_command.rb +29 -29
- data/lib/samagotchi/bridge/card_store.rb +4 -4
- data/lib/samagotchi/bridge/pending_card.rb +2 -2
- data/lib/samagotchi/bridge/sse_writer.rb +22 -28
- data/lib/samagotchi/bridge/turn_accumulator.rb +92 -5
- data/lib/samagotchi/bridge.rb +59 -112
- data/lib/samagotchi/bridge_client.rb +11 -28
- data/lib/samagotchi/bundle_command.rb +158 -212
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +3 -4
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +7 -3
- data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +10 -10
- data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
- data/lib/samagotchi/bundles/mcp/plugin.rb +2 -4
- data/lib/samagotchi/bundles/skills/manifest.yml +3 -3
- data/lib/samagotchi/bundles/skills/plugin.rb +65 -17
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +0 -1
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/identity.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +5 -4
- data/lib/samagotchi/cli/command.rb +51 -0
- data/lib/samagotchi/cli/flags.rb +116 -0
- data/lib/samagotchi/config.rb +70 -55
- data/lib/samagotchi/config_text_edit.rb +107 -0
- data/lib/samagotchi/desktop_command.rb +24 -18
- data/lib/samagotchi/edit_preview.rb +2 -2
- data/lib/samagotchi/engine.rb +20 -75
- data/lib/samagotchi/events.rb +23 -0
- data/lib/samagotchi/guardrail_wiring.rb +2 -5
- data/lib/samagotchi/guardrails/targets.rb +7 -1
- data/lib/samagotchi/hooks/bundle_loader.rb +13 -30
- data/lib/samagotchi/hooks/loader.rb +23 -33
- data/lib/samagotchi/hooks/registry.rb +41 -0
- data/lib/samagotchi/hooks.rb +0 -3
- data/lib/samagotchi/host_registry.rb +15 -0
- data/lib/samagotchi/idle_reminders.rb +0 -7
- data/lib/samagotchi/image_store.rb +22 -0
- data/lib/samagotchi/kernel_loop.rb +12 -16
- data/lib/samagotchi/live_versions.rb +2 -13
- data/lib/samagotchi/llm/native_tool_normalizer.rb +10 -227
- data/lib/samagotchi/log_subscriber.rb +5 -1
- data/lib/samagotchi/memory_bundle/builder.rb +1 -14
- data/lib/samagotchi/memory_bundle/index_sync.rb +73 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +2 -28
- data/lib/samagotchi/memory_bundle/installer.rb +25 -30
- data/lib/samagotchi/memory_bundle/provenance.rb +2 -6
- data/lib/samagotchi/memory_bundle/status.rb +1 -14
- data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -2
- data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -24
- data/lib/samagotchi/memory_paths.rb +10 -0
- data/lib/samagotchi/note_command.rb +23 -37
- data/lib/samagotchi/owner_lock.rb +14 -3
- data/lib/samagotchi/plugin/api.rb +11 -17
- data/lib/samagotchi/plugin/context.rb +70 -15
- data/lib/samagotchi/plugin/loader.rb +2 -3
- data/lib/samagotchi/sampling_settings.rb +3 -5
- data/lib/samagotchi/self_report.rb +6 -9
- data/lib/samagotchi/send_command.rb +33 -52
- data/lib/samagotchi/session.rb +88 -92
- data/lib/samagotchi/session_archive_command.rb +8 -14
- data/lib/samagotchi/session_commands.rb +3 -21
- data/lib/samagotchi/session_delete_command.rb +15 -29
- data/lib/samagotchi/session_manager.rb +29 -15
- data/lib/samagotchi/session_metrics.rb +2 -1
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/system_prompt.rb +2 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -80
- data/lib/samagotchi/terminal_ui/attached_view.rb +1 -10
- data/lib/samagotchi/terminal_ui/between_turns.rb +133 -0
- data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +51 -1
- data/lib/samagotchi/terminal_ui/status_row.rb +32 -0
- data/lib/samagotchi/terminal_ui/thinking_line.rb +3 -3
- data/lib/samagotchi/terminal_ui.rb +156 -234
- data/lib/samagotchi/thinking.rb +2 -3
- data/lib/samagotchi/thought_stream_splitter.rb +10 -9
- data/lib/samagotchi/tool_call_parser.rb +56 -292
- data/lib/samagotchi/tool_declarations.rb +1 -1
- data/lib/samagotchi/tool_runner.rb +8 -0
- data/lib/samagotchi/tools/builtin_calls.rb +146 -0
- data/lib/samagotchi/tools/builtins.rb +2 -1
- data/lib/samagotchi/tools/edit.rb +21 -29
- data/lib/samagotchi/tools/memory.rb +2 -13
- data/lib/samagotchi/tools/send_note.rb +1 -1
- data/lib/samagotchi/update_command.rb +32 -22
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +4 -5
- data/lib/samagotchi/web/app.rb +89 -151
- data/lib/samagotchi/web/public/activity.js +1 -11
- data/lib/samagotchi/web/public/annotate_bar.js +120 -0
- data/lib/samagotchi/web/public/app.js +158 -785
- data/lib/samagotchi/web/public/chunk_router.js +7 -6
- data/lib/samagotchi/web/public/command_list.js +99 -0
- data/lib/samagotchi/web/public/data.js +22 -66
- data/lib/samagotchi/web/public/format.js +0 -23
- data/lib/samagotchi/web/public/index.html +0 -2
- data/lib/samagotchi/web/public/model_pick.js +0 -7
- data/lib/samagotchi/web/public/notifications.js +169 -0
- data/lib/samagotchi/web/public/question_cards.js +352 -0
- data/lib/samagotchi/web/public/session_state.js +34 -0
- data/lib/samagotchi/web/public/timing.js +1 -1
- data/lib/samagotchi/web/public/turn_events.js +11 -2
- data/lib/samagotchi/web/public/turn_model.js +0 -14
- data/lib/samagotchi/web/session_hub.rb +6 -4
- data/lib/samagotchi/web/session_summary.rb +5 -3
- data/lib/samagotchi/worker.rb +59 -53
- data/lib/samagotchi/worker_sidecar.rb +27 -1
- metadata +13 -1
|
@@ -1,217 +1,19 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
-
require_relative "../tools/
|
|
5
|
-
require_relative "../tools/read"
|
|
6
|
-
require_relative "../tools/write"
|
|
7
|
-
require_relative "../tools/memory"
|
|
8
|
-
require_relative "../tools/edit"
|
|
9
|
-
require_relative "../tools/task_create"
|
|
10
|
-
require_relative "../tools/task_get"
|
|
11
|
-
require_relative "../tools/task_list"
|
|
12
|
-
require_relative "../tools/task_stop"
|
|
13
|
-
require_relative "../tools/task_wait"
|
|
14
|
-
require_relative "../tools/web_fetch"
|
|
15
|
-
require_relative "../tools/register_reminder"
|
|
16
|
-
require_relative "../tools/cancel_reminder"
|
|
17
|
-
require_relative "../tools/list_reminders"
|
|
18
|
-
require_relative "../tools/list_sessions"
|
|
19
|
-
require_relative "../tools/send_note"
|
|
20
|
-
require_relative "../tools/delegate"
|
|
21
|
-
require_relative "../tools/delegate_result"
|
|
22
|
-
require_relative "../tools/ask_user_question"
|
|
4
|
+
require_relative "../tools/builtin_calls"
|
|
23
5
|
|
|
24
6
|
module Samagotchi
|
|
25
7
|
module LLM
|
|
26
8
|
# Bridges a native tool call (an LLM::ToolCall from the chat adapter; any
|
|
27
|
-
# object with #name and #arguments) into samagotchi's internal tool-call
|
|
28
|
-
#
|
|
9
|
+
# object with #name and #arguments) into samagotchi's internal tool-call
|
|
10
|
+
# shape: its arguments read as a Hash, then Tools::BuiltinCalls builds
|
|
11
|
+
# the call, as it does for the Gemma and Qwen parsers.
|
|
29
12
|
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
# Hash), while the internal `content` is a per-tool serialized text blob
|
|
34
|
-
# (e.g. `Edit` wants "<old>…</old><new>…</new>"; `Read` wants a `path`;
|
|
35
|
-
# `Execute` wants the command). Rather than re-architect every tool to accept
|
|
36
|
-
# structured args (huge blast radius), we rebuild the exact blob per tool so
|
|
37
|
-
# `dispatch` and every tool stay unchanged.
|
|
38
|
-
#
|
|
39
|
-
# Unknown tool names pass through untouched so `dispatch` renders its standard
|
|
40
|
-
# "unknown tool … available: …" error — the loop feeds that back like any tool
|
|
41
|
-
# result.
|
|
42
|
-
#
|
|
43
|
-
# This is the Phase-3 crux from Spike #4: the native→internal mapping is
|
|
44
|
-
# independent of how tools were driven in the request body.
|
|
13
|
+
# Unknown tool names pass through with their arguments on args:, so
|
|
14
|
+
# `dispatch` renders its standard "unknown tool … available: …" error —
|
|
15
|
+
# the loop feeds that back like any tool result.
|
|
45
16
|
class NativeToolNormalizer
|
|
46
|
-
# Map each known tool NAME to a proc(args => internal-call-hash).
|
|
47
|
-
# Each proc receives the tool call's arguments (`.arguments` is a Hash of string→value)
|
|
48
|
-
# and returns the internal dispatch shape with every expected key present
|
|
49
|
-
# (missing values are nil).
|
|
50
|
-
#
|
|
51
|
-
# Args come back as strings from the wire (e.g. "start_line" => "1"); tools
|
|
52
|
-
# that read them do `.to_s.strip`, so we leave them as-is here and let the
|
|
53
|
-
# dispatch site coerce.
|
|
54
|
-
MAP = {
|
|
55
|
-
Tools::Execute::NAME => lambda { |args|
|
|
56
|
-
{
|
|
57
|
-
name: Tools::Execute::NAME,
|
|
58
|
-
content: args["command"].to_s,
|
|
59
|
-
path: nil, scope: nil,
|
|
60
|
-
start_line: nil, end_line: nil,
|
|
61
|
-
cwd: args["cwd"], env: args["env"]
|
|
62
|
-
}
|
|
63
|
-
},
|
|
64
|
-
Tools::Read::NAME => lambda { |args|
|
|
65
|
-
{
|
|
66
|
-
name: Tools::Read::NAME,
|
|
67
|
-
content: args["path"].to_s,
|
|
68
|
-
path: nil, scope: nil,
|
|
69
|
-
start_line: args["start_line"], end_line: args["end_line"],
|
|
70
|
-
cwd: nil, env: nil, description: nil,
|
|
71
|
-
args: args.is_a?(Hash) ? args : {}
|
|
72
|
-
}
|
|
73
|
-
},
|
|
74
|
-
Tools::Write::NAME => lambda { |args|
|
|
75
|
-
{
|
|
76
|
-
name: Tools::Write::NAME,
|
|
77
|
-
content: args["content"] || args["text"],
|
|
78
|
-
path: args["path"], scope: nil,
|
|
79
|
-
start_line: nil, end_line: nil,
|
|
80
|
-
cwd: nil, env: nil, description: nil
|
|
81
|
-
}
|
|
82
|
-
},
|
|
83
|
-
Tools::MemoryRead::NAME => lambda { |args|
|
|
84
|
-
{
|
|
85
|
-
name: Tools::MemoryRead::NAME,
|
|
86
|
-
content: args["name"].to_s,
|
|
87
|
-
path: nil, scope: args["scope"],
|
|
88
|
-
start_line: nil, end_line: nil,
|
|
89
|
-
cwd: nil, env: nil, description: nil
|
|
90
|
-
}
|
|
91
|
-
},
|
|
92
|
-
Tools::MemoryWrite::NAME => lambda { |args|
|
|
93
|
-
{
|
|
94
|
-
name: Tools::MemoryWrite::NAME,
|
|
95
|
-
content: args["content"] || args["text"] || args["body"],
|
|
96
|
-
path: args["name"], scope: args["scope"],
|
|
97
|
-
description: args["description"],
|
|
98
|
-
current_model_only: args["current_model_only"],
|
|
99
|
-
start_line: nil, end_line: nil,
|
|
100
|
-
cwd: nil, env: nil
|
|
101
|
-
}
|
|
102
|
-
},
|
|
103
|
-
Tools::Edit::NAME => lambda { |args|
|
|
104
|
-
old_text = args["old_text"] || args["old"]
|
|
105
|
-
new_text = args["new_text"] || args["new"]
|
|
106
|
-
{
|
|
107
|
-
name: Tools::Edit::NAME,
|
|
108
|
-
content: "<old>#{old_text}</old><new>#{new_text}</new>",
|
|
109
|
-
path: args["path"],
|
|
110
|
-
start_line: args["start_line"], end_line: args["end_line"],
|
|
111
|
-
scope: nil, cwd: nil, env: nil, description: nil
|
|
112
|
-
}
|
|
113
|
-
},
|
|
114
|
-
Tools::TaskCreate::NAME => lambda { |args|
|
|
115
|
-
{
|
|
116
|
-
name: Tools::TaskCreate::NAME,
|
|
117
|
-
content: args["command"].to_s,
|
|
118
|
-
path: nil, scope: nil,
|
|
119
|
-
start_line: nil, end_line: nil,
|
|
120
|
-
cwd: args["cwd"], env: args["env"], description: nil
|
|
121
|
-
}
|
|
122
|
-
},
|
|
123
|
-
Tools::TaskGet::NAME => lambda { |args|
|
|
124
|
-
{
|
|
125
|
-
name: Tools::TaskGet::NAME,
|
|
126
|
-
content: (args["id"] || args["task_id"]).to_s,
|
|
127
|
-
path: nil, scope: nil,
|
|
128
|
-
start_line: nil, end_line: nil,
|
|
129
|
-
cwd: nil, env: nil, description: nil
|
|
130
|
-
}
|
|
131
|
-
},
|
|
132
|
-
Tools::TaskStop::NAME => lambda { |args|
|
|
133
|
-
{
|
|
134
|
-
name: Tools::TaskStop::NAME,
|
|
135
|
-
content: (args["id"] || args["task_id"]).to_s,
|
|
136
|
-
path: nil, scope: nil,
|
|
137
|
-
start_line: nil, end_line: nil,
|
|
138
|
-
cwd: nil, env: nil, description: nil
|
|
139
|
-
}
|
|
140
|
-
},
|
|
141
|
-
Tools::TaskWait::NAME => lambda { |args|
|
|
142
|
-
{
|
|
143
|
-
name: Tools::TaskWait::NAME,
|
|
144
|
-
content: (args["id"] || args["task_id"]).to_s,
|
|
145
|
-
path: nil, scope: nil,
|
|
146
|
-
start_line: nil, end_line: nil,
|
|
147
|
-
cwd: nil, env: nil,
|
|
148
|
-
timeout: args["timeout"], tail_lines: args["tail_lines"], done_pattern: args["done_pattern"],
|
|
149
|
-
description: nil
|
|
150
|
-
}
|
|
151
|
-
},
|
|
152
|
-
Tools::TaskList::NAME => lambda { |_args|
|
|
153
|
-
{
|
|
154
|
-
name: Tools::TaskList::NAME,
|
|
155
|
-
content: "", path: nil, scope: nil,
|
|
156
|
-
start_line: nil, end_line: nil,
|
|
157
|
-
cwd: nil, env: nil, description: nil
|
|
158
|
-
}
|
|
159
|
-
},
|
|
160
|
-
Tools::WebFetch::NAME => lambda { |args|
|
|
161
|
-
{
|
|
162
|
-
name: Tools::WebFetch::NAME,
|
|
163
|
-
content: args["url"].to_s,
|
|
164
|
-
path: nil, scope: nil,
|
|
165
|
-
start_line: nil, end_line: nil,
|
|
166
|
-
cwd: nil, env: nil, description: nil
|
|
167
|
-
}
|
|
168
|
-
},
|
|
169
|
-
Tools::RegisterReminder::NAME => lambda { |args|
|
|
170
|
-
{
|
|
171
|
-
name: Tools::RegisterReminder::NAME,
|
|
172
|
-
content: args["name"].to_s,
|
|
173
|
-
path: nil, scope: nil,
|
|
174
|
-
description: args["description"],
|
|
175
|
-
interval_minutes: args["interval_minutes"]
|
|
176
|
-
}
|
|
177
|
-
},
|
|
178
|
-
Tools::CancelReminder::NAME => lambda { |args|
|
|
179
|
-
{ name: Tools::CancelReminder::NAME, content: args["name"].to_s, path: nil, scope: nil }
|
|
180
|
-
},
|
|
181
|
-
Tools::ListReminders::NAME => lambda { |_args|
|
|
182
|
-
{ name: Tools::ListReminders::NAME, content: "", path: nil, scope: nil }
|
|
183
|
-
},
|
|
184
|
-
Tools::ListSessions::NAME => lambda { |args|
|
|
185
|
-
{ name: Tools::ListSessions::NAME, content: "", path: nil, scope: nil, cwd: args["cwd"] }
|
|
186
|
-
},
|
|
187
|
-
Tools::SendNote::NAME => lambda { |args|
|
|
188
|
-
{ name: Tools::SendNote::NAME, content: args["text"].to_s, path: nil, scope: nil, session: args["session"].to_s }
|
|
189
|
-
},
|
|
190
|
-
Tools::Delegate::NAME => lambda { |args|
|
|
191
|
-
{ name: Tools::Delegate::NAME, content: args["task"].to_s, path: nil, scope: nil,
|
|
192
|
-
model: args["model"], session: args["session"], wait: args["wait"], timeout: args["timeout"] }
|
|
193
|
-
},
|
|
194
|
-
Tools::DelegateResult::NAME => lambda { |args|
|
|
195
|
-
{ name: Tools::DelegateResult::NAME, content: "", path: nil, scope: nil,
|
|
196
|
-
session: args["session"], timeout: args["timeout"] }
|
|
197
|
-
},
|
|
198
|
-
Tools::AskUserQuestion::NAME => lambda { |args|
|
|
199
|
-
raw_opts = args["options"]
|
|
200
|
-
norm_opts = Samagotchi::Tools::AskUserQuestion.normalize_options_lenient(raw_opts)
|
|
201
|
-
norm_opts = raw_opts if norm_opts.empty? && raw_opts.is_a?(Array)
|
|
202
|
-
{
|
|
203
|
-
name: Tools::AskUserQuestion::NAME,
|
|
204
|
-
content: args["question"].to_s,
|
|
205
|
-
path: nil, scope: nil,
|
|
206
|
-
question: args["question"].to_s,
|
|
207
|
-
options: norm_opts.empty? ? raw_opts : norm_opts,
|
|
208
|
-
header: args["header"],
|
|
209
|
-
multi_select: args["multi_select"],
|
|
210
|
-
allow_freeform: args["allow_freeform"]
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
}.freeze
|
|
214
|
-
|
|
215
17
|
class << self
|
|
216
18
|
# Map a single native tool call (LLM::ToolCall) to the internal call hash.
|
|
217
19
|
#
|
|
@@ -221,12 +23,7 @@ module Samagotchi
|
|
|
221
23
|
def normalize(call)
|
|
222
24
|
return nil if call.nil?
|
|
223
25
|
|
|
224
|
-
|
|
225
|
-
args = args_for(call)
|
|
226
|
-
proc = MAP[name]
|
|
227
|
-
return passthrough(name, args) unless proc
|
|
228
|
-
|
|
229
|
-
proc.call(args).merge(name: name)
|
|
26
|
+
Tools::BuiltinCalls.build(call.name.to_s, args_for(call))
|
|
230
27
|
end
|
|
231
28
|
|
|
232
29
|
# Map an Array of tool calls to internal call hashes (nils dropped).
|
|
@@ -252,25 +49,11 @@ module Samagotchi
|
|
|
252
49
|
end
|
|
253
50
|
|
|
254
51
|
def safe_json_parse(str)
|
|
255
|
-
JSON.parse(str)
|
|
52
|
+
parsed = JSON.parse(str)
|
|
53
|
+
parsed.is_a?(Hash) ? parsed : { "__raw__" => str }
|
|
256
54
|
rescue JSON::ParserError
|
|
257
55
|
{ "__raw__" => str }
|
|
258
56
|
end
|
|
259
|
-
|
|
260
|
-
# A registry (plugin) tool, or an unknown one: the arguments as args:
|
|
261
|
-
# (typed by the schema at dispatch), plus enough for `dispatch` to
|
|
262
|
-
# render the standard "unknown tool" error. `content` is the joined
|
|
263
|
-
# argument values (harmless; the unknown-tool branch ignores it).
|
|
264
|
-
def passthrough(name, args)
|
|
265
|
-
{
|
|
266
|
-
name: name,
|
|
267
|
-
content: args.is_a?(Hash) ? args.values.join(" ") : name,
|
|
268
|
-
path: nil, scope: nil,
|
|
269
|
-
start_line: nil, end_line: nil,
|
|
270
|
-
cwd: nil, env: nil, description: nil,
|
|
271
|
-
args: args.is_a?(Hash) ? args : {}
|
|
272
|
-
}
|
|
273
|
-
end
|
|
274
57
|
end
|
|
275
58
|
end
|
|
276
59
|
end
|
|
@@ -123,8 +123,12 @@ module Samagotchi
|
|
|
123
123
|
def on_tool_call_completed(event)
|
|
124
124
|
# A tool's output can be any bytes (invalid UTF-8 would fail the match).
|
|
125
125
|
output = event[:output].to_s.scrub
|
|
126
|
+
# ms leaves out a guardrail approval wait (waited_ms, logged apart).
|
|
127
|
+
waited = event[:waited_ms].to_i
|
|
128
|
+
ms = since(@tool_started_at.delete([event[:iteration], event[:call_index]]))
|
|
129
|
+
ms = [ms - waited, 0].max if ms
|
|
126
130
|
log(:info, :tool_call_completed, iteration: event[:iteration], tool: event[:tool],
|
|
127
|
-
ms:
|
|
131
|
+
ms: ms, waited_ms: (waited if waited.positive?),
|
|
128
132
|
output_chars: output.length, truncated: event[:output_truncated] || nil,
|
|
129
133
|
error: tool_error?(output) || nil)
|
|
130
134
|
end
|
|
@@ -14,8 +14,6 @@ module Samagotchi
|
|
|
14
14
|
# Inverse of Installer: reads from the resolved scope directory,
|
|
15
15
|
# computes SHA256 checksums, writes manifest.yml via Manifest.write,
|
|
16
16
|
# and optionally zips/tars the staging directory.
|
|
17
|
-
#
|
|
18
|
-
# Reuses Installer.system_dir / project_dir_base overrides for test isolation.
|
|
19
17
|
class Builder
|
|
20
18
|
class BuildError < StandardError; end
|
|
21
19
|
|
|
@@ -291,18 +289,7 @@ module Samagotchi
|
|
|
291
289
|
end
|
|
292
290
|
|
|
293
291
|
def resolve_target_dir(scope)
|
|
294
|
-
|
|
295
|
-
when "system"
|
|
296
|
-
Samagotchi::MemoryBundle::Installer.system_dir
|
|
297
|
-
when "project"
|
|
298
|
-
if Samagotchi::MemoryBundle::Installer.project_dir_base_override
|
|
299
|
-
Samagotchi::MemoryBundle::Installer.project_dir_base
|
|
300
|
-
else
|
|
301
|
-
File.join(Samagotchi::MemoryBundle::Installer.project_dir_base, MemoryPaths.project_key)
|
|
302
|
-
end
|
|
303
|
-
else
|
|
304
|
-
raise BuildError, "invalid scope: #{scope}"
|
|
305
|
-
end
|
|
292
|
+
MemoryPaths.scope_dir(scope) or raise BuildError, "invalid scope: #{scope}"
|
|
306
293
|
end
|
|
307
294
|
|
|
308
295
|
def resolve_out_path(out_arg, default_name)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "index_updater"
|
|
4
|
+
require_relative "../tools/memory"
|
|
5
|
+
require_relative "../model_overlay"
|
|
6
|
+
require_relative "../log"
|
|
7
|
+
|
|
8
|
+
module Samagotchi
|
|
9
|
+
module MemoryBundle
|
|
10
|
+
# Keeps a memory's managed index.md line current when the file tools
|
|
11
|
+
# (write, edit) change it, as memory_write does: the ToolRunner calls
|
|
12
|
+
# .refresh after a write/edit that changed its file (one it could diff:
|
|
13
|
+
# a memory over 1 MB isn't refreshed). The line's description is kept.
|
|
14
|
+
#
|
|
15
|
+
# Only a *.md right in the project or system memories dir (the dirs
|
|
16
|
+
# memory_write writes to) counts: not index.md, not a hidden file, not a
|
|
17
|
+
# model overlay (<name>.<key>.md next to its <name>.md; a memory whose
|
|
18
|
+
# name has a dot and a sibling of the same stem reads as an overlay too,
|
|
19
|
+
# a leftover ambiguity). Writes into another project's folder and
|
|
20
|
+
# execute's writes aren't seen.
|
|
21
|
+
module IndexSync
|
|
22
|
+
SCOPES = %w[project system].freeze
|
|
23
|
+
|
|
24
|
+
# @param path [String] the file write/edit changed
|
|
25
|
+
# @return [Boolean] whether an index line was written
|
|
26
|
+
def self.refresh(path)
|
|
27
|
+
path = File.expand_path(path.to_s)
|
|
28
|
+
scope = memory_scope(path)
|
|
29
|
+
return false unless scope && File.file?(path)
|
|
30
|
+
|
|
31
|
+
name = File.basename(path, ".md")
|
|
32
|
+
return false if overlay?(path, name)
|
|
33
|
+
|
|
34
|
+
IndexUpdater.update_index(scope, name, File.size(path), nil)
|
|
35
|
+
rescue StandardError => e
|
|
36
|
+
Log.warn(:memory, "index_sync_failed", path: path, error: "#{e.class}: #{e.message}")
|
|
37
|
+
false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# "project" or "system" when +path+ is a memory's file (or an
|
|
41
|
+
# overlay's): a *.md right in that scope's memories dir, not index.md,
|
|
42
|
+
# not hidden, not a symlink. nil otherwise. The guardrails' outside_repo
|
|
43
|
+
# uses it too: write/edit there is what memory_write does. A symlink
|
|
44
|
+
# isn't: write/edit follow it to wherever it points, memory_write
|
|
45
|
+
# replaces the link.
|
|
46
|
+
def self.memory_scope(path)
|
|
47
|
+
path = File.expand_path(path.to_s)
|
|
48
|
+
name = File.basename(path, ".md")
|
|
49
|
+
return nil unless File.extname(path) == ".md"
|
|
50
|
+
return nil if name == IndexUpdater::MEMORY_INDEX || name.start_with?(".") || File.symlink?(path)
|
|
51
|
+
|
|
52
|
+
dir = real(File.dirname(path))
|
|
53
|
+
SCOPES.find { |s| real(Tools::MemoryRead.memories_dir(s)) == dir }
|
|
54
|
+
rescue StandardError
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.overlay?(path, name)
|
|
59
|
+
match = File.basename(path).match(ModelOverlay::OVERLAY_SUFFIX_PATTERN)
|
|
60
|
+
return false unless match
|
|
61
|
+
|
|
62
|
+
base = name.delete_suffix(".#{match[1]}")
|
|
63
|
+
File.file?(File.join(File.dirname(path), "#{base}.md"))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.real(dir)
|
|
67
|
+
File.realpath(dir)
|
|
68
|
+
rescue SystemCallError
|
|
69
|
+
File.expand_path(dir)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -7,22 +7,9 @@ module Samagotchi
|
|
|
7
7
|
module MemoryBundle
|
|
8
8
|
# Updates the scoped index.md for a single entry, locked and atomically.
|
|
9
9
|
# Used by Installer (P3) to keep index.md in sync.
|
|
10
|
-
# Delegates to MemoryRead.memories_dir for test-isolated paths.
|
|
11
10
|
module IndexUpdater
|
|
12
11
|
MEMORY_INDEX = "index"
|
|
13
12
|
LOCK_FILE = ".index.lock"
|
|
14
|
-
def self.system_dir_override
|
|
15
|
-
@system_dir_override
|
|
16
|
-
end
|
|
17
|
-
def self.system_dir_override=(val)
|
|
18
|
-
@system_dir_override = val
|
|
19
|
-
end
|
|
20
|
-
def self.project_dir_base_override
|
|
21
|
-
@project_dir_base_override
|
|
22
|
-
end
|
|
23
|
-
def self.project_dir_base_override=(val)
|
|
24
|
-
@project_dir_base_override = val
|
|
25
|
-
end
|
|
26
13
|
def self.update_index(scope, entry_name, byte_count, description = nil)
|
|
27
14
|
index_path = index_path_for(scope)
|
|
28
15
|
return true unless index_path
|
|
@@ -93,21 +80,8 @@ module Samagotchi
|
|
|
93
80
|
Date.today.iso8601
|
|
94
81
|
end
|
|
95
82
|
def self.index_path_for(scope)
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
base_dir = case normalized
|
|
99
|
-
when "system"
|
|
100
|
-
system_dir_override || MemoryPaths.system_dir
|
|
101
|
-
when "project"
|
|
102
|
-
if project_dir_base_override
|
|
103
|
-
File.join(project_dir_base_override, MemoryPaths.project_key)
|
|
104
|
-
else
|
|
105
|
-
MemoryPaths.project_dir
|
|
106
|
-
end
|
|
107
|
-
else
|
|
108
|
-
return nil
|
|
109
|
-
end
|
|
110
|
-
File.join(base_dir, "#{MEMORY_INDEX}.md")
|
|
83
|
+
base_dir = MemoryPaths.scope_dir(scope.to_s.strip)
|
|
84
|
+
base_dir && File.join(base_dir, "#{MEMORY_INDEX}.md")
|
|
111
85
|
end
|
|
112
86
|
end
|
|
113
87
|
end
|
|
@@ -29,18 +29,6 @@ module Samagotchi
|
|
|
29
29
|
|
|
30
30
|
attr_reader :results, :warnings, :placeholder_warnings, :conflicts
|
|
31
31
|
|
|
32
|
-
class << self
|
|
33
|
-
attr_accessor :system_dir_override, :project_dir_base_override
|
|
34
|
-
|
|
35
|
-
def system_dir
|
|
36
|
-
system_dir_override || MemoryPaths.system_dir
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def project_dir_base
|
|
40
|
-
project_dir_base_override || MemoryPaths.projects_dir
|
|
41
|
-
end
|
|
42
|
-
end
|
|
43
|
-
|
|
44
32
|
def initialize(source:, name:, scope: nil, force: false, strict: true, upgrade: false, dry_run: false)
|
|
45
33
|
@source = source
|
|
46
34
|
@name = name
|
|
@@ -53,9 +41,6 @@ module Samagotchi
|
|
|
53
41
|
@warnings = []
|
|
54
42
|
@placeholder_warnings = []
|
|
55
43
|
@conflicts = {}
|
|
56
|
-
# Propagate scope overrides so IndexUpdater resolves the correct directories.
|
|
57
|
-
IndexUpdater.system_dir_override = self.class.system_dir_override
|
|
58
|
-
IndexUpdater.project_dir_base_override = self.class.project_dir_base_override
|
|
59
44
|
end
|
|
60
45
|
|
|
61
46
|
def run
|
|
@@ -86,6 +71,8 @@ module Samagotchi
|
|
|
86
71
|
all_files_in_bundle = []
|
|
87
72
|
provenance = Provenance.new(name: @name)
|
|
88
73
|
existing_provenance = provenance.read
|
|
74
|
+
# A plain install over an installed bundle: summary points at upgrade.
|
|
75
|
+
@reinstall = existing_provenance && !@upgrade && !@force && !@dry_run
|
|
89
76
|
|
|
90
77
|
Dir.glob(File.join(normalized_dir, "*.md")).each do |file_path|
|
|
91
78
|
file_key = File.basename(file_path)
|
|
@@ -125,12 +112,17 @@ module Samagotchi
|
|
|
125
112
|
classification = Merger.classify(base_path: base_path, current_path: target_path, incoming_path: file_path)
|
|
126
113
|
@results[file_key] = { status: classification.to_s }
|
|
127
114
|
@conflicts[file_key] = { base: base_path, current: target_path, incoming: file_path } if classification == :conflict
|
|
115
|
+
elsif FileUtils.identical?(file_path, target_path)
|
|
116
|
+
@results[file_key] = { status: "skipped", reason: "already up to date" }
|
|
128
117
|
else
|
|
129
118
|
@results[file_key] = { status: "would_skip" }
|
|
130
119
|
end
|
|
131
120
|
else
|
|
132
121
|
@results[file_key] = { status: "would_install" }
|
|
133
122
|
end
|
|
123
|
+
elsif File.exist?(target_path) && !@force && FileUtils.identical?(file_path, target_path)
|
|
124
|
+
@results[file_key] = { status: "skipped", reason: "already up to date" }
|
|
125
|
+
update_target_index(target_scope, target_path, file_key)
|
|
134
126
|
elsif File.exist?(target_path) && !@force
|
|
135
127
|
@results[file_key] = { status: "skipped", reason: "already exists" }
|
|
136
128
|
@warnings << "Skipped #{file_key} (already exists; use --force to overwrite)"
|
|
@@ -252,13 +244,19 @@ module Samagotchi
|
|
|
252
244
|
if @dry_run
|
|
253
245
|
incoming_rules.each { |src| @results["guardrails/#{File.basename(src)}"] = { status: "would_install" } }
|
|
254
246
|
else
|
|
247
|
+
# Same bytes as installed: reported as up to date, not "Installed".
|
|
248
|
+
unchanged = incoming_rules.select do |src|
|
|
249
|
+
dest = File.join(guardrails_target, File.basename(src))
|
|
250
|
+
File.file?(dest) && FileUtils.identical?(src, dest)
|
|
251
|
+
end
|
|
255
252
|
FileUtils.rm_rf(guardrails_target)
|
|
256
253
|
unless incoming_rules.empty?
|
|
257
254
|
FileUtils.mkdir_p(guardrails_target)
|
|
258
255
|
incoming_rules.each do |src|
|
|
259
256
|
dest = File.join(guardrails_target, File.basename(src))
|
|
260
257
|
FileUtils.cp(src, dest)
|
|
261
|
-
@results["guardrails/#{File.basename(src)}"] =
|
|
258
|
+
@results["guardrails/#{File.basename(src)}"] =
|
|
259
|
+
unchanged.include?(src) ? { status: "skipped", reason: "already up to date" } : { status: "installed" }
|
|
262
260
|
guardrail_files_for_provenance[File.basename(src)] = dest
|
|
263
261
|
end
|
|
264
262
|
end
|
|
@@ -278,7 +276,8 @@ module Samagotchi
|
|
|
278
276
|
all_files_in_bundle.each do |file_key|
|
|
279
277
|
next if @conflicts.key?(file_key)
|
|
280
278
|
status = @results[file_key] ? @results[file_key][:status].to_s : nil
|
|
281
|
-
|
|
279
|
+
# skipped: not copied, so a local edit isn't a bad download.
|
|
280
|
+
next if %w[kept kept_pruned fast_forward updated skipped].include?(status)
|
|
282
281
|
# For dry-run, fast_forward would appear as "fast_forward", updated not yet
|
|
283
282
|
target_path = File.join(target_dir, file_key)
|
|
284
283
|
next unless File.exist?(target_path)
|
|
@@ -340,7 +339,13 @@ module Samagotchi
|
|
|
340
339
|
else
|
|
341
340
|
all_files_in_bundle.each do |file_key|
|
|
342
341
|
target_path = File.join(target_dir, file_key)
|
|
343
|
-
|
|
342
|
+
next unless File.exist?(target_path)
|
|
343
|
+
|
|
344
|
+
# Skipped for a local edit: keep the installed base, so the
|
|
345
|
+
# next upgrade still sees the edit (not a fast-forward over it).
|
|
346
|
+
base_path = provenance.base_path(file_key)
|
|
347
|
+
kept = existing_provenance && @results.dig(file_key, :reason) == "already exists" && File.exist?(base_path)
|
|
348
|
+
provenance_files[file_key] = kept ? base_path : target_path
|
|
344
349
|
end
|
|
345
350
|
end
|
|
346
351
|
# Build hooks metadata for provenance
|
|
@@ -409,6 +414,7 @@ module Samagotchi
|
|
|
409
414
|
lines << "Conflicts: #{conflicts.join(', ')}" unless conflicts.empty?
|
|
410
415
|
lines.concat(@warnings) unless @warnings.empty?
|
|
411
416
|
lines.concat(@placeholder_warnings) unless @placeholder_warnings.empty?
|
|
417
|
+
lines << "#{@name} is already installed; `chi bundle upgrade #{@name}` updates it and keeps local edits" if @reinstall
|
|
412
418
|
|
|
413
419
|
lines.any? ? lines.join("\n") : "No files to install."
|
|
414
420
|
end
|
|
@@ -510,18 +516,7 @@ module Samagotchi
|
|
|
510
516
|
end
|
|
511
517
|
|
|
512
518
|
def resolve_target_dir(scope)
|
|
513
|
-
|
|
514
|
-
when "system", ""
|
|
515
|
-
self.class.system_dir
|
|
516
|
-
when "project"
|
|
517
|
-
if self.class.project_dir_base_override
|
|
518
|
-
self.class.project_dir_base
|
|
519
|
-
else
|
|
520
|
-
File.join(self.class.project_dir_base, MemoryPaths.project_key)
|
|
521
|
-
end
|
|
522
|
-
else
|
|
523
|
-
raise InstallError, "invalid scope: #{scope}"
|
|
524
|
-
end
|
|
519
|
+
MemoryPaths.scope_dir(scope) or raise InstallError, "invalid scope: #{scope}"
|
|
525
520
|
end
|
|
526
521
|
end
|
|
527
522
|
end
|
|
@@ -14,12 +14,8 @@ module Samagotchi
|
|
|
14
14
|
# manifest.json — metadata about the install (merged on reinstall)
|
|
15
15
|
# bases/ — snapshots of every file as installed (for 3-way merge later)
|
|
16
16
|
class Provenance
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def bundles_dir
|
|
21
|
-
bundles_dir_override || MemoryPaths.bundles_dir
|
|
22
|
-
end
|
|
17
|
+
def self.bundles_dir
|
|
18
|
+
MemoryPaths.bundles_dir
|
|
23
19
|
end
|
|
24
20
|
|
|
25
21
|
attr_reader :name, :bundle_dir
|
|
@@ -80,20 +80,7 @@ module Samagotchi
|
|
|
80
80
|
end
|
|
81
81
|
|
|
82
82
|
def self.resolve_target_dir(scope)
|
|
83
|
-
|
|
84
|
-
when "system"
|
|
85
|
-
Samagotchi::MemoryBundle::Installer.system_dir
|
|
86
|
-
when "project"
|
|
87
|
-
base = Samagotchi::MemoryBundle::Installer.project_dir_base
|
|
88
|
-
# If override set via Installer, use it; else compute hash
|
|
89
|
-
if Samagotchi::MemoryBundle::Installer.project_dir_base_override
|
|
90
|
-
base
|
|
91
|
-
else
|
|
92
|
-
File.join(base, MemoryPaths.project_key)
|
|
93
|
-
end
|
|
94
|
-
else
|
|
95
|
-
Samagotchi::MemoryBundle::Installer.system_dir
|
|
96
|
-
end
|
|
83
|
+
MemoryPaths.scope_dir(scope) || MemoryPaths.system_dir
|
|
97
84
|
end
|
|
98
85
|
|
|
99
86
|
# Index lines name the entry without ".md" (Installer#update_target_index,
|
|
@@ -160,7 +160,7 @@ module Samagotchi
|
|
|
160
160
|
# back via the Installer (which classifies it as :install), without a
|
|
161
161
|
# full reinstall.
|
|
162
162
|
def verify_files_present(gem_manifest, version, dry_run)
|
|
163
|
-
target_dir =
|
|
163
|
+
target_dir = MemoryPaths.system_dir
|
|
164
164
|
missing = gem_manifest.files.keys.any? { |k| !File.exist?(File.join(target_dir, k)) }
|
|
165
165
|
return result(:up_to_date, from: version, to: version) unless missing
|
|
166
166
|
return result(:restored, from: version, to: version) if dry_run
|
|
@@ -174,7 +174,7 @@ module Samagotchi
|
|
|
174
174
|
def conflicting(gem_manifest)
|
|
175
175
|
provenance = Provenance.new(name: BUNDLE_NAME)
|
|
176
176
|
gem_manifest.files.keys.map(&:to_s).sort.select do |key|
|
|
177
|
-
Merger.classify(base_path: provenance.base_path(key), current_path: File.join(
|
|
177
|
+
Merger.classify(base_path: provenance.base_path(key), current_path: File.join(MemoryPaths.system_dir, key),
|
|
178
178
|
incoming_path: File.join(GEM_BUNDLE_DIR, key)) == :conflict
|
|
179
179
|
end
|
|
180
180
|
end
|
|
@@ -13,24 +13,12 @@ module Samagotchi
|
|
|
13
13
|
|
|
14
14
|
attr_reader :warnings, :removed_files
|
|
15
15
|
|
|
16
|
-
class << self
|
|
17
|
-
attr_accessor :system_dir_override, :project_dir_base_override
|
|
18
|
-
def system_dir
|
|
19
|
-
system_dir_override || MemoryPaths.system_dir
|
|
20
|
-
end
|
|
21
|
-
def project_dir_base
|
|
22
|
-
project_dir_base_override || MemoryPaths.projects_dir
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
|
|
26
16
|
def initialize(name:, scope: nil, force: false)
|
|
27
17
|
@name = name
|
|
28
18
|
@scope = scope&.to_s&.strip&.downcase
|
|
29
19
|
@force = force
|
|
30
20
|
@warnings = []
|
|
31
21
|
@removed_files = []
|
|
32
|
-
IndexUpdater.system_dir_override = self.class.system_dir_override
|
|
33
|
-
IndexUpdater.project_dir_base_override = self.class.project_dir_base_override
|
|
34
22
|
end
|
|
35
23
|
|
|
36
24
|
def run
|
|
@@ -110,18 +98,7 @@ module Samagotchi
|
|
|
110
98
|
private
|
|
111
99
|
|
|
112
100
|
def resolve_target_dir(scope)
|
|
113
|
-
|
|
114
|
-
when "system", ""
|
|
115
|
-
self.class.system_dir
|
|
116
|
-
when "project"
|
|
117
|
-
if self.class.project_dir_base_override
|
|
118
|
-
self.class.project_dir_base
|
|
119
|
-
else
|
|
120
|
-
File.join(self.class.project_dir_base, MemoryPaths.project_key)
|
|
121
|
-
end
|
|
122
|
-
else
|
|
123
|
-
raise UninstallError, "invalid scope: #{scope}"
|
|
124
|
-
end
|
|
101
|
+
MemoryPaths.scope_dir(scope) or raise UninstallError, "invalid scope: #{scope}"
|
|
125
102
|
end
|
|
126
103
|
end
|
|
127
104
|
end
|
|
@@ -94,6 +94,16 @@ module Samagotchi
|
|
|
94
94
|
File.join(projects_dir(env: env), project_key(cwd))
|
|
95
95
|
end
|
|
96
96
|
|
|
97
|
+
# The folder of a memory scope: "system" (or blank) or "project"; nil for
|
|
98
|
+
# anything else. Every scope→folder lookup (bundle install/uninstall/build/
|
|
99
|
+
# status, index.md updates, the memory tools) resolves here.
|
|
100
|
+
def scope_dir(scope, env: ENV, cwd: Dir.pwd)
|
|
101
|
+
case scope.to_s
|
|
102
|
+
when "system", "" then system_dir(env: env)
|
|
103
|
+
when "project" then project_dir(env: env, cwd: cwd)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
97
107
|
def bundles_dir(env: ENV)
|
|
98
108
|
File.join(system_dir(env: env), ".bundles")
|
|
99
109
|
end
|