samagotchi 0.2.0 → 0.4.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 +198 -1
- data/README.md +56 -4
- data/bin/chi +118 -50
- data/docs/cli.md +184 -9
- data/docs/configuration.md +333 -47
- data/docs/desktop.md +45 -4
- data/docs/guardrails.md +11 -0
- data/docs/hooks.md +208 -5
- data/docs/plugins.md +68 -2
- data/docs/releasing.md +23 -13
- data/docs/sessions.md +45 -17
- data/lib/samagotchi/answer_display.rb +95 -0
- data/lib/samagotchi/archive_store.rb +90 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
- data/lib/samagotchi/bootstrap/probe.rb +262 -0
- data/lib/samagotchi/bootstrap_command.rb +347 -0
- data/lib/samagotchi/bridge/pending_card.rb +89 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +15 -3
- data/lib/samagotchi/bridge.rb +13 -1
- data/lib/samagotchi/bridge_client.rb +6 -2
- data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
- data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +531 -0
- data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
- data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
- data/lib/samagotchi/bundles/system/delegated.md +6 -7
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/self_map.md +8 -2
- data/lib/samagotchi/client.rb +81 -19
- data/lib/samagotchi/commands/registry.rb +8 -0
- data/lib/samagotchi/config.rb +252 -48
- data/lib/samagotchi/desktop/macos/App.swift +12 -8
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +17 -9
- data/lib/samagotchi/desktop/macos/Images.swift +113 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +180 -25
- data/lib/samagotchi/desktop/macos.rb +59 -8
- data/lib/samagotchi/desktop_command.rb +6 -3
- data/lib/samagotchi/edit_preview.rb +82 -0
- data/lib/samagotchi/empty_answer_retry.rb +43 -0
- data/lib/samagotchi/engine.rb +434 -140
- data/lib/samagotchi/gem_update.rb +89 -0
- data/lib/samagotchi/guardrails/approval.rb +35 -4
- data/lib/samagotchi/guardrails/load_failures.rb +9 -3
- data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/hooks/registry.rb +24 -5
- data/lib/samagotchi/host_registry.rb +9 -12
- data/lib/samagotchi/idle_client.rb +24 -15
- data/lib/samagotchi/idle_recap.rb +5 -1
- data/lib/samagotchi/idle_reminders.rb +2 -2
- data/lib/samagotchi/image_store.rb +10 -6
- data/lib/samagotchi/kernel_loop.rb +73 -94
- data/lib/samagotchi/live_versions.rb +59 -0
- data/lib/samagotchi/llm/api_key.rb +41 -0
- data/lib/samagotchi/llm/chat_loop.rb +132 -29
- data/lib/samagotchi/llm/errors.rb +41 -9
- data/lib/samagotchi/llm/http.rb +57 -17
- data/lib/samagotchi/llm/openai_chat.rb +17 -30
- data/lib/samagotchi/log_subscriber.rb +18 -3
- data/lib/samagotchi/memory_bundle/installer.rb +65 -63
- data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
- data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
- data/lib/samagotchi/memory_bundle/status.rb +4 -1
- data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
- data/lib/samagotchi/model_profile.rb +24 -1
- data/lib/samagotchi/plugin/context.rb +22 -1
- data/lib/samagotchi/plugin/sessions.rb +3 -1
- data/lib/samagotchi/prompt.rb +4 -2
- data/lib/samagotchi/reminder_store.rb +1 -9
- data/lib/samagotchi/reply_wait.rb +126 -0
- data/lib/samagotchi/sampling_settings.rb +58 -0
- data/lib/samagotchi/self_report.rb +18 -3
- data/lib/samagotchi/send_command.rb +252 -11
- data/lib/samagotchi/session.rb +52 -11
- data/lib/samagotchi/session_archive_command.rb +107 -0
- data/lib/samagotchi/session_commands.rb +46 -7
- data/lib/samagotchi/session_manager.rb +115 -25
- data/lib/samagotchi/session_metrics.rb +222 -106
- data/lib/samagotchi/steer.rb +72 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +57 -28
- data/lib/samagotchi/terminal_ui/event_renderer.rb +21 -11
- data/lib/samagotchi/terminal_ui/formatting.rb +40 -8
- data/lib/samagotchi/terminal_ui/input_support.rb +7 -19
- data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
- data/lib/samagotchi/terminal_ui.rb +134 -247
- data/lib/samagotchi/text_diff.rb +181 -0
- data/lib/samagotchi/thinking.rb +115 -0
- data/lib/samagotchi/tool_activity.rb +3 -1
- data/lib/samagotchi/tool_runner.rb +34 -1
- data/lib/samagotchi/tools/ask_user_question.rb +41 -33
- data/lib/samagotchi/tools/builtins.rb +15 -4
- data/lib/samagotchi/tools/delegate_wait.rb +26 -69
- data/lib/samagotchi/tools/edit.rb +23 -9
- data/lib/samagotchi/tools/execute.rb +52 -14
- data/lib/samagotchi/tools/task_runtime.rb +19 -0
- data/lib/samagotchi/tools/task_wait.rb +27 -3
- data/lib/samagotchi/tools/write.rb +4 -0
- data/lib/samagotchi/turn_flow.rb +12 -2
- data/lib/samagotchi/turn_note.rb +60 -6
- data/lib/samagotchi/update_command.rb +308 -0
- data/lib/samagotchi/update_hint.rb +59 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +7 -9
- data/lib/samagotchi/web/app.rb +91 -7
- data/lib/samagotchi/web/message_parts.rb +8 -3
- data/lib/samagotchi/web/public/activity.js +13 -1
- data/lib/samagotchi/web/public/annotate_presets.js +26 -0
- data/lib/samagotchi/web/public/annotations.js +13 -0
- data/lib/samagotchi/web/public/app.js +472 -111
- data/lib/samagotchi/web/public/card.js +5 -3
- data/lib/samagotchi/web/public/chat_view.js +13 -1
- data/lib/samagotchi/web/public/copy.js +20 -4
- data/lib/samagotchi/web/public/ctx.js +15 -0
- data/lib/samagotchi/web/public/data.js +23 -6
- data/lib/samagotchi/web/public/diff_view.js +58 -0
- data/lib/samagotchi/web/public/format.js +9 -0
- data/lib/samagotchi/web/public/index.html +60 -3
- data/lib/samagotchi/web/public/notify.js +175 -0
- data/lib/samagotchi/web/public/question_card.js +5 -2
- data/lib/samagotchi/web/public/sessions_list.js +7 -0
- data/lib/samagotchi/web/public/timing.js +39 -14
- data/lib/samagotchi/web/public/turn_events.js +75 -5
- data/lib/samagotchi/web/public/turn_view.js +49 -8
- data/lib/samagotchi/web/server.rb +8 -4
- data/lib/samagotchi/web/session_hub.rb +2 -1
- data/lib/samagotchi/web/session_summary.rb +24 -1
- data/lib/samagotchi/worker.rb +16 -4
- metadata +31 -1
data/lib/samagotchi/session.rb
CHANGED
|
@@ -8,6 +8,9 @@ require "time"
|
|
|
8
8
|
require_relative "project_scope"
|
|
9
9
|
|
|
10
10
|
module Samagotchi
|
|
11
|
+
# archive_store requires this file.
|
|
12
|
+
autoload :ArchiveStore, File.expand_path("archive_store", __dir__)
|
|
13
|
+
|
|
11
14
|
class Session
|
|
12
15
|
METADATA_VERSION = 3
|
|
13
16
|
STATE_SUBDIR = File.join("samagotchi", "sessions")
|
|
@@ -44,6 +47,17 @@ module Samagotchi
|
|
|
44
47
|
# plugin forked it from (ctx.sessions.fork), else nil.
|
|
45
48
|
# Set before the spawn and kept on respawns, like preloaded_memory_names.
|
|
46
49
|
attr_accessor :parent_id
|
|
50
|
+
# A `chi scratch` session: deleted when its REPL ends, and by the next
|
|
51
|
+
# sweep (or `chi sessions clean`) when the process died first.
|
|
52
|
+
attr_accessor :scratch
|
|
53
|
+
# How the last turn ended, for the web's notifications (the hub sends
|
|
54
|
+
# it in the summary): {"outcome" => "completed"|"failed"|"canceled",
|
|
55
|
+
# "ended_at" => iso8601, "seconds" => Float, "origin" =>
|
|
56
|
+
# "client"|"reminder"|"delegate"}; nil before the first turn.
|
|
57
|
+
attr_accessor :last_turn
|
|
58
|
+
# Archived (ArchiveStore): hidden from the lists. Set by .list (with
|
|
59
|
+
# include_archived); not saved in session.json.
|
|
60
|
+
attr_accessor :archived
|
|
47
61
|
|
|
48
62
|
# How many image refs a fork's seed lost (SessionManager.spawn_session
|
|
49
63
|
# sets it); not saved.
|
|
@@ -53,7 +67,8 @@ module Samagotchi
|
|
|
53
67
|
metadata_version: METADATA_VERSION, status: STATUS_IDLE, last_prompt: "",
|
|
54
68
|
first_preview: "", test_run: false, pending_question: nil,
|
|
55
69
|
used_memory_names: [], project_root: nil,
|
|
56
|
-
preloaded_memory_names: [], muted_memory_names: [], parent_id: nil
|
|
70
|
+
preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
|
|
71
|
+
last_turn: nil)
|
|
57
72
|
@id = id
|
|
58
73
|
@metadata_version = metadata_version
|
|
59
74
|
@mode = mode
|
|
@@ -72,6 +87,9 @@ module Samagotchi
|
|
|
72
87
|
@muted_memory_names = self.class.name_list(muted_memory_names)
|
|
73
88
|
@project_root = project_root
|
|
74
89
|
@parent_id = parent_id&.to_s
|
|
90
|
+
@scratch = !!scratch
|
|
91
|
+
@last_turn = last_turn
|
|
92
|
+
@archived = false
|
|
75
93
|
end
|
|
76
94
|
|
|
77
95
|
# The stored project root, else (a file from before the field) the
|
|
@@ -92,7 +110,8 @@ module Samagotchi
|
|
|
92
110
|
# @param messages [Array<Hash>] a conversation to start from (a fork's
|
|
93
111
|
# seed); [] by default
|
|
94
112
|
def self.new_session(mode:, model_name:, working_directory:, test_run: nil,
|
|
95
|
-
preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, messages: []
|
|
113
|
+
preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, messages: [],
|
|
114
|
+
scratch: false)
|
|
96
115
|
now = Time.now.iso8601(3)
|
|
97
116
|
resolved_test = if test_run.nil?
|
|
98
117
|
test_session_env?
|
|
@@ -113,7 +132,8 @@ module Samagotchi
|
|
|
113
132
|
project_root: ProjectScope.root_for(working_directory),
|
|
114
133
|
preloaded_memory_names: preloaded_memory_names,
|
|
115
134
|
muted_memory_names: muted_memory_names,
|
|
116
|
-
parent_id: parent_id
|
|
135
|
+
parent_id: parent_id,
|
|
136
|
+
scratch: scratch
|
|
117
137
|
)
|
|
118
138
|
end
|
|
119
139
|
|
|
@@ -154,7 +174,9 @@ module Samagotchi
|
|
|
154
174
|
project_root: data["project_root"],
|
|
155
175
|
preloaded_memory_names: Array(data["preloaded_memory_names"]),
|
|
156
176
|
muted_memory_names: Array(data["muted_memory_names"]),
|
|
157
|
-
parent_id: data["parent_id"]
|
|
177
|
+
parent_id: data["parent_id"],
|
|
178
|
+
scratch: data.fetch("scratch", false),
|
|
179
|
+
last_turn: data["last_turn"]
|
|
158
180
|
)
|
|
159
181
|
rescue JSON::ParserError => e
|
|
160
182
|
raise ArgumentError, "Session file corrupted (#{session_id}): #{e.message}"
|
|
@@ -186,14 +208,19 @@ module Samagotchi
|
|
|
186
208
|
# Supports sort: created_at|updated_at and order: asc|desc.
|
|
187
209
|
# +project_root+ keeps only that project's sessions (Session#project_root),
|
|
188
210
|
# before offset/limit so pages count within the project.
|
|
211
|
+
# Archived sessions (ArchiveStore) are left out unless +include_archived+;
|
|
212
|
+
# everything built on .list follows (the lists, the summaries, the
|
|
213
|
+
# retention prune, which then neither deletes nor counts them).
|
|
189
214
|
def self.list(state_dir: default_state_dir, sort: "updated_at", order: "desc", limit: nil, offset: 0,
|
|
190
|
-
project_root: nil)
|
|
215
|
+
project_root: nil, include_archived: false)
|
|
191
216
|
return [] unless Dir.exist?(state_dir)
|
|
192
217
|
|
|
193
218
|
sort_key = SORT_KEYS.include?(sort.to_s) ? sort.to_s : "updated_at"
|
|
194
219
|
sort_order = SORT_ORDERS.include?(order.to_s) ? order.to_s : "desc"
|
|
195
220
|
|
|
196
221
|
sessions = Dir.glob(File.join(state_dir, "*#{FILE_EXT}")).filter_map { |path| summary_from_file(path) }
|
|
222
|
+
sessions.each { |s| s.archived = ArchiveStore.archived?(session_dir(s.id, state_dir: state_dir)) }
|
|
223
|
+
sessions.reject!(&:archived) unless include_archived
|
|
197
224
|
if project_root
|
|
198
225
|
roots = {}
|
|
199
226
|
sessions.select! { |s| s.project_root(cache: roots) == project_root }
|
|
@@ -225,6 +252,7 @@ module Samagotchi
|
|
|
225
252
|
def self.summary_from_file(path)
|
|
226
253
|
data = JSON.parse(File.read(path))
|
|
227
254
|
used_mems = data["used_memory_names"] || data["used_memories"] || []
|
|
255
|
+
pending = data["pending_question"]
|
|
228
256
|
new(
|
|
229
257
|
id: data.fetch("id"),
|
|
230
258
|
metadata_version: data.fetch("metadata_version", 1),
|
|
@@ -238,11 +266,14 @@ module Samagotchi
|
|
|
238
266
|
last_prompt: data.fetch("last_prompt", ""),
|
|
239
267
|
first_preview: data.fetch("first_preview", ""),
|
|
240
268
|
test_run: data.fetch("test_run", false),
|
|
269
|
+
pending_question: pending.is_a?(Hash) ? symbolize_message_keys(pending) : nil,
|
|
241
270
|
used_memory_names: Array(used_mems),
|
|
242
271
|
project_root: data["project_root"],
|
|
243
272
|
preloaded_memory_names: Array(data["preloaded_memory_names"]),
|
|
244
273
|
muted_memory_names: Array(data["muted_memory_names"]),
|
|
245
|
-
parent_id: data["parent_id"]
|
|
274
|
+
parent_id: data["parent_id"],
|
|
275
|
+
scratch: data.fetch("scratch", false),
|
|
276
|
+
last_turn: data["last_turn"]
|
|
246
277
|
)
|
|
247
278
|
rescue JSON::ParserError, KeyError, SystemCallError
|
|
248
279
|
nil
|
|
@@ -261,6 +292,12 @@ module Samagotchi
|
|
|
261
292
|
# +any_age+ makes every session eligible, whatever its age and the
|
|
262
293
|
# count (`chi sessions clean` with no --days: test runs are throwaway).
|
|
263
294
|
#
|
|
295
|
+
# A scratch session nobody owns (its REPL was killed) goes whatever its
|
|
296
|
+
# age, its status and the count; +test_only+ takes it too.
|
|
297
|
+
#
|
|
298
|
+
# Archived sessions are not in .list, so they are neither deleted nor
|
|
299
|
+
# counted. One unarchived is aged from when it was unarchived, if later.
|
|
300
|
+
#
|
|
264
301
|
# @return [Hash] { deleted: [ids], kept: [ids], skipped: [ids] }
|
|
265
302
|
def self.prune(state_dir: default_state_dir, days: DEFAULT_RETENTION_DAYS, max_count: DEFAULT_MAX_COUNT,
|
|
266
303
|
keep_status: DEFAULT_KEEP_STATUS, dry_run: false, test_only: false, alive_check: nil,
|
|
@@ -270,7 +307,7 @@ module Samagotchi
|
|
|
270
307
|
all = list(state_dir: state_dir, sort: "updated_at", order: "desc")
|
|
271
308
|
# Filter test_only if requested
|
|
272
309
|
if test_only
|
|
273
|
-
all = all.select
|
|
310
|
+
all = all.select { |session| session.test_run || session.scratch }
|
|
274
311
|
end
|
|
275
312
|
|
|
276
313
|
now = Time.now
|
|
@@ -290,7 +327,7 @@ module Samagotchi
|
|
|
290
327
|
end
|
|
291
328
|
|
|
292
329
|
# Protected by keep_status
|
|
293
|
-
if keep_status.include?(session.status.to_s)
|
|
330
|
+
if keep_status.include?(session.status.to_s) && !session.scratch
|
|
294
331
|
kept << session.id
|
|
295
332
|
next
|
|
296
333
|
end
|
|
@@ -321,19 +358,21 @@ module Samagotchi
|
|
|
321
358
|
rescue ArgumentError
|
|
322
359
|
updated = File.mtime(path) rescue now
|
|
323
360
|
end
|
|
361
|
+
unarchived = ArchiveStore.unarchived_at(session_dir(session.id, state_dir: state_dir))
|
|
362
|
+
updated = unarchived if unarchived && unarchived > updated
|
|
324
363
|
expired = updated < cutoff
|
|
325
364
|
end
|
|
326
365
|
|
|
327
366
|
overflow = max.positive? && idx >= max
|
|
328
367
|
|
|
329
368
|
# retain forever when both disabled
|
|
330
|
-
if max.zero? && cutoff.nil? && !left_empty && !any_age
|
|
369
|
+
if max.zero? && cutoff.nil? && !left_empty && !any_age && !session.scratch
|
|
331
370
|
kept << session.id
|
|
332
371
|
next
|
|
333
372
|
end
|
|
334
373
|
|
|
335
374
|
# If neither expired nor overflow, keep
|
|
336
|
-
unless expired || overflow || left_empty || any_age
|
|
375
|
+
unless expired || overflow || left_empty || any_age || session.scratch
|
|
337
376
|
kept << session.id
|
|
338
377
|
next
|
|
339
378
|
end
|
|
@@ -387,7 +426,9 @@ module Samagotchi
|
|
|
387
426
|
"project_root" => @project_root,
|
|
388
427
|
"preloaded_memory_names" => Array(@preloaded_memory_names),
|
|
389
428
|
"muted_memory_names" => Array(@muted_memory_names),
|
|
390
|
-
"parent_id" => @parent_id
|
|
429
|
+
"parent_id" => @parent_id,
|
|
430
|
+
"scratch" => @scratch,
|
|
431
|
+
"last_turn" => @last_turn
|
|
391
432
|
}
|
|
392
433
|
|
|
393
434
|
File.write(temp_path, JSON.pretty_generate(record) + "\n")
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "session"
|
|
4
|
+
require_relative "session_manager"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
# `chi sessions archive|unarchive`: hide sessions from every list and keep
|
|
8
|
+
# them for good, or bring them back (SessionManager.archive_session /
|
|
9
|
+
# unarchive_session), one line per id. Delegated children follow their
|
|
10
|
+
# parent.
|
|
11
|
+
class SessionArchiveCommand
|
|
12
|
+
USAGE = {
|
|
13
|
+
"archive" => <<~TEXT,
|
|
14
|
+
Usage: chi sessions archive (ID|PREFIX)...
|
|
15
|
+
Hides each session (and its delegates) from every list and keeps it
|
|
16
|
+
for good: the retention sweep never deletes it. A live worker is
|
|
17
|
+
stopped first; a session running a turn, or open in a chi REPL, is
|
|
18
|
+
refused. chi sessions list --archived shows them; a message you send
|
|
19
|
+
to one brings it back.
|
|
20
|
+
TEXT
|
|
21
|
+
"unarchive" => <<~TEXT
|
|
22
|
+
Usage: chi sessions unarchive (ID|PREFIX)...
|
|
23
|
+
Brings archived sessions (and their delegates) back to the lists.
|
|
24
|
+
TEXT
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
# @param action [String] "archive" or "unarchive"
|
|
28
|
+
# @param argv [Array<String>] the arguments after "sessions <action>"
|
|
29
|
+
def initialize(action, argv, stdout: $stdout, stderr: $stderr, state_dir: nil)
|
|
30
|
+
@action = action
|
|
31
|
+
@argv = argv.dup
|
|
32
|
+
@stdout = stdout
|
|
33
|
+
@stderr = stderr
|
|
34
|
+
@state_dir = state_dir || Session.default_state_dir
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [Integer] exit status: 0 all done, 1 any refused or unknown, 2 usage
|
|
38
|
+
def run
|
|
39
|
+
if @argv.any? { |arg| %w[-h --help help].include?(arg) }
|
|
40
|
+
@stdout.puts(USAGE.fetch(@action))
|
|
41
|
+
return 0
|
|
42
|
+
end
|
|
43
|
+
bad = @argv.find { |arg| arg.start_with?("-") }
|
|
44
|
+
return usage_error(bad ? "unknown option #{bad}" : "give session ids") if bad || @argv.empty?
|
|
45
|
+
|
|
46
|
+
@argv.uniq.map { |given| @action == "archive" ? archive(given) : unarchive(given) }.all? ? 0 : 1
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def archive(given)
|
|
52
|
+
id = Session.resolve_id(given, state_dir: @state_dir)
|
|
53
|
+
short = id[0, 8]
|
|
54
|
+
result = SessionManager.archive_session(id, state_dir: @state_dir)
|
|
55
|
+
if result[:archived].include?(id)
|
|
56
|
+
notes = []
|
|
57
|
+
notes << "stopped its worker" if result[:stopped].include?(id)
|
|
58
|
+
others = result[:archived].size - 1
|
|
59
|
+
notes << "and #{others} delegate#{"s" if others != 1}" if others.positive?
|
|
60
|
+
@stdout.puts("#{short} archived#{" (#{notes.join(", ")})" unless notes.empty?}")
|
|
61
|
+
else
|
|
62
|
+
@stdout.puts("#{short} empty session discarded")
|
|
63
|
+
end
|
|
64
|
+
true
|
|
65
|
+
rescue SessionManager::OwnedByTUI => e
|
|
66
|
+
where = e.session_id == id ? "it is" : "a delegate of it is"
|
|
67
|
+
@stdout.puts("#{short} refused: #{where} open in a chi REPL; close it there first")
|
|
68
|
+
false
|
|
69
|
+
rescue SessionManager::ArchiveRefused => e
|
|
70
|
+
@stdout.puts("#{short} refused: #{e.message}")
|
|
71
|
+
false
|
|
72
|
+
rescue ArgumentError => e
|
|
73
|
+
error_line("chi sessions archive: #{e.is_a?(Session::AmbiguousId) ? e.message : "no session #{given}"}")
|
|
74
|
+
false
|
|
75
|
+
rescue SystemCallError => e
|
|
76
|
+
@stdout.puts("#{short} failed: #{e.message}")
|
|
77
|
+
false
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def unarchive(given)
|
|
81
|
+
id = Session.resolve_id(given, state_dir: @state_dir)
|
|
82
|
+
short = id[0, 8]
|
|
83
|
+
result = SessionManager.unarchive_session(id, state_dir: @state_dir)
|
|
84
|
+
@stdout.puts(result[:unarchived].empty? ? "#{short} not archived" : "#{short} unarchived")
|
|
85
|
+
true
|
|
86
|
+
rescue ArgumentError => e
|
|
87
|
+
error_line("chi sessions unarchive: #{e.is_a?(Session::AmbiguousId) ? e.message : "no session #{given}"}")
|
|
88
|
+
false
|
|
89
|
+
rescue SystemCallError => e
|
|
90
|
+
@stdout.puts("#{short} failed: #{e.message}")
|
|
91
|
+
false
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# stdout is buffered when it's a pipe: flush the lines already printed,
|
|
95
|
+
# so the output keeps the order of the ids given.
|
|
96
|
+
def error_line(text)
|
|
97
|
+
@stdout.flush
|
|
98
|
+
@stderr.puts(text)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def usage_error(message)
|
|
102
|
+
error_line("chi sessions #{@action}: #{message}")
|
|
103
|
+
@stderr.puts(USAGE.fetch(@action))
|
|
104
|
+
2
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -15,8 +15,10 @@ module Samagotchi
|
|
|
15
15
|
# the Engine and its TurnFlow; the host prints the result's output and
|
|
16
16
|
# runs a continue turn when asked to (#run never runs a turn).
|
|
17
17
|
#
|
|
18
|
-
# /stats, /recap
|
|
19
|
-
# (local: for Tab and help
|
|
18
|
+
# /stats, /recap, /exit, /quit, /archive and /detach are the terminal
|
|
19
|
+
# UIs' own: they are in the registry (local:) for Tab and help, and so
|
|
20
|
+
# both TUIs read the same words (Registry#lookup_local gives the entry,
|
|
21
|
+
# its id says what to do), but #run never runs them.
|
|
20
22
|
class SessionCommands
|
|
21
23
|
MODEL_COMMAND = "/model"
|
|
22
24
|
MODELS_COMMAND = "/models"
|
|
@@ -30,6 +32,8 @@ module Samagotchi
|
|
|
30
32
|
SHELL_BANG_PREFIX = "!"
|
|
31
33
|
RESERVED_MODEL_ARGS = %w[clear default none off].freeze
|
|
32
34
|
ALIAS_USAGE = "usage /model <model> --alias <name> [--default]"
|
|
35
|
+
# /exit --delete: delete the session on the way out.
|
|
36
|
+
EXIT_DELETE_FLAG = "--delete"
|
|
33
37
|
|
|
34
38
|
# @!attribute status [Symbol] :ok, or :error when the line was refused
|
|
35
39
|
# @!attribute output [String, nil] what to tell the user
|
|
@@ -66,12 +70,28 @@ module Samagotchi
|
|
|
66
70
|
registry.register(HELP_COMMAND, "list the commands, the bundles' too", anytime: true) { |_text| reply(help_listing) }
|
|
67
71
|
registry.register("/stats", "show the session's stats", local: true)
|
|
68
72
|
registry.register("/recap", "show the session's recap", local: true)
|
|
69
|
-
|
|
70
|
-
registry.register("/
|
|
71
|
-
|
|
73
|
+
# Bare `exit` too; any case; --delete after it.
|
|
74
|
+
registry.register("/exit", "leave (--delete also deletes the session)", local: true,
|
|
75
|
+
match: exit_match("/?exit"))
|
|
76
|
+
registry.register("/quit", "leave, like /exit", id: :exit, local: true, match: exit_match("/quit"))
|
|
77
|
+
registry.register("/archive", "leave and archive the session: hidden from the lists, kept for good", local: true,
|
|
78
|
+
match: ->(text) { text.casecmp?("/archive") })
|
|
79
|
+
# The REPL owns its session: it answers /detach with a note, and doesn't offer it.
|
|
80
|
+
registry.register("/detach", "leave and keep the worker running", local: true, uis: [:attached],
|
|
81
|
+
match: ->(text) { text.casecmp?("/detach") })
|
|
72
82
|
registry
|
|
73
83
|
end
|
|
74
84
|
|
|
85
|
+
# @param word [String] the regexp source of the exit word
|
|
86
|
+
def self.exit_match(word)
|
|
87
|
+
pattern = /\A#{word}(?:\s+#{EXIT_DELETE_FLAG})?\z/i
|
|
88
|
+
->(text) { text.match?(pattern) }
|
|
89
|
+
end
|
|
90
|
+
private_class_method :exit_match
|
|
91
|
+
|
|
92
|
+
# @return [Boolean] an exit line (/exit, /quit, exit) that deletes the session too
|
|
93
|
+
def self.delete_on_exit?(line) = line.to_s.split.last.to_s.casecmp?(EXIT_DELETE_FLAG)
|
|
94
|
+
|
|
75
95
|
# The built-ins alone, for callers without an Engine (an attached TUI
|
|
76
96
|
# before its snapshot names the session's commands, specs).
|
|
77
97
|
# @return [Commands::Registry] frozen
|
|
@@ -276,15 +296,18 @@ module Samagotchi
|
|
|
276
296
|
def model(text)
|
|
277
297
|
output, switched = model_command(text)
|
|
278
298
|
reply(output, changed: switched ? [:model] : [])
|
|
299
|
+
rescue ModelProfile::MissingModel => e
|
|
300
|
+
# A model qualified with an unknown host: nothing switched.
|
|
301
|
+
reply(e.message, status: :error)
|
|
279
302
|
end
|
|
280
303
|
|
|
281
304
|
# @return [Array(String, Boolean)] the message, and whether the model changed
|
|
282
305
|
def model_command(input)
|
|
283
306
|
suffix = input.delete_prefix(MODEL_COMMAND).strip
|
|
284
307
|
if suffix.empty?
|
|
285
|
-
return ["runtime model: #{model_name}#{model_note}#{served_note}", false] if model_name == @default_model
|
|
308
|
+
return ["runtime model: #{model_name}#{model_note}#{served_note}#{sampling_note}#{thinking_note}", false] if model_name == @default_model
|
|
286
309
|
|
|
287
|
-
return ["runtime model: #{model_name}#{model_note("default: #{@default_model}")}#{served_note}", false]
|
|
310
|
+
return ["runtime model: #{model_name}#{model_note("default: #{@default_model}")}#{served_note}#{sampling_note}#{thinking_note}", false]
|
|
288
311
|
end
|
|
289
312
|
|
|
290
313
|
# Parse flags: --default and --alias <name> / --alias=<name> (tolerant order)
|
|
@@ -411,6 +434,22 @@ module Samagotchi
|
|
|
411
434
|
""
|
|
412
435
|
end
|
|
413
436
|
|
|
437
|
+
# "; sampling: temperature=0.6 (hosts.work)" when the model has any.
|
|
438
|
+
def sampling_note
|
|
439
|
+
summary = @engine.respond_to?(:sampling_summary) ? @engine.sampling_summary : nil
|
|
440
|
+
summary ? "; sampling: #{summary}" : ""
|
|
441
|
+
rescue StandardError
|
|
442
|
+
""
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# "; thinking: off (models: qwen)" when the model has a level set.
|
|
446
|
+
def thinking_note
|
|
447
|
+
summary = @engine.respond_to?(:thinking_summary) ? @engine.thinking_summary : nil
|
|
448
|
+
summary ? "; thinking: #{summary}" : ""
|
|
449
|
+
rescue StandardError
|
|
450
|
+
""
|
|
451
|
+
end
|
|
452
|
+
|
|
414
453
|
# " (default: x, profile=qwen36, name)": +extra+ and the prompt profile,
|
|
415
454
|
# which a chat host's model doesn't have (its loop uses none); "" when
|
|
416
455
|
# there is neither.
|
|
@@ -8,6 +8,7 @@ require "securerandom"
|
|
|
8
8
|
require "rbconfig"
|
|
9
9
|
|
|
10
10
|
require_relative "session"
|
|
11
|
+
require_relative "session_metrics"
|
|
11
12
|
require_relative "turn_note"
|
|
12
13
|
require_relative "owner_lock"
|
|
13
14
|
require_relative "bridge_client"
|
|
@@ -15,6 +16,7 @@ require_relative "log"
|
|
|
15
16
|
require_relative "log_path"
|
|
16
17
|
require_relative "installed_gem"
|
|
17
18
|
require_relative "recap_store"
|
|
19
|
+
require_relative "archive_store"
|
|
18
20
|
require_relative "image_store"
|
|
19
21
|
require_relative "terminal_ui"
|
|
20
22
|
|
|
@@ -73,7 +75,12 @@ module Samagotchi
|
|
|
73
75
|
# Raised when the interactive TUI owns the session: it runs its own Engine
|
|
74
76
|
# and reads no input files, so a worker must not be spawned or signalled.
|
|
75
77
|
class OwnedByTUI < StandardError
|
|
78
|
+
# @return [String] the session the REPL owns (a delegate's, when an
|
|
79
|
+
# archive met one)
|
|
80
|
+
attr_reader :session_id
|
|
81
|
+
|
|
76
82
|
def initialize(session_id)
|
|
83
|
+
@session_id = session_id
|
|
77
84
|
super("session #{session_id} is owned by an interactive TUI")
|
|
78
85
|
end
|
|
79
86
|
end
|
|
@@ -95,6 +102,29 @@ module Samagotchi
|
|
|
95
102
|
end
|
|
96
103
|
end
|
|
97
104
|
|
|
105
|
+
# An archive that can't happen now: a turn runs in the session or in one
|
|
106
|
+
# of its children (+busy_id+ names which), or it is a scratch session.
|
|
107
|
+
class ArchiveRefused < StandardError
|
|
108
|
+
attr_reader :session_id, :reason, :busy_id
|
|
109
|
+
|
|
110
|
+
def initialize(session_id, reason, busy_id: nil)
|
|
111
|
+
@session_id = session_id
|
|
112
|
+
@reason = reason
|
|
113
|
+
@busy_id = busy_id
|
|
114
|
+
super(archive_refused_message)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def archive_refused_message
|
|
120
|
+
case @reason
|
|
121
|
+
when :scratch then "a scratch session is deleted when you leave; nothing to archive"
|
|
122
|
+
when :busy_child then "its delegate #{@busy_id[0, 8]} is running a turn; wait for it or stop it first"
|
|
123
|
+
else "a turn is running; wait for it or cancel it first"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
98
128
|
# Spawn a new background session that processes the given prompt (or,
|
|
99
129
|
# with none, waits idle for input).
|
|
100
130
|
#
|
|
@@ -118,7 +148,7 @@ module Samagotchi
|
|
|
118
148
|
sd = state_dir || Session.default_state_dir
|
|
119
149
|
session = Session.new_session(
|
|
120
150
|
mode: mode,
|
|
121
|
-
model_name:
|
|
151
|
+
model_name: Samagotchi::ModelProfile.check_host!(Samagotchi::ModelProfile.required_model_name(model_name)),
|
|
122
152
|
working_directory: working_directory || Dir.pwd,
|
|
123
153
|
preloaded_memory_names: memories,
|
|
124
154
|
muted_memory_names: muted_memories,
|
|
@@ -190,9 +220,11 @@ module Samagotchi
|
|
|
190
220
|
|
|
191
221
|
# List all sessions, reading status from persisted session.json files.
|
|
192
222
|
# +project_root+: only that project's sessions (nil: every session).
|
|
193
|
-
|
|
223
|
+
# +include_archived+: archived sessions too (Session#archived says which).
|
|
224
|
+
def self.list_sessions(state_dir: nil, sort: "updated_at", order: "desc", limit: nil, offset: 0, project_root: nil,
|
|
225
|
+
include_archived: false)
|
|
194
226
|
Session.list(state_dir: state_dir || Session.default_state_dir, sort: sort, order: order, limit: limit,
|
|
195
|
-
offset: offset, project_root: project_root)
|
|
227
|
+
offset: offset, project_root: project_root, include_archived: include_archived)
|
|
196
228
|
end
|
|
197
229
|
|
|
198
230
|
# Prune sessions per retention policy. Delegates to Session.prune with live-worker guard.
|
|
@@ -211,17 +243,19 @@ module Samagotchi
|
|
|
211
243
|
# @param limit [Integer, nil] taken after the filters
|
|
212
244
|
# @param include_tests [Boolean] false leaves out test runs
|
|
213
245
|
# @param exclude [String, nil] a session id to leave out (the asker)
|
|
246
|
+
# @param include_archived [Boolean] archived sessions too
|
|
214
247
|
# @return [Array<Hash>] {id:, short_id:, desc:, preview:, cwd:, project:,
|
|
215
248
|
# updated_at:, status:, live:, busy:, owner:, recap:, parent_id:,
|
|
216
|
-
# parent_short_id:}; busy = live with
|
|
249
|
+
# parent_short_id:, archived:, scratch:}; busy = live with
|
|
217
250
|
# a turn running, recap = the saved recap's first sentence, project =
|
|
218
251
|
# Session#project_root
|
|
219
252
|
def self.session_summaries(live: false, cwd: nil, limit: nil, include_tests: true, exclude: nil, state_dir: nil,
|
|
220
|
-
project_root: nil)
|
|
253
|
+
project_root: nil, include_archived: false)
|
|
221
254
|
sd = state_dir || Session.default_state_dir
|
|
222
255
|
root = cwd && folder_path(cwd)
|
|
223
256
|
roots = {}
|
|
224
|
-
summaries = Session.list(state_dir: sd, sort: "updated_at", order: "desc", project_root: project_root
|
|
257
|
+
summaries = Session.list(state_dir: sd, sort: "updated_at", order: "desc", project_root: project_root,
|
|
258
|
+
include_archived: include_archived).lazy
|
|
225
259
|
.reject { |s| (!include_tests && s.test_run) || s.id == exclude }
|
|
226
260
|
.select { |s| root.nil? || in_folder?(s.working_directory, root) }
|
|
227
261
|
.filter_map do |s|
|
|
@@ -232,18 +266,89 @@ module Samagotchi
|
|
|
232
266
|
{ id: s.id, short_id: s.id[0, 8], desc: summary_desc(s), preview: summary_preview(s), cwd: s.working_directory,
|
|
233
267
|
project: s.project_root(cache: roots), updated_at: s.updated_at, status: s.status, live: owned, busy: owned && s.status == Session::STATUS_RUNNING,
|
|
234
268
|
owner: owner, recap: RecapStore.preview(Session.session_dir(s.id, state_dir: sd)),
|
|
235
|
-
|
|
269
|
+
ctx_pct: SessionMetrics.saved_context_pct(Session.session_dir(s.id, state_dir: sd))&.round(1),
|
|
270
|
+
parent_id: s.parent_id, parent_short_id: s.parent_id&.[](0, 8), archived: s.archived,
|
|
271
|
+
scratch: s.scratch }
|
|
236
272
|
end
|
|
237
273
|
(limit ? summaries.first(limit) : summaries.to_a)
|
|
238
274
|
end
|
|
239
275
|
|
|
240
276
|
# The sessions delegated by +parent_id+ (the `delegate` tool), newest
|
|
241
277
|
# first, as .session_summaries rows. A running one (busy) counts against
|
|
242
|
-
# session.max_children.
|
|
278
|
+
# session.max_children. Archived ones too.
|
|
243
279
|
def self.children_of(parent_id, state_dir: nil)
|
|
244
280
|
return [] if parent_id.to_s.empty?
|
|
245
281
|
|
|
246
|
-
session_summaries(state_dir: state_dir, include_tests: true
|
|
282
|
+
session_summaries(state_dir: state_dir, include_tests: true, include_archived: true)
|
|
283
|
+
.select { |s| s[:parent_id] == parent_id.to_s }
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Archive a session and its delegated children (ArchiveStore): hidden
|
|
287
|
+
# from every list, kept by the retention sweep. A live idle worker is
|
|
288
|
+
# stopped first; the marker is written even while it shuts down.
|
|
289
|
+
# @return [Hash] {id:, archived: [ids], stopped: [ids], discarded: [ids]};
|
|
290
|
+
# discarded: empty sessions their stopping worker deleted
|
|
291
|
+
# @raise [ArgumentError] unknown id (Session::AmbiguousId for a prefix of several)
|
|
292
|
+
# @raise [OwnedByTUI] a chi REPL owns it or one of its children
|
|
293
|
+
# @raise [ArchiveRefused] a turn runs in it or in a child, or it is a
|
|
294
|
+
# scratch session
|
|
295
|
+
def self.archive_session(id_or_prefix, state_dir: nil, wait: 5)
|
|
296
|
+
sd = state_dir || Session.default_state_dir
|
|
297
|
+
id = archive_target(id_or_prefix, sd)
|
|
298
|
+
raise ArchiveRefused.new(id, :scratch) if Session.load(id, state_dir: sd).scratch
|
|
299
|
+
|
|
300
|
+
tree = [id, *descendant_ids(id, sd)]
|
|
301
|
+
owners = tree.to_h { |sid| [sid, session_owner(sid, state_dir: sd)] }
|
|
302
|
+
tree.each do |sid|
|
|
303
|
+
owner = owners[sid]
|
|
304
|
+
next unless owner
|
|
305
|
+
|
|
306
|
+
raise OwnedByTUI, sid if owner["kind"] == "tui"
|
|
307
|
+
next unless Session.load(sid, state_dir: sd).status == Session::STATUS_RUNNING
|
|
308
|
+
|
|
309
|
+
raise ArchiveRefused.new(id, sid == id ? :busy : :busy_child, busy_id: sid)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
stopped = tree.select { |sid| owners[sid] }
|
|
313
|
+
stopped.each { |sid| stop_session(sid, state_dir: sd, wait: wait) }
|
|
314
|
+
archived, discarded = tree.partition { |sid| ArchiveStore.archive(sid, state_dir: sd) }
|
|
315
|
+
{ id: id, archived: archived, stopped: stopped, discarded: discarded }
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Unarchive a session and its delegated children.
|
|
319
|
+
# @return [Hash] {id:, unarchived: [ids that were archived]}
|
|
320
|
+
# @raise [ArgumentError] unknown id
|
|
321
|
+
def self.unarchive_session(id_or_prefix, state_dir: nil)
|
|
322
|
+
sd = state_dir || Session.default_state_dir
|
|
323
|
+
id = archive_target(id_or_prefix, sd)
|
|
324
|
+
unarchived = [id, *descendant_ids(id, sd)].select { |sid| ArchiveStore.unarchive(sid, state_dir: sd) }
|
|
325
|
+
{ id: id, unarchived: unarchived }
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
private_class_method def self.archive_target(id_or_prefix, state_dir)
|
|
329
|
+
given = id_or_prefix.to_s
|
|
330
|
+
raise ArgumentError, "no session #{given}" unless given.match?(/\A[\w-]+\z/)
|
|
331
|
+
|
|
332
|
+
id = Session.resolve_id(given, state_dir: state_dir)
|
|
333
|
+
raise ArgumentError, "no session #{given}" unless Session.exist?(id, state_dir: state_dir)
|
|
334
|
+
|
|
335
|
+
id
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# Children, their children, …: a delegated session doesn't delegate
|
|
339
|
+
# further today, a plugin's fork may.
|
|
340
|
+
private_class_method def self.descendant_ids(id, state_dir)
|
|
341
|
+
seen = [id]
|
|
342
|
+
queue = [id]
|
|
343
|
+
until queue.empty?
|
|
344
|
+
children_of(queue.shift, state_dir: state_dir).each do |child|
|
|
345
|
+
next if seen.include?(child[:id])
|
|
346
|
+
|
|
347
|
+
seen << child[:id]
|
|
348
|
+
queue << child[:id]
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
seen.drop(1)
|
|
247
352
|
end
|
|
248
353
|
|
|
249
354
|
SUMMARY_PREVIEW_LIMIT = 120
|
|
@@ -534,7 +639,7 @@ module Samagotchi
|
|
|
534
639
|
if EMPTY_SKELETON_DIRS.include?(name)
|
|
535
640
|
File.directory?(path) && (!EMPTY_DIRS.include?(name) || Dir.children(path).empty?)
|
|
536
641
|
elsif name == "analytics.json"
|
|
537
|
-
JSON.parse(File.read(path))["
|
|
642
|
+
Array(JSON.parse(File.read(path))["turn_records"]).empty?
|
|
538
643
|
else
|
|
539
644
|
EMPTY_SKELETON_FILES.include?(name)
|
|
540
645
|
end
|
|
@@ -592,21 +697,6 @@ module Samagotchi
|
|
|
592
697
|
true
|
|
593
698
|
end
|
|
594
699
|
|
|
595
|
-
# Wait for a session to reach a terminal state (completed, error, stopped).
|
|
596
|
-
# Returns true if the session finished, false if the timeout elapsed.
|
|
597
|
-
def self.wait_for_session(session_id, timeout: 30, state_dir: nil)
|
|
598
|
-
sd = state_dir || Session.default_state_dir
|
|
599
|
-
elapsed = 0
|
|
600
|
-
while elapsed < timeout
|
|
601
|
-
session = Session.load(session_id, state_dir: sd)
|
|
602
|
-
return true if %w[completed error stopped].include?(session.status)
|
|
603
|
-
|
|
604
|
-
sleep(0.5)
|
|
605
|
-
elapsed += 0.5
|
|
606
|
-
end
|
|
607
|
-
false
|
|
608
|
-
end
|
|
609
|
-
|
|
610
700
|
# Run the session loop inside the forked process.
|
|
611
701
|
# This is the entry point called by Process.spawn.
|
|
612
702
|
#
|