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
|
@@ -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
|