samagotchi 0.101.0 → 0.103.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 (111) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +67 -1
  3. data/bin/chi +31 -196
  4. data/docs/architecture.md +62 -6
  5. data/docs/cli.md +154 -7
  6. data/docs/configuration.md +32 -5
  7. data/docs/guardrails.md +2 -1
  8. data/docs/internals/bundles.md +1 -1
  9. data/docs/internals/engine.md +1 -1
  10. data/docs/internals/llm-context-forget.md +25 -1
  11. data/docs/internals/prompt-caching.md +8 -1
  12. data/docs/internals/remote-sessions.md +303 -0
  13. data/docs/internals/working-set.md +174 -15
  14. data/docs/plugins.md +32 -5
  15. data/docs/sessions.md +38 -14
  16. data/docs/sub-agent.md +8 -0
  17. data/docs/testing.md +7 -0
  18. data/lib/samagotchi/answer_command.rb +27 -25
  19. data/lib/samagotchi/bridge/turn_accumulator.rb +16 -0
  20. data/lib/samagotchi/bridge.rb +33 -7
  21. data/lib/samagotchi/bridge_client/event_stream.rb +81 -12
  22. data/lib/samagotchi/bridge_client/stream_request.rb +77 -0
  23. data/lib/samagotchi/bridge_client.rb +22 -38
  24. data/lib/samagotchi/bundle_command/check.rb +5 -1
  25. data/lib/samagotchi/bundle_command/try/command.rb +6 -6
  26. data/lib/samagotchi/bundle_command/try/sandbox.rb +3 -3
  27. data/lib/samagotchi/bundle_command.rb +4 -4
  28. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  29. data/lib/samagotchi/bundles/claude-peer/scripts/pair +30 -1
  30. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  31. data/lib/samagotchi/bundles/codex-peer/scripts/pair +49 -4
  32. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +7 -4
  33. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  34. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  35. data/lib/samagotchi/chi_invocation.rb +43 -0
  36. data/lib/samagotchi/cli/command.rb +3 -2
  37. data/lib/samagotchi/cli/flags.rb +5 -0
  38. data/lib/samagotchi/cli/parent_wait.rb +22 -13
  39. data/lib/samagotchi/commands/registry.rb +12 -1
  40. data/lib/samagotchi/config.rb +89 -53
  41. data/lib/samagotchi/context_command.rb +80 -73
  42. data/lib/samagotchi/engine.rb +138 -15
  43. data/lib/samagotchi/idle_recap.rb +7 -1
  44. data/lib/samagotchi/idle_scheduler.rb +16 -1
  45. data/lib/samagotchi/kernel_loop.rb +13 -1
  46. data/lib/samagotchi/launch_mode.rb +22 -0
  47. data/lib/samagotchi/llm_context_edit.rb +32 -10
  48. data/lib/samagotchi/llm_context_forget.rb +24 -8
  49. data/lib/samagotchi/llm_context_view.rb +19 -7
  50. data/lib/samagotchi/memory_bundle/installer.rb +2 -0
  51. data/lib/samagotchi/memory_bundle/manifest.rb +13 -2
  52. data/lib/samagotchi/message_parts.rb +8 -3
  53. data/lib/samagotchi/note_command.rb +8 -7
  54. data/lib/samagotchi/parent_report.rb +55 -43
  55. data/lib/samagotchi/plugin/context.rb +2 -2
  56. data/lib/samagotchi/prompt.rb +1 -1
  57. data/lib/samagotchi/recap_command.rb +68 -0
  58. data/lib/samagotchi/recap_setup.rb +27 -14
  59. data/lib/samagotchi/remote_command.rb +170 -0
  60. data/lib/samagotchi/remotes.rb +130 -0
  61. data/lib/samagotchi/run_options.rb +233 -0
  62. data/lib/samagotchi/send_command/busy.rb +58 -0
  63. data/lib/samagotchi/send_command/parent_gate.rb +23 -10
  64. data/lib/samagotchi/send_command/wait_target.rb +16 -3
  65. data/lib/samagotchi/send_command.rb +186 -118
  66. data/lib/samagotchi/session.rb +48 -16
  67. data/lib/samagotchi/session_access/contract.rb +129 -0
  68. data/lib/samagotchi/session_access/dispatch.rb +133 -0
  69. data/lib/samagotchi/session_access/http_transport.rb +109 -0
  70. data/lib/samagotchi/session_access/local.rb +349 -0
  71. data/lib/samagotchi/session_access/remote.rb +638 -0
  72. data/lib/samagotchi/session_access/web_bridge_client.rb +181 -0
  73. data/lib/samagotchi/session_access.rb +141 -0
  74. data/lib/samagotchi/session_archive_command.rb +9 -5
  75. data/lib/samagotchi/session_commands.rb +53 -6
  76. data/lib/samagotchi/session_config.rb +411 -0
  77. data/lib/samagotchi/session_delete_command.rb +8 -4
  78. data/lib/samagotchi/session_manager.rb +7 -2
  79. data/lib/samagotchi/session_setup.rb +15 -10
  80. data/lib/samagotchi/sessions_command.rb +42 -16
  81. data/lib/samagotchi/terminal_ui/attach_launcher.rb +54 -18
  82. data/lib/samagotchi/terminal_ui/attach_link.rb +104 -0
  83. data/lib/samagotchi/terminal_ui/attached_loop.rb +82 -76
  84. data/lib/samagotchi/terminal_ui/formatting.rb +10 -3
  85. data/lib/samagotchi/terminal_ui/local_link.rb +130 -0
  86. data/lib/samagotchi/terminal_ui/version_lines.rb +5 -3
  87. data/lib/samagotchi/terminal_ui.rb +8 -6
  88. data/lib/samagotchi/testing/bundle.rb +3 -2
  89. data/lib/samagotchi/title_command.rb +9 -11
  90. data/lib/samagotchi/version.rb +1 -1
  91. data/lib/samagotchi/web/app.rb +387 -79
  92. data/lib/samagotchi/web/public/app.js +2 -0
  93. data/lib/samagotchi/web/public/commands.js +8 -2
  94. data/lib/samagotchi/web/public/data.js +6 -4
  95. data/lib/samagotchi/web/public/index.html +37 -3
  96. data/lib/samagotchi/web/public/stage_frame.js +7 -2
  97. data/lib/samagotchi/web/public/stage_view.js +3 -2
  98. data/lib/samagotchi/web/public/stream_handlers.js +7 -1
  99. data/lib/samagotchi/web/public/turn_html.js +5 -0
  100. data/lib/samagotchi/web/public/working_set.js +247 -24
  101. data/lib/samagotchi/web/public/working_set_rail.js +39 -12
  102. data/lib/samagotchi/web/public/working_set_view.js +198 -25
  103. data/lib/samagotchi/web/session_access_routes.rb +343 -0
  104. data/lib/samagotchi/worker.rb +9 -6
  105. data/lib/samagotchi/worker_sidecar.rb +12 -5
  106. data/lib/samagotchi/working_set.rb +132 -20
  107. data/lib/samagotchi/working_set_command.rb +222 -0
  108. data/lib/samagotchi/working_set_pin.rb +34 -0
  109. data/lib/samagotchi/working_set_steer.rb +414 -0
  110. metadata +23 -2
  111. data/lib/samagotchi/bundle_session_settings.rb +0 -251
