samagotchi 0.154.0 → 0.154.1

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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -1
  3. data/docs/cli.md +9 -7
  4. data/docs/internals/bridge.md +18 -5
  5. data/docs/internals/engine.md +1 -1
  6. data/docs/internals/prompt-caching.md +3 -1
  7. data/docs/internals/web-frontend.md +2 -2
  8. data/docs/sessions.md +1 -1
  9. data/docs/testing.md +15 -0
  10. data/lib/samagotchi/answer_text.rb +1 -2
  11. data/lib/samagotchi/bridge/card_places.rb +56 -7
  12. data/lib/samagotchi/bridge/card_store.rb +24 -9
  13. data/lib/samagotchi/bridge.rb +9 -0
  14. data/lib/samagotchi/bridge_client.rb +9 -5
  15. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  16. data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
  17. data/lib/samagotchi/bundles/btw/plugin.rb +1 -1
  18. data/lib/samagotchi/bundles/coordinator/manifest.yml +2 -2
  19. data/lib/samagotchi/bundles/coordinator/plugin.rb +3 -2
  20. data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/github-pr/plugin.rb +3 -3
  22. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/skills/plugin.rb +3 -2
  24. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  25. data/lib/samagotchi/desktop/macos/ChiRunner.swift +1 -1
  26. data/lib/samagotchi/engine_snapshots.rb +9 -8
  27. data/lib/samagotchi/idle_recap.rb +3 -1
  28. data/lib/samagotchi/kernel_loop.rb +4 -3
  29. data/lib/samagotchi/llm/chat_loop.rb +3 -1
  30. data/lib/samagotchi/session_manager.rb +8 -13
  31. data/lib/samagotchi/session_retention.rb +24 -4
  32. data/lib/samagotchi/steer.rb +12 -1
  33. data/lib/samagotchi/stream_event.rb +22 -0
  34. data/lib/samagotchi/system_prompt.rb +52 -36
  35. data/lib/samagotchi/terminal_ui/attached_view.rb +0 -9
  36. data/lib/samagotchi/terminal_ui/input_support.rb +16 -15
  37. data/lib/samagotchi/terminal_ui/join_replay.rb +16 -12
  38. data/lib/samagotchi/terminal_ui/reline_seam.rb +0 -10
  39. data/lib/samagotchi/terminal_ui/status_row.rb +0 -7
  40. data/lib/samagotchi/tool_runner.rb +2 -1
  41. data/lib/samagotchi/turn_flow.rb +7 -2
  42. data/lib/samagotchi/version.rb +1 -1
  43. data/lib/samagotchi/web/app.rb +3 -1
  44. data/lib/samagotchi/web/message_display.rb +7 -0
  45. data/lib/samagotchi/web/public/card_stamps.js +27 -0
  46. data/lib/samagotchi/web/public/cards_place.js +5 -3
  47. data/lib/samagotchi/web/public/composer_mode.js +1 -1
  48. data/lib/samagotchi/web/public/format.js +3 -2
  49. data/lib/samagotchi/web/public/turn_html.js +8 -4
  50. data/lib/samagotchi/web/session_access_routes.rb +8 -0
  51. data/lib/samagotchi/web/session_show.rb +4 -0
  52. data/lib/samagotchi/worker.rb +8 -5
  53. data/lib/samagotchi.rb +0 -1
  54. metadata +3 -2
  55. data/lib/samagotchi/pending_input_queue.rb +0 -55
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
3
4
  require_relative "output_formatter"
4
5
  require_relative "turn_note"
5
6
  require_relative "context_note"
@@ -175,9 +176,13 @@ module Samagotchi
175
176
  # Answer the offer with no (Stop): the interrupted turn stays, as
176
177
  # after a Ctrl-C or a new prompt (!rollback still erases it), and the
177
178
  # model reads that it wasn't continued: a note, or the user's reason
