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
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "time"
6
+ require "securerandom"
7
+ require_relative "atomic_file"
8
+ require_relative "session"
9
+
10
+ module Samagotchi
11
+ # A session directory's file inbox and outbox (see SessionManager for the
12
+ # layout): input/ holds one JSON file per queued turn, notes/ the context
13
+ # notes a worker adds between turns, output/ one text file per reply.
14
+ # Writers are any process (the web, chi send, chi note, send_note); the
15
+ # worker claims input and notes by renaming them to *.processing.
16
+ module SessionInbox
17
+ INPUT_DIR = "input"
18
+ # Context notes live apart from input/, so nothing that reads input/
19
+ # (the mid-turn drain, the idle-exit hold, the Waker) ever sees one.
20
+ NOTES_DIR = "notes"
21
+ NOTE_MAX_BYTES = 16 * 1024
22
+ OUTPUT_DIR = "output"
23
+ # Input-file format this worker reads (JSON with the sender's ids and
24
+ # images: refs), advertised in the Bridge sidecar so a chi from before
25
+ # it (one that wrote plain .txt to a worker not advertising 2, or held
26
+ # images back below 3) writes what this one reads.
27
+ INPUT_FORMAT = 3
28
+
29
+ # A note (or a `chi send` message) that can't go in: empty, or over
30
+ # NOTE_MAX_BYTES.
31
+ class NoteRejected < ArgumentError; end
32
+
33
+ # Queue a turn's input file: JSON with the prompt and the sender's ids.
34
+ # @param client_id [String, nil] the sending UI
35
+ # @param enqueued_id [String, nil] the id its ACK / :turn_enqueued carry
36
+ # @param images [Array<Hash>] image refs ({file:, name:}) in the
37
+ # session's images/
38
+ # @return [String, false] the input file's path, or false
39
+ def self.write_input(session_dir, prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, images: [])
40
+ images = Array(images)
41
+ input_dir = File.join(session_dir, INPUT_DIR)
42
+ FileUtils.mkdir_p(input_dir)
43
+
44
+ path = File.join(input_dir, "#{Time.now.strftime("%Y%m%d%H%M%S%9N")}.json")
45
+ record = { "prompt" => prompt.to_s, "client_id" => client_id, "enqueued_id" => enqueued_id,
46
+ "no_interrupt" => (no_interrupt ? true : nil),
47
+ "images" => (images.empty? ? nil : images.map { |image| image.transform_keys(&:to_s) }) }.compact
48
+ AtomicFile.write(path, JSON.generate(record))
49
+ path
50
+ rescue StandardError
51
+ false
52
+ end
53
+
54
+ # Queue a context note for a session: background text its worker adds
55
+ # to the conversation between turns. It never starts a turn.
56
+ # @param source [String] where it came from ("cli", "slack", "session")
57
+ # @param from_session [String, nil] the sending session, for a peer's note
58
+ # @return [String] the note file's path
59
+ # @raise [NoteRejected] for an empty note or one over 16 KiB (never cut)
60
+ def self.write_note(session_id, text:, source: "cli", from_session: nil, from_cwd: nil, state_dir: nil)
61
+ body = checked_text(text)
62
+ sd = state_dir || Session.default_state_dir
63
+ notes_dir = File.join(Session.session_dir(session_id, state_dir: sd), NOTES_DIR)
64
+ FileUtils.mkdir_p(notes_dir)
65
+
66
+ # The random part keeps two writers in one nanosecond apart; the
67
+ # timestamp keeps the names in arrival order.
68
+ name = "#{Time.now.strftime("%Y%m%d%H%M%S%9N")}-#{SecureRandom.hex(3)}.json"
69
+ path = File.join(notes_dir, name)
70
+ record = { "text" => body, "source" => source.to_s, "from_session" => from_session,
71
+ "from_cwd" => from_cwd, "created_at" => Time.now.iso8601 }.compact
72
+ AtomicFile.write(path, JSON.generate(record))
73
+ path
74
+ end
75
+
76
+ # The same empty and 16 KiB checks for a note and a sent message.
77
+ # @param noun [String] what the errors call the text
78
+ # @return [String] the stripped text
79
+ # @raise [NoteRejected]
80
+ def self.checked_text(text, noun: "note")
81
+ body = text.to_s.strip
82
+ raise NoteRejected, "the #{noun} is empty" if body.empty?
83
+ if body.bytesize > NOTE_MAX_BYTES
84
+ raise NoteRejected, "the #{noun} is #{body.bytesize} bytes; the limit is 16 KiB (#{NOTE_MAX_BYTES} bytes)"
85
+ end
86
+
87
+ body
88
+ end
89
+
90
+ # Queued notes, oldest first, plus any a crashed worker claimed and left
91
+ # (the absorber skips a note id the conversation already holds).
92
+ def self.find_new_note_files(session_dir)
93
+ notes_dir = File.join(session_dir, NOTES_DIR)
94
+ return [] unless Dir.exist?(notes_dir)
95
+
96
+ Dir.glob(File.join(notes_dir, "*.{json,json.processing}")).sort_by { |p| File.basename(p) }
97
+ end
98
+
99
+ # @return [String, nil] the claimed path, or nil when another claimed it
100
+ def self.claim_note_file(note_file)
101
+ return note_file if note_file.end_with?(".processing")
102
+
103
+ claim_input_file(note_file)
104
+ end
105
+
106
+ # @return [Hash, nil] {note_id:, text:, source:, from_session:, from_cwd:,
107
+ # created_at:}, or nil for a file that holds no usable note
108
+ def self.read_note(note_file)
109
+ data = JSON.parse(File.read(note_file))
110
+ text = data["text"].to_s.strip
111
+ return nil if text.empty?
112
+
113
+ { note_id: File.basename(note_file).sub(/\.json(\.processing)?\z/, ""), text: text,
114
+ source: data["source"].to_s.empty? ? "cli" : data["source"].to_s,
115
+ from_session: data["from_session"], from_cwd: data["from_cwd"], created_at: data["created_at"] }
116
+ rescue JSON::ParserError, SystemCallError, NoMethodError, TypeError
117
+ nil
118
+ end
119
+
120
+ def self.write_output(session_dir, response)
121
+ output_dir = File.join(session_dir, OUTPUT_DIR)
122
+ FileUtils.mkdir_p(output_dir)
123
+ timestamp = Time.now.strftime("%Y%m%d%H%M%S%9N")
124
+ AtomicFile.write(File.join(output_dir, "#{timestamp}.txt"), response.to_s)
125
+ end
126
+
127
+ # The output files' text, oldest first; with +since_time+ only files
128
+ # modified after it.
129
+ def self.read_outputs(session_dir, since_time: nil)
130
+ output_path = File.join(session_dir, OUTPUT_DIR)
131
+ return [] unless Dir.exist?(output_path)
132
+
133
+ Dir.glob(File.join(output_path, "*.txt")).sort.filter_map do |f|
134
+ File.read(f) if since_time.nil? || File.mtime(f) > since_time
135
+ end
136
+ end
137
+
138
+ def self.find_new_input_files(session_dir)
139
+ input_dir = File.join(session_dir, INPUT_DIR)
140
+ return [] unless Dir.exist?(input_dir)
141
+
142
+ Dir.glob(File.join(input_dir, "*.json"))
143
+ end
144
+
145
+ # @return [Array(String, Hash|nil, Boolean, Array<Hash>)] a claimed input
146
+ # file's prompt, origin ({client_id:, enqueued_id:}, nil when it names
147
+ # no sender), whether its turn runs with the raised iteration limit
148
+ # (--no-interrupt), and its image refs ({file:, name:})
149
+ def self.read_input(claimed_file)
150
+ data = JSON.parse(File.read(claimed_file).to_s)
151
+ origin = { client_id: data["client_id"], enqueued_id: data["enqueued_id"] }.compact
152
+ images = Array(data["images"]).select { |image| image.is_a?(Hash) }.map { |image| image.transform_keys(&:to_sym) }
153
+ [data["prompt"].to_s, origin.empty? ? nil : origin, data["no_interrupt"] == true, images]
154
+ rescue JSON::ParserError
155
+ [nil, nil, false, []]
156
+ end
157
+
158
+ # Whether an unclaimed input file carries images (a mid-turn drain
159
+ # leaves it for its own turn).
160
+ def self.input_has_images?(input_file)
161
+ data = JSON.parse(File.read(input_file))
162
+ data.is_a?(Hash) && Array(data["images"]).any?
163
+ rescue JSON::ParserError, SystemCallError
164
+ false
165
+ end
166
+
167
+ def self.claim_input_file(input_file)
168
+ processing_path = "#{input_file}.processing"
169
+ File.rename(input_file, processing_path)
170
+ processing_path
171
+ rescue Errno::ENOENT, Errno::EACCES
172
+ nil
173
+ end
174
+ end
175
+ end