@@ -0,0 +1,343 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../session"
4
+ require_relative "../session_access/local"
5
+ require_relative "../session_inbox"
6
+ require_relative "../reply_wait"
7
+ require_relative "../pending_question"
8
+ require_relative "../context_sources"
9
+ require_relative "../context_command"
10
+ require_relative "../send_command"
11
+ require_relative "../launch_mode"
12
+ require_relative "../model_profile"
13
+
14
+ module Samagotchi
15
+ module Web
16
+ # The routes chi's session commands need from another machine's chi web
17
+ # (chi --remote NAME …, SessionAccess::Remote), one per
18
+ # SessionAccess::Local operation that the page's own routes don't cover:
19
+ # a prefix resolved, the CLI's list rows, a wait's pre-send state and
20
+ # the wait itself, a note, a push into a context source. Each calls
21
+ # SessionAccess::Local, so a Remote maps 1:1 onto them. Included in
22
+ # App: its ROUTES name the handlers, and its helpers (json_response,
23
+ # parse_json, …) answer. See docs/architecture.md (chi web's routes for
24
+ # the session commands).
25
+ module SessionAccessRoutes
26
+ # A wait route answers within this many seconds (status timeout); the
27
+ # client asks again until its own --timeout.
28
+ WAIT_MAX_SECONDS = 30.0
29
+ # GET /api/info's feature: this chi web has these routes (a remote
30
+ # client checks it before its first session command).
31
+ FEATURE = "session_commands"
32
+ # GET /api/info's feature: this chi web has the routes a remote
33
+ # attached TUI needs (wake, worker; chi --remote NAME --attach).
34
+ ATTACH_FEATURE = "remote_attach"
35
+ # The longest POST …/wake waits for a worker's Bridge
36
+ # (TerminalUI::AttachLauncher::BRIDGE_WAIT, an attach's here).
37
+ WAKE_MAX_WAIT = 10.0
38
+
39
+ private
40
+
41
+ # This server's sessions, as a session command reaches them here.
42
+ def session_access = @session_access ||= SessionAccess::Local.new(state_dir: @state_dir)
43
+
44
+ # A refusal an exception carries: its stable +code+, +detail+ (what
45
+ # the page shows; the message by default), and for a remote client
46
+ # the exception's own message and the fields its class is built from
47
+ # (session_id, reason, …), so it can raise the same class.
48
+ def refusal_response(status, code, error, detail: error.message, **fields)
49
+ json_response(status, { error: code, detail: detail, message: error.message, **fields }.compact)
50
+ end
51
+
52
+ # The body as a Hash ({} when there is none), or nil when it isn't one.
53
+ def body_hash(req)
54
+ raw = request_body(req)
55
+ return {} if raw.to_s.strip.empty?
56
+
57
+ parsed = parse_json(raw)
58
+ parsed.is_a?(Hash) ? parsed : nil
59
+ end
60
+
61
+ def truthy_param?(value) = %w[1 true yes].include?(value.to_s.strip.downcase)
62
+
63
+ # GET /api/sessions/resolve?id=PREFIX: the full id of the session an id
64
+ # or a unique prefix names (SessionAccess::Local#resolve). 200 {id};
65
+ # 404 not_found; 400 ambiguous_id with ids (the sessions it names).
66
+ def handle_resolve(req)
67
+ given = req.params["id"].to_s
68
+ return error_response(404, "not_found", "no session #{given[0, 80]}") unless Session.valid_id?(given)
69
+
70
+ json_response(200, { id: session_access.resolve(given) })
71
+ rescue Session::AmbiguousId => e
72
+ refusal_response(400, "ambiguous_id", e, ids: e.ids)
73
+ rescue ArgumentError
74
+ error_response(404, "not_found", "no session #{given[0, 80]}")
75
+ end
76
+
77
+ # GET /api/sessions/:id?brief=1: the session as a session command
78
+ # reads it (SessionAccess::Local#show): the file's status (not the
79
+ # displayed one), its names, folder, model and parent_answers, its
80
+ # owner's kind, and the file's pending question, never its messages.
81
+ # A chi web from before ?brief answers the whole read, which has the
82
+ # session part too.
83
+ def handle_brief_read(id)
84
+ session = @session_class.load(id, state_dir: default_state_dir)
85
+ owner = session_access.owner(id)
86
+ json_response(200, {
87
+ brief: true,
88
+ session: { id: session.id, status: session.status, title: session.title,
89
+ display_title: session.display_title, project_root: session.project_root,
90
+ working_directory: session.working_directory, model_name: session.model_name,
91
+ model_typed: session.model_typed, parent_answers: session.parent_answers,
92
+ owner: owner&.kind },
93
+ pending_question: session.pending_question&.to_file
94
+ })
95
+ end
96
+
97
+ # GET /api/sessions/summaries: the rows chi sessions list prints
98
+ # (SessionManager.session_summaries: status, live, busy, owner, recap,
99
+ # ctx_pct, waiting, …), filtered as its flags filter. Params: dir (a
100
+ # folder: its project's sessions; none: every project's), cwd (in that
101
+ # folder or below), live=1, archived=1, tests=0 (leave test runs out),
102
+ # sort, order, limit. An array, newest first by default.
103
+ def handle_summaries(req)
104
+ dir, error = scope_dir(req.params["dir"])
105
+ return error if error
106
+
107
+ cwd = req.params["cwd"].to_s
108
+ cwd = nil if cwd.strip.empty?
109
+ return error_response(400, "invalid_cwd", "cwd is an absolute path: #{cwd[0, 200]}") if cwd && !cwd.start_with?("/")
110
+
111
+ rows = session_access.summaries(
112
+ live: truthy_param?(req.params["live"]), cwd: cwd, limit: sanitize_limit(req.params["limit"]),
113
+ include_tests: !%w[0 false no].include?(req.params["tests"].to_s.strip.downcase),
114
+ project_root: dir && ProjectScope.root_for(dir), include_archived: truthy_param?(req.params["archived"]),
115
+ sort: sanitize_sort(req.params["sort"]), order: sanitize_order(req.params["order"])
116
+ )
117
+ json_response(200, rows)
118
+ end
119
+
120
+ # POST /api/sessions/:id/wait/prepare: what chi send --wait and chi
121
+ # answer read before anything is sent (SessionAccess::Local#
122
+ # prepare_wait). Body (all optional): question_id (the question the
123
+ # baseline counts as reported; absent: the one pending now, null:
124
+ # none), turn (--turn: known_turn says whether it can be waited for),
125
+ # created (a session just created: no reply, no question), busy (chi
126
+ # send --wait with a message: refuse a running turn, 409 busy or
127
+ # waiting in SendCommand::Busy's words, with its fields: question,
128
+ # parent_strategy; queue: true, --queue, refuses only a waiting
129
+ # question). 200 {session_id, cursor, baseline:
130
+ # {messages, question_id, last_turn}, own_turn, known_turn, running,
131
+ # live, settled, last_turn_id}.
132
+ def handle_wait_prepare(req, id)
133
+ body = body_hash(req) or return error_response(400, "invalid_json", "invalid JSON body")
134
+ turn = body["turn"]
135
+ return error_response(400, "invalid_turn", "turn is a turn id") unless turn.nil? || turn.is_a?(String)
136
+
137
+ session = session_access.show(id)
138
+ if body["busy"] == true
139
+ owner = session_access.owner(id) if session.status == Session::STATUS_RUNNING
140
+ busy = SendCommand::Busy.for(session, owner: owner, queue: body["queue"] == true)
141
+ return json_response(409, busy_json(busy)) if busy
142
+ end
143
+
144
+ question_id = body.key?("question_id") ? body["question_id"]&.to_s : :pending
145
+ start = session_access.prepare_wait(id, session: session, question_id: question_id, turn: turn,
146
+ created: body["created"] == true)
147
+ json_response(200, wait_start_json(id, start))
148
+ end
149
+
150
+ # A Busy refusal: its words here, and its fields, which a chi --remote
151
+ # client words with its own commands (SessionAccess::Remote#busy).
152
+ def busy_json(busy)
153
+ { error: busy.error, detail: busy.message, message: busy.message, session_id: busy.session_id,
154
+ question: busy.question&.to_file, parent_strategy: (strategy_json(busy.strategy) if busy.strategy) }.compact
155
+ end
156
+
157
+ def strategy_json(strategy) = { name: strategy.name, allowed: strategy.allowed, origin: strategy.origin.to_s }
158
+
159
+ def wait_start_json(id, start)
160
+ { session_id: id, cursor: start.cursor, baseline: start.baseline.to_h, own_turn: start.own_turn,
161
+ known_turn: start.known_turn, running: start.running, live: start.live, settled: start.settled,
162
+ last_turn_id: start.last_turn_id }
163
+ end
164
+
165
+ # POST /api/sessions/:id/wait: ReplyWait for at most WAIT_MAX_SECONDS
166
+ # (SessionAccess::Local#wait_reply). Body: cursor, baseline
167
+ # ({messages, question_id, last_turn}, as the prepare route gave it),
168
+ # turn, queued, ended_before, owner_grace (seconds, null: wait for
169
+ # whatever wakes a worker), timeout (seconds, capped). 200: the
170
+ # ReplyWait::Result's fields (status: done, waiting_for_answer,
171
+ # no_reply, error, stopped, timeout or worker_gone; text, file,
172
+ # question, outcome, …; never the Session) and parent_strategy, what
173
+ # a parent may pick on the session's questions here
174
+ # (ParentReport.strategy_for). A timeout status: ask again. 503
175
+ # server_stopping when chi web stops mid-wait.
176
+ def handle_wait(req, id)
177
+ body = body_hash(req) or return error_response(400, "invalid_json", "invalid JSON body")
178
+ options, error = wait_options(body)
179
+ return error if error
180
+
181
+ session_access.show(id)
182
+ result = session_access.wait_reply(id, **options, poll_interval: @wait_poll_interval,
183
+ cancelled: -> { !@server_running.call })
184
+ return error_response(503, "server_stopping", "chi web is stopping; wait again") if result.status == :canceled
185
+
186
+ json_response(200, wait_result_json(result, session_access.parent_strategy(id)))
187
+ end
188
+
189
+ # The wait's ReplyWait arguments from the body: [options, nil] or
190
+ # [nil, a 400 response].
191
+ def wait_options(body)
192
+ strings = %w[cursor turn].to_h { |key| [key, body[key]] }
193
+ if strings.any? { |_, value| !value.nil? && !value.is_a?(String) }
194
+ return [nil, error_response(400, "invalid_wait", "cursor and turn are strings")]
195
+ end
196
+
197
+ baseline, error = wait_baseline(body["baseline"])
198
+ return [nil, error] if error
199
+
200
+ numbers = %w[timeout owner_grace].to_h { |key| [key, body[key]] }
201
+ if numbers.any? { |_, value| !value.nil? && !(value.is_a?(Numeric) && value.positive?) }
202
+ return [nil, error_response(400, "invalid_wait", "timeout and owner_grace are positive seconds")]
203
+ end
204
+
205
+ timeout = [numbers["timeout"] || WAIT_MAX_SECONDS, WAIT_MAX_SECONDS].min
206
+ [{ cursor: strings["cursor"], turn: strings["turn"], baseline: baseline, timeout: timeout,
207
+ owner_grace: numbers["owner_grace"], queued: body["queued"] == true, ended_before: body["ended_before"] == true }, nil]
208
+ end
209
+
210
+ def wait_baseline(raw)
211
+ return [nil, nil] if raw.nil?
212
+
213
+ fields = { "messages" => Integer, "question_id" => String, "last_turn" => String }
214
+ unless raw.is_a?(Hash) && raw.all? { |key, value| fields.key?(key) && (value.nil? || value.is_a?(fields[key])) }
215
+ return [nil, error_response(400, "invalid_wait", "baseline is {messages, question_id, last_turn}")]
216
+ end
217
+
218
+ [ReplyWait::Baseline.new(**raw.transform_keys(&:to_sym)), nil]
219
+ end
220
+
221
+ RESULT_FIELDS = %i[text file outcome limit error_kind retryable cancel_reason stopped_by kept_steps turn_id origin].freeze
222
+
223
+ def wait_result_json(result, strategy)
224
+ json = { status: result.status.to_s }
225
+ RESULT_FIELDS.each { |key| json[key] = result[key] }
226
+ json[:text] = json[:text]&.dup&.force_encoding(Encoding::UTF_8)&.scrub
227
+ json[:question] = result.question&.to_file
228
+ json[:parent_strategy] = strategy_json(strategy)
229
+ json
230
+ end
231
+
232
+ # POST /api/sessions/:id/wake {wait}: the session's worker for a remote
233
+ # attach (SessionAccess::Local#wake): the live one, else resumed and
234
+ # waited for up to +wait+ seconds (WAKE_MAX_WAIT, also the most). 200
235
+ # {session_id, woke, worker: {epoch, started_at, version, features},
236
+ # model_name, default_model (this machine's config, for the TUI's
237
+ # status row), parent_strategy, installed (the newest chi here)}; 409
238
+ # owned_by_tui (a chi REPL has it), 409 scratch (a leftover scratch
239
+ # session, as chi --attach here says); 504 worker_timeout (its Bridge
240
+ # didn't come in time); 404 not_found.
241
+ def handle_wake(req, id)
242
+ body = body_hash(req) or return error_response(400, "invalid_json", "invalid JSON body")
243
+ wait = body.fetch("wait", WAKE_MAX_WAIT)
244
+ return error_response(400, "invalid_wait", "wait is positive seconds") unless wait.is_a?(Numeric) && wait.positive?
245
+
246
+ woken = session_access.wake(id, wait: [wait, WAKE_MAX_WAIT].min)
247
+ unless woken.sidecar
248
+ late = format(LaunchMode::BRIDGE_LATE, id)
249
+ return json_response(504, { error: "worker_timeout", detail: late, message: late, session_id: id })
250
+ end
251
+
252
+ json_response(200, { session_id: id, woke: woken.woke, worker: worker_json(woken.sidecar),
253
+ model_name: session_access.show(id).model_name,
254
+ default_model: ModelProfile.default_model_name,
255
+ parent_strategy: strategy_json(session_access.parent_strategy(id)),
256
+ installed: @hub&.installed })
257
+ rescue SessionManager::OwnedByTUI => e
258
+ owned_by_tui(e)
259
+ rescue SessionAccess::Refused => e
260
+ refusal_response(409, e.code, e, session_id: id)
261
+ end
262
+
263
+ # GET /api/sessions/:id/worker: the session's worker now, never
264
+ # started (SessionAccess::Local#worker): a remote attach's
265
+ # rediscovery and its features after a restart. 200 {session_id,
266
+ # live, stopped, owner: worker|tui|null, worker: {epoch, started_at,
267
+ # version, features}|null, parent_strategy}; 404 not_found.
268
+ def handle_worker(_req, id)
269
+ state = session_access.worker(id)
270
+ json_response(200, { session_id: id, live: state.live?, stopped: state.stopped, owner: state.owner&.kind,
271
+ worker: state.sidecar && worker_json(state.sidecar),
272
+ parent_strategy: strategy_json(session_access.parent_strategy(id)) })
273
+ end
274
+
275
+ # A live worker as a remote client knows it: its epoch is its
276
+ # identity (the one in its event ids).
277
+ def worker_json(sidecar)
278
+ { epoch: sidecar.epoch, started_at: sidecar.started_at, version: sidecar.version, features: sidecar.features }
279
+ end
280
+
281
+ # POST /api/sessions/:id/notes {text, source}: a context note for the
282
+ # session (NoteDelivery, as chi note; source: where it came from,
283
+ # "cli" by default, never "broadcast"). 200 {status: "queued" (a
284
+ # worker adds it) or "waits" (the next start does), session_id,
285
+ # queued}; 409 owned_by_tui (status refused: a chi REPL has it); 400
286
+ # note_rejected (empty, or over 16 KiB).
287
+ def handle_notes(req, id)
288
+ body = body_hash(req) or return error_response(400, "invalid_json", "invalid JSON body")
289
+ text = body["text"]
290
+ source = body.fetch("source", "cli")
291
+ return error_response(400, "missing_fields", "text is required") unless text.is_a?(String)
292
+ return error_response(400, "invalid_source", "source is a word") unless source.is_a?(String) && !source.strip.empty?
293
+ return error_response(400, "invalid_source", "source broadcast is chi broadcast's own") if source.strip == "broadcast"
294
+
295
+ session_access.show(id)
296
+ result = session_access.note(id, text: text, source: source.strip)
297
+ if result.status == :refused
298
+ error = SessionManager::OwnedByTUI.new(id)
299
+ return refusal_response(409, "owned_by_tui", error, session_id: id, status: "refused", queued: 0)
300
+ end
301
+
302
+ json_response(200, { status: result.status.to_s, session_id: id, queued: result.queued })
303
+ rescue SessionInbox::NoteRejected => e
304
+ error_response(400, "note_rejected", e.message)
305
+ end
306
+
307
+ # POST /api/sessions/:id/context/:name/push {text, summary, wake,
308
+ # hint}: new text for the session's --push source (chi context push;
309
+ # the fields as a source's JSON output gives them, checked the same
310
+ # way). 200 {status: "pushed", name, before, revision, changed}; 404
311
+ # not_found (no such source); 409 not_push (a command source); 400
312
+ # invalid_text.
313
+ def handle_context_push(req, id, name)
314
+ body = body_hash(req) or return error_response(400, "invalid_json", "invalid JSON body")
315
+ return error_response(400, "missing_fields", "text is required") unless body["text"].is_a?(String)
316
+
317
+ fetched = begin
318
+ ContextSources.parse_output(JSON.generate(body.slice("text", "summary", "wake", "hint")))
319
+ rescue ContextSources::Invalid => e
320
+ return error_response(400, "invalid_text", e.message)
321
+ end
322
+ pushed = session_access.context_push(context_target(id), name, fetched)
323
+ case pushed.status
324
+ when :missing then error_response(404, "not_found", "no source #{name[0, 40]}")
325
+ when :not_push then error_response(409, "not_push", SessionAccess::ContextPush.not_push_message(name))
326
+ else json_response(200, { status: "pushed", name: name, before: pushed.before, revision: pushed.revision,
327
+ changed: pushed.before != pushed.revision })
328
+ end
329
+ rescue ContextSources::Invalid
330
+ error_response(404, "not_found", "no source #{name[0, 40]}")
331
+ end
332
+
333
+ # The session as chi context's Target (SessionAccess::Local's context
334
+ # calls take one).
335
+ # @raise [ArgumentError] no such session
336
+ def context_target(id)
337
+ session = session_access.show(id)
338
+ ContextCommand::Target.new(session_id: id, project_root: session.project_root, cwd: session.working_directory,
339
+ label: id[0, 8])
340
+ end
341
+ end
342
+ end
343
+ end
@@ -259,7 +259,7 @@ module Samagotchi
259
259
  })
