samagotchi 0.62.0 → 0.63.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 +33 -1
- data/README.md +4 -1
- data/bin/chi +3 -2
- data/docs/cli.md +4 -1
- data/docs/configuration.md +1 -1
- data/docs/context.md +13 -3
- data/docs/hooks.md +6 -3
- data/docs/memory.md +1 -1
- data/docs/plugins.md +89 -2
- data/docs/releasing.md +1 -1
- data/docs/sub-agent.md +6 -0
- data/docs/testing.md +7 -4
- data/lib/samagotchi/answer_refs.rb +18 -26
- data/lib/samagotchi/bridge.rb +23 -1
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +22 -0
- data/lib/samagotchi/bundles/claude-peer/plugin.rb +406 -0
- data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +104 -0
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +66 -0
- data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +60 -0
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +17 -6
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/context_absorber.rb +25 -6
- data/lib/samagotchi/idle_recap.rb +2 -3
- data/lib/samagotchi/image_store.rb +8 -0
- data/lib/samagotchi/plugin/attached_context.rb +7 -4
- data/lib/samagotchi/recap_setup.rb +3 -3
- data/lib/samagotchi/row_refs.rb +3 -4
- data/lib/samagotchi/session.rb +12 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +6 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +26 -9
- data/lib/samagotchi/terminal_ui.rb +31 -11
- data/lib/samagotchi/tools/execute.rb +22 -4
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +6 -4
- data/lib/samagotchi/web/public/activity.js +2 -2
- data/lib/samagotchi/web/public/app.js +47 -91
- data/lib/samagotchi/web/public/card.js +73 -23
- data/lib/samagotchi/web/public/chains.js +6 -4
- data/lib/samagotchi/web/public/command_view.js +39 -35
- data/lib/samagotchi/web/public/copy.js +7 -15
- data/lib/samagotchi/web/public/diff_view.js +20 -23
- data/lib/samagotchi/web/public/format.js +0 -48
- data/lib/samagotchi/web/public/images.js +12 -16
- data/lib/samagotchi/web/public/question_cards.js +3 -2
- data/lib/samagotchi/web/public/read_view.js +10 -9
- data/lib/samagotchi/web/public/scope.js +23 -0
- data/lib/samagotchi/web/public/timing.js +0 -7
- data/lib/samagotchi/web/public/turn_html.js +119 -59
- data/lib/samagotchi/web/public/turn_view.js +73 -116
- data/lib/samagotchi/web/public/vendor/directive.js +8 -0
- data/lib/samagotchi/web/public/vendor/directives/unsafe-html.js +8 -0
- data/lib/samagotchi/worker.rb +46 -30
- data/lib/samagotchi/worker_inbound.rb +12 -11
- metadata +8 -1
|
@@ -297,7 +297,7 @@ class Plugin
|
|
|
297
297
|
def register(chi)
|
|
298
298
|
chi.on(:before_turn) { |_event| reset }
|
|
299
299
|
chi.on(:before_tool_call) { |event, ctx| before(event, ctx) }
|
|
300
|
-
chi.on(:after_tool_call) { |event| after(event) }
|
|
300
|
+
chi.on(:after_tool_call) { |event, ctx| after(event, ctx) }
|
|
301
301
|
return unless @thinking["watch"]
|
|
302
302
|
|
|
303
303
|
chi.on(:before_generation) do |_event|
|
|
@@ -377,6 +377,7 @@ class Plugin
|
|
|
377
377
|
@warned = {} # key => true once notified this turn
|
|
378
378
|
@denials = 0
|
|
379
379
|
@pending = nil
|
|
380
|
+
@stop_card = nil # the stop's card, posted after the denied call
|
|
380
381
|
@thinking_loops = 0
|
|
381
382
|
@good_steps = 0
|
|
382
383
|
@watch = nil
|
|
@@ -412,7 +413,8 @@ class Plugin
|
|
|
412
413
|
# A call's result, paired with the before_tool_call that keyed it. A call
|
|
413
414
|
# that was denied (by us or another voter) ran nothing: no result, or the
|
|
414
415
|
# deny text would become the key's result and the next call would pass.
|
|
415
|
-
def after(event)
|
|
416
|
+
def after(event, ctx)
|
|
417
|
+
post_stop_card(ctx)
|
|
416
418
|
pending = @pending
|
|
417
419
|
@pending = nil
|
|
418
420
|
return unless pending && event[:tool].to_s == pending[:key].first
|
|
@@ -426,16 +428,25 @@ class Plugin
|
|
|
426
428
|
@previews[key] = preview(output)
|
|
427
429
|
end
|
|
428
430
|
|
|
431
|
+
# The turn stops now; its card waits for the denied call's
|
|
432
|
+
# after_tool_call (#post_stop_card), so a UI that prints events in order
|
|
433
|
+
# shows it under that call's row, not before it.
|
|
429
434
|
def stop(ctx)
|
|
430
435
|
ctx.stop_turn("the model kept repeating the same calls")
|
|
431
436
|
repeated = @attempts.select { |key, n| n > 1 && @last_result[key] && @results[[key, @last_result[key]]] >= @deny_after }
|
|
432
437
|
lines = repeated.map do |key, n|
|
|
433
438
|
"- `#{short_key(key)}`: #{n} times, the same result each time (#{@previews[key]})"
|
|
434
439
|
end
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
440
|
+
@stop_card = { title: "loop-guard stopped the turn",
|
|
441
|
+
body: "The model kept repeating these calls:\n\n#{lines.join("\n")}\n\n" \
|
|
442
|
+
"Tell it what to try instead, or where to look.",
|
|
443
|
+
level: :warn }
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def post_stop_card(ctx)
|
|
447
|
+
card = @stop_card
|
|
448
|
+
@stop_card = nil
|
|
449
|
+
ctx.card(**card) if card
|
|
439
450
|
end
|
|
440
451
|
|
|
441
452
|
def warn(ctx, key, what)
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: samagotchi-system
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.63.0
|
|
4
4
|
scope: system
|
|
5
5
|
description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
|
|
6
6
|
files:
|
|
7
7
|
identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
|
|
8
8
|
self_map.md: sha256:dfc19266259a9af1e0d769b7e0f67996b740b0c9921fdf3dadad973f6e5c144a
|
|
9
9
|
config_modification_protocol.md: sha256:16403da5dd97a335e19d9d15bb1ba4390237bf9f4869e35d63c172a8828b5ae0
|
|
10
|
-
memory_guide.md: sha256:
|
|
10
|
+
memory_guide.md: sha256:b0a1ba42c5334d4117b5733c8c26d7477364baca9169b0c0a711de6ac7fc7f3c
|
|
11
11
|
delegated.md: sha256:aedaa1e756a244904c5560fff0424e06fb468bbdcb0358bccefe321d67cd2614
|
|
@@ -94,7 +94,7 @@ Bundles are versioned directories/zips/tar.gz/git URLs with a `manifest.yml` and
|
|
|
94
94
|
- Integrity: each file's sha256 is recorded at install; a hook/plugin/rule file changed afterwards is not loaded (rules: every call denied) until reinstalled. It is an integrity check, not proof of authorship.
|
|
95
95
|
- Installing = trusting its Ruby code (hooks, plugin), like a gem. Install only copies; the code runs at the next session start (`Engine.new`), so a running worker needs a restart to pick it up.
|
|
96
96
|
- A bundle without `*.md` (btw, mcp, loop-guard) adds no line to the prompt's memory index.
|
|
97
|
-
- Profiles are shipped bundles with only `includes:` (bundle names): `core` (loop-guard, check-in, guardrails, slow-commands; `chi bootstrap` installs it) and `dev` (known-names, mcp, btw, skills, source-links, github-pr, coordinator). `chi bundle install core` installs the ones not installed and records them; a bundle the user uninstalled stays out on a later install, upgrade or `chi update`, which install only bundles new to the profile. `chi bundle uninstall core` removes its recorded bundles, then core (`docs/memory.md` "Bundle profiles").
|
|
97
|
+
- Profiles are shipped bundles with only `includes:` (bundle names): `core` (loop-guard, check-in, guardrails, slow-commands; `chi bootstrap` installs it) and `dev` (known-names, mcp, btw, skills, source-links, github-pr, coordinator, claude-peer). `chi bundle install core` installs the ones not installed and records them; a bundle the user uninstalled stays out on a later install, upgrade or `chi update`, which install only bundles new to the profile. `chi bundle uninstall core` removes its recorded bundles, then core (`docs/memory.md` "Bundle profiles").
|
|
98
98
|
|
|
99
99
|
### CLI — `chi bundle`
|
|
100
100
|
|
|
@@ -16,8 +16,9 @@ module Samagotchi
|
|
|
16
16
|
class ContextAbsorber
|
|
17
17
|
# +note+: Engine#add_context_note's hash; +subscription+: the session's
|
|
18
18
|
# Subscription after it (nil: dropped); +wake_note+: the same note with
|
|
19
|
-
# the wake line, for an update whose source asked to wake
|
|
20
|
-
# may start a turn with
|
|
19
|
+
# the wake line, for an update whose source asked to wake or a session
|
|
20
|
+
# push source's first text that asked (the worker may start a turn with
|
|
21
|
+
# it), else nil.
|
|
21
22
|
Delivery = Data.define(:name, :note, :subscription, :wake_note) do
|
|
22
23
|
def initialize(name:, note:, subscription:, wake_note: nil) = super
|
|
23
24
|
end
|
|
@@ -93,9 +94,12 @@ module Samagotchi
|
|
|
93
94
|
"(third-party text, not your user's words):\n> #{quoted}\n#{last_line}"
|
|
94
95
|
end
|
|
95
96
|
|
|
96
|
-
|
|
97
|
+
# The attached note's text; a wake turn's (+wake+) ends with the wake
|
|
98
|
+
# line in place of the background line.
|
|
99
|
+
def self.attached_text(name:, hint:, why:, summary:, wake: false)
|
|
97
100
|
why_part = why ? " Why: #{why}#{"." unless why.match?(/[.!?]\z/)}" : ""
|
|
98
|
-
|
|
101
|
+
last_line = wake ? WAKE_LAST_LINE : "#{BACKGROUND_LINE} #{read_line(name)}"
|
|
102
|
+
"Attached: #{name}#{" (#{hint})" if hint}.#{why_part}\nSummary: #{summary}\n#{last_line}"
|
|
99
103
|
end
|
|
100
104
|
|
|
101
105
|
def self.read_line(name) = "Read it with context_read(name: \"#{name}\") when your user's request is about it."
|
|
@@ -105,6 +109,8 @@ module Samagotchi
|
|
|
105
109
|
# A source the session sees: attached when it has text and was never
|
|
106
110
|
# noted; updated for a new revision; an error once per run of failures
|
|
107
111
|
# after a success. A source with no text yet waits (nothing to read).
|
|
112
|
+
# An update that asked to wake carries a wake note, and so does a first
|
|
113
|
+
# text that asked when #first_text_wakes?.
|
|
108
114
|
def delivery_for(attached, sub, now)
|
|
109
115
|
snapshot = attached.snapshot
|
|
110
116
|
return nil unless snapshot.text?
|
|
@@ -112,8 +118,11 @@ module Samagotchi
|
|
|
112
118
|
name = attached.name
|
|
113
119
|
hint = snapshot.hint || attached.source.hint
|
|
114
120
|
if sub.seen.nil?
|
|
115
|
-
|
|
116
|
-
|
|
121
|
+
args = { name: name, hint: hint, why: attached.source.why, summary: snapshot.summary }
|
|
122
|
+
found = delivery(name, text_note_id(name, snapshot), self.class.attached_text(**args), now, seen(sub, snapshot))
|
|
123
|
+
return found unless first_text_wakes?(attached, sub)
|
|
124
|
+
|
|
125
|
+
return found.with(wake_note: found.note.merge(text: self.class.attached_text(**args, wake: true)))
|
|
117
126
|
end
|
|
118
127
|
if sub.seen != snapshot.revision
|
|
119
128
|
args = { name: name, hint: hint, summary: snapshot.summary, changes: snapshot.serial - sub.seen_serial }
|
|
@@ -131,6 +140,16 @@ module Samagotchi
|
|
|
131
140
|
sub.with(error_seen: snapshot.error_since))
|
|
132
141
|
end
|
|
133
142
|
|
|
143
|
+
# A first text may wake only for a session's pushed source (`chi context
|
|
144
|
+
# add NAME --push`, attached empty) that asked. Not for a command source
|
|
145
|
+
# (a URL/provider source has a cmd too): its first fetch runs as it is
|
|
146
|
+
# attached, while the user is likely looking at the session. Not for a
|
|
147
|
+
# project source: its first text may predate this session. A blank
|
|
148
|
+
# Subscription has seen_serial 0, so any revision that asked counts.
|
|
149
|
+
def first_text_wakes?(attached, sub)
|
|
150
|
+
attached.source.push? && attached.location.session? && attached.snapshot.wake_serial.to_i > sub.seen_serial
|
|
151
|
+
end
|
|
152
|
+
|
|
134
153
|
# A subscription whose source is gone: detached when the session was
|
|
135
154
|
# told of it, dropped quietly otherwise.
|
|
136
155
|
def detached(sub, now)
|
|
@@ -7,6 +7,7 @@ require "time"
|
|
|
7
7
|
|
|
8
8
|
require_relative "idle_client"
|
|
9
9
|
require_relative "idle_target"
|
|
10
|
+
require_relative "image_store"
|
|
10
11
|
require_relative "output_formatter"
|
|
11
12
|
require_relative "steer"
|
|
12
13
|
require_relative "recap_store"
|
|
@@ -53,8 +54,6 @@ module Samagotchi
|
|
|
53
54
|
# separator, while the chat loop writes one message per call.
|
|
54
55
|
TOOL_OUTPUT_SEPARATOR = "\n\n---\n\n"
|
|
55
56
|
TOOL_OUTPUT_HEADER_RE = /\A\[([\w.:-]+)\]/
|
|
56
|
-
# The text the web sends for a turn of images alone (images.js turnText).
|
|
57
|
-
IMAGE_ONLY_RE = /\A\s*(?:\[image: [^\]\n]*\]\s*)+\z/
|
|
58
57
|
DELEGATE_REPORT_LINE = "(a delegate session's report, delivered by chi; not the user's words)"
|
|
59
58
|
|
|
60
59
|
module_function
|
|
@@ -76,7 +75,7 @@ module Samagotchi
|
|
|
76
75
|
# A delegate child's report (ChildReports) is chi's news, not
|
|
77
76
|
# what the user said.
|
|
78
77
|
content = "#{DELEGATE_REPORT_LINE}\n#{content}" if message["source"].to_s == Steer::DELEGATE_REPORT
|
|
79
|
-
images =
|
|
78
|
+
images = ImageRef.placeholder_only?(content) ? [] : image_lines(message["images"])
|
|
80
79
|
[content, *images].reject(&:empty?).join("\n")
|
|
81
80
|
when "model", "assistant"
|
|
82
81
|
strip_thought(message["content"].to_s.gsub(TOOL_CALL_RE, ""))
|
|
@@ -159,6 +159,14 @@ module Samagotchi
|
|
|
159
159
|
# One-line texts about an image ref ({file:, mime:, width:, height:,
|
|
160
160
|
# bytes:, name:, source:}).
|
|
161
161
|
module ImageRef
|
|
162
|
+
# The text the web sends for a turn of images alone (images.js
|
|
163
|
+
# turnText): "[image: name]" per image, nothing else.
|
|
164
|
+
PLACEHOLDER_ONLY_RE = /\A\s*(?:\[image: [^\]\n]*\]\s*)+\z/
|
|
165
|
+
|
|
166
|
+
# Whether +text+ is only that placeholder: the images' own lines would
|
|
167
|
+
# name them a second time.
|
|
168
|
+
def self.placeholder_only?(text) = PLACEHOLDER_ONLY_RE.match?(text.to_s)
|
|
169
|
+
|
|
162
170
|
# Claude's rule of thumb (28×28 px per token). About 30% over on the
|
|
163
171
|
# local Qwen, close enough for a label and the context estimate.
|
|
164
172
|
def self.estimated_tokens(ref)
|
|
@@ -94,18 +94,21 @@ module Samagotchi
|
|
|
94
94
|
nil
|
|
95
95
|
end
|
|
96
96
|
|
|
97
|
-
# This session's sources (its own, then its project's)
|
|
98
|
-
#
|
|
97
|
+
# This session's sources (its own, then its project's); none before
|
|
98
|
+
# the session has an id or a saved file.
|
|
99
|
+
# +summary+ is the last text's summary (nil before any text).
|
|
100
|
+
# @return [Array<Hash>] {name:, scope:, why:, hint:, fetched_at:, error:, provider:, summary:}
|
|
99
101
|
def list
|
|
100
102
|
id = @host.session_id.call
|
|
101
|
-
|
|
103
|
+
# No id, or no saved file yet (a command before the first turn).
|
|
104
|
+
return [] unless id && Session.exist?(id, state_dir: state_dir)
|
|
102
105
|
|
|
103
106
|
session = Session.load(id, state_dir: state_dir)
|
|
104
107
|
ContextSources.attached(id, project_root: session.project_root, state_dir: state_dir).map do |attached|
|
|
105
108
|
snapshot = attached.snapshot
|
|
106
109
|
{ name: attached.name, scope: attached.location.scope, why: attached.source.why,
|
|
107
110
|
hint: snapshot.hint || attached.source.hint, fetched_at: snapshot.fetched_at, error: snapshot.error,
|
|
108
|
-
provider: attached.source.provider }
|
|
111
|
+
provider: attached.source.provider, summary: snapshot.summary }
|
|
109
112
|
end
|
|
110
113
|
end
|
|
111
114
|
|
|
@@ -31,12 +31,12 @@ module Samagotchi
|
|
|
31
31
|
# @return [IdleRecap, nil]
|
|
32
32
|
def self.build(recap, engine:, host_registry:, session_target:, session_id:, state_dir:)
|
|
33
33
|
return nil if recap == false
|
|
34
|
-
# The
|
|
35
|
-
#
|
|
34
|
+
# The REPL and a worker pass nothing: the config is read here alone
|
|
35
|
+
# (a scalar `recap: false` is only seen this way).
|
|
36
36
|
return nil if recap.nil? && ConfigFile.recap_config == false
|
|
37
37
|
return nil if Samagotchi::Config.get("recap.enabled") == false
|
|
38
38
|
|
|
39
|
-
#
|
|
39
|
+
# An explicit kwarg (a spec, an embedder) over the registry.
|
|
40
40
|
kwarg_config = recap.is_a?(Hash) ? recap : {}
|
|
41
41
|
|
|
42
42
|
base_url = string_config(kwarg_config, :base_url) || registry_string("recap.base_url")
|
data/lib/samagotchi/row_refs.rb
CHANGED
|
@@ -88,7 +88,7 @@ module Samagotchi
|
|
|
88
88
|
# +cwd+ as [its spelling, its real path] (one when they are the same,
|
|
89
89
|
# none without a +cwd+). absolute_path, not expand_path: a leading ~ is
|
|
90
90
|
# a name, never a home. One realpath per row: the lambda's refs are
|
|
91
|
-
# checked together.
|
|
91
|
+
# checked together. The answer's refs (AnswerRef) check against it too.
|
|
92
92
|
def roots(cwd)
|
|
93
93
|
return [] if cwd.to_s.empty?
|
|
94
94
|
|
|
@@ -100,10 +100,10 @@ module Samagotchi
|
|
|
100
100
|
end
|
|
101
101
|
[given, real].uniq
|
|
102
102
|
end
|
|
103
|
-
private_class_method :roots
|
|
104
103
|
|
|
105
104
|
# +ref+ with a file's path absolute, resolved against the folder
|
|
106
|
-
# (+roots+, #roots), under its real spelling
|
|
105
|
+
# (+roots+, #roots), under its real spelling: plan D7, for a plugin
|
|
106
|
+
# row's refs and an answer's (AnswerRef) alike.
|
|
107
107
|
# @raise [ArgumentError] a relative path with no folder (or not
|
|
108
108
|
# +relative+), or a path outside the folder
|
|
109
109
|
def inside(ref, roots, relative)
|
|
@@ -121,6 +121,5 @@ module Samagotchi
|
|
|
121
121
|
|
|
122
122
|
ref.with(target: ref.target.merge(path: "#{roots.last}#{path.delete_prefix(root)}").freeze)
|
|
123
123
|
end
|
|
124
|
-
private_class_method :inside
|
|
125
124
|
end
|
|
126
125
|
end
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -275,6 +275,18 @@ module Samagotchi
|
|
|
275
275
|
{ id: pending[:id], kind: %w[approval hook continue].include?(kind) ? kind : "question", relayed_to: relayed_to }.compact
|
|
276
276
|
end
|
|
277
277
|
|
|
278
|
+
# A question saved by a process that no longer runs this session (the
|
|
279
|
+
# one that loads it now owns it): nobody waits on its answer, so it is
|
|
280
|
+
# dropped. Unsaved; the caller saves.
|
|
281
|
+
# @return [String, nil] the dropped question's id, nil with none
|
|
282
|
+
def drop_dead_question
|
|
283
|
+
pending = pending_question
|
|
284
|
+
return nil unless pending
|
|
285
|
+
|
|
286
|
+
@pending_question = nil
|
|
287
|
+
pending[:id].to_s
|
|
288
|
+
end
|
|
289
|
+
|
|
278
290
|
# The stored project root, else (a file from before the field) the
|
|
279
291
|
# project of the working directory now: nil when that is in no repo or
|
|
280
292
|
# gone. +cache+ is ProjectScope.root_for's, shared across one listing.
|
|
@@ -46,12 +46,15 @@ module Samagotchi
|
|
|
46
46
|
# @param thinking [String, nil] --thinking as typed: a new session
|
|
47
47
|
# starts with it as its own level; a resumed or attached one's
|
|
48
48
|
# worker gets a /thinking, after those
|
|
49
|
+
# @param state_dir [String, nil] where the sessions are (the default
|
|
50
|
+
# state dir by default): the worker's, for the attached loop too
|
|
49
51
|
# @return [Symbol] :detached, :closed when the worker went away,
|
|
50
52
|
# :failed when the --model switch didn't go through, or (input from a
|
|
51
53
|
# pipe) :turn_failed / :empty_answer / :unanswered (AttachedLoop#run)
|
|
52
54
|
def run(attach: nil, shared: false, resume: nil, prompt: nil, model: nil, no_interrupt: false, default_input: true,
|
|
53
|
-
memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [])
|
|
54
|
-
|
|
55
|
+
memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [], state_dir: nil)
|
|
56
|
+
state_dir ||= Session.default_state_dir
|
|
57
|
+
client = connect(attach: attach, shared: shared, resume: resume, model: model, state_dir: state_dir,
|
|
55
58
|
memories: memories, muted_memories: muted_memories, llm_context: llm_context, thinking: thinking,
|
|
56
59
|
appended_prompt_files: appended_prompt_files)
|
|
57
60
|
commands = []
|
|
@@ -65,7 +68,7 @@ module Samagotchi
|
|
|
65
68
|
first_command: first_command, no_interrupt: no_interrupt,
|
|
66
69
|
default_input: default_input && !prompt && !attach && !resume,
|
|
67
70
|
wait_at_eof: !$stdin.tty?,
|
|
68
|
-
parent_answers: Guardrails::ParentApprovals.parent_process
|
|
71
|
+
parent_answers: Guardrails::ParentApprovals.parent_process?, state_dir: state_dir)
|
|
69
72
|
ended = begin
|
|
70
73
|
attached.run
|
|
71
74
|
ensure
|
|
@@ -100,14 +100,18 @@ module Samagotchi
|
|
|
100
100
|
# (the -p one too) have had their turns; if one failed, #run says so
|
|
101
101
|
# @param children_counts [#call] session id -> its delegates'
|
|
102
102
|
# ChildrenStatus::Counts, the status row's children segment
|
|
103
|
+
# @param state_dir [String] the state dir the worker was found in: its
|
|
104
|
+
# session folder (@path images, saved records) is under it
|
|
103
105
|
def initialize(client:, screen:, client_id:, first_prompt: nil, first_command: nil, no_interrupt: false,
|
|
104
106
|
default_input: false, wait_at_eof: false, parent_answers: false,
|
|
105
107
|
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
|
|
106
108
|
delete_session: ->(id) { SessionManager.delete_session(id, stop: true, wait: DELETE_WAIT) },
|
|
107
109
|
archive_session: ->(id) { SessionManager.archive_session(id, wait: DELETE_WAIT) },
|
|
108
110
|
installed_version: -> { InstalledVersions.new.newest },
|
|
109
|
-
|
|
111
|
+
state_dir: Session.default_state_dir,
|
|
112
|
+
children_counts: ->(id) { ChildrenStatus.counts(id, state_dir: state_dir) })
|
|
110
113
|
@client = client
|
|
114
|
+
@state_dir = state_dir
|
|
111
115
|
@installed_version = installed_version
|
|
112
116
|
@delete_session = delete_session
|
|
113
117
|
@archive_session = archive_session
|
|
@@ -342,7 +346,7 @@ module Samagotchi
|
|
|
342
346
|
id = @client.session_id
|
|
343
347
|
return :gone if Session.stopped_marker?(id)
|
|
344
348
|
|
|
345
|
-
BridgeClient.discover(id, session_dir: Session.session_dir(id), host: @client.host)
|
|
349
|
+
BridgeClient.discover(id, session_dir: Session.session_dir(id, state_dir: @state_dir), host: @client.host)
|
|
346
350
|
end
|
|
347
351
|
|
|
348
352
|
# The stream moved to a new worker (a restart): requests go there too.
|
|
@@ -683,7 +687,11 @@ module Samagotchi
|
|
|
683
687
|
end
|
|
684
688
|
@continue_answer = nil
|
|
685
689
|
@screen.commit(QuestionSlot.continue_summary(outcome, paint: method(:paint))) if outcome && !asked
|
|
686
|
-
|
|
690
|
+
# Back to the > prompt: with the text a question set aside meanwhile
|
|
691
|
+
# (#close_question), else what the ? read holds.
|
|
692
|
+
set_aside = @set_aside
|
|
693
|
+
@set_aside = nil
|
|
694
|
+
set_aside ? sync_prompt(keep_text: false, prefill: set_aside) : sync_prompt
|
|
687
695
|
end
|
|
688
696
|
|
|
689
697
|
# The notes slot shows the offer's choices while it waits (a question
|
|
@@ -802,7 +810,7 @@ module Samagotchi
|
|
|
802
810
|
paths = ImageInput.extract(text)
|
|
803
811
|
return [] if paths.empty?
|
|
804
812
|
|
|
805
|
-
session_dir = Session.session_dir(@client.session_id)
|
|
813
|
+
session_dir = Session.session_dir(@client.session_id, state_dir: @state_dir)
|
|
806
814
|
paths.map do |image|
|
|
807
815
|
ref = ImageStore.ingest(session_dir, path: image[:path])
|
|
808
816
|
{ file: ref[:file], name: ref[:name] }
|
|
@@ -865,7 +873,9 @@ module Samagotchi
|
|
|
865
873
|
|
|
866
874
|
@view.finish_thinking_spinner
|
|
867
875
|
# What was typed at the prompt waits for the question to close.
|
|
868
|
-
|
|
876
|
+
# Not over text already set aside (a question that closed while a
|
|
877
|
+
# continue offer waits): the ? read holds an answer then, if anything.
|
|
878
|
+
@set_aside = @reader&.typed_text.to_s.then { |typed| typed.empty? ? nil : typed } unless @question || @set_aside
|
|
869
879
|
@question = QuestionPrompt.new(pending)
|
|
870
880
|
@question_pending = pending
|
|
871
881
|
# The step-limit question stands for the continue offer's own slot
|
|
@@ -987,10 +997,14 @@ module Samagotchi
|
|
|
987
997
|
@screen.clear_slot(:notes)
|
|
988
998
|
@screen.commit(@question.summary(outcome, paint: method(:paint))) if outcome
|
|
989
999
|
@question = nil
|
|
1000
|
+
# A continue offer still waiting keeps the ? prompt (the step-limit
|
|
1001
|
+
# question closes before its offer resolves): the text set aside
|
|
1002
|
+
# waits for the > prompt (#continue_resolved).
|
|
1003
|
+
return sync_continue_slot if @continue_offer
|
|
1004
|
+
|
|
990
1005
|
set_aside = @set_aside
|
|
991
1006
|
@set_aside = nil
|
|
992
1007
|
sync_prompt(keep_text: false, prefill: set_aside)
|
|
993
|
-
sync_continue_slot if @continue_offer
|
|
994
1008
|
end
|
|
995
1009
|
|
|
996
1010
|
# Restart the open read when its prompt no longer fits (a question or a
|
|
@@ -1195,7 +1209,10 @@ module Samagotchi
|
|
|
1195
1209
|
return unless last_user
|
|
1196
1210
|
|
|
1197
1211
|
@screen.commit(join_prompt_line(exchange[last_user]))
|
|
1198
|
-
|
|
1212
|
+
# An image-only turn's text names its images already.
|
|
1213
|
+
unless ImageRef.placeholder_only?(exchange[last_user][:content])
|
|
1214
|
+
Array(exchange[last_user][:images]).each { |ref| @screen.commit(format_image_line(ref)) }
|
|
1215
|
+
end
|
|
1199
1216
|
from = messages.index { |m| m.equal?(exchange[last_user]) }
|
|
1200
1217
|
render_join_steps(messages, from, durations: join_tool_records(exchange[last_user]))
|
|
1201
1218
|
failed = join_failed_record(exchange[last_user])
|
|
@@ -1244,7 +1261,7 @@ module Samagotchi
|
|
|
1244
1261
|
# machine's state dir, as #attach_images): none for a prompt with no
|
|
1245
1262
|
# turn id (an older session) or a session with no analytics.
|
|
1246
1263
|
def join_tool_records(prompt)
|
|
1247
|
-
SessionMetrics.saved_tool_records(Session.session_dir(@client.session_id), prompt[:turn_id] || prompt["turn_id"])
|
|
1264
|
+
SessionMetrics.saved_tool_records(Session.session_dir(@client.session_id, state_dir: @state_dir), prompt[:turn_id] || prompt["turn_id"])
|
|
1248
1265
|
rescue Session::InvalidId
|
|
1249
1266
|
[]
|
|
1250
1267
|
end
|
|
@@ -1254,7 +1271,7 @@ module Samagotchi
|
|
|
1254
1271
|
# canceled or older turn — or one saved before the failure field —
|
|
1255
1272
|
# draws its answer as before.
|
|
1256
1273
|
def join_failed_record(prompt)
|
|
1257
|
-
record = SessionMetrics.saved_turn_record(Session.session_dir(@client.session_id),
|
|
1274
|
+
record = SessionMetrics.saved_turn_record(Session.session_dir(@client.session_id, state_dir: @state_dir),
|
|
1258
1275
|
prompt[:turn_id] || prompt["turn_id"])
|
|
1259
1276
|
return nil unless record && record["status"] == "failed" && record["failure"].is_a?(Hash)
|
|
1260
1277
|
|
|
@@ -121,6 +121,7 @@ module Samagotchi
|
|
|
121
121
|
|
|
122
122
|
claim_session!(session_id) if session_id
|
|
123
123
|
@resume_session = session_id ? Session.load(session_id) : nil
|
|
124
|
+
drop_dead_question(@resume_session) if @resume_session
|
|
124
125
|
if @resume_session
|
|
125
126
|
# --model overrides resumed session's model (runtime only, default unchanged)
|
|
126
127
|
if flag_model_name
|
|
@@ -186,7 +187,6 @@ module Samagotchi
|
|
|
186
187
|
memories: @requested_memories,
|
|
187
188
|
muted_memories: @muted_memory_names,
|
|
188
189
|
appended_prompt_files: @appended_prompt_files,
|
|
189
|
-
recap: scratch ? nil : recap_config,
|
|
190
190
|
scratch: scratch,
|
|
191
191
|
reminders: {
|
|
192
192
|
callback: lambda { |due_names|
|
|
@@ -249,7 +249,8 @@ module Samagotchi
|
|
|
249
249
|
# post-turn conversation.
|
|
250
250
|
# @return [Symbol, nil] :turn_failed when lines came from a pipe and a
|
|
251
251
|
# turn failed, :empty_answer when a -p --non-interactive turn ended
|
|
252
|
-
# with no answer
|
|
252
|
+
# with no answer, :iteration_limit when the iteration limit cut that
|
|
253
|
+
# turn short (bin/chi exits 1 on all three), :interrupted when Ctrl-C
|
|
253
254
|
# cancelled that turn (bin/chi exits 130)
|
|
254
255
|
def run
|
|
255
256
|
# --non-interactive with no --prompt is a harmless no-op exit: build
|
|
@@ -309,12 +310,13 @@ module Samagotchi
|
|
|
309
310
|
# Retry lines and hook notices on stderr; stdout gets the answer below.
|
|
310
311
|
sink = OneShotSink.new
|
|
311
312
|
save_before_first_turn(session)
|
|
313
|
+
max_iterations = IterationLimit.for(no_interrupt: true)
|
|
312
314
|
begin
|
|
313
315
|
result = @engine.run_turn(
|
|
314
316
|
session,
|
|
315
317
|
one_shot_prompt,
|
|
316
318
|
on_event: sink,
|
|
317
|
-
max_iterations:
|
|
319
|
+
max_iterations: max_iterations,
|
|
318
320
|
cancel_controller: nil,
|
|
319
321
|
images: ImageInput.extract(one_shot_prompt)
|
|
320
322
|
)
|
|
@@ -337,6 +339,11 @@ module Samagotchi
|
|
|
337
339
|
session.save
|
|
338
340
|
# The after_turn hooks' notices, after the answer as the REPL shows them.
|
|
339
341
|
sink.flush
|
|
342
|
+
# The limit cut the turn short: what came so far is no answer, so say
|
|
343
|
+
# so and fail (bin/chi exits 1), as the REPL's "iteration limit
|
|
344
|
+
# reached" line does there.
|
|
345
|
+
return one_shot_exhausted(session, max_iterations) if result.resumable?
|
|
346
|
+
|
|
340
347
|
return
|
|
341
348
|
end
|
|
342
349
|
|
|
@@ -390,6 +397,16 @@ module Samagotchi
|
|
|
390
397
|
:turn_failed
|
|
391
398
|
end
|
|
392
399
|
|
|
400
|
+
# A -p --non-interactive turn the iteration limit cut short: a line on
|
|
401
|
+
# stderr, with how to go on unless the session goes with this run.
|
|
402
|
+
# @return [Symbol] :iteration_limit
|
|
403
|
+
def one_shot_exhausted(session, max_iterations)
|
|
404
|
+
line = "chi: the iteration limit (#{max_iterations}) was reached before the turn finished"
|
|
405
|
+
line += "; continue it with: chi --resume #{session.id}" unless @scratch
|
|
406
|
+
warn line
|
|
407
|
+
:iteration_limit
|
|
408
|
+
end
|
|
409
|
+
|
|
393
410
|
# `chi -p "/models" --non-interactive`: the command's output; a refused
|
|
394
411
|
# one fails the run (bin/chi exits 1). The session is saved only when
|
|
395
412
|
# the command changed it. Nobody is there to answer a confirm: a
|
|
@@ -518,6 +535,17 @@ module Samagotchi
|
|
|
518
535
|
@pending_card_handle = @engine.subscribe(observer: pending)
|
|
519
536
|
end
|
|
520
537
|
|
|
538
|
+
# A question the session's last owner saved and died with (a worker's,
|
|
539
|
+
# or another REPL's): this TUI owns the session now, so nobody waits on
|
|
540
|
+
# it. Dropped and saved, as the worker does (Worker#drop_dead_question).
|
|
541
|
+
def drop_dead_question(session)
|
|
542
|
+
id = session.drop_dead_question
|
|
543
|
+
return unless id
|
|
544
|
+
|
|
545
|
+
Log.info(:repl, "dead_question_dropped", id: id)
|
|
546
|
+
session.save
|
|
547
|
+
end
|
|
548
|
+
|
|
521
549
|
# Take the session's OwnerLock for this process's lifetime: the TUI runs
|
|
522
550
|
# its own Engine, so no worker may run the session meanwhile.
|
|
523
551
|
# @raise [SessionBusy] when a worker or another TUI owns it
|
|
@@ -1359,14 +1387,6 @@ module Samagotchi
|
|
|
1359
1387
|
Log.debug(:repl, "children_counts_failed", error: e.class.name)
|
|
1360
1388
|
end
|
|
1361
1389
|
|
|
1362
|
-
# Resolve the recap config (on by default). Returns false when explicitly
|
|
1363
|
-
# disabled, nil when nothing is configured, or a Hash when enabled so the
|
|
1364
|
-
# Engine can build the detector. Single precedence path via the Config
|
|
1365
|
-
# registry: CLI > ENV (SAMAGOTCHI_RECAP_*) > file (recap:) > default.
|
|
1366
|
-
def recap_config
|
|
1367
|
-
ConfigFile.recap_config
|
|
1368
|
-
end
|
|
1369
|
-
|
|
1370
1390
|
# Reset the Engine's shared inactivity clock when a prompt opens (a
|
|
1371
1391
|
# Reline.pre_input_hook) and on each key typed (RelineSeam.key_handler). This is the
|
|
1372
1392
|
# single seam the Engine-owned idle recap detector reads, so the idle clock
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
require_relative "../config"
|
|
5
|
+
require_relative "../log"
|
|
5
6
|
require_relative "../paths"
|
|
6
7
|
require_relative "../process_group"
|
|
7
8
|
require_relative "output_guardrails"
|
|
@@ -169,10 +170,14 @@ module Samagotchi
|
|
|
169
170
|
exit_code_path = File.join(dir, TaskRuntime::EXIT_CODE_FILENAME)
|
|
170
171
|
begin
|
|
171
172
|
pid = spawn_logged(command, env: env, cwd: cwd, logs: logs, exit_code_path: exit_code_path)
|
|
172
|
-
rescue StandardError
|
|
173
|
+
rescue StandardError => e
|
|
174
|
+
Log.info(:turn, "execute_spawn_failed", id: id, error: e.class.name)
|
|
173
175
|
FileUtils.rm_rf(dir)
|
|
174
176
|
raise
|
|
175
177
|
end
|
|
178
|
+
# Never the command line or its output: a command may carry a
|
|
179
|
+
# secret, and its output is the tool result's.
|
|
180
|
+
Log.debug(:turn, "execute_started", id: id, pid: pid, timeout_sec: timeout_sec, on_timeout: on_timeout)
|
|
176
181
|
wait_thr = Process.detach(pid)
|
|
177
182
|
# A stop is done once the shell is reaped and nothing holds the logs:
|
|
178
183
|
# a child that ignores TERM keeps them, so the stop goes on to KILL.
|
|
@@ -195,6 +200,7 @@ module Samagotchi
|
|
|
195
200
|
timeout_sec: timeout_sec, dir: dir, logs: logs, exit_code_path: exit_code_path)
|
|
196
201
|
if text
|
|
197
202
|
handed_over = true
|
|
203
|
+
Log.info(:turn, "execute_handed_over", id: id, after_sec: timeout_sec)
|
|
198
204
|
raise HandedOver, text
|
|
199
205
|
end
|
|
200
206
|
# Not adopted because it ended meanwhile: finished too.
|
|
@@ -205,10 +211,11 @@ module Samagotchi
|
|
|
205
211
|
status = wait_thr.value
|
|
206
212
|
rescue HandedOver
|
|
207
213
|
raise
|
|
208
|
-
rescue Exception # rubocop:disable Lint/RescueException -- kill the child, then re-raise
|
|
214
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- kill the child, then re-raise
|
|
209
215
|
# An Interrupt (or anything else) mid-wait: the child is in its own
|
|
210
216
|
# process group, so it never saw the SIGINT. Kill it.
|
|
211
217
|
aborted = true
|
|
218
|
+
Log.info(:turn, "execute_aborted", id: id, error: e.class.name)
|
|
212
219
|
terminate_process_tree(pid, stopped: stopped) if wait_thr.alive?
|
|
213
220
|
raise
|
|
214
221
|
ensure
|
|
@@ -230,9 +237,18 @@ module Samagotchi
|
|
|
230
237
|
end
|
|
231
238
|
end
|
|
232
239
|
|
|
233
|
-
|
|
234
|
-
|
|
240
|
+
elapsed_ms = ((monotonic_time - started) * 1000).round
|
|
241
|
+
if timed_out
|
|
242
|
+
Log.info(:turn, "execute_timed_out", id: id, after_sec: timeout_sec)
|
|
243
|
+
raise CommandTimedOut.new(stdout_text, stderr_text)
|
|
244
|
+
end
|
|
245
|
+
if was_cancelled
|
|
246
|
+
Log.info(:turn, "execute_stopped", id: id, ms: elapsed_ms)
|
|
247
|
+
raise CommandCancelled.new(stdout_text, stderr_text, monotonic_time - started)
|
|
248
|
+
end
|
|
235
249
|
|
|
250
|
+
Log.debug(:turn, "execute_finished", id: id, exit: status&.exitstatus, ms: elapsed_ms,
|
|
251
|
+
background: held_open || nil)
|
|
236
252
|
[stdout_text, stderr_text, status, held_open]
|
|
237
253
|
end
|
|
238
254
|
private_class_method :run_command
|
|
@@ -348,6 +364,8 @@ module Samagotchi
|
|
|
348
364
|
Thread.current.report_on_exception = false
|
|
349
365
|
code = waiter.value&.exitstatus
|
|
350
366
|
File.write(path, code.to_s) if code && !File.exist?(path) && Dir.exist?(File.dirname(path))
|
|
367
|
+
rescue StandardError => e
|
|
368
|
+
Log.debug(:turn, "execute_exit_code_failed", error: e.class.name, msg: e.message)
|
|
351
369
|
end
|
|
352
370
|
end
|
|
353
371
|
private_class_method :write_missing_exit_code
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -921,7 +921,7 @@ module Samagotchi
|
|
|
921
921
|
state = nil
|
|
922
922
|
session_json = { id: id, status: displayed_status(session), used_memory_names: Array(session.used_memory_names),
|
|
923
923
|
prompt_notes: session.prompt_notes.map(&:to_h) }
|
|
924
|
-
messages = last_assistant_for_display(session.messages, turn_id: turn_id)
|
|
924
|
+
messages = last_assistant_for_display(session.messages, turn_id: turn_id, cwd: session.working_directory)
|
|
925
925
|
end
|
|
926
926
|
live_metrics = state && state["metrics"]
|
|
927
927
|
# Without recent=1: a tab opened before this release (it replaces its
|
|
@@ -1923,7 +1923,7 @@ module Samagotchi
|
|
|
1923
1923
|
message[:html] = @markdown_renderer.render(rendered) if !rendered.empty? && @markdown_renderer.available?
|
|
1924
1924
|
# The refs after_turn hooks attached (AnswerRefs), checked again:
|
|
1925
1925
|
# a stored file is never trusted. Never raises.
|
|
1926
|
-
refs = Samagotchi::AnswerRefs.for_display(m[:display_refs] || m["display_refs"])
|
|
1926
|
+
refs = Samagotchi::AnswerRefs.for_display(m[:display_refs] || m["display_refs"], cwd: cwd)
|
|
1927
1927
|
message[:display_refs] = refs unless refs.empty?
|
|
1928
1928
|
end
|
|
1929
1929
|
message[:parts] = did if did
|
|
@@ -1968,8 +1968,10 @@ module Samagotchi
|
|
|
1968
1968
|
|
|
1969
1969
|
# The last message messages_for_display shows as an answer
|
|
1970
1970
|
# (AnswerTail), as a list of at most one: only that one is rendered.
|
|
1971
|
-
|
|
1972
|
-
|
|
1971
|
+
# +cwd+: the session's folder, for its file refs (nil from a live
|
|
1972
|
+
# worker's tail: its refs were held to the folder when they were added).
|
|
1973
|
+
def last_assistant_for_display(msgs, turn_id: nil, cwd: nil)
|
|
1974
|
+
messages_for_display([AnswerTail.find(msgs, turn_id: turn_id)].compact, cwd: cwd)
|
|
1973
1975
|
end
|
|
1974
1976
|
|
|
1975
1977
|
def parse_json(str)
|
|
@@ -140,10 +140,10 @@ export function addCompleted(model, event = {}, now = null) {
|
|
|
140
140
|
// A grep that found nothing (status ok): the row says "no match".
|
|
141
141
|
if (event.activity?.no_match) row.no_match = true;
|
|
142
142
|
// The images the tool returned (a reload in the turn view draws them from
|
|
143
|
-
// the saved parts, turn_html.js
|
|
143
|
+
// the saved parts, turn_html.js toolRowTemplate).
|
|
144
144
|
if (event.images?.length) row.images = event.images;
|
|
145
145
|
// What an edit/write changed in its file (a reload draws it from the
|
|
146
|
-
// saved parts, turn_html.js
|
|
146
|
+
// saved parts, turn_html.js toolRowTemplate).
|
|
147
147
|
if (event.diff) row.diff = event.diff;
|
|
148
148
|
const duration = callDuration(row, event, now);
|
|
149
149
|
if (duration !== null) row.duration_ms = duration;
|