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.
Files changed (96) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -1
  3. data/README.md +7 -3
  4. data/docs/architecture.md +4 -4
  5. data/docs/cli.md +19 -4
  6. data/docs/guardrails.md +2 -2
  7. data/docs/hooks.md +2 -2
  8. data/docs/internals/context-telemetry.md +3 -1
  9. data/docs/memory.md +48 -1
  10. data/docs/plugins.md +7 -5
  11. data/docs/releasing.md +5 -1
  12. data/docs/sessions.md +2 -2
  13. data/lib/samagotchi/archive_store.rb +3 -3
  14. data/lib/samagotchi/atomic_file.rb +34 -0
  15. data/lib/samagotchi/bootstrap/bundles.rb +37 -0
  16. data/lib/samagotchi/bootstrap/config_writer.rb +4 -6
  17. data/lib/samagotchi/bootstrap/probe.rb +9 -5
  18. data/lib/samagotchi/bootstrap_command.rb +78 -8
  19. data/lib/samagotchi/bridge/pending_card.rb +2 -3
  20. data/lib/samagotchi/bridge.rb +56 -116
  21. data/lib/samagotchi/bridge_client.rb +7 -11
  22. data/lib/samagotchi/bundle_command.rb +81 -21
  23. data/lib/samagotchi/bundle_needs.rb +2 -11
  24. data/lib/samagotchi/bundles/core/manifest.yml +11 -0
  25. data/lib/samagotchi/bundles/dev/manifest.yml +13 -0
  26. data/lib/samagotchi/bundles/system/identity.md +1 -0
  27. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  28. data/lib/samagotchi/bundles/system/memory_guide.md +3 -2
  29. data/lib/samagotchi/client.rb +3 -23
  30. data/lib/samagotchi/config.rb +6 -11
  31. data/lib/samagotchi/context_note.rb +1 -1
  32. data/lib/samagotchi/context_status.rb +255 -0
  33. data/lib/samagotchi/desktop/macos.rb +2 -3
  34. data/lib/samagotchi/engine.rb +6 -7
  35. data/lib/samagotchi/guardrail_wiring.rb +2 -5
  36. data/lib/samagotchi/guardrails/approvals.rb +2 -7
  37. data/lib/samagotchi/hooks/bundle_loader.rb +6 -3
  38. data/lib/samagotchi/hooks/loader.rb +2 -1
  39. data/lib/samagotchi/hooks/registry.rb +21 -0
  40. data/lib/samagotchi/image_store.rb +2 -3
  41. data/lib/samagotchi/kernel_loop.rb +21 -283
  42. data/lib/samagotchi/live_versions.rb +9 -13
  43. data/lib/samagotchi/llm/chat_loop.rb +1 -1
  44. data/lib/samagotchi/llm/native_backend.rb +8 -8
  45. data/lib/samagotchi/llm/openai_chat.rb +4 -17
  46. data/lib/samagotchi/llm/utf8_scrub.rb +32 -0
  47. data/lib/samagotchi/log_path.rb +2 -3
  48. data/lib/samagotchi/memory_bundle/index_updater.rb +2 -11
  49. data/lib/samagotchi/memory_bundle/installer.rb +5 -7
  50. data/lib/samagotchi/memory_bundle/listing.rb +27 -20
  51. data/lib/samagotchi/memory_bundle/manifest.rb +32 -1
  52. data/lib/samagotchi/memory_bundle/profile.rb +145 -0
  53. data/lib/samagotchi/memory_bundle/provenance.rb +42 -53
  54. data/lib/samagotchi/memory_bundle/shipped_update.rb +52 -27
  55. data/lib/samagotchi/memory_bundle/source.rb +2 -0
  56. data/lib/samagotchi/memory_bundle/status.rb +1 -2
  57. data/lib/samagotchi/memory_bundle.rb +1 -0
  58. data/lib/samagotchi/note_command.rb +5 -4
  59. data/lib/samagotchi/owner_lock.rb +0 -6
  60. data/lib/samagotchi/paths.rb +32 -0
  61. data/lib/samagotchi/plugin/api.rb +3 -6
  62. data/lib/samagotchi/plugin/context.rb +2 -3
  63. data/lib/samagotchi/plugin/loader.rb +1 -1
  64. data/lib/samagotchi/question_desk.rb +3 -2
  65. data/lib/samagotchi/recap_store.rb +2 -2
  66. data/lib/samagotchi/reply_wait.rb +2 -1
  67. data/lib/samagotchi/self_report.rb +2 -3
  68. data/lib/samagotchi/send_command.rb +3 -2
  69. data/lib/samagotchi/session.rb +41 -143
  70. data/lib/samagotchi/session_inbox.rb +175 -0
  71. data/lib/samagotchi/session_manager.rb +62 -363
  72. data/lib/samagotchi/session_metrics.rb +2 -3
  73. data/lib/samagotchi/session_retention.rb +259 -0
  74. data/lib/samagotchi/sessions_command.rb +3 -2
  75. data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -2
  76. data/lib/samagotchi/terminal_ui/input_support.rb +2 -9
  77. data/lib/samagotchi/terminal_ui.rb +10 -20
  78. data/lib/samagotchi/tools/memory.rb +3 -2
  79. data/lib/samagotchi/tools/send_note.rb +4 -3
  80. data/lib/samagotchi/tools/task_runtime.rb +2 -3
  81. data/lib/samagotchi/turn_note.rb +13 -2
  82. data/lib/samagotchi/update_command.rb +9 -2
  83. data/lib/samagotchi/version.rb +1 -1
  84. data/lib/samagotchi/web/app.rb +44 -76
  85. data/lib/samagotchi/web/info_probe.rb +33 -0
  86. data/lib/samagotchi/web/public/app.js +1 -1
  87. data/lib/samagotchi/web/public/index.html +1 -0
  88. data/lib/samagotchi/web/public/stage_model.js +22 -3
  89. data/lib/samagotchi/web/public/stage_view.js +25 -10
  90. data/lib/samagotchi/web/server.rb +8 -11
  91. data/lib/samagotchi/web/session_hub.rb +8 -7
  92. data/lib/samagotchi/web/session_summary.rb +4 -3
  93. data/lib/samagotchi/web/token.rb +2 -5
  94. data/lib/samagotchi/worker.rb +34 -20
  95. data/lib/samagotchi/worker_sidecar.rb +42 -0
  96. 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