260
260
  # A recap written while a continue offer waits says the turn stopped
261
261
  # unfinished (before the idle jobs start).
262
- @engine.recap&.awaiting_continue = -> { @turn_flow.awaiting_continue? }
262
+ @engine.recap_awaiting_continue = -> { @turn_flow.awaiting_continue? }
263
263
  # The seq of the last turn's end event: a command queued before it was
264
264
  # queued while that turn ran.
265
265
  @turn_end_seq = 0
@@ -943,14 +943,17 @@ module Samagotchi
943
943
  # finishes) comes after its command_queued. The cards it shows go out at
944
944
  # once (btw's "thinking…" card), after its command_queued, which the
945
945
  # UIs draw its line at (Engine#running_anytime). Its handler reads
946
- # copies (ctx.messages) and shows things through ctx only. One saves:
947
- # /title, a field no turn writes, through Session#save, which takes the
948
- # session's lock and writes a running turn's checkpoint (as
949
- # WorkerSignals' save off the turn thread does), so the turn's own saves
950
- # keep it.
946
+ # copies (ctx.messages) and shows things through ctx only. One that
947
+ # changed the session (/title, /recap off: fields no turn writes) is
948
+ # saved here, as a queued one is (#run_command), through Session#save,
949
+ # which takes the session's lock and writes a running turn's checkpoint
950
+ # (as WorkerSignals' save off the turn thread does), so the turn's own
951
+ # saves keep it.
951
952
  def start_anytime_command(command)
