samagotchi 0.19.0 → 0.21.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 +130 -1
- data/docs/architecture.md +8 -1
- data/docs/cli.md +16 -4
- data/docs/configuration.md +4 -1
- data/docs/guardrails.md +92 -14
- data/docs/plugins.md +27 -8
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bootstrap/config_writer.rb +14 -3
- data/lib/samagotchi/bootstrap_command.rb +26 -5
- data/lib/samagotchi/bridge/sse_writer.rb +32 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +10 -3
- data/lib/samagotchi/bridge.rb +16 -4
- data/lib/samagotchi/bridge_client.rb +33 -9
- data/lib/samagotchi/bundle_command.rb +28 -4
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +14 -5
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +24 -5
- data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +61 -5
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +18 -4
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/commands/registry.rb +4 -3
- data/lib/samagotchi/config.rb +3 -0
- data/lib/samagotchi/context_usage.rb +3 -3
- data/lib/samagotchi/engine.rb +31 -4
- data/lib/samagotchi/events.rb +1 -1
- data/lib/samagotchi/guardrail_wiring.rb +2 -1
- data/lib/samagotchi/guardrails/approval.rb +4 -3
- data/lib/samagotchi/guardrails/approvals.rb +11 -5
- data/lib/samagotchi/guardrails/context.rb +16 -9
- data/lib/samagotchi/guardrails/parent_approvals.rb +10 -4
- data/lib/samagotchi/guardrails/read_only_shell.rb +322 -0
- data/lib/samagotchi/guardrails/rm_targets.rb +86 -0
- data/lib/samagotchi/guardrails/rules.rb +69 -5
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +5 -137
- data/lib/samagotchi/guardrails/shell_lex.rb +164 -0
- data/lib/samagotchi/guardrails/shell_paths.rb +145 -0
- data/lib/samagotchi/guardrails/targets.rb +65 -4
- data/lib/samagotchi/guardrails.rb +2 -0
- data/lib/samagotchi/hooks/registry.rb +6 -2
- data/lib/samagotchi/idle_recap.rb +7 -2
- data/lib/samagotchi/llm/chat_loop.rb +1 -1
- data/lib/samagotchi/llm/openai_chat.rb +19 -5
- data/lib/samagotchi/llm/usage.rb +1 -1
- data/lib/samagotchi/log_subscriber.rb +4 -2
- data/lib/samagotchi/memory_bundle/builder.rb +1 -5
- data/lib/samagotchi/memory_bundle/installer.rb +46 -13
- data/lib/samagotchi/memory_bundle/shipped_update.rb +3 -1
- data/lib/samagotchi/memory_bundle/status.rb +12 -4
- data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -5
- data/lib/samagotchi/memory_paths.rb +6 -0
- data/lib/samagotchi/self_report.rb +38 -2
- data/lib/samagotchi/session.rb +35 -6
- data/lib/samagotchi/session_commands.rb +13 -2
- data/lib/samagotchi/session_manager.rb +26 -9
- data/lib/samagotchi/session_metrics.rb +230 -23
- data/lib/samagotchi/steer.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -9
- data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +59 -3
- data/lib/samagotchi/terminal_ui/live_region.rb +41 -2
- data/lib/samagotchi/terminal_ui/reline_seam.rb +10 -0
- data/lib/samagotchi/token_usage.rb +57 -41
- data/lib/samagotchi/tool_runner.rb +21 -2
- data/lib/samagotchi/tools/execute.rb +9 -8
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +19 -0
- data/lib/samagotchi/web/public/app.js +68 -22
- data/lib/samagotchi/web/public/card.js +34 -2
- data/lib/samagotchi/web/public/ctx.js +59 -0
- data/lib/samagotchi/web/public/format.js +11 -0
- data/lib/samagotchi/web/public/index.html +33 -2
- data/lib/samagotchi/web/public/model_pick.js +6 -2
- data/lib/samagotchi/web/public/model_picker.js +2 -1
- data/lib/samagotchi/web/public/notifications.js +8 -3
- data/lib/samagotchi/web/public/notify.js +49 -7
- data/lib/samagotchi/web/public/question_card.js +2 -1
- data/lib/samagotchi/web/public/timing.js +11 -5
- data/lib/samagotchi/web/public/turn_events.js +17 -1
- data/lib/samagotchi/web/session_summary.rb +6 -2
- metadata +5 -1
|
@@ -35,10 +35,12 @@ module Samagotchi
|
|
|
35
35
|
# ([] when none), usage (never nil) and the finish reason.
|
|
36
36
|
# +model+ is the model the server says answered (the body's `model`),
|
|
37
37
|
# which can differ from the one asked for; nil when it says nothing.
|
|
38
|
+
# +provider+ is the provider that served it (the body's `provider`,
|
|
39
|
+
# OpenRouter's routing name); nil when the server says nothing.
|
|
38
40
|
# +cut+ is set on the empty response the chat loop makes for a
|
|
39
41
|
# generation a plugin cut (stop_generation): {by:, reason:}.
|
|
40
|
-
ChatResponse = Data.define(:text, :reasoning, :tool_calls, :usage, :finish_reason, :model, :cut) do
|
|
41
|
-
def initialize(model: nil, cut: nil, **fields) = super
|
|
42
|
+
ChatResponse = Data.define(:text, :reasoning, :tool_calls, :usage, :finish_reason, :model, :provider, :cut) do
|
|
43
|
+
def initialize(model: nil, provider: nil, cut: nil, **fields) = super
|
|
42
44
|
end
|
|
43
45
|
|
|
44
46
|
# The OpenAI Chat Completions API (llama.cpp's /v1, and any compatible
|
|
@@ -248,7 +250,8 @@ module Samagotchi
|
|
|
248
250
|
ChatResponse.new(text: OpenAIChat.drop_leading_newlines(message["content"].to_s),
|
|
249
251
|
reasoning: (message["reasoning_content"] || message["reasoning"]).to_s,
|
|
250
252
|
tool_calls: calls, usage: Usage.from_payload(body) || Usage.none,
|
|
251
|
-
finish_reason: body.dig("choices", 0, "finish_reason"), model: served_model(body)
|
|
253
|
+
finish_reason: body.dig("choices", 0, "finish_reason"), model: served_model(body),
|
|
254
|
+
provider: self.class.served_provider(body))
|
|
252
255
|
end
|
|
253
256
|
|
|
254
257
|
# Workaround for Splash 1.0.2/1.1.0, which keeps the "\n\n" the model
|
|
@@ -265,6 +268,15 @@ module Samagotchi
|
|
|
265
268
|
|
|
266
269
|
def served_model(payload) = self.class.served_model(payload)
|
|
267
270
|
|
|
271
|
+
# OpenRouter names the provider that served a request (Baseten,
|
|
272
|
+
# Fireworks, …) in the body's `provider`; other servers send none.
|
|
273
|
+
def self.served_provider(payload)
|
|
274
|
+
provider = payload.is_a?(Hash) ? payload["provider"] : nil
|
|
275
|
+
provider.is_a?(String) && !provider.strip.empty? ? provider : nil
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def served_provider(payload) = self.class.served_provider(payload)
|
|
279
|
+
|
|
268
280
|
# The parsed payload of a `data:` line; nil for blank lines, comments
|
|
269
281
|
# and [DONE]. Error events raise their ProviderError.
|
|
270
282
|
# A 200 whose body is a plain JSON error instead of a stream (OpenRouter
|
|
@@ -352,7 +364,7 @@ module Samagotchi
|
|
|
352
364
|
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
353
365
|
|
|
354
366
|
# Streamed deltas put together: text, reasoning, tool calls by index,
|
|
355
|
-
# the finish reason, the usage chunk
|
|
367
|
+
# the finish reason, the usage chunk, the served model and provider.
|
|
356
368
|
class Assembly
|
|
357
369
|
# The chunk carries part of a tool call.
|
|
358
370
|
def self.tool_call_delta?(payload)
|
|
@@ -367,12 +379,14 @@ module Samagotchi
|
|
|
367
379
|
@finish_reason = nil
|
|
368
380
|
@usage = nil
|
|
369
381
|
@model = nil
|
|
382
|
+
@provider = nil
|
|
370
383
|
end
|
|
371
384
|
|
|
372
385
|
# @return [Array(String, String)] this chunk's content and reasoning
|
|
373
386
|
def add(payload)
|
|
374
387
|
@usage = Usage.from_payload(payload) || @usage if payload.key?("usage")
|
|
375
388
|
@model = OpenAIChat.served_model(payload) || @model
|
|
389
|
+
@provider = OpenAIChat.served_provider(payload) || @provider
|
|
376
390
|
choice = payload["choices"].is_a?(Array) ? payload["choices"].first : nil
|
|
377
391
|
return ["", ""] unless choice.is_a?(Hash)
|
|
378
392
|
|
|
@@ -392,7 +406,7 @@ module Samagotchi
|
|
|
392
406
|
ToolCall.new(id: call[:id], name: call[:name].to_s, arguments: OpenAIChat.parse_arguments(call[:arguments]))
|
|
393
407
|
end
|
|
394
408
|
ChatResponse.new(text: @text, reasoning: @reasoning, tool_calls: calls, usage: @usage || Usage.none,
|
|
395
|
-
finish_reason: @finish_reason, model: @model)
|
|
409
|
+
finish_reason: @finish_reason, model: @model, provider: @provider)
|
|
396
410
|
end
|
|
397
411
|
|
|
398
412
|
private
|
data/lib/samagotchi/llm/usage.rb
CHANGED
|
@@ -12,7 +12,7 @@ module Samagotchi
|
|
|
12
12
|
counts = TokenUsage.from_payload(payload)
|
|
13
13
|
return nil unless counts
|
|
14
14
|
|
|
15
|
-
new(prompt_tokens: counts
|
|
15
|
+
new(prompt_tokens: counts.prompt_tokens.to_i, completion_tokens: counts.completion_tokens.to_i,
|
|
16
16
|
source: :server)
|
|
17
17
|
end
|
|
18
18
|
|
|
@@ -82,7 +82,8 @@ module Samagotchi
|
|
|
82
82
|
end
|
|
83
83
|
|
|
84
84
|
def on_turn_canceled(event)
|
|
85
|
-
log(:info, :turn_canceled, ms: since(@turn_started_at), reason: event[:cancellation_reason],
|
|
85
|
+
log(:info, :turn_canceled, ms: since(@turn_started_at), reason: event[:cancellation_reason], by: event[:cancelled_by],
|
|
86
|
+
**origin(event))
|
|
86
87
|
end
|
|
87
88
|
|
|
88
89
|
def on_turn_failed(event)
|
|
@@ -100,7 +101,8 @@ module Samagotchi
|
|
|
100
101
|
def on_generation_completed(event)
|
|
101
102
|
ms = since(@generation_started_at.delete(event[:iteration]))
|
|
102
103
|
log(:info, :generation_completed, iteration: event[:iteration], ms: ms,
|
|
103
|
-
served_model: event[:served_model],
|
|
104
|
+
served_model: event[:served_model], provider: event[:served_provider],
|
|
105
|
+
requested_model: event[:requested_model],
|
|
104
106
|
content_length: event[:content_length],
|
|
105
107
|
thinking_chars: event[:thinking_chars], finish_reason: event[:finish_reason],
|
|
106
108
|
stopped_by: event[:stopped_by])
|
|
@@ -41,7 +41,7 @@ module Samagotchi
|
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
def run
|
|
44
|
-
target_dir =
|
|
44
|
+
target_dir = MemoryPaths.scope_dir(@scope) or raise BuildError, "invalid scope: #{@scope}"
|
|
45
45
|
resolved_name = @name || default_name(@scope)
|
|
46
46
|
resolved_version = @version || DEFAULT_VERSION
|
|
47
47
|
|
|
@@ -333,10 +333,6 @@ module Samagotchi
|
|
|
333
333
|
base
|
|
334
334
|
end
|
|
335
335
|
|
|
336
|
-
def resolve_target_dir(scope)
|
|
337
|
-
MemoryPaths.scope_dir(scope) or raise BuildError, "invalid scope: #{scope}"
|
|
338
|
-
end
|
|
339
|
-
|
|
340
336
|
def resolve_out_path(out_arg, default_name)
|
|
341
337
|
if out_arg.nil? || out_arg.empty?
|
|
342
338
|
# Default: <name>.zip in Dir.pwd
|
|
@@ -51,6 +51,22 @@ module Samagotchi
|
|
|
51
51
|
# none moved).
|
|
52
52
|
def trash_dir = @trash.dir
|
|
53
53
|
|
|
54
|
+
# The normalized source dir, kept alive past #run when an upgrade has
|
|
55
|
+
# conflicts (the agent step reads the incoming files); nil once cleaned.
|
|
56
|
+
attr_reader :source_dir
|
|
57
|
+
|
|
58
|
+
# Removes the normalized source dir an owned source (git/zip/tar) was
|
|
59
|
+
# extracted to. #run defers this when it found conflicts, so the
|
|
60
|
+
# conflict prompt and resolve_conflicts can still read the incoming
|
|
61
|
+
# files; the caller runs it once the agent step is done. Idempotent.
|
|
62
|
+
def cleanup_source!
|
|
63
|
+
return unless @defer_cleanup && @normalized_dir
|
|
64
|
+
|
|
65
|
+
SourceNormalizer.cleanup(@normalized_dir)
|
|
66
|
+
@normalized_dir = nil
|
|
67
|
+
@defer_cleanup = false
|
|
68
|
+
end
|
|
69
|
+
|
|
54
70
|
def run
|
|
55
71
|
normalized_dir = nil
|
|
56
72
|
source_owned = false
|
|
@@ -59,6 +75,7 @@ module Samagotchi
|
|
|
59
75
|
begin
|
|
60
76
|
normalized_dir, source_owned, source_commit = SourceNormalizer.normalize(@source)
|
|
61
77
|
@bundle_dir = normalized_dir
|
|
78
|
+
@normalized_dir = normalized_dir
|
|
62
79
|
manifest = Manifest.read(dir: normalized_dir)
|
|
63
80
|
rescue SourceNormalizer::UnknownSourceError => e
|
|
64
81
|
raise InstallError, "Source normalization failed: #{e.message}"
|
|
@@ -73,7 +90,7 @@ module Samagotchi
|
|
|
73
90
|
# Determine target scope (CLI wins over manifest).
|
|
74
91
|
cli_scope = @scope.to_s.strip.empty? ? nil : @scope
|
|
75
92
|
target_scope = cli_scope || manifest&.scope || "system"
|
|
76
|
-
target_dir =
|
|
93
|
+
target_dir = MemoryPaths.scope_dir(target_scope) or raise InstallError, "invalid scope: #{target_scope}"
|
|
77
94
|
FileUtils.mkdir_p(target_dir)
|
|
78
95
|
|
|
79
96
|
# Copy .md files and update index for each.
|
|
@@ -199,12 +216,15 @@ module Samagotchi
|
|
|
199
216
|
end
|
|
200
217
|
|
|
201
218
|
FileUtils.mkdir_p(hooks_target)
|
|
219
|
+
unchanged = File.exist?(dest) && FileUtils.identical?(src, dest)
|
|
202
220
|
FileUtils.cp(src, dest)
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
221
|
+
@results[basename] = if unchanged
|
|
222
|
+
{ status: "skipped", reason: "already up to date" }
|
|
223
|
+
elsif @upgrade && existing_provenance
|
|
224
|
+
{ status: "updated" }
|
|
225
|
+
else
|
|
226
|
+
{ status: "installed" }
|
|
227
|
+
end
|
|
208
228
|
hooks_files_for_provenance[basename] = dest
|
|
209
229
|
end
|
|
210
230
|
else
|
|
@@ -406,7 +426,17 @@ module Samagotchi
|
|
|
406
426
|
|
|
407
427
|
[normalized_dir, manifest]
|
|
408
428
|
ensure
|
|
409
|
-
|
|
429
|
+
# An owned source (git/zip/tar) is cleaned up here, unless an upgrade
|
|
430
|
+
# kept conflicts: the agent step still needs the incoming files, so
|
|
431
|
+
# the caller runs #cleanup_source! when it is done.
|
|
432
|
+
if source_owned
|
|
433
|
+
if @conflicts.any? && !@dry_run
|
|
434
|
+
@defer_cleanup = true
|
|
435
|
+
else
|
|
436
|
+
SourceNormalizer.cleanup(normalized_dir)
|
|
437
|
+
@normalized_dir = nil
|
|
438
|
+
end
|
|
439
|
+
end
|
|
410
440
|
end
|
|
411
441
|
|
|
412
442
|
def summary
|
|
@@ -541,11 +571,18 @@ module Samagotchi
|
|
|
541
571
|
return nil
|
|
542
572
|
end
|
|
543
573
|
|
|
574
|
+
dest = File.join(provenance.plugin_dir, plugin[:file])
|
|
575
|
+
unchanged = File.file?(dest) && FileUtils.identical?(src, dest)
|
|
544
576
|
FileUtils.rm_rf(provenance.plugin_dir)
|
|
545
577
|
FileUtils.mkdir_p(provenance.plugin_dir)
|
|
546
|
-
dest = File.join(provenance.plugin_dir, plugin[:file])
|
|
547
578
|
FileUtils.cp(src, dest)
|
|
548
|
-
@results[plugin[:file]] =
|
|
579
|
+
@results[plugin[:file]] = if unchanged
|
|
580
|
+
{ status: "skipped", reason: "already up to date" }
|
|
581
|
+
elsif @upgrade && provenance.read
|
|
582
|
+
{ status: "updated" }
|
|
583
|
+
else
|
|
584
|
+
{ status: "installed" }
|
|
585
|
+
end
|
|
549
586
|
expected = manifest.checksum_for_plugin
|
|
550
587
|
actual = Digest::SHA256.hexdigest(File.binread(dest))
|
|
551
588
|
if @strict && expected && actual != expected
|
|
@@ -598,10 +635,6 @@ module Samagotchi
|
|
|
598
635
|
@warnings << "index.md: line for #{entry_name} not updated (#{e.message})"
|
|
599
636
|
end
|
|
600
637
|
end
|
|
601
|
-
|
|
602
|
-
def resolve_target_dir(scope)
|
|
603
|
-
MemoryPaths.scope_dir(scope) or raise InstallError, "invalid scope: #{scope}"
|
|
604
|
-
end
|
|
605
638
|
end
|
|
606
639
|
end
|
|
607
640
|
end
|
|
@@ -9,6 +9,7 @@ require_relative "provenance"
|
|
|
9
9
|
require_relative "source"
|
|
10
10
|
require_relative "status"
|
|
11
11
|
require_relative "system_bundle"
|
|
12
|
+
require_relative "../memory_paths"
|
|
12
13
|
require_relative "../version"
|
|
13
14
|
|
|
14
15
|
module Samagotchi
|
|
@@ -112,6 +113,7 @@ module Samagotchi
|
|
|
112
113
|
return skip(row, "needs chi #{manifest.requires_chi}", keep_to: true)
|
|
113
114
|
end
|
|
114
115
|
return skip(row, "project scope: chi bundle upgrade #{ship.source} in the project") if row.scope == "project"
|
|
116
|
+
return skip(row, "unknown scope #{row.scope}: upgrade chi or reinstall") unless MemoryPaths.scope_dir(row.scope)
|
|
115
117
|
|
|
116
118
|
row.kept = conflicts(row, data)
|
|
117
119
|
row.replaced = edited_executables(name, data)
|
|
@@ -147,7 +149,7 @@ module Samagotchi
|
|
|
147
149
|
# version (a same-name file it didn't install is skipped, not merged).
|
|
148
150
|
def conflicts(row, data)
|
|
149
151
|
provenance = Provenance.new(name: row.name)
|
|
150
|
-
target_dir =
|
|
152
|
+
target_dir = MemoryPaths.scope_dir!(row.scope)
|
|
151
153
|
return [] unless data[:files].is_a?(Hash)
|
|
152
154
|
|
|
153
155
|
owned = data[:files].keys.map(&:to_s)
|
|
@@ -11,6 +11,9 @@ require_relative "../model_overlay"
|
|
|
11
11
|
module Samagotchi
|
|
12
12
|
module MemoryBundle
|
|
13
13
|
module Status
|
|
14
|
+
# A scope this chi can't resolve (a newer chi's, a hand edit) isn't
|
|
15
|
+
# guessed: scope_error says so, target_dir is nil and each file is
|
|
16
|
+
# unchecked (no disk reads, nothing counted as missing).
|
|
14
17
|
def self.bundle_status(name, scope_override: nil)
|
|
15
18
|
provenance = Provenance.new(name: name)
|
|
16
19
|
data = provenance.read
|
|
@@ -18,11 +21,13 @@ module Samagotchi
|
|
|
18
21
|
|
|
19
22
|
raw_scope = scope_override || data[:scope]&.to_s
|
|
20
23
|
scope = (raw_scope.nil? || raw_scope.strip.empty?) ? "system" : raw_scope
|
|
21
|
-
target_dir =
|
|
24
|
+
target_dir = MemoryPaths.scope_dir(scope)
|
|
22
25
|
files = data[:files] || {}
|
|
23
26
|
details = {}
|
|
24
27
|
files.each do |file_key, meta|
|
|
25
28
|
file_key_str = file_key.to_s
|
|
29
|
+
next details[file_key_str] = unchecked_file(meta) unless target_dir
|
|
30
|
+
|
|
26
31
|
target_path = File.join(target_dir, file_key_str)
|
|
27
32
|
base_path = provenance.base_path(file_key_str)
|
|
28
33
|
stored_checksum = meta[:checksum] || meta["checksum"]
|
|
@@ -46,7 +51,8 @@ module Samagotchi
|
|
|
46
51
|
base_path: base_path
|
|
47
52
|
}
|
|
48
53
|
end
|
|
49
|
-
{ provenance: data, scope: scope,
|
|
54
|
+
{ provenance: data, scope: scope, scope_error: target_dir ? nil : "unknown scope: #{scope}",
|
|
55
|
+
target_dir: target_dir, files: details, plugin: plugin_status(provenance, data),
|
|
50
56
|
hooks_requires_failure: hooks_requires_failure(data), needs: needs_status(data) }
|
|
51
57
|
end
|
|
52
58
|
|
|
@@ -91,9 +97,11 @@ module Samagotchi
|
|
|
91
97
|
requires_failure: Manifest.requires_chi_failure(data[:requires_chi], Samagotchi::VERSION) }
|
|
92
98
|
end
|
|
93
99
|
|
|
94
|
-
def self.
|
|
95
|
-
|
|
100
|
+
def self.unchecked_file(meta)
|
|
101
|
+
{ conflict: meta.is_a?(Hash) && meta[:conflict] == true, stored_checksum: meta.is_a?(Hash) ? meta[:checksum] || meta["checksum"] : nil,
|
|
102
|
+
unchecked: true, modified: false, missing: false, index_present: nil, overlay: false }
|
|
96
103
|
end
|
|
104
|
+
private_class_method :unchecked_file
|
|
97
105
|
|
|
98
106
|
# Index lines name the entry without ".md" (Installer#update_target_index,
|
|
99
107
|
# memory_write); a legacy "name.md" line counts too.
|
|
@@ -39,7 +39,7 @@ module Samagotchi
|
|
|
39
39
|
cli_scope = @scope.to_s.strip.empty? ? nil : @scope
|
|
40
40
|
raw = cli_scope || data[:scope]&.to_s
|
|
41
41
|
target_scope = (raw.nil? || raw.strip.empty?) ? "system" : raw
|
|
42
|
-
target_dir =
|
|
42
|
+
target_dir = MemoryPaths.scope_dir(target_scope) or raise UninstallError, "invalid scope: #{target_scope}"
|
|
43
43
|
|
|
44
44
|
files = data[:files] || {}
|
|
45
45
|
hooks = data[:hooks] || {}
|
|
@@ -129,10 +129,6 @@ module Samagotchi
|
|
|
129
129
|
rescue StandardError => e
|
|
130
130
|
@warnings << "index.md: line for #{file_key.delete_suffix(".md")} not updated (#{e.message})"
|
|
131
131
|
end
|
|
132
|
-
|
|
133
|
-
def resolve_target_dir(scope)
|
|
134
|
-
MemoryPaths.scope_dir(scope) or raise UninstallError, "invalid scope: #{scope}"
|
|
135
|
-
end
|
|
136
132
|
end
|
|
137
133
|
end
|
|
138
134
|
end
|
|
@@ -112,6 +112,12 @@ module Samagotchi
|
|
|
112
112
|
end
|
|
113
113
|
end
|
|
114
114
|
|
|
115
|
+
# scope_dir, or ArgumentError ("invalid scope: team") for a scope it
|
|
116
|
+
# doesn't know: for a caller that must not guess a folder.
|
|
117
|
+
def scope_dir!(scope, env: ENV, cwd: Dir.pwd)
|
|
118
|
+
scope_dir(scope, env: env, cwd: cwd) or raise ArgumentError, "invalid scope: #{scope}"
|
|
119
|
+
end
|
|
120
|
+
|
|
115
121
|
def bundles_dir(env: ENV)
|
|
116
122
|
File.join(system_dir(env: env), ".bundles")
|
|
117
123
|
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
4
6
|
require_relative "version"
|
|
5
7
|
require_relative "config"
|
|
6
8
|
require_relative "context_window"
|
|
@@ -11,6 +13,8 @@ require_relative "model_overlay"
|
|
|
11
13
|
require_relative "thinking"
|
|
12
14
|
require_relative "served_model"
|
|
13
15
|
require_relative "host_registry"
|
|
16
|
+
require_relative "llm/http"
|
|
17
|
+
require_relative "llm/api_key"
|
|
14
18
|
require_relative "tools/memory"
|
|
15
19
|
require_relative "hooks/loader"
|
|
16
20
|
require_relative "memory_bundle/provenance"
|
|
@@ -194,7 +198,9 @@ module Samagotchi
|
|
|
194
198
|
|
|
195
199
|
# chi self's one server call: a single-model llama.cpp answers any name
|
|
196
200
|
# with the model it loaded, and its /props names it (model_alias). One
|
|
197
|
-
# GET with the probe's short timeouts; a remote host isn't asked.
|
|
201
|
+
# GET with the probe's short timeouts; a remote host isn't asked. A local
|
|
202
|
+
# chat host (api: openai) has no /props, so it gets a GET <base>/models
|
|
203
|
+
# with the same timeouts: up/down and the ids it serves.
|
|
198
204
|
def served_model_for(model, env)
|
|
199
205
|
registry = HostRegistry.new(env: env)
|
|
200
206
|
target = registry.resolve(model)
|
|
@@ -202,7 +208,7 @@ module Samagotchi
|
|
|
202
208
|
bare = target.bare_model
|
|
203
209
|
return "-" unless entry
|
|
204
210
|
return "reported per turn (remote host)" if entry.remote?
|
|
205
|
-
return
|
|
211
|
+
return chat_served_model_for(entry, bare, env) if entry.chat?
|
|
206
212
|
|
|
207
213
|
client = registry.client_for(entry)
|
|
208
214
|
props = client.server_props(model: bare)
|
|
@@ -217,6 +223,36 @@ module Samagotchi
|
|
|
217
223
|
"unknown (#{e.class})"
|
|
218
224
|
end
|
|
219
225
|
|
|
226
|
+
# A local chat host's reachability: one GET <base>/models with the
|
|
227
|
+
# native probe's short timeouts (Client's /props probe), no retry. Up
|
|
228
|
+
# with the ids it serves, down when it doesn't answer.
|
|
229
|
+
def chat_served_model_for(entry, bare, env)
|
|
230
|
+
uri = URI("#{entry.openai_base_url}/models")
|
|
231
|
+
request = Net::HTTP::Get.new(uri)
|
|
232
|
+
http = LLM::HTTP.new(label: entry.name, open_timeout: Client::CONTEXT_WINDOW_PROBE_OPEN_TIMEOUT,
|
|
233
|
+
read_timeout: Client::CONTEXT_WINDOW_PROBE_READ_TIMEOUT,
|
|
234
|
+
retry_policy: LLM::HTTP::RetryPolicy.none,
|
|
235
|
+
api_key: LLM::ApiKey.for(entry.api_key_env, host: entry.host, env: env))
|
|
236
|
+
response = http.fetch(uri, request, retries: false, check_status: false, log_fields: { purpose: "models" })
|
|
237
|
+
return "down (HTTP #{response.code})" unless response.code.to_s == "200"
|
|
238
|
+
|
|
239
|
+
ids = chat_model_ids(response.body)
|
|
240
|
+
return "up (no models listed)" if ids.empty?
|
|
241
|
+
|
|
242
|
+
note = ids.any? { |id| id.casecmp?(bare.to_s) } ? "" : " (not #{bare})"
|
|
243
|
+
"up: #{ids.join(", ")}#{note}"
|
|
244
|
+
rescue StandardError
|
|
245
|
+
"down (the server didn't answer; is it running?)"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def chat_model_ids(body)
|
|
249
|
+
parsed = JSON.parse(body.to_s)
|
|
250
|
+
data = parsed.is_a?(Hash) ? parsed["data"] : parsed
|
|
251
|
+
Array(data).filter_map { |raw| raw.is_a?(Hash) ? (raw["id"] || raw[:id]).to_s : raw.to_s }.reject(&:empty?)
|
|
252
|
+
rescue JSON::ParserError
|
|
253
|
+
[]
|
|
254
|
+
end
|
|
255
|
+
|
|
220
256
|
# Offline, so no /props probe: where nothing is configured, a native
|
|
221
257
|
# llama.cpp host's chat template decides at runtime.
|
|
222
258
|
def profile_for(model, env)
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -5,6 +5,7 @@ require "json"
|
|
|
5
5
|
require "securerandom"
|
|
6
6
|
require "time"
|
|
7
7
|
require_relative "atomic_file"
|
|
8
|
+
require_relative "context_note"
|
|
8
9
|
|
|
9
10
|
require_relative "paths"
|
|
10
11
|
require_relative "project_scope"
|
|
@@ -413,27 +414,55 @@ module Samagotchi
|
|
|
413
414
|
|
|
414
415
|
# Derive and cache the first user-message preview in the session record.
|
|
415
416
|
# Returns true if the cached value was set or updated.
|
|
417
|
+
# A session with only context notes so far previews by its first note
|
|
418
|
+
# ("note: …"), until a message is typed.
|
|
416
419
|
def compute_first_preview!
|
|
417
|
-
|
|
420
|
+
from_note = note_preview
|
|
421
|
+
return false if @first_preview && !@first_preview.empty? && @first_preview != from_note
|
|
418
422
|
|
|
419
423
|
first_user = @messages.find { |m| m[:role].to_s == "user" || m["role"].to_s == "user" }
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
preview = self.class.preview_of(first_user[:content] || first_user["content"])
|
|
423
|
-
return false if preview.empty?
|
|
424
|
+
preview = first_user ? self.class.preview_of(first_user[:content] || first_user["content"]) : from_note.to_s
|
|
425
|
+
return false if preview.empty? || preview == @first_preview
|
|
424
426
|
|
|
425
427
|
@first_preview = preview
|
|
426
428
|
true
|
|
427
429
|
end
|
|
428
430
|
|
|
431
|
+
# The web's annotation labels (annotations.js sourceLabel), each on its
|
|
432
|
+
# own line above a quote.
|
|
433
|
+
QUOTE_LABEL_RE = /\AFrom (?:your thinking|your earlier step|the .{1,80} call|my earlier message):\z/
|
|
434
|
+
|
|
429
435
|
# A prompt as a one-line preview: whitespace collapsed, cut at 80 chars.
|
|
436
|
+
# A quoted or annotated message previews by what was typed under the
|
|
437
|
+
# quote; a quote alone by its words, without the "> " markers or label.
|
|
430
438
|
def self.preview_of(text)
|
|
431
|
-
norm = text.to_s.gsub(/\s+/, " ").strip
|
|
439
|
+
norm = preview_words(text.to_s).gsub(/\s+/, " ").strip
|
|
432
440
|
norm.length > 80 ? "#{norm[0, 80]}…" : norm
|
|
433
441
|
end
|
|
434
442
|
|
|
443
|
+
def self.preview_words(text)
|
|
444
|
+
lines = text.lines.map(&:strip)
|
|
445
|
+
quoted = lines.select { |line| line.start_with?(">") }
|
|
446
|
+
return text if quoted.empty?
|
|
447
|
+
|
|
448
|
+
own = lines.reject { |line| line.start_with?(">") || QUOTE_LABEL_RE.match?(line) }.join("\n")
|
|
449
|
+
return own unless own.strip.empty?
|
|
450
|
+
|
|
451
|
+
quoted.map { |line| line.sub(/\A(?:>[ \t]?)+/, "") }.join("\n")
|
|
452
|
+
end
|
|
453
|
+
private_class_method :preview_words
|
|
454
|
+
|
|
435
455
|
private
|
|
436
456
|
|
|
457
|
+
# @return [String, nil] the first context note as a preview
|
|
458
|
+
def note_preview
|
|
459
|
+
note = @messages.find { |m| ContextNote.note?(m) }
|
|
460
|
+
return nil unless note
|
|
461
|
+
|
|
462
|
+
text = self.class.preview_of(ContextNote.text_of(note))
|
|
463
|
+
text.empty? ? nil : self.class.preview_of("note: #{text}")
|
|
464
|
+
end
|
|
465
|
+
|
|
437
466
|
def stringify_message_keys(hash)
|
|
438
467
|
hash.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
|
|
439
468
|
end
|
|
@@ -234,6 +234,9 @@ module Samagotchi
|
|
|
234
234
|
rules = @engine.guardrail_rules
|
|
235
235
|
approvals = @engine.guardrail_approvals.entries
|
|
236
236
|
lines = ["guardrails: #{rules.enabled? ? "on" : "off (guardrails.enabled: false; denies still apply)"}"]
|
|
237
|
+
lines << if rules.mode == "strict" then "mode: strict (every rule votes)"
|
|
238
|
+
else "mode: auto (rules marked strict only don't vote; guardrails.mode: strict in config.yml for those)"
|
|
239
|
+
end
|
|
237
240
|
name = @engine.guardrail_model_name
|
|
238
241
|
key = @engine.model_key
|
|
239
242
|
lines << "model: #{name || "none"} — #{Guardrails::ModelSize.describe(name, key)}"
|
|
@@ -253,9 +256,14 @@ module Samagotchi
|
|
|
253
256
|
("command /#{shorten_pattern(rule.command.source)}/" if rule.command),
|
|
254
257
|
("path #{rule.path}" if rule.path),
|
|
255
258
|
("git #{rule.git}" if rule.git),
|
|
256
|
-
("
|
|
259
|
+
("touches #{rule.touches}" if rule.touches),
|
|
260
|
+
("rm #{rule.rm}" if rule.rm),
|
|
261
|
+
("models #{rule.models.join(",")}" if rule.models),
|
|
262
|
+
("modes #{rule.modes.join(",")}" if rule.modes),
|
|
263
|
+
("skip_read_only" if rule.skip_read_only)
|
|
257
264
|
].compact.join(", ")
|
|
258
265
|
off = "disabled (guardrails.disable) — " if rules.disabled?(rule)
|
|
266
|
+
off ||= "(#{rule.modes.join(", ")} only) — " unless rule.for_mode?(rules.mode)
|
|
259
267
|
off ||= "off for this model — " unless rule.for_model?(name, key)
|
|
260
268
|
lines << " #{idx + 1}. #{rule.id}: #{off}#{rule.verdict} (#{match}) — #{rule.reason} [#{rule.source}]"
|
|
261
269
|
end
|
|
@@ -277,7 +285,10 @@ module Samagotchi
|
|
|
277
285
|
|
|
278
286
|
def approval_line(entry)
|
|
279
287
|
what = entry["key"] ? entry["key"].tr("\n", " ") : "any call rule #{entry["rule"]} asks about"
|
|
280
|
-
where = entry["scope"] == "session"
|
|
288
|
+
where = if entry["scope"] == "session" then "session #{entry["session_id"].to_s[0, 8]}"
|
|
289
|
+
elsif entry["repo"] then "in repo #{entry["repo"]}"
|
|
290
|
+
else "in #{entry["repo_root"]}"
|
|
291
|
+
end
|
|
281
292
|
rule = entry["rule"] ? " (rule #{entry["rule"]}, #{entry["source"]})" : ""
|
|
282
293
|
"#{entry["scope"]}: #{what} — #{where}#{rule}, #{entry["created_at"]}"
|
|
283
294
|
end
|
|
@@ -24,6 +24,7 @@ require_relative "installed_gem"
|
|
|
24
24
|
require_relative "installed_versions"
|
|
25
25
|
require_relative "recap_store"
|
|
26
26
|
require_relative "archive_store"
|
|
27
|
+
require_relative "continue_offer"
|
|
27
28
|
require_relative "image_store"
|
|
28
29
|
require_relative "plugin_session_state"
|
|
29
30
|
require_relative "terminal_ui"
|
|
@@ -105,6 +106,10 @@ module Samagotchi
|
|
|
105
106
|
case @reason
|
|
106
107
|
when :scratch then "a scratch session is deleted when you leave; nothing to archive"
|
|
107
108
|
when :busy_child then "its delegate #{@busy_id[0, 8]} is running a turn; wait for it or stop it first"
|
|
109
|
+
when :queued then "a prompt is queued, which a resume would run; let it run first"
|
|
110
|
+
when :queued_child then "its delegate #{@busy_id[0, 8]} has a prompt queued, which a resume would run; let it run first"
|
|
111
|
+
when :offer then "the step-limit question waits for an answer; answer Continue or Stop first"
|
|
112
|
+
when :offer_child then "its delegate #{@busy_id[0, 8]} waits for an answer to its step-limit question; answer it first"
|
|
108
113
|
else "a turn is running; wait for it or cancel it first"
|
|
109
114
|
end
|
|
110
115
|
end
|
|
@@ -309,8 +314,9 @@ module Samagotchi
|
|
|
309
314
|
# discarded: empty sessions their stopping worker deleted
|
|
310
315
|
# @raise [ArgumentError] unknown id (Session::AmbiguousId for a prefix of several)
|
|
311
316
|
# @raise [OwnedByTUI] a chi REPL owns it or one of its children
|
|
312
|
-
# @raise [ArchiveRefused] a turn runs in it or in a child,
|
|
313
|
-
#
|
|
317
|
+
# @raise [ArchiveRefused] a turn runs in it or in a child, a prompt is
|
|
318
|
+
# queued in one (a later resume would run it), a live one offers to
|
|
319
|
+
# continue past the step limit, or it is a scratch session
|
|
314
320
|
def self.archive_session(id_or_prefix, state_dir: nil, wait: 5)
|
|
315
321
|
sd = state_dir || Session.default_state_dir
|
|
316
322
|
id = archive_target(id_or_prefix, sd)
|
|
@@ -319,13 +325,8 @@ module Samagotchi
|
|
|
319
325
|
tree = [id, *descendant_ids(id, sd)]
|
|
320
326
|
owners = tree.to_h { |sid| [sid, session_owner(sid, state_dir: sd)] }
|
|
321
327
|
tree.each do |sid|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
raise OwnedByTUI, sid if owner.tui?
|
|
326
|
-
next unless Session.load(sid, state_dir: sd).status == Session::STATUS_RUNNING
|
|
327
|
-
|
|
328
|
-
raise ArchiveRefused.new(id, sid == id ? :busy : :busy_child, busy_id: sid)
|
|
328
|
+
refusal = archive_refusal(sid, owners[sid], sd)
|
|
329
|
+
raise ArchiveRefused.new(id, sid == id ? refusal : :"#{refusal}_child", busy_id: sid) if refusal
|
|
329
330
|
end
|
|
330
331
|
|
|
331
332
|
stopped = tree.select { |sid| owners[sid] }
|
|
@@ -334,6 +335,22 @@ module Samagotchi
|
|
|
334
335
|
{ id: id, archived: archived, stopped: stopped, discarded: discarded }
|
|
335
336
|
end
|
|
336
337
|
|
|
338
|
+
# What keeps +sid+ (in an archive's tree) from being archived: :busy (a
|
|
339
|
+
# turn runs), :queued (input/ holds a prompt, which outlives the stop and
|
|
340
|
+
# runs on a resume, live worker or not), :offer (a live worker's continue
|
|
341
|
+
# offer, which the stop would drop), or nil.
|
|
342
|
+
# @raise [OwnedByTUI] a chi REPL owns it
|
|
343
|
+
def self.archive_refusal(sid, owner, state_dir)
|
|
344
|
+
raise OwnedByTUI, sid if owner&.tui?
|
|
345
|
+
|
|
346
|
+
session = owner && Session.load(sid, state_dir: state_dir)
|
|
347
|
+
return :busy if session&.status == Session::STATUS_RUNNING
|
|
348
|
+
return :queued unless SessionInbox.find_new_input_files(Session.session_dir(sid, state_dir: state_dir)).empty?
|
|
349
|
+
|
|
350
|
+
:offer if session&.waiting_question(live: true)&.dig(:kind) == ContinueOffer::KIND
|
|
351
|
+
end
|
|
352
|
+
private_class_method :archive_refusal
|
|
353
|
+
|
|
337
354
|
# Unarchive a session and its delegated children.
|
|
338
355
|
# @return [Hash] {id:, unarchived: [ids that were archived]}
|
|
339
356
|
# @raise [ArgumentError] unknown id
|