- Row = Struct.new(:name, :from, :to, :status, :note, :kept, :replaced, :source_dir, :scope, keyword_init: true)
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
- installed_names.map do |name|
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
- names = installed_names
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 installed_names
77
- dir = Provenance.bundles_dir
78
- return [] unless File.directory?(dir)
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
- Dir.children(dir).reject { |e| e.start_with?(".") || e == SystemBundle::BUNDLE_NAME }
81
- .select { |e| File.file?(File.join(dir, e, "manifest.json")) }.sort
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
- data = begin
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].to_s.delete_prefix("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
- Digest::SHA256.hexdigest(File.binread(path)) != recorded
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 Digest::SHA256.hexdigest(File.binread(path)) == recorded then "ok"
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 = SessionManager.checked_text(text)
44
- rescue SessionManager::NoteRejected => e
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 = SessionManager.write_note(id, text: text, source: source, state_dir: @state_dir)
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 = SessionManager.find_new_note_files(File.dirname(File.dirname(path))).size
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
- quiet_for = hook[:event] == :generation_progress ? FAILED_STREAM_HOOK_LOG_SECONDS : 0
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
- now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
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
- xdg = @env.fetch("XDG_STATE_HOME", "").to_s.strip
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.each_installed_with_plugin do |bundle_name, data|
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. Go on with your best judgement, " \
25
- "or ask in your reply if you can't."
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
- File.write("#{path}.tmp", JSON.generate(state))
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), SessionManager::OUTPUT_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 = Dir[File.join(MemoryBundle::Provenance.bundles_dir, "*", "manifest.json")].sort.map do |path|
219
- name = File.basename(File.dirname(path))
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 = SessionManager.checked_text(prompt, noun: "message")
126
- rescue SessionManager::NoteRejected => e
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
@@ -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 id.match?(/\A[\w-]+\z/) && !File.exist?(session_path(id, state_dir: state_dir))
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 = File.join(state_dir, "#{@id}#{FILE_EXT}")
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
- File.write(temp_path, JSON.pretty_generate(record) + "\n")
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
- xdg = env.fetch("XDG_STATE_HOME", "").to_s.strip
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
- File.join(state_dir, "#{session_id}#{FILE_EXT}")
453
+ session_file(session_id, state_dir: state_dir)
556
454
  end
557
455
  end
558
456
  end