952
953
  @engine.spawn_anytime do
953
954
  result = command_result(command) { @engine.running_anytime { @commands.run(command.line, confirm: confirm?(command)) } }
955
+ # Saved before its command_ran: a client that reads the file on the reply finds it.
956
+ save_or_log(:command) { save_session } unless Array(result.changed).empty?
954
957
  announce_command(command, status: result.status.to_s, output: result.output, changed: Array(result.changed),
955
958
  anytime: true, confirm: result.confirm)
956
959
  rescue StandardError => e
@@ -10,10 +10,14 @@ module Samagotchi
10
10
  # Bridge (port, bind), when it started (a new worker behind the same
11
11
  # "worker" owner), the chi it runs (chi update, the web's stale-worker
12
12
  # badge), the input-file format it reads (SessionInbox::INPUT_FORMAT) and
13
- # what else it can do (Bridge::FEATURES). The Bridge writes it on start
13
+ # what else it can do (Bridge::FEATURES), and its Bridge's epoch (the
14
+ # one worker identity: the epoch in every event id, so a remote client
15
+ # tells a restarted worker apart; nil from a worker before it). The
16
+ # Bridge writes it on start
14
17
  # and removes it on stop; one a dead worker left stays until a client's
15
18
  # probe finds its port closed (.live with unlink: true).
