samagotchi 0.40.0 → 0.42.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -1
  3. data/README.md +1 -1
  4. data/bin/chi +19 -2
  5. data/docs/cli.md +32 -7
  6. data/docs/configuration.md +34 -3
  7. data/docs/guardrails.md +15 -3
  8. data/docs/internals/prompt-caching.md +2 -1
  9. data/docs/memory.md +108 -1
  10. data/docs/sessions.md +5 -5
  11. data/docs/testing.md +16 -0
  12. data/lib/samagotchi/bridge/card_store.rb +6 -7
  13. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +23 -0
  14. data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -5
  15. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  16. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  17. data/lib/samagotchi/bundles/system/memory_guide.md +13 -0
  18. data/lib/samagotchi/bundles/system/self_map.md +4 -2
  19. data/lib/samagotchi/child_reports.rb +8 -4
  20. data/lib/samagotchi/config.rb +3 -0
  21. data/lib/samagotchi/context_command.rb +5 -2
  22. data/lib/samagotchi/engine.rb +121 -16
  23. data/lib/samagotchi/events.rb +8 -1
  24. data/lib/samagotchi/guardrails/model_size.rb +3 -4
  25. data/lib/samagotchi/guardrails/rules.rb +27 -17
  26. data/lib/samagotchi/guardrails/targets.rb +40 -1
  27. data/lib/samagotchi/guardrails/verdict.rb +6 -3
  28. data/lib/samagotchi/llm/errors.rb +33 -1
  29. data/lib/samagotchi/llm_context_command.rb +151 -0
  30. data/lib/samagotchi/llm_context_override.rb +180 -0
  31. data/lib/samagotchi/llm_context_strategy.rb +63 -13
  32. data/lib/samagotchi/memory_bundle/index_size.rb +103 -0
  33. data/lib/samagotchi/memory_bundle/index_sync.rb +3 -2
  34. data/lib/samagotchi/model_match.rb +54 -0
  35. data/lib/samagotchi/model_notes.rb +205 -0
  36. data/lib/samagotchi/parent_report.rb +12 -2
  37. data/lib/samagotchi/plugin/context.rb +3 -2
  38. data/lib/samagotchi/plugin/sessions.rb +3 -1
  39. data/lib/samagotchi/prompt_note.rb +53 -0
  40. data/lib/samagotchi/reply_wait.rb +3 -17
  41. data/lib/samagotchi/self_report.rb +47 -0
  42. data/lib/samagotchi/send_command.rb +22 -2
  43. data/lib/samagotchi/session.rb +44 -4
  44. data/lib/samagotchi/session_commands.rb +45 -6
  45. data/lib/samagotchi/session_manager.rb +5 -2
  46. data/lib/samagotchi/session_metrics.rb +47 -7
  47. data/lib/samagotchi/system_prompt.rb +79 -11
  48. data/lib/samagotchi/terminal_ui/attach_launcher.rb +13 -5
  49. data/lib/samagotchi/terminal_ui/attached_loop.rb +32 -12
  50. data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -0
  51. data/lib/samagotchi/terminal_ui/formatting.rb +40 -0
  52. data/lib/samagotchi/terminal_ui.rb +16 -6
  53. data/lib/samagotchi/tool_runner.rb +26 -1
  54. data/lib/samagotchi/tools/memory.rb +70 -3
  55. data/lib/samagotchi/turn_flow.rb +44 -8
  56. data/lib/samagotchi/turn_note.rb +40 -1
  57. data/lib/samagotchi/version.rb +1 -1
  58. data/lib/samagotchi/web/app.rb +35 -6
  59. data/lib/samagotchi/web/public/app.js +47 -7
  60. data/lib/samagotchi/web/public/ctx.js +28 -5
  61. data/lib/samagotchi/web/public/format.js +29 -2
  62. data/lib/samagotchi/web/public/index.html +12 -0
  63. data/lib/samagotchi/web/public/live_turn.js +1 -0
  64. data/lib/samagotchi/web/public/llm_context_chip.js +194 -0
  65. data/lib/samagotchi/web/public/session_state.js +6 -0
  66. data/lib/samagotchi/web/public/timing.js +8 -2
  67. data/lib/samagotchi/web/public/turn_view.js +15 -2
  68. data/lib/samagotchi/web/session_summary.rb +4 -0
  69. data/lib/samagotchi/worker.rb +36 -18
  70. data/lib/samagotchi/worker_wakes.rb +2 -1
  71. metadata +8 -1
@@ -3,6 +3,7 @@
3
3
  require_relative "output_formatter"
4
4
  require_relative "turn_note"
5
5
  require_relative "context_note"
6
+ require_relative "llm/errors"
6
7
 
7
8
  module Samagotchi
8
9
  # The state around turns that the REPL and a session worker share, kept out