178
- # (with a summary of that turn).
179
+ # (with a summary of that turn). The reason's line opens a turn on the
180
+ # page (Steer.turn_opening?) that no turn runs: it carries an
181
+ # +opening_id+ of its own, never a turn_id (a claim link, a fold, Revise
182
+ # and Apply read a turn_id as a turn), so a card shown after it finds its
183
+ # place by that id (Bridge::CardPlaces#opening_of).
179
184
  def abort_continue!(reason: nil)
180
- message = reason ? { role: "user", content: reason_message(reason) } : TurnNote.not_continued
185
+ message = reason ? { role: "user", content: reason_message(reason), opening_id: SecureRandom.uuid } : TurnNote.not_continued
181
186
  @engine.append_messages([message])
182
187
  @offer = nil
183
188
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.154.0"
4
+ VERSION = "0.154.1"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -1558,10 +1558,12 @@ module Samagotchi
1558
1558
  def handle_stream(req, id)
1559
1559
  # Validate session exists
1560
1560
  begin
1561
- @session_class.load(id, state_dir: default_state_dir)
1561
+ session = @session_class.load(id, state_dir: default_state_dir)
1562
1562
  rescue ArgumentError
1563
1563
  return error_response(404, "not_found", "Session not found: #{id}")
1564
1564
  end
1565
+ # Its terminal's alone: a tab's stream would keep its worker up.
1566
+ return scratch_refused(id) if session.scratch
1565
1567
 
1566
1568
  link = worker_link(id)
1567
1569
  if link.is_a?(WorkerLink::Starting)
@@ -134,6 +134,9 @@ module Samagotchi
134
134
  end
135
135
  next if role == "system"
136
136
  next if role == "tool_response"
137
+ # KernelLoop's recovery line: a row of its turn the page showed as
138
+ # nothing live (no turn opens with it, Steer.recovery?).
139
+ next if Samagotchi::Steer.recovery?(m)
137
140
 
138
141
  stripped = Samagotchi::OutputFormatter.strip_markup(content)
139
142
  did = parts && %w[model assistant].include?(role) ? message_parts(list, index, cwd, marks) : nil
@@ -165,6 +168,10 @@ module Samagotchi
165
168
  # record by this, not by its place.
166
169
  turn_id = m[:turn_id] || m["turn_id"]
167
170
  message[:turn_id] = turn_id.to_s if norm_role == "user" && turn_id
171
+ # An opening no turn ran (TurnFlow#abort_continue!'s reason): its own
172
+ # id, which a card's stamp names (Bridge::CardPlaces#opening_of).
173
+ opening_id = m[:opening_id] || m["opening_id"]
174
+ message[:opening_id] = opening_id.to_s if norm_role == "user" && opening_id && !turn_id && !message[:merged]
168
175
  # The turn it revised (a Revise stack, Engine#stamp_opening), and
169
176
  # whether it was a Verify turn (the page offers no Revise on its
170
177
  # answer).
@@ -0,0 +1,27 @@
1
+ // Where a snapshot's card or notice goes by its stamp: the id of the turn
2
+ // opening it comes after (+opening_id+, Bridge::CardPlaces#opening_of: a
3
+ // prompt's turn_id, a wake note's, /continue no's reason line's own id). The
4
+ // page finds that opening's bubble (data-turn-id on a prompt, data-opening-id
5
+ // on a wake note or a reason line, turn_html.js) and the entry goes before
6
+ // the next turn's opening, or at the end. No counting, so the page and the
7
+ // worker can't disagree on which bubbles open turns. An entry with no stamp
8
+ // (an older worker's, one before every opening), or whose opening the page
9
+ // doesn't draw, falls back to its count (card.js cardPlace, turns_since).
10
+ // A new module: the static JS is unversioned (docs/internals/web-frontend.md).
11
+ import { cardPlace } from "./card.js";
12
+
13
+ // The opening bubble +id+ names among the turns' openings (oldest first,
14
+ // bubbles.js turnPromptBubbles), or -1.
15
+ export function stampedOpening(userBubbles, id) {
16
+ if (!id) return -1;
17
+ const stamp = String(id);
18
+ return userBubbles.findIndex((el) => el.dataset?.turnId === stamp || el.dataset?.openingId === stamp);
19
+ }
20
+
21
+ // The element +entry+ goes before: the opening after its stamped one (null:
22
+ // the end), else what its count says (null: the end too).
23
+ export function snapshotPlace(userBubbles, entry) {
24
+ const at = stampedOpening(userBubbles, entry?.opening_id);
25
+ if (at < 0) return cardPlace(userBubbles, entry);
26
+ return userBubbles[at + 1] || null;
27
+ }
@@ -8,7 +8,8 @@
8
8
  // above the first turn after them, a finished turn's notice in its step, a