16
- class WorkerSidecar < Data.define(:port, :bind, :session_id, :started_at, :version, :input_format, :features)
19
+ class WorkerSidecar < Data.define(:port, :bind, :session_id, :started_at, :version, :input_format, :features,
20
+ :epoch)
17
21
  FILE = "bridge.json"
18
22
  # Seconds a liveness probe waits for the port to take a connect.
19
23
  PROBE_TIMEOUT = 0.2
@@ -29,7 +33,7 @@ module Samagotchi
29
33
  port = data["port"]
30
34
  new(port: port.respond_to?(:to_i) ? port.to_i : 0, bind: data["bind"], session_id: data["session_id"],
31
35
  started_at: data["started_at"], version: data["version"], input_format: data["input_format"],
32
- features: Array(data["features"]).map(&:to_s))
36
+ features: Array(data["features"]).map(&:to_s), epoch: data["epoch"]&.to_s)
33
37
  rescue JSON::ParserError, SystemCallError
34
38
  nil
35
39
  end
@@ -63,13 +67,16 @@ module Samagotchi
63
67
  # @return [Integer, nil] .live's port
64
68
  def self.live_port(session_dir, unlink:, host: "127.0.0.1") = live(session_dir, unlink: unlink, host: host)&.port
65
69
 
66
- def initialize(port:, bind: nil, session_id: nil, started_at: nil, version: nil, input_format: nil, features: []) = super
70
+ def initialize(port:, bind: nil, session_id: nil, started_at: nil, version: nil, input_format: nil, features: [],
71
+ epoch: nil) = super
67
72
 