@@ -45,11 +46,18 @@ module Samagotchi
45
46
  # (context summarizes the interrupted turn for a "no, <reason>")
46
47
  attr_reader :offer
47
48
 
49
+ # How many times the conversation went back to a checkpoint (a failed
50
+ # or canceled turn's restore, !rollback): a worker compares it across a
51
+ # turn to tell whether the turn's messages (the delegate reports it
52
+ # merged) left the conversation.
53
+ attr_reader :restores
54
+
48
55
  def initialize(engine:)
49
56
  @engine = engine
50
57
  @checkpoint = nil
51
58
  @continue_checkpoint = nil
52
59
  @offer = nil
60
+ @restores = 0
53
61
  end
54
62
 
55
63
  def awaiting_continue? = !@offer.nil?
@@ -95,17 +103,44 @@ module Samagotchi
95
103
  end
96
104
  end
97
105
 
98
- # A prompt turn failed: back to the conversation before it, plus the
99
- # note that says so (TurnNote.failed) in place of an older one at the
100
- # tail, so the model reads why its last message went unanswered.
101
- def prompt_turn_failed(note: nil)
106
+ # A prompt turn (or a wake turn) failed. One that got somewhere (the
107
+ # Engine kept its tool results: LLM::FailedTurn.kept_steps, never for
108
+ # a context overflow) stays, as after a cancel: its tool calls ran, and
109
+ # the model must see what they did. The checkpoint stays for !rollback, and
110
+ # the prompt is answered by what the turn did, so it doesn't go back to
111
+ # its sender. One that got nowhere goes back to the conversation before
112
+ # it, and its prompt to its sender (the caller hands it back).
113
+ # Either way the note at the tail says why (TurnNote.failed), in place
114
+ # of an older one, so the model reads why its last message went
115
+ # unanswered.
116
+ # @param error [Exception, nil] what failed: its one line is the note's
117
+ # @param wake [String, nil] what woke the turn (TurnNote.failed's wake:)
118
+ # @param wake_kept [String, nil] what becomes of the wake's news when
119
+ # the turn is rolled back (TurnNote.failed's kept:)
120
+ # @param note [Boolean] false: no note (an image that never reached the
121
+ # model)
122
+ # @return [Symbol] :kept or :restored
123
+ def prompt_turn_failed(error: nil, wake: nil, wake_kept: nil, note: true)
124
+ summary = error && (error.respond_to?(:summary) ? error.summary : error.message)
125
+ steps = LLM::FailedTurn.kept_steps(error)
126
+ @offer = nil
127
+ if steps
128
+ # The Engine's note says so already; a wake turn's says what woke it.
129
+ if wake && note
130
+ @engine.rollback_to(TurnNote.replace_trailing(@engine.messages_checkpoint,
131
+ TurnNote.failed(summary, wake: wake, steps: steps)))
132
+ end
133
+ return :kept
134
+ end
135
+
136
+ failed = note && summary && TurnNote.failed(summary, restored: wake.nil?, wake: wake, kept: wake_kept)
102
137
  if @checkpoint
103
- restore(@checkpoint, note: note)
104
- elsif note
105
- @engine.rollback_to(TurnNote.replace_trailing(@engine.messages_checkpoint, note))
138
+ restore(@checkpoint, note: failed)
139
+ elsif failed
140
+ @engine.rollback_to(TurnNote.replace_trailing(@engine.messages_checkpoint, failed))
106
141
  end
107
- @offer = nil
108
142
  @checkpoint = nil
143
+ :restored
109
144
  end
110
145
 
111
146
  # Answer the offer with no (Stop): the interrupted turn stays, as
@@ -170,6 +205,7 @@ module Samagotchi
170
205
  restored = Array(checkpoint) + arrived
171
206
  restored = TurnNote.replace_trailing(restored, note) if note
172
207
  @engine.rollback_to(restored)
208
+ @restores += 1
173
209
  end
174
210
 
175
211
  def conversation_of(result)
@@ -17,6 +17,10 @@ module Samagotchi
17
17
  OPEN = "[SYSTEM: "
18
18
  CLOSE = "]"
19
19
  FAILED = "the previous turn failed before any answer: "
20
+ # A failed turn whose work stayed (#failed_after): "failed after N tool
21
+ # steps: <why>. Its work so far … stays; …".
22
+ FAILED_AFTER = "the previous turn failed after "
23
+ KEPT_WORK = ". Its work so far (tool calls, file changes) stays; "
20
24
  RESTORED = "The message went back to the user, who may send it again."
21
25
  TASKS_LISTED = 5
22
26
  TASK_COMMAND_CHARS = 60
@@ -41,7 +45,12 @@ module Samagotchi
41
45
  # wrote nothing, and wakes pause until they do
42
46
  # @param kept [String, nil] what becomes of the wake's news, after
43
47
  # that (a delegate report's rings stay for the user's next turn)