9
9
  // finished turn's question resolved where it was asked.
10
10
  import { getSession, sendCommand } from "./data.js";
11
- import { cardPlace, drawCard, isCard, isNoticeCard, refreshCardBody, turnNoticePlace } from "./card.js";
11
+ import { drawCard, isCard, isNoticeCard, refreshCardBody, turnNoticePlace } from "./card.js";
12
+ import { snapshotPlace } from "./card_stamps.js";
12
13
  import { elementOf } from "./turn_html.js";
13
14
  import { stepText } from "./turn_block.js";
14
15
  import { llmContextNotice, noticeLine } from "./turn_events.js";
@@ -83,14 +84,15 @@ export function createCardsPlace({
83
84
  bubbles.placeFollowing(el, before);
84
85
  }
85
86
 
86
- // A snapshot's cards and notices, each above the first turn after it; a
87
+ // A snapshot's cards and notices, each above the first turn after it (the
88
+ // one after its stamped opening, card_stamps.js; else by its count); a
87
89
  // turn's own notice where it showed live (placeTurnNotice). +end+: what
88
90
  // closes the history below them (a failed turn's prompt), for those with
89
91
  // no turn after them; one shown after that prompt went back stays below it.
90
92
  function placeSnapshotCards(entries, { before: end = null } = {}) {
91
93
  const users = turnPromptBubbles(historyEl);
92
94
  for (const entry of entries) {
93
- const before = cardPlace(users, entry) || (entry.after_failed ? null : end);
95
+ const before = snapshotPlace(users, entry) || (entry.after_failed ? null : end);
94
96
  if (isCard(entry)) upsertCard(entry, { before });
95
97
  else if (entry.type === "question") placeResolvedQuestion(entry, before);
96
98
  else if (entry.type === "guardrail_warning") bubbles.showGuardrailWarning(entry.message, entry.label || "guardrails", { before });
@@ -51,7 +51,7 @@ export function composerMode({ selected, owner, running, narrow }) {
51
51
  if (tui) {
52
52
  return {
53
53
  ...base,
54
- placeholder: pick("A terminal chi owns this session · its one-turn run (chi --non-interactive) lets go as it ends", "A terminal chi owns this session"),
54
+ placeholder: pick("A terminal chi owns this session · a one-turn chi --non-interactive run or an older chi's terminal", "A terminal chi owns this session"),
55
55
  hint: "", button: "Send", disabled: true,
56
56
  };
57
57
  }
@@ -97,13 +97,14 @@ export function deleteConfirmText(session) {
97
97
  }
98
98
 
99
99
  // The badge on a session a process holds right now (the list's `owner`):
100
- // a worker (every UI can send), or a terminal chi's one-turn run (--non-interactive).
100
+ // a worker (every UI can send), or a terminal chi: a one-turn --non-interactive
101
+ // run or an older chi's terminal, in OwnerLock::TUI_BUSY's words.
101
102
  export function ownerBadge(owner, id) {
102
103
  if (owner === "worker") {
103
104
  return { kind: "worker", text: "live", title: `A worker runs this session; a terminal joins with: chi --attach ${id}` };
104
105
  }
105
106
  if (owner === "tui") {
106
- return { kind: "tui", text: "terminal", title: "A terminal chi runs one turn in this session (chi --non-interactive) and lets go as it ends" };
107
+ return { kind: "tui", text: "terminal", title: "In use by a one-turn chi --non-interactive run or an older chi's terminal" };
107
108
  }
108
109
  return null;
109
110
  }
@@ -285,9 +285,11 @@ export function noteBubbleTemplate(note) {
285
285
  if (note?.objectives) return objectivesEntryTemplate(note, timeTitle(note.at));
286
286
  const from = note?.label ? `note from ${note.label}` : "note";
287
287
  // A wake turn's note opens that turn (TURN_PROMPT, bubbles.js); a class, not
288
- // data-turn-id, which marks a claim's anchor (claim_link.js).
288
+ // data-turn-id, which marks a claim's anchor (claim_link.js). Its turn's id
289
+ // is its data-opening-id: a card's stamp finds it (card_stamps.js).
289
290
  const cls = note?.turn_start ? "bubble note turn-start" : "bubble note";
290
- return html`<div class=${cls} title=${timeTitle(note?.at)}><div class="note-line">${from}</div><div class="note-text">${note?.content ?? ""}</div></div>`;
291
+ const opening = note?.turn_start && note?.turn_id ? String(note.turn_id) : nothing;
292
+ return html`<div class=${cls} title=${timeTitle(note?.at)} data-opening-id=${opening}><div class="note-line">${from}</div><div class="note-text">${note?.content ?? ""}</div></div>`;
291
293
  }
292
294
 
293
295
  // A plugin's steer (a nudge put into the running turn): one line that
@@ -311,7 +313,9 @@ export function steerRowTemplate({ source, text } = {}, { bubble = false } = {})
311
313
  // participant's badge (participant_names.js authorBadge: their name and
312
314
  // colour) in place of +label+. A turn's opening carries its Revise stamp
313
315
  // (+message.revises+: data-revises, revise_stack.js) and whether it was a
314
- // Verify turn (+message.verify+: data-verify, no Revise on its answer).
316
+ // Verify turn (+message.verify+: data-verify, no Revise on its answer). An
317
+ // opening no turn ran (/continue no's reason) has +message.opening_id+
318
+ // instead of a turn id: data-opening-id, a card's stamp (card_stamps.js).
315
319
  // +message.source+ (a delegate report's "delegate_report") is its
316
320
  // data-source; +lineKey+, a merged line's fold key (answer_fold.js:
317
321
  // data-line-key, the reload's "<turn id>:input:<place>").
@@ -320,7 +324,7 @@ export function userBubbleTemplate(message, { label = null, state = null, step =
320
324
  const from = author
321
325
  ? html`<span class="origin-label author-badge" title=${authorBadgeTitle(author.name, author.mode)} data-unnamed=${author.unnamed ? "1" : nothing}>${authorBadgeText(author.name, author.mode)}</span>`
322
326
  : label ? html`<span class="origin-label">${label}</span>` : nothing;
323
- return html`<div class=${`bubble user${named ? ` ${named}` : ""}${author ? " by-participant" : ""}`} title=${timeTitle(message?.at)} style=${author ? `--author-color:${author.color}` : nothing} data-author=${author?.author || nothing} data-participant-mode=${author?.mode || nothing} data-user-state=${named || nothing} data-step=${step ? Number(step) : nothing} data-copy-source=${copySource({ ...message, role: "user" })} data-user-content=${key !== null ? key : nothing} data-turn-id=${message?.turn_id || nothing} data-revises=${(message?.turn_id && message?.revises?.turn_id) || nothing} data-verify=${message?.turn_id && message?.verify ? "1" : nothing} data-own=${own ? "1" : nothing} data-enqueued-id=${enqueuedId || nothing} data-delivery=${delivery || nothing} data-source=${message?.source || nothing} data-line-key=${lineKey || nothing}>${from}${userMessageTemplate(message, thumbs)}${stateBadgeTemplate(named, delivery)}</div>`;
327
+ return html`<div class=${`bubble user${named ? ` ${named}` : ""}${author ? " by-participant" : ""}`} title=${timeTitle(message?.at)} style=${author ? `--author-color:${author.color}` : nothing} data-author=${author?.author || nothing} data-participant-mode=${author?.mode || nothing} data-user-state=${named || nothing} data-step=${step ? Number(step) : nothing} data-copy-source=${copySource({ ...message, role: "user" })} data-user-content=${key !== null ? key : nothing} data-turn-id=${message?.turn_id || nothing} data-opening-id=${(!message?.turn_id && message?.opening_id) || nothing} data-revises=${(message?.turn_id && message?.revises?.turn_id) || nothing} data-verify=${message?.turn_id && message?.verify ? "1" : nothing} data-own=${own ? "1" : nothing} data-enqueued-id=${enqueuedId || nothing} data-delivery=${delivery || nothing} data-source=${message?.source || nothing} data-line-key=${lineKey || nothing}>${from}${userMessageTemplate(message, thumbs)}${stateBadgeTemplate(named, delivery)}</div>`;
324
328
  }
325
329
 
326
330
  // A user line's text: plain (its quotes set apart), or the server's
@@ -54,6 +54,14 @@ module Samagotchi
54
54
  json_response(status, { error: code, detail: detail, message: error.message, **fields }.compact)
55
55
  end
56
56
 
57
+ # 409 scratch for a chi scratch session +id+, which only its terminal
58
+ # uses (Session::SCRATCH_REFUSED): the session page's read and stream
59
+ # (a tab opened at its id by hand: the hub never lists it), as
60
+ # POST …/wake refuses it.
61
+ def scratch_refused(id)
62
+ refusal_response(409, "scratch", SessionManager::ScratchSession.new(id), session_id: id)
63
+ end
64
+
57
65
  # The body as a Hash ({} when there is none), or nil when it isn't one.
58
66
  def body_hash(req)
59
67
  raw = request_body(req)
@@ -40,6 +40,10 @@ module Samagotchi
40
40
  return handle_brief_read(id) if req.params["brief"] == "1"
41
41
 
42
42
  session = @session_class.load(id, state_dir: default_state_dir)
43
+ # A tab opened at a chi scratch session's id: its terminal's alone.
44
+ # (The light re-reads come after a full read only.)
45
+ return scratch_refused(id) if session.scratch
46
+
43
47
  # Read-only preview: selecting a session never spawns a worker.
44
48
  # The worker is woken only on POST /turn (handle_turn) via
45
49
  # SessionManager.resume_session + write_turn_input. This avoids
@@ -1608,14 +1608,17 @@ module Samagotchi
1608
1608
  SCRATCH_GONE_AFTER = 2 * BridgeClient::EventStream::REDISCOVER_DELAYS.sum
1609
1609
 
1610
1610
  # Whether this is a `chi scratch` session's worker whose terminal is
1611
- # gone: no stream open for SCRATCH_GONE_AFTER, after one was (a killed
1612
- # TUI's closes as its writer reads the hang-up), or none came within
1613
- # SCRATCH_JOIN_GRACE. Its terminal deletes it on every way out it sees;
1614
- # this is for the ones it doesn't (kill -9, a closed terminal window).
1611
+ # gone: no terminal stream (a tui: client id's) open for
1612
+ # SCRATCH_GONE_AFTER, after a stream was (a killed TUI's closes as its
1613
+ # writer reads the hang-up), or none came within SCRATCH_JOIN_GRACE.
1614
+ # Any other stream (a web tab's, which chi web refuses for a scratch
1615
+ # session anyway) never keeps it. Its terminal deletes it on every way
1616
+ # out it sees; this is for the ones it doesn't (kill -9, a closed
1617
+ # terminal window).
1615
1618
  def scratch_unattended?
1616
1619
  return false unless @session&.scratch && @bridge
1617
1620
 
1618
- if @bridge.open_streams.positive?
1621
+ if @bridge.open_streams_of(ClientId::TUI_PREFIX).positive?
1619
1622
  @unattended_since = nil
1620
1623
  return false
1621
1624
  end
data/lib/samagotchi.rb CHANGED
@@ -7,7 +7,6 @@ require_relative "samagotchi/prompt"
7
7
  require_relative "samagotchi/client"
8
8
  require_relative "samagotchi/debug_log"
9
9
  require_relative "samagotchi/delivery"
10
- require_relative "samagotchi/pending_input_queue"
11
10
  require_relative "samagotchi/kernel_loop"
12
11
  require_relative "samagotchi/session"
13
12
  require_relative "samagotchi/terminal_ui"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.154.0
4
+ version: 0.154.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -563,7 +563,6 @@ files:
563
563
  - lib/samagotchi/participants_watch.rb
564
564
  - lib/samagotchi/paths.rb
565
565
  - lib/samagotchi/peer_sources.rb
566
- - lib/samagotchi/pending_input_queue.rb
567
566
  - lib/samagotchi/pending_question.rb
568
567
  - lib/samagotchi/plugin/api.rb
569
568
  - lib/samagotchi/plugin/attached_context.rb
@@ -670,6 +669,7 @@ files:
670
669
  - lib/samagotchi/steer.rb
671
670
  - lib/samagotchi/steer_cut.rb
672
671
  - lib/samagotchi/steer_cut_after.rb
672
+ - lib/samagotchi/stream_event.rb
673
673
  - lib/samagotchi/system_one.rb
674
674
  - lib/samagotchi/system_one/answer.rb
675
675
  - lib/samagotchi/system_one/approval_label.rb
@@ -867,6 +867,7 @@ files:
867
867
  - lib/samagotchi/web/public/block_source.js
868
868
  - lib/samagotchi/web/public/bubbles.js
869
869
  - lib/samagotchi/web/public/card.js
870
+ - lib/samagotchi/web/public/card_stamps.js
870
871
  - lib/samagotchi/web/public/cards_place.js
871
872
  - lib/samagotchi/web/public/chains.js
872
873
  - lib/samagotchi/web/public/chip_popover.js
@@ -1,55 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Samagotchi
4
- # Thread-safe FIFO of user steering messages submitted while a turn is
5
- # running. UIs (TUI, web, background workers) are push-only producers;
6
- # KernelLoop drains the queue at iteration boundaries and injects the
7
- # messages into the conversation. True mid-stream injection is impossible
8
- # with llama.cpp's /completion API, so draining only happens between full
9
- # LLM responses / tool dispatches.
10
- #
11
- # Usage:
12
- # queue = PendingInputQueue.new
13
- # queue.push("please also check the specs")
14
- # kernel.run(messages, pending_input: queue.method(:drain))
15
- class PendingInputQueue
16
- def initialize
17
- @mutex = Mutex.new
18
- @messages = []
19
- end
20
-
21
- # Append a message to the queue. Nil/empty strings are ignored.
22
- # +seq+ is the sender's own order number for it (a sender that numbers
23
- # every line it takes during a turn, so its leftovers keep send order across
24
- # this queue and its after-turn list: #drain_entries).
25
- def push(text, seq: nil)
26
- text = text.to_s
27
- return if text.empty?
28
-
29
- @mutex.synchronize { @messages << [seq, text] }
30
- nil
31
- end
32
-
33
- # Remove and return all queued messages in FIFO order. Non-blocking.
34
- # @return [Array<String>]
35
- def drain = drain_entries.map(&:last)
36
-
37
- # #drain with each message's +seq+ (nil when pushed without one).
38
- # @return [Array(Integer|nil, String)]
39
- def drain_entries
40
- @mutex.synchronize do
41
- drained = @messages
42
- @messages = []
43
- drained
44
- end
45
- end
46
-
47
- def empty?
48
- @mutex.synchronize { @messages.empty? }
49
- end
50
-
51
- def size
52
- @mutex.synchronize { @messages.size }
53
- end
54
- end
55
- end