68
- # Written whole or not at all; input_format only when it names one.
73
+ # Written whole or not at all; input_format and epoch only when they
74
+ # name one.
69
75
  def write(session_dir)
70
76
  FileUtils.mkdir_p(session_dir)
71
77
  record = to_h.transform_keys(&:to_s)
72
78
  record.delete("input_format") if input_format.nil?
79
+ record.delete("epoch") if epoch.nil?
73
80
  AtomicFile.write(self.class.path(session_dir), "#{JSON.pretty_generate(record)}\n")
74
81
  end
75
82
  end
@@ -39,24 +39,53 @@ module Samagotchi
39
39
  LIVE = "live"
40
40
  STALE = "stale"
41
41
  FORGOTTEN = "forgotten"
42
+ # Forgotten, every output of it by the user's drop (WorkingSetSteer).
43
+ DROPPED = "dropped"
42
44
 
43
45
  # One thing the model touched: +kind+ file|memory|url; +key+ a file's
44
46
  # absolute path (FileRef), a memory's name, a URL (whitespace
45
- # collapsed); +state+ live|stale|forgotten; +tokens+ its content
47
+ # collapsed); +state+ live|stale|forgotten|dropped; +tokens+ its content
46
48
  # outputs as sent; +ops+ read|edit for a file, read|write for a memory,
47
49
  # read for a URL, in first-seen order; +count+ the runs that touched
48
- # it; +first_id+ the first one's chi id (ToolIds).
49
- Item = Data.define(:kind, :key, :state, :tokens, :ops, :count, :first_id)
50
+ # it; +first_id+ the first one's chi id (ToolIds); +pinned+ whether
51
+ # the user pinned it (WorkingSetPin: the model's forget_outputs refuses
52
+ # its outputs), whatever the strategy; +has_drop+ whether any of its
53
+ # content outputs holds the user's applied drop (an undrop has
54
+ # something to take back: a forgotten item too, its other outputs
55
+ # stubbed by the model's layers).
56
+ Item = Data.define(:kind, :key, :state, :tokens, :ops, :count, :first_id, :pinned, :has_drop)
57
+
58
+ # An item the user added (/working-set add) that the next turn's note
59
+ # asks the model to read: its identity (+kind+, +key+, as an Item's)
60
+ # and how the note names it (+label+: a file relative to the session's
61
+ # folder, "memory NAME", a URL).
62
+ Add = Data.define(:kind, :key, :label)
50
63
 
