samagotchi 0.7.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 +59 -1
- data/README.md +7 -3
- data/docs/architecture.md +4 -4
- data/docs/cli.md +19 -4
- 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 +6 -11
- data/lib/samagotchi/context_note.rb +1 -1
- data/lib/samagotchi/context_status.rb +255 -0
- data/lib/samagotchi/desktop/macos.rb +2 -3
- 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 +9 -2
- 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 +13 -1
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "digest"
|
|
4
|
-
require "json"
|
|
5
3
|
require_relative "installer"
|
|
6
4
|
require_relative "listing"
|
|
7
5
|
require_relative "manifest"
|
|
8
6
|
require_relative "merger"
|
|
7
|
+
require_relative "profile"
|
|
9
8
|
require_relative "provenance"
|
|
10
9
|
require_relative "source"
|
|
11
10
|
require_relative "status"
|
|
@@ -26,13 +25,18 @@ module Samagotchi
|
|
|
26
25
|
# user's file (kept). Hooks, rules and the plugin are replaced; an
|
|
27
26
|
# edited one (which didn't load: its sha no longer matched) is listed
|
|
28
27
|
# in replaced.
|
|
28
|
+
#
|
|
29
|
+
# A profile (a shipped meta bundle) is updated through Profile: its row
|
|
30
|
+
# is :would_update when the shipped version is newer or it has members
|
|
31
|
+
# to install (Profile.to_install, less the ones this chi can't load),
|
|
32
|
+
# even at the same version, so a member that failed is retried. adds
|
|
33
|
+
# names them.
|
|
29
34
|
module ShippedUpdate
|
|
30
35
|
# status: :would_update (plan), :updated, :up_to_date, :skipped,
|
|
31
36
|
# :failed. note says why for skipped/failed. kept and replaced are
|
|
32
37
|
# file names; under plan they are what apply would keep or replace.
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
SHIPPED_SOURCE = %r{/lib/samagotchi/bundles/[^/]+/?\z}
|
|
38
|
+
# adds is nil but for a profile: the members it installs.
|
|
39
|
+
Row = Struct.new(:name, :from, :to, :status, :note, :kept, :replaced, :source_dir, :scope, :adds, keyword_init: true)
|
|
36
40
|
|
|
37
41
|
module_function
|
|
38
42
|
|
|
@@ -40,16 +44,16 @@ module Samagotchi
|
|
|
40
44
|
# @return [Array<Row>] by name
|
|
41
45
|
def plan(shipped_dir: SourceNormalizer::SHIPPED_DIR, chi_version: Samagotchi::VERSION)
|
|
42
46
|
shipped = Listing.shipped(dir: shipped_dir).to_h { |s| [s.name, s] }
|
|
43
|
-
|
|
44
|
-
plan_row(name, shipped[name], shipped_dir, chi_version)
|
|
47
|
+
installed.map do |name, data|
|
|
48
|
+
plan_row(name, data, shipped[name], shipped_dir, chi_version)
|
|
45
49
|
end
|
|
46
50
|
end
|
|
47
51
|
|
|
48
|
-
# The shipped bundles not installed (what the footer lists)
|
|
52
|
+
# The shipped bundles not installed (what the footer lists), a
|
|
53
|
+
# profile's members folded into it (Listing.grouped).
|
|
49
54
|
# @return [Array<Listing::Shipped>]
|
|
50
55
|
def not_installed(shipped_dir: SourceNormalizer::SHIPPED_DIR)
|
|
51
|
-
|
|
52
|
-
Listing.shipped(dir: shipped_dir).reject { |s| names.include?(s.name) }
|
|
56
|
+
Listing.grouped(Listing.shipped(dir: shipped_dir), installed.map(&:first))
|
|
53
57
|
end
|
|
54
58
|
|
|
55
59
|
# Runs the Installer for each :would_update row; the others pass
|
|
@@ -58,6 +62,7 @@ module Samagotchi
|
|
|
58
62
|
def apply(rows)
|
|
59
63
|
rows.map do |row|
|
|
60
64
|
next row unless row.status == :would_update
|
|
65
|
+
next apply_profile(row) if row.adds
|
|
61
66
|
|
|
62
67
|
installer = Installer.new(source: row.source_dir, name: row.name, scope: row.scope, strict: true, upgrade: true)
|
|
63
68
|
installer.run
|
|
@@ -73,32 +78,35 @@ module Samagotchi
|
|
|
73
78
|
end
|
|
74
79
|
end
|
|
75
80
|
|
|
76
|
-
def
|
|
77
|
-
|
|
78
|
-
|
|
81
|
+
def apply_profile(row)
|
|
82
|
+
result = Profile.install(row.source_dir, shipped_dir: File.dirname(row.source_dir))
|
|
83
|
+
row.dup.tap do |r|
|
|
84
|
+
r.adds = result.installed
|
|
85
|
+
r.status = result.failed? ? :failed : :updated
|
|
86
|
+
r.note = result.failed.map { |member, why| "#{member}: #{why}" }.join("; ") if result.failed?
|
|
87
|
+
end
|
|
88
|
+
end
|
|
79
89
|
|
|
80
|
-
|
|
81
|
-
|
|
90
|
+
# [name, data] for each installed bundle but the system one, by name.
|
|
91
|
+
def installed
|
|
92
|
+
Provenance.each_installed.reject { |name, _| name == SystemBundle::BUNDLE_NAME }
|
|
82
93
|
end
|
|
83
94
|
|
|
84
|
-
def plan_row(name, ship, shipped_dir, chi_version)
|
|
95
|
+
def plan_row(name, data, ship, shipped_dir, chi_version)
|
|
85
96
|
row = Row.new(name: name, kept: [], replaced: [])
|
|
86
|
-
|
|
87
|
-
Provenance.new(name: name).read
|
|
88
|
-
rescue JSON::ParserError, SystemCallError
|
|
89
|
-
nil
|
|
90
|
-
end
|
|
91
|
-
return skip(row, "manifest.json unreadable") unless data.is_a?(Hash)
|
|
97
|
+
return skip(row, "manifest.json unreadable") if data[:error]
|
|
92
98
|
|
|
93
99
|
row.from = data[:version].to_s
|
|
94
100
|
row.scope = data[:scope].to_s.empty? ? "system" : data[:scope].to_s
|
|
95
|
-
return skip(row, "not from chi") unless ship && data[:source].to_s.match?(SHIPPED_SOURCE)
|
|
101
|
+
return skip(row, "not from chi") unless ship && data[:source].to_s.match?(SourceNormalizer::SHIPPED_SOURCE)
|
|
96
102
|
|
|
97
103
|
row.to = ship.version.to_s
|
|
98
104
|
return skip(row, "newer than shipped: left") if Listing.newer?(row.from, row.to)
|
|
99
|
-
return row.tap { |r| r.status = :up_to_date; r.to = nil } unless Listing.newer?(row.to, row.from)
|
|
100
105
|
|
|
101
106
|
row.source_dir = File.join(shipped_dir, ship.source)
|
|
107
|
+
return profile_row(row, data, shipped_dir, chi_version) if Profile.shipped_meta?(row.source_dir, shipped_dir: shipped_dir)
|
|
108
|
+
return row.tap { |r| r.status = :up_to_date; r.to = nil } unless Listing.newer?(row.to, row.from)
|
|
109
|
+
|
|
102
110
|
manifest = Manifest.read(dir: row.source_dir)
|
|
103
111
|
if Manifest.requires_chi_failure(manifest.requires_chi, chi_version)
|
|
104
112
|
return skip(row, "needs chi #{manifest.requires_chi}", keep_to: true)
|
|
@@ -111,6 +119,23 @@ module Samagotchi
|
|
|
111
119
|
row
|
|
112
120
|
end
|
|
113
121
|
|
|
122
|
+
def profile_row(row, data, shipped_dir, chi_version)
|
|
123
|
+
manifest = Manifest.read(dir: row.source_dir)
|
|
124
|
+
newer = Listing.newer?(row.to, row.from)
|
|
125
|
+
if newer && Manifest.requires_chi_failure(manifest.requires_chi, chi_version)
|
|
126
|
+
return skip(row, "needs chi #{manifest.requires_chi}", keep_to: true)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
row.adds = Profile.to_install(manifest, data).reject do |member|
|
|
130
|
+
Manifest.requires_chi_failure(Manifest.read(dir: File.join(shipped_dir, member)).requires_chi, chi_version)
|
|
131
|
+
rescue Manifest::ValidationError, Psych::Exception
|
|
132
|
+
false # apply reports it failing
|
|
133
|
+
end
|
|
134
|
+
row.to = nil unless newer
|
|
135
|
+
row.status = newer || row.adds.any? ? :would_update : :up_to_date
|
|
136
|
+
row
|
|
137
|
+
end
|
|
138
|
+
|
|
114
139
|
def skip(row, note, keep_to: false)
|
|
115
140
|
row.to = nil unless keep_to
|
|
116
141
|
row.status = :skipped
|
|
@@ -147,10 +172,10 @@ module Samagotchi
|
|
|
147
172
|
end
|
|
148
173
|
|
|
149
174
|
def edited?(path, meta)
|
|
150
|
-
recorded = meta.is_a?(Hash) ? meta[:sha256]
|
|
151
|
-
return false if recorded.empty? || !File.file?(path)
|
|
175
|
+
recorded = meta.is_a?(Hash) ? meta[:sha256] : nil
|
|
176
|
+
return false if Provenance.recorded_sha(recorded).empty? || !File.file?(path)
|
|
152
177
|
|
|
153
|
-
|
|
178
|
+
!Provenance.sha_matches?(path, recorded)
|
|
154
179
|
end
|
|
155
180
|
end
|
|
156
181
|
end
|
|
@@ -38,6 +38,8 @@ module Samagotchi
|
|
|
38
38
|
|
|
39
39
|
# The bundles shipped with chi (lib/samagotchi/bundles/<name>).
|
|
40
40
|
SHIPPED_DIR = File.expand_path("../bundles", __dir__)
|
|
41
|
+
# A provenance source that is a shipped dir (in a gem or a checkout).
|
|
42
|
+
SHIPPED_SOURCE = %r{/lib/samagotchi/bundles/[^/]+/?\z}
|
|
41
43
|
|
|
42
44
|
# A bare name (no path separator) that isn't a path here names a
|
|
43
45
|
# shipped bundle: `chi bundle install guardrails`. A directory or file
|
|
@@ -71,9 +71,8 @@ module Samagotchi
|
|
|
71
71
|
path = provenance.plugin_path(data)
|
|
72
72
|
return nil unless path
|
|
73
73
|
|
|
74
|
-
recorded = data[:plugin][:sha256].to_s.delete_prefix("sha256:")
|
|
75
74
|
state = if !File.file?(path) then "missing"
|
|
76
|
-
elsif
|
|
75
|
+
elsif Provenance.sha_matches?(path, data[:plugin][:sha256]) then "ok"
|
|
77
76
|
else "modified"
|
|
78
77
|
end
|
|
79
78
|
{ file: File.basename(path), path: path, state: state, requires_chi: data[:requires_chi],
|
|
@@ -11,6 +11,7 @@ require_relative "memory_bundle/status"
|
|
|
11
11
|
require_relative "memory_bundle/system_bundle"
|
|
12
12
|
require_relative "memory_bundle/builder"
|
|
13
13
|
require_relative "memory_bundle/listing"
|
|
14
|
+
require_relative "memory_bundle/profile"
|
|
14
15
|
module Samagotchi
|
|
15
16
|
# Namespace for shareable memory bundle functionality.
|
|
16
17
|
module MemoryBundle; end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "session"
|
|
4
|
+
require_relative "session_inbox"
|
|
4
5
|
require_relative "session_manager"
|
|
5
6
|
|
|
6
7
|
module Samagotchi
|
|
@@ -40,8 +41,8 @@ module Samagotchi
|
|
|
40
41
|
return 2
|
|
41
42
|
end
|
|
42
43
|
begin
|
|
43
|
-
text =
|
|
44
|
-
rescue
|
|
44
|
+
text = SessionInbox.checked_text(text)
|
|
45
|
+
rescue SessionInbox::NoteRejected => e
|
|
45
46
|
@stderr.puts("chi note: #{e.message}")
|
|
46
47
|
return 1
|
|
47
48
|
end
|
|
@@ -131,11 +132,11 @@ module Samagotchi
|
|
|
131
132
|
return false
|
|
132
133
|
end
|
|
133
134
|
|
|
134
|
-
path =
|
|
135
|
+
path = SessionInbox.write_note(id, text: text, source: source, state_dir: @state_dir)
|
|
135
136
|
if owner
|
|
136
137
|
@stdout.puts("#{short} queued: its worker adds it within a few seconds")
|
|
137
138
|
else
|
|
138
|
-
queued =
|
|
139
|
+
queued = SessionInbox.find_new_note_files(File.dirname(File.dirname(path))).size
|
|
139
140
|
@stdout.puts("#{short} waits for the session's next start (#{queued} #{queued == 1 ? "note" : "notes"} queued)")
|
|
140
141
|
end
|
|
141
142
|
true
|
|
@@ -65,12 +65,6 @@ module Samagotchi
|
|
|
65
65
|
nil
|
|
66
66
|
end
|
|
67
67
|
|
|
68
|
-
# @return [Boolean] whether this session has ever had a lock-taking owner
|
|
69
|
-
# (workers from before the lock only left a pid file)
|
|
70
|
-
def self.lock_file?(session_dir)
|
|
71
|
-
File.exist?(path(session_dir))
|
|
72
|
-
end
|
|
73
|
-
|
|
74
68
|
def self.path(session_dir)
|
|
75
69
|
File.join(session_dir, FILE)
|
|
76
70
|
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# The XDG base directories chi keeps its files under, read from +env+ on
|
|
5
|
+
# every call (specs and smoke runs point XDG_* elsewhere after load):
|
|
6
|
+
# state: $XDG_STATE_HOME or ~/.local/state (sessions, logs, history, plugin data)
|
|
7
|
+
# config: $XDG_CONFIG_HOME or ~/.config (config.yml, memories, hooks)
|
|
8
|
+
# A blank variable counts as unset. The samagotchi folder inside each is
|
|
9
|
+
# #state_dir / ConfigFile.config_dir.
|
|
10
|
+
module Paths
|
|
11
|
+
APP_DIR = "samagotchi"
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# @return [String] $XDG_STATE_HOME, else ~/.local/state
|
|
16
|
+
def state_home(env: ENV)
|
|
17
|
+
xdg = env.fetch("XDG_STATE_HOME", "").to_s.strip
|
|
18
|
+
xdg.empty? ? File.join(Dir.home, ".local", "state") : xdg
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @return [String] <state home>/samagotchi
|
|
22
|
+
def state_dir(env: ENV)
|
|
23
|
+
File.join(state_home(env: env), APP_DIR)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [String] $XDG_CONFIG_HOME, else ~/.config
|
|
27
|
+
def config_home(env: ENV)
|
|
28
|
+
xdg = env.fetch("XDG_CONFIG_HOME", "").to_s.strip
|
|
29
|
+
xdg.empty? ? File.expand_path("~/.config") : xdg
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../log"
|
|
4
|
+
require_relative "../hooks/registry"
|
|
4
5
|
require_relative "../tools/args"
|
|
5
6
|
require_relative "service"
|
|
6
7
|
require_relative "tool_result"
|
|
@@ -14,7 +15,6 @@ module Samagotchi
|
|
|
14
15
|
COMMAND_NAME = %r{\A/[a-z][a-z0-9_-]{0,31}\z}
|
|
15
16
|
TOOL_NAME = /\A[a-z][a-z0-9_]{0,47}\z/
|
|
16
17
|
PARAM_NAME = /\A[a-z_][a-z0-9_]*\z/i
|
|
17
|
-
FAILED_STREAM_HOOK_LOG_SECONDS = 60
|
|
18
18
|
|
|
19
19
|
# @param bundle [String]
|
|
20
20
|
# @param label [String] "<file> (bundle <name>)": what hooks and
|
|
@@ -224,16 +224,13 @@ module Samagotchi
|
|
|
224
224
|
block = hook[:block]
|
|
225
225
|
context = @context
|
|
226
226
|
# A stream hook fires ~once a second: a broken one logs once a minute.
|
|
227
|
-
|
|
228
|
-
logged_at = nil
|
|
227
|
+
log_failure = Hooks.failure_log_gate(hook[:event])
|
|
229
228
|
@registries.hooks.register_bundle(bundle, hook[:event], hook_name: label.split(" ").first,
|
|
230
229
|
priority: hook[:priority]) do |event|
|
|
231
230
|
block.call(event, context)
|
|
232
231
|
rescue StandardError => e
|
|
233
|
-
|
|
234
|
-
next if logged_at && now - logged_at < quiet_for
|
|
232
|
+
next unless log_failure.call
|
|
235
233
|
|
|
236
|
-
logged_at = now
|
|
237
234
|
# Logged only: a turn's live region is on screen.
|
|
238
235
|
Log.warn(:plugins, "plugin_hook_failed", bundle: bundle, event: hook[:event].to_s, error: e.class.name,
|
|
239
236
|
msg: "#{label} #{hook[:event]} hook failed: #{e.message}")
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
require_relative "../log"
|
|
5
|
+
require_relative "../paths"
|
|
5
6
|
require_relative "../guardrails/context"
|
|
6
7
|
require_relative "../idle_client"
|
|
7
8
|
require_relative "side_question"
|
|
@@ -85,9 +86,7 @@ module Samagotchi
|
|
|
85
86
|
# @return [String]
|
|
86
87
|
def data_dir
|
|
87
88
|
@data_dir ||= begin
|
|
88
|
-
|
|
89
|
-
base = xdg.empty? ? File.join(Dir.home, ".local", "state") : xdg
|
|
90
|
-
File.join(base, "samagotchi", "plugins", @bundle).tap { |dir| FileUtils.mkdir_p(dir) }
|
|
89
|
+
File.join(Paths.state_dir(env: @env), "plugins", @bundle).tap { |dir| FileUtils.mkdir_p(dir) }
|
|
91
90
|
end
|
|
92
91
|
end
|
|
93
92
|
|
|
@@ -44,7 +44,7 @@ module Samagotchi
|
|
|
44
44
|
# @return [Array<String>] the bundles whose plugin loaded
|
|
45
45
|
def load_installed(registries, failures: nil, settings: {})
|
|
46
46
|
loaded = []
|
|
47
|
-
MemoryBundle::Provenance.
|
|
47
|
+
MemoryBundle::Provenance.each_installed(holding: :plugin) do |bundle_name, data|
|
|
48
48
|
ok = load_bundle(bundle_name, data, registries, failures: failures, settings: settings[bundle_name.to_s] || {})
|
|
49
49
|
loaded << bundle_name if ok
|
|
50
50
|
end
|
|
@@ -21,8 +21,9 @@ module Samagotchi
|
|
|
21
21
|
# of ArgumentError for existing callers; transports map it to 409 Conflict.
|
|
22
22
|
class NotPending < ArgumentError; end
|
|
23
23
|
|
|
24
|
-
DISMISSED_NOTE = "The user dismissed the question without answering.
|
|
25
|
-
"or
|
|
24
|
+
DISMISSED_NOTE = "The user dismissed the question without answering. Don't do what you asked about, " \
|
|
25
|
+
"or anything else that changes files or state. Finish your reply with what you found " \
|
|
26
|
+
"and what you would do, and wait."
|
|
26
27
|
|
|
27
28
|
# @param session [#call] → Session, nil
|
|
28
29
|
# @param state_dir [#call] → String, the session's state dir
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "fileutils"
|
|
5
|
+
require_relative "atomic_file"
|
|
5
6
|
|
|
6
7
|
require_relative "session"
|
|
7
8
|
|
|
@@ -54,8 +55,7 @@ module Samagotchi
|
|
|
54
55
|
dir = Session.session_dir(id, state_dir: state_dir)
|
|
55
56
|
FileUtils.mkdir_p(dir)
|
|
56
57
|
path = File.join(dir, FILE)
|
|
57
|
-
|
|
58
|
-
File.rename("#{path}.tmp", path)
|
|
58
|
+
AtomicFile.write(path, JSON.generate(state))
|
|
59
59
|
end
|
|
60
60
|
|
|
61
61
|
# Remove the saved recap (it no longer describes the session).
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "session"
|
|
4
4
|
require_relative "turn_note"
|
|
5
|
+
require_relative "session_inbox"
|
|
5
6
|
|
|
6
7
|
module Samagotchi
|
|
7
8
|
# Loaded on first use: session_manager requires terminal_ui, which
|
|
@@ -165,7 +166,7 @@ module Samagotchi
|
|
|
165
166
|
end
|
|
166
167
|
|
|
167
168
|
def reply_dir(id, state_dir:)
|
|
168
|
-
File.join(Session.session_dir(id, state_dir: state_dir),
|
|
169
|
+
File.join(Session.session_dir(id, state_dir: state_dir), SessionInbox::OUTPUT_DIR)
|
|
169
170
|
end
|
|
170
171
|
|
|
171
172
|
def newer?(file, cursor)
|
|
@@ -215,9 +215,8 @@ module Samagotchi
|
|
|
215
215
|
# "samagotchi-system 0.1.5 (shipped 0.1.5), other 1.0.0"
|
|
216
216
|
def bundles_summary
|
|
217
217
|
shipped = MemoryBundle::Manifest.read(dir: MemoryBundle::SystemBundle::GEM_BUNDLE_DIR).version rescue nil
|
|
218
|
-
installed =
|
|
219
|
-
|
|
220
|
-
version = (JSON.parse(File.read(path))["version"] rescue nil) || "?"
|
|
218
|
+
installed = MemoryBundle::Provenance.each_installed.map do |name, data|
|
|
219
|
+
version = data[:version] || "?"
|
|
221
220
|
label = "#{name} #{version}"
|
|
222
221
|
label += " (shipped #{shipped})" if name == MemoryBundle::SystemBundle::BUNDLE_NAME
|
|
223
222
|
label
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "session"
|
|
4
|
+
require_relative "session_inbox"
|
|
4
5
|
require_relative "session_manager"
|
|
5
6
|
require_relative "context_quote"
|
|
6
7
|
require_relative "reply_wait"
|
|
@@ -122,8 +123,8 @@ module Samagotchi
|
|
|
122
123
|
return 2
|
|
123
124
|
end
|
|
124
125
|
begin
|
|
125
|
-
prompt =
|
|
126
|
-
rescue
|
|
126
|
+
prompt = SessionInbox.checked_text(prompt, noun: "message")
|
|
127
|
+
rescue SessionInbox::NoteRejected => e
|
|
127
128
|
@stderr.puts("chi send: #{e.message}")
|
|
128
129
|
return 1
|
|
129
130
|
end
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -4,7 +4,9 @@ require "fileutils"
|
|
|
4
4
|
require "json"
|
|
5
5
|
require "securerandom"
|
|
6
6
|
require "time"
|
|
7
|
+
require_relative "atomic_file"
|
|
7
8
|
|
|
9
|
+
require_relative "paths"
|
|
8
10
|
require_relative "project_scope"
|
|
9
11
|
|
|
10
12
|
module Samagotchi
|
|
@@ -22,14 +24,6 @@ module Samagotchi
|
|
|
22
24
|
STATUS_ERROR = "error"
|
|
23
25
|
STATUS_STOPPED = "stopped"
|
|
24
26
|
|
|
25
|
-
# Retention defaults, the session.retention_days / max_count /
|
|
26
|
-
# keep_status defaults (SessionManager resolves the settings).
|
|
27
|
-
DEFAULT_RETENTION_DAYS = 14
|
|
28
|
-
DEFAULT_MAX_COUNT = 500
|
|
29
|
-
# status is turn state; a live owner (the prune alive_check) is what
|
|
30
|
-
# protects a session in use, so no status is kept by default: a
|
|
31
|
-
# "running" left by a crashed worker must not keep it forever.
|
|
32
|
-
DEFAULT_KEEP_STATUS = [].freeze
|
|
33
27
|
SORT_KEYS = %w[created_at updated_at].freeze
|
|
34
28
|
SORT_ORDERS = %w[asc desc].freeze
|
|
35
29
|
|
|
@@ -146,7 +140,7 @@ module Samagotchi
|
|
|
146
140
|
# Load a persisted session by its UUID.
|
|
147
141
|
# Whether +session_id+ has a saved session.
|
|
148
142
|
def self.exist?(session_id, state_dir: default_state_dir)
|
|
149
|
-
File.exist?(session_path(session_id, state_dir: state_dir))
|
|
143
|
+
valid_id?(session_id) && File.exist?(session_path(session_id, state_dir: state_dir))
|
|
150
144
|
end
|
|
151
145
|
|
|
152
146
|
def self.load(session_id, state_dir: default_state_dir)
|
|
@@ -192,7 +186,7 @@ module Samagotchi
|
|
|
192
186
|
# names several sessions raises AmbiguousId, which lists them.
|
|
193
187
|
def self.resolve_id(id_or_prefix, state_dir: default_state_dir)
|
|
194
188
|
id = id_or_prefix.to_s
|
|
195
|
-
return id unless
|
|
189
|
+
return id unless valid_id?(id) && !File.exist?(session_path(id, state_dir: state_dir))
|
|
196
190
|
|
|
197
191
|
matches = Dir.glob(File.join(state_dir, "#{id}*#{FILE_EXT}")).map { |path| File.basename(path, FILE_EXT) }.sort
|
|
198
192
|
return matches.fetch(0, id) if matches.size <= 1
|
|
@@ -253,6 +247,9 @@ module Samagotchi
|
|
|
253
247
|
# required field, or gone
|
|
254
248
|
def self.summary_from_file(path)
|
|
255
249
|
data = JSON.parse(File.read(path))
|
|
250
|
+
# An id that isn't one (hand-edited, or not ours) would be a path.
|
|
251
|
+
return nil unless valid_id?(data["id"])
|
|
252
|
+
|
|
256
253
|
used_mems = data["used_memory_names"] || data["used_memories"] || []
|
|
257
254
|
pending = data["pending_question"]
|
|
258
255
|
new(
|
|
@@ -281,130 +278,6 @@ module Samagotchi
|
|
|
281
278
|
nil
|
|
282
279
|
end
|
|
283
280
|
|
|
284
|
-
# Prune old sessions according to retention policy.
|
|
285
|
-
#
|
|
286
|
-
# Only deletes when the json file exists — orphan dirs without json are never removed.
|
|
287
|
-
# Honors keep_status and live-worker guard.
|
|
288
|
-
# A session is kept only if it is NOT expired by age AND within max_count;
|
|
289
|
-
# either expiry or overflow triggers deletion (unless protected).
|
|
290
|
-
#
|
|
291
|
-
# +empty_check+ (id → Boolean) marks a session left empty: it goes
|
|
292
|
-
# whatever its age and the count (SessionManager.prune_sessions).
|
|
293
|
-
#
|
|
294
|
-
# +any_age+ makes every session eligible, whatever its age and the
|
|
295
|
-
# count (`chi sessions clean` with no --days: test runs are throwaway).
|
|
296
|
-
#
|
|
297
|
-
# A scratch session nobody owns (its REPL was killed) goes whatever its
|
|
298
|
-
# age, its status and the count; +test_only+ takes it too.
|
|
299
|
-
#
|
|
300
|
-
# +max_count+ counts the sessions that stay: one deleted whatever its
|
|
301
|
-
# place (scratch, left empty, expired, +any_age+) takes no slot.
|
|
302
|
-
#
|
|
303
|
-
# Archived sessions are not in .list, so they are neither deleted nor
|
|
304
|
-
# counted. One unarchived is aged from when it was unarchived, if later.
|
|
305
|
-
#
|
|
306
|
-
# @return [Hash] { deleted: [ids], kept: [ids], skipped: [ids] }
|
|
307
|
-
def self.prune(state_dir: default_state_dir, days: DEFAULT_RETENTION_DAYS, max_count: DEFAULT_MAX_COUNT,
|
|
308
|
-
keep_status: DEFAULT_KEEP_STATUS, dry_run: false, test_only: false, alive_check: nil,
|
|
309
|
-
empty_check: nil, any_age: false)
|
|
310
|
-
keep_status = Array(keep_status).map(&:to_s)
|
|
311
|
-
# Fetch all sessions sorted newest-first for count logic
|
|
312
|
-
all = list(state_dir: state_dir, sort: "updated_at", order: "desc")
|
|
313
|
-
# Filter test_only if requested
|
|
314
|
-
if test_only
|
|
315
|
-
all = all.select { |session| session.test_run || session.scratch }
|
|
316
|
-
end
|
|
317
|
-
|
|
318
|
-
now = Time.now
|
|
319
|
-
cutoff = days.to_i.positive? ? now - days.to_i * 86_400 : nil
|
|
320
|
-
max = max_count.to_i
|
|
321
|
-
|
|
322
|
-
deleted = []
|
|
323
|
-
kept = []
|
|
324
|
-
skipped = []
|
|
325
|
-
passed_over = 0
|
|
326
|
-
|
|
327
|
-
all.each_with_index do |session, idx|
|
|
328
|
-
path = File.join(state_dir, "#{session.id}#{FILE_EXT}")
|
|
329
|
-
# Only when json present
|
|
330
|
-
unless File.exist?(path)
|
|
331
|
-
skipped << session.id
|
|
332
|
-
next
|
|
333
|
-
end
|
|
334
|
-
|
|
335
|
-
# Protected by keep_status
|
|
336
|
-
if keep_status.include?(session.status.to_s) && !session.scratch
|
|
337
|
-
kept << session.id
|
|
338
|
-
next
|
|
339
|
-
end
|
|
340
|
-
|
|
341
|
-
# Protected by live worker
|
|
342
|
-
if alive_check
|
|
343
|
-
begin
|
|
344
|
-
if alive_check.call(session.id)
|
|
345
|
-
kept << session.id
|
|
346
|
-
next
|
|
347
|
-
end
|
|
348
|
-
rescue StandardError
|
|
349
|
-
nil
|
|
350
|
-
end
|
|
351
|
-
end
|
|
352
|
-
|
|
353
|
-
left_empty = begin
|
|
354
|
-
empty_check&.call(session.id)
|
|
355
|
-
rescue StandardError
|
|
356
|
-
false
|
|
357
|
-
end
|
|
358
|
-
|
|
359
|
-
# Determine expiry and overflow
|
|
360
|
-
expired = false
|
|
361
|
-
if cutoff
|
|
362
|
-
begin
|
|
363
|
-
updated = Time.iso8601(session.updated_at.to_s)
|
|
364
|
-
rescue ArgumentError
|
|
365
|
-
updated = File.mtime(path) rescue now
|
|
366
|
-
end
|
|
367
|
-
unarchived = ArchiveStore.unarchived_at(session_dir(session.id, state_dir: state_dir))
|
|
368
|
-
updated = unarchived if unarchived && unarchived > updated
|
|
369
|
-
expired = updated < cutoff
|
|
370
|
-
end
|
|
371
|
-
|
|
372
|
-
# A session deleted whatever its place (scratch, left empty,
|
|
373
|
-
# expired, any_age) takes no max_count slot: the count is of the
|
|
374
|
-
# sessions ahead that stay.
|
|
375
|
-
overflow = max.positive? && idx - passed_over >= max
|
|
376
|
-
passed_over += 1 if expired || left_empty || any_age || session.scratch
|
|
377
|
-
|
|
378
|
-
# retain forever when both disabled
|
|
379
|
-
if max.zero? && cutoff.nil? && !left_empty && !any_age && !session.scratch
|
|
380
|
-
kept << session.id
|
|
381
|
-
next
|
|
382
|
-
end
|
|
383
|
-
|
|
384
|
-
# If neither expired nor overflow, keep
|
|
385
|
-
unless expired || overflow || left_empty || any_age || session.scratch
|
|
386
|
-
kept << session.id
|
|
387
|
-
next
|
|
388
|
-
end
|
|
389
|
-
|
|
390
|
-
# Eligible for deletion
|
|
391
|
-
if dry_run
|
|
392
|
-
deleted << session.id
|
|
393
|
-
else
|
|
394
|
-
begin
|
|
395
|
-
FileUtils.rm_f(path)
|
|
396
|
-
sidecar = File.join(state_dir, session.id)
|
|
397
|
-
FileUtils.rm_rf(sidecar) if File.exist?(sidecar)
|
|
398
|
-
deleted << session.id
|
|
399
|
-
rescue StandardError
|
|
400
|
-
skipped << session.id
|
|
401
|
-
end
|
|
402
|
-
end
|
|
403
|
-
end
|
|
404
|
-
|
|
405
|
-
{ deleted: deleted, kept: kept, skipped: skipped }
|
|
406
|
-
end
|
|
407
|
-
|
|
408
281
|
# Persist the session atomically. Updates +updated_at+ in place.
|
|
409
282
|
def save(state_dir: self.class.default_state_dir)
|
|
410
283
|
@updated_at = Time.now.iso8601(3)
|
|
@@ -412,8 +285,7 @@ module Samagotchi
|
|
|
412
285
|
@metadata_version = METADATA_VERSION
|
|
413
286
|
FileUtils.mkdir_p(state_dir)
|
|
414
287
|
|
|
415
|
-
path =
|
|
416
|
-
temp_path = "#{path}.tmp"
|
|
288
|
+
path = self.class.session_file(@id, state_dir: state_dir)
|
|
417
289
|
|
|
418
290
|
# Auto-compute first_preview if not yet cached and messages contain a user entry.
|
|
419
291
|
compute_first_preview!
|
|
@@ -441,8 +313,7 @@ module Samagotchi
|
|
|
441
313
|
"last_turn" => @last_turn
|
|
442
314
|
}
|
|
443
315
|
|
|
444
|
-
|
|
445
|
-
File.rename(temp_path, path)
|
|
316
|
+
AtomicFile.write(path, JSON.pretty_generate(record) + "\n")
|
|
446
317
|
self
|
|
447
318
|
end
|
|
448
319
|
|
|
@@ -475,9 +346,38 @@ module Samagotchi
|
|
|
475
346
|
session.save(state_dir: state_dir)
|
|
476
347
|
end
|
|
477
348
|
|
|
349
|
+
# An id that is not a session id: it could name a path outside the
|
|
350
|
+
# sessions dir ("../x", "/etc", "a/b", a NUL), or is empty.
|
|
351
|
+
class InvalidId < ArgumentError; end
|
|
352
|
+
|
|
353
|
+
# Ids are SecureRandom.uuid; anything made of letters, digits, "-" and
|
|
354
|
+
# "_" (up to 128, not starting with "-") is taken, so a UUID prefix the
|
|
355
|
+
# CLI resolves passes too. This is the one check every id from outside
|
|
356
|
+
# (the bridge, the web routes, chi send/note, delegate) meets before it
|
|
357
|
+
# becomes a path: session_dir and the session file path refuse others.
|
|
358
|
+
VALID_ID = /\A[A-Za-z0-9_][A-Za-z0-9_-]{0,127}\z/
|
|
359
|
+
|
|
360
|
+
def self.valid_id?(session_id)
|
|
361
|
+
session_id.is_a?(String) && VALID_ID.match?(session_id)
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# @raise [InvalidId] unless valid_id?(session_id)
|
|
365
|
+
def self.check_id!(session_id)
|
|
366
|
+
return session_id if valid_id?(session_id)
|
|
367
|
+
|
|
368
|
+
raise InvalidId, "invalid session id: #{session_id.to_s[0, 80].inspect}"
|
|
369
|
+
end
|
|
370
|
+
|
|
478
371
|
# Directory for a specific session (holds IPC files alongside session.json).
|
|
372
|
+
# @raise [InvalidId] for an id that isn't one (valid_id?)
|
|
479
373
|
def self.session_dir(session_id, state_dir: default_state_dir)
|
|
480
|
-
File.join(state_dir, session_id)
|
|
374
|
+
File.join(state_dir, check_id!(session_id))
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# A session's <id>.json.
|
|
378
|
+
# @raise [InvalidId] for an id that isn't one (valid_id?)
|
|
379
|
+
def self.session_file(session_id, state_dir: default_state_dir)
|
|
380
|
+
File.join(state_dir, "#{check_id!(session_id)}#{FILE_EXT}")
|
|
481
381
|
end
|
|
482
382
|
|
|
483
383
|
# Default sessions directory path.
|
|
@@ -487,9 +387,7 @@ module Samagotchi
|
|
|
487
387
|
|
|
488
388
|
# XDG-aware sessions directory.
|
|
489
389
|
def self.default_state_dir(env: ENV)
|
|
490
|
-
|
|
491
|
-
base = xdg.empty? ? File.join(Dir.home, ".local", "state") : xdg
|
|
492
|
-
File.join(base, STATE_SUBDIR)
|
|
390
|
+
File.join(Paths.state_home(env: env), STATE_SUBDIR)
|
|
493
391
|
end
|
|
494
392
|
|
|
495
393
|
# Derive and cache the first user-message preview in the session record.
|
|
@@ -552,7 +450,7 @@ module Samagotchi
|
|
|
552
450
|
end
|
|
553
451
|
|
|
554
452
|
def session_path(session_id, state_dir:)
|
|
555
|
-
|
|
453
|
+
session_file(session_id, state_dir: state_dir)
|
|
556
454
|
end
|
|
557
455
|
end
|
|
558
456
|
end
|