samagotchi 0.6.0 → 0.8.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 +72 -1
- data/README.md +7 -3
- data/docs/architecture.md +4 -4
- data/docs/cli.md +19 -4
- data/docs/configuration.md +20 -0
- data/docs/desktop.md +49 -0
- data/docs/guardrails.md +2 -2
- data/docs/hooks.md +2 -2
- data/docs/internals/context-telemetry.md +3 -1
- data/docs/memory.md +48 -1
- data/docs/plugins.md +7 -5
- data/docs/releasing.md +5 -1
- data/docs/sessions.md +2 -2
- data/lib/samagotchi/archive_store.rb +3 -3
- data/lib/samagotchi/atomic_file.rb +34 -0
- data/lib/samagotchi/bootstrap/bundles.rb +37 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +4 -6
- data/lib/samagotchi/bootstrap/probe.rb +9 -5
- data/lib/samagotchi/bootstrap_command.rb +78 -8
- data/lib/samagotchi/bridge/pending_card.rb +2 -3
- data/lib/samagotchi/bridge.rb +56 -116
- data/lib/samagotchi/bridge_client.rb +7 -11
- data/lib/samagotchi/bundle_command.rb +81 -21
- data/lib/samagotchi/bundle_needs.rb +2 -11
- data/lib/samagotchi/bundles/core/manifest.yml +11 -0
- data/lib/samagotchi/bundles/dev/manifest.yml +13 -0
- data/lib/samagotchi/bundles/system/identity.md +1 -0
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +3 -2
- data/lib/samagotchi/client.rb +3 -23
- data/lib/samagotchi/config.rb +11 -11
- data/lib/samagotchi/context_note.rb +1 -1
- data/lib/samagotchi/context_status.rb +255 -0
- data/lib/samagotchi/desktop/macos/App.swift +5 -1
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +7 -63
- data/lib/samagotchi/desktop/macos/Images.swift +38 -3
- data/lib/samagotchi/desktop/macos/Kitty.swift +346 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +233 -55
- data/lib/samagotchi/desktop/macos/ProcessRunner.swift +74 -0
- data/lib/samagotchi/desktop/macos.rb +25 -11
- data/lib/samagotchi/desktop_command.rb +1 -1
- data/lib/samagotchi/engine.rb +6 -7
- data/lib/samagotchi/guardrail_wiring.rb +2 -5
- data/lib/samagotchi/guardrails/approvals.rb +2 -7
- data/lib/samagotchi/hooks/bundle_loader.rb +6 -3
- data/lib/samagotchi/hooks/loader.rb +2 -1
- data/lib/samagotchi/hooks/registry.rb +21 -0
- data/lib/samagotchi/image_store.rb +2 -3
- data/lib/samagotchi/kernel_loop.rb +21 -283
- data/lib/samagotchi/live_versions.rb +9 -13
- data/lib/samagotchi/llm/chat_loop.rb +1 -1
- data/lib/samagotchi/llm/native_backend.rb +8 -8
- data/lib/samagotchi/llm/openai_chat.rb +4 -17
- data/lib/samagotchi/llm/utf8_scrub.rb +32 -0
- data/lib/samagotchi/log_path.rb +2 -3
- data/lib/samagotchi/memory_bundle/index_updater.rb +2 -11
- data/lib/samagotchi/memory_bundle/installer.rb +5 -7
- data/lib/samagotchi/memory_bundle/listing.rb +27 -20
- data/lib/samagotchi/memory_bundle/manifest.rb +32 -1
- data/lib/samagotchi/memory_bundle/profile.rb +145 -0
- data/lib/samagotchi/memory_bundle/provenance.rb +42 -53
- data/lib/samagotchi/memory_bundle/shipped_update.rb +52 -27
- data/lib/samagotchi/memory_bundle/source.rb +2 -0
- data/lib/samagotchi/memory_bundle/status.rb +1 -2
- data/lib/samagotchi/memory_bundle.rb +1 -0
- data/lib/samagotchi/note_command.rb +5 -4
- data/lib/samagotchi/owner_lock.rb +0 -6
- data/lib/samagotchi/paths.rb +32 -0
- data/lib/samagotchi/plugin/api.rb +3 -6
- data/lib/samagotchi/plugin/context.rb +2 -3
- data/lib/samagotchi/plugin/loader.rb +1 -1
- data/lib/samagotchi/question_desk.rb +3 -2
- data/lib/samagotchi/recap_store.rb +2 -2
- data/lib/samagotchi/reply_wait.rb +2 -1
- data/lib/samagotchi/self_report.rb +2 -3
- data/lib/samagotchi/send_command.rb +3 -2
- data/lib/samagotchi/session.rb +41 -143
- data/lib/samagotchi/session_inbox.rb +175 -0
- data/lib/samagotchi/session_manager.rb +62 -363
- data/lib/samagotchi/session_metrics.rb +2 -3
- data/lib/samagotchi/session_retention.rb +259 -0
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -2
- data/lib/samagotchi/terminal_ui/input_support.rb +2 -9
- data/lib/samagotchi/terminal_ui.rb +10 -20
- data/lib/samagotchi/tools/memory.rb +3 -2
- data/lib/samagotchi/tools/send_note.rb +4 -3
- data/lib/samagotchi/tools/task_runtime.rb +2 -3
- data/lib/samagotchi/turn_note.rb +13 -2
- data/lib/samagotchi/update_command.rb +10 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +44 -76
- data/lib/samagotchi/web/info_probe.rb +33 -0
- data/lib/samagotchi/web/public/app.js +1 -1
- data/lib/samagotchi/web/public/index.html +1 -0
- data/lib/samagotchi/web/public/stage_model.js +22 -3
- data/lib/samagotchi/web/public/stage_view.js +25 -10
- data/lib/samagotchi/web/server.rb +8 -11
- data/lib/samagotchi/web/session_hub.rb +8 -7
- data/lib/samagotchi/web/session_summary.rb +4 -3
- data/lib/samagotchi/web/token.rb +2 -5
- data/lib/samagotchi/worker.rb +34 -20
- data/lib/samagotchi/worker_sidecar.rb +42 -0
- metadata +15 -1
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "time"
|
|
5
|
+
require_relative "session"
|
|
6
|
+
require_relative "archive_store"
|
|
7
|
+
require_relative "config"
|
|
8
|
+
require_relative "log"
|
|
9
|
+
require_relative "model_profile"
|
|
10
|
+
require_relative "session_manager"
|
|
11
|
+
|
|
12
|
+
module Samagotchi
|
|
13
|
+
# Which sessions go: `chi sessions prune` / `clean` (.prune) and the lazy
|
|
14
|
+
# sweep chi web runs at most once a day (.sweep_if_due). Age
|
|
15
|
+
# (session.retention_days), count (session.max_count, counting only the
|
|
16
|
+
# sessions that stay), statuses kept (session.keep_status), sessions left
|
|
17
|
+
# empty (SessionManager.discardable?, an hour on), leftover scratch
|
|
18
|
+
# sessions and skeleton-only directories; a session with a live owner
|
|
19
|
+
# always stays.
|
|
20
|
+
module SessionRetention
|
|
21
|
+
# The session.retention_days / max_count / keep_status defaults.
|
|
22
|
+
DEFAULT_RETENTION_DAYS = 14
|
|
23
|
+
DEFAULT_MAX_COUNT = 500
|
|
24
|
+
# status is turn state; a live owner (the prune alive_check) is what
|
|
25
|
+
# protects a session in use, so no status is kept by default: a
|
|
26
|
+
# "running" left by a crashed worker must not keep it forever.
|
|
27
|
+
DEFAULT_KEEP_STATUS = [].freeze
|
|
28
|
+
|
|
29
|
+
# How long a session may sit empty before the sweep takes it: its
|
|
30
|
+
# worker (or a REPL) deletes it as it leaves, so the sweep only catches
|
|
31
|
+
# those killed first (a reboot, kill -9).
|
|
32
|
+
EMPTY_GRACE_SECONDS = 3600
|
|
33
|
+
|
|
34
|
+
# The settings (or the caller's flags) applied, with live owners kept and
|
|
35
|
+
# sessions left empty an hour taken (unless session.keep_empty).
|
|
36
|
+
# @return [Hash] { deleted: [ids], kept: [ids], skipped: [ids] }
|
|
37
|
+
def self.prune(state_dir: nil, days: nil, max_count: nil, keep_status: nil, dry_run: false, test_only: false,
|
|
38
|
+
any_age: false)
|
|
39
|
+
sd = state_dir || Session.default_state_dir
|
|
40
|
+
days = resolve_days(days)
|
|
41
|
+
max_count = resolve_max_count(max_count)
|
|
42
|
+
keep_status = resolve_keep_status(keep_status)
|
|
43
|
+
discard = SessionManager.discard_empty?
|
|
44
|
+
default_model = discard ? (begin ModelProfile.required_model_name(nil) rescue nil end) : nil
|
|
45
|
+
result = apply(
|
|
46
|
+
state_dir: sd,
|
|
47
|
+
days: days,
|
|
48
|
+
max_count: max_count,
|
|
49
|
+
keep_status: keep_status,
|
|
50
|
+
dry_run: dry_run,
|
|
51
|
+
test_only: test_only,
|
|
52
|
+
any_age: any_age,
|
|
53
|
+
alive_check: ->(sid) { !SessionManager.session_owner(sid, state_dir: sd).nil? },
|
|
54
|
+
empty_check: discard ? ->(sid) { left_empty?(sid, state_dir: sd, default_model: default_model) } : nil
|
|
55
|
+
)
|
|
56
|
+
result[:deleted].concat(prune_orphan_dirs(sd, dry_run: dry_run)) if discard && !test_only
|
|
57
|
+
result
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private_class_method def self.left_empty?(session_id, state_dir:, default_model:)
|
|
61
|
+
path = Session.session_file(session_id, state_dir: state_dir)
|
|
62
|
+
Time.now - File.mtime(path) > EMPTY_GRACE_SECONDS &&
|
|
63
|
+
SessionManager.discardable?(session_id, state_dir: state_dir, default_model: default_model)
|
|
64
|
+
rescue SystemCallError
|
|
65
|
+
false
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Directories with no session file and nothing but the skeleton, nobody
|
|
69
|
+
# owning them: a REPL killed before its first save, or a worker woken
|
|
70
|
+
# just as its session was discarded.
|
|
71
|
+
# @return [Array<String>] their ids
|
|
72
|
+
private_class_method def self.prune_orphan_dirs(state_dir, dry_run:)
|
|
73
|
+
return [] unless Dir.exist?(state_dir)
|
|
74
|
+
|
|
75
|
+
Dir.children(state_dir).filter_map do |name|
|
|
76
|
+
dir = File.join(state_dir, name)
|
|
77
|
+
next unless Session.valid_id?(name) && File.directory?(dir)
|
|
78
|
+
next if File.exist?(File.join(state_dir, "#{name}#{Session::FILE_EXT}"))
|
|
79
|
+
next unless Time.now - File.mtime(dir) > EMPTY_GRACE_SECONDS && SessionManager.empty_session_dir?(dir)
|
|
80
|
+
next if SessionManager.session_owner(name, state_dir: state_dir)
|
|
81
|
+
|
|
82
|
+
FileUtils.rm_rf(dir) unless dry_run
|
|
83
|
+
name
|
|
84
|
+
rescue SystemCallError
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Lazy sweep guard: runs prune at most once per SWEEP_INTERVAL_HOURS
|
|
90
|
+
# (session.sweep_interval_hours).
|
|
91
|
+
MARKER = ".last_retention"
|
|
92
|
+
SWEEP_INTERVAL_HOURS = 24
|
|
93
|
+
|
|
94
|
+
def self.sweep_if_due(state_dir: nil)
|
|
95
|
+
sd = state_dir || Session.default_state_dir
|
|
96
|
+
return unless Dir.exist?(sd)
|
|
97
|
+
|
|
98
|
+
hours = Samagotchi::Config.get("session.sweep_interval_hours").to_i
|
|
99
|
+
interval = (hours.positive? ? hours : SWEEP_INTERVAL_HOURS) * 3600
|
|
100
|
+
marker = File.join(sd, MARKER)
|
|
101
|
+
if File.exist?(marker)
|
|
102
|
+
age = Time.now - File.mtime(marker)
|
|
103
|
+
return if age < interval
|
|
104
|
+
end
|
|
105
|
+
result = prune(state_dir: sd)
|
|
106
|
+
FileUtils.touch(marker)
|
|
107
|
+
if result[:deleted].any?
|
|
108
|
+
Log.info(:worker, "retention_pruned", echo: "[retention] pruned #{result[:deleted].size} sessions (kept #{result[:kept].size})", deleted: result[:deleted].size, kept: result[:kept].size)
|
|
109
|
+
end
|
|
110
|
+
result
|
|
111
|
+
rescue StandardError => e
|
|
112
|
+
Log.warn(:worker, "retention_failed", echo: "[retention] sweep failed: #{e.class}: #{e.message}", error: e.class.name)
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The caller's value (a sessions prune flag) or session.retention_days.
|
|
117
|
+
private_class_method def self.resolve_days(val)
|
|
118
|
+
return val.to_i if !val.nil? && val.to_s.strip != ""
|
|
119
|
+
|
|
120
|
+
Samagotchi::Config.get("session.retention_days").to_i
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private_class_method def self.resolve_max_count(val)
|
|
124
|
+
return val.to_i if !val.nil? && val.to_s.strip != ""
|
|
125
|
+
|
|
126
|
+
Samagotchi::Config.get("session.max_count").to_i
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# A comma list of statuses never pruned; "" keeps none.
|
|
130
|
+
private_class_method def self.resolve_keep_status(val)
|
|
131
|
+
raw = !val.nil? && val.to_s.strip != "" ? val.to_s : Samagotchi::Config.get("session.keep_status").to_s
|
|
132
|
+
raw.split(",").map(&:strip).reject(&:empty?)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Apply the retention policy to the sessions on disk.
|
|
136
|
+
#
|
|
137
|
+
# Only deletes when the json file exists — orphan dirs without json are never removed.
|
|
138
|
+
# Honors keep_status and live-worker guard.
|
|
139
|
+
# A session is kept only if it is NOT expired by age AND within max_count;
|
|
140
|
+
# either expiry or overflow triggers deletion (unless protected).
|
|
141
|
+
#
|
|
142
|
+
# +empty_check+ (id → Boolean) marks a session left empty: it goes
|
|
143
|
+
# whatever its age and the count (.prune).
|
|
144
|
+
#
|
|
145
|
+
# +any_age+ makes every session eligible, whatever its age and the
|
|
146
|
+
# count (`chi sessions clean` with no --days: test runs are throwaway).
|
|
147
|
+
#
|
|
148
|
+
# A scratch session nobody owns (its REPL was killed) goes whatever its
|
|
149
|
+
# age, its status and the count; +test_only+ takes it too.
|
|
150
|
+
#
|
|
151
|
+
# +max_count+ counts the sessions that stay: one deleted whatever its
|
|
152
|
+
# place (scratch, left empty, expired, +any_age+) takes no slot.
|
|
153
|
+
#
|
|
154
|
+
# Archived sessions are not in Session.list, so they are neither deleted nor
|
|
155
|
+
# counted. One unarchived is aged from when it was unarchived, if later.
|
|
156
|
+
#
|
|
157
|
+
# @return [Hash] { deleted: [ids], kept: [ids], skipped: [ids] }
|
|
158
|
+
def self.apply(state_dir: Session.default_state_dir, days: DEFAULT_RETENTION_DAYS, max_count: DEFAULT_MAX_COUNT,
|
|
159
|
+
keep_status: DEFAULT_KEEP_STATUS, dry_run: false, test_only: false, alive_check: nil,
|
|
160
|
+
empty_check: nil, any_age: false)
|
|
161
|
+
keep_status = Array(keep_status).map(&:to_s)
|
|
162
|
+
# Fetch all sessions sorted newest-first for count logic
|
|
163
|
+
all = Session.list(state_dir: state_dir, sort: "updated_at", order: "desc")
|
|
164
|
+
# Filter test_only if requested
|
|
165
|
+
if test_only
|
|
166
|
+
all = all.select { |session| session.test_run || session.scratch }
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
now = Time.now
|
|
170
|
+
cutoff = days.to_i.positive? ? now - days.to_i * 86_400 : nil
|
|
171
|
+
max = max_count.to_i
|
|
172
|
+
|
|
173
|
+
deleted = []
|
|
174
|
+
kept = []
|
|
175
|
+
skipped = []
|
|
176
|
+
passed_over = 0
|
|
177
|
+
|
|
178
|
+
all.each_with_index do |session, idx|
|
|
179
|
+
path = File.join(state_dir, "#{session.id}#{Session::FILE_EXT}")
|
|
180
|
+
# Only when json present
|
|
181
|
+
unless File.exist?(path)
|
|
182
|
+
skipped << session.id
|
|
183
|
+
next
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Protected by keep_status
|
|
187
|
+
if keep_status.include?(session.status.to_s) && !session.scratch
|
|
188
|
+
kept << session.id
|
|
189
|
+
next
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Protected by live worker
|
|
193
|
+
if alive_check
|
|
194
|
+
begin
|
|
195
|
+
if alive_check.call(session.id)
|
|
196
|
+
kept << session.id
|
|
197
|
+
next
|
|
198
|
+
end
|
|
199
|
+
rescue StandardError
|
|
200
|
+
nil
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
left_empty = begin
|
|
205
|
+
empty_check&.call(session.id)
|
|
206
|
+
rescue StandardError
|
|
207
|
+
false
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Determine expiry and overflow
|
|
211
|
+
expired = false
|
|
212
|
+
if cutoff
|
|
213
|
+
begin
|
|
214
|
+
updated = Time.iso8601(session.updated_at.to_s)
|
|
215
|
+
rescue ArgumentError
|
|
216
|
+
updated = File.mtime(path) rescue now
|
|
217
|
+
end
|
|
218
|
+
unarchived = ArchiveStore.unarchived_at(Session.session_dir(session.id, state_dir: state_dir))
|
|
219
|
+
updated = unarchived if unarchived && unarchived > updated
|
|
220
|
+
expired = updated < cutoff
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# A session deleted whatever its place (scratch, left empty,
|
|
224
|
+
# expired, any_age) takes no max_count slot: the count is of the
|
|
225
|
+
# sessions ahead that stay.
|
|
226
|
+
overflow = max.positive? && idx - passed_over >= max
|
|
227
|
+
passed_over += 1 if expired || left_empty || any_age || session.scratch
|
|
228
|
+
|
|
229
|
+
# retain forever when both disabled
|
|
230
|
+
if max.zero? && cutoff.nil? && !left_empty && !any_age && !session.scratch
|
|
231
|
+
kept << session.id
|
|
232
|
+
next
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# If neither expired nor overflow, keep
|
|
236
|
+
unless expired || overflow || left_empty || any_age || session.scratch
|
|
237
|
+
kept << session.id
|
|
238
|
+
next
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Eligible for deletion
|
|
242
|
+
if dry_run
|
|
243
|
+
deleted << session.id
|
|
244
|
+
else
|
|
245
|
+
begin
|
|
246
|
+
FileUtils.rm_f(path)
|
|
247
|
+
sidecar = File.join(state_dir, session.id)
|
|
248
|
+
FileUtils.rm_rf(sidecar) if File.exist?(sidecar)
|
|
249
|
+
deleted << session.id
|
|
250
|
+
rescue StandardError
|
|
251
|
+
skipped << session.id
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
{ deleted: deleted, kept: kept, skipped: skipped }
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require_relative "session"
|
|
5
5
|
require_relative "session_manager"
|
|
6
|
+
require_relative "session_retention"
|
|
6
7
|
require_relative "project_scope"
|
|
7
8
|
require_relative "session_metrics"
|
|
8
9
|
require_relative "recap_store"
|
|
@@ -239,8 +240,8 @@ module Samagotchi
|
|
|
239
240
|
# unless --days asks for the older ones only. Live workers and
|
|
240
241
|
# keep_status still protect a session.
|
|
241
242
|
any_age = sub == "clean" && test_only && days.nil?
|
|
242
|
-
result = Samagotchi::
|
|
243
|
-
|
|
243
|
+
result = Samagotchi::SessionRetention.prune(days: days, max_count: keep, keep_status: keep_status, dry_run: dry_run,
|
|
244
|
+
test_only: test_only, any_age: any_age)
|
|
244
245
|
mode = dry_run ? "Would delete" : "Deleted"
|
|
245
246
|
@stdout.puts "#{mode} #{result[:deleted].size} sessions (kept #{result[:kept].size}, skipped #{result[:skipped].size})"
|
|
246
247
|
if result[:deleted].any?
|
|
@@ -523,8 +523,7 @@ module Samagotchi
|
|
|
523
523
|
# Read after its deadline and dropped (BridgeClient::DEADLINE_SHARE).
|
|
524
524
|
return @screen.commit("could not send the prompt (#{LATE})") if detail == "deadline_passed"
|
|
525
525
|
|
|
526
|
-
|
|
527
|
-
@screen.commit("could not send the prompt (#{[reply.status, detail].compact.join(" ")})#{": #{explained}" if explained}")
|
|
526
|
+
@screen.commit("could not send the prompt (#{[reply.status, detail].compact.join(" ")})")
|
|
528
527
|
rescue SystemCallError, IOError => e
|
|
529
528
|
own_prompt_failed
|
|
530
529
|
@screen.commit("could not send the prompt (#{worker_down(e)})")
|
|
@@ -6,6 +6,7 @@ require "reline"
|
|
|
6
6
|
|
|
7
7
|
require_relative "../config"
|
|
8
8
|
require_relative "../memory_paths"
|
|
9
|
+
require_relative "../paths"
|
|
9
10
|
require_relative "../tools/memory"
|
|
10
11
|
require_relative "../session_commands"
|
|
11
12
|
|
|
@@ -20,7 +21,6 @@ module Samagotchi
|
|
|
20
21
|
# Included for private use; it keeps state in @next_input_prefill and
|
|
21
22
|
# reads @no_default_input.
|
|
22
23
|
module InputSupport
|
|
23
|
-
XDG_STATE_HOME_ENV = "XDG_STATE_HOME"
|
|
24
24
|
PROMPT_HISTORY_FILE = "history.json"
|
|
25
25
|
PROMPT_HISTORY_STATE_DIR = "samagotchi"
|
|
26
26
|
PROMPT_HISTORY_LIMIT = 20
|
|
@@ -207,14 +207,7 @@ module Samagotchi
|
|
|
207
207
|
explicit = Samagotchi::Config.get("history.file").to_s.strip
|
|
208
208
|
return explicit unless explicit.empty?
|
|
209
209
|
|
|
210
|
-
File.join(
|
|
211
|
-
end
|
|
212
|
-
|
|
213
|
-
def xdg_state_home
|
|
214
|
-
configured = ENV[XDG_STATE_HOME_ENV].to_s.strip
|
|
215
|
-
return configured unless configured.empty?
|
|
216
|
-
|
|
217
|
-
File.join(Dir.home, ".local", "state")
|
|
210
|
+
File.join(Paths.state_home, PROMPT_HISTORY_STATE_DIR, PROMPT_HISTORY_FILE)
|
|
218
211
|
end
|
|
219
212
|
|
|
220
213
|
def load_persistent_history
|
|
@@ -37,9 +37,11 @@ module Samagotchi
|
|
|
37
37
|
# process loads the whole UI stack through it), a require cycle otherwise.
|
|
38
38
|
autoload :SessionManager, File.expand_path("session_manager", __dir__)
|
|
39
39
|
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
40
|
+
# The in-process terminal front end (`chi --no-shared`, `chi scratch`,
|
|
41
|
+
# --verbose, --non-interactive): it owns one session and its Engine, runs
|
|
42
|
+
# an optional -p prompt (--non-interactive: that one turn and out), then the REPL,
|
|
43
|
+
# where the user types, the model answers and tools run inline. The
|
|
44
|
+
# attached terminal (a worker's session) is TerminalUI::AttachedLoop.
|
|
43
45
|
class TerminalUI
|
|
44
46
|
include Formatting
|
|
45
47
|
include InputSupport
|
|
@@ -68,9 +70,8 @@ module Samagotchi
|
|
|
68
70
|
|
|
69
71
|
# @param scratch [Boolean] `chi scratch`: a new session that is deleted
|
|
70
72
|
# however the REPL ends, saves no memories and writes no recap
|
|
71
|
-
def initialize(
|
|
73
|
+
def initialize(prompt: nil, client: nil, host_registry: nil, profile: nil, session_id: nil, no_interrupt: false, no_default_input: false, model_name: nil, memories: [], muted_memories: [], non_interactive: false, surface: nil,
|
|
72
74
|
spinner_tick_interval: AttachedView::TICK_INTERVAL, spinner_clock: nil, scratch: false)
|
|
73
|
-
@mode = mode.to_sym
|
|
74
75
|
@scratch = scratch
|
|
75
76
|
@prompt = prompt
|
|
76
77
|
@default_model_name = ModelProfile.required_model_name(nil)
|
|
@@ -138,7 +139,6 @@ module Samagotchi
|
|
|
138
139
|
@renderer = EventRenderer.new(@view)
|
|
139
140
|
@render_event = ->(event) { handle_stream_event(event) }
|
|
140
141
|
@engine = Engine.new(
|
|
141
|
-
mode: :assist,
|
|
142
142
|
client: client,
|
|
143
143
|
host_registry: @host_registry,
|
|
144
144
|
profile: profile,
|
|
@@ -206,16 +206,12 @@ module Samagotchi
|
|
|
206
206
|
# @return [Symbol, nil] :turn_failed when lines came from a pipe and a
|
|
207
207
|
# turn failed (bin/chi exits 1)
|
|
208
208
|
def run
|
|
209
|
-
unless @mode == :assist
|
|
210
|
-
raise ArgumentError, "Unknown mode '#{@mode}'. Use: assist"
|
|
211
|
-
end
|
|
212
|
-
|
|
213
209
|
# --non-interactive with no --prompt is a harmless no-op exit: build
|
|
214
210
|
# nothing and return (no transient session, no banner).
|
|
215
211
|
return if @non_interactive && @prompt.nil?
|
|
216
212
|
|
|
217
213
|
session = @resume_session || Session.new_session(
|
|
218
|
-
mode:
|
|
214
|
+
mode: "assist",
|
|
219
215
|
model_name: @effective_model_name,
|
|
220
216
|
working_directory: Dir.pwd,
|
|
221
217
|
preloaded_memory_names: @requested_memories,
|
|
@@ -316,19 +312,13 @@ module Samagotchi
|
|
|
316
312
|
nil
|
|
317
313
|
end
|
|
318
314
|
|
|
319
|
-
# Nothing happened in the session (SessionManager.
|
|
315
|
+
# Nothing happened in the session (SessionManager.discardable?). The
|
|
320
316
|
# REPL saves only after a turn and keeps /model in its Engine, so a
|
|
321
317
|
# session never saved is judged from memory, and one on another model
|
|
322
318
|
# than the default (/model, --model) is kept.
|
|
323
319
|
def discard_on_exit?(session)
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
if File.exist?(File.join(Session.default_state_dir, "#{session.id}#{Session::FILE_EXT}"))
|
|
327
|
-
return SessionManager.empty_session?(session.id, default_model: @default_model_name)
|
|
328
|
-
end
|
|
329
|
-
|
|
330
|
-
SessionManager.no_conversation?(session.messages) && session.last_prompt.to_s.strip.empty? &&
|
|
331
|
-
SessionManager.empty_session_dir?(Session.session_dir(session.id))
|
|
320
|
+
SessionManager.discardable?(session.id, default_model: @default_model_name, model_name: @effective_model_name,
|
|
321
|
+
used_memory_names: @engine.used_memory_names, unsaved: session)
|
|
332
322
|
end
|
|
333
323
|
|
|
334
324
|
# A session left empty: give it up and delete it, one quiet line.
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
require "fileutils"
|
|
5
5
|
require "digest"
|
|
6
6
|
require "date"
|
|
7
|
+
require_relative "../atomic_file"
|
|
7
8
|
require_relative "../memory_paths"
|
|
8
9
|
|
|
9
10
|
module Samagotchi
|
|
@@ -186,7 +187,7 @@ module Samagotchi
|
|
|
186
187
|
end
|
|
187
188
|
|
|
188
189
|
file_path = File.join(dir, "#{entry_name}.#{model_key}.md")
|
|
189
|
-
Samagotchi::
|
|
190
|
+
Samagotchi::AtomicFile.write(file_path, body)
|
|
190
191
|
bytes = body.bytesize
|
|
191
192
|
return "Model overlay '#{entry_name}' for #{model_key} saved to #{resolved_scope} scope (#{bytes} bytes). File written: #{file_path}"
|
|
192
193
|
end
|
|
@@ -195,7 +196,7 @@ module Samagotchi
|
|
|
195
196
|
if entry_name == MEMORY_INDEX
|
|
196
197
|
Samagotchi::MemoryBundle::IndexUpdater.locked_write(dir) { body }
|
|
197
198
|
else
|
|
198
|
-
Samagotchi::
|
|
199
|
+
Samagotchi::AtomicFile.write(file_path, body)
|
|
199
200
|
end
|
|
200
201
|
bytes = body.bytesize
|
|
201
202
|
message = "Memory '#{entry_name}' saved to #{resolved_scope} scope (#{bytes} bytes). File written: #{file_path}"
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "../session"
|
|
4
4
|
require_relative "../context_note"
|
|
5
|
+
require_relative "../session_inbox"
|
|
5
6
|
require_relative "peers"
|
|
6
7
|
|
|
7
8
|
module Samagotchi
|
|
@@ -33,13 +34,13 @@ module Samagotchi
|
|
|
33
34
|
return "Error: session #{id[0, 8]} is open in a chi REPL, which can't take notes"
|
|
34
35
|
end
|
|
35
36
|
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
SessionInbox.write_note(id, text: content, source: "session", from_session: peers.session_id,
|
|
38
|
+
from_cwd: peers.cwd, state_dir: peers.state_dir)
|
|
38
39
|
where = owner ? "its worker adds it before its next turn" : "it has no worker now, so it waits for its next start"
|
|
39
40
|
"Queued a note for session #{id[0, 8]}: #{where}. It does not start a turn there."
|
|
40
41
|
rescue Session::AmbiguousId => e
|
|
41
42
|
"Error: #{e.message}"
|
|
42
|
-
rescue
|
|
43
|
+
rescue SessionInbox::NoteRejected => e
|
|
43
44
|
"Error: #{e.message}"
|
|
44
45
|
rescue ArgumentError
|
|
45
46
|
"Error: no session #{session.to_s.strip} (list_sessions shows them)"
|
|
@@ -5,6 +5,7 @@ require "json"
|
|
|
5
5
|
require "securerandom"
|
|
6
6
|
require "shellwords"
|
|
7
7
|
require "time"
|
|
8
|
+
require_relative "../atomic_file"
|
|
8
9
|
|
|
9
10
|
module Samagotchi
|
|
10
11
|
module Tools
|
|
@@ -198,9 +199,7 @@ module Samagotchi
|
|
|
198
199
|
FileUtils.mkdir_p(dir)
|
|
199
200
|
|
|
200
201
|
path = File.join(dir, METADATA_FILENAME)
|
|
201
|
-
|
|
202
|
-
File.write(temp_path, JSON.pretty_generate(record) + "\n")
|
|
203
|
-
File.rename(temp_path, path)
|
|
202
|
+
AtomicFile.write(path, JSON.pretty_generate(record) + "\n")
|
|
204
203
|
end
|
|
205
204
|
|
|
206
205
|
def metadata_path(task_id)
|
data/lib/samagotchi/turn_note.rb
CHANGED
|
@@ -39,13 +39,24 @@ module Samagotchi
|
|
|
39
39
|
# model message precedes this note)
|
|
40
40
|
# @param running_tasks [Array<Hash>] {id:, command:} of this session's
|
|
41
41
|
# background tasks still running: a Stop doesn't end them
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
# @param stopped_by [Hash, nil] {by:, reason:} of a hook that stopped
|
|
43
|
+
# it (stop_turn, or a cut with no retry left)
|
|
44
|
+
def cancelled(reason, seconds: nil, shown: false, running_tasks: [], stopped_by: nil)
|
|
45
|
+
why = reason.to_s.empty? ? "" : " (#{reason.to_s.tr("_", "-")}#{stopper(stopped_by)})"
|
|
44
46
|
after = seconds ? " after #{seconds.round}s" : ""
|
|
45
47
|
what = shown ? "the answer above ends where it was cut off." : "no answer had been shown."
|
|
46
48
|
message("the previous turn was cancelled#{why}#{after}; #{what}#{still_running(running_tasks)}")
|
|
47
49
|
end
|
|
48
50
|
|
|
51
|
+
# " <by>: <reason>" of a hook that stopped the turn, as far as known.
|
|
52
|
+
def stopper(stopped_by)
|
|
53
|
+
return "" unless stopped_by.is_a?(Hash)
|
|
54
|
+
|
|
55
|
+
by = one_line(stopped_by[:by])
|
|
56
|
+
reason = one_line(stopped_by[:reason])
|
|
57
|
+
[by.empty? ? nil : " #{by}", reason.empty? ? nil : ": #{reason}"].compact.join
|
|
58
|
+
end
|
|
59
|
+
|
|
49
60
|
def still_running(tasks)
|
|
50
61
|
return "" if tasks.empty?
|
|
51
62
|
|
|
@@ -62,7 +62,7 @@ module Samagotchi
|
|
|
62
62
|
@stderr = stderr
|
|
63
63
|
@gem_spec = gem_spec
|
|
64
64
|
@shipped_dir = shipped_dir
|
|
65
|
-
@platform = platform || ->(register:) { Desktop::MacOS.new(register: register) }
|
|
65
|
+
@platform = platform || ->(register:) { Desktop::MacOS.new(register: register, kitty: Desktop::MacOS.kitty_settings) }
|
|
66
66
|
@supported = supported
|
|
67
67
|
@state_dir = state_dir
|
|
68
68
|
@web = web || [Web::Lan.local_host(Config.get("web.host")), Config.get("web.port").to_i]
|
|
@@ -192,7 +192,7 @@ module Samagotchi
|
|
|
192
192
|
row = Row.new(component: update.name, from: update.from, to: update.to)
|
|
193
193
|
case update.status
|
|
194
194
|
when :would_update, :updated
|
|
195
|
-
notes = [kept(update.kept, update.name), replaced(update.replaced)].compact
|
|
195
|
+
notes = [adds(update.adds), kept(update.kept, update.name), replaced(update.replaced)].compact
|
|
196
196
|
done(row, :update, notes.empty? ? nil : notes.join("; "))
|
|
197
197
|
when :up_to_date then up_to_date(row)
|
|
198
198
|
when :skipped then skipped(row, update.note)
|
|
@@ -247,7 +247,9 @@ module Samagotchi
|
|
|
247
247
|
def footers(options)
|
|
248
248
|
return [] if off(options, "bundles")
|
|
249
249
|
|
|
250
|
-
names = MemoryBundle::ShippedUpdate.not_installed(shipped_dir: @shipped_dir).map
|
|
250
|
+
names = MemoryBundle::ShippedUpdate.not_installed(shipped_dir: @shipped_dir).map do |s|
|
|
251
|
+
s.includes.empty? ? s.source : "#{s.source} (#{s.includes.join(", ")})"
|
|
252
|
+
end
|
|
251
253
|
names.empty? ? [] : ["Also shipped, not installed: #{names.join(", ")} (chi bundle install NAME)"]
|
|
252
254
|
end
|
|
253
255
|
|
|
@@ -284,6 +286,11 @@ module Samagotchi
|
|
|
284
286
|
row
|
|
285
287
|
end
|
|
286
288
|
|
|
289
|
+
# A profile's new members: "+ btw".
|
|
290
|
+
def adds(members)
|
|
291
|
+
members.nil? || members.empty? ? nil : "+ #{members.join(", ")}"
|
|
292
|
+
end
|
|
293
|
+
|
|
287
294
|
def kept(files, bundle)
|
|
288
295
|
return nil if files.empty?
|
|
289
296
|
|