51
64
  # A projection and the conversation's size (entries) it was built
52
65
  # from: the web keeps the newer of an event's and a GET's by it.
53
- Snapshot = Data.define(:items, :at) do
54
- def empty? = items.empty?
66
+ # +adds+: the pending note's adds (#pending_adds), not items yet.
67
+ Snapshot = Data.define(:items, :at, :adds) do
68
+ def initialize(items:, at:, adds: [])
69
+ super
70
+ end
55
71
 
56
- # The JSON the event and the route carry: {items: [...], at:}.
57
- def to_h = { items: items.map(&:to_h), at: at }
72
+ def empty? = items.empty? && adds.empty?
73
+
74
+ # The JSON the event and the route carry: {items: [...], at:}, and
75
+ # adds: [{kind, key, label}] while any is pending.
76
+ def to_h
77
+ hash = { items: items.map(&:to_h), at: at }
78
+ hash[:adds] = adds.map(&:to_h) unless adds.empty?
79
+ hash
80
+ end
58
81
  end
59
82
 
83
+ # The turn-start note (WorkingSetSteer#record!): a tail system message
84
+ # of this kind, its ops ([{"op", "kind", "key", "label"}]) in NOTE_OPS.
85
+ NOTE_KIND = "working_set"
86
+ NOTE_OPS = :working_set_ops
87
+ ADD = "add"
88
+
60
89
  FILE_OPS = { "read" => "read", "edit" => "edit", "write" => "edit" }.freeze
61
90
  CONTENT = %w[read memory_read web_fetch].freeze
62
91
  MEMORY_READ = Tools::MemoryRead::NAME
@@ -81,21 +110,95 @@ module Samagotchi
81
110
  # @param strategy [Symbol, Array<Symbol>] :none or the layers, as
82
111
  # LLMContextView takes it (the session's current one)
83
112
  # @param stat [#call] File.stat, or a stand-in (specs)
113
+ # @param pins [Array<WorkingSetPin>] the session's pins
84
114
  # @return [Snapshot]
85
- def build(conversation, cwd:, strategy:, stat: File.method(:stat))
115
+ def build(conversation, cwd:, strategy:, stat: File.method(:stat), pins: [])
116
+ conversation = Array(conversation)
117
+ view = LLMContextView.new(strategy: strategy)
118
+ pinned = pins.to_set { |pin| [pin.kind, pin.key] }
119
+ items = all_touches(conversation, cwd, view).group_by { |touch| [touch.kind, touch.key] }.map do |(kind, key), group|
120
+ item(kind, key, group, conversation, view, stat, pinned.include?([kind, key]))
121
+ end
122
+ Snapshot.new(items: items, at: conversation.size, adds: pending_adds(conversation))
123
+ end
124
+
125
+ # The index of the note the next turn will send (one after the last
126
+ # turn's entries: only system messages follow it), or nil.
127
+ def pending_note_index(messages)
128
+ (messages.size - 1).downto(0) do |index|
129
+ message = messages[index]
130
+ return nil unless message.is_a?(Hash) && message[:role].to_s == "system"
131
+ return index if message[:kind].to_s == NOTE_KIND
132
+ end
133
+ nil
134
+ end
135
+
136
+ # The ops the pending note lists ([{"op", "kind", "key", "label"}]).
137
+ def pending_ops(messages)
138
+ messages = Array(messages)
139
+ index = pending_note_index(messages)
140
+ index ? Array(messages[index][NOTE_OPS]).select { |op| op.is_a?(Hash) } : []
141
+ end
142
+
143
+ # The items the user added since the last turn, which the next turn's
144
+ # note asks the model to read.
145
+ # @return [Array<Add>]
146
+ def pending_adds(messages)
147
+ pending_ops(messages).select { |op| op["op"] == ADD }.map do |op|
148
+ Add.new(kind: op["kind"].to_s, key: op["key"].to_s, label: op["label"].to_s)
149
+ end
150
+ end
151
+
152
+ # One content output (read, memory_read, web_fetch) of an item: its
153
+ # chi +id+ (ToolIds), the +index+ of the tool_response entry holding
154
+ # it, whether the id is +derived+ (a legacy entry's: nothing can name
155
+ # it) and whether the model is still +sent+ it under the strategy.
156
+ # +shared_with+: the other names a memory_read of several names holds in the
157
+ # same output (an op on the output acts on them too), else empty.
158
+ Output = Data.define(:id, :index, :derived, :sent, :shared_with)
159
+
160
+ # The content outputs of the item +kind+/+key+ (#build's identity: a
161
+ # file's absolute path, a memory's name, a URL), in conversation
162
+ # order, by the same pairing #build uses, so what acts on an item's
163
+ # outputs and the projection never disagree. Edit-only items have
164
+ # none. A memory_read of several names is one output of each.
165
+ # @return [Array<Output>]
166
+ def outputs_of(conversation, kind:, key:, strategy:, cwd:)
86
167
  conversation = Array(conversation)
87
168
  view = LLMContextView.new(strategy: strategy)