44
- def failed(summary, restored: false, continued: false, wake: nil, kept: nil)
48
+ # @param steps [Integer, nil] the turn's work stays in the conversation
49
+ # (LLM::FailedTurn.progress): the tool results it got to. Not with
50
+ # +restored+, which took the turn out.
51
+ def failed(summary, restored: false, continued: false, wake: nil, kept: nil, steps: nil)
52
+ return failed_after(summary, steps, continued: continued, wake: wake) if steps && !restored
53
+
45
54
  tail = if restored then RESTORED
46
55
  elsif wake
47
56
  "The wake turn for #{one_line(wake)} was not answered; chi starts no other wake turn until the user writes." +
@@ -52,6 +61,36 @@ module Samagotchi
52
61
  message("#{FAILED}#{one_line(summary)}. #{tail}")
53
62
  end
54
63
 
64
+ # A turn that failed partway, its work kept: "the previous turn failed
65
+ # after 3 tool steps: <why>. Its work so far (tool calls, file changes)
66
+ # stays; …".
67
+ def failed_after(summary, steps, continued: false, wake: nil)
68
+ count = "#{steps} tool step#{"s" if steps != 1}"
69
+ tail = if wake
70
+ "the wake turn for #{one_line(wake)} stopped there; chi starts no other wake turn until the user writes."
71
+ elsif continued then "the continued turn stopped there."
72
+ else
73
+ "the user's last message is not answered yet."
74
+ end
75
+ message("#{FAILED_AFTER}#{count}: #{one_line(summary)}#{KEPT_WORK}#{tail}")
76
+ end
77
+
78
+ # The failure summary of the failed-turn note at the tail of +messages+
79
+ # (behind context notes at most), either kind (rolled back, or its work
80
+ # kept), or nil without one.
81
+ def failure_summary(messages)
82
+ list = Array(messages)
83
+ index = trailing_index(list)
84
+ return nil unless index
85
+
86
+ text = (list[index][:content] || list[index]["content"]).to_s
87
+ if text.start_with?("#{OPEN}#{FAILED}")
88
+ text["#{OPEN}#{FAILED}".length..].sub(/\. [^.]*\.\]\z/, "")
89
+ elsif text.start_with?("#{OPEN}#{FAILED_AFTER}") && text.include?(KEPT_WORK)
90
+ text[0...text.rindex(KEPT_WORK)].sub(/\A#{Regexp.escape(OPEN + FAILED_AFTER)}\d+ tool steps?: /, "")
91
+ end
92
+ end
93
+
55
94
  # @param reason [Symbol, String, nil] the cancel reason (:ctrl_c, :user…)
56
95
  # @param seconds [Numeric, nil] how long the turn had run
57
96
  # @param shown [Boolean] visible text had streamed (an `[interrupted]`
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.40.0"
4
+ VERSION = "0.42.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -474,6 +474,20 @@ module Samagotchi
474
474
  nil
475
475
  end
476
476
 
477
+ # A session's LLM context strategy, apply rule and budget, each with
478
+ # where it came from (LLMContextStrategy::Explained#summary), as its
479
+ # worker would resolve them; nil when they can't be.
480
+ def llm_context_for(session)
481
+ registry = host_registry
482
+ name = session.model_name.to_s.strip.empty? ? ModelProfile.required_model_name(nil) : session.model_name
483
+ target = registry.resolve(name)
484
+ names = registry.lookup_names(session.model_typed || name, resolved: name, target: target)
485
+ own = session.llm_context
486
+ LLMContextStrategy.explain(target, names: names, **(own&.resolve_args || {})).summary(own)
487
+ rescue StandardError
488
+ nil
489
+ end
490
+
477
491
  # Built again when config.yml's hosts: changed (compared as read: the
478
492
  # YAML read is mtime-cached), so the model picker lists a host added
479
493
  # while chi web runs. An injected registry (specs) stays.
@@ -612,7 +626,12 @@ module Samagotchi
612
626
  # What the worker's server said it served for that model (after a turn).
613
627
  if snapshot
614
628
  session_json = session_json.merge(served_model: snapshot["served_model"], served_model_for: snapshot["served_model_for"])
629
+ # The model notes its prompt carries now (a /model there changes them).
630
+ session_json = session_json.merge(prompt_notes: snapshot["prompt_notes"]) if snapshot["prompt_notes"].is_a?(Array)
615
631
  end
632
+ # The LLM context strategy the next turn runs under (the info bar's
633
+ # chip): the worker's, else worked out here from the session file.
634
+ session_json = session_json.merge(llm_context: (snapshot && snapshot["llm_context"]) || llm_context_for(session))
616
635
  raw_messages = turn_snapshot ? turn_snapshot["messages"] : session.messages
617
636
  timing = timing_payload(id, live_metrics: snapshot && snapshot["metrics"])
618
637
  json_response(200, {
@@ -663,8 +682,9 @@ module Samagotchi
663
682
  # history again (nor a render of every earlier answer). Live: the
664
683
  # worker's GET tail alone (no session file read); else the file. The
665
684
  # session part is what the page reads of it (id, status, used memory
666
- # names). ?turn_id=: that turn's answer (a queued turn's page re-reads
667
- # after the next one started). ?recent=1 (the page since this
685
+ # names, the model notes its prompt carried: the first turn's build or
686
+ # a /model records them). ?turn_id=: that turn's answer (a queued
687
+ # turn's page re-reads after the next one started). ?recent=1 (the page since this
668
688
  # release): the timing's records are the newest turn's (#recent_timing),
669
689
  # which the page merges by id; without it, the whole timing as before.
670
690
  def handle_tail_read(req, id)
@@ -675,12 +695,14 @@ module Samagotchi
675
695
  tail = live_tail(id, turn_id: turn_id)
676
696
  state = tail && tail["session_state_snapshot"]
677
697
  if state.is_a?(Hash)
678
- session_json = { id: id, status: state["status"], used_memory_names: Array(state["used_memory_names"]) }
698
+ session_json = { id: id, status: state["status"], used_memory_names: Array(state["used_memory_names"]),
699
+ prompt_notes: Array(state["prompt_notes"]) }
679
700
  messages = last_assistant_for_display([tail["answer"]].compact)
680
701
  else
681
702
  session = @session_class.load(id, state_dir: default_state_dir)
682
703
  state = nil
683
- session_json = { id: id, status: displayed_status(session), used_memory_names: Array(session.used_memory_names) }
704
+ session_json = { id: id, status: displayed_status(session), used_memory_names: Array(session.used_memory_names),
705
+ prompt_notes: session.prompt_notes.map(&:to_h) }
684
706
  messages = last_assistant_for_display(session.messages, turn_id: turn_id)
685
707
  end
686
708
  live_metrics = state && state["metrics"]
@@ -822,7 +844,12 @@ module Samagotchi
822
844
  when 202
823
845
  record_history(body, line) if PromptHistory.shell_line?(line)
824
846
  json_response(202, reply.json || { status: "accepted" })
825
- when 400 then error_response(400, reply.json&.dig("error") || "unknown_command", reply_detail(reply, "not a session command"))
847
+ when 400
848
+ # One of this chi's own commands the worker doesn't know: it runs an older chi.
849
+ name = reply.json&.dig("error") == "unknown_command" && SessionCommands.builtin_name(line)
850
+ next error_response(501, "not_supported", BridgeClient.stale_worker_message(id, cant: "run #{name}")) if name
851
+
852
+ error_response(400, reply.json&.dig("error") || "unknown_command", reply_detail(reply, "not a session command"))
826
853
  end
827
854
  end
828
855
  rescue SessionManager::OwnedByTUI => e
@@ -1514,7 +1541,9 @@ module Samagotchi
1514
1541
  # The context last seen and the token sums, for the ctx meter
1515
1542
  # before the next turn streams (live over saved).
1516
1543
  context: source["context"],
1517
- tokens: source["tokens"]
1544
+ tokens: source["tokens"],
1545
+ # The memory indexes the session's prompt holds (the ctx tooltip).
1546
+ memory_index: source["memory_index"]
1518
1547
  }
1519
1548
  end
1520
1549
 
@@ -21,13 +21,14 @@ import {
21
21
  addContext,
22
22
  } from "./data.js";
23
23
  import { createContextChips } from "./context_chips.js";
24
+ import { chipHtml as llmContextChipHtml, createLlmContextControl } from "./llm_context_chip.js";
24
25
  import { renderStable } from "./stable_html.js";
25
26
  import { createShownTexts } from "./shown_texts.js";
26
27
  import { MODEL_KEY } from "./model_pick.js";
27
28
  import { emptySessionState } from "./session_state.js";
28
29
  import { createModelPicker } from "./model_picker.js";
29
30
  import { chipName, chipsHtml, imageFiles, restoredChips, thumbsHtml, turnRefs, turnText } from "./images.js";
30
- import { escapeHtml, memChipText, memChipTitle, noteHtml, normalize, steerRowHtml, ownerBadge, previewForCard, relativeTime, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy, childrenSummary } from "./format.js";
31
+ import { escapeHtml, memChipText, memChipTitle, noteHtml, normalize, steerRowHtml, ownerBadge, previewForCard, relativeTime, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy, childrenSummary, promptNotesChip } from "./format.js";
31
32
  import { createTurnView, turnHistoryHtml } from "./turn_view.js";
32
33
  import { BADGES, emptyAnswerHtml, htmlElement, stateBadgeHtml, userBubbleHtml } from "./turn_html.js";
33
34
  import { createStageView } from "./stage_view.js";
@@ -75,6 +76,16 @@ const contextChips = createContextChips({
75
76
  escapeHtml,
76
77
  toast: (text) => showToast(text),
77
78
  });
79
+ // The open session's LLM context chip in the info bar, and its popover:
80
+ // /llm-context runs in the session's worker.
81
+ const llmContextControl = createLlmContextControl({
82
+ el: infoTextEl,
83
+ escapeHtml,
84
+ summary: () => (selected ? current.llmContext : null),
85
+ session: () => selected,
86
+ running: () => liveTurn.running,
87
+ run: (line) => runSessionCommand(line),
88
+ });
78
89
  const markdownWarningEl = $("#markdownWarning");
79
90
  // What the page renders that a notice may stand in for (the server's
80
91
  // Web::Capabilities, next to markdown_warning); a notice it names isn't
@@ -353,7 +364,7 @@ function cardHtml(s, { deletable = false, archivable = false, selectable = false
353
364
  const folderHtml = folder ? `<span class="folder" title="${escapeHtml(s.working_directory)}">${escapeHtml(folder)}</span>` : "";
354
365
  const archivedHtml = s.archived ? `<span class="archived-badge" title="Archived: hidden from the lists, kept for good">archived</span>` : "";
355
366
  const ctx = cardCtxText(s.ctx_pct);
356
- const ctxHtml = ctx ? `<span class="ctx" title="${escapeHtml(tokensTipText(s.tokens))}">${escapeHtml(ctx)}</span>` : "";
367
+ const ctxHtml = ctx ? `<span class="ctx" title="${escapeHtml(tokensTipText(s.tokens, undefined, s.memory_index))}">${escapeHtml(ctx)}</span>` : "";
357
368
  const waiting = waitingBadge(s);
358
369
  const waitingHtml = waiting ? `<span class="attn attn-${waiting.kind}" title="${escapeHtml(waiting.title)}">${escapeHtml(waiting.text)}</span>` : "";
359
370
  // A turn a hook stopped wears its own badge, unless the session waits on
@@ -1115,6 +1126,9 @@ function renderSessionView(data) {
1115
1126
  current.usedMemories = s.used_memory_names || [];
1116
1127
  current.preloadedMemories = s.preloaded_memory_names || [];
1117
1128
  current.mutedMemories = s.muted_memory_names || [];
1129
+ current.promptNotes = Array.isArray(s.prompt_notes) ? s.prompt_notes : [];
1130
+ current.llmContext = s.llm_context || null;
1131
+ llmContextControl.refresh();
1118
1132
  current.parentId = s.parent_id || null;
1119
1133
  current.commands = Array.isArray(data.commands) ? data.commands : [];
1120
1134
  if (data.messages && data.messages.length) {
@@ -1219,6 +1233,8 @@ async function readTurnEnd() {
1219
1233
  if (id !== selected) return;
1220
1234
  if (d.session) {
1221
1235
  if (Array.isArray(d.session.used_memory_names)) current.usedMemories = d.session.used_memory_names;
1236
+ // The notes chip: the first turn's build (or a /model) records them.
1237
+ if (Array.isArray(d.session.prompt_notes)) current.promptNotes = d.session.prompt_notes;
1222
1238
  if (d.session.status) current.status = d.session.status;
1223
1239
  if (!(await mergeTurnTiming(id, d.timing))) return;
1224
1240
  liveTurn.timing.finishLine(line);
@@ -1752,7 +1768,7 @@ function updateInfoBar() {
1752
1768
  const memText = current.usedMemories.length ? current.usedMemories.join(", ") : "—";
1753
1769
  const ctxText = current.ctxPct !== null ? `ctx ${Math.round(current.ctxPct)}%` : "";
1754
1770
  const tokens = current.timing.tokens;
1755
- const ctxTip = tokensTipText(tokens);
1771
+ const ctxTip = tokensTipText(tokens, undefined, current.timing.memoryIndex);
1756
1772
  const speedTextNow = lastSpeedText(tokens);
1757
1773
  const avgSpeed = speedText(tokens?.avg_decode_tps, tokens?.tps_source);
1758
1774
  const speedTip = `Decode speed of the last generation${avgSpeed ? `; session average ${avgSpeed}` : ""}`;
@@ -1765,11 +1781,14 @@ function updateInfoBar() {
1765
1781
  const parentHtml = parentChip ? `<a class="model parent" href="${escapeHtml(sessionHash(current.parentId))}" title="${escapeHtml(parentChip.title)}">delegated by ${escapeHtml(String(current.parentId).slice(0, 8))}</a>` : "";
1766
1782
  // A parent names its delegates; the link opens all sessions.
1767
1783
  const kids = childrenSummary({ id: selected }, allSessions);
1784
+ // The model notes the session's prompt carried.
1785
+ const notes = promptNotesChip(current.promptNotes);
1786
+ const notesHtml = notes ? `<span class="model notes-chip" title="${escapeHtml(notes.title)}">${escapeHtml(notes.text)}</span>` : "";
1768
1787
  const kidsHtml = kids ? `<a class="model children-chip${kids.waitingText ? " waiting" : ""}" href="${escapeHtml(ALL_SESSIONS_HASH)}" title="${escapeHtml(`${kids.title} (all sessions)`)}">${escapeHtml(kids.text)}${kids.waitingText ? ` · <span class="kids-waiting">${escapeHtml(kids.waitingText)}</span>` : ""}</a>` : "";
1769
1788
  const attachCmd = `chi --attach ${selected}`;
1770
1789
  const copied = Date.now() - attachCopiedAt < 2000;
1771
1790
  const copyHtml = `<button type="button" class="copy-attach" title="Copy &quot;${escapeHtml(attachCmd)}&quot; to attach a terminal">${copied ? "copied ✓" : "copy chi --attach"}</button>`;
1772
- const metaHtml = `<div class="meta"><span class="id" title="${escapeHtml(selected)}">${escapeHtml(idShort)}</span>${copyHtml}<span class="${statusClass}">${escapeHtml(current.status||"")}</span><span class="model${label.mismatch ? " served-mismatch" : ""}" title="${escapeHtml(label.title)}">${escapeHtml(label.text)}</span>${parentHtml}${kidsHtml}${current.usedMemories.length ? `<span class="model mem-chip" title="${escapeHtml(memChipTitle(current.usedMemories))}">${escapeHtml(memChipText(current.usedMemories))}</span>` : ""}<span class="dir" title="${escapeHtml(current.dir||"")}"><bdi>${escapeHtml(current.dir||"")}</bdi></span>${ctxText ? `<span class="model ctx" title="${escapeHtml(ctxTip)}">${escapeHtml(ctxText)}</span>` : ""}${speedTextNow ? `<span class="model speed" title="${escapeHtml(speedTip)}">${escapeHtml(speedTextNow)}</span>` : ""}${timingText ? `<span class="model session-time"></span>` : ""}${workerBadgeHtml()}</div>`;
1791
+ const metaHtml = `<div class="meta"><span class="id" title="${escapeHtml(selected)}">${escapeHtml(idShort)}</span>${copyHtml}<span class="${statusClass}">${escapeHtml(current.status||"")}</span><span class="model${label.mismatch ? " served-mismatch" : ""}" title="${escapeHtml(label.title)}">${escapeHtml(label.text)}</span>${parentHtml}${kidsHtml}${llmContextChipHtml(current.llmContext, escapeHtml)}${notesHtml}${current.usedMemories.length ? `<span class="model mem-chip" title="${escapeHtml(memChipTitle(current.usedMemories))}">${escapeHtml(memChipText(current.usedMemories))}</span>` : ""}<span class="dir" title="${escapeHtml(current.dir||"")}"><bdi>${escapeHtml(current.dir||"")}</bdi></span>${ctxText ? `<span class="model ctx" title="${escapeHtml(ctxTip)}">${escapeHtml(ctxText)}</span>` : ""}${speedTextNow ? `<span class="model speed" title="${escapeHtml(speedTip)}">${escapeHtml(speedTextNow)}</span>` : ""}${timingText ? `<span class="model session-time"></span>` : ""}${workerBadgeHtml()}</div>`;
1773
1792
  const previewHtml = `<div class="preview-line"><span class="preview">${escapeHtml(current.firstPreview || "—")}</span> · <span class="mem" title="${escapeHtml(current.usedMemories.join(", "))}">mem: ${escapeHtml(memText)}</span></div>`;
1774
1793
  // The session clock ticks every second while a turn runs: only its text
1775
1794
  // changes, the bar's other nodes (a tooltip, a button mid-press) stay.
@@ -2006,10 +2025,13 @@ const streamHandlers = {
2006
2025
  },
2007
2026
  // The turn raised (e.g. the model server stayed unreachable). The worker
2008
2027
  // stays up: it rolls the turn back and hands the prompt back
2009
- // (prompt_restored), and the prompts queued behind it run as usual.
2028
+ // (prompt_restored), and the prompts queued behind it run as usual. A turn
2029
+ // that got to tool steps stays as it is (kept_steps; no prompt_restored).
2010
2030
  turn_failed: (data) => {
2011
- liveTurn.failedText = failedTurnText(data);
2012
- endTurnView("failed", liveTurn.failedText);
2031
+ const text = failedTurnText(data);
2032
+ // Only a rolled-back turn's line waits for its prompt_restored.
2033
+ liveTurn.failedText = Number.isInteger(data.kept_steps) ? null : text;
2034
+ endTurnView("failed", text);
2013
2035
  setLiveStatus("idle");
2014
2036
  },
2015
2037
  // The rolled-back turn left the session: render it from the server again,
@@ -2047,6 +2069,12 @@ const streamHandlers = {
2047
2069
  current.served = null;
2048
2070
  updateInfoBar();
2049
2071
  }
2072
+ // /llm-context changed what the next turn runs under.
2073
+ if (data.llm_context) {
2074
+ current.llmContext = data.llm_context;
2075
+ updateInfoBar();
2076
+ llmContextControl.refresh();
2077
+ }
2050
2078
  if (view.resync) await resync();
2051
2079
  // A card's action that isn't anytime (the step-limit question's
2052
2080
  // /continue) has no bubble: nothing to remove.
@@ -2320,6 +2348,18 @@ async function handleSendCommand(line) {
2320
2348
  }
2321
2349
  }
2322
2350
 
2351
+ // A session command from a control (the llm ctx chip), not the composer:
2352
+ // its command_ran shows its line and reply, as a typed one's would.
2353
+ async function runSessionCommand(line) {
2354
+ if (!selected) return;
2355
+ try {
2356
+ await sendCommand(selected, line, { clientId });
2357
+ followWokenWorker();
2358
+ } catch (e) {
2359
+ addCommandBubble({ label: null, line, text: e.message, failed: true });
2360
+ }
2361
+ }
2362
+
2323
2363
  function addCommandBubble(view) {
2324
2364
  removeHintIfEmpty();
2325
2365
  const div = document.createElement("div");
@@ -71,14 +71,37 @@ function count(n) {
71
71
  return Math.round(n).toLocaleString("en-US");
72
72
  }
73
73
 
74
+ // A token count as the size readouts word it: "450", "2.0k". As
75
+ // MemoryBundle::IndexSize.count_text.
76
+ function countK(n) {
77
+ return n >= 1000 ? `${(Math.round(n / 100) / 10).toFixed(1)}k` : String(Math.round(n));
78
+ }
79
+
80
+ // "~3.4k tokens in this session's prompt (system 2.0k, project 1.4k)": the
81
+ // memory indexes the session's prompt holds (the snapshot's memory_index,
82
+ // measured when the prompt was built); "" without one. As
83
+ // Formatting#memory_index_text (spec/shared/labels_matrix.json).
84
+ export function memoryIndexText(memoryIndex) {
85
+ if (!memoryIndex || typeof memoryIndex !== "object") return "";
86
+ const scopes = ["system", "project"]
87
+ .map((scope) => [scope, num(memoryIndex[scope]?.tokens)])
88
+ .filter(([, tokens]) => tokens !== null);
89
+ if (!scopes.length) return "";
90
+ const total = scopes.reduce((sum, [, tokens]) => sum + tokens, 0);
91
+ return `~${countK(total)} tokens in this session's prompt (${scopes.map(([scope, tokens]) => `${scope} ${countK(tokens)}`).join(", ")})`;
92
+ }
93
+
74
94
  // The ctx tooltip (info bar and card): +title+, then the session's tokens
75
95
  // (in summed over every request, cached with its share, out with the
76
- // reasoning part) and its cost, when it has any. A delegated session keeps
77
- // its own metrics, so these are this session's only.
78
- export function tokensTipText(tokens, title = "Context used after the last turn") {
96
+ // reasoning part) and its cost, when it has any, and the memory indexes
97
+ // its prompt holds (+memoryIndex+, the snapshot's memory_index). A
98
+ // delegated session keeps its own metrics, so these are this session's only.
99
+ export function tokensTipText(tokens, title = "Context used after the last turn", memoryIndex = null) {
100
+ const memory = memoryIndexText(memoryIndex);
101
+ const memoryLine = memory ? `\nmemory index: ${memory}` : "";
79
102
  const prompt = num(tokens?.prompt_sum) || 0;
80
103
  const completion = num(tokens?.completion_sum) || 0;
81
- if (!prompt && !completion) return title;
104
+ if (!prompt && !completion) return `${title}${memoryLine}`;
82
105
  const cached = num(tokens.cached_sum) || 0;
83
106
  const reasoning = num(tokens.reasoning_sum) || 0;
84
107
  const parts = [`in ${count(prompt)}`];
@@ -88,7 +111,7 @@ export function tokensTipText(tokens, title = "Context used after the last turn"
88
111
  const cost = costText(tokens.cost_sum);
89
112
  if (cost) lines.push(`cost: ${cost}`);
90
113
  lines.push("this session only, all requests");
91
- return lines.join("\n");
114
+ return `${lines.join("\n")}${memoryLine}`;
92
115
  }
93
116
 
94
117
  // The info bar's speed: the newest generation's decode speed, "" before one.
@@ -234,11 +234,14 @@ export function recapPlace(userBubbles, turnsSince) {
234
234
  }
235
235
 
236
236
  // The line a failed turn's bubble shows: a provider error's one-line
237
- // summary, else the error's message and class.
237
+ // summary, else the error's message and class. A turn whose work stayed
238
+ // (turn_failed's kept_steps; no prompt_restored follows) says so, as the
239
+ // terminal's rollback hint does.
238
240
  export function failedTurnText(data) {
239
241
  data = data || {};
240
242
  const detail = data.summary || `${data.message || "error"}${data.error_class ? ` (${data.error_class})` : ""}`;
241
- return `\u2715 turn failed: ${detail}`;
243
+ const kept = Number.isInteger(data.kept_steps) ? "; partial progress kept (!rollback restores the pre-turn state)" : "";
244
+ return `\u2715 turn failed: ${detail}${kept}`;
242
245
  }
243
246
 
244
247
  // Whether the server served another model than asked: names equal in any
@@ -298,3 +301,27 @@ export function memChipTitle(names) {
298
301
  const list = names || [];
299
302
  return list.length ? `memories: ${list.join(", ")}` : "no memories in this session";
300
303
  }
304
+
305
+ // "model_notes_deepseek (system, 612 chars), model_notes_x (project, 80
306
+ // chars)": the model notes the session's prompt carried (its prompt_notes);
307
+ // "" without any. As PromptNote.text (spec/shared/labels_matrix.json).
308
+ export function promptNotesText(notes) {
309
+ return (Array.isArray(notes) ? notes : [])
310
+ .filter((note) => note && typeof note.name === "string" && note.name.trim())
311
+ .map((note) => {
312
+ const details = [note.scope, Number.isFinite(note.chars) ? `${Math.trunc(note.chars)} chars` : null].filter(Boolean);
313
+ return details.length ? `${note.name.trim()} (${details.join(", ")})` : note.name.trim();
314
+ })
315
+ .join(", ");
316
+ }
317
+
318
+ // The info bar's model notes chip, for a session whose prompt carried
319
+ // some: "notes: deepseek" (the names without model_notes_), the full line
320
+ // its tooltip; null without notes.
321
+ export function promptNotesChip(notes) {
322
+ const text = promptNotesText(notes);
323
+ if (!text) return null;
324
+ const names = notes.filter((note) => note && typeof note.name === "string" && note.name.trim())
325
+ .map((note) => note.name.trim().replace(/^model_notes_/, ""));
326
+ return { text: `notes: ${names.join(", ")}`, title: `model notes in this session's prompt: ${text}` };
327
+ }
@@ -684,6 +684,18 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
684
684
  #infoBar #infoArchiveBtn:hover{color:var(--text);background:var(--bg-hover)}
685
685
  #infoBar #infoDeleteBtn{background:transparent;color:var(--text-muted);border:1px solid transparent}
686
686
  #infoBar #infoDeleteBtn:hover{color:var(--on-solid);background:var(--danger-bg);border-color:var(--danger-bg)}
687
+ /* The LLM context chip (llm_context_chip.js): a model-like chip that opens the strategy popover; accent when the session set its own. */
688
+ #infoBar .meta .llmctx-chip{padding:2px 6px;border-radius:6px;font-size:11px;font-weight:400;border:1px solid transparent;cursor:pointer;font-family:inherit;white-space:nowrap}
689
+ #infoBar .meta .llmctx-chip:hover{color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}
690
+ #infoBar .meta .llmctx-chip.own{color:var(--accent);border-color:var(--accent-border)}
691
+ .llmctx-form{display:flex;flex-direction:column;gap:8px;margin:0}
692
+ .llmctx-form .ctx-pop-head strong{font-family:inherit}
693
+ .llmctx-field{display:flex;align-items:center;gap:8px}
694
+ .llmctx-field>span{flex:0 0 58px;font-size:12px;color:var(--text-muted)}
695
+ .llmctx-field select,.llmctx-field input{flex:1;min-width:0;box-sizing:border-box;padding:5px 8px;border-radius:7px;border:1px solid var(--border-strong);background:var(--bg-inset);color:var(--text);font:13px/1.4 inherit}
696
+ .llmctx-field select:focus,.llmctx-field input:focus{outline:none;border-color:var(--accent-border)}
697
+ .llmctx-form .llmctx-set{background:var(--accent-bg);color:var(--accent);border-color:var(--accent-border)}
698
+ .llmctx-form .llmctx-set:disabled{opacity:.6;cursor:default}
687
699
  /* Attached context (context_chips.js): a row of its own under the meta; not the composer's #chips/.chip. */
688
700
  #contextChips{order:5;flex-basis:100%;display:flex;flex-wrap:wrap;gap:6px;align-items:center;min-width:0}
689
701
  #contextChips[hidden]{display:none}
@@ -38,6 +38,7 @@ export function createLiveTurn(timingDeps) {
38
38
  this.timing.drop();
39
39
  this.unmatchedMerge = false;
40
40
  this.wakeStart = false;
41
+ this.failedText = null;
41
42
  this.releaseDisplay();
42
43
  },
43
44
  };