169
+ all_touches(conversation, cwd, view).filter_map do |touch|
170
+ next unless touch.content && touch.kind == kind && touch.key == key
171
+
172
+ ref = touch.run.ref
173
+ Output.new(id: ref.id, index: touch.run.index, derived: ref.derived?,
174
+ sent: still_sent?(touch, conversation, view), shared_with: shared_with(touch, key))
175
+ end
176
+ end
177
+
178
+ # The ids of the content outputs of the items +pins+ name (the ones
179
+ # the model's forget_outputs refuses), by the same pairing as #build.
180
+ # @param pins [Array<WorkingSetPin>]
181
+ # @return [Set<String>]
182
+ def pinned_ids(conversation, pins:, cwd:)
183
+ return Set.new if pins.empty?
184
+
185
+ conversation = Array(conversation)
186
+ pinned = pins.to_set { |pin| [pin.kind, pin.key] }
187
+ touches = all_touches(conversation, cwd, LLMContextView.new(strategy: LLMContextView::NONE))
188
+ touches.select { |touch| touch.content && pinned.include?([touch.kind, touch.key]) }.to_set { |touch| touch.run.ref.id }
189
+ end
190
+
191
+ # Every paired run's touches, with what +view+ sends of its output.
192
+ def all_touches(conversation, cwd, view)
88
193
  stored = LLMContextStale.paired_runs(conversation)
89
- sent = if view.none?
194
+ # Under none with no drop the view sends the conversation as it is.
195
+ messages = view.messages(conversation)
196
+ sent = if messages.equal?(conversation)
90
197
  {}
91
198
  else
92
- LLMContextStale.paired_runs(view.messages(conversation)).to_h { |run| [run.ref.id, run.text] }
199
+ LLMContextStale.paired_runs(messages).to_h { |run| [run.ref.id, run.text] }
93
200
  end
94
- touches = stored.flat_map { |run| touches(run, sent.fetch(run.ref.id, run.text), cwd) }
95
- items = touches.group_by { |touch| [touch.kind, touch.key] }.map do |(kind, key), group|
96
- item(kind, key, group, conversation, view, stat)
97
- end
98
- Snapshot.new(items: items, at: conversation.size)
201
+ stored.flat_map { |run| touches(run, sent.fetch(run.ref.id, run.text), cwd) }
99
202
  end
100
203
 
101
204
  # The items +run+ touched (none when it failed or was interrupted).
@@ -127,6 +230,9 @@ module Samagotchi
127
230
  ToolResponse.interrupted_text?(text) || text.sub(ToolResponse::LEAD, "").lstrip.start_with?("Error")
128
231
  end
129
232
 
233
+ # The other names +touch+'s output holds (a memory_read of several).
234
+ def shared_with(touch, key) = touch.share > 1 ? found_names(touch.run) - [key] : []
235
+
130
236
  # A memory_read's names (Tools::MemoryRead.parse_names: none for the
131
237
  # index listing), less the ones its output says weren't found.
132
238
  def found_names(run)
@@ -136,19 +242,19 @@ module Samagotchi
136
242
  names - missing
137
243
  end
138
244
 
139
- def item(kind, key, group, conversation, view, stat)
245
+ def item(kind, key, group, conversation, view, stat, pinned)
140
246
  first = group.first
141
247
  contents = group.select(&:content)
142
248
  tokens = contents.sum { |touch| touch.sent.length / TokenUsage::CHARS_PER_TOKEN / touch.share }.ceil
143
249
  state = if contents.any? && contents.none? { |touch| still_sent?(touch, conversation, view) }
144
- FORGOTTEN
250
+ contents.all? { |touch| dropped?(touch, conversation) } ? DROPPED : FORGOTTEN
145
251
  elsif kind == FILE && changed?(key, group.last.run, conversation, stat)
146
252
  STALE
147
253
  else
148
254
  LIVE
149
255
  end
150
256
  Item.new(kind: kind, key: key, state: state, tokens: tokens, ops: group.map(&:op).uniq, count: group.size,
151
- first_id: first.run.ref.id)
257
+ first_id: first.run.ref.id, pinned: pinned, has_drop: contents.any? { |touch| dropped?(touch, conversation) })
152
258
  end
153
259
 
154
260
  # Whether the model is still sent +touch+'s output: whole (with the
@@ -161,11 +267,17 @@ module Samagotchi
161
267
  return true if sent == stored || sent == with_id(stored, touch.run.ref.id)
162
268
 
163
269
  edit = LLMContextEdit.on(conversation[touch.run.index])[touch.run.ref.id]
164
- return true unless edit&.applied? && view.layers.include?(edit.kind)
270
+ return true unless edit && view.sends?(edit)
165
271
 
166
272
  edit.kind == :forget ? edit.keep? : edit.note.match?(BY_EDIT)
167
273
  end
168
274
 
275
+ # Whether +touch+'s output holds the user's applied drop.
276
+ def dropped?(touch, conversation)
277
+ edit = LLMContextEdit.on(conversation[touch.run.index])[touch.run.ref.id]
278
+ !edit.nil? && edit.kind == LLMContextEdit::DROP && edit.applied?
279
+ end
280
+
169
281
  def with_id(text, id)
170
282
  match = text.match(ToolResponse::LEAD)
171
283
  match ? "#{match[0]}[##{id}] #{match.post_match}" : nil
@@ -195,6 +307,6 @@ module Samagotchi
195
307
 
196
308
  def field(hash, key) = hash.key?(key) ? hash[key] : hash[key.to_s]
197
309
 
198
- private_class_method :touches, :failed?, :found_names, :item, :still_sent?, :with_id, :changed?, :stamp_of, :field
310
+ private_class_method :all_touches, :touches, :failed?, :found_names, :shared_with, :item, :still_sent?, :dropped?, :with_id, :changed?, :stamp_of, :field
199
311
  end
200
312
  end